refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

View file

@ -1,20 +1,18 @@
import { RenderConfig } from "../engine/render/RenderConfig"
import { RenderScene, type RenderInstance } from "../engine/render/RenderScene"
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 {
CharacterController,
type CharacterInput,
} from "../engine/world/CharacterController"
import { Level } from "../engine/world/Level"
import { loadTextures } from "./assets"
import { buildLevel, type Level } from "../game/level"
import { EYE_HEIGHT, Player } from "../game/player"
import { buildLevel } from "../game/level"
import { Player, type Player as PlayerState } 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<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
@ -28,7 +26,11 @@ 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 },
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,
@ -39,41 +41,34 @@ function round2(n: number): number {
return Math.round(n * 100) / 100
}
function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
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) }
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)
// 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<MobKind, Mesh>
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
const forceWorkers =
benchMode === "mt" ? true : benchMode === "st" ? false : undefined
let config: RenderConfig = RenderConfig.standard
const renderer = createRenderer(scene, config, forceWorkers)
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)
@ -93,20 +88,30 @@ async function main(): Promise<void> {
// 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 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"
screen.style.imageRendering =
config.upscaleFilter === "linear" ? "auto" : "pixelated"
}
retarget()
function useConfig(next: RenderConfig): void {
config = next
renderer.reconfigure(next)
inFlight = false
retarget()
}
@ -122,9 +127,7 @@ async function main(): Promise<void> {
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 player: PlayerState = Player.create()
const keys = new Set<string>()
globalThis.addEventListener("keydown", (e) => {
keys.add(e.code)
@ -154,26 +157,30 @@ async function main(): Promise<void> {
return
}
player.yaw += e.movementX * 0.0025
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 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
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 inFlight = false
let last = performance.now()
let fpsLast = last
let fpsFrames = 0
@ -182,7 +189,13 @@ async function main(): Promise<void> {
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 }
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()
@ -213,30 +226,42 @@ async function main(): Promise<void> {
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`
`vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastInstances, 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)
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 + EYE_HEIGHT, z: player.position.z },
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 = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
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, mobDraws)
renderer.dispatch(camera, viewProj, visible, now / 1000, instances)
inFlight = true
if (renderer.done()) {
show()
@ -250,7 +275,7 @@ async function main(): Promise<void> {
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
function runBench(
renderer: ReturnType<typeof createRenderer>,
level: Level,
level: ReturnType<typeof buildLevel>,
present: () => void,
mode: string,
): void {
@ -282,7 +307,9 @@ function runBench(
mode,
parallel: renderer.parallel,
cores: (globalThis.navigator as Navigator).hardwareConcurrency,
coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true,
coi:
(globalThis as { crossOriginIsolated?: boolean })
.crossOriginIsolated === true,
res: `${renderer.fb.width}x${renderer.fb.height}`,
workMs: benchStats(work),
frameMs: benchStats(frame),
@ -306,14 +333,15 @@ function runBench(
return
}
}
for (const m of level.mobs) {
Mob.update(m, 1 / 60, level.terrain)
}
Level.update(level, 1 / 60)
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)
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()
@ -322,29 +350,12 @@ function runBench(
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,
})
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"),
}
}