meat/app/main.ts
2026-08-24 15:54:44 +02:00

364 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
const fpsEl = document.querySelector<HTMLDivElement>("#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 ~40100) 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<void> {
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<string>()
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<typeof createRenderer>,
level: ReturnType<typeof buildLevel>,
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<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"),
}
}
main().catch((error) => {
console.error(error)
})