feat: threads
This commit is contained in:
parent
6fb46573da
commit
46e7070b6b
18 changed files with 878 additions and 125 deletions
285
app/main.ts
285
app/main.ts
|
|
@ -1,59 +1,109 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import { Frustum } from "../engine/render/Frustum"
|
||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Sky } from "../engine/render/Sky"
|
||||
import { Camera } from "../engine/scene/Camera"
|
||||
import { Sprite } from "../engine/scene/Sprite"
|
||||
import { loadTextures } from "./assets"
|
||||
import { buildLevel } from "./level"
|
||||
import { EYE_HEIGHT, Player } from "./player"
|
||||
import { createRenderer } from "./renderer"
|
||||
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
|
||||
|
||||
const FOV = Math.PI / 3
|
||||
/** Sky is drawn at 1/SKY_STEP resolution (the cloud fbm is the costly part). */
|
||||
const SKY_STEP = 2
|
||||
|
||||
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||
const ctx = screen.getContext("2d")!
|
||||
const back = document.createElement("canvas")
|
||||
const backCtx = back.getContext("2d")!
|
||||
const fpsEl = document.querySelector<HTMLDivElement>("#fps")!
|
||||
|
||||
let config: RenderConfig = RenderConfig.standard
|
||||
let fb = Framebuffer.create(1, 1)
|
||||
let image = new ImageData(1, 1)
|
||||
|
||||
function useConfig(next: RenderConfig): void {
|
||||
config = next
|
||||
fb = Framebuffer.create(config.internalWidth, config.internalHeight)
|
||||
back.width = fb.width
|
||||
back.height = fb.height
|
||||
image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height)
|
||||
/** 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 resize(): void {
|
||||
screen.width = globalThis.innerWidth
|
||||
screen.height = globalThis.innerHeight
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
backCtx.putImageData(image, 0, 0)
|
||||
const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height)))
|
||||
const w = fb.width * scale
|
||||
const h = fb.height * scale
|
||||
const x = (screen.width - w) >> 1
|
||||
const y = (screen.height - h) >> 1
|
||||
ctx.imageSmoothingEnabled = config.upscaleFilter === "linear"
|
||||
ctx.clearRect(0, 0, screen.width, screen.height)
|
||||
ctx.drawImage(back, x, y, w, h)
|
||||
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()
|
||||
const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc }
|
||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
||||
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 } },
|
||||
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.chunks, present, benchMode)
|
||||
return
|
||||
}
|
||||
|
||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
||||
const keys = new Set<string>()
|
||||
globalThis.addEventListener("keydown", (e) => {
|
||||
keys.add(e.code)
|
||||
|
|
@ -86,56 +136,161 @@ async function main(): Promise<void> {
|
|||
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
|
||||
})
|
||||
|
||||
useConfig(config)
|
||||
globalThis.addEventListener("resize", resize)
|
||||
resize()
|
||||
// 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]
|
||||
t += c.grass.indices.length + c.flowers.indices.length
|
||||
const far = chunkFar(c, cam.position, config.lodDistance)
|
||||
t += far
|
||||
? c.barkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length
|
||||
: c.bark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.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
|
||||
function frame(now: number): void {
|
||||
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
|
||||
fpsFrames++
|
||||
if (now - fpsLast >= 250) {
|
||||
fpsEl.textContent = `${Math.round((fpsFrames * 1000) / (now - fpsLast))} fps`
|
||||
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
|
||||
}
|
||||
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, fb.width / fb.height)
|
||||
|
||||
Sky.render(fb, camera, level.sky, now / 1000, SKY_STEP)
|
||||
// Room is small and always near where you play; draw it unconditionally.
|
||||
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
|
||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
||||
// Outdoor world: skip whole chunks that fall outside the view frustum.
|
||||
const frustum = Frustum.fromViewProj(viewProj)
|
||||
for (const c of level.chunks) {
|
||||
if (!Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
|
||||
continue
|
||||
}
|
||||
Rasterizer.draw(fb, c.grass, textures.grass, viewProj, config, true)
|
||||
Rasterizer.draw(fb, c.rock, textures.rock, viewProj, config, true)
|
||||
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
|
||||
Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true)
|
||||
Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true)
|
||||
Rasterizer.draw(fb, c.flowers, textures.flower, viewProj, config)
|
||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||
const visible = visibleChunks(level.chunks, viewProj)
|
||||
lastVisible = visible
|
||||
lastCamera = camera
|
||||
renderer.dispatch(camera, viewProj, visible, now / 1000)
|
||||
inFlight = true
|
||||
if (renderer.done()) {
|
||||
show()
|
||||
inFlight = false
|
||||
}
|
||||
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
|
||||
Framebuffer.quantize(fb, config)
|
||||
present()
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
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>,
|
||||
chunks: Scene["chunks"],
|
||||
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
|
||||
}
|
||||
}
|
||||
const camera = benchCamera(i / 60)
|
||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||
const visible = visibleChunks(chunks, viewProj)
|
||||
renderer.dispatch(camera, viewProj, visible, i / 60)
|
||||
inFlight = true
|
||||
if (renderer.done() && record()) {
|
||||
report()
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue