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
- **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.52x, 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.52x, 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

View file

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

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 { 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<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
const back = document.createElement("canvas")
const backCtx = back.getContext("2d")!
const fpsEl = document.querySelector<HTMLDivElement>("#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 ~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 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<void> {
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<string>()
globalThis.addEventListener("keydown", (e) => {
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))
})
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<typeof createRenderer>,
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) => {

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",
"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=="],
}
}

View file

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

View file

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

View file

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

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
* 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

View file

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

View file

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

View file

@ -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;
}
</style>

View file

@ -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: [

View file

@ -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",

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"
// 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,
},
})