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
|
## Stack & tooling
|
||||||
|
|
||||||
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict,
|
- **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).
|
- **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier).
|
||||||
- commitlint + husky (Conventional Commits), OpenSpec change workflow,
|
- commitlint + husky (Conventional Commits), OpenSpec change workflow,
|
||||||
forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source.
|
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 start` — Vite dev server; open the printed URL. Edits hot-reload.
|
||||||
- `bun run build` — production build to `dist/`.
|
- `bun run build` — production build to `dist/`.
|
||||||
- `bun run assets` — regenerate the placeholder PNGs in `/assets`.
|
- `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
|
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
|
||||||
this**, not `bun run check` (see Caveats).
|
this**, not `bun run check` (see Caveats).
|
||||||
- `bunx oxlint engine app` — lint.
|
- `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;
|
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
||||||
samples a 2x2 color-atlas texture, drawn double-sided).
|
samples a 2x2 color-atlas texture, drawn double-sided).
|
||||||
- `app/` — browser glue.
|
- `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
|
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
||||||
are already the `Color` layout).
|
are already the `Color` layout).
|
||||||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
- `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 =
|
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
|
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
|
`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
|
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
|
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
||||||
are single flat quads -- no subdivision needed since texturing is
|
are single flat quads -- no subdivision needed since texturing is
|
||||||
|
|
@ -104,16 +125,36 @@ rules live in `.agents/rules/*.md`.
|
||||||
filenames are the contract.
|
filenames are the contract.
|
||||||
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
- `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` →
|
`Player.update` → build `Camera` → `Camera.viewProjection` → `visibleChunks`
|
||||||
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
|
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
|
||||||
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
|
||||||
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
|
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
|
||||||
bark, leaf, needle (backface-culled) + flowers (double-sided) →
|
desync from rAF.
|
||||||
`Sprite.billboard(npc)` (double-sided) →
|
|
||||||
`Framebuffer.quantize` → `present` (integer-scale, letterboxed blit;
|
**`present` is a GPU/CSS upscale, not a CPU blit.** The `#screen` canvas backing
|
||||||
`imageSmoothingEnabled` follows `upscaleFilter`).
|
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**,
|
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
|
||||||
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
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
|
`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.
|
`ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
|
||||||
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
|
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
|
||||||
`vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always
|
`vertexSnap`, `textureFilter`, `lighting`, `fog`, `lodDistance`. (Texturing is
|
||||||
perspective-correct — the affine-swim dial was removed.)
|
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
|
- **`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
|
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
||||||
(there are no mipmaps); higher = finer but shimmerier.
|
(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.
|
(terrain, foliage, rock). See the Rasterizer note re winding.
|
||||||
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
|
- **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.
|
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
|
- **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
|
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)
|
(measured). Buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) and
|
||||||
and makes geometry shareable for Web-Worker rasterization later. Note it did
|
makes geometry shareable across worker threads. It did **not** raise mean fps —
|
||||||
**not** raise mean fps — allocation was never the bottleneck (JSC collects the
|
allocation was never the bottleneck; the mean is the transform+fill **compute**.
|
||||||
churn ~free); 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
|
Frustum + backface + half-res sky give ~1.5–2x, workers ~1.5x more, distance LOD
|
||||||
chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so
|
another ~1.4x of the tris in dense views. Together they get the heavy in-forest
|
||||||
the mean-fps levers left are to **do less** (LOD / impostors for far trees —
|
view (the worst case) under the 60fps vsync budget on the worker path; the
|
||||||
`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores**
|
single-thread fallback still lands ~30fps there. The blunt content dials if it
|
||||||
(Web-Worker banded rasterization, now unblocked by the flat geometry).
|
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
|
## Clouds
|
||||||
|
|
||||||
|
|
|
||||||
20
app/level.ts
20
app/level.ts
|
|
@ -39,6 +39,13 @@ export type Chunk = {
|
||||||
rock: Mesh
|
rock: Mesh
|
||||||
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
|
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
|
||||||
flowers: Mesh
|
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
|
/** 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 needle = mesh()
|
||||||
const rock = mesh()
|
const rock = mesh()
|
||||||
const flowerMesh = 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)
|
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
||||||
for (const tree of trees) {
|
for (const tree of trees) {
|
||||||
if (inCell(tree.position, x0, z0, x1, z1)) {
|
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) {
|
for (const boulder of boulders) {
|
||||||
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
||||||
Boulder.build(boulder, rock)
|
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) {
|
for (const bush of bushes) {
|
||||||
if (inCell(bush.position, x0, z0, x1, z1)) {
|
if (inCell(bush.position, x0, z0, x1, z1)) {
|
||||||
Bush.build(bush, leaf)
|
Bush.build(bush, leaf)
|
||||||
|
|
@ -240,7 +256,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
|
||||||
if (b === null) {
|
if (b === null) {
|
||||||
continue
|
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
|
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 { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Sky } from "../engine/render/Sky"
|
|
||||||
import { Camera } from "../engine/scene/Camera"
|
import { Camera } from "../engine/scene/Camera"
|
||||||
import { Sprite } from "../engine/scene/Sprite"
|
|
||||||
import { loadTextures } from "./assets"
|
import { loadTextures } from "./assets"
|
||||||
import { buildLevel } from "./level"
|
import { buildLevel } from "./level"
|
||||||
import { EYE_HEIGHT, Player } from "./player"
|
import { EYE_HEIGHT, Player } from "./player"
|
||||||
|
import { createRenderer } from "./renderer"
|
||||||
|
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
|
||||||
|
|
||||||
const FOV = Math.PI / 3
|
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 screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||||
const ctx = screen.getContext("2d")!
|
const ctx = screen.getContext("2d")!
|
||||||
const back = document.createElement("canvas")
|
|
||||||
const backCtx = back.getContext("2d")!
|
|
||||||
const fpsEl = document.querySelector<HTMLDivElement>("#fps")!
|
const fpsEl = document.querySelector<HTMLDivElement>("#fps")!
|
||||||
|
|
||||||
let config: RenderConfig = RenderConfig.standard
|
/** Deterministic flythrough (virtual time from frame index), so the st and mt
|
||||||
let fb = Framebuffer.create(1, 1)
|
* bench runs render the exact same work. Deliberately stands *inside* the dense
|
||||||
let image = new ImageData(1, 1)
|
* 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 useConfig(next: RenderConfig): void {
|
function benchCamera(tv: number): Camera {
|
||||||
config = next
|
const drift = tv * 0.12
|
||||||
fb = Framebuffer.create(config.internalWidth, config.internalHeight)
|
const radius = 65 + 30 * Math.sin(tv * 0.25)
|
||||||
back.width = fb.width
|
return {
|
||||||
back.height = fb.height
|
position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius },
|
||||||
image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height)
|
yaw: tv * 0.7,
|
||||||
|
pitch: 0.05 * Math.sin(tv * 0.5),
|
||||||
|
fov: FOV,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(): void {
|
function round2(n: number): number {
|
||||||
screen.width = globalThis.innerWidth
|
return Math.round(n * 100) / 100
|
||||||
screen.height = globalThis.innerHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function present(): void {
|
function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } {
|
||||||
backCtx.putImageData(image, 0, 0)
|
const s = a.toSorted((x, y) => x - y)
|
||||||
const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height)))
|
const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))]
|
||||||
const w = fb.width * scale
|
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 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const textures = await loadTextures()
|
const textures = await loadTextures()
|
||||||
const level = buildLevel()
|
const level = buildLevel()
|
||||||
const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc }
|
const scene: Scene = {
|
||||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
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>()
|
const keys = new Set<string>()
|
||||||
globalThis.addEventListener("keydown", (e) => {
|
globalThis.addEventListener("keydown", (e) => {
|
||||||
keys.add(e.code)
|
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))
|
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
|
||||||
})
|
})
|
||||||
|
|
||||||
useConfig(config)
|
// Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD.
|
||||||
globalThis.addEventListener("resize", resize)
|
function frameTris(visible: number[], cam: Camera): number {
|
||||||
resize()
|
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 last = performance.now()
|
||||||
let fpsLast = last
|
let fpsLast = last
|
||||||
let fpsFrames = 0
|
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)
|
const dt = Math.min(0.05, (now - last) / 1000)
|
||||||
last = now
|
last = now
|
||||||
fpsFrames++
|
|
||||||
if (now - fpsLast >= 250) {
|
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
|
fpsLast = now
|
||||||
fpsFrames = 0
|
fpsFrames = 0
|
||||||
|
workMax = 0
|
||||||
|
presentMax = 0
|
||||||
|
vsyncMax = 0
|
||||||
}
|
}
|
||||||
Player.update(player, keys, dt, level)
|
Player.update(player, keys, dt, level)
|
||||||
|
|
||||||
const camera: Camera = {
|
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 + EYE_HEIGHT, z: player.position.z },
|
||||||
yaw: player.yaw,
|
yaw: player.yaw,
|
||||||
pitch: player.pitch,
|
pitch: player.pitch,
|
||||||
fov: FOV,
|
fov: FOV,
|
||||||
}
|
}
|
||||||
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
|
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||||
|
const visible = visibleChunks(level.chunks, viewProj)
|
||||||
Sky.render(fb, camera, level.sky, now / 1000, SKY_STEP)
|
lastVisible = visible
|
||||||
// Room is small and always near where you play; draw it unconditionally.
|
lastCamera = camera
|
||||||
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
|
renderer.dispatch(camera, viewProj, visible, now / 1000)
|
||||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
inFlight = true
|
||||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
if (renderer.done()) {
|
||||||
// Outdoor world: skip whole chunks that fall outside the view frustum.
|
show()
|
||||||
const frustum = Frustum.fromViewProj(viewProj)
|
inFlight = false
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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) => {
|
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",
|
"husky": "^9.1.7",
|
||||||
"oxfmt": "^0.47.0",
|
"oxfmt": "^0.47.0",
|
||||||
"oxlint": "^1.62.0",
|
"oxlint": "^1.62.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.1.5",
|
"vite": "^8.1.5",
|
||||||
},
|
},
|
||||||
|
|
@ -277,7 +278,7 @@
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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
|
* 15-bit output. Skipped entirely when it would be a no-op (full depth, no
|
||||||
* dither).
|
* 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
|
const levels = (1 << config.colorDepth) - 1
|
||||||
if (levels >= 255 && config.dither === 0) {
|
if (levels >= 255 && config.dither === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { width, height, color } = fb
|
const { width, color } = fb
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = y0; y < y1; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
|
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
|
||||||
// scaled by strength, nudges each channel before it snaps to a level.
|
// scaled by strength, nudges each channel before it snaps to a level.
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ export namespace Rasterizer {
|
||||||
viewProj: Mat4,
|
viewProj: Mat4,
|
||||||
config: RenderConfig,
|
config: RenderConfig,
|
||||||
cull = false,
|
cull = false,
|
||||||
|
clipY0 = 0,
|
||||||
|
clipY1 = 1 << 30,
|
||||||
): void {
|
): void {
|
||||||
const { verts, indices } = mesh
|
const { verts, indices } = mesh
|
||||||
const flat = config.lighting === "flat"
|
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.
|
// Near-clipping can turn one triangle into a quad; fan it back to tris.
|
||||||
const n = clipNear(3)
|
const n = clipNear(3)
|
||||||
for (let k = 1; k + 1 < n; k++) {
|
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,
|
texture: Texture,
|
||||||
config: RenderConfig,
|
config: RenderConfig,
|
||||||
cull: boolean,
|
cull: boolean,
|
||||||
|
clipY0: number,
|
||||||
|
clipY1: number,
|
||||||
): void {
|
): void {
|
||||||
const oa = ia * CLIP
|
const oa = ia * CLIP
|
||||||
const ob = ib * CLIP
|
const ob = ib * CLIP
|
||||||
|
|
@ -210,8 +214,10 @@ export namespace Rasterizer {
|
||||||
const vC = dst[oc + 4]
|
const vC = dst[oc + 4]
|
||||||
const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC)))
|
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 maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC)))
|
||||||
const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC)))
|
// Clamp to the caller's Y-band (default full frame) so worker threads can
|
||||||
const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC)))
|
// 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).
|
// Edge deltas for the three barycentric edge functions (b->c, c->a, a->b).
|
||||||
const dx0 = sxC - sxB
|
const dx0 = sxC - sxB
|
||||||
const dy0 = syC - syB
|
const dy0 = syC - syB
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ export type RenderConfig = {
|
||||||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
* 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`. */
|
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||||
fog: Fog | null
|
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,
|
/** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
|
||||||
|
|
@ -61,6 +66,7 @@ export namespace RenderConfig {
|
||||||
textureFilter: "nearest",
|
textureFilter: "nearest",
|
||||||
lighting: "flat",
|
lighting: "flat",
|
||||||
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
||||||
|
lodDistance: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ps1: RenderConfig = {
|
export const ps1: RenderConfig = {
|
||||||
|
|
@ -73,6 +79,7 @@ export namespace RenderConfig {
|
||||||
textureFilter: "nearest",
|
textureFilter: "nearest",
|
||||||
lighting: "flat",
|
lighting: "flat",
|
||||||
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
|
||||||
|
lodDistance: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const soft: RenderConfig = {
|
export const soft: RenderConfig = {
|
||||||
|
|
@ -85,6 +92,7 @@ export namespace RenderConfig {
|
||||||
textureFilter: "nearest",
|
textureFilter: "nearest",
|
||||||
lighting: "flat",
|
lighting: "flat",
|
||||||
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
|
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
|
||||||
|
lodDistance: 70,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clean: RenderConfig = {
|
export const clean: RenderConfig = {
|
||||||
|
|
@ -94,9 +102,9 @@ export namespace RenderConfig {
|
||||||
colorDepth: 8,
|
colorDepth: 8,
|
||||||
dither: 0,
|
dither: 0,
|
||||||
vertexSnap: 0,
|
vertexSnap: 0,
|
||||||
perspectiveCorrect: 1,
|
|
||||||
textureFilter: "linear",
|
textureFilter: "linear",
|
||||||
lighting: "flat",
|
lighting: "flat",
|
||||||
fog: null,
|
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
|
* 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.
|
* 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 { width, height, color, depth } = fb
|
||||||
|
const bottom = y1 < 0 ? height : y1
|
||||||
const forward = Camera.forward(camera)
|
const forward = Camera.forward(camera)
|
||||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||||
const up = Vec3.cross(right, forward)
|
const up = Vec3.cross(right, forward)
|
||||||
|
|
@ -76,11 +77,13 @@ export namespace Sky {
|
||||||
const clouds = sky.clouds
|
const clouds = sky.clouds
|
||||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||||
const s = Math.max(1, step | 0)
|
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.
|
// Shade at the block center, then flood the whole block with that color.
|
||||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||||
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
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) {
|
for (let bx = 0; bx < width; bx += s) {
|
||||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||||
const ndcX = ((sampleX + 0.5) / width) * 2 - 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.
|
* field of boulders batches into a single draw call.
|
||||||
*/
|
*/
|
||||||
export namespace Boulder {
|
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 rand = rng(boulder.seed)
|
||||||
const seg = 5
|
const seg = lod === "impostor" ? 4 : 5
|
||||||
const rings = 4
|
const rings = lod === "impostor" ? 2 : 4
|
||||||
const r = boulder.radius
|
const r = boulder.radius
|
||||||
// Squat and slightly oval, so it reads as a rock, not a ball.
|
// Squat and slightly oval, so it reads as a rock, not a ball.
|
||||||
const sx = r * (0.8 + rand() * 0.5)
|
const sx = r * (0.8 + rand() * 0.5)
|
||||||
|
|
|
||||||
|
|
@ -28,24 +28,32 @@ export type Tree = {
|
||||||
export namespace Tree {
|
export namespace Tree {
|
||||||
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
|
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
|
||||||
* for its kind (oak leaf vs spruce needle). */
|
* 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)
|
const rand = rng(tree.seed)
|
||||||
if (tree.kind === "oak") {
|
if (tree.kind === "oak") {
|
||||||
oak(tree.position, tree.growth, rand, trunk, foliage)
|
oak(tree.position, tree.growth, rand, trunk, foliage, lod)
|
||||||
} else {
|
} 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 h = lerp(0.8, 7, g)
|
||||||
const rTrunk = lerp(0.04, 0.32, g)
|
const rTrunk = lerp(0.04, 0.32, g)
|
||||||
const forkY = base.y + h * 0.5
|
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)
|
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 spread = h * 0.32
|
||||||
const canopyY = base.y + h * 0.72
|
|
||||||
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
|
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
|
||||||
const blobs = 1 + Math.round(g * 2)
|
const blobs = 1 + Math.round(g * 2)
|
||||||
for (let i = 0; i < blobs; i++) {
|
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 h = lerp(0.6, 9, g)
|
||||||
const rTrunk = lerp(0.03, 0.2, 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.
|
// 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 bottom = base.y + h * 0.1
|
||||||
const span = h * 0.9
|
const span = h * 0.9
|
||||||
for (let i = 0; i < tiers; i++) {
|
for (let i = 0; i < tiers; i++) {
|
||||||
|
|
@ -86,7 +97,7 @@ export namespace Tree {
|
||||||
const y = bottom + t * span * 0.82
|
const y = bottom + t * span * 0.82
|
||||||
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
|
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
|
||||||
const coneH = (span / tiers) * 1.9
|
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
|
/** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps
|
||||||
* it organic without cracking the longitude seam. */
|
* it organic without cracking the longitude seam. */
|
||||||
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void {
|
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void {
|
||||||
const seg = 5
|
|
||||||
const rings = 3
|
|
||||||
const start = mesh.verts.length / STRIDE
|
const start = mesh.verts.length / STRIDE
|
||||||
for (let r = 0; r <= rings; r++) {
|
for (let r = 0; r <= rings; r++) {
|
||||||
const phi = (r / rings) * Math.PI
|
const phi = (r / rings) * Math.PI
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,21 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
#screen {
|
#screen {
|
||||||
display: block;
|
position: absolute;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
#fps {
|
#fps {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
bottom: 6px;
|
bottom: 6px;
|
||||||
font: 16px monospace;
|
font: 13px monospace;
|
||||||
|
white-space: pre;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: right;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: #000;
|
background: #000;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
padding: 1px 5px;
|
padding: 2px 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ export default defineConfig({
|
||||||
"typescript/prefer-function-type": "off",
|
"typescript/prefer-function-type": "off",
|
||||||
"unicorn/no-process-exit": "off",
|
"unicorn/no-process-exit": "off",
|
||||||
"unicorn/prefer-string-raw": "off",
|
"unicorn/prefer-string-raw": "off",
|
||||||
|
"unicorn/relative-url-style": "off",
|
||||||
|
"unicorn/require-post-message-target-origin": "off",
|
||||||
"unicorn/text-encoding-identifier-case": "off",
|
"unicorn/text-encoding-identifier-case": "off",
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -12,19 +12,21 @@
|
||||||
"url": "git+https://github.com/sigitex/meat.git"
|
"url": "git+https://github.com/sigitex/meat.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^7.0.2",
|
|
||||||
"@types/bun": "^1.3.13",
|
|
||||||
"oxfmt": "^0.47.0",
|
|
||||||
"oxlint": "^1.62.0",
|
|
||||||
"@commitlint/cli": "^20.5.3",
|
"@commitlint/cli": "^20.5.3",
|
||||||
"@commitlint/config-conventional": "^20.5.3",
|
"@commitlint/config-conventional": "^20.5.3",
|
||||||
|
"@types/bun": "^1.3.13",
|
||||||
"husky": "^9.1.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"
|
"vite": "^8.1.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bunx --bun vite --host",
|
"start": "bunx --bun vite --host",
|
||||||
"build": "bunx --bun vite build",
|
"build": "bunx --bun vite build",
|
||||||
"assets": "bun scripts/gen-assets.ts",
|
"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:builds": "rsync -avz builds/ sigitex.com:~/meat.errilaz.org/builds",
|
||||||
"publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org",
|
"publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org",
|
||||||
"publish": "bun run publish:current && bun run publish:builds",
|
"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"
|
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({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: "es",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
headers: crossOriginIsolation,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
headers: crossOriginIsolation,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue