import { RenderConfig } from "../engine/render/RenderConfig" import { RenderScene, type RenderInstance } from "../engine/render/RenderScene" import { Camera } from "../engine/scene/Camera" import { CharacterController, type CharacterInput, } from "../engine/world/CharacterController" import { Level } from "../engine/world/Level" import { loadTextures } from "./assets" import { buildLevel } from "../game/level" import { Player, type Player as PlayerState } from "../game/player" import { createRenderer } from "./renderer" const FOV_DEGREES = 75 const FOV = (FOV_DEGREES * Math.PI) / 180 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(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(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) // 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) inFlight = false retarget() } function present(): void { image.data.set(colorBytes) ctx.putImageData(image, 0, 0) } globalThis.addEventListener("resize", layout) if (benchMode) { runBench(renderer, level, present, benchMode) return } const player: PlayerState = Player.create() 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[], 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 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 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() 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, lastInstances, lastCamera)} tris` fpsLast = now fpsFrames = 0 workMax = 0 presentMax = 0 vsyncMax = 0 } 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 + 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 = RenderScene.visibleChunks(level.render, viewProj) const instances = Level.visibleInstances(level, viewProj) lastVisible = visible lastInstances = instances lastCamera = camera renderer.dispatch(camera, viewProj, visible, now / 1000, instances) 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, level: ReturnType, 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 } } Level.update(level, 1 / 60) const camera = benchCamera(i / 60) 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() } } requestAnimationFrame(tick) } function readPlayerInput(keys: Set): 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"), } } main().catch((error) => { console.error(error) })