feat: threads

This commit is contained in:
Dan Finch 2026-08-05 09:24:13 +02:00
parent 6fb46573da
commit 46e7070b6b
18 changed files with 878 additions and 125 deletions

119
AGENTS.md
View file

@ -32,7 +32,10 @@ rules live in `.agents/rules/*.md`.
## Stack & tooling ## Stack & tooling
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict, - **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). - **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier).
- commitlint + husky (Conventional Commits), OpenSpec change workflow, - commitlint + husky (Conventional Commits), OpenSpec change workflow,
forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source. 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 start` — Vite dev server; open the printed URL. Edits hot-reload.
- `bun run build` — production build to `dist/`. - `bun run build` — production build to `dist/`.
- `bun run assets` — regenerate the placeholder PNGs in `/assets`. - `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 - `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
this**, not `bun run check` (see Caveats). this**, not `bun run check` (see Caveats).
- `bunx oxlint engine app` — lint. - `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; blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
samples a 2x2 color-atlas texture, drawn double-sided). samples a 2x2 color-atlas texture, drawn double-sided).
- `app/` — browser glue. - `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 - `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
are already the `Color` layout). are already the `Color` layout).
- `level.ts` — builds the playground: a flat stone-floored room (three thick - `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 = 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 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 `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 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 stays clean over the terrain skirt that laps under the room edges. Room surfaces
are single flat quads -- no subdivision needed since texturing is are single flat quads -- no subdivision needed since texturing is
@ -104,16 +125,36 @@ rules live in `.agents/rules/*.md`.
filenames are the contract. filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot. - `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` `Player.update` → build `Camera``Camera.viewProjection``visibleChunks`
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → (frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`, next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock, while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
bark, leaf, needle (backface-culled) + flowers (double-sided) → desync from rAF.
`Sprite.billboard(npc)` (double-sided) →
`Framebuffer.quantize``present` (integer-scale, letterboxed blit; **`present` is a GPU/CSS upscale, not a CPU blit.** The `#screen` canvas backing
`imageSmoothingEnabled` follows `upscaleFilter`). 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**, Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
perspective-correct UVs, screen-space vertex snap, flat directional lighting, 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 `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. `ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`, Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
`vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always `vertexSnap`, `textureFilter`, `lighting`, `fog`, `lodDistance`. (Texturing is
perspective-correct — the affine-swim dial was removed.) 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 - **`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 unit. Lower = the stone tiles bigger and less busy = less far-distance moire
(there are no mipmaps); higher = finer but shimmerier. (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. (terrain, foliage, rock). See the Rasterizer note re winding.
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per - **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. 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 - **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 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) (measured). Buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) and
and makes geometry shareable for Web-Worker rasterization later. Note it did makes geometry shareable across worker threads. It did **not** raise mean fps —
**not** raise mean fps — allocation was never the bottleneck (JSC collects the allocation was never the bottleneck; the mean is the transform+fill **compute**.
churn ~free); 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.52x, growing with content since culled Frustum + backface + half-res sky give ~1.52x, workers ~1.5x more, distance LOD
chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so another ~1.4x of the tris in dense views. Together they get the heavy in-forest
the mean-fps levers left are to **do less** (LOD / impostors for far trees — view (the worst case) under the 60fps vsync budget on the worker path; the
`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores** single-thread fallback still lands ~30fps there. The blunt content dials if it
(Web-Worker banded rasterization, now unblocked by the flat geometry). 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 ## Clouds

View file

@ -39,6 +39,13 @@ export type Chunk = {
rock: Mesh rock: Mesh
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */ /** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
flowers: Mesh 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 /** 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 needle = mesh()
const rock = mesh() const rock = mesh()
const flowerMesh = 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) Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) { for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) { 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) { for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) { if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, rock) 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) { for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) { if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, leaf) Bush.build(bush, leaf)
@ -240,7 +256,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
if (b === null) { if (b === null) {
continue 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 return chunks

View file

@ -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 { RenderConfig } from "../engine/render/RenderConfig"
import { Sky } from "../engine/render/Sky"
import { Camera } from "../engine/scene/Camera" import { Camera } from "../engine/scene/Camera"
import { Sprite } from "../engine/scene/Sprite"
import { loadTextures } from "./assets" import { loadTextures } from "./assets"
import { buildLevel } from "./level" import { buildLevel } from "./level"
import { EYE_HEIGHT, Player } from "./player" import { EYE_HEIGHT, Player } from "./player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
const FOV = Math.PI / 3 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<HTMLCanvasElement>("#screen")! const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")! const ctx = screen.getContext("2d")!
const back = document.createElement("canvas")
const backCtx = back.getContext("2d")!
const fpsEl = document.querySelector<HTMLDivElement>("#fps")! const fpsEl = document.querySelector<HTMLDivElement>("#fps")!
let config: RenderConfig = RenderConfig.standard /** Deterministic flythrough (virtual time from frame index), so the st and mt
let fb = Framebuffer.create(1, 1) * bench runs render the exact same work. Deliberately stands *inside* the dense
let image = new ImageData(1, 1) * tree ring (radius ~40100) 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 useConfig(next: RenderConfig): void { function benchCamera(tv: number): Camera {
config = next const drift = tv * 0.12
fb = Framebuffer.create(config.internalWidth, config.internalHeight) const radius = 65 + 30 * Math.sin(tv * 0.25)
back.width = fb.width return {
back.height = fb.height position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius },
image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height) yaw: tv * 0.7,
pitch: 0.05 * Math.sin(tv * 0.5),
fov: FOV,
}
} }
function resize(): void { function round2(n: number): number {
screen.width = globalThis.innerWidth return Math.round(n * 100) / 100
screen.height = globalThis.innerHeight
} }
function present(): void { function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
backCtx.putImageData(image, 0, 0) const s = a.toSorted((x, y) => x - y)
const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height))) const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))]
const w = fb.width * scale 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) }
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)
} }
async function main(): Promise<void> { async function main(): Promise<void> {
const textures = await loadTextures() const textures = await loadTextures()
const level = buildLevel() const level = buildLevel()
const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc } const scene: Scene = {
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true } 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<string>() const keys = new Set<string>()
globalThis.addEventListener("keydown", (e) => { globalThis.addEventListener("keydown", (e) => {
keys.add(e.code) keys.add(e.code)
@ -86,56 +136,161 @@ async function main(): Promise<void> {
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025)) player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
}) })
useConfig(config) // Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
globalThis.addEventListener("resize", resize) function frameTris(visible: number[], cam: Camera): number {
resize() 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 last = performance.now()
let fpsLast = last let fpsLast = last
let fpsFrames = 0 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) const dt = Math.min(0.05, (now - last) / 1000)
last = now last = now
fpsFrames++
if (now - fpsLast >= 250) { 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 fpsLast = now
fpsFrames = 0 fpsFrames = 0
workMax = 0
presentMax = 0
vsyncMax = 0
} }
Player.update(player, keys, dt, level) Player.update(player, keys, dt, level)
const camera: Camera = { const camera: Camera = {
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z }, position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
yaw: player.yaw, yaw: player.yaw,
pitch: player.pitch, pitch: player.pitch,
fov: FOV, fov: FOV,
} }
const viewProj = Camera.viewProjection(camera, fb.width / fb.height) 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
}
}
requestAnimationFrame(tick)
}
Sky.render(fb, camera, level.sky, now / 1000, SKY_STEP) /** Scripted flythrough that records critical-path work time and present-to-present
// Room is small and always near where you play; draw it unconditionally. * interval, then reports the distributions (exposed on `window.__BENCH__`). */
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config) function runBench(
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) renderer: ReturnType<typeof createRenderer>,
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config) chunks: Scene["chunks"],
// Outdoor world: skip whole chunks that fall outside the view frustum. present: () => void,
const frustum = Frustum.fromViewProj(viewProj) mode: string,
for (const c of level.chunks) { ): void {
if (!Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) { const WARM = 60
continue const MEASURE = 300
} const work: number[] = []
Rasterizer.draw(fb, c.grass, textures.grass, viewProj, config, true) const frame: number[] = []
Rasterizer.draw(fb, c.rock, textures.rock, viewProj, config, true) let i = 0
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true) let inFlight = false
Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true) let prev = performance.now()
Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true) let finished = false
Rasterizer.draw(fb, c.flowers, textures.flower, viewProj, config)
} function record(): boolean {
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
Framebuffer.quantize(fb, config)
present() present()
requestAnimationFrame(frame) const now = performance.now()
if (i >= WARM) {
work.push(renderer.workMs())
frame.push(now - prev)
} }
requestAnimationFrame(frame) 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) => { main().catch((error) => {

61
app/render-worker.ts Normal file
View file

@ -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)
}
})

102
app/renderScene.ts Normal file
View file

@ -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
}

196
app/renderer.ts Normal file
View file

@ -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<ArrayBufferLike> = new Int32Array(0)
let cam: Float64Array<ArrayBufferLike> = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time
let vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
let times: Float64Array<ArrayBufferLike> = 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
}

View file

@ -11,6 +11,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"oxfmt": "^0.47.0", "oxfmt": "^0.47.0",
"oxlint": "^1.62.0", "oxlint": "^1.62.0",
"playwright": "^1.62.1",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.1.5", "vite": "^8.1.5",
}, },
@ -277,7 +278,7 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],
} }
} }

View file

@ -40,13 +40,13 @@ export namespace Framebuffer {
* 15-bit output. Skipped entirely when it would be a no-op (full depth, no * 15-bit output. Skipped entirely when it would be a no-op (full depth, no
* dither). * 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 const levels = (1 << config.colorDepth) - 1
if (levels >= 255 && config.dither === 0) { if (levels >= 255 && config.dither === 0) {
return return
} }
const { width, height, color } = fb const { width, color } = fb
for (let y = 0; y < height; y++) { for (let y = y0; y < y1; y++) {
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and // Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
// scaled by strength, nudges each channel before it snaps to a level. // scaled by strength, nudges each channel before it snaps to a level.

View file

@ -51,6 +51,8 @@ export namespace Rasterizer {
viewProj: Mat4, viewProj: Mat4,
config: RenderConfig, config: RenderConfig,
cull = false, cull = false,
clipY0 = 0,
clipY1 = 1 << 30,
): void { ): void {
const { verts, indices } = mesh const { verts, indices } = mesh
const flat = config.lighting === "flat" 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. // Near-clipping can turn one triangle into a quad; fan it back to tris.
const n = clipNear(3) const n = clipNear(3)
for (let k = 1; k + 1 < n; k++) { 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, texture: Texture,
config: RenderConfig, config: RenderConfig,
cull: boolean, cull: boolean,
clipY0: number,
clipY1: number,
): void { ): void {
const oa = ia * CLIP const oa = ia * CLIP
const ob = ib * CLIP const ob = ib * CLIP
@ -210,8 +214,10 @@ export namespace Rasterizer {
const vC = dst[oc + 4] const vC = dst[oc + 4]
const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC))) 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 maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC)))
const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC))) // Clamp to the caller's Y-band (default full frame) so worker threads can
const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC))) // 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). // Edge deltas for the three barycentric edge functions (b->c, c->a, a->b).
const dx0 = sxC - sxB const dx0 = sxC - sxB
const dy0 = syC - syB const dy0 = syC - syB

