feat: threads
This commit is contained in:
parent
6fb46573da
commit
46e7070b6b
18 changed files with 878 additions and 125 deletions
20
app/level.ts
20
app/level.ts
|
|
@ -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
|
||||
|
|
|
|||
285
app/main.ts
285
app/main.ts
|
|
@ -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 ~40–100) 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
61
app/render-worker.ts
Normal 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
102
app/renderScene.ts
Normal 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
196
app/renderer.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue