refactor: move world concepts into engine
This commit is contained in:
parent
eeedcb8e48
commit
2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions
205
app/main.ts
205
app/main.ts
|
|
@ -1,20 +1,18 @@
|
|||
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
||||
import { Camera } from "../engine/scene/Camera"
|
||||
import type { Mesh } from "../engine/scene/Mesh"
|
||||
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import {
|
||||
CharacterController,
|
||||
type CharacterInput,
|
||||
} from "../engine/world/CharacterController"
|
||||
import { Level } from "../engine/world/Level"
|
||||
import { loadTextures } from "./assets"
|
||||
import { buildLevel, type Level } from "../game/level"
|
||||
import { EYE_HEIGHT, Player } from "../game/player"
|
||||
import { buildLevel } from "../game/level"
|
||||
import { Player, type Player as PlayerState } from "../game/player"
|
||||
import { createRenderer } from "./renderer"
|
||||
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"
|
||||
|
||||
const FOV_DEGREES = 75
|
||||
const FOV = (FOV_DEGREES * Math.PI) / 180
|
||||
/** How close (world units) a mob must be to the player to get a live collider.
|
||||
* Mobs farther than this can't be touched this frame, so skip them -- keeps the
|
||||
* per-frame collider list (and the player's collision loop) short. */
|
||||
const MOB_COLLIDE_RANGE = 3
|
||||
|
||||
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||
const ctx = screen.getContext("2d")!
|
||||
|
|
@ -28,7 +26,11 @@ 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 },
|
||||
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,
|
||||
|
|
@ -39,41 +41,34 @@ function round2(n: number): number {
|
|||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
|
||||
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) }
|
||||
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(textures)
|
||||
// Build each kind's canonical mesh once, shared by every instance (the sim
|
||||
// supplies each mob's per-frame transform). Registry-driven -- a new kind needs
|
||||
// no change here.
|
||||
const mobMesh = {} as Record<MobKind, Mesh>
|
||||
for (const kind of MOB_KINDS) {
|
||||
const m: Mesh = { verts: [], indices: [] }
|
||||
Mob.build(kind, m)
|
||||
mobMesh[kind] = m
|
||||
}
|
||||
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 } },
|
||||
mobMesh,
|
||||
mobCount: level.mobs.length,
|
||||
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
|
||||
const forceWorkers =
|
||||
benchMode === "mt" ? true : benchMode === "st" ? false : undefined
|
||||
|
||||
let config: RenderConfig = RenderConfig.standard
|
||||
const renderer = createRenderer(scene, config, forceWorkers)
|
||||
const renderer = createRenderer(level.render, config, forceWorkers)
|
||||
let inFlight = false
|
||||
let image = new ImageData(renderer.fb.width, renderer.fb.height)
|
||||
let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer)
|
||||
|
||||
|
|
@ -93,20 +88,30 @@ async function main(): Promise<void> {
|
|||
// 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 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"
|
||||
screen.style.imageRendering =
|
||||
config.upscaleFilter === "linear" ? "auto" : "pixelated"
|
||||
}
|
||||
retarget()
|
||||
|
||||
function useConfig(next: RenderConfig): void {
|
||||
config = next
|
||||
renderer.reconfigure(next)
|
||||
inFlight = false
|
||||
retarget()
|
||||
}
|
||||
|
||||
|
|
@ -122,9 +127,7 @@ async function main(): Promise<void> {
|
|||
return
|
||||
}
|
||||
|
||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
||||
// Colliders past this index are the dynamic mob ones, rebuilt every frame.
|
||||
const staticColliderCount = level.colliders.length
|
||||
const player: PlayerState = Player.create()
|
||||
const keys = new Set<string>()
|
||||
globalThis.addEventListener("keydown", (e) => {
|
||||
keys.add(e.code)
|
||||
|
|
@ -154,26 +157,30 @@ async function main(): Promise<void> {
|
|||
return
|
||||
}
|
||||
player.yaw += e.movementX * 0.0025
|
||||
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
|
||||
player.pitch = Math.max(
|
||||
-1.4,
|
||||
Math.min(1.4, player.pitch - e.movementY * 0.0025),
|
||||
)
|
||||
})
|
||||
|
||||
// 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]
|
||||
const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
|
||||
for (const g of groups) {
|
||||
t += g.mesh.indices.length
|
||||
}
|
||||
}
|
||||
return (t / 3) | 0
|
||||
function frameTris(
|
||||
visible: number[],
|
||||
instances: RenderInstance[],
|
||||
camera: Camera,
|
||||
): number {
|
||||
return RenderScene.triangleCount(
|
||||
level.render,
|
||||
visible,
|
||||
instances,
|
||||
camera.position,
|
||||
config.lodDistance,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -182,7 +189,13 @@ async function main(): Promise<void> {
|
|||
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 }
|
||||
let lastInstances: RenderInstance[] = []
|
||||
let lastCamera: Camera = {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fov: FOV,
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
const p0 = performance.now()
|
||||
|
|
@ -213,30 +226,42 @@ async function main(): Promise<void> {
|
|||
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`
|
||||
`vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastInstances, lastCamera)} tris`
|
||||
fpsLast = now
|
||||
fpsFrames = 0
|
||||
workMax = 0
|
||||
presentMax = 0
|
||||
vsyncMax = 0
|
||||
}
|
||||
for (const m of level.mobs) {
|
||||
Mob.update(m, dt, level.terrain)
|
||||
}
|
||||
rebuildMobColliders(level, player.position, staticColliderCount)
|
||||
Player.update(player, keys, dt, level)
|
||||
Level.update(level, dt)
|
||||
Level.refreshActorColliders(level, player.position, Player.actorCollisionRange)
|
||||
CharacterController.update(
|
||||
player,
|
||||
readPlayerInput(keys),
|
||||
dt,
|
||||
level.collision,
|
||||
Player.config,
|
||||
)
|
||||
const camera: Camera = {
|
||||
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
|
||||
position: {
|
||||
x: player.position.x,
|
||||
y: player.position.y + Player.config.eyeHeight,
|
||||
z: player.position.z,
|
||||
},
|
||||
yaw: player.yaw,
|
||||
pitch: player.pitch,
|
||||
fov: FOV,
|
||||
}
|
||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||
const visible = visibleChunks(level.chunks, viewProj)
|
||||
const mobDraws = visibleMobs(level.mobs, viewProj)
|
||||
const viewProj = Camera.viewProjection(
|
||||
camera,
|
||||
renderer.fb.width / renderer.fb.height,
|
||||
)
|
||||
const visible = RenderScene.visibleChunks(level.render, viewProj)
|
||||
const instances = Level.visibleInstances(level, viewProj)
|
||||
lastVisible = visible
|
||||
lastInstances = instances
|
||||
lastCamera = camera
|
||||
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
|
||||
renderer.dispatch(camera, viewProj, visible, now / 1000, instances)
|
||||
inFlight = true
|
||||
if (renderer.done()) {
|
||||
show()
|
||||
|
|
@ -250,7 +275,7 @@ async function main(): Promise<void> {
|
|||
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
||||
function runBench(
|
||||
renderer: ReturnType<typeof createRenderer>,
|
||||
level: Level,
|
||||
level: ReturnType<typeof buildLevel>,
|
||||
present: () => void,
|
||||
mode: string,
|
||||
): void {
|
||||
|
|
@ -282,7 +307,9 @@ function runBench(
|
|||
mode,
|
||||
parallel: renderer.parallel,
|
||||
cores: (globalThis.navigator as Navigator).hardwareConcurrency,
|
||||
coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true,
|
||||
coi:
|
||||
(globalThis as { crossOriginIsolated?: boolean })
|
||||
.crossOriginIsolated === true,
|
||||
res: `${renderer.fb.width}x${renderer.fb.height}`,
|
||||
workMs: benchStats(work),
|
||||
frameMs: benchStats(frame),
|
||||
|
|
@ -306,14 +333,15 @@ function runBench(
|
|||
return
|
||||
}
|
||||
}
|
||||
for (const m of level.mobs) {
|
||||
Mob.update(m, 1 / 60, level.terrain)
|
||||
}
|
||||
Level.update(level, 1 / 60)
|
||||
const camera = benchCamera(i / 60)
|
||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||
const visible = visibleChunks(level.chunks, viewProj)
|
||||
const mobDraws = visibleMobs(level.mobs, viewProj)
|
||||
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
|
||||
const viewProj = Camera.viewProjection(
|
||||
camera,
|
||||
renderer.fb.width / renderer.fb.height,
|
||||
)
|
||||
const visible = RenderScene.visibleChunks(level.render, viewProj)
|
||||
const instances = Level.visibleInstances(level, viewProj)
|
||||
renderer.dispatch(camera, viewProj, visible, i / 60, instances)
|
||||
inFlight = true
|
||||
if (renderer.done() && record()) {
|
||||
report()
|
||||
|
|
@ -322,29 +350,12 @@ function runBench(
|
|||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
|
||||
* a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
|
||||
* baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched
|
||||
* robin); bees and airborne birds still block but never make a mid-air platform.
|
||||
* Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be reached this
|
||||
* frame anyway. */
|
||||
function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void {
|
||||
level.colliders.length = staticCount
|
||||
for (const m of level.mobs) {
|
||||
const dx = m.position.x - playerPos.x
|
||||
const dz = m.position.z - playerPos.z
|
||||
if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) {
|
||||
continue
|
||||
}
|
||||
const half = Mob.boundingRadius(m.kind) * m.scale * 0.7
|
||||
level.colliders.push({
|
||||
minX: m.position.x - half,
|
||||
maxX: m.position.x + half,
|
||||
minZ: m.position.z - half,
|
||||
maxZ: m.position.z + half,
|
||||
top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
|
||||
standable: m.kind !== "bee" && m.grounded,
|
||||
})
|
||||
function readPlayerInput(keys: Set<string>): CharacterInput {
|
||||
return {
|
||||
forward: (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0),
|
||||
right: (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0),
|
||||
jump: keys.has("Space"),
|
||||
run: keys.has("ShiftLeft") || keys.has("ShiftRight"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,12 @@
|
|||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { MOB_KINDS } from "../game/actors/Mob"
|
||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/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
|
||||
mobSAB: SharedArrayBuffer
|
||||
timesSAB: SharedArrayBuffer
|
||||
index: number
|
||||
}
|
||||
|
||||
const FRAME = 0
|
||||
const DONE = 1
|
||||
const VIS = 2
|
||||
const MOBVIS = 3
|
||||
import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol"
|
||||
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
||||
|
||||
const ctx = globalThis as unknown as {
|
||||
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
|
||||
addEventListener: (
|
||||
type: "message",
|
||||
handler: (e: { data: RenderWorkerInit }) => void,
|
||||
) => void
|
||||
}
|
||||
|
||||
ctx.addEventListener("message", (e) => {
|
||||
|
|
@ -41,31 +18,46 @@ ctx.addEventListener("message", (e) => {
|
|||
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 mob = new Float32Array(m.mobSAB)
|
||||
const cameraData = new Float64Array(m.cameraSAB)
|
||||
const viewProjection = new Float32Array(m.viewProjectionSAB)
|
||||
const visibleChunks = new Int32Array(m.visibleChunkSAB)
|
||||
const instanceIds = new Int32Array(m.instanceIdSAB)
|
||||
const instanceTransforms = new Float32Array(m.instanceTransformSAB)
|
||||
const times = new Float64Array(m.timesSAB)
|
||||
const { scene, band, config, skyStep, index } = m
|
||||
const { scene, band, config, skyStep, workerIndex } = m
|
||||
const instances: RenderInstance[] = []
|
||||
|
||||
// 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)
|
||||
Atomics.wait(ctrl, RenderProtocol.FRAME, last)
|
||||
last = Atomics.load(ctrl, RenderProtocol.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)]
|
||||
const mobCount = Atomics.load(ctrl, MOBVIS)
|
||||
const mobDraws: MobDraw[] = []
|
||||
for (let i = 0; i < mobCount; i++) {
|
||||
const o = i * MOB_FLOATS
|
||||
mobDraws.push({ kind: MOB_KINDS[mob[o]] ?? "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] })
|
||||
}
|
||||
renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1])
|
||||
times[index] = performance.now() - t0
|
||||
Atomics.add(ctrl, DONE, 1)
|
||||
const frameCamera = RenderProtocol.readCamera(cameraData)
|
||||
const count = Atomics.load(ctrl, RenderProtocol.VISIBLE_CHUNKS)
|
||||
const visible = [...visibleChunks.subarray(0, count)]
|
||||
const instanceCount = Atomics.load(ctrl, RenderProtocol.VISIBLE_INSTANCES)
|
||||
RenderProtocol.readInstances(
|
||||
instanceIds,
|
||||
instanceTransforms,
|
||||
instanceCount,
|
||||
instances,
|
||||
)
|
||||
RenderScene.renderBand(
|
||||
fb,
|
||||
scene,
|
||||
frameCamera.camera,
|
||||
viewProjection,
|
||||
visible,
|
||||
instances,
|
||||
config,
|
||||
skyStep,
|
||||
frameCamera.time,
|
||||
band[0],
|
||||
band[1],
|
||||
)
|
||||
times[workerIndex] = performance.now() - t0
|
||||
Atomics.add(ctrl, RenderProtocol.DONE, 1)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
228
app/renderer.ts
228
app/renderer.ts
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue