2026-08-24 15:54:44 +02:00
|
|
|
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"
|
2026-08-05 09:24:13 +02:00
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
/** 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. */
|
2026-08-05 09:24:13 +02:00
|
|
|
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). */
|
2026-08-24 15:17:53 +02:00
|
|
|
dispatch: (
|
|
|
|
|
camera: Camera,
|
|
|
|
|
viewProj: Mat4,
|
|
|
|
|
visible: number[],
|
|
|
|
|
time: number,
|
|
|
|
|
instances: RenderInstance[],
|
|
|
|
|
) => void
|
2026-08-05 09:24:13 +02:00
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
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
|
2026-08-05 09:24:13 +02:00
|
|
|
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
|
|
|
|
|
const maxVis = Math.max(1, scene.chunks.length)
|
2026-08-24 15:17:53 +02:00
|
|
|
const maxInstances = Math.max(1, scene.maxInstances)
|
2026-08-05 09:24:13 +02:00
|
|
|
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
|
2026-08-24 15:17:53 +02:00
|
|
|
let instanceIds: Int32Array<ArrayBufferLike> = new Int32Array(0)
|
|
|
|
|
let instanceTransforms: Float32Array<ArrayBufferLike> = new Float32Array(0)
|
2026-08-05 09:24:13 +02:00
|
|
|
let times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
|
|
|
|
|
let lastWork = 0
|
2026-08-24 15:17:53 +02:00
|
|
|
let generation = 0
|
|
|
|
|
let pending: PendingFrame | null = null
|
2026-08-05 09:24:13 +02:00
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
function stopWorkers(): void {
|
|
|
|
|
for (const worker of workers) {
|
|
|
|
|
worker.terminate()
|
2026-08-05 09:24:13 +02:00
|
|
|
}
|
|
|
|
|
workers = []
|
2026-08-24 15:17:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
2026-08-05 09:24:13 +02:00
|
|
|
const width = config.internalWidth
|
|
|
|
|
const height = config.internalHeight
|
|
|
|
|
if (parallel) {
|
|
|
|
|
const n = width * height
|
2026-08-24 15:17:53 +02:00
|
|
|
fb = {
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
color: new Uint32Array(new SharedArrayBuffer(n * 4)),
|
|
|
|
|
depth: new Float32Array(new SharedArrayBuffer(n * 4)),
|
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
const bands = splitBands(height, workerCount, SKY_STEP)
|
2026-08-24 15:17:53 +02:00
|
|
|
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),
|
|
|
|
|
)
|
2026-08-05 09:24:13 +02:00
|
|
|
vis = new Int32Array(new SharedArrayBuffer(maxVis * 4))
|
2026-08-24 15:17:53 +02:00
|
|
|
instanceIds = new Int32Array(new SharedArrayBuffer(maxInstances * 4))
|
|
|
|
|
instanceTransforms = new Float32Array(
|
|
|
|
|
new SharedArrayBuffer(
|
|
|
|
|
maxInstances * RenderProtocol.TRANSFORM_FLOATS * 4,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-08-05 09:24:13 +02:00
|
|
|
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
|
|
|
|
|
try {
|
|
|
|
|
bands.forEach((band, index) => {
|
2026-08-24 15:17:53 +02:00
|
|
|
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),
|
2026-08-05 09:24:13 +02:00
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
scene,
|
|
|
|
|
band,
|
|
|
|
|
config,
|
|
|
|
|
skyStep: SKY_STEP,
|
2026-08-24 15:17:53 +02:00
|
|
|
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)
|
2026-08-05 09:24:13 +02:00
|
|
|
workers.push(worker)
|
|
|
|
|
})
|
2026-08-24 15:17:53 +02:00
|
|
|
Atomics.store(ctrl, RenderProtocol.DONE, workers.length)
|
2026-08-05 09:24:13 +02:00
|
|
|
} catch {
|
|
|
|
|
parallel = false
|
2026-08-24 15:17:53 +02:00
|
|
|
stopWorkers()
|
2026-08-05 09:24:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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()
|
|
|
|
|
},
|
2026-08-24 15:17:53 +02:00
|
|
|
dispatch(camera, viewProj, visible, time, instances) {
|
|
|
|
|
pending = {
|
|
|
|
|
camera,
|
|
|
|
|
viewProjection: viewProj,
|
|
|
|
|
visibleChunks: visible,
|
|
|
|
|
instances,
|
|
|
|
|
time,
|
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
if (parallel && workers.length > 0) {
|
2026-08-24 15:17:53 +02:00
|
|
|
RenderProtocol.writeCamera(cam, camera, time)
|
|
|
|
|
RenderProtocol.writeViewProjection(vp, viewProj)
|
2026-08-05 09:24:13 +02:00
|
|
|
const count = Math.min(visible.length, vis.length)
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
vis[i] = visible[i]
|
|
|
|
|
}
|
2026-08-24 15:17:53 +02:00
|
|
|
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)
|
2026-08-05 09:24:13 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 15:17:53 +02:00
|
|
|
renderInline(pending)
|
|
|
|
|
pending = null
|
2026-08-05 09:24:13 +02:00
|
|
|
},
|
|
|
|
|
done() {
|
2026-08-24 15:17:53 +02:00
|
|
|
const complete =
|
|
|
|
|
!(parallel && workers.length > 0) ||
|
|
|
|
|
Atomics.load(ctrl, RenderProtocol.DONE) >= workers.length
|
|
|
|
|
if (complete) {
|
|
|
|
|
pending = null
|
|
|
|
|
}
|
|
|
|
|
return complete
|
2026-08-05 09:24:13 +02:00
|
|
|
},
|
|
|
|
|
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" &&
|
2026-08-24 15:17:53 +02:00
|
|
|
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated ===
|
|
|
|
|
true
|
2026-08-05 09:24:13 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
function shared(buffer: ArrayBufferLike): SharedArrayBuffer {
|
|
|
|
|
if (!(buffer instanceof SharedArrayBuffer)) {
|
|
|
|
|
throw new Error("render worker buffer is not shared")
|
|
|
|
|
}
|
|
|
|
|
return buffer
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
/** 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. */
|
2026-08-24 15:17:53 +02:00
|
|
|
function splitBands(
|
|
|
|
|
height: number,
|
|
|
|
|
count: number,
|
|
|
|
|
step: number,
|
|
|
|
|
): [number, number][] {
|
2026-08-05 09:24:13 +02:00
|
|
|
const bands: [number, number][] = []
|
|
|
|
|
const per = Math.ceil(height / count)
|
|
|
|
|
let y = 0
|
|
|
|
|
while (y < height) {
|
|
|
|
|
const raw = y + per
|
2026-08-24 15:17:53 +02:00
|
|
|
const y1 =
|
|
|
|
|
raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step)
|
2026-08-05 09:24:13 +02:00
|
|
|
bands.push([y, y1])
|
|
|
|
|
y = y1
|
|
|
|
|
}
|
|
|
|
|
return bands
|
|
|
|
|
}
|