import { RenderConfig } from "../engine/render/RenderConfig" 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 { loadTextures } from "./assets" import { buildLevel, type Level } from "../game/level" import { EYE_HEIGHT, Player } 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("#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) // 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 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 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, present, benchMode) 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 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] 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 } // 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 } for (const m of level.mobs) { Mob.update(m, dt, level.terrain) } rebuildMobColliders(level, player.position, staticColliderCount) 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) const mobDraws = visibleMobs(level.mobs, viewProj) lastVisible = visible lastCamera = camera renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws) 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: Level, 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 } } for (const m of level.mobs) { Mob.update(m, 1 / 60, level.terrain) } 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) inFlight = true if (renderer.done() && record()) { report() } } 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, }) } } main().catch((error) => { console.error(error) })