From 46e7070b6b0cb6c55312011a65209607516b621c Mon Sep 17 00:00:00 2001 From: Errilaz Date: Wed, 5 Aug 2026 09:24:13 +0200 Subject: [PATCH] feat: threads --- AGENTS.md | 119 +++++++++++--- app/level.ts | 20 ++- app/main.ts | 285 ++++++++++++++++++++++++++-------- app/render-worker.ts | 61 ++++++++ app/renderScene.ts | 102 ++++++++++++ app/renderer.ts | 196 +++++++++++++++++++++++ bun.lock | 9 +- engine/render/Framebuffer.ts | 6 +- engine/render/Rasterizer.ts | 12 +- engine/render/RenderConfig.ts | 10 +- engine/render/Sky.ts | 9 +- engine/scene/Boulder.ts | 7 +- engine/scene/Tree.ts | 35 +++-- index.html | 9 +- oxlint.config.ts | 2 + package.json | 10 +- scripts/bench-browser.ts | 93 +++++++++++ vite.config.ts | 18 ++- 18 files changed, 878 insertions(+), 125 deletions(-) create mode 100644 app/render-worker.ts create mode 100644 app/renderScene.ts create mode 100644 app/renderer.ts create mode 100644 scripts/bench-browser.ts diff --git a/AGENTS.md b/AGENTS.md index fbe377c..65fbdd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,10 @@ rules live in `.agents/rules/*.md`. ## Stack & tooling - **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict, - `moduleResolution: bundler`. **Vite 8** serves/builds the client. + `moduleResolution: bundler`. **Vite 8** serves/builds the client (ES-module + workers; dev/preview serve COOP/COEP headers so the multi-threaded renderer's + `SharedArrayBuffer` works — a static prod host must send them too, or the app + falls back to single-threaded). - **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier). - commitlint + husky (Conventional Commits), OpenSpec change workflow, forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source. @@ -42,6 +45,9 @@ rules live in `.agents/rules/*.md`. - `bun start` — Vite dev server; open the printed URL. Edits hot-reload. - `bun run build` — production build to `dist/`. - `bun run assets` — regenerate the placeholder PNGs in `/assets`. +- `bun run bench:browser` — Playwright: drive headless Chromium through the + `?bench=st`/`?bench=mt` flythrough, print single-thread vs worker frame timings + (median/p95/max). The real-browser profiler; run it on the target machine. - `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use this**, not `bun run check` (see Caveats). - `bunx oxlint engine app` — lint. @@ -73,7 +79,19 @@ rules live in `.agents/rules/*.md`. blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; samples a 2x2 color-atlas texture, drawn double-sided). - `app/` — browser glue. - - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. + - `main.ts` — game loop: input, sim, preset switching, per-frame culling, then + the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS + upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync + interval, visible chunks / LOD-aware tri count — the real profiler in play). + - `renderer.ts` — the render driver. When the page is cross-origin-isolated it + runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a + `SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a + lock-free `Atomics` barrier; otherwise it renders inline. `dispatch`/`done` + are non-blocking so the caller paces on rAF. `?bench=st|mt` A/Bs the paths. + - `renderScene.ts` — `renderBand(fb, scene, …, y0, y1)`: the single source of + render truth (sky + room + culled chunks + sprite + quantize for a row band). + Used full-height by the inline path, per-band by each worker. `Scene` bundles + the meshes/textures so it clones to a worker whole. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are already the `Color` layout). - `level.ts` — builds the playground: a flat stone-floored room (three thick @@ -84,7 +102,10 @@ rules live in `.agents/rules/*.md`. terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that `main` frustum-culls; bushes fold into the leaf mesh, flowers get their own - (drawn double-sided). `Aabb` colliders (walls, crate, grown trunks, big + (drawn double-sided). Trees + boulders are baked **twice** — full geometry and + a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders' + `lod` arg) — so a far chunk can swap to the cheap set with no per-frame work + (see `chunkFar` / `RenderConfig.lodDistance`). `Aabb` colliders (walls, crate, grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the terrain skirt that laps under the room edges. Room surfaces are single flat quads -- no subdivision needed since texturing is @@ -104,16 +125,36 @@ rules live in `.agents/rules/*.md`. filenames are the contract. - `server/` — Bun server stub. `shared/` — isomorphic slot. -## Frame pipeline (`app/main.ts` `frame`) +## Frame pipeline (`app/main.ts` `tick`) -`Player.update` → build `Camera` → `Camera.viewProjection` → -`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → -`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`, -then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock, -bark, leaf, needle (backface-culled) + flowers (double-sided) → -`Sprite.billboard(npc)` (double-sided) → -`Framebuffer.quantize` → `present` (integer-scale, letterboxed blit; -`imageSmoothingEnabled` follows `upscaleFilter`). +`Player.update` → build `Camera` → `Camera.viewProjection` → `visibleChunks` +(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) → +next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented +while N+1 is dispatched; the pump never blocks or async-awaits, so it can't +desync from rAF. + +**`present` is a GPU/CSS upscale, not a CPU blit.** The `#screen` canvas backing +store *is* the internal render resolution, so `present` is one internal-res +`putImageData` (viewport-independent, ~fixed cost). The browser compositor scales +the element to the display via CSS — `layout()` sets the element's pixel size to +an integer multiple (crisp letterbox, centered) once per resize/config, and +`image-rendering` follows `upscaleFilter` (`pixelated` for `nearest`, `auto` for +`linear`). This replaced a per-frame main-thread `drawImage` that scaled to the +whole window (cost grew with window size); present is now ~0.2ms. + +`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at +1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw` +floor/walls/crate (room, always) → for each visible `Chunk` draw grass, then — +per chunk via the pure `chunkFar` test (dist² from camera to the chunk AABB vs +`lodDistance²`) — either the full rock/bark/leaf/needle + flowers, or the cheap +`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped). +All backface-culled except double-sided flowers → `Sprite.billboard(npc)` +→ `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config +only), so every worker band picks the same LOD for a chunk → no horizontal seam. +Multi-threaded: N workers each run `renderBand` over +their band of the shared framebuffer in parallel; single-threaded: one call over +the full height. Bands are disjoint (no two workers touch a pixel) and their +interior edges snap to `SKY_STEP` so the sky's block grid stays seamless. Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**, perspective-correct UVs, screen-space vertex snap, flat directional lighting, @@ -130,8 +171,13 @@ front-out — a culled mesh that renders inside-out has its index order flipped `standard` (384×216, the startup default), `soft`, `clean`, plus an unbound `ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live. Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`, - `vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always - perspective-correct — the affine-swim dial was removed.) + `vertexSnap`, `textureFilter`, `lighting`, `fog`, `lodDistance`. (Texturing is + always perspective-correct — the affine-swim dial was removed.) +- **`RenderConfig.lodDistance`** — beyond this many world units, a chunk's trees + + boulders draw as cheap impostors (see Performance). ~60 for standard/soft/ps1 + (well inside `fog.far`, so far detail is already fog-dimmed at the switch), + `Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop), + raise it for more far detail (more tris). - **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world unit. Lower = the stone tiles bigger and less busy = less far-distance moire (there are no mipmaps); higher = finer but shimmerier. @@ -150,18 +196,45 @@ off-screen or fogged each frame, so several things keep it cheap: (terrain, foliage, rock). See the Rasterizer note re winding. - **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per pixel and dominated the frame; sampling once per 2×2 block quarters it. +- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) — + past `lodDistance` a chunk's trees + boulders swap to pre-baked low-poly + impostors and its bushes/flowers drop; both meshes are baked once at load, and + the near/far pick is a pure function of camera + chunk bounds, so it costs + nothing per frame and stays worker-safe (no seam). In a dense forest view this + is what tips the per-frame work under the 16.67ms (60fps) vsync budget — it cut + ~1.4x of the triangles in-forest and drops far-tree/rock detail that fog is + already dimming anyway. - **Flat geometry + zero-alloc raster** — `Mesh` is a flat float array and the whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes - (measured). This buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) - and makes geometry shareable for Web-Worker rasterization later. Note it did - **not** raise mean fps — allocation was never the bottleneck (JSC collects the - churn ~free); the mean is the transform+fill **compute**. + (measured). Buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) and + makes geometry shareable across worker threads. It did **not** raise mean fps — + allocation was never the bottleneck; the mean is the transform+fill **compute**. +- **Multi-threaded rasterization** (`renderer.ts` + `render-worker.ts`) — split the + framebuffer into row bands, one worker each, over a `SharedArrayBuffer`. The + barrier is **lock-free**: workers `Atomics.wait` on a frame counter (no per-frame + messages), main writes camera/matrix/visible-list into shared arrays, `dispatch` + is non-blocking, and `main` polls `done()` on its rAF and presents the finished + frame (present frame N, dispatch N+1). Measured real-browser (`bun run + bench:browser`): **~1.5x median AND ~1.2x p95** vs single-thread, frame time + pinned near vsync. The gotcha is **oversubscription** — too many workers (main + + browser + OS competing) wrecks the p95 tail even as the median improves (6 + workers were far worse than 3); `MAX_WORKERS` caps it, retune per machine. + Requires a cross-origin-isolated page (COOP/COEP; Vite serves them) — else it + falls back to single-thread, so the app never breaks. -Frustum + backface + half-res sky give ~1.5–2x, growing with content since culled -chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so -the mean-fps levers left are to **do less** (LOD / impostors for far trees — -`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores** -(Web-Worker banded rasterization, now unblocked by the flat geometry). +Frustum + backface + half-res sky give ~1.5–2x, workers ~1.5x more, distance LOD +another ~1.4x of the tris in dense views. Together they get the heavy in-forest +view (the worst case) under the 60fps vsync budget on the worker path; the +single-thread fallback still lands ~30fps there. The blunt content dials if it +still lags are `TREE_COUNT`/`BOULDER_COUNT` (less world) and `lodDistance` (more +aggressive impostor swap). + +**Profiling**: `bun run bench:browser` (Playwright) starts Vite, drives headless +Chromium through `?bench=st` and `?bench=mt` (a scripted flythrough with a fixed +camera path), and prints median/p95/max **work time** (critical-path band time) +and **frame time** for both. Headless absolute fps ≠ a real display, but the +single-vs-workers *relative* result and the *tail* (p95/max = jitter) are real — +that's how the worker path was actually validated instead of guessed. ## Clouds diff --git a/app/level.ts b/app/level.ts index 6471f3b..da651a4 100644 --- a/app/level.ts +++ b/app/level.ts @@ -39,6 +39,13 @@ export type Chunk = { rock: Mesh /** Flowers (color-atlas texture); drawn double-sided, so kept separate. */ flowers: Mesh + /** Cheap low-poly impostors of the same trees/boulders, drawn instead of the + * full meshes once the chunk is past `config.lodDistance` (renderScene). Same + * textures as bark/leaf/needle/rock. Bushes/flowers have no far version. */ + barkFar: Mesh + leafFar: Mesh + needleFar: Mesh + rockFar: Mesh } /** The playground: a flat-floored room dropped into the center of a big open @@ -215,17 +222,26 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers const needle = mesh() const rock = mesh() const flowerMesh = mesh() + const barkFar = mesh() + const leafFar = mesh() + const needleFar = mesh() + const rockFar = mesh() Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) for (const tree of trees) { if (inCell(tree.position, x0, z0, x1, z1)) { - Tree.build(tree, bark, tree.kind === "oak" ? leaf : needle) + const foliage = tree.kind === "oak" ? leaf : needle + const foliageFar = tree.kind === "oak" ? leafFar : needleFar + Tree.build(tree, bark, foliage) + Tree.build(tree, barkFar, foliageFar, "impostor") } } for (const boulder of boulders) { if (inCell(boulder.position, x0, z0, x1, z1)) { Boulder.build(boulder, rock) + Boulder.build(boulder, rockFar, "impostor") } } + // Bushes share the near leaf mesh; they just drop out past lodDistance. for (const bush of bushes) { if (inCell(bush.position, x0, z0, x1, z1)) { Bush.build(bush, leaf) @@ -240,7 +256,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers if (b === null) { continue } - chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh }) + chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar }) } } return chunks diff --git a/app/main.ts b/app/main.ts index 8a9e195..0597c89 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,59 +1,109 @@ -import { Framebuffer } from "../engine/render/Framebuffer" -import { Frustum } from "../engine/render/Frustum" -import { Rasterizer } from "../engine/render/Rasterizer" import { RenderConfig } from "../engine/render/RenderConfig" -import { Sky } from "../engine/render/Sky" import { Camera } from "../engine/scene/Camera" -import { Sprite } from "../engine/scene/Sprite" import { loadTextures } from "./assets" import { buildLevel } from "./level" import { EYE_HEIGHT, Player } from "./player" +import { createRenderer } from "./renderer" +import { chunkFar, visibleChunks, type Scene } from "./renderScene" const FOV = Math.PI / 3 -/** Sky is drawn at 1/SKY_STEP resolution (the cloud fbm is the costly part). */ -const SKY_STEP = 2 const screen = document.querySelector("#screen")! const ctx = screen.getContext("2d")! -const back = document.createElement("canvas") -const backCtx = back.getContext("2d")! const fpsEl = document.querySelector("#fps")! -let config: RenderConfig = RenderConfig.standard -let fb = Framebuffer.create(1, 1) -let image = new ImageData(1, 1) - -function useConfig(next: RenderConfig): void { - config = next - fb = Framebuffer.create(config.internalWidth, config.internalHeight) - back.width = fb.width - back.height = fb.height - image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height) +/** Deterministic flythrough (virtual time from frame index), so the st and mt + * bench runs render the exact same work. Deliberately stands *inside* the dense + * tree ring (radius ~40–100) and sweeps a full 360° yaw so the frame is filled + * with forest -- the heavy case that quantizes to 30fps, not the empty clearing. */ +function benchCamera(tv: number): Camera { + const drift = tv * 0.12 + const radius = 65 + 30 * Math.sin(tv * 0.25) + return { + position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius }, + yaw: tv * 0.7, + pitch: 0.05 * Math.sin(tv * 0.5), + fov: FOV, + } } -function resize(): void { - screen.width = globalThis.innerWidth - screen.height = globalThis.innerHeight +function round2(n: number): number { + return Math.round(n * 100) / 100 } -function present(): void { - backCtx.putImageData(image, 0, 0) - const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height))) - const w = fb.width * scale - const h = fb.height * scale - const x = (screen.width - w) >> 1 - const y = (screen.height - h) >> 1 - ctx.imageSmoothingEnabled = config.upscaleFilter === "linear" - ctx.clearRect(0, 0, screen.width, screen.height) - ctx.drawImage(back, x, y, w, h) +function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } { + const s = a.toSorted((x, y) => x - y) + const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))] + return { median: round2(q(0.5)), p95: round2(q(0.95)), max: round2(s[s.length - 1]), mean: round2(a.reduce((x, y) => x + y, 0) / a.length) } } async function main(): Promise { const textures = await loadTextures() const level = buildLevel() - const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc } - const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true } + const scene: Scene = { + chunks: level.chunks, + floor: level.floor, + walls: level.walls, + crate: level.crate, + npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } }, + sky: level.sky, + textures, + } + // `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings. + const benchMode = new URLSearchParams(globalThis.location.search).get("bench") + const forceWorkers = benchMode === "mt" ? true : benchMode === "st" ? false : undefined + let config: RenderConfig = RenderConfig.standard + const renderer = createRenderer(scene, config, forceWorkers) + let image = new ImageData(renderer.fb.width, renderer.fb.height) + let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer) + + // The canvas backing store IS the internal resolution; the browser/compositor + // scales the element up (see `layout`). So `present` is one internal-res + // putImageData with no per-frame window-sized blit on the main thread. + function retarget(): void { + const fb = renderer.fb + screen.width = fb.width + screen.height = fb.height + image = new ImageData(fb.width, fb.height) + colorBytes = new Uint8ClampedArray(fb.color.buffer) + layout() + } + + // Size + center the canvas to an integer multiple of the internal res (crisp + // letterboxed upscale, done by the GPU). Recomputed only on resize/config. + function layout(): void { + const fb = renderer.fb + const scale = Math.max(1, Math.floor(Math.min(globalThis.innerWidth / fb.width, globalThis.innerHeight / fb.height))) + const w = fb.width * scale + const h = fb.height * scale + screen.style.width = `${w}px` + screen.style.height = `${h}px` + screen.style.left = `${(globalThis.innerWidth - w) >> 1}px` + screen.style.top = `${(globalThis.innerHeight - h) >> 1}px` + screen.style.imageRendering = config.upscaleFilter === "linear" ? "auto" : "pixelated" + } + retarget() + + function useConfig(next: RenderConfig): void { + config = next + renderer.reconfigure(next) + retarget() + } + + function present(): void { + image.data.set(colorBytes) + ctx.putImageData(image, 0, 0) + } + + globalThis.addEventListener("resize", layout) + + if (benchMode) { + runBench(renderer, level.chunks, present, benchMode) + return + } + + const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true } const keys = new Set() globalThis.addEventListener("keydown", (e) => { keys.add(e.code) @@ -86,56 +136,161 @@ async function main(): Promise { player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025)) }) - useConfig(config) - globalThis.addEventListener("resize", resize) - resize() + // Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD. + function frameTris(visible: number[], cam: Camera): number { + let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length + for (const i of visible) { + const c = level.chunks[i] + t += c.grass.indices.length + c.flowers.indices.length + const far = chunkFar(c, cam.position, config.lodDistance) + t += far + ? c.barkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length + : c.bark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length + } + return (t / 3) | 0 + } + // Poll-based pump: present the finished frame, dispatch the next; if workers + // aren't done we skip this vsync (no async/rAF desync). The HUD reports the + // critical-path budget (work + present) so the real bottleneck is visible. + let inFlight = false let last = performance.now() let fpsLast = last let fpsFrames = 0 - function frame(now: number): void { + let workMax = 0 + let presentMax = 0 + let vsyncMax = 0 + let lastPresent = performance.now() + let lastVisible: number[] = [] + let lastCamera: Camera = { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0, fov: FOV } + + function show(): void { + const p0 = performance.now() + present() + const p1 = performance.now() + presentMax = Math.max(presentMax, p1 - p0) + vsyncMax = Math.max(vsyncMax, p1 - lastPresent) + lastPresent = p1 + workMax = Math.max(workMax, renderer.workMs()) + fpsFrames++ + } + + function tick(): void { + requestAnimationFrame(tick) + if (inFlight) { + if (!renderer.done()) { + return + } + show() + inFlight = false + } + const now = performance.now() const dt = Math.min(0.05, (now - last) / 1000) last = now - fpsFrames++ if (now - fpsLast >= 250) { - fpsEl.textContent = `${Math.round((fpsFrames * 1000) / (now - fpsLast))} fps` + const fps = Math.round((fpsFrames * 1000) / (now - fpsLast)) + const tag = renderer.parallel ? "" : " 1core" + fpsEl.textContent = + `${fps} fps${tag}\n` + + `work ${round2(workMax)} + present ${round2(presentMax)} = ${round2(workMax + presentMax)}ms\n` + + `vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastCamera)} tris` fpsLast = now fpsFrames = 0 + workMax = 0 + presentMax = 0 + vsyncMax = 0 } Player.update(player, keys, dt, level) - const camera: Camera = { position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z }, yaw: player.yaw, pitch: player.pitch, fov: FOV, } - const viewProj = Camera.viewProjection(camera, fb.width / fb.height) - - Sky.render(fb, camera, level.sky, now / 1000, SKY_STEP) - // Room is small and always near where you play; draw it unconditionally. - Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config) - Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) - Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config) - // Outdoor world: skip whole chunks that fall outside the view frustum. - const frustum = Frustum.fromViewProj(viewProj) - for (const c of level.chunks) { - if (!Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) { - continue - } - Rasterizer.draw(fb, c.grass, textures.grass, viewProj, config, true) - Rasterizer.draw(fb, c.rock, textures.rock, viewProj, config, true) - Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true) - Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true) - Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true) - Rasterizer.draw(fb, c.flowers, textures.flower, viewProj, config) + const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height) + const visible = visibleChunks(level.chunks, viewProj) + lastVisible = visible + lastCamera = camera + renderer.dispatch(camera, viewProj, visible, now / 1000) + inFlight = true + if (renderer.done()) { + show() + inFlight = false } - Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config) - Framebuffer.quantize(fb, config) - present() - requestAnimationFrame(frame) } - requestAnimationFrame(frame) + requestAnimationFrame(tick) +} + +/** Scripted flythrough that records critical-path work time and present-to-present + * interval, then reports the distributions (exposed on `window.__BENCH__`). */ +function runBench( + renderer: ReturnType, + chunks: Scene["chunks"], + present: () => void, + mode: string, +): void { + const WARM = 60 + const MEASURE = 300 + const work: number[] = [] + const frame: number[] = [] + let i = 0 + let inFlight = false + let prev = performance.now() + let finished = false + + function record(): boolean { + present() + const now = performance.now() + if (i >= WARM) { + work.push(renderer.workMs()) + frame.push(now - prev) + } + prev = now + inFlight = false + i++ + return i >= WARM + MEASURE + } + + function report(): void { + finished = true + const result = { + mode, + parallel: renderer.parallel, + cores: (globalThis.navigator as Navigator).hardwareConcurrency, + coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true, + res: `${renderer.fb.width}x${renderer.fb.height}`, + workMs: benchStats(work), + frameMs: benchStats(frame), + } + ;(globalThis as { __BENCH__?: unknown }).__BENCH__ = result + console.log(`BENCH ${JSON.stringify(result)}`) + fpsEl.textContent = `bench ${mode}: work ${result.workMs.median}ms (p95 ${result.workMs.p95})` + } + + function tick(): void { + if (finished) { + return + } + requestAnimationFrame(tick) + if (inFlight) { + if (!renderer.done()) { + return + } + if (record()) { + report() + return + } + } + const camera = benchCamera(i / 60) + const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height) + const visible = visibleChunks(chunks, viewProj) + renderer.dispatch(camera, viewProj, visible, i / 60) + inFlight = true + if (renderer.done() && record()) { + report() + } + } + requestAnimationFrame(tick) } main().catch((error) => { diff --git a/app/render-worker.ts b/app/render-worker.ts new file mode 100644 index 0000000..8e7b824 --- /dev/null +++ b/app/render-worker.ts @@ -0,0 +1,61 @@ +import type { Framebuffer } from "../engine/render/Framebuffer" +import type { RenderConfig } from "../engine/render/RenderConfig" +import { renderBand, type Scene } from "./renderScene" + +/** One-time setup: shared framebuffer + control/param buffers, the (cloned) + * scene, this worker's row band, and its index into the per-worker times array. */ +type Init = { + colorSAB: SharedArrayBuffer + depthSAB: SharedArrayBuffer + width: number + height: number + scene: Scene + band: [number, number] + config: RenderConfig + skyStep: number + ctrlSAB: SharedArrayBuffer + camSAB: SharedArrayBuffer + vpSAB: SharedArrayBuffer + visSAB: SharedArrayBuffer + timesSAB: SharedArrayBuffer + index: number +} + +const FRAME = 0 +const DONE = 1 +const VIS = 2 + +const ctx = globalThis as unknown as { + addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void +} + +ctx.addEventListener("message", (e) => { + const m = e.data + const fb: Framebuffer = { + width: m.width, + height: m.height, + color: new Uint32Array(m.colorSAB), + depth: new Float32Array(m.depthSAB), + } + const ctrl = new Int32Array(m.ctrlSAB) + const cam = new Float64Array(m.camSAB) + const vp = new Float32Array(m.vpSAB) + const vis = new Int32Array(m.visSAB) + const times = new Float64Array(m.timesSAB) + const { scene, band, config, skyStep, index } = m + + // Lock-free frame loop: block until main bumps the frame counter, render this + // band, record the band time, and signal done. No messages per frame. + let last = 0 + for (;;) { + Atomics.wait(ctrl, FRAME, last) + last = Atomics.load(ctrl, FRAME) + const t0 = performance.now() + const camera = { position: { x: cam[0], y: cam[1], z: cam[2] }, yaw: cam[3], pitch: cam[4], fov: cam[5] } + const count = Atomics.load(ctrl, VIS) + const visible = [...vis.subarray(0, count)] + renderBand(fb, scene, camera, vp, visible, config, skyStep, cam[6], band[0], band[1]) + times[index] = performance.now() - t0 + Atomics.add(ctrl, DONE, 1) + } +}) diff --git a/app/renderScene.ts b/app/renderScene.ts new file mode 100644 index 0000000..d4cebdc --- /dev/null +++ b/app/renderScene.ts @@ -0,0 +1,102 @@ +import { Framebuffer } from "../engine/render/Framebuffer" +import { Frustum } from "../engine/render/Frustum" +import { Rasterizer } from "../engine/render/Rasterizer" +import type { RenderConfig } from "../engine/render/RenderConfig" +import { Sky, type SkyConfig } from "../engine/render/Sky" +import type { Camera } from "../engine/scene/Camera" +import type { Mat4 } from "../engine/math/Mat4" +import type { Mesh } from "../engine/scene/Mesh" +import { Sprite } from "../engine/scene/Sprite" +import type { Vec2 } from "../engine/math/Vec2" +import type { Vec3 } from "../engine/math/Vec3" +import type { Textures } from "./assets" +import type { Chunk } from "./level" + +/** Everything needed to render the world: the room, the cullable chunks, the NPC + * billboard source, sky, and textures. Bundled so it can be handed to a worker + * whole (it is plain data + typed arrays, structured-clone friendly). */ +export type Scene = { + chunks: Chunk[] + floor: Mesh + walls: Mesh + crate: Mesh + npc: { position: Vec3; size: Vec2 } + sky: SkyConfig + textures: Textures +} + +/** Chunk indices whose bounding box is inside the view frustum. Computed once on + * the main thread and shared with every worker (so they don't each re-cull). */ +export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] { + const frustum = Frustum.fromViewProj(viewProj) + const out: number[] = [] + for (let i = 0; i < chunks.length; i++) { + const c = chunks[i] + if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) { + out.push(i) + } + } + return out +} + +/** + * Render rows [y0, y1) of one frame into `fb`. This is the single source of + * render truth: the single-thread path calls it with the full height, and each + * worker calls it with its own disjoint band -- same output either way, and no + * two bands touch the same pixel (so the shared framebuffer needs no locking). + */ +export function renderBand( + fb: Framebuffer, + scene: Scene, + camera: Camera, + viewProj: Mat4, + visible: number[], + config: RenderConfig, + skyStep: number, + time: number, + y0: number, + y1: number, +): void { + const tx = scene.textures + Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1) + // Room: small and always near, drawn unconditionally (double-sided). + Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1) + Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1) + Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1) + for (const i of visible) { + const c = scene.chunks[i] + Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1) + // Past lodDistance, swap full tree/boulder geometry for cheap impostors. + // `chunkFar` is pure (camera + chunk bounds + config), so every worker band + // makes the identical choice -- no full/impostor seam across bands. + if (chunkFar(c, camera.position, config.lodDistance)) { + Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1) + } else { + Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1) + } + } + const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc } + Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1) + Framebuffer.quantize(fb, config, y0, y1) +} + +/** Whether a chunk is far enough to draw its impostor meshes: squared distance + * from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on + * camera, the chunk's baked bounds, and the config constant, all of which every + * worker already holds, so the choice is identical across bands. */ +export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean { + if (!(lodDistance < Infinity)) { + return false + } + const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x)) + const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y)) + const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z)) + return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance +} diff --git a/app/renderer.ts b/app/renderer.ts new file mode 100644 index 0000000..1f4e9a7 --- /dev/null +++ b/app/renderer.ts @@ -0,0 +1,196 @@ +import { Framebuffer } from "../engine/render/Framebuffer" +import type { RenderConfig } from "../engine/render/RenderConfig" +import type { Mat4 } from "../engine/math/Mat4" +import type { Camera } from "../engine/scene/Camera" +import { renderBand, type Scene } from "./renderScene" + +/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */ +const SKY_STEP = 2 +/** Use worker threads when the page can share memory (else single-thread). */ +const ENABLE_WORKERS = true +/** Cap on render worker threads. Deliberately low: a browser game shares the + * machine with the browser itself, the compositor, and whatever else is open, so + * grabbing every core backfires -- the frame-time *tail* blows up even while the + * median improves. Measured (`bun run bench:browser`) on a busy 16-core desktop: + * 3 workers beat single-thread on both median (~1.5x) and p95 (~1.2x); 6 were far + * worse. Raise only if the target is a dedicated/idle machine; retune via the + * bench. */ +const MAX_WORKERS = 3 + +// Indices into the shared control Int32Array. +const FRAME = 0 // bumped by main to dispatch a frame +const DONE = 1 // workers add 1 when their band is finished +const VIS = 2 // number of visible chunk indices this frame + +/** + * Render driver. When the page is cross-origin-isolated it runs a pool of worker + * threads, each owning a disjoint row band of a `SharedArrayBuffer` framebuffer; + * otherwise it renders inline on the main thread. Same output either way. + * + * The worker barrier is lock-free: workers `Atomics.wait` on a frame counter, so + * there are **no per-frame messages** (the old postMessage barrier was the jitter + * source). Per-frame inputs (camera, matrix, visible list) live in shared arrays. + * `dispatch` starts a frame without blocking; the caller polls `done()` and + * presents when ready, so pacing stays on the caller's `requestAnimationFrame`. + */ +export type Renderer = { + readonly fb: Framebuffer + readonly parallel: boolean + reconfigure: (config: RenderConfig) => void + /** Start rendering one frame (non-blocking in the worker path). */ + dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number) => void + /** Has the dispatched frame finished? (always true single-threaded.) */ + done: () => boolean + /** Critical-path render time of the last frame, ms (max band time / inline time). */ + workMs: () => number +} + +export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer { + const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4 + const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1)) + const maxVis = Math.max(1, scene.chunks.length) + let config = initial + const want = forceWorkers ?? ENABLE_WORKERS + let parallel = want && canShare() + + let fb = Framebuffer.create(1, 1) + let workers: Worker[] = [] + let ctrl: Int32Array = new Int32Array(0) + let cam: Float64Array = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time + let vp: Float32Array = new Float32Array(0) // the view-projection matrix + let vis: Int32Array = new Int32Array(0) // visible chunk indices + let times: Float64Array = new Float64Array(0) // per-worker band render ms + let lastWork = 0 + + function setup(): void { + for (const w of workers) { + w.terminate() + } + workers = [] + const width = config.internalWidth + const height = config.internalHeight + if (parallel) { + const n = width * height + fb = { width, height, color: new Uint32Array(new SharedArrayBuffer(n * 4)), depth: new Float32Array(new SharedArrayBuffer(n * 4)) } + const bands = splitBands(height, workerCount, SKY_STEP) + ctrl = new Int32Array(new SharedArrayBuffer(4 * 4)) + cam = new Float64Array(new SharedArrayBuffer(7 * 8)) + vp = new Float32Array(new SharedArrayBuffer(16 * 4)) + vis = new Int32Array(new SharedArrayBuffer(maxVis * 4)) + times = new Float64Array(new SharedArrayBuffer(bands.length * 8)) + try { + bands.forEach((band, index) => { + const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" }) + worker.addEventListener("error", () => { + parallel = false + }) + worker.postMessage({ + colorSAB: fb.color.buffer, + depthSAB: fb.depth.buffer, + width, + height, + scene, + band, + config, + skyStep: SKY_STEP, + ctrlSAB: ctrl.buffer, + camSAB: cam.buffer, + vpSAB: vp.buffer, + visSAB: vis.buffer, + timesSAB: times.buffer, + index, + }) + workers.push(worker) + }) + } catch { + parallel = false + for (const w of workers) { + w.terminate() + } + workers = [] + } + } + if (!parallel || workers.length === 0) { + fb = Framebuffer.create(width, height) + } + } + setup() + + return { + get fb() { + return fb + }, + get parallel() { + return parallel && workers.length > 0 + }, + reconfigure(next) { + config = next + setup() + }, + dispatch(camera, viewProj, visible, time) { + if (parallel && workers.length > 0) { + cam[0] = camera.position.x + cam[1] = camera.position.y + cam[2] = camera.position.z + cam[3] = camera.yaw + cam[4] = camera.pitch + cam[5] = camera.fov + cam[6] = time + vp.set(viewProj) + const count = Math.min(visible.length, vis.length) + for (let i = 0; i < count; i++) { + vis[i] = visible[i] + } + Atomics.store(ctrl, VIS, count) + Atomics.store(ctrl, DONE, 0) + Atomics.add(ctrl, FRAME, 1) + Atomics.notify(ctrl, FRAME, workers.length) + return + } + const t0 = performance.now() + renderBand(fb, scene, camera, viewProj, visible, config, SKY_STEP, time, 0, fb.height) + lastWork = performance.now() - t0 + }, + done() { + return !(parallel && workers.length > 0) || Atomics.load(ctrl, DONE) >= workers.length + }, + workMs() { + if (parallel && workers.length > 0) { + let m = 0 + for (let i = 0; i < workers.length; i++) { + if (times[i] > m) { + m = times[i] + } + } + return m + } + return lastWork + }, + } +} + +/** Shared memory needs SharedArrayBuffer + a cross-origin-isolated page (the + * COOP/COEP headers Vite serves). Without it, render on the main thread. */ +function canShare(): boolean { + return ( + typeof SharedArrayBuffer !== "undefined" && + typeof Worker !== "undefined" && + (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true + ) +} + +/** Split `height` rows into ~`count` bands. Interior boundaries snap up to a + * multiple of `step` so the sky's block grid stays aligned (no seam), while the + * bands stay disjoint so no two workers write the same pixel. */ +function splitBands(height: number, count: number, step: number): [number, number][] { + const bands: [number, number][] = [] + const per = Math.ceil(height / count) + let y = 0 + while (y < height) { + const raw = y + per + const y1 = raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step) + bands.push([y, y1]) + y = y1 + } + return bands +} diff --git a/bun.lock b/bun.lock index e0c0f48..8d42a78 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "husky": "^9.1.7", "oxfmt": "^0.47.0", "oxlint": "^1.62.0", + "playwright": "^1.62.1", "typescript": "^7.0.2", "vite": "^8.1.5", }, @@ -277,7 +278,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -355,6 +356,10 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -396,5 +401,7 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } } diff --git a/engine/render/Framebuffer.ts b/engine/render/Framebuffer.ts index bd4e6d2..5b1ffed 100644 --- a/engine/render/Framebuffer.ts +++ b/engine/render/Framebuffer.ts @@ -40,13 +40,13 @@ export namespace Framebuffer { * 15-bit output. Skipped entirely when it would be a no-op (full depth, no * dither). */ - export function quantize(fb: Framebuffer, config: RenderConfig): void { + export function quantize(fb: Framebuffer, config: RenderConfig, y0 = 0, y1 = fb.height): void { const levels = (1 << config.colorDepth) - 1 if (levels >= 255 && config.dither === 0) { return } - const { width, height, color } = fb - for (let y = 0; y < height; y++) { + const { width, color } = fb + for (let y = y0; y < y1; y++) { for (let x = 0; x < width; x++) { // Per-pixel threshold from the tiled Bayer matrix, centered on 0 and // scaled by strength, nudges each channel before it snaps to a level. diff --git a/engine/render/Rasterizer.ts b/engine/render/Rasterizer.ts index b907e52..68da0b3 100644 --- a/engine/render/Rasterizer.ts +++ b/engine/render/Rasterizer.ts @@ -51,6 +51,8 @@ export namespace Rasterizer { viewProj: Mat4, config: RenderConfig, cull = false, + clipY0 = 0, + clipY1 = 1 << 30, ): void { const { verts, indices } = mesh const flat = config.lighting === "flat" @@ -65,7 +67,7 @@ export namespace Rasterizer { // Near-clipping can turn one triangle into a quad; fan it back to tris. const n = clipNear(3) for (let k = 1; k + 1 < n; k++) { - fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull) + fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull, clipY0, clipY1) } } } @@ -167,6 +169,8 @@ export namespace Rasterizer { texture: Texture, config: RenderConfig, cull: boolean, + clipY0: number, + clipY1: number, ): void { const oa = ia * CLIP const ob = ib * CLIP @@ -210,8 +214,10 @@ export namespace Rasterizer { const vC = dst[oc + 4] const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC))) const maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC))) - const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC))) - const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC))) + // Clamp to the caller's Y-band (default full frame) so worker threads can + // each fill a disjoint slice of rows without ever touching the same pixel. + const minY = Math.max(0, clipY0, Math.floor(Math.min(syA, syB, syC))) + const maxY = Math.min(height - 1, clipY1 - 1, Math.ceil(Math.max(syA, syB, syC))) // Edge deltas for the three barycentric edge functions (b->c, c->a, a->b). const dx0 = sxC - sxB const dy0 = syC - syB diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index 1d252ba..ed147f9 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -46,6 +46,11 @@ export type RenderConfig = { * short draw distance and the shimmer of far geometry. It also colors pixels * no triangle covers, so the frame's clear color should match `fog.color`. */ fog: Fog | null + /** Beyond this distance (world units) trees + boulders draw as cheap low-poly + * impostors instead of full geometry, cutting per-triangle work in dense + * views. Kept inside `fog.far` so far detail is already fog-dimmed at the + * switch; `Infinity` disables LOD. */ + lodDistance: number } /** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution, @@ -61,6 +66,7 @@ export namespace RenderConfig { textureFilter: "nearest", lighting: "flat", fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 }, + lodDistance: 60, } export const ps1: RenderConfig = { @@ -73,6 +79,7 @@ export namespace RenderConfig { textureFilter: "nearest", lighting: "flat", fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 }, + lodDistance: 60, } export const soft: RenderConfig = { @@ -85,6 +92,7 @@ export namespace RenderConfig { textureFilter: "nearest", lighting: "flat", fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 }, + lodDistance: 70, } export const clean: RenderConfig = { @@ -94,9 +102,9 @@ export namespace RenderConfig { colorDepth: 8, dither: 0, vertexSnap: 0, - perspectiveCorrect: 1, textureFilter: "linear", lighting: "flat", fog: null, + lodDistance: Infinity, } } diff --git a/engine/render/Sky.ts b/engine/render/Sky.ts index 7f442bc..58684dd 100644 --- a/engine/render/Sky.ts +++ b/engine/render/Sky.ts @@ -64,8 +64,9 @@ export namespace Sky { * block and is copied across it. The sky is low-frequency, so 2 is nearly free * visually and quarters the cloud cost; 1 is full resolution. */ - export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1): void { + export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void { const { width, height, color, depth } = fb + const bottom = y1 < 0 ? height : y1 const forward = Camera.forward(camera) const right = Vec3.normalize(Vec3.cross(forward, UP)) const up = Vec3.cross(right, forward) @@ -76,11 +77,13 @@ export namespace Sky { const clouds = sky.clouds const cloud: CloudSample = { cover: 0, shade: 1 } const s = Math.max(1, step | 0) - for (let by = 0; by < height; by += s) { + // Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block + // grid stays global and neighboring bands don't seam. + for (let by = y0; by < bottom; by += s) { // Shade at the block center, then flood the whole block with that color. const sampleY = Math.min(height - 1, by + (s >> 1)) const ndcY = 1 - ((sampleY + 0.5) / height) * 2 - const yEnd = Math.min(height, by + s) + const yEnd = Math.min(bottom, by + s) for (let bx = 0; bx < width; bx += s) { const sampleX = Math.min(width - 1, bx + (s >> 1)) const ndcX = ((sampleX + 0.5) / width) * 2 - 1 diff --git a/engine/scene/Boulder.ts b/engine/scene/Boulder.ts index 9e8b1e9..e71c869 100644 --- a/engine/scene/Boulder.ts +++ b/engine/scene/Boulder.ts @@ -22,10 +22,11 @@ export type Boulder = { * field of boulders batches into a single draw call. */ export namespace Boulder { - export function build(boulder: Boulder, mesh: Mesh): void { + /** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */ + export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void { const rand = rng(boulder.seed) - const seg = 5 - const rings = 4 + const seg = lod === "impostor" ? 4 : 5 + const rings = lod === "impostor" ? 2 : 4 const r = boulder.radius // Squat and slightly oval, so it reads as a rock, not a ball. const sx = r * (0.8 + rand() * 0.5) diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts index cb0a700..ad92662 100644 --- a/engine/scene/Tree.ts +++ b/engine/scene/Tree.ts @@ -28,24 +28,32 @@ export type Tree = { export namespace Tree { /** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh * for its kind (oak leaf vs spruce needle). */ - export function build(tree: Tree, trunk: Mesh, foliage: Mesh): void { + /** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures + + * faceted look, same height/position) for far chunks; "full" is up close. */ + export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void { const rand = rng(tree.seed) if (tree.kind === "oak") { - oak(tree.position, tree.growth, rand, trunk, foliage) + oak(tree.position, tree.growth, rand, trunk, foliage, lod) } else { - spruce(tree.position, tree.growth, rand, trunk, foliage) + spruce(tree.position, tree.growth, rand, trunk, foliage, lod) } } - function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh): void { + function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { const h = lerp(0.8, 7, g) const rTrunk = lerp(0.04, 0.32, g) const forkY = base.y + h * 0.5 + const canopyY = base.y + h * 0.72 + const blobR = h * 0.3 + if (lod === "impostor") { + // One low-poly blob on a stubby trunk -- reads as an oak at distance. + limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3) + blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2) + return + } limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5) - const blobR = h * 0.3 const spread = h * 0.32 - const canopyY = base.y + h * 0.72 // Central blob plus, as it grows, a couple offset ones -> broad bushy crown. const blobs = 1 + Math.round(g * 2) for (let i = 0; i < blobs; i++) { @@ -72,13 +80,16 @@ export namespace Tree { } } - function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh): void { + function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { const h = lerp(0.6, 9, g) const rTrunk = lerp(0.03, 0.2, g) - limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, 5) + const impostor = lod === "impostor" + limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5) // Stacked cones: widest low, shrinking to a point up top -> conical tiers. - const tiers = 2 + Math.round(g * 3) + // The impostor keeps the first two tiers at low sides (same seed => aligned). + const tiers = impostor ? 2 : 2 + Math.round(g * 3) + const sides = impostor ? 4 : 6 const bottom = base.y + h * 0.1 const span = h * 0.9 for (let i = 0; i < tiers; i++) { @@ -86,7 +97,7 @@ export namespace Tree { const y = bottom + t * span * 0.82 const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) const coneH = (span / tiers) * 1.9 - cone(needles, { x: base.x, y, z: base.z }, coneH, radius, 6) + cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides) } } @@ -127,9 +138,7 @@ export namespace Tree { /** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps * it organic without cracking the longitude seam. */ - function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void { - const seg = 5 - const rings = 3 + function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void { const start = mesh.verts.length / STRIDE for (let r = 0; r <= rings; r++) { const phi = (r / rings) * Math.PI diff --git a/index.html b/index.html index 432d1d2..9f59971 100644 --- a/index.html +++ b/index.html @@ -13,18 +13,21 @@ overflow: hidden; } #screen { - display: block; + position: absolute; image-rendering: pixelated; } #fps { position: fixed; right: 6px; bottom: 6px; - font: 16px monospace; + font: 13px monospace; + white-space: pre; + line-height: 1.35; + text-align: right; color: #fff; background: #000; opacity: 0.5; - padding: 1px 5px; + padding: 2px 6px; pointer-events: none; } diff --git a/oxlint.config.ts b/oxlint.config.ts index e75b9b1..3a5aa6d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -67,6 +67,8 @@ export default defineConfig({ "typescript/prefer-function-type": "off", "unicorn/no-process-exit": "off", "unicorn/prefer-string-raw": "off", + "unicorn/relative-url-style": "off", + "unicorn/require-post-message-target-origin": "off", "unicorn/text-encoding-identifier-case": "off", }, overrides: [ diff --git a/package.json b/package.json index e3c86ee..68bc967 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,21 @@ "url": "git+https://github.com/sigitex/meat.git" }, "devDependencies": { - "typescript": "^7.0.2", - "@types/bun": "^1.3.13", - "oxfmt": "^0.47.0", - "oxlint": "^1.62.0", "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", + "@types/bun": "^1.3.13", "husky": "^9.1.7", + "oxfmt": "^0.47.0", + "oxlint": "^1.62.0", + "playwright": "^1.62.1", + "typescript": "^7.0.2", "vite": "^8.1.5" }, "scripts": { "start": "bunx --bun vite --host", "build": "bunx --bun vite build", "assets": "bun scripts/gen-assets.ts", + "bench:browser": "bun scripts/bench-browser.ts", "publish:builds": "rsync -avz builds/ sigitex.com:~/meat.errilaz.org/builds", "publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org", "publish": "bun run publish:current && bun run publish:builds", diff --git a/scripts/bench-browser.ts b/scripts/bench-browser.ts new file mode 100644 index 0000000..92d31de --- /dev/null +++ b/scripts/bench-browser.ts @@ -0,0 +1,93 @@ +import { chromium } from "playwright" + +// Start the Vite dev server (it serves the COOP/COEP headers needed for +// SharedArrayBuffer), drive real headless Chromium through both bench modes, and +// read the timings back. Real V8 + Web Workers + SAB + rAF -- the environment my +// headless Bun benches can't see. + +const PORT = 5199 +const URL = `http://localhost:${PORT}` + +const vite = Bun.spawn(["bunx", "--bun", "vite", "--port", String(PORT), "--strictPort"], { + cwd: import.meta.dir + "/..", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, +}) + +async function waitForServer(): Promise { + for (let i = 0; i < 150; i++) { + try { + const r = await fetch(URL + "/") + if (r.ok) { + return + } + } catch { + // not up yet + } + await Bun.sleep(200) + } + throw new Error("vite dev server did not start") +} + +type Bench = { + mode: string + parallel: boolean + cores: number + coi: boolean + res: string + workMs: { median: number; p95: number; max: number; mean: number } + frameMs: { median: number; p95: number; max: number; mean: number } +} + +async function main(): Promise { + await waitForServer() + const browser = await chromium.launch({ + headless: true, + args: [ + "--enable-features=SharedArrayBuffer", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-backgrounding-occluded-windows", + ], + }) + + async function run(mode: string): Promise { + const page = await browser.newPage() + page.on("pageerror", (e) => console.log(` [page error] ${e.message}`)) + page.on("console", (m) => { + const t = m.text() + if (t.startsWith("BENCH") || m.type() === "error") { + console.log(` [console] ${t}`) + } + }) + await page.setViewportSize({ width: 800, height: 600 }) + await page.goto(`${URL}/?bench=${mode}`, { waitUntil: "load" }) + await page.waitForFunction("window.__BENCH__ !== undefined", null, { timeout: 120000 }) + const result = (await page.evaluate("window.__BENCH__")) as Bench + await page.close() + return result + } + + console.log("--- single-thread ---") + const st = await run("st") + console.log("--- workers ---") + const mt = await run("mt") + await browser.close() + + const line = (b: Bench) => + `parallel=${b.parallel} coi=${b.coi} cores=${b.cores} res=${b.res} work med/p95/max = ${b.workMs.median}/${b.workMs.p95}/${b.workMs.max} ms frame med/p95 = ${b.frameMs.median}/${b.frameMs.p95} ms` + console.log("") + console.log(`single-thread : ${line(st)}`) + console.log(`workers : ${line(mt)}`) + console.log("") + console.log(`work median : ${st.workMs.median} -> ${mt.workMs.median} ms (${(st.workMs.median / mt.workMs.median).toFixed(2)}x)`) + console.log(`work p95 : ${st.workMs.p95} -> ${mt.workMs.p95} ms (${(st.workMs.p95 / mt.workMs.p95).toFixed(2)}x)`) + console.log(`frame p95 : ${st.frameMs.p95} -> ${mt.frameMs.p95} ms (jitter: lower p95/median = steadier)`) +} + +try { + await main() +} finally { + vite.kill() +} diff --git a/vite.config.ts b/vite.config.ts index 8153be3..cb69edb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,24 @@ import { defineConfig } from "vite" +// Cross-origin isolation (COOP + COEP) is required for SharedArrayBuffer, which +// the multi-threaded renderer uses to share the framebuffer across workers. +// Without these headers the app still runs -- it falls back to single-threaded. +const crossOriginIsolation = { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", +} + export default defineConfig({ resolve: { tsconfigPaths: true, }, + worker: { + format: "es", + }, + server: { + headers: crossOriginIsolation, + }, + preview: { + headers: crossOriginIsolation, + }, }) -