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

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
}