feat: threads
This commit is contained in:
parent
6fb46573da
commit
46e7070b6b
18 changed files with 878 additions and 125 deletions
119
AGENTS.md
119
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue