feat: threads
This commit is contained in:
parent
6fb46573da
commit
46e7070b6b
18 changed files with 878 additions and 125 deletions
119
AGENTS.md
119
AGENTS.md
|
|
@ -32,7 +32,10 @@ rules live in `.agents/rules/*.md`.
|
|||
## Stack & tooling
|
||||
|
||||
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict,
|
||||
`moduleResolution: bundler`. **Vite 8** serves/builds the client.
|
||||
`moduleResolution: bundler`. **Vite 8** serves/builds the client (ES-module
|
||||
workers; dev/preview serve COOP/COEP headers so the multi-threaded renderer's
|
||||
`SharedArrayBuffer` works — a static prod host must send them too, or the app
|
||||
falls back to single-threaded).
|
||||
- **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier).
|
||||
- commitlint + husky (Conventional Commits), OpenSpec change workflow,
|
||||
forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source.
|
||||
|
|
@ -42,6 +45,9 @@ rules live in `.agents/rules/*.md`.
|
|||
- `bun start` — Vite dev server; open the printed URL. Edits hot-reload.
|
||||
- `bun run build` — production build to `dist/`.
|
||||
- `bun run assets` — regenerate the placeholder PNGs in `/assets`.
|
||||
- `bun run bench:browser` — Playwright: drive headless Chromium through the
|
||||
`?bench=st`/`?bench=mt` flythrough, print single-thread vs worker frame timings
|
||||
(median/p95/max). The real-browser profiler; run it on the target machine.
|
||||
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
|
||||
this**, not `bun run check` (see Caveats).
|
||||
- `bunx oxlint engine app` — lint.
|
||||
|
|
@ -73,7 +79,19 @@ rules live in `.agents/rules/*.md`.
|
|||
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
||||
samples a 2x2 color-atlas texture, drawn double-sided).
|
||||
- `app/` — browser glue.
|
||||
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
|
||||
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
||||
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS
|
||||
upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync
|
||||
interval, visible chunks / LOD-aware tri count — the real profiler in play).
|
||||
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it
|
||||
runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a
|
||||
`SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a
|
||||
lock-free `Atomics` barrier; otherwise it renders inline. `dispatch`/`done`
|
||||
are non-blocking so the caller paces on rAF. `?bench=st|mt` A/Bs the paths.
|
||||
- `renderScene.ts` — `renderBand(fb, scene, …, y0, y1)`: the single source of
|
||||
render truth (sky + room + culled chunks + sprite + quantize for a row band).
|
||||
Used full-height by the inline path, per-band by each worker. `Scene` bundles
|
||||
the meshes/textures so it clones to a worker whole.
|
||||
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
||||
are already the `Color` layout).
|
||||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
||||
|
|
@ -84,7 +102,10 @@ rules live in `.agents/rules/*.md`.
|
|||
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
|
||||
per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that
|
||||
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
|
||||
(drawn double-sided). `Aabb` colliders (walls, crate, grown trunks, big
|
||||
(drawn double-sided). Trees + boulders are baked **twice** — full geometry and
|
||||
a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders'
|
||||
`lod` arg) — so a far chunk can swap to the cheap set with no per-frame work
|
||||
(see `chunkFar` / `RenderConfig.lodDistance`). `Aabb` colliders (walls, crate, grown trunks, big
|
||||
boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
|
||||
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
||||
are single flat quads -- no subdivision needed since texturing is
|
||||
|
|
@ -104,16 +125,36 @@ rules live in `.agents/rules/*.md`.
|
|||
filenames are the contract.
|
||||
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
||||
|
||||
## Frame pipeline (`app/main.ts` `frame`)
|
||||
## Frame pipeline (`app/main.ts` `tick`)
|
||||
|
||||
`Player.update` → build `Camera` → `Camera.viewProjection` →
|
||||
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
|
||||
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
||||
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
|
||||
bark, leaf, needle (backface-culled) + flowers (double-sided) →
|
||||
`Sprite.billboard(npc)` (double-sided) →
|
||||
`Framebuffer.quantize` → `present` (integer-scale, letterboxed blit;
|
||||
`imageSmoothingEnabled` follows `upscaleFilter`).
|
||||
`Player.update` → build `Camera` → `Camera.viewProjection` → `visibleChunks`
|
||||
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
|
||||
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
|
||||
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
|
||||
desync from rAF.
|
||||
|
||||
**`present` is a GPU/CSS upscale, not a CPU blit.** The `#screen` canvas backing
|
||||
store *is* the internal render resolution, so `present` is one internal-res
|
||||
`putImageData` (viewport-independent, ~fixed cost). The browser compositor scales
|
||||
the element to the display via CSS — `layout()` sets the element's pixel size to
|
||||
an integer multiple (crisp letterbox, centered) once per resize/config, and
|
||||
`image-rendering` follows `upscaleFilter` (`pixelated` for `nearest`, `auto` for
|
||||
`linear`). This replaced a per-frame main-thread `drawImage` that scaled to the
|
||||
whole window (cost grew with window size); present is now ~0.2ms.
|
||||
|
||||
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at
|
||||
1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
|
||||
floor/walls/crate (room, always) → for each visible `Chunk` draw grass, then —
|
||||
per chunk via the pure `chunkFar` test (dist² from camera to the chunk AABB vs
|
||||
`lodDistance²`) — either the full rock/bark/leaf/needle + flowers, or the cheap
|
||||
`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped).
|
||||
All backface-culled except double-sided flowers → `Sprite.billboard(npc)`
|
||||
→ `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
|
||||
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
|
||||
Multi-threaded: N workers each run `renderBand` over
|
||||
their band of the shared framebuffer in parallel; single-threaded: one call over
|
||||
the full height. Bands are disjoint (no two workers touch a pixel) and their
|
||||
interior edges snap to `SKY_STEP` so the sky's block grid stays seamless.
|
||||
|
||||
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
|
||||
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
||||
|
|
@ -130,8 +171,13 @@ front-out — a culled mesh that renders inside-out has its index order flipped
|
|||
`standard` (384×216, the startup default), `soft`, `clean`, plus an unbound
|
||||
`ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
|
||||
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
|
||||
`vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always
|
||||
perspective-correct — the affine-swim dial was removed.)
|
||||
`vertexSnap`, `textureFilter`, `lighting`, `fog`, `lodDistance`. (Texturing is
|
||||
always perspective-correct — the affine-swim dial was removed.)
|
||||
- **`RenderConfig.lodDistance`** — beyond this many world units, a chunk's trees
|
||||
+ boulders draw as cheap impostors (see Performance). ~60 for standard/soft/ps1
|
||||
(well inside `fog.far`, so far detail is already fog-dimmed at the switch),
|
||||
`Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop),
|
||||
raise it for more far detail (more tris).
|
||||
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
||||
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
||||
(there are no mipmaps); higher = finer but shimmerier.
|
||||
|
|
@ -150,18 +196,45 @@ off-screen or fogged each frame, so several things keep it cheap:
|
|||
(terrain, foliage, rock). See the Rasterizer note re winding.
|
||||
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
|
||||
pixel and dominated the frame; sampling once per 2×2 block quarters it.
|
||||
- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) —
|
||||
past `lodDistance` a chunk's trees + boulders swap to pre-baked low-poly
|
||||
impostors and its bushes/flowers drop; both meshes are baked once at load, and
|
||||
the near/far pick is a pure function of camera + chunk bounds, so it costs
|
||||
nothing per frame and stays worker-safe (no seam). In a dense forest view this
|
||||
is what tips the per-frame work under the 16.67ms (60fps) vsync budget — it cut
|
||||
~1.4x of the triangles in-forest and drops far-tree/rock detail that fog is
|
||||
already dimming anyway.
|
||||
- **Flat geometry + zero-alloc raster** — `Mesh` is a flat float array and the
|
||||
whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes
|
||||
(measured). This buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x)
|
||||
and makes geometry shareable for Web-Worker rasterization later. Note it did
|
||||
**not** raise mean fps — allocation was never the bottleneck (JSC collects the
|
||||
churn ~free); the mean is the transform+fill **compute**.
|
||||
(measured). Buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) and
|
||||
makes geometry shareable across worker threads. It did **not** raise mean fps —
|
||||
allocation was never the bottleneck; the mean is the transform+fill **compute**.
|
||||
- **Multi-threaded rasterization** (`renderer.ts` + `render-worker.ts`) — split the
|
||||
framebuffer into row bands, one worker each, over a `SharedArrayBuffer`. The
|
||||
barrier is **lock-free**: workers `Atomics.wait` on a frame counter (no per-frame
|
||||
messages), main writes camera/matrix/visible-list into shared arrays, `dispatch`
|
||||
is non-blocking, and `main` polls `done()` on its rAF and presents the finished
|
||||
frame (present frame N, dispatch N+1). Measured real-browser (`bun run
|
||||
bench:browser`): **~1.5x median AND ~1.2x p95** vs single-thread, frame time
|
||||
pinned near vsync. The gotcha is **oversubscription** — too many workers (main +
|
||||
browser + OS competing) wrecks the p95 tail even as the median improves (6
|
||||
workers were far worse than 3); `MAX_WORKERS` caps it, retune per machine.
|
||||
Requires a cross-origin-isolated page (COOP/COEP; Vite serves them) — else it
|
||||
falls back to single-thread, so the app never breaks.
|
||||
|
||||
Frustum + backface + half-res sky give ~1.5–2x, growing with content since culled
|
||||
chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so
|
||||
the mean-fps levers left are to **do less** (LOD / impostors for far trees —
|
||||
`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores**
|
||||
(Web-Worker banded rasterization, now unblocked by the flat geometry).
|
||||
Frustum + backface + half-res sky give ~1.5–2x, workers ~1.5x more, distance LOD
|
||||
another ~1.4x of the tris in dense views. Together they get the heavy in-forest
|
||||
view (the worst case) under the 60fps vsync budget on the worker path; the
|
||||
single-thread fallback still lands ~30fps there. The blunt content dials if it
|
||||
still lags are `TREE_COUNT`/`BOULDER_COUNT` (less world) and `lodDistance` (more
|
||||
aggressive impostor swap).
|
||||
|
||||
**Profiling**: `bun run bench:browser` (Playwright) starts Vite, drives headless
|
||||
Chromium through `?bench=st` and `?bench=mt` (a scripted flythrough with a fixed
|
||||
camera path), and prints median/p95/max **work time** (critical-path band time)
|
||||
and **frame time** for both. Headless absolute fps ≠ a real display, but the
|
||||
single-vs-workers *relative* result and the *tail* (p95/max = jitter) are real —
|
||||
that's how the worker path was actually validated instead of guessed.
|
||||
|
||||
## Clouds
|
||||
|
||||
|
|
|
|||
20
app/level.ts
20
app/level.ts
|
|
@ -39,6 +39,13 @@ export type Chunk = {
|
|||
rock: Mesh
|
||||
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
|
||||
flowers: Mesh
|
||||
/** Cheap low-poly impostors of the same trees/boulders, drawn instead of the
|
||||
* full meshes once the chunk is past `config.lodDistance` (renderScene). Same
|
||||
* textures as bark/leaf/needle/rock. Bushes/flowers have no far version. */
|
||||
barkFar: Mesh
|
||||
leafFar: Mesh
|
||||
needleFar: Mesh
|
||||
rockFar: Mesh
|
||||
}
|
||||
|
||||
/** The playground: a flat-floored room dropped into the center of a big open
|
||||
|
|
@ -215,17 +222,26 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
|
|||
const needle = mesh()
|
||||
const rock = mesh()
|
||||
const flowerMesh = mesh()
|
||||
const barkFar = mesh()
|
||||
const leafFar = mesh()
|
||||
const needleFar = mesh()
|
||||
const rockFar = mesh()
|
||||
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
||||
for (const tree of trees) {
|
||||
if (inCell(tree.position, x0, z0, x1, z1)) {
|
||||
Tree.build(tree, bark, tree.kind === "oak" ? leaf : needle)
|
||||
const foliage = tree.kind === "oak" ? leaf : needle
|
||||
const foliageFar = tree.kind === "oak" ? leafFar : needleFar
|
||||
Tree.build(tree, bark, foliage)
|
||||
Tree.build(tree, barkFar, foliageFar, "impostor")
|
||||
}
|
||||
}
|
||||
for (const boulder of boulders) {
|
||||
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
||||
Boulder.build(boulder, rock)
|
||||
Boulder.build(boulder, rockFar, "impostor")
|
||||
}
|
||||
}
|
||||
// Bushes share the near leaf mesh; they just drop out past lodDistance.
|
||||
for (const bush of bushes) {
|
||||
if (inCell(bush.position, x0, z0, x1, z1)) {
|
||||
Bush.build(bush, leaf)
|
||||
|
|
@ -240,7 +256,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
|
|||
if (b === null) {
|
||||
continue
|
||||
}
|
||||
chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh })
|
||||
chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar })
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
|
|
|
|||
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) => {
|
||||
|
|
|
|||
61
app/render-worker.ts
Normal file
61
app/render-worker.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { renderBand, type Scene } from "./renderScene"
|
||||
|
||||
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
||||
* scene, this worker's row band, and its index into the per-worker times array. */
|
||||
type Init = {
|
||||
colorSAB: SharedArrayBuffer
|
||||
depthSAB: SharedArrayBuffer
|
||||
width: number
|
||||
height: number
|
||||
scene: Scene
|
||||
band: [number, number]
|
||||
config: RenderConfig
|
||||
skyStep: number
|
||||
ctrlSAB: SharedArrayBuffer
|
||||
camSAB: SharedArrayBuffer
|
||||
vpSAB: SharedArrayBuffer
|
||||
visSAB: SharedArrayBuffer
|
||||
timesSAB: SharedArrayBuffer
|
||||
index: number
|
||||
}
|
||||
|
||||
const FRAME = 0
|
||||
const DONE = 1
|
||||
const VIS = 2
|
||||
|
||||
const ctx = globalThis as unknown as {
|
||||
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
|
||||
}
|
||||
|
||||
ctx.addEventListener("message", (e) => {
|
||||
const m = e.data
|
||||
const fb: Framebuffer = {
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
color: new Uint32Array(m.colorSAB),
|
||||
depth: new Float32Array(m.depthSAB),
|
||||
}
|
||||
const ctrl = new Int32Array(m.ctrlSAB)
|
||||
const cam = new Float64Array(m.camSAB)
|
||||
const vp = new Float32Array(m.vpSAB)
|
||||
const vis = new Int32Array(m.visSAB)
|
||||
const times = new Float64Array(m.timesSAB)
|
||||
const { scene, band, config, skyStep, index } = m
|
||||
|
||||
// Lock-free frame loop: block until main bumps the frame counter, render this
|
||||
// band, record the band time, and signal done. No messages per frame.
|
||||
let last = 0
|
||||
for (;;) {
|
||||
Atomics.wait(ctrl, FRAME, last)
|
||||
last = Atomics.load(ctrl, FRAME)
|
||||
const t0 = performance.now()
|
||||
const camera = { position: { x: cam[0], y: cam[1], z: cam[2] }, yaw: cam[3], pitch: cam[4], fov: cam[5] }
|
||||
const count = Atomics.load(ctrl, VIS)
|
||||
const visible = [...vis.subarray(0, count)]
|
||||
renderBand(fb, scene, camera, vp, visible, config, skyStep, cam[6], band[0], band[1])
|
||||
times[index] = performance.now() - t0
|
||||
Atomics.add(ctrl, DONE, 1)
|
||||
}
|
||||
})
|
||||
102
app/renderScene.ts
Normal file
102
app/renderScene.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import { Frustum } from "../engine/render/Frustum"
|
||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Sky, type SkyConfig } from "../engine/render/Sky"
|
||||
import type { Camera } from "../engine/scene/Camera"
|
||||
import type { Mat4 } from "../engine/math/Mat4"
|
||||
import type { Mesh } from "../engine/scene/Mesh"
|
||||
import { Sprite } from "../engine/scene/Sprite"
|
||||
import type { Vec2 } from "../engine/math/Vec2"
|
||||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Textures } from "./assets"
|
||||
import type { Chunk } from "./level"
|
||||
|
||||
/** Everything needed to render the world: the room, the cullable chunks, the NPC
|
||||
* billboard source, sky, and textures. Bundled so it can be handed to a worker
|
||||
* whole (it is plain data + typed arrays, structured-clone friendly). */
|
||||
export type Scene = {
|
||||
chunks: Chunk[]
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
npc: { position: Vec3; size: Vec2 }
|
||||
sky: SkyConfig
|
||||
textures: Textures
|
||||
}
|
||||
|
||||
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
|
||||
* the main thread and shared with every worker (so they don't each re-cull). */
|
||||
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
|
||||
const frustum = Frustum.fromViewProj(viewProj)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const c = chunks[i]
|
||||
if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
|
||||
out.push(i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Render rows [y0, y1) of one frame into `fb`. This is the single source of
|
||||
* render truth: the single-thread path calls it with the full height, and each
|
||||
* worker calls it with its own disjoint band -- same output either way, and no
|
||||
* two bands touch the same pixel (so the shared framebuffer needs no locking).
|
||||
*/
|
||||
export function renderBand(
|
||||
fb: Framebuffer,
|
||||
scene: Scene,
|
||||
camera: Camera,
|
||||
viewProj: Mat4,
|
||||
visible: number[],
|
||||
config: RenderConfig,
|
||||
skyStep: number,
|
||||
time: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
const tx = scene.textures
|
||||
Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1)
|
||||
// Room: small and always near, drawn unconditionally (double-sided).
|
||||
Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1)
|
||||
Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1)
|
||||
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
|
||||
for (const i of visible) {
|
||||
const c = scene.chunks[i]
|
||||
Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1)
|
||||
// Past lodDistance, swap full tree/boulder geometry for cheap impostors.
|
||||
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band
|
||||
// makes the identical choice -- no full/impostor seam across bands.
|
||||
if (chunkFar(c, camera.position, config.lodDistance)) {
|
||||
Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1)
|
||||
} else {
|
||||
Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1)
|
||||
Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1)
|
||||
}
|
||||
}
|
||||
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
|
||||
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
|
||||
Framebuffer.quantize(fb, config, y0, y1)
|
||||
}
|
||||
|
||||
/** Whether a chunk is far enough to draw its impostor meshes: squared distance
|
||||
* from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on
|
||||
* camera, the chunk's baked bounds, and the config constant, all of which every
|
||||
* worker already holds, so the choice is identical across bands. */
|
||||
export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
|
||||
if (!(lodDistance < Infinity)) {
|
||||
return false
|
||||
}
|
||||
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
|
||||
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
|
||||
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
|
||||
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
|
||||
}
|
||||
196
app/renderer.ts
Normal file
196
app/renderer.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import type { Mat4 } from "../engine/math/Mat4"
|
||||
import type { Camera } from "../engine/scene/Camera"
|
||||
import { renderBand, type Scene } from "./renderScene"
|
||||
|
||||
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
||||
const SKY_STEP = 2
|
||||
/** Use worker threads when the page can share memory (else single-thread). */
|
||||
const ENABLE_WORKERS = true
|
||||
/** Cap on render worker threads. Deliberately low: a browser game shares the
|
||||
* machine with the browser itself, the compositor, and whatever else is open, so
|
||||
* grabbing every core backfires -- the frame-time *tail* blows up even while the
|
||||
* median improves. Measured (`bun run bench:browser`) on a busy 16-core desktop:
|
||||
* 3 workers beat single-thread on both median (~1.5x) and p95 (~1.2x); 6 were far
|
||||
* worse. Raise only if the target is a dedicated/idle machine; retune via the
|
||||
* bench. */
|
||||
const MAX_WORKERS = 3
|
||||
|
||||
// Indices into the shared control Int32Array.
|
||||
const FRAME = 0 // bumped by main to dispatch a frame
|
||||
const DONE = 1 // workers add 1 when their band is finished
|
||||
const VIS = 2 // number of visible chunk indices this frame
|
||||
|
||||
/**
|
||||
* Render driver. When the page is cross-origin-isolated it runs a pool of worker
|
||||
* threads, each owning a disjoint row band of a `SharedArrayBuffer` framebuffer;
|
||||
* otherwise it renders inline on the main thread. Same output either way.
|
||||
*
|
||||
* The worker barrier is lock-free: workers `Atomics.wait` on a frame counter, so
|
||||
* there are **no per-frame messages** (the old postMessage barrier was the jitter
|
||||
* source). Per-frame inputs (camera, matrix, visible list) live in shared arrays.
|
||||
* `dispatch` starts a frame without blocking; the caller polls `done()` and
|
||||
* presents when ready, so pacing stays on the caller's `requestAnimationFrame`.
|
||||
*/
|
||||
export type Renderer = {
|
||||
readonly fb: Framebuffer
|
||||
readonly parallel: boolean
|
||||
reconfigure: (config: RenderConfig) => void
|
||||
/** Start rendering one frame (non-blocking in the worker path). */
|
||||
dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number) => void
|
||||
/** Has the dispatched frame finished? (always true single-threaded.) */
|
||||
done: () => boolean
|
||||
/** Critical-path render time of the last frame, ms (max band time / inline time). */
|
||||
workMs: () => number
|
||||
}
|
||||
|
||||
export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer {
|
||||
const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
|
||||
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
|
||||
const maxVis = Math.max(1, scene.chunks.length)
|
||||
let config = initial
|
||||
const want = forceWorkers ?? ENABLE_WORKERS
|
||||
let parallel = want && canShare()
|
||||
|
||||
let fb = Framebuffer.create(1, 1)
|
||||
let workers: Worker[] = []
|
||||
let ctrl: Int32Array<ArrayBufferLike> = new Int32Array(0)
|
||||
let cam: Float64Array<ArrayBufferLike> = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time
|
||||
let vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
|
||||
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
|
||||
let times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
|
||||
let lastWork = 0
|
||||
|
||||
function setup(): void {
|
||||
for (const w of workers) {
|
||||
w.terminate()
|
||||
}
|
||||
workers = []
|
||||
const width = config.internalWidth
|
||||
const height = config.internalHeight
|
||||
if (parallel) {
|
||||
const n = width * height
|
||||
fb = { width, height, color: new Uint32Array(new SharedArrayBuffer(n * 4)), depth: new Float32Array(new SharedArrayBuffer(n * 4)) }
|
||||
const bands = splitBands(height, workerCount, SKY_STEP)
|
||||
ctrl = new Int32Array(new SharedArrayBuffer(4 * 4))
|
||||
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
|
||||
vp = new Float32Array(new SharedArrayBuffer(16 * 4))
|
||||
vis = new Int32Array(new SharedArrayBuffer(maxVis * 4))
|
||||
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
|
||||
try {
|
||||
bands.forEach((band, index) => {
|
||||
const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" })
|
||||
worker.addEventListener("error", () => {
|
||||
parallel = false
|
||||
})
|
||||
worker.postMessage({
|
||||
colorSAB: fb.color.buffer,
|
||||
depthSAB: fb.depth.buffer,
|
||||
width,
|
||||
height,
|
||||
scene,
|
||||
band,
|
||||
config,
|
||||
skyStep: SKY_STEP,
|
||||
ctrlSAB: ctrl.buffer,
|
||||
camSAB: cam.buffer,
|
||||
vpSAB: vp.buffer,
|
||||
visSAB: vis.buffer,
|
||||
timesSAB: times.buffer,
|
||||
index,
|
||||
})
|
||||
workers.push(worker)
|
||||
})
|
||||
} catch {
|
||||
parallel = false
|
||||
for (const w of workers) {
|
||||
w.terminate()
|
||||
}
|
||||
workers = []
|
||||
}
|
||||
}
|
||||
if (!parallel || workers.length === 0) {
|
||||
fb = Framebuffer.create(width, height)
|
||||
}
|
||||
}
|
||||
setup()
|
||||
|
||||
return {
|
||||
get fb() {
|
||||
return fb
|
||||
},
|
||||
get parallel() {
|
||||
return parallel && workers.length > 0
|
||||
},
|
||||
reconfigure(next) {
|
||||
config = next
|
||||
setup()
|
||||
},
|
||||
dispatch(camera, viewProj, visible, time) {
|
||||
if (parallel && workers.length > 0) {
|
||||
cam[0] = camera.position.x
|
||||
cam[1] = camera.position.y
|
||||
cam[2] = camera.position.z
|
||||
cam[3] = camera.yaw
|
||||
cam[4] = camera.pitch
|
||||
cam[5] = camera.fov
|
||||
cam[6] = time
|
||||
vp.set(viewProj)
|
||||
const count = Math.min(visible.length, vis.length)
|
||||
for (let i = 0; i < count; i++) {
|
||||
vis[i] = visible[i]
|
||||
}
|
||||
Atomics.store(ctrl, VIS, count)
|
||||
Atomics.store(ctrl, DONE, 0)
|
||||
Atomics.add(ctrl, FRAME, 1)
|
||||
Atomics.notify(ctrl, FRAME, workers.length)
|
||||
return
|
||||
}
|
||||
const t0 = performance.now()
|
||||
renderBand(fb, scene, camera, viewProj, visible, config, SKY_STEP, time, 0, fb.height)
|
||||
lastWork = performance.now() - t0
|
||||
},
|
||||
done() {
|
||||
return !(parallel && workers.length > 0) || Atomics.load(ctrl, DONE) >= workers.length
|
||||
},
|
||||
workMs() {
|
||||
if (parallel && workers.length > 0) {
|
||||
let m = 0
|
||||
for (let i = 0; i < workers.length; i++) {
|
||||
if (times[i] > m) {
|
||||
m = times[i]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
return lastWork
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared memory needs SharedArrayBuffer + a cross-origin-isolated page (the
|
||||
* COOP/COEP headers Vite serves). Without it, render on the main thread. */
|
||||
function canShare(): boolean {
|
||||
return (
|
||||
typeof SharedArrayBuffer !== "undefined" &&
|
||||
typeof Worker !== "undefined" &&
|
||||
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Split `height` rows into ~`count` bands. Interior boundaries snap up to a
|
||||
* multiple of `step` so the sky's block grid stays aligned (no seam), while the
|
||||
* bands stay disjoint so no two workers write the same pixel. */
|
||||
function splitBands(height: number, count: number, step: number): [number, number][] {
|
||||
const bands: [number, number][] = []
|
||||
const per = Math.ceil(height / count)
|
||||
let y = 0
|
||||
while (y < height) {
|
||||
const raw = y + per
|
||||
const y1 = raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step)
|
||||
bands.push([y, y1])
|
||||
y = y1
|
||||
}
|
||||
return bands
|
||||
}
|
||||
9
bun.lock
9
bun.lock
|
|
@ -11,6 +11,7 @@
|
|||
"husky": "^9.1.7",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"playwright": "^1.62.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
},
|
||||
|
|
@ -277,7 +278,7 @@
|
|||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
|
|
@ -355,6 +356,10 @@
|
|||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
|
@ -396,5 +401,7 @@
|
|||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,13 +40,13 @@ export namespace Framebuffer {
|
|||
* 15-bit output. Skipped entirely when it would be a no-op (full depth, no
|
||||
* dither).
|
||||
*/
|
||||
export function quantize(fb: Framebuffer, config: RenderConfig): void {
|
||||
export function quantize(fb: Framebuffer, config: RenderConfig, y0 = 0, y1 = fb.height): void {
|
||||
const levels = (1 << config.colorDepth) - 1
|
||||
if (levels >= 255 && config.dither === 0) {
|
||||
return
|
||||
}
|
||||
const { width, height, color } = fb
|
||||
for (let y = 0; y < height; y++) {
|
||||
const { width, color } = fb
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
|
||||
// scaled by strength, nudges each channel before it snaps to a level.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export namespace Rasterizer {
|
|||
viewProj: Mat4,
|
||||
config: RenderConfig,
|
||||
cull = false,
|
||||
clipY0 = 0,
|
||||
clipY1 = 1 << 30,
|
||||
): void {
|
||||
const { verts, indices } = mesh
|
||||
const flat = config.lighting === "flat"
|
||||
|
|
@ -65,7 +67,7 @@ export namespace Rasterizer {
|
|||
// Near-clipping can turn one triangle into a quad; fan it back to tris.
|
||||
const n = clipNear(3)
|
||||
for (let k = 1; k + 1 < n; k++) {
|
||||
fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull)
|
||||
fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull, clipY0, clipY1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -167,6 +169,8 @@ export namespace Rasterizer {
|
|||
texture: Texture,
|
||||
config: RenderConfig,
|
||||
cull: boolean,
|
||||
clipY0: number,
|
||||
clipY1: number,
|
||||
): void {
|
||||
const oa = ia * CLIP
|
||||
const ob = ib * CLIP
|
||||
|
|
@ -210,8 +214,10 @@ export namespace Rasterizer {
|
|||
const vC = dst[oc + 4]
|
||||
const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC)))
|
||||
const maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC)))
|
||||
const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC)))
|
||||
const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC)))
|
||||
// Clamp to the caller's Y-band (default full frame) so worker threads can
|
||||
// each fill a disjoint slice of rows without ever touching the same pixel.
|
||||
const minY = Math.max(0, clipY0, Math.floor(Math.min(syA, syB, syC)))
|
||||
const maxY = Math.min(height - 1, clipY1 - 1, Math.ceil(Math.max(syA, syB, syC)))
|
||||
// Edge deltas for the three barycentric edge functions (b->c, c->a, a->b).
|
||||
const dx0 = sxC - sxB
|
||||
const dy0 = syC - syB
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export type RenderConfig = {
|
|||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
||||
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||
fog: Fog | null
|
||||
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
|
||||
* impostors instead of full geometry, cutting per-triangle work in dense
|
||||
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
|
||||
* switch; `Infinity` disables LOD. */
|
||||
lodDistance: number
|
||||
}
|
||||
|
||||
/** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
|
||||
|
|
@ -61,6 +66,7 @@ export namespace RenderConfig {
|
|||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
||||
lodDistance: 60,
|
||||
}
|
||||
|
||||
export const ps1: RenderConfig = {
|
||||
|
|
@ -73,6 +79,7 @@ export namespace RenderConfig {
|
|||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
||||
lodDistance: 60,
|
||||
}
|
||||
|
||||
export const soft: RenderConfig = {
|
||||
|
|
@ -85,6 +92,7 @@ export namespace RenderConfig {
|
|||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
|
||||
lodDistance: 70,
|
||||
}
|
||||
|
||||
export const clean: RenderConfig = {
|
||||
|
|
@ -94,9 +102,9 @@ export namespace RenderConfig {
|
|||
colorDepth: 8,
|
||||
dither: 0,
|
||||
vertexSnap: 0,
|
||||
perspectiveCorrect: 1,
|
||||
textureFilter: "linear",
|
||||
lighting: "flat",
|
||||
fog: null,
|
||||
lodDistance: Infinity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,8 +64,9 @@ export namespace Sky {
|
|||
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
|
||||
* visually and quarters the cloud cost; 1 is full resolution.
|
||||
*/
|
||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1): void {
|
||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
|
||||
const { width, height, color, depth } = fb
|
||||
const bottom = y1 < 0 ? height : y1
|
||||
const forward = Camera.forward(camera)
|
||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||
const up = Vec3.cross(right, forward)
|
||||
|
|
@ -76,11 +77,13 @@ export namespace Sky {
|
|||
const clouds = sky.clouds
|
||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||
const s = Math.max(1, step | 0)
|
||||
for (let by = 0; by < height; by += s) {
|
||||
// Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block
|
||||
// grid stays global and neighboring bands don't seam.
|
||||
for (let by = y0; by < bottom; by += s) {
|
||||
// Shade at the block center, then flood the whole block with that color.
|
||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
||||
const yEnd = Math.min(height, by + s)
|
||||
const yEnd = Math.min(bottom, by + s)
|
||||
for (let bx = 0; bx < width; bx += s) {
|
||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ export type Boulder = {
|
|||
* field of boulders batches into a single draw call.
|
||||
*/
|
||||
export namespace Boulder {
|
||||
export function build(boulder: Boulder, mesh: Mesh): void {
|
||||
/** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */
|
||||
export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void {
|
||||
const rand = rng(boulder.seed)
|
||||
const seg = 5
|
||||
const rings = 4
|
||||
const seg = lod === "impostor" ? 4 : 5
|
||||
const rings = lod === "impostor" ? 2 : 4
|
||||
const r = boulder.radius
|
||||
// Squat and slightly oval, so it reads as a rock, not a ball.
|
||||
const sx = r * (0.8 + rand() * 0.5)
|
||||
|
|
|
|||
|
|
@ -28,24 +28,32 @@ export type Tree = {
|
|||
export namespace Tree {
|
||||
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
|
||||
* for its kind (oak leaf vs spruce needle). */
|
||||
export function build(tree: Tree, trunk: Mesh, foliage: Mesh): void {
|
||||
/** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures +
|
||||
* faceted look, same height/position) for far chunks; "full" is up close. */
|
||||
export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
|
||||
const rand = rng(tree.seed)
|
||||
if (tree.kind === "oak") {
|
||||
oak(tree.position, tree.growth, rand, trunk, foliage)
|
||||
oak(tree.position, tree.growth, rand, trunk, foliage, lod)
|
||||
} else {
|
||||
spruce(tree.position, tree.growth, rand, trunk, foliage)
|
||||
spruce(tree.position, tree.growth, rand, trunk, foliage, lod)
|
||||
}
|
||||
}
|
||||
|
||||
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh): void {
|
||||
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
||||
const h = lerp(0.8, 7, g)
|
||||
const rTrunk = lerp(0.04, 0.32, g)
|
||||
const forkY = base.y + h * 0.5
|
||||
const canopyY = base.y + h * 0.72
|
||||
const blobR = h * 0.3
|
||||
if (lod === "impostor") {
|
||||
// One low-poly blob on a stubby trunk -- reads as an oak at distance.
|
||||
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3)
|
||||
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2)
|
||||
return
|
||||
}
|
||||
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5)
|
||||
|
||||
const blobR = h * 0.3
|
||||
const spread = h * 0.32
|
||||
const canopyY = base.y + h * 0.72
|
||||
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
|
||||
const blobs = 1 + Math.round(g * 2)
|
||||
for (let i = 0; i < blobs; i++) {
|
||||
|
|
@ -72,13 +80,16 @@ export namespace Tree {
|
|||
}
|
||||
}
|
||||
|
||||
function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh): void {
|
||||
function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
|
||||
const h = lerp(0.6, 9, g)
|
||||
const rTrunk = lerp(0.03, 0.2, g)
|
||||
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, 5)
|
||||
const impostor = lod === "impostor"
|
||||
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5)
|
||||
|
||||
// Stacked cones: widest low, shrinking to a point up top -> conical tiers.
|
||||
const tiers = 2 + Math.round(g * 3)
|
||||
// The impostor keeps the first two tiers at low sides (same seed => aligned).
|
||||
const tiers = impostor ? 2 : 2 + Math.round(g * 3)
|
||||
const sides = impostor ? 4 : 6
|
||||
const bottom = base.y + h * 0.1
|
||||
const span = h * 0.9
|
||||
for (let i = 0; i < tiers; i++) {
|
||||
|
|
@ -86,7 +97,7 @@ export namespace Tree {
|
|||
const y = bottom + t * span * 0.82
|
||||
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
|
||||
const coneH = (span / tiers) * 1.9
|
||||
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, 6)
|
||||
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +138,7 @@ export namespace Tree {
|
|||
|
||||
/** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps
|
||||
* it organic without cracking the longitude seam. */
|
||||
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void {
|
||||
const seg = 5
|
||||
const rings = 3
|
||||
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void {
|
||||
const start = mesh.verts.length / STRIDE
|
||||
for (let r = 0; r <= rings; r++) {
|
||||
const phi = (r / rings) * Math.PI
|
||||
|
|
|
|||
|
|
@ -13,18 +13,21 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
#screen {
|
||||
display: block;
|
||||
position: absolute;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
#fps {
|
||||
position: fixed;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
font: 16px monospace;
|
||||
font: 13px monospace;
|
||||
white-space: pre;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
opacity: 0.5;
|
||||
padding: 1px 5px;
|
||||
padding: 2px 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ export default defineConfig({
|
|||
"typescript/prefer-function-type": "off",
|
||||
"unicorn/no-process-exit": "off",
|
||||
"unicorn/prefer-string-raw": "off",
|
||||
"unicorn/relative-url-style": "off",
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
"unicorn/text-encoding-identifier-case": "off",
|
||||
},
|
||||
overrides: [
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -12,19 +12,21 @@
|
|||
"url": "git+https://github.com/sigitex/meat.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
"@types/bun": "^1.3.13",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@types/bun": "^1.3.13",
|
||||
"husky": "^9.1.7",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"playwright": "^1.62.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bunx --bun vite --host",
|
||||
"build": "bunx --bun vite build",
|
||||
"assets": "bun scripts/gen-assets.ts",
|
||||
"bench:browser": "bun scripts/bench-browser.ts",
|
||||
"publish:builds": "rsync -avz builds/ sigitex.com:~/meat.errilaz.org/builds",
|
||||
"publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org",
|
||||
"publish": "bun run publish:current && bun run publish:builds",
|
||||
|
|
|
|||
93
scripts/bench-browser.ts
Normal file
93
scripts/bench-browser.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { chromium } from "playwright"
|
||||
|
||||
// Start the Vite dev server (it serves the COOP/COEP headers needed for
|
||||
// SharedArrayBuffer), drive real headless Chromium through both bench modes, and
|
||||
// read the timings back. Real V8 + Web Workers + SAB + rAF -- the environment my
|
||||
// headless Bun benches can't see.
|
||||
|
||||
const PORT = 5199
|
||||
const URL = `http://localhost:${PORT}`
|
||||
|
||||
const vite = Bun.spawn(["bunx", "--bun", "vite", "--port", String(PORT), "--strictPort"], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, NO_COLOR: "1" },
|
||||
})
|
||||
|
||||
async function waitForServer(): Promise<void> {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
try {
|
||||
const r = await fetch(URL + "/")
|
||||
if (r.ok) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await Bun.sleep(200)
|
||||
}
|
||||
throw new Error("vite dev server did not start")
|
||||
}
|
||||
|
||||
type Bench = {
|
||||
mode: string
|
||||
parallel: boolean
|
||||
cores: number
|
||||
coi: boolean
|
||||
res: string
|
||||
workMs: { median: number; p95: number; max: number; mean: number }
|
||||
frameMs: { median: number; p95: number; max: number; mean: number }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await waitForServer()
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--enable-features=SharedArrayBuffer",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
],
|
||||
})
|
||||
|
||||
async function run(mode: string): Promise<Bench> {
|
||||
const page = await browser.newPage()
|
||||
page.on("pageerror", (e) => console.log(` [page error] ${e.message}`))
|
||||
page.on("console", (m) => {
|
||||
const t = m.text()
|
||||
if (t.startsWith("BENCH") || m.type() === "error") {
|
||||
console.log(` [console] ${t}`)
|
||||
}
|
||||
})
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await page.goto(`${URL}/?bench=${mode}`, { waitUntil: "load" })
|
||||
await page.waitForFunction("window.__BENCH__ !== undefined", null, { timeout: 120000 })
|
||||
const result = (await page.evaluate("window.__BENCH__")) as Bench
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
console.log("--- single-thread ---")
|
||||
const st = await run("st")
|
||||
console.log("--- workers ---")
|
||||
const mt = await run("mt")
|
||||
await browser.close()
|
||||
|
||||
const line = (b: Bench) =>
|
||||
`parallel=${b.parallel} coi=${b.coi} cores=${b.cores} res=${b.res} work med/p95/max = ${b.workMs.median}/${b.workMs.p95}/${b.workMs.max} ms frame med/p95 = ${b.frameMs.median}/${b.frameMs.p95} ms`
|
||||
console.log("")
|
||||
console.log(`single-thread : ${line(st)}`)
|
||||
console.log(`workers : ${line(mt)}`)
|
||||
console.log("")
|
||||
console.log(`work median : ${st.workMs.median} -> ${mt.workMs.median} ms (${(st.workMs.median / mt.workMs.median).toFixed(2)}x)`)
|
||||
console.log(`work p95 : ${st.workMs.p95} -> ${mt.workMs.p95} ms (${(st.workMs.p95 / mt.workMs.p95).toFixed(2)}x)`)
|
||||
console.log(`frame p95 : ${st.frameMs.p95} -> ${mt.frameMs.p95} ms (jitter: lower p95/median = steadier)`)
|
||||
}
|
||||
|
||||
try {
|
||||
await main()
|
||||
} finally {
|
||||
vite.kill()
|
||||
}
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
import { defineConfig } from "vite"
|
||||
|
||||
// Cross-origin isolation (COOP + COEP) is required for SharedArrayBuffer, which
|
||||
// the multi-threaded renderer uses to share the framebuffer across workers.
|
||||
// Without these headers the app still runs -- it falls back to single-threaded.
|
||||
const crossOriginIsolation = {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
server: {
|
||||
headers: crossOriginIsolation,
|
||||
},
|
||||
preview: {
|
||||
headers: crossOriginIsolation,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue