From 7e7968885f42cc12c4ddf846427293154147d630 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 14:36:53 -0700 Subject: [PATCH 01/26] docs(PRD-860): a repeat web run is ready in about a second, with a load benchmark --- ...peat-web-run-is-ready-in-about-a-second.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md diff --git a/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md new file mode 100644 index 000000000..78b368b3b --- /dev/null +++ b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md @@ -0,0 +1,85 @@ +# PRD-860 — A repeat web run is ready in about a second + +**Status:** IN PROGRESS +**Priority:** P1 — AC-1 and AC-2 are open: a repeat run of the collectathon kit takes 2.37 s, almost as long as the 5.05 s first run, because no cache covers the work a run repeats. +**Complexity:** 6 (MEDIUM) — 6-10 implementation files (+2), a new generated-content cache (+2), engine packages and a kit template change together (+2); risk override: none. +**Owner:** João +**Depends on:** None. Follows PRD-743 (asset disk cache) and PRD-828 (engine work moves into C++). + +## Context + +Owner, 2026-11-08: "Goal is to run as fast as possible initially and sequential runs are almost instant. in all platforms." Then: "setup a benchmark on this PRD and only stop when its significantly faster, setup some realistic goal." + +Spike, 2026-11-08: the collectathon kit (`--genre mario`) from the published 0.3.12 packages, `pnpm build:web`, `vite preview`, Chromium WebGPU, time from navigation to `__TN_STARTUP_READY__`. The clean numbers come from the quiet laptop (Intel Iris Xe, load 0.3). The desktop numbers were about 4x higher at host load 57, so this PRD uses none of them. + +| Run | localhost | 20 Mbit/s, 40 ms | +| --- | --- | --- | +| first visit (empty profile) | 2.36 s | 5.05 s | +| repeat, same browser | 2.00 s | 2.28 s | +| repeat, browser relaunched | 2.01-2.03 s | 2.37-2.39 s | + +A repeat run downloads 6 KB, so the 2.0 s floor is work, not bytes: + +- **1.2 s scene build.** `Play.load` regenerates the same seeded world in JS every run. A CPU profile splits it into about 65% course and foliage placement (`src/level/Course.ts`, `src/render/foliage.ts`; per-instance `setMatrixAt`, `pathDistance`, `traverse`), about 25% a 2048x1024 sky painted per pixel in JS (`src/render/`), and about 10% collider boxes. +- **0.4 s pipeline warm-up.** 350 pipelines, created after the scene build, not during it. +- **0.3 s boot.** The engine Wasm compiles by `compileStreaming` in about 89 ms. + +The first visit adds about 2.7 s of download at 20 Mbit/s for 6.0 MB. The engine Wasm crossed uncompressed because `vite preview` does not serve the `.br` copies the build writes. Rapier sits in `index.js` as a 2.09 MB base64 string (two-thirds of the bundle), so it cannot use streaming compile or the Wasm code cache, and its decode costs about 150 ms each run. The generated `_headers` sets COOP/COEP only, with no `Cache-Control`, so each repeat visit revalidates every file. + +Unreal answers the repeated-pipeline part with a user pipeline cache: it records the pipelines a run used and precompiles them on the next launch in batches under a frame budget (UE 5.8.3: Engine/Source/Runtime/RenderCore/Private/ShaderPipelineCache.cpp:66-100 for the batch size of 50 and the 16 ms batch time, :214 for `r.ShaderPipelineCache.SaveUserCache`). WebGPU has no serialized pipeline to restore (`packages/core/src/warmup.ts:1112`), so this PRD borrows the order, not the file: start the recorded pipelines while the scene builds. + +## Solution + +1. **Benchmark first.** `pnpm bench:load` scaffolds a kit from the local packages, builds it for web and serves `dist/` as a production host does: the `.br` copies with `Content-Encoding`, and the headers in `_headers`. Then it drives Chromium with `--browser-recipe webgpu` flags, a persistent profile and a CDP throttle of 20 Mbit/s with 40 ms latency. Each arm makes one first visit and five relaunches, and the bench reports the median `readyMs`. It also reports the adapter, bytes on the wire and the stage marks (`TN_STATIC_COLLIDERS`, `TN_STARTUP_WARMUP`). The goal machine is the laptop (Intel Iris Xe) through `laptop-offload --gpu --quiet`, because the desktop runs at load 50+. +2. **Repeat-run levers.** + - `_headers` gives hashed `/assets/*` files `Cache-Control: public, max-age=31536000, immutable`. + - A generated-content cache (`ctx.assets.memo(key, build)`) keeps typed arrays the game generates in the per-game CacheStorage cache that PRD-743 already owns. The key carries the build identity, so a new build regenerates. On a native host it calls `build` every time and reports why. The kit moves its foliage instances, course buffers and sky pixels behind it, so the look stays the same. + - The pipelines a run created are recorded and started while the next run builds its scene, in the Unreal order. +3. **First-run levers.** Rapier loads as its own hashed `.wasm` through `instantiateStreaming`. The bench confirms the host serves `.br` for the engine Wasm. Then the bench decides whether more is needed. + +Each lever lands only when the bench shows a gain above run noise on the paired arms, and it is reverted otherwise. + +## Acceptance Criteria + +- [ ] AC-1 [local]: a repeat run of the collectathon kit, browser relaunched, is ready in at most 1.2 s median at 20 Mbit/s and 40 ms on the laptop (baseline 2.37 s, at least 2x faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. +- [ ] AC-2 [local]: the first visit of the same kit is ready in at most 3.5 s median under the same conditions (baseline 5.05 s, at least 30% faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. +- [ ] AC-3 [local]: the control, the minimal kit, which uses none of the new caches, is no slower than its develop baseline on either arm, within the spread of three runs. It charges the levers' own bookkeeping to a game that gains nothing. proof: `pnpm bench:load --kit minimal --network 20000:40 --runs 5` — Evidence: pending. +- [ ] AC-4 [local]: a fresh visual judge rates the spawn frame NEUTRAL or IMPROVEMENT against the develop baseline, two headed WebGPU captures per arm. proof: visual judge subagent on the bench captures — Evidence: pending. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Load benchmark | `pnpm bench:load` → `scripts/load-bench.ts` | none, new harness | Phase 1 | +| Generated-content cache | kit `Play.load` → `ctx.assets.memo` → `packages/core/src/asset-cache.ts` | the per-run regeneration | Phase 2 | +| Immutable asset headers | `threenative build --target web` → `_headers` | headers with no `Cache-Control` | Phase 2 | +| Rapier out of line | `@threenative/physics` web init → hashed `.wasm` | the inline base64 module | Phase 3 | + +## Decisions + +- 2026-11-08 (agent, from the spike): the goal machine is the quiet laptop, not the desktop, because desktop runs at host load 50+ read 4x high and vary by 40% between runs. +- 2026-11-08 (agent): the cache stores bytes in TypeScript in core, next to the PRD-743 asset cache. It is I/O, not compute, and core must not carry Wasm. Generation that stays per run moves into C++ through the PRD-828 bulk calls only when the bench shows that the cache alone misses AC-2. +- 2026-11-08 (agent): native relaunch (pipeline cache on the owned engine, V8 code cache) stays with PRD-368 and PRD-335. This PRD reports native as `memo` disabled with its reason and changes nothing else there. + +## Execution Phases + +#### Phase 1: The benchmark and its baseline +**Status:** IN PROGRESS +**Files:** `scripts/load-bench.ts` (new), `package.json` (the `bench:load` script). + +- [ ] `pnpm bench:load` reports median first-visit and repeat `readyMs`, the adapter and bytes on the wire for one kit. proof: `pnpm bench:load --kit collectathon --runs 5` on the laptop. +- [ ] The develop baseline is recorded here: three bench runs on the laptop for collectathon and for the minimal control, 20 Mbit/s and 40 ms. proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5`. + +#### Phase 2: A repeat run does not redo the last run's work +**Status:** NOT STARTED +**Files:** `packages/create-threenative/src/` (the `_headers` writer), `packages/core/src/asset-cache.ts` and `assets.ts` (`memo`), the collectathon template `src/level/` and `src/render/`, the warm-up path in `packages/core/src/warmup.ts`. + +- [ ] Hashed assets ship `Cache-Control: public, max-age=31536000, immutable`, and a repeat run makes no revalidation request. proof: `pnpm exec vitest run packages/create-threenative/__tests__` plus the bench request count. +- [ ] `ctx.assets.memo` returns the stored arrays on a repeat run, and the collectathon kit builds its world through it with its playtests green. proof: `pnpm exec vitest run packages/core/__tests__/asset-cache.spec.ts` plus `pnpm test:templates` for collectathon. +- [ ] The recorded pipelines start while the scene builds, and the bench shows the warm-up mark earlier. proof: `pnpm bench:load --kit collectathon --runs 5`. + +#### Phase 3: A first visit downloads and compiles less +**Status:** NOT STARTED +**Files:** `packages/physics/src/web.ts`, the web build in `packages/create-threenative/src/`. + +- [ ] Rapier loads as a hashed `.wasm` through `instantiateStreaming`, and `index.js` carries no base64 module. proof: `pnpm exec vitest run packages/physics/__tests__` plus the bench byte report. From 3ebe059ed5d76866c375feb798bc68332c1f135e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 14:58:00 -0700 Subject: [PATCH 02/26] perf(PRD-860): load bench, ctx.assets.memo, immutable asset headers, kits keep cloud noise and sky between runs --- package.json | 1 + packages/core/__tests__/asset-memo.spec.ts | 106 ++++++ packages/core/src/asset-cache.ts | 6 +- packages/core/src/asset-memo.ts | 200 +++++++++++ packages/core/src/assets.ts | 12 + .../__tests__/web-brand.spec.ts | 17 +- packages/create-threenative/src/web-brand.ts | 26 +- .../templates/action-rpg/src/render/clouds.ts | 13 + .../templates/action-rpg/src/scenes/Play.ts | 3 + .../collectathon/src/level/Course.ts | 8 +- .../templates/collectathon/src/render/sky.ts | 17 +- .../templates/collectathon/src/scenes/Play.ts | 11 +- .../templates/minimal/src/render/clouds.ts | 13 + .../templates/minimal/src/scenes/Play.ts | 3 + .../templates/racing/src/render/clouds.ts | 13 + .../templates/racing/src/scenes/Race.ts | 3 + .../templates/runner/src/render/clouds.ts | 13 + .../templates/runner/src/scenes/Run.ts | 3 + .../templates/shooter/src/render/clouds.ts | 13 + .../templates/shooter/src/scenes/Play.ts | 3 + .../templates/starter/src/render/clouds.ts | 13 + .../templates/starter/src/scenes/Play.ts | 3 + scripts/__tests__/load-bench.spec.ts | 21 ++ scripts/load-bench.ts | 337 ++++++++++++++++++ 24 files changed, 845 insertions(+), 13 deletions(-) create mode 100644 packages/core/__tests__/asset-memo.spec.ts create mode 100644 packages/core/src/asset-memo.ts create mode 100644 scripts/__tests__/load-bench.spec.ts create mode 100644 scripts/load-bench.ts diff --git a/package.json b/package.json index 5ce5907fd..7f710dd23 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "api:surface:sync": "tsx scripts/check-api-surface.ts --update && biome format --write scripts/api-surface.json", "bake:delete-test": "sh scripts/xvfb.sh tsx scripts/bake-delete-test.ts", "bench:engines": "tsx scripts/engine-load-test/cli.ts", + "bench:load": "tsx scripts/load-bench.ts", "bench:engines:report": "tsx scripts/engine-load-test/cli.ts --compare", "bench:engines:monitor": "tsx scripts/engine-load-test/monitor.ts", "dashboard": "tsx scripts/perf-live.ts", diff --git a/packages/core/__tests__/asset-memo.spec.ts b/packages/core/__tests__/asset-memo.spec.ts new file mode 100644 index 000000000..1c2e9d071 --- /dev/null +++ b/packages/core/__tests__/asset-memo.spec.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAssetMemo, decodeMemo, encodeMemo } from "../src/asset-memo.js"; +import { FakeCacheStorage } from "./fixtures/fake-cache.js"; + +const scope = globalThis as { caches?: unknown; __TN_BUILD_ID__?: unknown }; + +describe("ctx.assets.memo (PRD-860)", () => { + let storage: FakeCacheStorage; + beforeEach(() => { + storage = new FakeCacheStorage(); + scope.caches = storage; + scope.__TN_BUILD_ID__ = "/assets/index-AAAA.js"; + vi.spyOn(console, "info").mockImplementation(() => undefined); + }); + afterEach(() => { + delete scope.caches; + delete scope.__TN_BUILD_ID__; + vi.restoreAllMocks(); + }); + + it("builds once, then a later run of the same build reads the stored arrays back", async () => { + const build = vi.fn(() => ({ + pixels: new Uint8Array([1, 2, 3]), + at: new Float32Array([0.5, -2]), + })); + const first = await createAssetMemo("game")("sky", build); + await vi.waitFor(async () => + expect((await storage.open([...storage.caches.keys()][0] ?? "")).store.size).toBe(1), + ); + const second = await createAssetMemo("game")("sky", build); + expect(build).toHaveBeenCalledTimes(1); + expect([...second.pixels]).toEqual([...first.pixels]); + expect(second.at).toBeInstanceOf(Float32Array); + expect([...second.at]).toEqual([0.5, -2]); + }); + + it("rebuilds after a new build and deletes the old build's entries", async () => { + const build = vi.fn(() => ({ v: new Uint16Array([7]) })); + await createAssetMemo("game")("k", build); + await vi.waitFor(() => expect(storage.caches.size).toBe(1)); + scope.__TN_BUILD_ID__ = "/assets/index-BBBB.js"; + await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + const cache = [...storage.caches.values()][0]; + await vi.waitFor(async () => { + const urls = (await cache?.keys())?.map((request) => request.url) ?? []; + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(encodeURIComponent("/assets/index-BBBB.js")); + }); + }); + + it("builds every run, and says why, with no build id or no cache storage", async () => { + delete scope.__TN_BUILD_ID__; + const build = vi.fn(() => ({ v: new Int32Array([1]) })); + await createAssetMemo("game")("k", build); + await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + expect(storage.caches.size).toBe(0); + expect(String(vi.mocked(console.info).mock.calls.at(-1)?.[0])).toContain( + '"reason":"no build id', + ); + }); + + it("rebuilds over a corrupt entry instead of failing the load", async () => { + const build = vi.fn(() => ({ v: new Float64Array([3]) })); + const memo = createAssetMemo("game"); + await memo("k", build); + const cache = [...storage.caches.values()][0]; + await vi.waitFor(async () => expect((await cache?.keys())?.length).toBe(1)); + const url = (await cache?.keys())?.[0]?.url ?? ""; + await cache?.put(url, new Response(new Uint8Array([9, 9]))); + const again = await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + expect([...again.v]).toEqual([3]); + }); + + it("rejects a key or a value it cannot store", async () => { + const memo = createAssetMemo("game"); + await expect(memo("", () => ({ v: new Uint8Array(1) }))).rejects.toThrow( + "TN_ASSET_MEMO_INVALID", + ); + await expect(memo("k", () => ({ v: [1, 2] as unknown as Uint8Array }))).rejects.toThrow( + "TN_ASSET_MEMO_INVALID", + ); + }); + + it("round-trips every typed array kind with its exact values", () => { + const value = { + a: new Float32Array([1.5]), + b: new Float64Array([Math.PI]), + c: new Int8Array([-3]), + d: new Int16Array([-300]), + e: new Int32Array([-70000]), + f: new Uint8Array([250]), + g: new Uint8ClampedArray([255]), + h: new Uint16Array([65000]), + i: new Uint32Array([4000000000]), + empty: new Float32Array(0), + }; + const decoded = decodeMemo(encodeMemo(value)); + for (const [name, array] of Object.entries(value)) { + expect(decoded[name]?.constructor).toBe(array.constructor); + expect([...(decoded[name] ?? [])]).toEqual([...array]); + } + }); +}); diff --git a/packages/core/src/asset-cache.ts b/packages/core/src/asset-cache.ts index a61fb6fee..5aaf5876e 100644 --- a/packages/core/src/asset-cache.ts +++ b/packages/core/src/asset-cache.ts @@ -73,13 +73,13 @@ const ACCESS_REFRESH_MS = 3_600_000; /** Lower is more urgent. */ export const PRIORITY_ORDER: Record = { now: 0, soon: 1, idle: 2 }; -type CacheStorageLike = { +export type CacheStorageLike = { delete(name: string): Promise; keys(): Promise; open(name: string): Promise; }; -type CacheLike = { +export type CacheLike = { delete(request: string): Promise; keys(): Promise; match(request: string): Promise; @@ -94,7 +94,7 @@ interface IJob { priority: AssetPriority; } -function cacheStorage(): CacheStorageLike | undefined { +export function cacheStorage(): CacheStorageLike | undefined { return (globalThis as { caches?: CacheStorageLike }).caches; } diff --git a/packages/core/src/asset-memo.ts b/packages/core/src/asset-memo.ts new file mode 100644 index 000000000..645b4dd60 --- /dev/null +++ b/packages/core/src/asset-memo.ts @@ -0,0 +1,200 @@ +import { type CacheLike, cacheStorage, manifestFingerprint } from "./asset-cache.js"; +import { isNative } from "./platform.js"; + +/** + * Generated content kept between runs (PRD-860): `ctx.assets.memo(key, build)` returns what `build` + * returned the last time this exact build of the game ran, read back from disk, and calls `build` + * only when nothing is stored. A seeded world, a painted sky or a baked placement list is the same + * every run of one build, so a repeat visit stops paying for it. + * + * The value is named typed arrays, the shape a generator hands to geometry, instancing and + * textures. The build identity is the hashed entry script the web build writes into the page + * (`__TN_BUILD_ID__`), so any code change regenerates and the old build's entries are deleted. With + * no build identity (the dev server) or no Cache API (a native host), `build` runs every time and + * `TN_ASSET_MEMO` says why. + */ +export type MemoArray = + | Float32Array + | Float64Array + | Int8Array + | Int16Array + | Int32Array + | Uint8Array + | Uint8ClampedArray + | Uint16Array + | Uint32Array; + +export type MemoArrays = Readonly>; + +export type AssetMemo = ( + key: string, + build: () => T | Promise, +) => Promise; + +const KINDS = { + Float32Array, + Float64Array, + Int16Array, + Int32Array, + Int8Array, + Uint16Array, + Uint32Array, + Uint8Array, + Uint8ClampedArray, +} as const; +type Kind = keyof typeof KINDS; + +const BUILD_ID_GLOBAL = "__TN_BUILD_ID__"; +const MEMO_CACHE = "threenative-memo"; +/** A host no request ever reaches: the Cache API only needs an absolute url to key on. */ +const MEMO_ORIGIN = "https://memo.threenative.invalid/"; +const MAGIC = 0x4f4d4e54; // "TNMO" + +function invalid(detail: string): Error { + return new Error(`TN_ASSET_MEMO_INVALID: ${detail}`); +} + +function kindOf(value: unknown): Kind | undefined { + for (const kind of Object.keys(KINDS) as Kind[]) if (value instanceof KINDS[kind]) return kind; + return undefined; +} + +/** Header (magic, JSON length, JSON) then each array's bytes at an 8-byte boundary. */ +export function encodeMemo(value: MemoArrays): ArrayBuffer { + const entries = Object.entries(value).map(([name, array]) => { + const kind = kindOf(array); + if (kind === undefined) throw invalid(`"${name}" is not a typed array.`); + return { array, kind, name }; + }); + const align = (n: number): number => Math.ceil(n / 8) * 8; + const header = new TextEncoder().encode( + JSON.stringify(entries.map(({ array, kind, name }) => ({ kind, length: array.length, name }))), + ); + let offset = align(8 + header.byteLength); + const placed = entries.map((entry) => { + const at = offset; + offset = align(at + entry.array.byteLength); + return { ...entry, at }; + }); + const buffer = new ArrayBuffer(offset); + const view = new DataView(buffer); + view.setUint32(0, MAGIC, true); + view.setUint32(4, header.byteLength, true); + new Uint8Array(buffer, 8, header.byteLength).set(header); + for (const { array, at } of placed) + new Uint8Array(buffer, at, array.byteLength).set( + new Uint8Array(array.buffer, array.byteOffset, array.byteLength), + ); + return buffer; +} + +/** The inverse of `encodeMemo`; throws on anything it did not write. */ +export function decodeMemo(buffer: ArrayBuffer): Record { + const view = new DataView(buffer); + if (buffer.byteLength < 8 || view.getUint32(0, true) !== MAGIC) + throw invalid("stored entry is not a memo."); + const headerBytes = view.getUint32(4, true); + if (8 + headerBytes > buffer.byteLength) throw invalid("stored entry is truncated."); + const header: unknown = JSON.parse( + new TextDecoder().decode(new Uint8Array(buffer, 8, headerBytes)), + ); + if (!Array.isArray(header)) throw invalid("stored entry has no array list."); + const result: Record = {}; + let offset = Math.ceil((8 + headerBytes) / 8) * 8; + for (const entry of header as Array<{ kind?: unknown; length?: unknown; name?: unknown }>) { + const kind = entry.kind as Kind; + const Ctor = KINDS[kind]; + if (Ctor === undefined || typeof entry.name !== "string" || !Number.isInteger(entry.length)) + throw invalid("stored entry has a malformed array."); + const length = entry.length as number; + const bytes = length * Ctor.BYTES_PER_ELEMENT; + if (offset + bytes > buffer.byteLength) throw invalid("stored entry is truncated."); + result[entry.name] = new Ctor(buffer, offset, length); + offset = Math.ceil((offset + bytes) / 8) * 8; + } + return result; +} + +function report(fields: Record): void { + console.info(`TN_ASSET_MEMO:${JSON.stringify(fields)}`); +} + +function now(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +export function createAssetMemo(namespace: string): AssetMemo { + const storage = cacheStorage(); + const id = (globalThis as Record)[BUILD_ID_GLOBAL]; + const buildId = typeof id === "string" && id !== "" ? id : undefined; + const reason = + storage === undefined + ? isNative() + ? "native host has no Cache API: generated content rebuilds each run" + : "cache API unavailable: generated content rebuilds each run" + : buildId === undefined + ? "no build id (dev server or a page the web build did not write): generated content rebuilds each run" + : undefined; + const prefix = buildId === undefined ? "" : `${MEMO_ORIGIN}${encodeURIComponent(buildId)}/`; + const name = `${MEMO_CACHE}-${manifestFingerprint([{ url: namespace }]).slice(0, 8)}`; + let opened: Promise | undefined; + /** Opens once, deleting what another build stored, so a cache holds one build's content. */ + const open = (): Promise => { + opened ??= (async () => { + const cache = await storage?.open(name); + if (cache === undefined) return undefined; + for (const request of await cache.keys()) + if (!request.url.startsWith(prefix)) await cache.delete(request.url); + return cache; + })().catch(() => undefined); + return opened; + }; + const checked = (key: string, value: T): T => { + if (typeof value !== "object" || value === null) + throw invalid(`build for "${key}" returned no object.`); + for (const [field, array] of Object.entries(value)) + if (kindOf(array) === undefined) + throw invalid(`build for "${key}" returned "${field}", not a typed array.`); + return value; + }; + + return async (key: string, build: () => T | Promise): Promise => { + if (typeof key !== "string" || key === "") throw invalid("key must be a non-empty string."); + const started = now(); + if (reason !== undefined) { + const value = checked(key, await build()); + report({ key, hit: false, ms: Math.round(now() - started), reason }); + return value; + } + const url = `${prefix}${encodeURIComponent(key)}`; + const cache = await open(); + if (cache !== undefined) { + try { + const stored = await cache.match(url); + if (stored !== undefined) { + const buffer = await stored.arrayBuffer(); + const value = decodeMemo(buffer) as unknown as T; + report({ bytes: buffer.byteLength, hit: true, key, ms: Math.round(now() - started) }); + return value; + } + } catch { + // A damaged entry is a miss: delete it and build, never fail the load over a cache. + await cache.delete(url).catch(() => undefined); + } + } + const value = checked(key, await build()); + const encoded = encodeMemo(value); + const builtMs = Math.round(now() - started); + if (cache === undefined) { + report({ hit: false, key, ms: builtMs, reason: "cache storage refused to open" }); + return value; + } + void cache + .put(url, new Response(encoded)) + .then(() => report({ bytes: encoded.byteLength, hit: false, key, ms: builtMs, stored: true })) + .catch((error: unknown) => + report({ hit: false, key, ms: builtMs, stored: false, reason: String(error) }), + ); + return value; + }; +} diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index f61089345..8d20ffd6d 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -20,6 +20,7 @@ import { canonicalUrl, createAssetCache, } from "./asset-cache.js"; +import { type AssetMemo, createAssetMemo } from "./asset-memo.js"; import { TN_VIRTUAL_GEOMETRY, VirtualGeometryPlugin } from "./clustered-mesh.js"; import { GEOMETRY_ASSET_KEY } from "./geometry-capture.js"; import { attachInstancedLod } from "./instanced-batch-lod.js"; @@ -218,6 +219,16 @@ export interface IAssetLoader { * a game that drives a loader outside `defineGame` may call it after its own reveal. */ startBackgroundFill(): Promise; + /** + * Generated content kept between runs (PRD-860): returns what `build` returned the last time this + * build of the game ran, read from disk, and calls `build` only when nothing is stored. Use it for + * seeded work that is the same every run, such as placement lists, painted sky pixels or terrain + * heights. The value is named typed arrays. A new build regenerates. On the dev server and on a + * native host `build` runs every time, and `TN_ASSET_MEMO` says why. + * + * @example const { pixels } = await ctx.assets.memo("sky", () => ({ pixels: paintSky() })); + */ + memo: AssetMemo; /** * How many loads this loader has been asked for and how many have settled (resolved or * rejected). A loading view reads the ratio; the runtime folds it into `ctx.startup.progress` @@ -1514,6 +1525,7 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad get progress() { return { pending: [...pending], requested, requestedBytes, settled, settledBytes }; }, + memo: createAssetMemo(canonicalUrl(manifestUrl)), prefetch: prefetchPaths, resolve: async (path) => (await resolveCandidates(path)).urls, startBackgroundFill: () => { diff --git a/packages/create-threenative/__tests__/web-brand.spec.ts b/packages/create-threenative/__tests__/web-brand.spec.ts index 6a37a3c55..9af625270 100644 --- a/packages/create-threenative/__tests__/web-brand.spec.ts +++ b/packages/create-threenative/__tests__/web-brand.spec.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { makeTempDir } from "../../../test-support/temp-dir.js"; -import { createWebBrandPlugin } from "../src/web-brand.js"; +import { createWebBrandPlugin, withBuildId } from "../src/web-brand.js"; const roots: string[] = []; @@ -186,7 +186,8 @@ describe("web branding adapter", () => { }, }); expect(emitted._headers).toBe( - "/*\n Cross-Origin-Embedder-Policy: require-corp\n Cross-Origin-Opener-Policy: same-origin\n", + "/*\n Cross-Origin-Embedder-Policy: require-corp\n Cross-Origin-Opener-Policy: same-origin\n" + + "/assets/*\n Cache-Control: public, max-age=31536000, immutable\n", ); }); it("leaves upstream three alone in a native bundle, whose host is not a browser (PRD-637)", async () => { @@ -260,4 +261,16 @@ describe("web branding adapter", () => { expect(html.match(/rel="icon"/gu)).toHaveLength(1); expect(html).toContain('rel="manifest" href="/manifest.webmanifest"'); }); + + it("writes the hashed entry script as the build id ctx.assets.memo keys on, and none in dev (PRD-860)", () => { + const built = withBuildId( + '\n \n', + ); + expect(built).toContain( + '', + ); + expect(built.indexOf("__TN_BUILD_ID__")).toBeLessThan(built.indexOf('type="module"')); + const dev = ''; + expect(withBuildId(dev)).toBe(dev); + }); }); diff --git a/packages/create-threenative/src/web-brand.ts b/packages/create-threenative/src/web-brand.ts index 405864e9c..a8c8a40ee 100644 --- a/packages/create-threenative/src/web-brand.ts +++ b/packages/create-threenative/src/web-brand.ts @@ -50,11 +50,15 @@ export const isolationMiddleware: Middleware = (req, res, next) => { next(); }; -/** The same headers for a static host that reads a `_headers` file (Netlify, Cloudflare Pages). */ +/** + * The same headers for a static host that reads a `_headers` file (Netlify, Cloudflare Pages), plus + * a year-long immutable cache for Vite's content-hashed `assets/` files (PRD-860): a repeat visit + * then reads them from disk with no revalidation round trip, and a new build has new names. + */ export function renderIsolationHeaders(): string { return `/*\n${Object.entries(ISOLATION_HEADERS) .map(([key, value]) => ` ${key}: ${value}\n`) - .join("")}`; + .join("")}/assets/*\n Cache-Control: public, max-age=31536000, immutable\n`; } /** @@ -107,6 +111,22 @@ const ISOLATION_BOOT = ``; +/** + * The build identity `ctx.assets.memo` keys on (PRD-860): the hashed entry script, which changes + * whenever any of the game's code does. Only a production page has one; the dev server's entry is + * `src/main.ts`, so a dev page writes none and generated content rebuilds every run there. + */ +export function withBuildId(html: string): string { + const entry = /]*\btype="module"[^>]*\bsrc="([^"]*\/assets\/[^"]+\.js)"/iu.exec( + html, + )?.[1]; + if (entry === undefined) return html; + return html.replace( + /\n (); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts index 58f15ee81..a97d72c15 100644 --- a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts +++ b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts @@ -32,6 +32,7 @@ import { materialLightingEnabled, } from "../render/quality.js"; import { SKY_OPTIONS, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { TouchControls } from "../render/touch-controls.js"; import { type IActionRpgVfxOptions, @@ -111,6 +112,8 @@ export class Play extends Scene { // Only the photo-sky override reads the photograph; the atmosphere sky is computed. SKY_OPTIONS.sky === "photo" ? ctx.assets.texture("sky.jpg") : undefined, ]); + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(ctx.scene, this.#sky, ctx.renderer.softwareAdapter !== undefined); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", diff --git a/packages/create-threenative/templates/collectathon/src/level/Course.ts b/packages/create-threenative/templates/collectathon/src/level/Course.ts index 34f5b0ff5..860efafc5 100644 --- a/packages/create-threenative/templates/collectathon/src/level/Course.ts +++ b/packages/create-threenative/templates/collectathon/src/level/Course.ts @@ -555,8 +555,14 @@ export function buildCourse(rng: () => number, surfaces: ISurfaces, grassShare = steepShare: steep / landCells, }; + let audit: ICourse["audit"] | undefined; return { - audit: { ...auditCourse(group, pickups, ground), ...world }, + // Measured on first read, not on every load: only the debug snapshot and the layout + // playtests read it, and walking every solid's bounds cost a run ~0.1 s. + get audit() { + audit ??= { ...auditCourse(group, pickups, ground), ...world }; + return audit; + }, checkpoints: [ new Vector3(...COURSE.spawn), new Vector3(...onGround(sx + 9, 0, sz + 13)), diff --git a/packages/create-threenative/templates/collectathon/src/render/sky.ts b/packages/create-threenative/templates/collectathon/src/render/sky.ts index 298220727..bee02213c 100644 --- a/packages/create-threenative/templates/collectathon/src/render/sky.ts +++ b/packages/create-threenative/templates/collectathon/src/render/sky.ts @@ -92,7 +92,7 @@ const smooth = (from: number, to: number, value: number): number => { * flatten toward the horizon. Each is lit by where its density climbs: a texel whose deck is * denser toward the sun, or above it on screen, is on the cloud's shaded side or its flat base. */ -export function skyTexture(saturation = 1, width = 2048): DataTexture { +export function skyPixels(saturation = 1, width = 2048): Uint8Array { // three's PMREM sizes its cube from width / 4 and needs at least 16: keep width >= 64. const height = width / 2; const data = new Uint8Array(width * height * 4); @@ -231,6 +231,21 @@ export function skyTexture(saturation = 1, width = 2048): DataTexture { data[i + 3] = 255; } } + return data; +} + +/** + * The sky as a texture. `data` is `skyPixels(saturation, width)`; the scene passes the copy it kept + * from the last run (`ctx.assets.memo`), because painting four million texels costs a run ~0.3 s. + */ +export function skyTexture( + saturation = 1, + width = 2048, + data: Uint8Array = skyPixels(saturation, width), +): DataTexture { + const height = width / 2; + if (data.length !== width * height * 4) + throw new Error(`sky pixels are ${data.length} bytes, expected ${width * height * 4}.`); const sky = new DataTexture(data, width, height, RGBAFormat, UnsignedByteType); sky.mapping = EquirectangularReflectionMapping; sky.colorSpace = SRGBColorSpace; diff --git a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts index 36ebd6a72..62a9675f1 100644 --- a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts +++ b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts @@ -25,7 +25,7 @@ import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { postTier, setupPost } from "../render/postprocessing.js"; import { GRASS_SHARE } from "../render/quality.js"; -import { setupSky, skyTexture } from "../render/sky.js"; +import { setupSky, skyPixels, skyTexture } from "../render/sky.js"; import { loadSurfaces, updateSurfaceSampling } from "../render/surface.js"; import { TouchControls } from "../render/touch-controls.js"; import { type GameState, HUD_PILL } from "../state.js"; @@ -91,8 +91,13 @@ export class Play extends Scene { // The HUD is page UI the cover cannot hide, so it waits for the same readiness. void ctx.startup.whenReady().then(() => ctx.state.set({ loaded: true })); const surfaces = await loadSurfaces(ctx.assets); - this.#sky = skyTexture(); - this.#fill = skyTexture(0.3, 128); + // The sky is the same every run of this build: paint it once, then read the pixels back. + const painted = await ctx.assets.memo("sky", () => ({ + fill: skyPixels(0.3, 128), + sky: skyPixels(), + })); + this.#sky = skyTexture(1, 2048, painted.sky); + this.#fill = skyTexture(0.3, 128, painted.fill); setupSky(ctx.scene, this.#sky, this.#fill); const tier = postTier({ gpuClass: ctx.renderer.gpuClass?.class, diff --git a/packages/create-threenative/templates/minimal/src/render/clouds.ts b/packages/create-threenative/templates/minimal/src/render/clouds.ts index 4047017f1..0bb22540c 100644 --- a/packages/create-threenative/templates/minimal/src/render/clouds.ts +++ b/packages/create-threenative/templates/minimal/src/render/clouds.ts @@ -282,6 +282,19 @@ export interface ICloudTextures { } const textureCache = new Map(); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/minimal/src/scenes/Play.ts b/packages/create-threenative/templates/minimal/src/scenes/Play.ts index 011051126..ff8a2a948 100644 --- a/packages/create-threenative/templates/minimal/src/scenes/Play.ts +++ b/packages/create-threenative/templates/minimal/src/scenes/Play.ts @@ -39,6 +39,7 @@ import { import { setupPost } from "../render/postprocessing.js"; import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { SKY_OPTIONS, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -62,6 +63,8 @@ export class Play extends Scene { // Only the photo-sky override reads the photograph; the atmosphere sky is computed. SKY_OPTIONS.sky === "photo" ? ctx.assets.texture("sky.jpg") : undefined, ]); + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(ctx.scene, this.#sky, ctx.renderer.softwareAdapter !== undefined); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", diff --git a/packages/create-threenative/templates/racing/src/render/clouds.ts b/packages/create-threenative/templates/racing/src/render/clouds.ts index 4047017f1..0bb22540c 100644 --- a/packages/create-threenative/templates/racing/src/render/clouds.ts +++ b/packages/create-threenative/templates/racing/src/render/clouds.ts @@ -282,6 +282,19 @@ export interface ICloudTextures { } const textureCache = new Map(); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/racing/src/scenes/Race.ts b/packages/create-threenative/templates/racing/src/scenes/Race.ts index 4620f9d7b..89da582ad 100644 --- a/packages/create-threenative/templates/racing/src/scenes/Race.ts +++ b/packages/create-threenative/templates/racing/src/scenes/Race.ts @@ -24,6 +24,7 @@ import { materialLightingEnabled, } from "../render/quality.js"; import { SKY_OPTIONS, SUN_DIRECTION, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { TouchControls } from "../render/touch-controls.js"; import { type GameState, type RaceStatus, resolveRaceStatus } from "../state.js"; import { Lap } from "../track/Lap.js"; @@ -119,6 +120,8 @@ export class Race extends Scene { this.#sky = texture; const scene = ctx.scene; if (scene === undefined) return; + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(scene, texture, ctx.renderer.softwareAdapter !== undefined); // The measurement is not awaited: it reads the GPU back and must not hold the first frame. void this.#sampleSky(ctx, scene, generation); diff --git a/packages/create-threenative/templates/runner/src/render/clouds.ts b/packages/create-threenative/templates/runner/src/render/clouds.ts index 4047017f1..0bb22540c 100644 --- a/packages/create-threenative/templates/runner/src/render/clouds.ts +++ b/packages/create-threenative/templates/runner/src/render/clouds.ts @@ -282,6 +282,19 @@ export interface ICloudTextures { } const textureCache = new Map(); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/runner/src/scenes/Run.ts b/packages/create-threenative/templates/runner/src/scenes/Run.ts index d87a3d944..d7a0ca97c 100644 --- a/packages/create-threenative/templates/runner/src/scenes/Run.ts +++ b/packages/create-threenative/templates/runner/src/scenes/Run.ts @@ -26,6 +26,7 @@ import { } from "../render/quality.js"; import { OBSTACLE_SIZE } from "../render/shapes.js"; import { SKY_OPTIONS, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { TouchControls } from "../render/touch-controls.js"; import { type GameState, INITIAL_STATE } from "../state.js"; import { Track } from "../track.js"; @@ -55,6 +56,8 @@ export class Run extends Scene { // Only the photo-sky override reads the photograph; the atmosphere sky is computed. SKY_OPTIONS.sky === "photo" ? ctx.assets.texture("sky.jpg") : undefined, ]); + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(ctx.scene, this.#sky, ctx.renderer.softwareAdapter !== undefined); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", diff --git a/packages/create-threenative/templates/shooter/src/render/clouds.ts b/packages/create-threenative/templates/shooter/src/render/clouds.ts index 4047017f1..0bb22540c 100644 --- a/packages/create-threenative/templates/shooter/src/render/clouds.ts +++ b/packages/create-threenative/templates/shooter/src/render/clouds.ts @@ -282,6 +282,19 @@ export interface ICloudTextures { } const textureCache = new Map(); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/shooter/src/scenes/Play.ts b/packages/create-threenative/templates/shooter/src/scenes/Play.ts index d43e4dddd..1fdd57eea 100644 --- a/packages/create-threenative/templates/shooter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/shooter/src/scenes/Play.ts @@ -51,6 +51,7 @@ import { } from "../render/quality.js"; import { scale } from "../render/scale.js"; import { SKY_OPTIONS, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { loadSurfaces } from "../render/surface.js"; import { TOWN_HALF, type Town, type TownCollider, buildTown } from "../render/town.js"; import { createTownMaterials } from "../render/townMaterials.js"; @@ -143,6 +144,8 @@ export class Play extends Scene { // The town's five generated surfaces (`render/surface.ts`), before `enter` builds the town. await loadSurfaces(ctx.assets); this.#assets = { enemy, viewmodel, sky }; + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(ctx.scene, this.#assets.sky, ctx.renderer.softwareAdapter !== undefined); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", diff --git a/packages/create-threenative/templates/starter/src/render/clouds.ts b/packages/create-threenative/templates/starter/src/render/clouds.ts index 4047017f1..0bb22540c 100644 --- a/packages/create-threenative/templates/starter/src/render/clouds.ts +++ b/packages/create-threenative/templates/starter/src/render/clouds.ts @@ -282,6 +282,19 @@ export interface ICloudTextures { } const textureCache = new Map(); +/** + * Hand back the textures an earlier run generated, so `cloudTextures(seed)` skips ~0.6 s of noise + * on the main thread. The scene keeps them between runs with `ctx.assets.memo`. + */ +export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { + if ( + textures.pattern.length !== PATTERN_SIZE ** 2 * 4 || + textures.volume.length !== VOLUME_SIZE ** 3 * 2 + ) + throw new Error("cloud textures do not match PATTERN_SIZE and VOLUME_SIZE."); + textureCache.set(seed, textures); +} + export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index 3248e776e..0067ebeb5 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -34,6 +34,7 @@ import { setupPost } from "../render/postprocessing.js"; import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { ball, block, spike, tube } from "../render/shapes.js"; import { SKY_OPTIONS, setupSky } from "../render/sky.js"; +import { cloudTextures, primeCloudTextures } from "../render/clouds.js"; import { TouchControls } from "../render/touch-controls.js"; import { STARTER_MIST, createVolumetricFog } from "../render/volumetricFog.js"; import type { GameState } from "../state.js"; @@ -84,6 +85,8 @@ export class Play extends Scene { ]); this.#playerModel = playerModel; this.#sky = sky; + // The cloud noise is the same every run of this build: generate it once, then read it back. + primeCloudTextures(await ctx.assets.memo("clouds", () => cloudTextures())); setupSky(ctx.scene, sky, ctx.renderer.softwareAdapter !== undefined); this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { web: getPlatform().runtime === "web", diff --git a/scripts/__tests__/load-bench.spec.ts b/scripts/__tests__/load-bench.spec.ts new file mode 100644 index 000000000..f7f2f391d --- /dev/null +++ b/scripts/__tests__/load-bench.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseHeadersFile } from "../load-bench.js"; + +describe("load-bench _headers parser", () => { + it("applies every matching rule's headers, with * matching any run", () => { + const rules = parseHeadersFile( + "/*\n Cross-Origin-Opener-Policy: same-origin\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n", + ); + const matched = (path: string) => + rules.filter((rule) => rule.pattern.test(path)).flatMap((rule) => rule.headers); + expect(matched("/assets/index-abc.js")).toEqual([ + ["Cross-Origin-Opener-Policy", "same-origin"], + ["Cache-Control", "public, max-age=31536000, immutable"], + ]); + expect(matched("/index.html")).toEqual([["Cross-Origin-Opener-Policy", "same-origin"]]); + }); + + it("fails on a header line with no path line above it", () => { + expect(() => parseHeadersFile(" Cache-Control: no-cache\n")).toThrow("TN_LOAD_BENCH_HEADERS"); + }); +}); diff --git a/scripts/load-bench.ts b/scripts/load-bench.ts new file mode 100644 index 000000000..aac37e10d --- /dev/null +++ b/scripts/load-bench.ts @@ -0,0 +1,337 @@ +/** + * PRD-860 load benchmark: how long a built web game takes from navigation to + * `__TN_STARTUP_READY__`, on a first visit (empty browser profile) and on repeat visits + * (the same profile, browser relaunched), served the way a production host serves it. + * + * pnpm bench:load --prepare [--kit collectathon] scaffold from local packages, build + * pnpm bench:load --dist /dist [--runs 5] [--network 20000:40] [--json] + * [--cpu-profile ] the first repeat visit's main-thread CPU profile + * + * The server sends the build's `.br` copies with `Content-Encoding: br` and applies the + * `_headers` file, so the result reads the bytes and cache rules a player gets, not a dev server's. + * A software WebGPU adapter fails the run: its timings describe a CPU rasteriser. + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { type IncomingMessage, type ServerResponse, createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { chromium } from "@playwright/test"; +import { WEBGPU_BROWSER_ARGS } from "../packages/playtest/src/runner/browser.js"; + +const READY = "globalThis.__TN_STARTUP_READY__ === true"; +const STAGE_MARKERS = ["TN_STATIC_COLLIDERS", "TN_STARTUP_WARMUP", "TN_AUTO_EXPOSURE"] as const; + +export interface ILoadRun { + readonly arm: "first" | "repeat"; + readonly readyMs: number; + readonly requests: number; + readonly wireBytes: number; + readonly stages: Readonly>; +} + +interface IArgs { + readonly prepare?: string; + readonly kit: string; + readonly dist?: string; + readonly runs: number; + readonly network?: { readonly kbps: number; readonly latencyMs: number }; + readonly json: boolean; + readonly cpuProfile?: string; +} + +function parseArgs(argv: readonly string[]): IArgs { + const value = (name: string): string | undefined => { + const index = argv.indexOf(name); + if (index === -1) return undefined; + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) + throw new Error(`TN_LOAD_BENCH_ARGS: ${name} needs a value.`); + return next; + }; + const runs = Number(value("--runs") ?? 5); + if (!Number.isInteger(runs) || runs < 1) + throw new Error("TN_LOAD_BENCH_ARGS: --runs must be a positive integer."); + const net = value("--network"); + let network: IArgs["network"]; + if (net !== undefined) { + const [kbps = Number.NaN, latencyMs = Number.NaN] = net.split(":").map(Number); + if (!(kbps > 0) || !(latencyMs >= 0)) + throw new Error("TN_LOAD_BENCH_ARGS: --network is :, for example 20000:40."); + network = { kbps, latencyMs }; + } + const args = { + cpuProfile: value("--cpu-profile"), + dist: value("--dist"), + json: argv.includes("--json"), + kit: value("--kit") ?? "collectathon", + network, + prepare: value("--prepare"), + runs, + }; + if ((args.dist === undefined) === (args.prepare === undefined)) + throw new Error("TN_LOAD_BENCH_ARGS: pass exactly one of --prepare or --dist ."); + return args; +} + +const TYPES: Record = { + ".css": "text/css", + ".glb": "model/gltf-binary", + ".html": "text/html", + ".jpg": "image/jpeg", + ".js": "text/javascript", + ".json": "application/json", + ".mjs": "text/javascript", + ".ogg": "audio/ogg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", +}; + +/** Netlify/Cloudflare `_headers`: a path line, then indented `Name: value` lines. `*` matches any run. */ +export function parseHeadersFile( + text: string, +): Array<{ pattern: RegExp; headers: [string, string][] }> { + const rules: Array<{ pattern: RegExp; headers: [string, string][] }> = []; + for (const line of text.split("\n")) { + if (line.trim() === "" || line.trim().startsWith("#")) continue; + if (!/^\s/.test(line)) { + const source = line + .trim() + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + rules.push({ headers: [], pattern: new RegExp(`^${source}$`) }); + continue; + } + const rule = rules.at(-1); + const colon = line.indexOf(":"); + if (rule === undefined || colon === -1) + throw new Error(`TN_LOAD_BENCH_HEADERS: cannot read _headers line "${line.trim()}".`); + rule.headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]); + } + return rules; +} + +function serve(dist: string): Promise<{ url: string; close: () => Promise }> { + const headersFile = path.join(dist, "_headers"); + const rules = existsSync(headersFile) ? parseHeadersFile(readFileSync(headersFile, "utf8")) : []; + const handler = (request: IncomingMessage, response: ServerResponse): void => { + const urlPath = decodeURIComponent(new URL(request.url ?? "/", "http://x").pathname); + const relative = urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath; + const file = path.join(dist, relative); + if (!file.startsWith(dist) || !existsSync(file) || !statSync(file).isFile()) { + response.writeHead(404).end(); + return; + } + const headers: Record = { + "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream", + }; + for (const rule of rules) + if (rule.pattern.test(urlPath)) for (const [k, v] of rule.headers) headers[k] = v; + // A production host revalidates a file with no cache rule; ETag lets the browser get a 304. + const stat = statSync(file); + const etag = `"${stat.size}-${stat.mtimeMs}"`; + headers.ETag = etag; + if (request.headers["if-none-match"] === etag) { + response.writeHead(304, headers).end(); + return; + } + const brotli = `${file}.br`; + const acceptsBr = String(request.headers["accept-encoding"] ?? "").includes("br"); + const body = acceptsBr && existsSync(brotli) ? readFileSync(brotli) : readFileSync(file); + if (acceptsBr && existsSync(brotli)) headers["Content-Encoding"] = "br"; + headers.Vary = "Accept-Encoding"; + response.writeHead(200, headers).end(body); + }; + const server = createServer(handler); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + resolve({ + close: () => new Promise((done) => server.close(() => done())), + url: `http://127.0.0.1:${port}/`, + }); + }); + }); +} + +async function visit( + url: string, + profile: string, + arm: ILoadRun["arm"], + network: IArgs["network"], + cpuProfile?: string, +): Promise { + const context = await chromium.launchPersistentContext(profile, { + args: [...WEBGPU_BROWSER_ARGS], + headless: false, + viewport: { height: 720, width: 1280 }, + }); + try { + const page = await context.newPage(); + const stages: Record = {}; + let started = 0; + page.on("console", (message) => { + const text = message.text(); + for (const marker of STAGE_MARKERS) + if (text.startsWith(marker) && stages[marker] === undefined) + stages[marker] = Date.now() - started; + }); + let requests = 0; + let wireBytes = 0; + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + cdp.on("Network.loadingFinished", (event) => { + requests += 1; + wireBytes += event.encodedDataLength; + }); + if (network !== undefined) + await cdp.send("Network.emulateNetworkConditions", { + downloadThroughput: (network.kbps * 1000) / 8, + latency: network.latencyMs, + offline: false, + uploadThroughput: (network.kbps * 1000) / 8, + }); + if (cpuProfile !== undefined) { + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 200 }); + await cdp.send("Profiler.start"); + } + started = Date.now(); + await page.goto(url, { waitUntil: "commit" }); + await page.waitForFunction(READY, null, { polling: 16, timeout: 180_000 }); + if (cpuProfile !== undefined) { + const { profile } = await cdp.send("Profiler.stop"); + await writeFile(cpuProfile, JSON.stringify(profile)); + } + const readyMs = await page.evaluate(() => Math.round(performance.now())); + const adapter = await page.evaluate(async () => { + const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise } }).gpu; + const found = (await gpu?.requestAdapter()) as + | { info?: { vendor?: string; architecture?: string }; isFallbackAdapter?: boolean } + | null + | undefined; + if (found === null || found === undefined) return "none"; + return `${found.info?.vendor ?? "?"}/${found.info?.architecture ?? "?"}${found.isFallbackAdapter === true ? "/fallback" : ""}`; + }); + return { adapter, arm, readyMs, requests, stages, wireBytes }; + } finally { + await context.close(); + } +} + +function median(values: readonly number[]): number { + if (values.length === 0) throw new Error("TN_LOAD_BENCH_EMPTY: no run to take a median of."); + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + const at = (index: number): number => sorted[index] as number; + return sorted.length % 2 === 1 ? at(middle) : Math.round((at(middle - 1) + at(middle)) / 2); +} + +export async function measure(args: IArgs & { dist: string }) { + const dist = path.resolve(args.dist); + if (!existsSync(path.join(dist, "index.html"))) + throw new Error(`TN_LOAD_BENCH_DIST: ${dist} has no index.html; build the game first.`); + const server = await serve(dist); + const runs: Array = []; + try { + for (let cycle = 0; cycle < args.runs; cycle += 1) { + const profile = await mkdtemp(path.join(tmpdir(), "tn-load-bench-")); + try { + runs.push(await visit(server.url, profile, "first", args.network)); + const profileOut = cycle === 0 ? args.cpuProfile : undefined; + runs.push(await visit(server.url, profile, "repeat", args.network, profileOut)); + runs.push(await visit(server.url, profile, "repeat", args.network)); + } finally { + await rm(profile, { force: true, recursive: true }); + } + } + } finally { + await server.close(); + } + const adapters = [...new Set(runs.map((run) => run.adapter))]; + if (adapters.some((adapter) => adapter === "none" || /fallback|swiftshader|cpu/i.test(adapter))) + throw new Error( + `TN_LOAD_BENCH_ADAPTER: a run had no hardware WebGPU adapter (${adapters.join(", ")}).`, + ); + const summary = (arm: ILoadRun["arm"]) => { + const own = runs.filter((run) => run.arm === arm); + const stage = (marker: string) => { + const seen = own + .map((run) => run.stages[marker]) + .filter((ms): ms is number => ms !== undefined); + return seen.length === own.length ? median(seen) : undefined; + }; + return { + medianReadyMs: median(own.map((run) => run.readyMs)), + minReadyMs: Math.min(...own.map((run) => run.readyMs)), + maxReadyMs: Math.max(...own.map((run) => run.readyMs)), + medianRequests: median(own.map((run) => run.requests)), + medianWireKB: Math.round(median(own.map((run) => run.wireBytes)) / 1024), + runs: own.length, + stagesMs: Object.fromEntries(STAGE_MARKERS.map((marker) => [marker, stage(marker)])), + }; + }; + return { + adapter: adapters.join(", "), + first: summary("first"), + network: args.network ?? null, + repeat: summary("repeat"), + runs, + }; +} + +function run(command: string, commandArgs: readonly string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { cwd, stdio: "inherit" }); + child.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`${command} ${commandArgs.join(" ")} exited ${code}.`)), + ); + }); +} + +async function prepare(out: string, kit: string): Promise { + const { createProject } = await import("../packages/create-threenative/src/index.js"); + const { packageLocalFramework } = await import("./visual-gate.js"); + const root = path.resolve(out); + await mkdir(root, { recursive: true }); + const packageSources = await packageLocalFramework(root); + const project = await createProject( + { install: true, packageSources, target: kit, template: kit }, + root, + ); + await run("pnpm", ["build:web"], project.target); + return path.join(project.target, "dist"); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.prepare !== undefined) { + process.stdout.write(`TN_LOAD_BENCH_DIST ${await prepare(args.prepare, args.kit)}\n`); + return; + } + const result = await measure({ ...args, dist: args.dist as string }); + if (args.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + const line = (name: string, arm: typeof result.first) => + `${name}: median ${arm.medianReadyMs} ms (min ${arm.minReadyMs}, max ${arm.maxReadyMs}, n=${arm.runs}), ` + + `${arm.medianRequests} requests, ${arm.medianWireKB} KB on the wire, stages ${JSON.stringify(arm.stagesMs)}`; + process.stdout.write( + `TN_LOAD_BENCH adapter=${result.adapter} network=${JSON.stringify(result.network)}\n` + + `${line("first visit", result.first)}\n${line("repeat visit", result.repeat)}\n`, + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} From 1bc012e900cf335001bad55505562ad422e37c41 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 15:20:38 -0700 Subject: [PATCH 03/26] perf(PRD-860): the sky's PMREM bake takes 4 GPU round trips, not 10 Every bake group waits for the GPU's completion, about two frames, and a held startup draws no world until the bake ends: ten groups held the minimal kit about 350 ms on an Intel iGPU. Roughness-0 packets cost one sample, as the shader reads; the per-group budget doubles after a group the GPU finished by the next call, up to 4x the heaviest packet (the old largest submission), and falls back after a slow group. renderer_lifetime_test environment_bake_round_trips: 11 calls before, 5 after. The bench writes every TN_ marker beside a profile. --- ...peat-web-run-is-ready-in-about-a-second.md | 18 ++++++++---- .../src/engine/renderer/renderer.cpp | 22 ++++++++++---- .../src/engine/renderer/renderer.h | 4 ++- .../native-engine/renderer_lifetime_test.cpp | 29 +++++++++++++++++++ scripts/load-bench.ts | 4 +++ 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md index 78b368b3b..5a4cb1223 100644 --- a/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md +++ b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md @@ -41,8 +41,8 @@ Each lever lands only when the bench shows a gain above run noise on the paired ## Acceptance Criteria -- [ ] AC-1 [local]: a repeat run of the collectathon kit, browser relaunched, is ready in at most 1.2 s median at 20 Mbit/s and 40 ms on the laptop (baseline 2.37 s, at least 2x faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. -- [ ] AC-2 [local]: the first visit of the same kit is ready in at most 3.5 s median under the same conditions (baseline 5.05 s, at least 30% faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. +- [ ] AC-1 [local]: a repeat run of the collectathon kit, browser relaunched, is ready in at most 1.2 s median at 20 Mbit/s and 40 ms on the laptop (bench baseline 2.51 s, at least 2x faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. +- [ ] AC-2 [local]: the first visit of the same kit is ready in at most 2.7 s median under the same conditions (bench baseline 3.90 s, at least 30% faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — Evidence: pending. - [ ] AC-3 [local]: the control, the minimal kit, which uses none of the new caches, is no slower than its develop baseline on either arm, within the spread of three runs. It charges the levers' own bookkeeping to a game that gains nothing. proof: `pnpm bench:load --kit minimal --network 20000:40 --runs 5` — Evidence: pending. - [ ] AC-4 [local]: a fresh visual judge rates the spawn frame NEUTRAL or IMPROVEMENT against the develop baseline, two headed WebGPU captures per arm. proof: visual judge subagent on the bench captures — Evidence: pending. @@ -57,6 +57,9 @@ Each lever lands only when the bench shows a gain above run noise on the paired ## Decisions +- 2026-11-08 (agent): AC-2 moves from "at most 3.5 s against 5.05 s" to "at most 2.7 s against 3.90 s". The spike served no Brotli; the bench does, as a production host does, and its first-visit baseline is 3.90 s. The bar stays at least 30% faster. +- 2026-11-08 (agent): the minimal kit, with almost no scene, repeats in 2.25 s, so most of a repeat run is startup every kit shares (the sky's cloud noise first), not the collectathon's world. The levers start there. The collectathon's course placement shares one seeded random stream with the rest of the course, so it is not cached: skipping it on a hit would change the world. + - 2026-11-08 (agent, from the spike): the goal machine is the quiet laptop, not the desktop, because desktop runs at host load 50+ read 4x high and vary by 40% between runs. - 2026-11-08 (agent): the cache stores bytes in TypeScript in core, next to the PRD-743 asset cache. It is I/O, not compute, and core must not carry Wasm. Generation that stays per run moves into C++ through the PRD-828 bulk calls only when the bench shows that the cache alone misses AC-2. - 2026-11-08 (agent): native relaunch (pipeline cache on the owned engine, V8 code cache) stays with PRD-368 and PRD-335. This PRD reports native as `memo` disabled with its reason and changes nothing else there. @@ -64,14 +67,17 @@ Each lever lands only when the bench shows a gain above run noise on the paired ## Execution Phases #### Phase 1: The benchmark and its baseline -**Status:** IN PROGRESS +**Status:** DONE **Files:** `scripts/load-bench.ts` (new), `package.json` (the `bench:load` script). -- [ ] `pnpm bench:load` reports median first-visit and repeat `readyMs`, the adapter and bytes on the wire for one kit. proof: `pnpm bench:load --kit collectathon --runs 5` on the laptop. -- [ ] The develop baseline is recorded here: three bench runs on the laptop for collectathon and for the minimal control, 20 Mbit/s and 40 ms. proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5`. +- [x] `pnpm bench:load` reports median first-visit and repeat `readyMs`, the adapter and bytes on the wire for one kit. proof: `pnpm bench:load --dist /dist --network 20000:40 --runs 5` on the laptop — 2026-11-08, `3ebe059ed`: adapter `intel/gen-12lp`, 5 first and 10 repeat visits per run, `.br` served (2805 KB first visit), `scripts/__tests__/load-bench.spec.ts` 2 passed. +- [x] The develop baseline is recorded here: three bench runs on the laptop for collectathon and for the minimal control, 20 Mbit/s and 40 ms. proof: `pnpm bench:load --dist /dist --network 20000:40 --runs 5` — packages from `origin/develop` `4dd50f4c2`, laptop load under 2, 74-98 °C: + - collectathon first visit 3846 / 3902 / 3907 ms, repeat 2433 / 2512 / 2568 ms. + - minimal first visit 3507 / 3555 / 3596 ms, repeat 2249 / 2292 / 2284 ms. + - Unthrottled repeat-visit CPU profile (unminified build): in the minimal kit `cloudTextures()` (`templates/*/src/render/clouds.ts`, shared by six kits) runs about 0.6 s of main-thread JS before the first frame, and `bakedRadiance` about 0.12 s. #### Phase 2: A repeat run does not redo the last run's work -**Status:** NOT STARTED +**Status:** IN PROGRESS **Files:** `packages/create-threenative/src/` (the `_headers` writer), `packages/core/src/asset-cache.ts` and `assets.ts` (`memo`), the collectathon template `src/level/` and `src/render/`, the warm-up path in `packages/core/src/warmup.ts`. - [ ] Hashed assets ship `Cache-Control: public, max-age=31536000, immutable`, and a repeat run makes no revalidation request. proof: `pnpm exec vitest run packages/create-threenative/__tests__` plus the bench request count. diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index d4b368bea..2b3bb2510 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -2475,7 +2475,9 @@ void Renderer::beginEnvironment(const Texture& equirect) { for (uint32_t face = 0; face < 6; ++face) passes.push_back({env.targetViews[size_t(lod) * 6 + face], env.sourceView, env.sampler, integrate ? envIntegrationPipeline_ : envGgxPipeline_, size, - integrate ? kPmremIntegrationSize * kPmremIntegrationSize * 3u : kPmremGgxSamples, + // Roughness 0 reads one texel (kPmremGgx's roughness < 0.001 branch), not 256. + integrate ? kPmremIntegrationSize * kPmremIntegrationSize * 3u + : roughness < 0.001f ? 1u : kPmremGgxSamples, roughness, float(bias), sourceLod, face}); } @@ -2514,7 +2516,8 @@ void Renderer::beginEnvironment(const Texture& equirect) { for (std::size_t p = 0; p < passes.size(); ++p) prep.packets.push_back({passes[p].target, passes[p].source, passes[p].sampler, passes[p].pipeline, passes[p].size, passes[p].size, passes[p].samples, uint32_t(p)}); - for (const PmremPacket& packet : prep.packets) prep.budget = std::max(prep.budget, pmremPacketCost(packet)); + for (const PmremPacket& packet : prep.packets) prep.heaviest = std::max(prep.heaviest, pmremPacketCost(packet)); + prep.budget = prep.heaviest; envPrep_ = std::move(prep); } void Renderer::abandonEnvironment() { @@ -2534,7 +2537,10 @@ bool Renderer::pumpEnvironment() { return false; } // Advance one packet only once the previous submission's completion is observed. - if (prep.next > 0 && gpu_.completedSerial() < prep.awaitSerial) return false; + if (prep.next > 0 && gpu_.completedSerial() < prep.awaitSerial) { + ++prep.waits; + return false; + } if (prep.next >= prep.packets.size()) { if (const auto found = environments_.find(prep.ident); found != environments_.end() && found->second.version == prep.version) @@ -2542,8 +2548,14 @@ bool Renderer::pumpEnvironment() { abandonEnvironment(); return true; } - // One frame carries packets up to the cost of the heaviest single packet (fragments x samples), - // so the cheap low-LOD packets share frames while no submission outgrows the costliest one. + // One frame carries packets up to the budget (fragments x samples). It starts at the heaviest single packet, so + // the cheap low-LOD packets share frames, and doubles after each group the GPU finished by the next frame: every + // group costs a completion round trip (about two frames), and a GPU that keeps up needs 4 groups for a 512 cube, + // not 10 (PRD-860). It stops at 4x the heaviest packet, and a slow group (a software adapter) puts it back. + constexpr uint64_t kPmremBudgetGrowth = 4; + if (prep.next > 0) + prep.budget = prep.waits <= 1 ? std::min(prep.budget * 2, prep.heaviest * kPmremBudgetGrowth) : prep.heaviest; + prep.waits = 0; uint64_t spent = 0; do { const PmremPacket& packet = prep.packets[prep.next]; diff --git a/packages/runtime-native/src/engine/renderer/renderer.h b/packages/runtime-native/src/engine/renderer/renderer.h index 14b8d771a..35379df1f 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.h +++ b/packages/runtime-native/src/engine/renderer/renderer.h @@ -1158,10 +1158,12 @@ class Renderer { std::vector packets; std::size_t next = 0; uint64_t awaitSerial = 0; // the last submitted packet's serial, awaited before the next + uint32_t waits = 0; // calls that found the last group still on the GPU WGPUBindGroup group = nullptr; // the current packet's bind group, rebuilt per packet uint32_t groupUniform = UINT32_MAX; uint64_t stride = 0; - uint64_t budget = 0; // the costliest packet's cost: the per-call submission cap + uint64_t heaviest = 0; // the costliest packet's cost: the smallest per-call cap + uint64_t budget = 0; // the per-call submission cap, heaviest..4x heaviest }; std::optional envPrep_; bool environmentPending_ = false; diff --git a/packages/runtime-native/tests/native-engine/renderer_lifetime_test.cpp b/packages/runtime-native/tests/native-engine/renderer_lifetime_test.cpp index 1125e1b45..ac9527941 100644 --- a/packages/runtime-native/tests/native-engine/renderer_lifetime_test.cpp +++ b/packages/runtime-native/tests/native-engine/renderer_lifetime_test.cpp @@ -11,6 +11,9 @@ // hands the camera it renders with: a shared object posed at the probe, looking down the face's axis. // `database_environment_warmup`: startup warm-up's bake step (the V8 player's tn.prepareEnvironment) // advances the PMREM bake with no frame drawn, so the first frame after it draws the world. +// `environment_bake_round_trips`: a GPU that finishes each group before the next call bakes a 2048-wide sky +// in a few submissions. Every group waits for the GPU, so each one costs about two frames of a held startup +// (PRD-860: ten groups held the minimal kit 350 ms on an Intel iGPU). #include "check.h" #include "engine/assets/probe_volume.h" @@ -256,9 +259,35 @@ void databaseEnvironmentWarmUp() { CHECK(renderer.lastFrame().draws > 0); } +void environmentBakeRoundTrips() { + mystral::webgpu::Context context; + CHECK(context.initializeHeadless()); + EventQueue events; + Renderer renderer(context.getInstance(), context.getDevice(), context.getQueue(), events); + auto sky = std::make_shared(); + sky->mapping = 303; + sky->width = 2048; // a 512 cube: the kits' sky + sky->height = 1024; + sky->data.assign(size_t(sky->width) * sky->height * 4, 200); + sky->needsUpdate(); + std::array items{}; + items[0].envMap = sky.get(); + int calls = 0; + bool done = false; + while (!done && calls < 64) { + done = renderer.prepareEnvironments(items); + ++calls; + CHECK(waitFor(renderer, [&] { return renderer.gpu().completedSerial() >= renderer.gpu().submittedSerial(); })); + } + std::printf("environment_bake_round_trips: %d calls\n", calls); + CHECK(done); + CHECK(calls <= 5); +} + } // namespace TN_TEST_MAIN({"database_environment_warmup", databaseEnvironmentWarmUp}, + {"environment_bake_round_trips", environmentBakeRoundTrips}, {"shared_retired_sweep", sharedRetiredSweep}, {"probe_capture_environment", probeCaptureDuringEnvironmentBake}, {"probe_capture_callback_camera", probeCaptureCallbackCamera}) diff --git a/scripts/load-bench.ts b/scripts/load-bench.ts index aac37e10d..c44b69bc9 100644 --- a/scripts/load-bench.ts +++ b/scripts/load-bench.ts @@ -174,8 +174,10 @@ async function visit( const page = await context.newPage(); const stages: Record = {}; let started = 0; + const marks: string[] = []; page.on("console", (message) => { const text = message.text(); + if (text.startsWith("TN_")) marks.push(`${Date.now() - started} ${text.slice(0, 240)}`); for (const marker of STAGE_MARKERS) if (text.startsWith(marker) && stages[marker] === undefined) stages[marker] = Date.now() - started; @@ -206,6 +208,8 @@ async function visit( if (cpuProfile !== undefined) { const { profile } = await cdp.send("Profiler.stop"); await writeFile(cpuProfile, JSON.stringify(profile)); + // Every TN_ line with its time since navigation, beside the profile it explains. + await writeFile(`${cpuProfile}.marks.txt`, `${marks.join("\n")}\n`); } const readyMs = await page.evaluate(() => Math.round(performance.now())); const adapter = await page.evaluate(async () => { From 40babaeb2f970536fbf74033faa8761d44a05b9a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 16:02:09 -0700 Subject: [PATCH 04/26] perf(PRD-860): load bench can trace the GPU process, inject a probe and add browser switches TN_LOAD_BENCH_TRACE=1 writes a whole-browser trace beside --cpu-profile, TN_LOAD_BENCH_INIT runs a probe script before the page, TN_LOAD_BENCH_CHROME_ARGS adds switches for one experiment. --- scripts/load-bench.ts | 52 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/scripts/load-bench.ts b/scripts/load-bench.ts index c44b69bc9..a41472903 100644 --- a/scripts/load-bench.ts +++ b/scripts/load-bench.ts @@ -164,12 +164,21 @@ async function visit( arm: ILoadRun["arm"], network: IArgs["network"], cpuProfile?: string, + marksFile?: string, ): Promise { const context = await chromium.launchPersistentContext(profile, { - args: [...WEBGPU_BROWSER_ARGS], + // TN_LOAD_BENCH_CHROME_ARGS adds browser switches for one experiment, e.g. a Dawn toggle. + args: [ + ...WEBGPU_BROWSER_ARGS, + ...(process.env.TN_LOAD_BENCH_CHROME_ARGS?.split(" ").filter(Boolean) ?? []), + ], headless: false, viewport: { height: 720, width: 1280 }, }); + // TN_LOAD_BENCH_INIT= runs a probe before the page's own scripts (for example, one that + // logs a hash of every shader module), so a stage can be attributed without rebuilding the game. + if (process.env.TN_LOAD_BENCH_INIT) + await context.addInitScript({ path: process.env.TN_LOAD_BENCH_INIT }); try { const page = await context.newPage(); const stages: Record = {}; @@ -201,6 +210,22 @@ async function visit( await cdp.send("Profiler.enable"); await cdp.send("Profiler.setSamplingInterval", { interval: 200 }); await cdp.send("Profiler.start"); + // TN_LOAD_BENCH_TRACE=1: a whole-browser trace too, for the GPU process the CPU profile cannot see. + if (process.env.TN_LOAD_BENCH_TRACE === "1") + await cdp.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + includedCategories: [ + "toplevel", + "gpu", + "dawn", + "disabled-by-default-gpu.dawn", + "gpu.capture", + "v8.execute", + "blink", + ], + }, + }); } started = Date.now(); await page.goto(url, { waitUntil: "commit" }); @@ -210,7 +235,22 @@ async function visit( await writeFile(cpuProfile, JSON.stringify(profile)); // Every TN_ line with its time since navigation, beside the profile it explains. await writeFile(`${cpuProfile}.marks.txt`, `${marks.join("\n")}\n`); + if (process.env.TN_LOAD_BENCH_TRACE === "1") { + const done = new Promise<{ stream?: string }>((resolve) => + cdp.once("Tracing.tracingComplete", resolve), + ); + await cdp.send("Tracing.end"); + const { stream } = await done; + const chunks: string[] = []; + for (let eof = false; stream !== undefined && !eof; ) { + const chunk = await cdp.send("IO.read", { handle: stream }); + chunks.push(chunk.data); + eof = chunk.eof; + } + await writeFile(`${cpuProfile}.trace.json`, chunks.join("")); + } } + if (marksFile !== undefined) await writeFile(marksFile, `${marks.join("\n")}\n`); const readyMs = await page.evaluate(() => Math.round(performance.now())); const adapter = await page.evaluate(async () => { const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise } }).gpu; @@ -245,10 +285,14 @@ export async function measure(args: IArgs & { dist: string }) { for (let cycle = 0; cycle < args.runs; cycle += 1) { const profile = await mkdtemp(path.join(tmpdir(), "tn-load-bench-")); try { - runs.push(await visit(server.url, profile, "first", args.network)); + // TN_LOAD_BENCH_MARKS= keeps every visit's TN_ lines, to compare one stage across visits. + const marks = (n: number) => + process.env.TN_LOAD_BENCH_MARKS && + path.join(process.env.TN_LOAD_BENCH_MARKS, `${cycle}-${n}.txt`); + runs.push(await visit(server.url, profile, "first", args.network, undefined, marks(0))); const profileOut = cycle === 0 ? args.cpuProfile : undefined; - runs.push(await visit(server.url, profile, "repeat", args.network, profileOut)); - runs.push(await visit(server.url, profile, "repeat", args.network)); + runs.push(await visit(server.url, profile, "repeat", args.network, profileOut, marks(1))); + runs.push(await visit(server.url, profile, "repeat", args.network, undefined, marks(2))); } finally { await rm(profile, { force: true, recursive: true }); } From e6f24156fb3f567f068b477e4356aee3d261ceda Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 16:12:37 -0700 Subject: [PATCH 05/26] perf(PRD-860): colliders cull by one matrix row, the sky table and sea ripples are built once - buildStaticColliders reads the lowest world Y from the position attribute and the matrix's Y row, the exact answer Box3.setFromObject(carrier, true) gave, without its per-vertex walk (35 ms of a minimal repeat visit). A rotated-sphere case locks the exact cull. - ctx.assets.memo accepts a build typed by an interface (the kits' ICloudTextures failed tsc). - The six atmosphere kits build the multiple-scattering table once a run, not once a refresh. - The collectathon keeps its sea ripple pixels in the sky's memo entry (118 ms a run). --- packages/core/__tests__/asset-memo.spec.ts | 10 ++++++ packages/core/src/asset-memo.ts | 10 +++--- .../action-rpg/src/render/atmosphere.ts | 6 ++++ .../collectathon/src/render/water.ts | 31 +++++++++++++------ .../templates/collectathon/src/scenes/Play.ts | 3 ++ .../minimal/src/render/atmosphere.ts | 6 ++++ .../templates/racing/src/render/atmosphere.ts | 6 ++++ .../templates/runner/src/render/atmosphere.ts | 6 ++++ .../shooter/src/render/atmosphere.ts | 6 ++++ .../starter/src/render/atmosphere.ts | 6 ++++ .../__tests__/static-colliders.spec.ts | 17 ++++++++++ packages/physics/src/static-colliders.ts | 18 ++++++++--- 12 files changed, 108 insertions(+), 17 deletions(-) diff --git a/packages/core/__tests__/asset-memo.spec.ts b/packages/core/__tests__/asset-memo.spec.ts index 1c2e9d071..dd893da73 100644 --- a/packages/core/__tests__/asset-memo.spec.ts +++ b/packages/core/__tests__/asset-memo.spec.ts @@ -61,6 +61,16 @@ describe("ctx.assets.memo (PRD-860)", () => { ); }); + it("takes a build typed by an interface, the shape a generator module exports", async () => { + interface IPainted { + readonly sky: Float32Array; + readonly mask: Uint8Array; + } + const paint = (): IPainted => ({ mask: new Uint8Array([1]), sky: new Float32Array([0.5]) }); + const painted: IPainted = await createAssetMemo("game")("painted", paint); + expect([...painted.mask]).toEqual([1]); + }); + it("rebuilds over a corrupt entry instead of failing the load", async () => { const build = vi.fn(() => ({ v: new Float64Array([3]) })); const memo = createAssetMemo("game"); diff --git a/packages/core/src/asset-memo.ts b/packages/core/src/asset-memo.ts index 645b4dd60..69d1c3d43 100644 --- a/packages/core/src/asset-memo.ts +++ b/packages/core/src/asset-memo.ts @@ -25,8 +25,10 @@ export type MemoArray = | Uint32Array; export type MemoArrays = Readonly>; +/** Every field a typed array: a record or an interface, which has no index signature. */ +export type MemoShape = { readonly [K in keyof T]: MemoArray }; -export type AssetMemo = ( +export type AssetMemo = >( key: string, build: () => T | Promise, ) => Promise; @@ -149,7 +151,7 @@ export function createAssetMemo(namespace: string): AssetMemo { })().catch(() => undefined); return opened; }; - const checked = (key: string, value: T): T => { + const checked = >(key: string, value: T): T => { if (typeof value !== "object" || value === null) throw invalid(`build for "${key}" returned no object.`); for (const [field, array] of Object.entries(value)) @@ -158,7 +160,7 @@ export function createAssetMemo(namespace: string): AssetMemo { return value; }; - return async (key: string, build: () => T | Promise): Promise => { + return async >(key: string, build: () => T | Promise): Promise => { if (typeof key !== "string" || key === "") throw invalid("key must be a non-empty string."); const started = now(); if (reason !== undefined) { @@ -183,7 +185,7 @@ export function createAssetMemo(namespace: string): AssetMemo { } } const value = checked(key, await build()); - const encoded = encodeMemo(value); + const encoded = encodeMemo(value as MemoArrays); const builtMs = Math.round(now() - started); if (cache === undefined) { report({ hit: false, key, ms: builtMs, reason: "cache storage refused to open" }); diff --git a/packages/create-threenative/templates/action-rpg/src/render/atmosphere.ts b/packages/create-threenative/templates/action-rpg/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/create-threenative/templates/collectathon/src/render/water.ts b/packages/create-threenative/templates/collectathon/src/render/water.ts index ff524ef57..ce75ce705 100644 --- a/packages/create-threenative/templates/collectathon/src/render/water.ts +++ b/packages/create-threenative/templates/collectathon/src/render/water.ts @@ -85,8 +85,28 @@ function depthMap(floor: ISeaFloor): DataTexture { } /** Tiling ripples: periodic value noise, its slope in red/green and its height in alpha. */ +const RIPPLE_SIZE = 256; +let primedRipples: Uint8Array | undefined; +/** Hand the ripple pixels back from a store (`ctx.assets.memo`), so `seaMaterial` skips painting them. */ +export function primeRipplePixels(pixels: Uint8Array): void { + if (pixels.length !== RIPPLE_SIZE * RIPPLE_SIZE * 4) + throw new Error("ripple pixels do not match RIPPLE_SIZE."); + primedRipples = pixels; +} function rippleMap(): DataTexture { - const n = 256; + const n = RIPPLE_SIZE; + const map = new DataTexture(primedRipples ?? ripplePixels(), n, n); + map.wrapS = RepeatWrapping; + map.wrapT = RepeatWrapping; + map.magFilter = LinearFilter; + map.minFilter = LinearMipmapLinearFilter; + map.generateMipmaps = true; + map.needsUpdate = true; + return map; +} +/** The sea's ripple normal (RG) and height (A), the same every run: no input, no randomness. */ +export function ripplePixels(): Uint8Array { + const n = RIPPLE_SIZE; const hash = (x: number, z: number): number => { const a = Math.sin(x * 127.1 + z * 311.7) * 43758.5453; return a - Math.floor(a); @@ -126,14 +146,7 @@ function rippleMap(): DataTexture { data[at + 3] = height(u, v) * 255; } } - const map = new DataTexture(data, n, n); - map.wrapS = RepeatWrapping; - map.wrapT = RepeatWrapping; - map.magFilter = LinearFilter; - map.minFilter = LinearMipmapLinearFilter; - map.generateMipmaps = true; - map.needsUpdate = true; - return map; + return data; } export function seaMaterial(floor: ISeaFloor, swell: ISwell): MeshStandardNodeMaterial { diff --git a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts index 62a9675f1..8d2b6dc98 100644 --- a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts +++ b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts @@ -28,6 +28,7 @@ import { GRASS_SHARE } from "../render/quality.js"; import { setupSky, skyPixels, skyTexture } from "../render/sky.js"; import { loadSurfaces, updateSurfaceSampling } from "../render/surface.js"; import { TouchControls } from "../render/touch-controls.js"; +import { primeRipplePixels, ripplePixels } from "../render/water.js"; import { type GameState, HUD_PILL } from "../state.js"; export type { GameCtx }; @@ -94,8 +95,10 @@ export class Play extends Scene { // The sky is the same every run of this build: paint it once, then read the pixels back. const painted = await ctx.assets.memo("sky", () => ({ fill: skyPixels(0.3, 128), + ripples: ripplePixels(), sky: skyPixels(), })); + primeRipplePixels(painted.ripples); this.#sky = skyTexture(1, 2048, painted.sky); this.#fill = skyTexture(0.3, 128, painted.fill); setupSky(ctx.scene, this.#sky, this.#fill); diff --git a/packages/create-threenative/templates/minimal/src/render/atmosphere.ts b/packages/create-threenative/templates/minimal/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/minimal/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/minimal/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/create-threenative/templates/racing/src/render/atmosphere.ts b/packages/create-threenative/templates/racing/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/racing/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/racing/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/create-threenative/templates/runner/src/render/atmosphere.ts b/packages/create-threenative/templates/runner/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/runner/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/runner/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/create-threenative/templates/shooter/src/render/atmosphere.ts b/packages/create-threenative/templates/shooter/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/shooter/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/shooter/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/create-threenative/templates/starter/src/render/atmosphere.ts b/packages/create-threenative/templates/starter/src/render/atmosphere.ts index ec2183f9d..d5f4a636e 100644 --- a/packages/create-threenative/templates/starter/src/render/atmosphere.ts +++ b/packages/create-threenative/templates/starter/src/render/atmosphere.ts @@ -235,7 +235,13 @@ function march( * approximation in RenderMultiScatteredLuminanceLutCS); the full sphere gather overestimated the * ground bounce against the Unreal frames by up to 1.6x, so this uses the same two rays. */ +// The table depends on no input: build it once a run, not once a sky refresh. +let multipleScattering: Multi | undefined; function multipleScatteringTable(): Multi { + multipleScattering ??= buildMultipleScatteringTable(); + return multipleScattering; +} +function buildMultipleScatteringTable(): Multi { const nh = 12; const nc = 16; const dirs: Vec3[] = [ diff --git a/packages/physics/__tests__/static-colliders.spec.ts b/packages/physics/__tests__/static-colliders.spec.ts index b992fb054..a105c53be 100644 --- a/packages/physics/__tests__/static-colliders.spec.ts +++ b/packages/physics/__tests__/static-colliders.spec.ts @@ -9,6 +9,7 @@ import { Matrix4, Mesh, MeshBasicMaterial, + SphereGeometry, } from "three"; import { beforeAll, describe, expect, it } from "vitest"; import "../src/index.js"; @@ -137,4 +138,20 @@ describe("buildStaticColliders", () => { plugin.dispose?.(ctx); }); + + it("culls by the lowest transformed vertex, not by a loose box around the rotated mesh", async () => { + const { ctx, plugin } = await setup(); + const root = new Group(); + // A unit sphere at y 7 turned 45 degrees: its lowest vertex is at 6.0, the box around its rotated + // bounding box reaches 5.59. A 5.8 ceiling culls it only when the cull reads the real vertices. + const tilted = new Mesh(new SphereGeometry(1, 16, 12)); + tilted.position.y = 7; + tilted.rotation.z = Math.PI / 4; + root.add(tilted); + root.add(new Mesh(new BoxGeometry(1, 1, 1))); + + expect(buildStaticColliders(ctx, root, { reachableCeiling: 5.8 })).toHaveLength(1); + expect(buildStaticColliders(ctx, root, { reachableCeiling: 6.1 })).toHaveLength(2); + plugin.dispose?.(ctx); + }); }); diff --git a/packages/physics/src/static-colliders.ts b/packages/physics/src/static-colliders.ts index fd543506d..48f506a9a 100644 --- a/packages/physics/src/static-colliders.ts +++ b/packages/physics/src/static-colliders.ts @@ -1,4 +1,4 @@ -import { Box3, InstancedMesh, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three"; +import { InstancedMesh, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three"; import { CollisionShape3D } from "./CollisionShape3D.js"; import { RigidBody3D } from "./RigidBody3D.js"; import type { IPhysicsContext } from "./plugin.js"; @@ -31,7 +31,6 @@ const scratchWorld = new Matrix4(); const scratchPosition = new Vector3(); const scratchScale = new Vector3(); const scratchRotation = new Quaternion(); -const scratchBounds = new Box3(); function optionsFor(filter: StaticColliderFilter | undefined): IBuildStaticCollidersOptions { return typeof filter === "function" ? { predicate: filter } : (filter ?? {}); @@ -63,9 +62,20 @@ function configureCarrier(carrier: Mesh, transform: Matrix4, scale: Vector3): vo carrier.updateMatrixWorld(true); } +/** + * Whether the carrier's lowest world-space vertex sits below the ceiling: the exact answer + * `Box3.setFromObject(carrier, true)` gives, read from the Y row of the world matrix alone. + * The Box3 walk calls getVertexPosition and applyMatrix4 per vertex, about 35 ms per arena at load. + */ function isReachable(carrier: Mesh, ceiling: number): boolean { - scratchBounds.setFromObject(carrier, true); - return !scratchBounds.isEmpty() && scratchBounds.min.y < ceiling; + const position = carrier.geometry.getAttribute("position"); + if (position === undefined || position.count === 0) return false; + const e = carrier.matrixWorld.elements; + const [m1, m5, m9, m13] = [e[1] ?? 0, e[5] ?? 0, e[9] ?? 0, e[13] ?? 0]; + for (let i = 0; i < position.count; i++) + if (m1 * position.getX(i) + m5 * position.getY(i) + m9 * position.getZ(i) + m13 < ceiling) + return true; + return false; } function addCollider( From 03414c30b49be43df2a1bedaf5b0670be5fc60b6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 16:31:33 -0700 Subject: [PATCH 06/26] perf(PRD-860): collectathon builds instance matrices and path distances without engine or array churn - Foliage composes instance matrices with Matrix4 and a YXZ Euler instead of a scratch Object3D, which crossed into the engine five times a placement (~90 ms of a load). Same matrix: 0 difference over 1000 random placements; only setMatrixAt crosses. - pathDistance loops over the path segments instead of building arrays and spreading them into Math.min: same minimum (0 mismatches over 200 000 points), 5x faster, ~65 ms of a load. --- .../collectathon/src/level/terrain.ts | 17 +++++++++------ .../collectathon/src/render/foliage.ts | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/create-threenative/templates/collectathon/src/level/terrain.ts b/packages/create-threenative/templates/collectathon/src/level/terrain.ts index 4ac5d2519..c8318617d 100644 --- a/packages/create-threenative/templates/collectathon/src/level/terrain.ts +++ b/packages/create-threenative/templates/collectathon/src/level/terrain.ts @@ -134,11 +134,16 @@ export function buildTerrain(plan: ITerrainPlan): ITerrain { field, flatness: (x, z) => (inside(x, z) ? field.normalAt(x, z).y : 1), ground: (x, z) => (inside(x, z) ? field.heightAt(x, z) : seaFloor), - pathDistance: (x, z) => - Math.min( - ...plan.paths.flatMap((path) => - path.slice(1).map((corner, i) => segmentDistance(x, z, path[i] ?? corner, corner)), - ), - ), + // A plain loop: planting asks this thousands of times a load, and the array-building form cost + // ~65 ms of a repeat visit. Same segments, same order, same minimum. + pathDistance: (x, z) => { + let nearest = Number.POSITIVE_INFINITY; + for (const path of plan.paths) + for (let i = 1; i < path.length; i += 1) { + const corner = path[i] as readonly [number, number]; + nearest = Math.min(nearest, segmentDistance(x, z, path[i - 1] ?? corner, corner)); + } + return nearest; + }, }; } diff --git a/packages/create-threenative/templates/collectathon/src/render/foliage.ts b/packages/create-threenative/templates/collectathon/src/render/foliage.ts index 9940cc866..15617674f 100644 --- a/packages/create-threenative/templates/collectathon/src/render/foliage.ts +++ b/packages/create-threenative/templates/collectathon/src/render/foliage.ts @@ -17,6 +17,7 @@ import { CylinderGeometry, DataTexture, DoubleSide, + Euler, Group, InstancedMesh, LatheGeometry, @@ -24,7 +25,6 @@ import { type Material, Matrix4, Mesh, - Object3D, OctahedronGeometry, PlaneGeometry, Quaternion, @@ -83,9 +83,16 @@ export interface IPlantingSite { readonly surfaces: ISurfaces; } -const dummy = new Object3D(); +// Plain math, not a scratch Object3D: an engine object's position, scale, rotation and matrix each +// cross into the engine, five crossings an instance (~90 ms of a load); these stay in JavaScript. // Yaw last: a placement leans or droops about X, then turns to face its way round Y. -dummy.rotation.order = "YXZ"; +const turn = new Euler(0, 0, 0, "YXZ"); +const placed = { + at: new Vector3(), + matrix: new Matrix4(), + size: new Vector3(), + spin: new Quaternion(), +}; function instanced( geometry: BufferGeometry, @@ -97,11 +104,9 @@ function instanced( const mesh = new InstancedMesh(geometry, material, Math.max(1, placements.length)); mesh.count = placements.length; placements.forEach(([x, y, z, sx, sy, sz, rx, ry, rz], i) => { - dummy.position.set(x, y, z); - dummy.scale.set(sx, sy, sz); - dummy.rotation.set(rx, ry, rz); - dummy.updateMatrix(); - mesh.setMatrixAt(i, dummy.matrix); + placed.spin.setFromEuler(turn.set(rx, ry, rz, "YXZ")); + placed.matrix.compose(placed.at.set(x, y, z), placed.spin, placed.size.set(sx, sy, sz)); + mesh.setMatrixAt(i, placed.matrix); const tint = tints?.[i]; if (tint !== undefined) mesh.setColorAt(i, tint); }); From bf9a55778647d410db80a6bbc593a4e3dc364315 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 16:54:50 -0700 Subject: [PATCH 07/26] perf(PRD-860): a matrix over shared engine memory is a view onto it, not a trapped mirror Every engine Matrix3/Matrix4 answered 'elements' with a Proxy over a mirrored array that wrote each set element through writeDouble: three's compose wrote 16 trapped elements a call, 49 ms of a collectathon load on a laptop (~6k instance matrices). When engine memory is a SharedArrayBuffer (the threads build) it never detaches as it grows, so 'elements' is now a Float64Array view onto the matrix's 16 doubles: one store a write, live reads, no call. Unshared memory keeps the mirror. --- .../__tests__/browser-math.spec.ts | 41 ++++++++++++++++++- .../three-native/src/browser-value-views.ts | 16 ++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/three-native/__tests__/browser-math.spec.ts b/packages/three-native/__tests__/browser-math.spec.ts index 8fe170f67..04170c247 100644 --- a/packages/three-native/__tests__/browser-math.spec.ts +++ b/packages/three-native/__tests__/browser-math.spec.ts @@ -7,7 +7,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { Euler, Quaternion } from "three"; +import { Euler, Matrix4, Quaternion, Vector3 } from "three"; import { describe, expect, it } from "vitest"; import { @@ -473,6 +473,45 @@ describe("three's math values on the browser back end", () => { expect(matrix.elements[12]).toBe(7); }); + it("writes a matrix's elements straight into shared engine memory, with no per-element call (PRD-860)", () => { + // Collectathon composes ~6k instance matrices at load: 16 trapped writes each cost a laptop 49 ms. + const { runtime } = memoryRuntime(); + const heap = new Float64Array(new SharedArrayBuffer(1024 * 8)); + let writes = 0; + const { classes } = defineBrowserClasses(registry, { + ...runtime, + heapF64: () => heap, + writeDouble(address, value) { + writes++; + heap[address / 8] = value; + }, + get(self, property) { + if (property === "matrixWorld") { + const matrix = runtime.construct("Matrix4", []); + runtime.construct("Object3D", []); // the spacer: a matrix is 16 doubles, an object 8 + return matrix; + } + return runtime.get(self, property); + }, + }); + const matrix = new ( + classes.Group as new () => { + matrixWorld: { elements: number[]; compose(p: object, q: object, s: object): unknown }; + } + )().matrixWorld; + const [offset = 0] = registry.classes.Matrix4?.fields?.elements ?? []; + const at = (64 + offset) / 8; + const held = matrix.elements; + writes = 0; + const turn = new Quaternion().setFromEuler(new Euler(0.3, -0.7, 0.2)); + matrix.compose({ x: 1, y: 2, z: 3 }, turn, { x: 2, y: 2, z: 2 }); + expect(writes).toBe(0); + const expected = new Matrix4().compose(new Vector3(1, 2, 3), turn, new Vector3(2, 2, 2)); + expect([...heap.subarray(at, at + 16)]).toEqual(expected.elements); + heap[at + 12] = 9; // the engine moved it: an elements array the game kept sees it + expect(held[12]).toBe(9); + }); + it("binds the host projection report as one engine call, which core's projection takes (web cut 3)", () => { const { runtime } = memoryRuntime(); const invoked: string[] = []; diff --git a/packages/three-native/src/browser-value-views.ts b/packages/three-native/src/browser-value-views.ts index ea7defdaf..b413c414f 100644 --- a/packages/three-native/src/browser-value-views.ts +++ b/packages/three-native/src/browser-value-views.ts @@ -465,10 +465,26 @@ export function defineValueViews(host: IValueViewHost): IValueViews { const memory = read(address, count) as number[]; for (let i = 0; i < count; i++) values[i] = memory[i] as number; }; + // Shared memory (the threads build) never detaches when it grows, so a view onto it is the + // matrix itself: three's compose wrote 16 trapped elements a call, 49 ms of a laptop load (PRD-860). + const shared = new WeakMap(); Object.defineProperty(accessors, "elements", { configurable: true, get(this: object): number[] { const address = addressOf(this) + offset; + const memory = heap?.(); + if ( + typeof SharedArrayBuffer !== "undefined" && + memory?.buffer instanceof SharedArrayBuffer + ) { + let view = shared.get(this); + if (view === undefined) { + view = memory.subarray(address / 8, address / 8 + count); + shared.set(this, view); + } + // quality-allow: three's elements are an indexable number list; a Float64Array is one. + return view as unknown as number[]; + } let mirror = mirrors.get(this); if (mirror === undefined) { const values = new Array(count); From 3ae3ff027d0782af71d67d359f97b0b00be6770b Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 17:17:35 -0700 Subject: [PATCH 08/26] perf(PRD-860): Rapier downloads beside the engine boot, not in the entry chunk The web backend imported @dimforge/rapier3d-compat statically, so its ~2 MB base64 module (~660 KB Brotli) sat inside index.js: a first visit downloaded it before any engine code ran. The module is now a dynamic import started when physics is imported and awaited only by initialize(), so the bundler splits it into its own chunk that downloads while the engine's Wasm compiles. A caller that inits Rapier itself and hands over a World still works: the module binds when the import resolves. --- packages/physics/src/web.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/physics/src/web.ts b/packages/physics/src/web.ts index 4a3c2d03f..b6b9425bd 100644 --- a/packages/physics/src/web.ts +++ b/packages/physics/src/web.ts @@ -1,4 +1,4 @@ -import * as rapier from "@dimforge/rapier3d-compat"; +import type * as Rapier from "@dimforge/rapier3d-compat"; import { type IPhysicsSimulation, createWebPhysicsShape, @@ -6,19 +6,35 @@ import { installPhysicsSimulationBackend, } from "./simulation.js"; +// Fetched the moment physics is imported, awaited only by `initialize`: a static import put Rapier's +// ~660 KB (Brotli) in the entry chunk, so a first visit downloaded it before the engine could boot. +// As its own chunk it downloads while the engine's Wasm compiles and the game boots (PRD-860). +// A caller that inits Rapier itself and hands over a World needs the module bound once it resolves, +// not only after `initialize`. A failed fetch rejects `initialize`, where the game awaits it. +let loaded: typeof Rapier | undefined; +const rapierModule = import("@dimforge/rapier3d-compat").then((module) => { + loaded = module; + return module; +}); +rapierModule.catch(() => undefined); +const rapierReady = (): typeof Rapier => { + if (loaded === undefined) throw new Error("Rapier used before the physics backend initialized."); + return loaded; +}; let initialized: Promise | undefined; const simulations = new WeakMap(); const RAPIER_INIT_WARNING = "using deprecated parameters for the initialization function; pass a single object instead"; -function initializeRapier(): Promise { +async function initializeRapier(): Promise { + const rapier = await rapierModule; const previousWarn = console.warn; console.warn = (...args) => { if (args[0] === RAPIER_INIT_WARNING) return; previousWarn(...args); }; try { - return rapier.init().finally(() => { + await rapier.init().finally(() => { console.warn = previousWarn; }); } catch (error) { @@ -33,6 +49,7 @@ installPhysicsSimulationBackend({ return initialized; }, createSimulation: (options = {}) => { + const rapier = rapierReady(); const world = new rapier.World(options.gravity ?? { x: 0, y: -9.81, z: 0 }); const simulation = createWebPhysicsSimulation({ eventQueue: new rapier.EventQueue(true), @@ -48,14 +65,15 @@ installPhysicsSimulationBackend({ throw new Error("Physics nodes expected a Rapier World."); const simulation = simulations.get(world); if (simulation !== undefined) return simulation; + const rapier = rapierReady(); const created = createWebPhysicsSimulation({ eventQueue: new rapier.EventQueue(true), rapier, version: rapier.version(), - world: world as rapier.World, + world: world as Rapier.World, }); simulations.set(world, created); return created; }, - createShape: (shape) => createWebPhysicsShape(rapier, shape), + createShape: (shape) => createWebPhysicsShape(rapierReady(), shape), }); From 637eda3a95a1bd820ed14ff38f39483ea43cdaf5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 17:35:06 -0700 Subject: [PATCH 09/26] perf(PRD-860): the collectathon paints its sky while its surface textures download A first visit painted the sky (~600 ms on a laptop) only after loadSurfaces resolved, and the main thread sat ~50% idle for ~500 ms waiting on those downloads. Both now run under one Promise.all. The load bench can profile the first visit (TN_LOAD_BENCH_PROFILE=first). --- .../templates/collectathon/src/scenes/Play.ts | 17 +++++++------ scripts/load-bench.ts | 24 +++++++++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts index 8d2b6dc98..b5b142450 100644 --- a/packages/create-threenative/templates/collectathon/src/scenes/Play.ts +++ b/packages/create-threenative/templates/collectathon/src/scenes/Play.ts @@ -91,13 +91,16 @@ export class Play extends Scene { this.#loading = createLoadingScreen(ctx); // The HUD is page UI the cover cannot hide, so it waits for the same readiness. void ctx.startup.whenReady().then(() => ctx.state.set({ loaded: true })); - const surfaces = await loadSurfaces(ctx.assets); - // The sky is the same every run of this build: paint it once, then read the pixels back. - const painted = await ctx.assets.memo("sky", () => ({ - fill: skyPixels(0.3, 128), - ripples: ripplePixels(), - sky: skyPixels(), - })); + // The sky is the same every run of this build: paint it once, then read the pixels back. A first + // visit paints it (~600 ms on a laptop) while the surface textures download, not after them. + const [surfaces, painted] = await Promise.all([ + loadSurfaces(ctx.assets), + ctx.assets.memo("sky", () => ({ + fill: skyPixels(0.3, 128), + ripples: ripplePixels(), + sky: skyPixels(), + })), + ]); primeRipplePixels(painted.ripples); this.#sky = skyTexture(1, 2048, painted.sky); this.#fill = skyTexture(0.3, 128, painted.fill); diff --git a/scripts/load-bench.ts b/scripts/load-bench.ts index a41472903..5cf485ec0 100644 --- a/scripts/load-bench.ts +++ b/scripts/load-bench.ts @@ -289,9 +289,29 @@ export async function measure(args: IArgs & { dist: string }) { const marks = (n: number) => process.env.TN_LOAD_BENCH_MARKS && path.join(process.env.TN_LOAD_BENCH_MARKS, `${cycle}-${n}.txt`); - runs.push(await visit(server.url, profile, "first", args.network, undefined, marks(0))); const profileOut = cycle === 0 ? args.cpuProfile : undefined; - runs.push(await visit(server.url, profile, "repeat", args.network, profileOut, marks(1))); + // TN_LOAD_BENCH_PROFILE=first profiles the first visit instead of the first repeat. + const first = process.env.TN_LOAD_BENCH_PROFILE === "first"; + runs.push( + await visit( + server.url, + profile, + "first", + args.network, + first ? profileOut : undefined, + marks(0), + ), + ); + runs.push( + await visit( + server.url, + profile, + "repeat", + args.network, + first ? undefined : profileOut, + marks(1), + ), + ); runs.push(await visit(server.url, profile, "repeat", args.network, undefined, marks(2))); } finally { await rm(profile, { force: true, recursive: true }); From a1fe3000c0ae16971945a4178a220e0f3269633e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 8 Nov 2026 17:38:30 -0700 Subject: [PATCH 10/26] perf(PRD-860): a built page starts the engine's Wasm with itself, then Rapier A first visit's waterfall showed the network idle until the entry script ran: the engine glue asked for its 1 MB Wasm at ~330 ms (done ~800 ms), and Rapier's chunk waited until ~900 ms for the engine to boot. The web build now writes a head script that preloads the Wasm the page will run (the threads build only when the page is isolated) and, once it lands, modulepreloads Rapier, so each download gets the whole link in turn. The load bench can dump resource timing (TN_LOAD_BENCH_RESOURCES=1). --- .../__tests__/web-brand.spec.ts | 39 ++++++++++- packages/create-threenative/src/web-brand.ts | 67 ++++++++++++++++++- scripts/load-bench.ts | 11 +++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/create-threenative/__tests__/web-brand.spec.ts b/packages/create-threenative/__tests__/web-brand.spec.ts index 9af625270..c4a97bad5 100644 --- a/packages/create-threenative/__tests__/web-brand.spec.ts +++ b/packages/create-threenative/__tests__/web-brand.spec.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { makeTempDir } from "../../../test-support/temp-dir.js"; -import { createWebBrandPlugin, withBuildId } from "../src/web-brand.js"; +import { createWebBrandPlugin, withBuildId, withEnginePreload } from "../src/web-brand.js"; const roots: string[] = []; @@ -273,4 +273,41 @@ describe("web branding adapter", () => { const dev = ''; expect(withBuildId(dev)).toBe(dev); }); + + it("starts the engine's Wasm with the page, then Rapier, instead of after the entry script ran (PRD-860)", () => { + const html = + '\n \n'; + const bundle = { + "assets/index-A1.js": { fileName: "assets/index-A1.js", type: "chunk", moduleIds: [] }, + "assets/tn-native-engine-web-threads-T1.wasm": { + fileName: "assets/tn-native-engine-web-threads-T1.wasm", + type: "asset", + }, + "assets/tn-native-engine-web-P1.wasm": { + fileName: "assets/tn-native-engine-web-P1.wasm", + type: "asset", + }, + "assets/rapier-R1.js": { + fileName: "assets/rapier-R1.js", + type: "chunk", + moduleIds: ["/n/node_modules/@dimforge/rapier3d-compat/rapier.es.js"], + }, + }; + const threaded = withEnginePreload(html, bundle, true); + // Isolated, the threads build; not isolated, nothing (the isolation worker may reload the page). + expect(threaded).toContain('"/game/assets/tn-native-engine-web-threads-T1.wasm"'); + expect(threaded).not.toContain("web-P1.wasm"); + expect(threaded).toContain('"/game/assets/rapier-R1.js"'); + expect(threaded.indexOf("tn-native-engine-web-threads-T1")).toBeLessThan( + threaded.indexOf('type="module"'), + ); + const plain = withEnginePreload(html, bundle, false); + expect(plain).toContain('"/game/assets/tn-native-engine-web-P1.wasm"'); + expect(plain).not.toContain("threads-T1.wasm"); + // The dev server has no bundle, and a build without the engine has no Wasm to start. + expect(withEnginePreload(html, undefined, true)).toBe(html); + expect( + withEnginePreload(html, { "assets/index-A1.js": bundle["assets/index-A1.js"] }, true), + ).toBe(html); + }); }); diff --git a/packages/create-threenative/src/web-brand.ts b/packages/create-threenative/src/web-brand.ts index a8c8a40ee..87af07bec 100644 --- a/packages/create-threenative/src/web-brand.ts +++ b/packages/create-threenative/src/web-brand.ts @@ -14,7 +14,10 @@ export interface IWebBrandVitePlugin { readonly root: string; readonly command?: "build" | "serve"; }): Promise; - transformIndexHtml(html: string): string; + transformIndexHtml( + html: string, + context?: { readonly bundle?: Readonly> }, + ): string; configureServer(server: IMiddlewareServer): void; configurePreviewServer(server: IMiddlewareServer): void; generateBundle(this: { @@ -127,6 +130,61 @@ export function withBuildId(html: string): string { ); } +export interface IBundleFile { + readonly fileName: string; + readonly type: string; + readonly moduleIds?: readonly string[]; +} + +/** + * Starts the engine's Wasm download with the page (PRD-860). The glue that fetches it loads only + * after the entry script ran, so a first visit's network idled until ~330 ms and the 1 MB module + * ended at ~800 ms; Rapier's chunk then waited for the engine to boot. This head script preloads the + * Wasm the page will run (the threads build only when isolated: otherwise the isolation worker may + * reload the page) and, once it lands, Rapier, so each gets the whole link in turn. + */ +export function withEnginePreload( + html: string, + bundle: Readonly> | undefined, + threaded: boolean, +): string { + if (bundle === undefined) return html; + const files = Object.values(bundle); + const entry = /]*\btype="module"[^>]*\bsrc="([^"]*)"/iu.exec(html)?.[1]; + const entryFile = files.find((file) => entry?.endsWith(file.fileName) === true); + if (entry === undefined || entryFile === undefined) return html; + const base = entry.slice(0, entry.length - entryFile.fileName.length); + const href = (test: (file: IBundleFile) => boolean): string | undefined => { + const found = files.find(test); + return found === undefined ? undefined : base + found.fileName; + }; + const threads = href((file) => /tn-native-engine-web-threads-[^/]*\.wasm$/u.test(file.fileName)); + const plain = href((file) => + /tn-native-engine-web-(?!threads-)[^/]*\.wasm$/u.test(file.fileName), + ); + const wasm = threaded ? threads : plain; + if (wasm === undefined) return html; + const rapier = href( + (file) => + file.type === "chunk" && + file.moduleIds?.some((id) => id.includes("/@dimforge/rapier3d")) === true, + ); + const quote = (value: string | undefined) => + JSON.stringify(value ?? null).replaceAll("<", "\\u003c"); + const script = ``; + return html.replace(/`; return html.replace(/