refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

View file

@ -2,8 +2,8 @@ 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 { MOB_KINDS } from "../game/actors/Mob"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
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. */
@ -19,12 +19,6 @@ const ENABLE_WORKERS = true
* 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
const MOBVIS = 3 // number of visible mobs 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;
@ -41,18 +35,37 @@ export type Renderer = {
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, mobDraws: MobDraw[]) => void
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
}
export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer {
const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
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 maxMobs = Math.max(1, scene.mobCount)
const maxInstances = Math.max(1, scene.maxInstances)
let config = initial
const want = forceWorkers ?? ENABLE_WORKERS
let parallel = want && canShare()
@ -63,58 +76,116 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
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 mob: Float32Array<ArrayBufferLike> = new Float32Array(0) // visible mob transforms (MOB_FLOATS each)
let instanceIds: Int32Array<ArrayBufferLike> = new Int32Array(0)
let instanceTransforms: Float32Array<ArrayBufferLike> = new Float32Array(0)
let times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
let lastWork = 0
let generation = 0
let pending: PendingFrame | null = null
function setup(): void {
for (const w of workers) {
w.terminate()
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)) }
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))
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))
mob = new Float32Array(new SharedArrayBuffer(maxMobs * MOB_FLOATS * 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", () => {
parallel = false
})
worker.postMessage({
colorSAB: fb.color.buffer,
depthSAB: fb.depth.buffer,
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: ctrl.buffer,
camSAB: cam.buffer,
vpSAB: vp.buffer,
visSAB: vis.buffer,
mobSAB: mob.buffer,
timesSAB: times.buffer,
index,
})
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
for (const w of workers) {
w.terminate()
}
workers = []
stopWorkers()
}
}
if (!parallel || workers.length === 0) {
@ -134,44 +205,44 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
config = next
setup()
},
dispatch(camera, viewProj, visible, time, mobDraws) {
dispatch(camera, viewProj, visible, time, instances) {
pending = {
camera,
viewProjection: viewProj,
visibleChunks: visible,
instances,
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)
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 mobCount = Math.min(mobDraws.length, maxMobs)
for (let i = 0; i < mobCount; i++) {
const d = mobDraws[i]
const o = i * MOB_FLOATS
mob[o] = MOB_KINDS.indexOf(d.kind)
mob[o + 1] = d.x
mob[o + 2] = d.y
mob[o + 3] = d.z
mob[o + 4] = d.heading
mob[o + 5] = d.scale
}
Atomics.store(ctrl, VIS, count)
Atomics.store(ctrl, MOBVIS, mobCount)
Atomics.store(ctrl, DONE, 0)
Atomics.add(ctrl, FRAME, 1)
Atomics.notify(ctrl, FRAME, workers.length)
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
}
const t0 = performance.now()
renderBand(fb, scene, camera, viewProj, visible, mobDraws, config, SKY_STEP, time, 0, fb.height)
lastWork = performance.now() - t0
renderInline(pending)
pending = null
},
done() {
return !(parallel && workers.length > 0) || Atomics.load(ctrl, DONE) >= workers.length
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) {
@ -194,20 +265,33 @@ function canShare(): boolean {
return (
typeof SharedArrayBuffer !== "undefined" &&
typeof Worker !== "undefined" &&
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true
(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][] {
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)
const y1 =
raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step)
bands.push([y, y1])
y = y1
}