import { RenderConfig } from "../engine/render/RenderConfig" import { Camera } from "../engine/scene/Camera" 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 const screen = document.querySelector("#screen")! const ctx = screen.getContext("2d")! const fpsEl = document.querySelector("#fps")! /** 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 round2(n: number): number { return Math.round(n * 100) / 100 } 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 { const textures = await loadTextures() const level = buildLevel() 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() globalThis.addEventListener("keydown", (e) => { keys.add(e.code) if (e.code === "Digit1") { useConfig(RenderConfig.standard) } if (e.code === "Digit2") { useConfig(RenderConfig.soft) } if (e.code === "Digit3") { useConfig(RenderConfig.clean) } }) globalThis.addEventListener("keyup", (e) => { keys.delete(e.code) }) // Losing focus (alt-tab, pointer-lock exit) drops keyup events, so clear held // keys or movement sticks on. globalThis.addEventListener("blur", () => { keys.clear() }) screen.addEventListener("click", () => { screen.requestPointerLock() }) globalThis.addEventListener("mousemove", (e) => { if (document.pointerLockElement !== screen) { return } player.yaw += e.movementX * 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] 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 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 if (now - fpsLast >= 250) { 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, 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 } } 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, 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) => { console.error(error) })