2026-08-04 02:09:54 +02:00
|
|
|
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
2026-08-24 15:17:53 +02:00
|
|
|
|
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
|
2026-08-04 02:09:54 +02:00
|
|
|
|
import { Camera } from "../engine/scene/Camera"
|
2026-08-24 15:17:53 +02:00
|
|
|
|
import {
|
|
|
|
|
|
CharacterController,
|
|
|
|
|
|
type CharacterInput,
|
|
|
|
|
|
} from "../engine/world/CharacterController"
|
|
|
|
|
|
import { Level } from "../engine/world/Level"
|
2026-08-04 02:09:54 +02:00
|
|
|
|
import { loadTextures } from "./assets"
|
2026-08-24 15:17:53 +02:00
|
|
|
|
import { buildLevel } from "../game/level"
|
|
|
|
|
|
import { Player, type Player as PlayerState } from "../game/player"
|
2026-08-05 09:24:13 +02:00
|
|
|
|
import { createRenderer } from "./renderer"
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
2026-08-07 13:37:30 +02:00
|
|
|
|
const FOV_DEGREES = 75
|
|
|
|
|
|
const FOV = (FOV_DEGREES * Math.PI) / 180
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
|
|
|
|
|
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
|
|
|
|
|
const ctx = screen.getContext("2d")!
|
2026-08-04 03:00:58 +02:00
|
|
|
|
const fpsEl = document.querySelector<HTMLDivElement>("#fps")!
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
/** 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 {
|
2026-08-24 15:17:53 +02:00
|
|
|
|
position: {
|
|
|
|
|
|
x: Math.cos(drift) * radius,
|
|
|
|
|
|
y: 3,
|
|
|
|
|
|
z: Math.sin(drift) * radius,
|
|
|
|
|
|
},
|
2026-08-05 09:24:13 +02:00
|
|
|
|
yaw: tv * 0.7,
|
|
|
|
|
|
pitch: 0.05 * Math.sin(tv * 0.5),
|
|
|
|
|
|
fov: FOV,
|
|
|
|
|
|
}
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
function round2(n: number): number {
|
|
|
|
|
|
return Math.round(n * 100) / 100
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
function benchStats(a: number[]): {
|
|
|
|
|
|
median: number
|
|
|
|
|
|
p95: number
|
|
|
|
|
|
max: number
|
|
|
|
|
|
mean: number
|
|
|
|
|
|
} {
|
2026-08-05 09:24:13 +02:00
|
|
|
|
const s = a.toSorted((x, y) => x - y)
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
|
|
|
const textures = await loadTextures()
|
2026-08-07 16:58:24 +02:00
|
|
|
|
const level = buildLevel(textures)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
// `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings.
|
|
|
|
|
|
const benchMode = new URLSearchParams(globalThis.location.search).get("bench")
|
2026-08-24 15:17:53 +02:00
|
|
|
|
const forceWorkers =
|
|
|
|
|
|
benchMode === "mt" ? true : benchMode === "st" ? false : undefined
|
2026-08-05 09:24:13 +02:00
|
|
|
|
|
|
|
|
|
|
let config: RenderConfig = RenderConfig.standard
|
2026-08-24 15:17:53 +02:00
|
|
|
|
const renderer = createRenderer(level.render, config, forceWorkers)
|
|
|
|
|
|
let inFlight = false
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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
|
2026-08-24 15:17:53 +02:00
|
|
|
|
const scale = Math.max(
|
|
|
|
|
|
1,
|
|
|
|
|
|
Math.floor(
|
|
|
|
|
|
Math.min(
|
|
|
|
|
|
globalThis.innerWidth / fb.width,
|
|
|
|
|
|
globalThis.innerHeight / fb.height,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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`
|
2026-08-24 15:17:53 +02:00
|
|
|
|
screen.style.imageRendering =
|
|
|
|
|
|
config.upscaleFilter === "linear" ? "auto" : "pixelated"
|
2026-08-05 09:24:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
retarget()
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
function useConfig(next: RenderConfig): void {
|
|
|
|
|
|
config = next
|
|
|
|
|
|
renderer.reconfigure(next)
|
2026-08-24 15:17:53 +02:00
|
|
|
|
inFlight = false
|
2026-08-05 09:24:13 +02:00
|
|
|
|
retarget()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function present(): void {
|
|
|
|
|
|
image.data.set(colorBytes)
|
|
|
|
|
|
ctx.putImageData(image, 0, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
globalThis.addEventListener("resize", layout)
|
|
|
|
|
|
|
|
|
|
|
|
if (benchMode) {
|
2026-08-07 12:49:13 +02:00
|
|
|
|
runBench(renderer, level, present, benchMode)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
const player: PlayerState = Player.create()
|
2026-08-04 02:09:54 +02:00
|
|
|
|
const keys = new Set<string>()
|
|
|
|
|
|
globalThis.addEventListener("keydown", (e) => {
|
|
|
|
|
|
keys.add(e.code)
|
|
|
|
|
|
if (e.code === "Digit1") {
|
2026-08-04 02:39:20 +02:00
|
|
|
|
useConfig(RenderConfig.standard)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (e.code === "Digit2") {
|
|
|
|
|
|
useConfig(RenderConfig.soft)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.code === "Digit3") {
|
|
|
|
|
|
useConfig(RenderConfig.clean)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
globalThis.addEventListener("keyup", (e) => {
|
|
|
|
|
|
keys.delete(e.code)
|
|
|
|
|
|
})
|
2026-08-04 11:39:39 +02:00
|
|
|
|
// Losing focus (alt-tab, pointer-lock exit) drops keyup events, so clear held
|
|
|
|
|
|
// keys or movement sticks on.
|
|
|
|
|
|
globalThis.addEventListener("blur", () => {
|
|
|
|
|
|
keys.clear()
|
|
|
|
|
|
})
|
2026-08-04 02:09:54 +02:00
|
|
|
|
screen.addEventListener("click", () => {
|
|
|
|
|
|
screen.requestPointerLock()
|
|
|
|
|
|
})
|
|
|
|
|
|
globalThis.addEventListener("mousemove", (e) => {
|
|
|
|
|
|
if (document.pointerLockElement !== screen) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
player.yaw += e.movementX * 0.0025
|
2026-08-24 15:17:53 +02:00
|
|
|
|
player.pitch = Math.max(
|
|
|
|
|
|
-1.4,
|
|
|
|
|
|
Math.min(1.4, player.pitch - e.movementY * 0.0025),
|
|
|
|
|
|
)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
// Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
function frameTris(
|
|
|
|
|
|
visible: number[],
|
|
|
|
|
|
instances: RenderInstance[],
|
|
|
|
|
|
camera: Camera,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
return RenderScene.triangleCount(
|
|
|
|
|
|
level.render,
|
|
|
|
|
|
visible,
|
|
|
|
|
|
instances,
|
|
|
|
|
|
camera.position,
|
|
|
|
|
|
config.lodDistance,
|
|
|
|
|
|
)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
}
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
// 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.
|
2026-08-04 02:09:54 +02:00
|
|
|
|
let last = performance.now()
|
2026-08-04 03:00:58 +02:00
|
|
|
|
let fpsLast = last
|
|
|
|
|
|
let fpsFrames = 0
|
2026-08-05 09:24:13 +02:00
|
|
|
|
let workMax = 0
|
|
|
|
|
|
let presentMax = 0
|
|
|
|
|
|
let vsyncMax = 0
|
|
|
|
|
|
let lastPresent = performance.now()
|
|
|
|
|
|
let lastVisible: number[] = []
|
2026-08-24 15:17:53 +02:00
|
|
|
|
let lastInstances: RenderInstance[] = []
|
|
|
|
|
|
let lastCamera: Camera = {
|
|
|
|
|
|
position: { x: 0, y: 0, z: 0 },
|
|
|
|
|
|
yaw: 0,
|
|
|
|
|
|
pitch: 0,
|
|
|
|
|
|
fov: FOV,
|
|
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-08-04 02:09:54 +02:00
|
|
|
|
const dt = Math.min(0.05, (now - last) / 1000)
|
|
|
|
|
|
last = now
|
2026-08-04 03:00:58 +02:00
|
|
|
|
if (now - fpsLast >= 250) {
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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` +
|
2026-08-24 15:17:53 +02:00
|
|
|
|
`vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastInstances, lastCamera)} tris`
|
2026-08-04 03:00:58 +02:00
|
|
|
|
fpsLast = now
|
|
|
|
|
|
fpsFrames = 0
|
2026-08-05 09:24:13 +02:00
|
|
|
|
workMax = 0
|
|
|
|
|
|
presentMax = 0
|
|
|
|
|
|
vsyncMax = 0
|
2026-08-04 03:00:58 +02:00
|
|
|
|
}
|
2026-08-24 15:17:53 +02:00
|
|
|
|
Level.update(level, dt)
|
|
|
|
|
|
Level.refreshActorColliders(level, player.position, Player.actorCollisionRange)
|
|
|
|
|
|
CharacterController.update(
|
|
|
|
|
|
player,
|
|
|
|
|
|
readPlayerInput(keys),
|
|
|
|
|
|
dt,
|
|
|
|
|
|
level.collision,
|
|
|
|
|
|
Player.config,
|
|
|
|
|
|
)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
const camera: Camera = {
|
2026-08-24 15:17:53 +02:00
|
|
|
|
position: {
|
|
|
|
|
|
x: player.position.x,
|
|
|
|
|
|
y: player.position.y + Player.config.eyeHeight,
|
|
|
|
|
|
z: player.position.z,
|
|
|
|
|
|
},
|
2026-08-04 02:09:54 +02:00
|
|
|
|
yaw: player.yaw,
|
|
|
|
|
|
pitch: player.pitch,
|
|
|
|
|
|
fov: FOV,
|
|
|
|
|
|
}
|
2026-08-24 15:17:53 +02:00
|
|
|
|
const viewProj = Camera.viewProjection(
|
|
|
|
|
|
camera,
|
|
|
|
|
|
renderer.fb.width / renderer.fb.height,
|
|
|
|
|
|
)
|
|
|
|
|
|
const visible = RenderScene.visibleChunks(level.render, viewProj)
|
|
|
|
|
|
const instances = Level.visibleInstances(level, viewProj)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
lastVisible = visible
|
2026-08-24 15:17:53 +02:00
|
|
|
|
lastInstances = instances
|
2026-08-05 09:24:13 +02:00
|
|
|
|
lastCamera = camera
|
2026-08-24 15:17:53 +02:00
|
|
|
|
renderer.dispatch(camera, viewProj, visible, now / 1000, instances)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
inFlight = true
|
|
|
|
|
|
if (renderer.done()) {
|
|
|
|
|
|
show()
|
|
|
|
|
|
inFlight = false
|
2026-08-04 15:05:02 +02:00
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
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>,
|
2026-08-24 15:17:53 +02:00
|
|
|
|
level: ReturnType<typeof buildLevel>,
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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 {
|
2026-08-04 02:09:54 +02:00
|
|
|
|
present()
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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,
|
2026-08-24 15:17:53 +02:00
|
|
|
|
coi:
|
|
|
|
|
|
(globalThis as { crossOriginIsolated?: boolean })
|
|
|
|
|
|
.crossOriginIsolated === true,
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-24 15:17:53 +02:00
|
|
|
|
Level.update(level, 1 / 60)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
const camera = benchCamera(i / 60)
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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)
|
2026-08-05 09:24:13 +02:00
|
|
|
|
inFlight = true
|
|
|
|
|
|
if (renderer.done() && record()) {
|
|
|
|
|
|
report()
|
|
|
|
|
|
}
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
|
requestAnimationFrame(tick)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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"),
|
2026-08-07 12:49:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 02:09:54 +02:00
|
|
|
|
main().catch((error) => {
|
|
|
|
|
|
console.error(error)
|
|
|
|
|
|
})
|