View file

@ -46,6 +46,11 @@ export type RenderConfig = {
* short draw distance and the shimmer of far geometry. It also colors pixels * 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`. */ * no triangle covers, so the frame's clear color should match `fog.color`. */
fog: Fog | null 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, /** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
@ -61,6 +66,7 @@ export namespace RenderConfig {
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 }, fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
lodDistance: 60,
} }
export const ps1: RenderConfig = { export const ps1: RenderConfig = {
@ -73,6 +79,7 @@ export namespace RenderConfig {
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 }, fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
lodDistance: 60,
} }
export const soft: RenderConfig = { export const soft: RenderConfig = {
@ -85,6 +92,7 @@ export namespace RenderConfig {
textureFilter: "nearest", textureFilter: "nearest",
lighting: "flat", lighting: "flat",
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 }, fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
lodDistance: 70,
} }
export const clean: RenderConfig = { export const clean: RenderConfig = {
@ -94,9 +102,9 @@ export namespace RenderConfig {
colorDepth: 8, colorDepth: 8,
dither: 0, dither: 0,
vertexSnap: 0, vertexSnap: 0,
perspectiveCorrect: 1,
textureFilter: "linear", textureFilter: "linear",
lighting: "flat", lighting: "flat",
fog: null, fog: null,
lodDistance: Infinity,
} }
} }

View file

@ -64,8 +64,9 @@ export namespace Sky {
* block and is copied across it. The sky is low-frequency, so 2 is nearly free * 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. * 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 { width, height, color, depth } = fb
const bottom = y1 < 0 ? height : y1
const forward = Camera.forward(camera) const forward = Camera.forward(camera)
const right = Vec3.normalize(Vec3.cross(forward, UP)) const right = Vec3.normalize(Vec3.cross(forward, UP))
const up = Vec3.cross(right, forward) const up = Vec3.cross(right, forward)
@ -76,11 +77,13 @@ export namespace Sky {
const clouds = sky.clouds const clouds = sky.clouds
const cloud: CloudSample = { cover: 0, shade: 1 } const cloud: CloudSample = { cover: 0, shade: 1 }
const s = Math.max(1, step | 0) 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. // Shade at the block center, then flood the whole block with that color.
const sampleY = Math.min(height - 1, by + (s >> 1)) const sampleY = Math.min(height - 1, by + (s >> 1))
const ndcY = 1 - ((sampleY + 0.5) / height) * 2 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) { for (let bx = 0; bx < width; bx += s) {
const sampleX = Math.min(width - 1, bx + (s >> 1)) const sampleX = Math.min(width - 1, bx + (s >> 1))
const ndcX = ((sampleX + 0.5) / width) * 2 - 1 const ndcX = ((sampleX + 0.5) / width) * 2 - 1

View file

@ -22,10 +22,11 @@ export type Boulder = {
* field of boulders batches into a single draw call. * field of boulders batches into a single draw call.
*/ */
export namespace Boulder { 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 rand = rng(boulder.seed)
const seg = 5 const seg = lod === "impostor" ? 4 : 5
const rings = 4 const rings = lod === "impostor" ? 2 : 4
const r = boulder.radius const r = boulder.radius
// Squat and slightly oval, so it reads as a rock, not a ball. // Squat and slightly oval, so it reads as a rock, not a ball.
const sx = r * (0.8 + rand() * 0.5) const sx = r * (0.8 + rand() * 0.5)

View file

@ -28,24 +28,32 @@ export type Tree = {
export namespace Tree { export namespace Tree {
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh /** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
* for its kind (oak leaf vs spruce needle). */ * 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) const rand = rng(tree.seed)
if (tree.kind === "oak") { if (tree.kind === "oak") {
oak(tree.position, tree.growth, rand, trunk, foliage) oak(tree.position, tree.growth, rand, trunk, foliage, lod)
} else { } 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 h = lerp(0.8, 7, g)
const rTrunk = lerp(0.04, 0.32, g) const rTrunk = lerp(0.04, 0.32, g)
const forkY = base.y + h * 0.5 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) 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 spread = h * 0.32
const canopyY = base.y + h * 0.72
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown. // Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
const blobs = 1 + Math.round(g * 2) const blobs = 1 + Math.round(g * 2)
for (let i = 0; i < blobs; i++) { 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 h = lerp(0.6, 9, g)
const rTrunk = lerp(0.03, 0.2, 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. // 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 bottom = base.y + h * 0.1
const span = h * 0.9 const span = h * 0.9
for (let i = 0; i < tiers; i++) { for (let i = 0; i < tiers; i++) {
@ -86,7 +97,7 @@ export namespace Tree {
const y = bottom + t * span * 0.82 const y = bottom + t * span * 0.82
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
const coneH = (span / tiers) * 1.9 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 /** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps
* it organic without cracking the longitude seam. */ * it organic without cracking the longitude seam. */
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void { function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void {
const seg = 5
const rings = 3
const start = mesh.verts.length / STRIDE const start = mesh.verts.length / STRIDE
for (let r = 0; r <= rings; r++) { for (let r = 0; r <= rings; r++) {
const phi = (r / rings) * Math.PI const phi = (r / rings) * Math.PI

View file

@ -13,18 +13,21 @@
overflow: hidden; overflow: hidden;
} }
#screen { #screen {
display: block; position: absolute;
image-rendering: pixelated; image-rendering: pixelated;
} }
#fps { #fps {
position: fixed; position: fixed;
right: 6px; right: 6px;
bottom: 6px; bottom: 6px;
font: 16px monospace; font: 13px monospace;
white-space: pre;
line-height: 1.35;
text-align: right;
color: #fff; color: #fff;
background: #000; background: #000;
opacity: 0.5; opacity: 0.5;
padding: 1px 5px; padding: 2px 6px;
pointer-events: none; pointer-events: none;
} }
</style> </style>

View file

@ -67,6 +67,8 @@ export default defineConfig({
"typescript/prefer-function-type": "off", "typescript/prefer-function-type": "off",
"unicorn/no-process-exit": "off", "unicorn/no-process-exit": "off",
"unicorn/prefer-string-raw": "off", "unicorn/prefer-string-raw": "off",
"unicorn/relative-url-style": "off",
"unicorn/require-post-message-target-origin": "off",
"unicorn/text-encoding-identifier-case": "off", "unicorn/text-encoding-identifier-case": "off",
}, },
overrides: [ overrides: [

View file

@ -12,19 +12,21 @@
"url": "git+https://github.com/sigitex/meat.git" "url": "git+https://github.com/sigitex/meat.git"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2",
"@types/bun": "^1.3.13",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"@commitlint/cli": "^20.5.3", "@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3", "@commitlint/config-conventional": "^20.5.3",
"@types/bun": "^1.3.13",
"husky": "^9.1.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" "vite": "^8.1.5"
}, },
"scripts": { "scripts": {
"start": "bunx --bun vite --host", "start": "bunx --bun vite --host",
"build": "bunx --bun vite build", "build": "bunx --bun vite build",
"assets": "bun scripts/gen-assets.ts", "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:builds": "rsync -avz builds/ sigitex.com:~/meat.errilaz.org/builds",
"publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org", "publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org",
"publish": "bun run publish:current && bun run publish:builds", "publish": "bun run publish:current && bun run publish:builds",

93
scripts/bench-browser.ts Normal file
View file

@ -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<void> {
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<void> {
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<Bench> {
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()
}

View file

@ -1,8 +1,24 @@
import { defineConfig } from "vite" 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({ export default defineConfig({
resolve: { resolve: {
tsconfigPaths: true, tsconfigPaths: true,
}, },
worker: {
format: "es",
},
server: {
headers: crossOriginIsolation,
},
preview: {
headers: crossOriginIsolation,
},
}) })