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 { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol" import { RenderScene, type RenderInstance } from "../engine/render/RenderScene" /** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel); * band splits align to it so the cloud block grid stays seamless across workers. */ 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 /** * 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, instances: RenderInstance[], ) => 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 } type PendingFrame = { camera: Camera viewProjection: Mat4 visibleChunks: number[] instances: RenderInstance[] time: number } export function createRenderer( scene: RenderScene, 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) const maxInstances = Math.max(1, scene.maxInstances) let config = initial const want = forceWorkers ?? ENABLE_WORKERS let parallel = want && canShare() let fb = Framebuffer.create(1, 1) let workers: Worker[] = [] let ctrl: Int32Array = new Int32Array(0) let cam: Float64Array = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time let vp: Float32Array = new Float32Array(0) // the view-projection matrix let vis: Int32Array = new Int32Array(0) // visible chunk indices let instanceIds: Int32Array = new Int32Array(0) let instanceTransforms: Float32Array = new Float32Array(0) let times: Float64Array = new Float64Array(0) // per-worker band render ms let lastWork = 0 let generation = 0 let pending: PendingFrame | null = null function stopWorkers(): void { for (const worker of workers) { worker.terminate() } workers = [] } function renderInline(frame: PendingFrame): void { const t0 = performance.now() RenderScene.renderBand( fb, scene, frame.camera, frame.viewProjection, frame.visibleChunks, frame.instances, config, SKY_STEP, frame.time, 0, fb.height, ) lastWork = performance.now() - t0 } function disableParallel(currentGeneration: number): void { if (!parallel || currentGeneration !== generation) { return } const frame = pending parallel = false stopWorkers() if (frame !== null) { renderInline(frame) pending = null } } function setup(): void { generation++ const currentGeneration = generation pending = null stopWorkers() 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(RenderProtocol.CONTROL_LENGTH * 4), ) cam = new Float64Array( new SharedArrayBuffer(RenderProtocol.CAMERA_LENGTH * 8), ) vp = new Float32Array( new SharedArrayBuffer(RenderProtocol.VIEW_PROJECTION_LENGTH * 4), ) vis = new Int32Array(new SharedArrayBuffer(maxVis * 4)) instanceIds = new Int32Array(new SharedArrayBuffer(maxInstances * 4)) instanceTransforms = new Float32Array( new SharedArrayBuffer( maxInstances * RenderProtocol.TRANSFORM_FLOATS * 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", () => disableParallel(currentGeneration)) const init: RenderWorkerInit = { colorSAB: shared(fb.color.buffer), depthSAB: shared(fb.depth.buffer), width, height, scene, band, config, skyStep: SKY_STEP, ctrlSAB: shared(ctrl.buffer), cameraSAB: shared(cam.buffer), viewProjectionSAB: shared(vp.buffer), visibleChunkSAB: shared(vis.buffer), instanceIdSAB: shared(instanceIds.buffer), instanceTransformSAB: shared(instanceTransforms.buffer), timesSAB: shared(times.buffer), workerIndex: index, } worker.postMessage(init) workers.push(worker) }) Atomics.store(ctrl, RenderProtocol.DONE, workers.length) } catch { parallel = false stopWorkers() } } 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, instances) { pending = { camera, viewProjection: viewProj, visibleChunks: visible, instances, time, } if (parallel && workers.length > 0) { RenderProtocol.writeCamera(cam, camera, time) RenderProtocol.writeViewProjection(vp, viewProj) const count = Math.min(visible.length, vis.length) for (let i = 0; i < count; i++) { vis[i] = visible[i] } const instanceCount = RenderProtocol.writeInstances( instanceIds, instanceTransforms, instances, ) Atomics.store(ctrl, RenderProtocol.VISIBLE_CHUNKS, count) Atomics.store(ctrl, RenderProtocol.VISIBLE_INSTANCES, instanceCount) Atomics.store(ctrl, RenderProtocol.DONE, 0) Atomics.add(ctrl, RenderProtocol.FRAME, 1) Atomics.notify(ctrl, RenderProtocol.FRAME, workers.length) return } renderInline(pending) pending = null }, done() { const complete = !(parallel && workers.length > 0) || Atomics.load(ctrl, RenderProtocol.DONE) >= workers.length if (complete) { pending = null } return complete }, 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 ) } function shared(buffer: ArrayBufferLike): SharedArrayBuffer { if (!(buffer instanceof SharedArrayBuffer)) { throw new Error("render worker buffer is not shared") } return buffer } /** 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 }