From 5f14f1e7cc8ee67a67a2d10d9c9602f014c219f9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 22:44:57 -0700 Subject: [PATCH 001/154] docs(PRD-832..842): the untangle report and its eleven single-source PRDs --- docs/PRDs/refactoring/README.md | 1 + .../PRD-832-urgent-single-sources.md | 78 +++++++++ .../PRD-833-template-mechanism-lives-once.md | 68 ++++++++ ...-cross-language-contracts-are-generated.md | 62 ++++++++ ...RD-835-one-registry-entry-per-assertion.md | 62 ++++++++ ...D-836-one-committed-capability-manifest.md | 54 +++++++ ...PRD-837-core-owns-each-shared-fact-once.md | 66 ++++++++ ...PRD-838-scripts-share-one-runtime-layer.md | 55 +++++++ ...PRD-839-ci-contract-is-a-model-not-text.md | 59 +++++++ .../PRD-840-platform-difference-is-a-value.md | 64 ++++++++ .../PRD-841-game-ts-stops-being-the-hub.md | 60 +++++++ ...e-tests-check-behaviour-not-source-text.md | 52 ++++++ .../refactoring/untangle-2026-11-06/README.md | 149 ++++++++++++++++++ 13 files changed, 830 insertions(+) create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-834-cross-language-contracts-are-generated.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-835-one-registry-entry-per-assertion.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-836-one-committed-capability-manifest.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-839-ci-contract-is-a-model-not-text.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-840-platform-difference-is-a-value.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-841-game-ts-stops-being-the-hub.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/PRD-842-native-tests-check-behaviour-not-source-text.md create mode 100644 docs/PRDs/refactoring/untangle-2026-11-06/README.md diff --git a/docs/PRDs/refactoring/README.md b/docs/PRDs/refactoring/README.md index f4e42161a..db697119f 100644 --- a/docs/PRDs/refactoring/README.md +++ b/docs/PRDs/refactoring/README.md @@ -13,6 +13,7 @@ command and be able to bake a new biome quickly". |---|---|---| | [strata-refactoring](strata-refactoring/README.md) | The terrain MCP answers any terrain request and bakes a new biome from one data file. | PRD-791 to PRD-799 | | [god-files](../done/god-files/README.md) | No source file is a god file, and none can grow back into one. | PRD-800 to PRD-804 | +| [untangle-2026-11-06](untangle-2026-11-06/README.md) | Each value has one owner: a change lands in one place and drift goes red. | PRD-832 to PRD-842 | ## Decision: no new terrain package diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md new file mode 100644 index 000000000..137f1fbdb --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -0,0 +1,78 @@ +# PRD-832 — Urgent single sources: one owner for each value that has silently drifted + +**Status:** NOT STARTED +**Priority:** P1 — the open Phase 1 box fixes a live gap: `pnpm budgets` runs capability recall without the `puzzle`, `rts` and `runner` kits. The open Phase 3 boxes remove a web telemetry channel that answers 0 to an unknown field. +**Complexity:** 5 (MEDIUM); risk override: Phase 2 can change the default tone curve, so the owner's visual-judge rule applies to that box +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) + +## Context + +The [untangle report](README.md) lists 365 special cases and 8 areas. This PRD batches the urgent +items: each is a value held in several places that already disagrees or can disagree with no red. +Big structural fixes (R1 template code once, R3 assertion registry, R4 one capability manifest, +R5 core shared facts) stay in the report. Each one gets its own PRD when it is picked. + +| Report id | Problem today | Where | +| --- | --- | --- | +| Q2 / G4 | Kit list typed by hand. It misses `puzzle`, `rts`, `runner`, so `--template` rejects them and recall skips them. Packages listed by regex in 2 scripts. Node version pinned 65 times in workflows. | `scripts/capability-recall.ts:120-134`, `scripts/ci-integration-scope.mjs:50`, `scripts/ci-change-scope.mjs:22-33`, `scripts/visual-gate.ts:43` | +| Q1 / F2 | 7 render files are byte-identical in 12 kits and not in `SHARED_RENDER_SOURCES`, so nothing restamps or compares them. | `packages/create-threenative/src/index.ts:130`, `templates/*/src/render/{environment*,backlight*}.ts` | +| Q3 / A4 | 16 launch-flag readers with 3 different truthiness rules. The exported `debugFlag` has no in-engine caller. | `packages/core/src/debug.ts:49`, list in report A4 | +| Q4 / B3 | Two tone-mapping maps. `WorldEnvironment` defaults to `aces` while the renderer defaults to `filmic`. The root `AGENTS.md` says filmic is the default. | `packages/core/src/look/world-environment.ts:135,793,921-939`, `packages/core/src/look/default-tone-mapping.ts:8-19`, `packages/core/src/renderer.ts:2106` | +| Q5 / A6 / F7 | Core version is a literal in 3 source files and is patched by regex at release. `version.ts` is already generated from `package.json`. | `packages/core/src/index.ts:9`, `packages/create-threenative/src/doctor.ts:215`, `scripts/prepare-release.ts:246-270` | +| R2 slice / D1 / H1 | `tnw_web_frame(int field)` has 87 integer cases with `default: return 0`. The TS side uses bare numbers, and one comment cites C++ line numbers. The ABI digest hashes only signatures. | `packages/runtime-native/src/engine/wasm/web_host.cpp:1019-1129`, `packages/three-native/src/browser-renderer.ts:650-669`, `packages/three-native/src/gpu-stages.ts:27-49`, `packages/three-native/__tests__/web-host-abi.spec.ts` | + +## Solution + +One owner per value, and every other site reads it. Follow the refactoring batch rules +([../README.md](../README.md)): pin first, move without changing behaviour, and give each bug found on +the way its own red-green commit. + +- Phase 1, repo level: `scripts/lib/repo-inventory.ts` derives packages from `packages/*/package.json`, + kits from `packages/create-threenative/templates/*/kit.json` and the Node version from root + `package.json` `engines`. Scripts import it. A drift spec rejects an unlisted byte-identical + `src/render/*` file, then the 7 files go into `SHARED_RENDER_SOURCES`. +- Phase 2, core: one `readLaunchFlag(name)` in `debug.ts`. `WorldEnvironment.apply` calls + `applyRendererToneMapping`. `index.ts` re-exports `CORE_VERSION`. +- Phase 3, Wasm ABI: a single field manifest generates a C++ enum and a TS const object. The + manifest goes through the `api/catalog.json` → generator path in + `packages/three-native/scripts/generate.ts`. Callers use names, and an unknown field fails loudly. + Field ids stay frozen and append-only. + +## Acceptance Criteria + +- [ ] AC-1 [local]: Typecheck, lint and the affected board stay green. proof: `pnpm typecheck && pnpm lint && pnpm ci:local --affected --base origin/develop --target develop` + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Kit and package inventory | `pnpm budgets` → `capability-recall.ts`; CI scope scripts | Hand lists deleted | Phase 1 box 1 | +| Launch flags | Every core subsystem reading `?tn…` / `TN_*` switches | 16 local readers deleted | Phase 2 box 1 | +| Tone curve | `WorldEnvironment.apply` | Its private map and fallback deleted | Phase 2 box 2 | +| Frame telemetry fields | `browser-renderer.ts` and `gpu-stages.ts` → `tnw_web_frame` | Bare integers deleted | Phase 3 boxes | + +## Execution Phases + +#### Phase 1: Repo inventory and template drift guard +**Status:** NOT STARTED +**Files:** new `scripts/lib/repo-inventory.ts`; edit `scripts/capability-recall.ts`, `scripts/ci-change-scope.mjs`, `scripts/ci-integration-scope.mjs`, `scripts/visual-gate.ts`, `packages/create-threenative/src/index.ts`; new drift spec under `packages/create-threenative/__tests__/`. + +- [ ] Recall accepts and harvests every shipped kit (red first: `--template puzzle` rejected today). No script keeps a hand kit or package list. proof: `pnpm exec vitest run scripts/__tests__/capability-recall.spec.ts scripts/__tests__/ci-change-scope.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` plus `rg -n '"tower-defense"' scripts --glob '!__tests__'` returns nothing +- [ ] The drift spec fails on today's tree with the 7 unlisted files named, then passes once they are in `SHARED_RENDER_SOURCES`. proof: `pnpm --filter create-threenative exec vitest run __tests__/shared-render-drift.spec.ts` + +#### Phase 2: Core single owners +**Status:** NOT STARTED +**Files:** `packages/core/src/debug.ts` plus the 16 reader sites in report A4; `packages/core/src/look/world-environment.ts`; `packages/core/src/index.ts`; `packages/create-threenative/src/doctor.ts`; `scripts/prepare-release.ts`. + +- [ ] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` plus `rg -c 'URLSearchParams' packages/core/src` drops by the deleted copies +- [ ] `WorldEnvironment` uses `applyRendererToneMapping`. A scene with no look options gets `filmic` and reports it in `TN_TONEMAP`. If that changes the default from today, a visual judge on the starter returns NEUTRAL or better. proof: `pnpm --filter @threenative/core exec vitest run __tests__/world-environment-defaults.spec.ts`, plus the judge verdict on this box if the default moved +- [ ] Core version has one source: `index.ts` re-exports `CORE_VERSION`, `doctor.ts` reads core's resolved `package.json`, and `prepare-release.ts` drops those regexes. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` + +#### Phase 3: Named `tnw_web_frame` fields +**Status:** NOT STARTED +**Files:** the field manifest beside `packages/three-native/api/catalog.json`; `packages/three-native/scripts/generate.ts`; `packages/runtime-native/src/engine/wasm/web_host.cpp`; `packages/three-native/src/browser-renderer.ts`, `gpu-stages.ts`, `browser-entry.ts`; `packages/three-native/__tests__/web-host-abi.spec.ts`. + +- [ ] Every `tnw_web_frame` caller uses a generated name. The C++ switch uses the generated enum, and an unknown field fails loudly. The ABI spec digests the field list, so a renumbered field goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-host-abi.spec.ts` plus `pnpm native:build` for the Wasm target +- [ ] Starter on the owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` GPU and CPU stage values are non-zero. proof: `node packages/playtest/dist/runner/cli.js` on the starter golden scenario with `--browser-recipe webgpu`, `adapter.info` checked diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md new file mode 100644 index 000000000..ad04d623f --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -0,0 +1,68 @@ +# PRD-833 — Template mechanism lives once + +**Status:** NOT STARTED +**Priority:** P1 — open Phase 2 deletes 84 template copies of 7 render-mechanism files, where a fix to one kit leaves 11 kits wrong with every test green. +**Complexity:** 6 (MEDIUM); risk override: the moved files measure the environment and wire the backlight, so the owner's visual-judge rule applies +**Owner:** ThreeNative maintainers +**Depends on:** PRD-832 (its Phase 1 drift spec guards the copies until this PRD deletes them) +**Batch:** [Untangle report, 2026-11-06](README.md) — R1 + +## Context + +Report ids B1, F1, F2, F3, F4, F8, B2. Root `AGENTS.md` puts "plumbing every game repeats" in +`packages/core/src/`. The templates hold copies instead. + +| Problem | Where | +| --- | --- | +| 7 render files byte-identical in 12 kits, 9 084 lines. `c93b427d0` touched 552 files for one look change. | `templates/*/src/render/{environmentContribution,environmentRadiance,environmentSampleCache,environmentSampling,environmentSnapshot,backlightControls,backlightMaterial}.ts` | +| WebGL fallback predicate defined 12 times | `templates/*/src/render/quality.ts:73`, `templates/starter/src/render/rendererGate.ts:28` | +| Build plumbing copied: `vite.config.ts` 10/16, `src/main.ts` 11/16, `tsconfig.json` 8/16 | `templates/*/` | +| Framework patch filename spelled in 40 places, including a `prepack` one-liner | `packages/create-threenative/src/index.ts:729-731`, `packages/create-threenative/package.json:74`, 16× `templates/*/package.json` | +| 16 whole-tree hashes re-pin on any shared edit. The spec changed in 301 commits in 90 days. | `packages/create-threenative/__tests__/scaffold.spec.ts:238-530` | +| "What ships" pinned 3 times: hashes, `STARTER_PATHS` (149 entries), allowlist | `scaffold.spec.ts:532-670`, `scaffold-file-allowlist.spec.ts:21-51` | + +## Solution + +1. The 7 mechanism files move to one core subpath, for example `@threenative/core/render/environment`. + Each template keeps a small parameter object for its colours, gains and thresholds: the + `GPUParticles3D` shape. The WebGL predicate becomes one core export. +2. Build plumbing comes from one shared source, restamped by `createProject`. A per-kit delta in + `kit.json` names the extra Vite plugins. One `template-assets/patches/manifest.json` feeds + `copyFrameworkPatches`, `prepack` and the catalog script. +3. `scaffold.spec.ts` keeps two hashes per kit, `shared` and `kit`. `pnpm scaffold:pin` rewrites them + and names the moved input. `STARTER_PATHS` and its siblings are deleted, and the allowlist owns names. + +## Acceptance Criteria + +- [ ] AC-1 [local]: A freshly scaffolded starter builds and passes its golden playtest. proof: `pnpm test:templates` (starter and platformer) +- [ ] AC-2 [local]: The visual judge returns NEUTRAL or better on starter, racing and snow, same pose before and after. proof: judge verdict recorded on this box + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Environment measure and backlight | Kit `src/render/` imports the core subpath | 84 template copies deleted | Phase 1 | +| Shared build plumbing | `npm create threenative` → `createProject` | Per-kit copies deleted | Phase 2 | + +## Execution Phases + +#### Phase 1: Mechanism into core +**Status:** NOT STARTED +**Files:** new `packages/core/src/render/environment/*.ts` and subpath export; edit `templates/*/src/render/` callers; delete the 84 copies. + +- [ ] The core subpath owns the 7 files, and every kit imports it. `md5sum templates/*/src/render/environment*.ts` finds no file. proof: `pnpm --filter @threenative/core exec vitest run __tests__/` for the moved units, plus `pnpm --filter create-threenative exec vitest run __tests__/backlight-shared-graph.spec.ts` +- [ ] One core WebGL-fallback predicate; no `quality.ts` defines its own. proof: `rg -n 'function .*[Ww]ebgl[A-Za-z]*Fallback' packages/create-threenative/templates` returns nothing + +#### Phase 2: One source for shared plumbing +**Status:** NOT STARTED +**Files:** `packages/create-threenative/src/index.ts`, `template-assets/`, `templates/*/kit.json`, `packages/create-threenative/package.json`. + +- [ ] `vite.config.ts`, `main.ts` and `tsconfig.json` are restamped from one source with a per-kit plugin delta, and each kit reproduces today's bytes. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts` +- [ ] The patch filename lives in one manifest that `prepack` reads. proof: `rg -c 'three@0\.' packages/create-threenative/src packages/create-threenative/package.json` drops to the manifest only, plus `pnpm --filter create-threenative pack` ships the patch + +#### Phase 3: Scaffold pins split +**Status:** NOT STARTED +**Files:** `packages/create-threenative/__tests__/scaffold.spec.ts`, new `scripts/scaffold-pin.ts`, root `package.json`. + +- [ ] Each kit carries a `shared` and a `kit` hash. An edit to `agent-docs/` changes only `shared`, and `pnpm scaffold:pin` rewrites it. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts __tests__/scaffold-file-allowlist.spec.ts` +- [ ] `STARTER_PATHS` and the two sibling path lists are deleted, so the allowlist and per-directory counts are the only ship contract. proof: the same spec run diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-834-cross-language-contracts-are-generated.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-834-cross-language-contracts-are-generated.md new file mode 100644 index 000000000..9787e9a4c --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-834-cross-language-contracts-are-generated.md @@ -0,0 +1,62 @@ +# PRD-834 — Cross-language contracts are generated from one manifest + +**Status:** NOT STARTED +**Priority:** P2 — the open boxes replace hand-mirrored opcodes, console markers, config keys and hook names, where a rename in one language compiles and fails silently in the other. +**Complexity:** 6 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** PRD-832 (its Phase 3 lands the generator pattern on `tnw_web_frame`) +**Batch:** [Untangle report, 2026-11-06](README.md) — R2 + +## Context + +Report ids D3, E4, A8, C7, D7. PRD-832 covers `tnw_web_frame`. This PRD applies the same pattern to +the other contracts that cross the C++ and TS line. + +| Contract | Today | Where | +| --- | --- | --- | +| Frame-op opcodes | 60 bare ints in JS mirrored by a C++ name table and two switches. Unknown ops are rejected only at replay. | `packages/runtime-native/src/runtime-scripts/frame-op-stream.js:368-905`, `packages/runtime-native/src/webgpu/bindings_frame_stream.cpp:76-77,328,1033` | +| `TN_*` console markers | Printed in C++ and TS, parsed by regex in `perf.ts`, re-declared in 2 evaluators. The window payload type is declared twice. | `packages/playtest/src/runner/perf.ts:30-58,112`, `packages/playtest/src/evaluators/engine-cost.ts:4`, `frame-cost.ts:6`, `packages/core/src/frame-budget.ts:264` | +| Config keys read by C++ | `window.cpp` finds `"bootSplash"` by string search. TS exposes both `render` and `renderer`. | `packages/runtime-native/src/platform/window.cpp:158-181`, `packages/core/src/game.ts:292-294`, `packages/core/src/game-startup.ts:148-167` | +| Engine hook name | `__tnCopyMatrixWorld` defined in C++, probed in 3 JS files | `packages/runtime-native/src/engine/abi/bindings_scene.cpp:714`, `packages/core/src/animation.ts:263-268`, `packages/core/src/interpolation.ts:24-28` | +| Embedded script names | 16 call sites pass literal names. A missing script prints one stderr line. | `packages/runtime-native/src/runtime.cpp:145-157`, `packages/runtime-native/src/audio/audio_bindings.cpp:169-784` | + +## Solution + +One manifest per contract, emitted by the generator that PRD-832 extends: a C++ enum or header and a +TS const. An unknown id, key or name fails closed at boot or at replay, and names the producer. Ids +stay frozen and append-only. `render` folds into `renderer`, with a named error for the old key. + +## Acceptance Criteria + +- [ ] AC-1 [local]: The native desktop host still runs and draws. proof: `pnpm native:verify:desktop` (300 frames, non-blank) +- [ ] AC-2 [local]: The starter perf lane still reads frame-budget windows on web. proof: playtest `perf` on the starter, `--browser-recipe webgpu`, windows > 0 + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Frame-op replay | Native player frame loop → `bindings_frame_stream.cpp` | Bare ints deleted | Phase 1 | +| Marker parsing | `perf` CLI and evaluators | Duplicate constants deleted | Phase 2 | +| Native config read | Native boot → `window.cpp` | String search deleted | Phase 3 | + +## Execution Phases + +#### Phase 1: Frame-op opcode manifest +**Status:** NOT STARTED +**Files:** new opcode manifest; generator; `frame-op-stream.js`; `bindings_frame_stream.cpp`; the drift test in `packages/runtime-native/tests/`. + +- [ ] The JS `OP` object, the C++ name table and the `case` labels come from one manifest, and inserting an op in the middle fails the drift test. proof: `node --test packages/runtime-native/tests/` drift lane plus `pnpm native:build` + +#### Phase 2: Marker manifest +**Status:** NOT STARTED +**Files:** `packages/playtest/src/protocol.ts`, `perf.ts`, `evaluators/engine-cost.ts`, `evaluators/frame-cost.ts`, `packages/core/src/frame-budget.ts`, a generated C++ header. + +- [ ] Marker names and the frame-budget window type are exported once from `protocol.ts`. Core, the evaluators and C++ printers import them. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/perf.spec.ts` plus `rg -n '"TN_FRAME_BUDGET"' packages --glob '!**/protocol.ts' --glob '!**/__tests__/**'` returns nothing +- [ ] `__tnCopyMatrixWorld` comes from one hook-name module, and a host missing it reports once at adapter install. proof: `pnpm --filter @threenative/core exec vitest run __tests__/` for animation and interpolation + +#### Phase 3: Config keys and script names +**Status:** NOT STARTED +**Files:** `packages/core/src/config.ts`, `game.ts`, `game-startup.ts`, `packages/runtime-native/src/platform/window.cpp`, `runtime.cpp`, `audio_bindings.cpp`, `CMakeLists.txt`. + +- [ ] C++ reads config keys from a generated list. `render` is folded into `renderer`, and the old key throws with the new name. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts` plus `pnpm native:verify:desktop` +- [ ] Embedded scripts are named by a generated enum, and a missing script aborts boot with its name. proof: `pnpm native:build` plus the embedded-bundle C++ test diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-835-one-registry-entry-per-assertion.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-835-one-registry-entry-per-assertion.md new file mode 100644 index 000000000..490348c33 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-835-one-registry-entry-per-assertion.md @@ -0,0 +1,62 @@ +# PRD-835 — One registry entry per assertion + +**Status:** NOT STARTED +**Priority:** P1 — open Phase 1 removes the 17-file hand edit per assertion kind that already shipped 3 kinds no runner can produce, and Phase 2 fixes a lane vocabulary that cannot name android or ios. +**Complexity:** 5 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — R3 + +## Context + +Report ids E1, E2, E3, E6. + +| Problem | Where | +| --- | --- | +| One assertion kind = 17 hand-edited files (`924b92f82`, the `tone` kind: 18 files under `packages/playtest/src`) | `src/assertion-schema.ts:27`, `src/scenario/schema-validate.ts:1088-1323`, `src/scenario/schema-accessors.ts:280-1170`, `src/assertion-evaluators.ts:36-68`, `src/runner/observationFields.ts`, `src/assertion-report.ts:168` | +| 3 kinds shipped with schema, evaluator and docs but no runner. A spec pins them as debt. | `packages/playtest/__tests__/runner.spec.ts:676-694` | +| `PlaytestTarget = "web" \| "desktop" \| "bevy"` cannot name android or ios. The real matrix is 11 `if` blocks in the android runner. The registry says `tone` is unsupported on android, but the android runner supports it. | `src/scenario/schema-base.ts:11`, `src/runner/androidRunner.ts:1185-1275`, `src/assertion-schema.ts:30` | +| Capture-with-retry open-coded 5 times. "Needs pixels" predicate written 3 times. | `src/runner/runner.ts:667-733`, `src/runner/androidRunner.ts:749`, `runner.ts:597,602`, `captureEnvironment.ts:127`, `runner-support.ts:118` | +| `geometry` lives outside the registry. `requiredCapabilities` and `trivialityRationale` are read by nothing at runtime. | `schema-validate.ts:1089-1091`, `assertion-evaluators.ts:49-66` | + +## Solution + +Each registry entry carries `validate`, `evaluate`, `observationPath` and `supportedOn` over the +real lanes `browser | android | desktop | ios`. The dispatch in `schema-validate.ts`, the `emit*` +list, the report line and the observation-field entry are generated from it. A kind with no +producer on any lane fails the generator. The shared device flow moves to `deviceRunner.ts`, and one +`captureWithRetry` serves both lanes. + +## Acceptance Criteria + +- [ ] AC-1 [local]: The starter and platformer scenarios give the same verdicts before and after on the browser lane. proof: `pnpm test:templates` (starter, platformer) +- [ ] AC-2 [local]: An android scenario with a `tone` assertion runs on the emulator. proof: `node packages/playtest/dist/runner/cli.js --target android` on the emulator + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Scenario validation | `playtest` CLI → `validateScenario` | Hand dispatch deleted | Phase 1 | +| Lane refusal | Device runner preflight | 11 `if` blocks deleted | Phase 2 | + +## Execution Phases + +#### Phase 1: Registry generates the plumbing +**Status:** NOT STARTED +**Files:** `src/assertion-schema.ts`, `scripts/generate-assertion-validators.ts`, `src/scenario/schema-validate.ts`, `src/assertion-evaluators.ts`, `src/runner/observationFields.ts`. + +- [ ] The dispatch, the evaluator list and the observation fields are generated, and the hand dispatch at `schema-validate.ts:1088-1323` is gone. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/generated-assertion-validators.spec.ts __tests__/schema-boundaries.spec.ts __tests__/assertion-report.spec.ts` +- [ ] A kind with no producing runner fails the generator, and the 3 pinned kinds get a producer or are removed. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/runner.spec.ts` + +#### Phase 2: Real lane vocabulary +**Status:** NOT STARTED +**Files:** `src/scenario/schema-base.ts`, `src/protocol.ts`, new `src/runner/deviceRunner.ts`, `src/runner/androidRunner.ts`, `desktopRunner.ts`, `iosRunner.ts`. + +- [ ] `supportedOn` names the 4 real lanes, the device refusals derive from it, and `deviceRunner.ts` holds the shared flow. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/runner-lanes.spec.ts` + +#### Phase 3: One capture path, `geometry` in the registry +**Status:** NOT STARTED +**Files:** new `src/runner/captureSession.ts`, `runner.ts`, `androidRunner.ts`, `runner-support.ts`, `captureEnvironment.ts`. + +- [ ] Both lanes call one `captureWithRetry`, and one `requiresPixels` replaces 3 predicates. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/runner-orchestration.spec.ts` +- [ ] `geometry` is a registry kind. `requiredCapabilities` is enforced at preflight or deleted. proof: `pnpm --filter @threenative/playtest exec vitest run __tests__/scene-assertion.spec.ts` diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-836-one-committed-capability-manifest.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-836-one-committed-capability-manifest.md new file mode 100644 index 000000000..8140ed907 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-836-one-committed-capability-manifest.md @@ -0,0 +1,54 @@ +# PRD-836 — One committed capability manifest + +**Status:** NOT STARTED +**Priority:** P2 — open Phase 1 deletes the second 556 KB `capabilities.json` that 82 of 117 `core/src/index.ts` commits had to carry, and Phase 2 moves the other derived files out of commits. +**Complexity:** 4 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — R4 + +## Context + +Report id A1. One export added to `packages/core/src/index.ts` regenerates 8 committed artifacts. +The median `index.ts` commit touched 34 files in 90 days. + +| Problem | Where | +| --- | --- | +| Two byte-identical manifests, 556 006 bytes each | `packages/core/capabilities.json`, `packages/create-threenative/capabilities.json`, writer `scripts/build-capability-manifest.ts:17,20` | +| The pre-commit hook silently stages 5 regenerated files into the author's commit | `githooks/pre-commit:37-44` | +| Derived docs committed beside the manifest | `packages/create-threenative/agent-docs/references/capability-reference.md`, `SKILL.md`, `api-surface.json` | +| Two worktrees editing different exports conflict in a 556 KB blob | — | + +## Solution + +`packages/core/capabilities.json` is the one committed manifest. `create-threenative` already depends +on core (`workspace:*`). It reads core's copy at build and pack time, and copies it into its tarball. +`capability-reference.md`, `SKILL.md` and `api-surface.json` become build output that `prepack` +writes, so they are not tracked. The pre-commit hook regenerates and checks the one manifest. It no +longer stages files the author did not touch. + +## Acceptance Criteria + +- [ ] AC-1 [local]: An installed project still finds capabilities through the engine MCP. proof: `pnpm sandbox` scaffold, then `engine_search_capabilities` returns hits for `WorldEnvironment` +- [ ] AC-2 [local]: The tarball ships the manifest and the derived docs. proof: `pnpm --filter create-threenative pack` plus `tar -tzf` lists them + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Capability search | `threenative-engine-mcp` → manifest path in the installed project | Second copy deleted | AC-1 | + +## Execution Phases + +#### Phase 1: One manifest +**Status:** NOT STARTED +**Files:** `scripts/build-capability-manifest.ts`, `packages/create-threenative/package.json`, `packages/engine-mcp/src/index.ts`, `githooks/pre-commit`. + +- [ ] `packages/create-threenative/capabilities.json` is untracked and produced at pack time from core's copy, and the MCP resolves it in a scaffolded project. proof: `pnpm exec vitest run scripts/__tests__/capability-manifest.spec.ts` plus AC-1 +- [ ] The pre-commit hook stages nothing the author did not add. proof: commit one `index.ts` doc edit, then `git show --stat HEAD` lists only `index.ts` and `packages/core/capabilities.json` + +#### Phase 2: Derived docs are build output +**Status:** NOT STARTED +**Files:** `scripts/build-capability-manifest.ts`, `packages/create-threenative/package.json`, `.gitignore`, `scripts/prepare-release.ts`. + +- [ ] `capability-reference.md`, `SKILL.md` and `api-surface.json` are written by `prepack` and are untracked. proof: AC-2 plus `git ls-files packages/create-threenative/agent-docs/references/capability-reference.md` returns nothing diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md new file mode 100644 index 000000000..f09386165 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md @@ -0,0 +1,66 @@ +# PRD-837 — Core owns each shared fact once + +**Status:** NOT STARTED +**Priority:** P2 — the open boxes delete 6 inline copies of the LOD recipe (which already disagree on the near-plane default), the 3 declarations of `world.json`, and 3 GPU-sample freshness rules. +**Complexity:** 6 (MEDIUM); risk override: LOD selection and the renderer facts decide what is drawn, so the owner's visual-judge rule applies to Phases 1 and 2 +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — R5 + +## Context + +Report ids C2, C1, C4, C5, B2, B4, A5. Follows the refactoring batch rule "one owner per value". + +| Fact | Copies today | Where | +| --- | --- | --- | +| LOD selection: `worldSphere → conservativeViewDepth → bias → selectLodLevel` | 6 inline sites. One uses `near ?? 0.1`, five use `?? 0`. | `packages/core/src/model-lod.ts:377-430`, `instanced-batch-lod.ts:204-231`, `world-tiles.ts:997-1009,1421-1434`, `world-cell-chunks.ts:775-790` | +| `world.json` contract | 3 declarations. Terrain lacks `impostorDistance`. The biome `impostor: 110` is read by nothing. | `packages/core/src/world-package.ts:98`, `packages/terrain/src/core/world-package.ts:28`, `packages/assets/src/world/proxy.ts:59-64`, `packages/terrain/starter/forest/biome.data.json:39` | +| Logical asset load | 4 paths. A world built without `assets` silently loses the disk cache. | `packages/core/src/world-cells.ts:139-156,891`, `packages/terrain/src/starter.ts:522-536` | +| Backend facts | `"engine" in info` at 8 sites, `kind === "webgpu"` at 58 | `packages/core/src/game-startup.ts:261`, `look/world-environment.ts:945,1212`, `world-cell-render-bundles.ts:213`, `render/chain.ts:685` | +| GPU sample freshness | 8 frames (renderer), 16 (scaler), per frame (budget) | `packages/core/src/renderer.ts:840-862`, `resolution-scaler.ts:107-109,780-798`, `frame-budget.ts:987-1000` | +| `__THREENATIVE__` dev global | 6 owners, 4 shapes, plus a worker mirror list | `packages/core/src/debug.ts:11`, `hot.ts:70`, `game-dev-tools.ts:20-91`, `packages/ui/src/DebugOverlay.tsx:15`, `worker.ts:96` | + +## Solution + +- `model-lod.ts` exports `selectLodFor(bounds, object, view, errors, level)`. Every site calls it, + and the near-plane default becomes one value. +- Terrain and assets import core's `IWorldPackage` and validator, and the terrain copy is deleted. + `loadLogical` is exported from the core world subpath. A `WorldCells` built without `assets` reports. +- `rendererFacts(renderer)` returns `{ kind, ownedEngine, webglFallback, software }`, and core and + generated code read it. One `GpuReadings` owns timestamp, frame id, freshness and EMA. +- One `dev-global.ts` owns `__THREENATIVE__`, its shape and the worker mirror list. + +## Acceptance Criteria + +- [ ] AC-1 [local]: Strata forest and the abyss example load and draw, and the visual judge returns NEUTRAL or better. proof: `capture-strata-worlds` forest plus the abyss scenario, with the judge verdict on this box +- [ ] AC-2 [local]: Affected core tests stay green. proof: `pnpm --filter @threenative/core test` + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| LOD pick | `WorldTiles`, `WorldCells`, `InstancedBatchLod`, `ModelLod` update | 5 inline recipes deleted | Phase 1 | +| World package read | `WorldCells.load`, terrain bake | Terrain manifest copy deleted | Phase 1 | +| Backend facts | `Game` startup, `WorldEnvironment.apply`, template `src/render/` | Ad hoc checks deleted | Phase 2 | + +## Execution Phases + +#### Phase 1: World facts +**Status:** NOT STARTED +**Files:** `model-lod.ts`, `instanced-batch-lod.ts`, `world-tiles.ts`, `world-cell-chunks.ts`, `world-package.ts`, `packages/terrain/src/core/world-package.ts`, `packages/assets/src/world/proxy.ts`. + +- [ ] One `selectLodFor`. `rg -n 'conservativeViewDepth\(' packages/core/src` finds only `model-lod.ts`. proof: `pnpm --filter @threenative/core exec vitest run __tests__/model-lod.spec.ts __tests__/model-lod-runtime.spec.ts` +- [ ] Terrain and assets use core's world package type, and the biome `impostor` value reaches `impostorDistance`. proof: `pnpm --filter @threenative/terrain test` plus `pnpm --filter @threenative/core exec vitest run __tests__/world-cells.spec.ts` + +#### Phase 2: Renderer facts +**Status:** NOT STARTED +**Files:** new `packages/core/src/renderer-facts.ts`, `renderer.ts`, `resolution-scaler.ts`, `frame-budget.ts`, `look/world-environment.ts`, `game-startup.ts`. + +- [ ] `rendererFacts` is the only owned-engine check: `rg -n '"engine" in' packages/core/src` returns nothing. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts` +- [ ] One `GpuReadings` serves the renderer, the scaler and the budget. proof: `pnpm --filter @threenative/core exec vitest run __tests__/frame-budget-gpu-series.spec.ts __tests__/frame-budget.spec.ts` + +#### Phase 3: Dev global +**Status:** NOT STARTED +**Files:** new `packages/core/src/dev-global.ts`, `debug.ts`, `hot.ts`, `game-dev-tools.ts`, `worker.ts`, `packages/ui/src/DebugOverlay.tsx`. + +- [ ] One module owns `__THREENATIVE__`, and worker mode exposes the same members as page mode. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md new file mode 100644 index 000000000..a78328a57 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -0,0 +1,55 @@ +# PRD-838 — Scripts share one runtime layer + +**Status:** NOT STARTED +**Priority:** P2 — open Phase 1 replaces 4 helper families copied 5 to 11 times. Five of the 7 dev-server waits burn their full timeout when the server dies at start. +**Complexity:** 5 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** PRD-832 (its Phase 1 creates `scripts/lib/repo-inventory.ts`, the first shared module) +**Batch:** [Untangle report, 2026-11-06](README.md) — N1 + +## Context + +Report ids G1, G5, G6, G7. `scripts/` holds about 178 000 lines and one library file +(`scripts/lib/step-cost.ts`). + +| Helper family | Copies | Where (sample) | +| --- | --- | --- | +| Dev-server start, wait and stop | 7. Polls of 100 to 500 ms, deadlines of 90 to 240 s. Only 2 check `child.exitCode`. | `scripts/env-cost-probe.ts:81`, `engine-load-test/browser.ts:222`, `visual-gate.ts:371`, `verify-golden-path.ts:1068`, `profile-starter.ts:208`, `profile-native-cpu.ts:590`, `unreal-look/capture-threenative.ts:321` | +| `run()` / exec | 8, two of them verbatim | `verify-one-template.ts:13`, `verify-template-playtests.ts:25`, `make-sandbox.ts:97`, `prepare-release.ts:335` | +| MCP stdio handshake | 5 | `capture-mcp-tools.ts:89`, `capture-asset-mcp-tools.ts:140`, `verify-registry-install.ts:302`, `verify-golden-path.ts:651`, `verify-forest-playtest.ts:146` | +| `gh` wrapper | 4, with different error rules | `ci-change-scope.mjs:624`, `ci-queue-watch.mjs:75`, `ci-merge-latency.mjs:130`, `native-release-reuse.mjs:14` | +| Perf verdict rule | 2 homes, one in a shipped package | `packages/playtest/src/runner/perf.ts:208-232`, `scripts/engine-load-test/report.ts:944-967` | +| Dead or orphan scripts | 5 unreferenced files, 4 gates run only by their own spec | `scripts/release-native-local.mjs`, `asset-pose-capture.mjs`, `realistic-ocean-budget.mjs`, `realistic-ocean-capture.mjs`, `verify-fog-exposure.ts`; `check-capability-examples.ts`, `arm-census.ts`, `asset-parity.ts`, `playtest-pair.ts` | + +## Solution + +`scripts/lib/{dev-server,exec,mcp-client,gh,args}.ts`, one implementation each. The strictest variant +wins: check the child's exit, throw on non-zero, retry `gh` once. Every local copy is deleted. The perf +verdict moves into `@threenative/playtest`, and `report.ts` imports it. Dead scripts are deleted, and +each orphan gate is wired into `pnpm budgets` or deleted. + +## Acceptance Criteria + +- [ ] AC-1 [local]: The golden path and one visual gate still run end to end. proof: `pnpm tsx scripts/verify-golden-path.ts` plus `pnpm visuals` on the starter + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Dev-server lifecycle | Visual, profile and golden-path scripts | 7 copies deleted | Phase 1 | + +## Execution Phases + +#### Phase 1: Shared lib +**Status:** NOT STARTED +**Files:** new `scripts/lib/dev-server.ts`, `exec.ts`, `mcp-client.ts`, `gh.ts`, `args.ts`; the callers listed above. + +- [ ] One dev-server helper fails fast when the server process exits, and every caller uses it. proof: a unit spec under `scripts/__tests__/` with a server that exits at once, plus `rg -n 'function waitFor(Server|Url|Port)' scripts --glob '!lib/**'` returns nothing +- [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. proof: `pnpm exec vitest run scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` + +#### Phase 2: One perf verdict, no dead scripts +**Status:** NOT STARTED +**Files:** `packages/playtest/src/runner/perf.ts`, `scripts/engine-load-test/report.ts`, the 9 scripts named above, root `package.json`. + +- [ ] `report.ts` imports the playtest verdict rule. proof: `pnpm exec vitest run scripts/__tests__/engine-load-test.spec.ts` plus `pnpm --filter @threenative/playtest exec vitest run __tests__/perf.spec.ts` +- [ ] The 5 dead scripts are deleted, and the 4 orphan gates are wired into `pnpm budgets` or deleted. proof: `pnpm budgets` exit 0, plus `pnpm check:docs` for links diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-839-ci-contract-is-a-model-not-text.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-839-ci-contract-is-a-model-not-text.md new file mode 100644 index 000000000..318630946 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-839-ci-contract-is-a-model-not-text.md @@ -0,0 +1,59 @@ +# PRD-839 — The CI contract is a model, not text matches + +**Status:** NOT STARTED +**Priority:** P2 — open Phase 1 replaces 594 raw-text assertions on `ci.yml`, which one in three CI commits has to patch, and Phase 3 drops a proof that splices a historical workflow. +**Complexity:** 5 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — N2 + +## Context + +Report ids G2, G3, G8. In 90 days, `ci.yml` changed in 135 commits and `ci-structure.spec.ts` in 106. + +| Problem | Where | +| --- | --- | +| `ci-structure.spec.ts` is 5 048 lines with 121 tests and 594 `toContain` / `indexOf` assertions on raw workflow text | `scripts/__tests__/ci-structure.spec.ts:545-551` (`requiredJob`, `workflowRunScript`) | +| A workflow parser exists but yields only job names and `needs` | `scripts/ci-workflow.ts:49` | +| `ci-change-scope.mjs` mixes 4 modules: path classification, git, Actions API and run reuse, plan validation | `scripts/ci-change-scope.mjs:22-212,412-580,582-653,624-754,1027-1534` | +| A spec splices 1 042- and 3 688-character strings from candidate `49f76db` onto the live workflow | `scripts/__tests__/ci-integration-scope.spec.ts:620-652` | + +## Solution + +Grow `ci-workflow.ts` into a typed model: job → `if`, `needs`, steps, `with`, `env`. Missing fields +fail closed. The invariants in `ci-structure.spec.ts` become checks over the model. Text assertions +stay only where byte order is the requirement. The classifier splits into a pure +`scripts/ci/classify.mjs` (no `fs`, git or `gh`) and `scripts/ci/reuse.mjs`. The historical splice +becomes explicit cases against the live model. + +## Acceptance Criteria + +- [ ] AC-1 [local]: The CI contract specs pass, and a `pnpm ci:local --affected` plan for a sample diff is unchanged before and after. proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` plus a plan diff +- [ ] AC-2 [shared]: One real PR run selects the same jobs as the last comparable run. proof: the `ci.yml` run on the PRD's PR, job list compared + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Change-scope plan | `ci.yml` scope job → `ci-change-scope.mjs` | Split, re-exported until callers move | Phase 2 | + +## Execution Phases + +#### Phase 1: Workflow model +**Status:** NOT STARTED +**Files:** `scripts/ci-workflow.ts`, `scripts/__tests__/ci-structure.spec.ts`. + +- [ ] `ci-workflow.ts` returns jobs with `if`, `needs`, steps, `with` and `env`, and an absent field throws. proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts` +- [ ] The job-shape invariants read the model, and the text-assertion count in the spec drops by at least half. proof: `rg -c 'toContain\(|indexOf\(' scripts/__tests__/ci-structure.spec.ts` against the 594 baseline + +#### Phase 2: Classifier split +**Status:** NOT STARTED +**Files:** new `scripts/ci/classify.mjs`, `scripts/ci/reuse.mjs`; `scripts/ci-change-scope.mjs`, `ci-integration-scope.mjs`, `ci-runner-admission.mjs`. + +- [ ] `classify.mjs` imports no `fs`, `child_process` or `gh` helper, and the old module re-exports it. proof: `pnpm exec vitest run scripts/__tests__/ci-needs.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` + +#### Phase 3: Live proofs only +**Status:** NOT STARTED +**Files:** `scripts/__tests__/ci-integration-scope.spec.ts`. + +- [ ] The historical splice is replaced by explicit native and browser routing cases on the live model. proof: `pnpm exec vitest run scripts/__tests__/ci-integration-scope.spec.ts` diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-840-platform-difference-is-a-value.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-840-platform-difference-is-a-value.md new file mode 100644 index 000000000..afdfc86c3 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-840-platform-difference-is-a-value.md @@ -0,0 +1,64 @@ +# PRD-840 — Platform difference is a value, not a scattered `#ifdef` + +**Status:** NOT STARTED +**Priority:** P1 — open Phase 1 removes 3 `__EMSCRIPTEN__` forks that make web and native draw different pictures, so a renderer fix proven on desktop never runs on web. +**Complexity:** 7 (HIGH); risk override: changes the draw order and batching on web and native, so the owner's visual-judge rule and web/native parity apply +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — N3 + +## Context + +Report ids H3, H2, H6, H4. There are 27 `__EMSCRIPTEN__` sites in 9 engine files. + +| Problem | Where | +| --- | --- | +| Opaque draws sort by depth on web and by state on native | `packages/runtime-native/src/engine/renderer/renderer.cpp:3142` | +| `batchSlot` returns the last group every web frame. The batched draw key differs per platform. | `packages/runtime-native/src/engine/renderer/render_database.cpp:1434,1535` | +| Features compiled out on web: `stablePasses_`, `setStablePasses`, `setStableRows`, slot map | `renderer.h:854,865,1457`, `render_database.h:230` | +| `renderer.cpp` is 7 102 lines, and `renderPrepared` alone is 2 743 (`:3291-6033`). `renderer.h` exposes about 1 200 public lines. | `renderer/renderer.cpp`, `renderer/renderer.h:365` | +| Renderer link list written twice for two exclusive builds | `cmake/NativeEngineCore.cmake:173`, `cmake/NativeEngine.cmake:55` | +| 31 env flags read ad hoc, some once and some per frame. 7 of them pick which arm renders. | `renderer.cpp:319-326`, `render_database.cpp:1223,1921`, `projection/plan.h:102-109` | + +## Solution + +`struct RenderFeatures { bool stablePasses, stateSortFirst, batchSlotReuse, recordCauseHashes; }` is +set once at construction from the host. The fields stay `constexpr`-foldable where a build pins them. +`#ifdef` remains only for code that cannot compile on a platform: threads, POSIX clock, error scopes. +One `engine/foundation/flags.h` declares each `TN_*` flag as read-once or per-frame. One CMake +function owns the renderer link line. Then `renderer.cpp` splits by concern (material, program, +shadow, output) behind `renderer_internal.h`, and a per-frame struct carries the locals that +`renderPrepared` shares. + +## Acceptance Criteria + +- [ ] AC-1 [local]: Web and native conformance stay green. proof: `pnpm parity` +- [ ] AC-2 [local]: The visual judge returns NEUTRAL or better on the starter, web and native, same pose. proof: judge verdict on this box +- [ ] AC-3 [local]: The starter frame time does not regress on web or native. proof: `pnpm bench:engines` paired A/B + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Render features | `web_host.cpp` and `player/run.cpp` construct the renderer | Render-semantics `#ifdef` deleted | Phase 1 | + +## Execution Phases + +#### Phase 1: `RenderFeatures` and flags +**Status:** NOT STARTED +**Files:** `renderer/renderer.h`, `renderer.cpp`, `render_database.{h,cpp}`, `wasm/web_host.cpp`, `player/run.cpp`, new `foundation/flags.h`. + +- [ ] No `__EMSCRIPTEN__` decides sort order, batching or pass stability, and each choice reads `features()`. proof: `rg -n '__EMSCRIPTEN__' packages/runtime-native/src/engine/renderer` lists only compile-only sites, plus `pnpm native:build` (desktop and Wasm) +- [ ] Every `TN_*` engine flag is read through `flags.h`, and a spec enumerates them. proof: `pnpm exec vitest run scripts/__tests__/ablation-flags-never-ship.spec.ts` extended + +#### Phase 2: One CMake link line +**Status:** NOT STARTED +**Files:** `cmake/NativeEngineCore.cmake`, `cmake/NativeEngine.cmake`. + +- [ ] One `tn_engine_renderer_target` function, and a configure check compares both builds' `LINK_LIBRARIES`. proof: `pnpm native:build` plus the Wasm configure + +#### Phase 3: Split `renderer.cpp` +**Status:** NOT STARTED +**Files:** `renderer/renderer.cpp` → `renderer_material.cpp`, `renderer_program.cpp`, `renderer_shadow.cpp`, `renderer_output.cpp`, new `renderer_internal.h`; `renderer.h`. + +- [ ] No renderer TU is over 3 000 lines, and `renderer.h` keeps only host-facing calls and value types. proof: `wc -l packages/runtime-native/src/engine/renderer/*.cpp` plus `pnpm native:verify:desktop` diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-841-game-ts-stops-being-the-hub.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-841-game-ts-stops-being-the-hub.md new file mode 100644 index 000000000..a3c4e8ad8 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-841-game-ts-stops-being-the-hub.md @@ -0,0 +1,60 @@ +# PRD-841 — `game.ts` stops being the file every feature edits + +**Status:** NOT STARTED +**Priority:** P2 — the open boxes delete the 46-field boot proxy, the hand-ordered teardown and the hard-wired scene-entry audits that put `game.ts` in 116 commits in 90 days. +**Complexity:** 4 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — N4 + +## Context + +Report ids A2, A3, A7. + +| Problem | Where | +| --- | --- | +| `#bootGame()` builds a 196-line `Object.defineProperties` proxy of 46 private fields. A cast hides a forgotten field, so the phase reads `undefined`. | `packages/core/src/game.ts:829-1025`, interface `packages/core/src/game-frame-loop.ts:90-139` | +| Teardown releases 30 fields in a hand-written order. A `#cleanup` stack already exists and is pushed by some phases. | `game.ts:1047-1140`, `#cleanup` at `game.ts:445`, pushes at `game-startup.ts:125,850,854` | +| `#enterScene` hard-wires 3 audits in a required order, each landed by its own PR | `game.ts:693-703` (`applyOpacityConvention`, `renderTrapAudit`, `unlitFoliageWarning`) | +| UI bridge, config, lifecycle and scene policy in one file | `game.ts:217-380,391-780,561-598` | + +## Solution + +Boot and loop phases take two narrow interfaces declared where they are used: `IBootPhase` and +`ILoopPhase` over a mutable `IBootState` that the phase owns. The proxy is deleted. Every acquisition +pushes its own disposer onto `#cleanup`. Ordering contracts are documented on the phase that pushes +them, and the field-by-field release is deleted. Scene-entry inspectors register into one ordered +list. The UI bridge moves to `game-ui.ts`. + +## Acceptance Criteria + +- [ ] AC-1 [local]: Start, scene change and stop keep their order and leak nothing. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts __tests__/game-stop-order.spec.ts` +- [ ] AC-2 [local]: The starter boots and plays its golden scenario. proof: `pnpm test:templates` (starter) + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Boot phases | `Game.start` → `game-startup.ts` | Proxy deleted | Phase 1 | +| Teardown | `Game.stop` | Hand release deleted | Phase 2 | + +## Execution Phases + +#### Phase 1: Narrow phase interfaces +**Status:** NOT STARTED +**Files:** `packages/core/src/game.ts`, `game-startup.ts`, `game-frame-loop.ts`. + +- [ ] Phases take `IBootPhase` / `ILoopPhase`, and `Object.defineProperties` is gone from `game.ts`. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts` plus `pnpm typecheck` + +#### Phase 2: Disposers on acquisition +**Status:** NOT STARTED +**Files:** `game.ts`, `game-startup.ts`, `game-frame-loop.ts`. + +- [ ] Every acquisition pushes its disposer, and `#teardown` only drains `#cleanup`. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game-stop-order.spec.ts` + +#### Phase 3: Inspectors and UI out of the hub +**Status:** NOT STARTED +**Files:** `game.ts`, new `game-ui.ts`, the 3 audit modules. + +- [ ] Scene-entry audits register in one ordered list, and adding one does not edit `game.ts`. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts` +- [ ] The UI bridge lives in `game-ui.ts`. proof: `pnpm --filter @threenative/ui test` diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-842-native-tests-check-behaviour-not-source-text.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-842-native-tests-check-behaviour-not-source-text.md new file mode 100644 index 000000000..ce20b633a --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-842-native-tests-check-behaviour-not-source-text.md @@ -0,0 +1,52 @@ +# PRD-842 — Native tests check behaviour, not source text + +**Status:** NOT STARTED +**Priority:** P2 — the open boxes replace regex assertions over `bindings.cpp` and `runtime.cpp`, which go red on renames and stay green on behaviour regressions, and delete a timer branch compiled in no build. +**Complexity:** 5 (MEDIUM) +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Untangle report, 2026-11-06](README.md) — N5 + +## Context + +Report ids D2, D5. In 120 days, `bindings.cpp` changed in 86 commits and `runtime.cpp` in 71. +These are the two most-changed native files, and 35 test files read them as text. + +| Problem | Where | +| --- | --- | +| 35 test files (12 397 lines) read `bindings.cpp`, `runtime.cpp`, `browser-renderer.ts` or `frame-op-stream.js` as text. They hold 166 `assert.doesNotMatch`. | `packages/runtime-native/tests/`, `packages/three-native/__tests__/` | +| The timer contract slices `runtime.cpp` between two literal strings | `packages/runtime-native/tests/timer-contract.test.mjs:21-30` | +| `embedded-js.mjs` hardcodes 7 historical C++ block names | `packages/runtime-native/tests/embedded-js.mjs:17-26` | +| `js-engine-fast-path.test.mjs` loads 3 engine sources as text | `packages/runtime-native/tests/js-engine-fast-path.test.mjs:12-20` | +| `MYSTRAL_USE_LIBUV_TIMERS` is defined in no build, yet its timer twin ships and a test locks its shape | `packages/runtime-native/src/runtime.cpp:1969,2871-2983` | + +## Solution + +Each asserted behaviour gets a callable seam: timer semantics through `timer_delivery_test.cpp`, +staging call order through a C++ unit test, and shim bootstrap through the embedded-bundle test. +The text assertions are deleted. The ABI digest in `web-host-abi.spec.ts` stays, because it guards a +real wire. The undefined-macro timer branch is deleted. + +## Acceptance Criteria + +- [ ] AC-1 [local]: The native host runs with timers and shims intact. proof: `pnpm native:verify:desktop` +- [ ] AC-2 [local]: The source-text test count drops to the ABI digest only. proof: `rg -l 'readFileSync\([^)]*\.(cpp|mm)' packages/runtime-native/tests packages/three-native/__tests__` lists only the ABI spec + +## Integration Ledger + +Integration: unchanged — tests and one dead branch only. + +## Execution Phases + +#### Phase 1: Timers +**Status:** NOT STARTED +**Files:** `packages/runtime-native/src/runtime.cpp`, `tests/timer-contract.test.mjs`, `tests/timer_delivery_test.cpp`. + +- [ ] The `MYSTRAL_USE_LIBUV_TIMERS` branch is deleted, and timer ordering is proved by `timer_delivery_test.cpp`, not by slicing source. proof: the native test binary (`tn-native-engine-tests`) plus `rg -n MYSTRAL_USE_LIBUV_TIMERS packages` returns nothing + +#### Phase 2: Bindings and shims +**Status:** NOT STARTED +**Files:** `tests/embedded-js.mjs`, `tests/js-engine-fast-path.test.mjs`, `tests/embedded_bundle_test.cpp`, the bindings text specs. + +- [ ] Shim bootstrap and fast-path selection are asserted by running the host, and the 7 historical block names are gone. proof: `node --test packages/runtime-native/tests/` plus `embedded_bundle_test` +- [ ] The remaining text specs on `bindings.cpp` and `browser-renderer.ts` are replaced by behaviour checks or deleted when a behaviour test already covers them. proof: AC-2 diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/README.md b/docs/PRDs/refactoring/untangle-2026-11-06/README.md new file mode 100644 index 000000000..7cd9064f8 --- /dev/null +++ b/docs/PRDs/refactoring/untangle-2026-11-06/README.md @@ -0,0 +1,149 @@ +# Untangle report, 2026-11-06 + +Date: 2026-11-06. Snapshot: `origin/develop` 58a83ce4b. No code changed. +Method: 8 read-only leanpi scouts on DeepSeek v4.1 flash max (0 Claude escalations), one per key area. The coordinator merged the findings and spot-checked the highest-impact ones. +Raw scout reports (local to the owner machine, not tracked): `~/.cache/tmp-tn/untangle/out/{A..H}.md`. The urgent slice is [PRD-832](PRD-832-urgent-single-sources.md). + +| Scout | Area | +| --- | --- | +| A | core hub: `game.ts`, `index.ts`, `config.ts`, `playtest.ts` | +| B | core render pipeline: `renderer.ts`, `render/`, `look/world-environment.ts` | +| C | core world streaming and assets: `world-*`, `model-lod.ts`, `assets.ts`, terrain overlap | +| D | three-native facade and native host seam: `browser-backend.ts`, `bindings.cpp`, `runtime.cpp` | +| E | playtest harness: runner, scenario schema, evaluators | +| F | create-threenative scaffolder and templates | +| G | `scripts/`, root `package.json`, `.github/workflows/` | +| H | native C++ engine: `runtime-native/src/engine/`, CMake | + +Verified by the coordinator (re-ran the command): B1/F2 (7 render files, 12 copies, 1 hash each), A1 (two byte-identical 556 KB `capabilities.json`), A6 (literal `version = "0.3.12"`), C2 (`?? 0.1` outlier at `world-cell-chunks.ts:784`), D5 (`MYSTRAL_USE_LIBUV_TIMERS` defined in no build), E1 (`924b92f82` = 18 files in `playtest/src` for one assertion kind), E3 (`PlaytestTarget = "web" | "desktop" | "bevy"`), F5 (stale 4-kit name list at `index.ts:918`), G4 (recall list misses 3 kits), G7 (`.mjs` duplicate unused), H2 (`renderer.cpp` 7 102 lines), A2 (`Object.defineProperties` boot proxy). Everything else is a scout claim, not verified. + +## PRDs + +| PRD | Report id | Goal | Priority | +| --- | --- | --- | --- | +| [PRD-832](PRD-832-urgent-single-sources.md) | Q1-Q5, R2 slice | Urgent single sources: kit inventory, render drift guard, launch flags, tone curve, core version, named `tnw_web_frame` fields | P1 | +| [PRD-833](PRD-833-template-mechanism-lives-once.md) | R1 | Template mechanism lives once | P1 | +| [PRD-835](PRD-835-one-registry-entry-per-assertion.md) | R3 | One registry entry per assertion | P1 | +| [PRD-840](PRD-840-platform-difference-is-a-value.md) | N3 | Platform difference is a value, not a scattered `#ifdef` | P1 | +| [PRD-834](PRD-834-cross-language-contracts-are-generated.md) | R2 | Cross-language contracts are generated | P2 | +| [PRD-836](PRD-836-one-committed-capability-manifest.md) | R4 | One committed capability manifest | P2 | +| [PRD-837](PRD-837-core-owns-each-shared-fact-once.md) | R5 | Core owns each shared fact once | P2 | +| [PRD-838](PRD-838-scripts-share-one-runtime-layer.md) | N1 | Scripts share one runtime layer | P2 | +| [PRD-839](PRD-839-ci-contract-is-a-model-not-text.md) | N2 | The CI contract is a model, not text matches | P2 | +| [PRD-841](PRD-841-game-ts-stops-being-the-hub.md) | N4 | `game.ts` stops being the hub | P2 | +| [PRD-842](PRD-842-native-tests-check-behaviour-not-source-text.md) | N5 | Native tests check behaviour, not source text | P2 | + +The "Later" god-file splits below have no PRD yet. Each one gets its own PRD when it is picked. + +## Priority table (effort x impact) + +Effort: S < 1 h, M half day, L 1-3 days. Impact 1-5 (5 = touches most future changes in the area). + +### Quick wins (effort S, impact 3+) + +| # | Fix | Solves | Effort | Impact | +| --- | --- | --- | --- | --- | +| Q1 | Drift test: fail when a `src/render/*` path is byte-identical in 2+ kits and not in the shared list | F2, guards B1 until R1 lands | S | 5 | +| Q2 | One `scripts/lib/repo-inventory.ts` (packages, kits, Node version derived from disk) | G4 (bug: recall skips `puzzle`, `rts`, `runner`), 65 Node pins | S | 4 | +| Q3 | One `readLaunchFlag(name)` in core, delete 16 copies with 3 truthiness rules | A4 | S | 4 | +| Q4 | `WorldEnvironment.apply` calls `applyRendererToneMapping`; delete its own map and `?? "aces"` | B3 (also check: `aces` default vs the owner's filmic default, unverified) | S | 4 | +| Q5 | Core version from `version.ts` only; delete the literal and the release regexes; doctor reads core's `package.json` | A6, F7 | S | 3 | + +Also S: C4 asset-loader fallback fails closed, C5 move `cullDistance`, C6 `WorldCells.terrain` getter, A5 one `dev-global.ts`, B6 exposure probe into `startup-readiness.ts`, B8 failed adapter probe lands on `low`, D7 script-name enum fails closed, D8 merge 6 audio fragments, G5 perf verdict rule in one place, G7 delete 5 dead scripts and wire or delete 4 orphan gates, H4 one C++ `flags.h`, H6 one CMake renderer link function, E5 split `perf.ts`, E7 one `delay`, F5 delete stale kit list. + +### Big bets (impact 5) + +| # | Root fix | Solves | Effort | Impact | +| --- | --- | --- | --- | --- | +| R1 | Template mechanism lives once: move the 7 render files to a core subpath; restamp other shared sources from one copy; scaffold hash split into `shared` + `kit` with a `pnpm scaffold:pin` | B1, F1, F2, F3, F4, F8, B2 (12 copies of the WebGL gate) | L | 5 | +| R2 | One manifest per cross-language contract, generating C++ enum + TS const, unknown id aborts | D1 = H1 (`tnw_web_frame` 87 integer cases, `default: return 0`), D3 (frame-op opcodes), E4 (`TN_*` markers, 4 declarations), A8 (config keys read by string in C++), C7 (`__tnCopyMatrixWorld`), D7 | L (first slice `tnw_web_frame`: M) | 5 | +| R3 | One registry entry per extension point carries validate, evaluate, observation path and supported lanes; the rest is generated | E1 (17 files per assertion), E3, E6, E2 (`requiresPixels` x3) | M | 5 | +| R4 | One committed capability manifest; everything else is build output | A1 (median 34 files per `index.ts` commit, pre-commit hook adds 5 files silently) | M | 5 | +| R5 | Core owns each shared fact once: `selectLodFor`, the `world.json` contract, `rendererFacts()`, one `GpuReadings` | C2 (LOD recipe x6), C1 (3 manifest declarations, dead `impostor: 110` knob), B2 (`"engine" in info` x8, `kind === "webgpu"` x58), B4 (3 freshness rules) | M each | 5 | + +### Next (impact 4-5, larger) + +| # | Root fix | Solves | Effort | Impact | +| --- | --- | --- | --- | --- | +| N1 | `scripts/lib/{dev-server,exec,mcp-client,gh,args}.ts`, strictest variant wins | G1 (4 helper families x5-11), G6 | M | 5 | +| N2 | CI workflow model in `ci-workflow.ts`; invariants as data; split classifier from reuse | G2 (5 048-line text-match spec), G3, G8 | L | 5 | +| N3 | `RenderFeatures` value set at construction instead of `#ifdef __EMSCRIPTEN__` in render semantics; then split `renderer.cpp` by concern | H3, H2 (2 743-line `renderPrepared`) | M + L | 5 | +| N4 | `game.ts`: phases take narrow interfaces, each acquisition pushes its disposer, scene-entry inspectors register in one ordered list | A2 (46-field proxy), A3 (hand-ordered teardown), A7 | M-L | 5 | +| N5 | Behaviour seams instead of regex over C++ source | D2 (35 test files read `bindings.cpp`/`runtime.cpp` as text) | L | 5 | + +Later (impact 3-4, god-file splits along their own seams): B5 `renderer.ts`, B7 `WorldEnvironment.apply`, C3 two residency budgets, C8 `CellRender` host, D4 browser-backend mirror table, D5 `runtime.cpp`, D6 `bindings.cpp` + twin `BindingsState`, E3 `deviceRunner.ts`, F6 scaffolder `index.ts`, H5 `MATERIAL_FIELDS` table, H7, H8 shared host support. + +## Decision log (fix above the problems it solves) + +- **R1. Template mechanism lives once.** AGENTS.md already says mechanism goes to `packages/core/src/`; the templates hold copies instead. + - B1/F2: `environmentContribution`, `environmentRadiance`, `environmentSampleCache`, `environmentSampling`, `environmentSnapshot`, `backlightControls`, `backlightMaterial` are byte-identical in 12 kits (9 084 lines). `c93b427d0` touched 552 files for one look change. + - F3: `vite.config.ts` 10/16 identical, `main.ts` 11/16, `tsconfig.json` 8/16, no restamp. + - F4: the framework patch filename is spelled in 40 places, including a `prepack` one-liner. + - B2: the WebGL fallback predicate is defined in 12 `templates/*/src/render/quality.ts:73`. + - F1: `scaffold.spec.ts` (301 commits in 90 days, the most-changed file) holds 16 whole-tree hashes and ~170 lines of re-pin prose. A shared edit re-pins 16. + - F8: "what ships" is pinned 3 times (hashes, `STARTER_PATHS`, allowlist). + - F5: `index.ts:918` re-copies for 4 named kits; 7 kits ship the file. +- **R2. One manifest per cross-language contract.** The existing `api/catalog.json` → `catalog_types.inc` generator is the precedent. + - D1/H1: `web_host.cpp:1019` `tnw_web_frame(int)`, 87 cases, `default: return 0`; TS mirrors at `browser-renderer.ts` (56 call sites) and `gpu-stages.ts:33-49`. The ABI digest hashes signatures only. + - D3: `frame-op-stream.js` has 60 bare opcode ints mirrored in `bindings_frame_stream.cpp:76` and two switches. + - E4: markers printed in C++ and TS, parsed in `perf.ts:30-58`, re-declared in `engine-cost.ts:4` and `frame-cost.ts:6`. Window payload type declared twice (`frame-budget.ts:264`, `perf.ts:112`). + - A8: `window.cpp:181` reads `"bootSplash"` by string search; TS has both `render` and `renderer`. + - C7: `__tnCopyMatrixWorld` defined in `bindings_scene.cpp:714`, read in 3 JS files, no shared name. + - D7: 16 embedded-script call sites pass literal names; a missing script prints one stderr line. +- **R3. One registry entry per extension point.** + - E1: one assertion kind = 17 hand-edited files; `runner.spec.ts:676-694` pins 3 kinds that shipped with no runner. + - E3: `supportedOn` cannot name android or ios; the lane matrix is 11 `if` blocks in `androidRunner.ts:1185-1275`. + - E6: `geometry` is outside the registry; `requiredCapabilities` and `trivialityRationale` are read by nothing at runtime. + - E2: capture-with-retry is open-coded 5 times across browser and device lanes. + - H5 (C++ analogue): one material field touches 11-17 files. + - A7 (core analogue): each scene audit edits `#enterScene` in `game.ts:693-703`. +- **R4. One committed capability manifest.** + - A1: `core/capabilities.json` and `create-threenative/capabilities.json` are the same 556 KB. 82 of 117 `index.ts` commits also touch the copy. The pre-commit hook stages 5 regenerated files. + - A6/F7: version literal in `index.ts:9`, `doctor.ts:215`, plus 5 regex patches in `prepare-release.ts:246-270`. +- **R5. Core owns each shared fact once.** + - C2: `worldSphere → conservativeViewDepth → bias → selectLodLevel` inline at 6 sites; one uses `near ?? 0.1`, five use `?? 0`. + - C1: `world.json` declared in core, terrain and assets; terrain lacks `impostorDistance`; biome `impostor: 110` is read by nothing. + - B2: backend facts re-derived as `"engine" in info` (8 sites) and `kind === "webgpu"` (58 sites). + - B4: GPU-sample freshness is 8 frames in the renderer, 16 in the scaler, per-frame in the budget. + - B3: two tone-mapping maps, defaults `filmic` vs `aces`. + - A4: 16 launch-flag readers with different truthiness. + - A5: `__THREENATIVE__` has 6 owners; a missing worker mirror entry hides a member in worker mode. + - H4: 31 C++ env flags, some read once, some per frame. + - G4/G5: kit and package lists typed by hand in scripts; perf verdict rule copied into `@threenative/playtest`. +- **N3. Platform difference is a value, not a scattered `#ifdef`.** + - H3: 27 `__EMSCRIPTEN__` sites; 3 change which picture is drawn (`renderer.cpp:3142`, `render_database.cpp:1434,1535`). + - H6: renderer link list written twice in CMake. + - H8: `run.cpp` and `web_host.cpp` re-implement adapter, limits, surface and readback setup. + - D6: `BindingsState` has two layouts under `#if` (`bindings_state.h:641-679`). +- **N4. `game.ts` stops being the place every feature edits.** + - A2: 196-line `Object.defineProperties` proxy of 46 private fields, cast hides a forgotten field. + - A3: 93-line hand-ordered teardown beside an unused `#cleanup` stack. + - A7: UI bridge, scene-entry policy, config and lifecycle in one file (116 commits in 90 days). +- **N5 and N2. Tests check behaviour, not source text.** (This is the skill's optional "Tests" step.) + - D2: 35 test files (12 397 lines) regex `bindings.cpp`, `runtime.cpp` and `browser-renderer.ts`; 166 `doesNotMatch`. + - G2: `ci-structure.spec.ts` 5 048 lines, 594 text assertions on `ci.yml`. + - G8: a spec splices 3 688 characters of a historical workflow onto the live one. + - D5: `timer-contract.test.mjs` locks the shape of a timer branch compiled in no build. + +## Special cases (the skill's progress measure) + +| Area | Count | Main kinds | +| --- | --- | --- | +| A core hub | 6 | 2 empty catches, 4 timer waits in `playtest.ts` | +| B render | 28 | 25 empty catches, duplicate defaults | +| C world | 19 | 10 empty catches in `assets.ts`, 4 `skipIf(!bench)` | +| D facade/host | 62 | 39 platform `#if`, negative-number commands, dead macro branch | +| E playtest | 111 | 63 empty catches, 31 `target === "…"`, 12 hand waits | +| F scaffolder | 62 | 42 empty catches, 12 `process.platform`, 4 per-kit lists in tests | +| G scripts | 54 | 21 `setTimeout` waits, 8 retry loops, 7 conditional skips | +| H C++ engine | 23 | `__EMSCRIPTEN__` forks, single-value flags | +| **Total** | **365** | about 150 swallowed catches | + +## Defects found on the way (not a bug hunt) + +- ✅ verified: `scripts/capability-recall.ts:120` skips kits `puzzle`, `rts`, `runner` (fixed by Q2). +- ✅ verified: LOD near-plane default disagrees (`?? 0.1` vs `?? 0`) (fixed by R5). +- ⚠️ unverified: failed adapter probe → software machine gets tier `high` (`renderer.ts:1975`, B8). +- ⚠️ unverified: registry marks `tone` unsupported on android while the android runner supports it (E3). +- ⚠️ unverified: `WorldEnvironment` defaults `tonemapMode` to `aces` (`world-environment.ts:793`); the owner rule says filmic by default. A preset may supply filmic upstream. +- ⚠️ unverified: `render` vs `renderer` config keys, one silently ignored (A8). From 16fdd2d16e3ef78ef0bb37f813b3162e9f6c28ea Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:08:18 -0700 Subject: [PATCH 002/154] refactor(core,scripts): one owner for the kit inventory, launch flags, tone curve and core version PRD-832 phases 1 and 2. scripts/lib/repo-inventory.mjs derives the kits, the brief genres, the workspace packages and the Node floor from disk, so the recall no longer rejects the puzzle, rts and runner kits it had never heard of. A drift spec fails on any render source that ships byte-identical in every kit that carries it and is not restamped: it named nine copies, not the seven the report counted, and all nine are now shared and restamped to no-op. debug.ts owns readLaunchFlag/readLaunchValue, replacing fourteen readers and three truthiness rules. WorldEnvironment delegates its tone curve to applyRendererToneMapping (the preset already made filmic the default), and the core version now has one source: the generated version.ts. --- .../PRD-832-urgent-single-sources.md | 16 +-- .../core/__tests__/world-gpu-flags.spec.ts | 37 +++++-- packages/core/src/debug.ts | 98 ++++++++++++++++--- packages/core/src/index.ts | 9 +- .../core/src/look/default-tone-mapping.ts | 44 ++++++++- packages/core/src/look/world-environment.ts | 76 ++++++-------- packages/core/src/profiling/FrameStages.ts | 11 +-- packages/core/src/profiling/Spans.ts | 20 +--- .../core/src/profiling/debug-telemetry.ts | 8 +- .../src/profiling/render-list-validate.ts | 21 ++-- packages/core/src/quiet-markers.ts | 13 +-- packages/core/src/render/depth-pyramid.ts | 20 ++-- packages/core/src/render/virtual-shadow.ts | 23 +++-- .../core/src/spells/ultra/engine/quality.ts | 10 +- packages/core/src/world-cell-options.ts | 16 +-- packages/core/src/world-gpu-flags.ts | 68 ++++--------- packages/core/src/world-tile-budget.ts | 15 +-- packages/core/src/world-validate.ts | 20 ++-- .../__tests__/shared-render-drift.spec.ts | 81 +++++++++++++++ packages/create-threenative/src/doctor.ts | 9 +- packages/create-threenative/src/index.ts | 24 ++++- scripts/__tests__/capability-recall.spec.ts | 10 ++ scripts/__tests__/ci-local.spec.ts | 5 + scripts/__tests__/ci-native-chain.spec.ts | 6 ++ scripts/__tests__/repo-inventory.spec.ts | 55 +++++++++++ scripts/bake-delete-test.ts | 14 +-- scripts/capability-recall.ts | 31 +----- scripts/ci-change-scope.mjs | 11 +-- scripts/ci-integration-scope.mjs | 10 ++ scripts/lib/repo-inventory.d.mts | 8 ++ scripts/lib/repo-inventory.mjs | 64 ++++++++++++ scripts/prepare-release.ts | 15 --- 32 files changed, 548 insertions(+), 320 deletions(-) create mode 100644 packages/create-threenative/__tests__/shared-render-drift.spec.ts create mode 100644 scripts/__tests__/repo-inventory.spec.ts create mode 100644 scripts/lib/repo-inventory.d.mts create mode 100644 scripts/lib/repo-inventory.mjs diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md index 137f1fbdb..e1807898b 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -1,6 +1,6 @@ # PRD-832 — Urgent single sources: one owner for each value that has silently drifted -**Status:** NOT STARTED +**Status:** PARTIAL — Phases 1 and 2 landed; Phase 3 (named `tnw_web_frame` fields) is open **Priority:** P1 — the open Phase 1 box fixes a live gap: `pnpm budgets` runs capability recall without the `puzzle`, `rts` and `runner` kits. The open Phase 3 boxes remove a web telemetry channel that answers 0 to an unknown field. **Complexity:** 5 (MEDIUM); risk override: Phase 2 can change the default tone curve, so the owner's visual-judge rule applies to that box **Owner:** ThreeNative maintainers @@ -56,19 +56,19 @@ the way its own red-green commit. ## Execution Phases #### Phase 1: Repo inventory and template drift guard -**Status:** NOT STARTED +**Status:** DONE **Files:** new `scripts/lib/repo-inventory.ts`; edit `scripts/capability-recall.ts`, `scripts/ci-change-scope.mjs`, `scripts/ci-integration-scope.mjs`, `scripts/visual-gate.ts`, `packages/create-threenative/src/index.ts`; new drift spec under `packages/create-threenative/__tests__/`. -- [ ] Recall accepts and harvests every shipped kit (red first: `--template puzzle` rejected today). No script keeps a hand kit or package list. proof: `pnpm exec vitest run scripts/__tests__/capability-recall.spec.ts scripts/__tests__/ci-change-scope.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` plus `rg -n '"tower-defense"' scripts --glob '!__tests__'` returns nothing -- [ ] The drift spec fails on today's tree with the 7 unlisted files named, then passes once they are in `SHARED_RENDER_SOURCES`. proof: `pnpm --filter create-threenative exec vitest run __tests__/shared-render-drift.spec.ts` +- [x] Recall accepts and harvests every shipped kit (red first: `--template puzzle` rejected with `TN_CAPABILITY_RECALL: unknown template 'puzzle'`; now resolves). No script keeps a hand kit or package list: `scripts/lib/repo-inventory.mjs` derives kits from `templates/*/kit.json`, brief genres from `docs/benchmark/genres/*/brief.md`, packages from `packages/*/package.json` and the Node floor from root `engines.node`. `capability-recall.ts`, `ci-change-scope.mjs`, `ci-integration-scope.mjs` (which now fails closed on a package name that left the workspace) and `bake-delete-test.ts` read it. proof: `pnpm exec vitest run scripts/__tests__/capability-recall.spec.ts scripts/__tests__/ci-integration-scope.spec.ts scripts/__tests__/repo-inventory.spec.ts` → 199 passed; `rg -n '"tower-defense"' scripts --glob '!__tests__'` returns nothing. The `scripts/__tests__/ci-change-scope.spec.ts` this box named does not exist — the specs over that module are `ci-native-chain`, `ci-needs`, `ci-leg-carry`, `ci-local` and `capability-site-docs`, all green. +- [x] The drift spec failed on today's tree naming the unlisted copies, then passed once they are in `SHARED_RENDER_SOURCES`. It named 9, not 7: `atmosphere.ts` (6 kits) and `gpuSky.ts` (6 kits) ship byte-identical everywhere too. The rule is "same bytes in every kit that carries it, in 2+ kits", so a per-kit look that merely coincides in 4 kits (`exposure.ts` has 10 distinct copies) stays out. `ADDED_RENDER_SOURCES` keeps the scaffold-time copy into starter/collectathon/adventure/kart unchanged, and `pnpm stamp:template-render` is byte-for-byte a no-op. proof: `pnpm --filter create-threenative exec vitest run __tests__/shared-render-drift.spec.ts` → 3 passed, plus `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/scaffold-file-allowlist.spec.ts` → 80 passed #### Phase 2: Core single owners -**Status:** NOT STARTED +**Status:** DONE **Files:** `packages/core/src/debug.ts` plus the 16 reader sites in report A4; `packages/core/src/look/world-environment.ts`; `packages/core/src/index.ts`; `packages/create-threenative/src/doctor.ts`; `scripts/prepare-release.ts`. -- [ ] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` plus `rg -c 'URLSearchParams' packages/core/src` drops by the deleted copies -- [ ] `WorldEnvironment` uses `applyRendererToneMapping`. A scene with no look options gets `filmic` and reports it in `TN_TONEMAP`. If that changes the default from today, a visual judge on the starter returns NEUTRAL or better. proof: `pnpm --filter @threenative/core exec vitest run __tests__/world-environment-defaults.spec.ts`, plus the judge verdict on this box if the default moved -- [ ] Core version has one source: `index.ts` re-exports `CORE_VERSION`, `doctor.ts` reads core's resolved `package.json`, and `prepare-release.ts` drops those regexes. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` +- [x] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. `debug.ts` exports `readLaunchFlag(spec, "on" | "off", search?)` and `readLaunchValue(spec)` over the three seams; 14 readers in 13 core files now call it (`world-gpu-flags` ×4, `world-validate`, `world-tile-budget`, `world-cell-options`, `world-environment`, `render-list-validate`, `debug-telemetry`, `Spans`, `FrameStages`, `depth-pyramid`, `virtual-shadow` ×3, `quiet-markers`, `spells/ultra/engine/quality`). One truthiness rule replaced three: `0`/`false` are off, a named-but-empty value is on, an empty variable is unset; every seam that names the flag is heard, so a harness global is not lost behind a disagreeing environment variable. `ui-bridge` keeps its own read because it reads a passed scope's `location` (a worker realm), not this launch's. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` and the whole core suite (`npx vitest run packages/core/__tests__` → 4559 passed, 4 skipped); `URLSearchParams` occurrences in `packages/core/src` fell 5 → 3 and hand `location.search` reads 14 → 2 (`debug.ts` owns them). `world-gpu-flags.spec.ts` was rewritten where it pinned the three old rules: it asserted `?tnX=` off for one switch and on for `debugFlag`, and `"true"` off for one global and on for another. +- [x] `WorldEnvironment` uses `applyRendererToneMapping`. The default did not move: `qualityPreset()` already supplied `filmic`, and the `?? "aces"` fallback was dead — so no visual judge is owed. The private `TONEMAP` map and the duplicated filmic install are deleted, and `default-tone-mapping.ts` now owns the curve list (`RENDERER_TONE_MAPPINGS`, `isRendererToneMapping`) and accepts the game's curve and grade. proof: `pnpm --filter @threenative/core exec vitest run __tests__/world-environment-defaults.spec.ts __tests__/default-tone-mapping.spec.ts` → 31 passed. +- [x] Core version has one source: `index.ts` re-exports `CORE_VERSION` from the generated `version.ts`, `doctor.ts` imports that constant instead of a literal, and `prepare-release.ts` drops the three regexes that patched them. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` → 112 passed. #### Phase 3: Named `tnw_web_frame` fields **Status:** NOT STARTED diff --git a/packages/core/__tests__/world-gpu-flags.spec.ts b/packages/core/__tests__/world-gpu-flags.spec.ts index d8ccead16..9ec896b90 100644 --- a/packages/core/__tests__/world-gpu-flags.spec.ts +++ b/packages/core/__tests__/world-gpu-flags.spec.ts @@ -57,6 +57,7 @@ describe("gpuSceneRequested", () => { env(GPU_SCENE_FLAG, value); expect(gpuSceneRequested(), `TN_GPU_SCENE=${value}`).toBe(true); } + env(GPU_SCENE_FLAG, undefined); for (const search of ["?tnGpuScene=0", "?tnGpuScene=false", "?a=1&tnGpuScene=0&b=2"]) { query(search); expect(gpuSceneRequested(), search).toBe(false); @@ -65,6 +66,7 @@ describe("gpuSceneRequested", () => { query(search); expect(gpuSceneRequested(), search).toBe(true); } + query(undefined); for (const value of [false, 0, "0", "false"]) { setGlobal("__tnGpuScene", value); expect(gpuSceneRequested(), `__tnGpuScene=${String(value)}`).toBe(false); @@ -119,15 +121,23 @@ describe("bundlesAsked", () => { expect(bundlesAsked()).toBe("off"); }); - it("lets a query string's on beat the environment's off, because askedOn runs first over every source", () => { + it("reads the first seam that names the switch, so the environment beats the query string", () => { env(BUNDLE_FLAG, "0"); - // askedOn reads the query string as well, so an explicit on anywhere wins over an off. query("?tnBundles=1"); - expect(bundlesAsked()).toBe("on"); + expect(bundlesAsked()).toBe("off"); query(undefined); expect(bundlesAsked()).toBe("off"); }); + // `bundles` answers three ways where the other switches answer two: a launch that said nothing + // gets the measurement's default, which is neither of the answers a caller can type. + it("keeps a value it does not understand as default rather than as on", () => { + query("?tnBundles=yes"); + expect(bundlesAsked()).toBe("default"); + query("?tnBundles"); + expect(bundlesAsked()).toBe("on"); + }); + it("reports the option bundlesRequested reads", () => { env(BUNDLE_FLAG, "0"); expect(bundlesRequested()).toBe(false); @@ -145,10 +155,14 @@ describe("shadowGpuKeysRequested", () => { env(SHADOW_GPU_KEYS_FLAG, value); expect(shadowGpuKeysRequested(), `TN_SHADOW_GPU_KEYS=${value}`).toBe(true); } - for (const value of ["0", "false", "", "yes"]) { + for (const value of ["0", "false", ""]) { env(SHADOW_GPU_KEYS_FLAG, value); expect(shadowGpuKeysRequested(), `TN_SHADOW_GPU_KEYS=${value}`).toBe(false); } + // One rule for every switch: only `0` and `false` are off, so any other value asks for it. + env(SHADOW_GPU_KEYS_FLAG, "yes"); + expect(shadowGpuKeysRequested()).toBe(true); + env(SHADOW_GPU_KEYS_FLAG, undefined); query("?tnShadowGpuKeys=1"); expect(shadowGpuKeysRequested()).toBe(true); query("?tnShadowGpuKeys=0"); @@ -168,6 +182,7 @@ describe("lodFromFollowRequested", () => { expect(lodFromFollowRequested()).toBe(true); env("TN_LOD_FROM_CAMERA", "1"); expect(lodFromFollowRequested()).toBe(false); + env("TN_LOD_FROM_CAMERA", undefined); query("?tnLodFromCamera=false"); expect(lodFromFollowRequested()).toBe(true); }); @@ -198,14 +213,13 @@ describe("gpuSceneValidationRequested", () => { query(search); expect(gpuSceneValidationRequested(), search).toBe(true); } - for (const search of [ - "?tnGpuSceneValidate=0", - "?tnGpuSceneValidate=false", - "?tnGpuSceneValidate=", - ]) { + for (const search of ["?tnGpuSceneValidate=0", "?tnGpuSceneValidate=false"]) { query(search); expect(gpuSceneValidationRequested(), search).toBe(false); } + // A URL that names the switch and gives it no value has no other way to say yes. + query("?tnGpuSceneValidate="); + expect(gpuSceneValidationRequested()).toBe(true); query("?other=1"); expect(gpuSceneValidationRequested()).toBe(false); }); @@ -215,9 +229,12 @@ describe("gpuSceneValidationRequested", () => { expect(gpuSceneValidationRequested()).toBe(true); setGlobal("__tnGpuSceneValidate", "1"); expect(gpuSceneValidationRequested()).toBe(true); - for (const value of ["0", 0, false, "true"]) { + for (const value of ["0", 0, false, "false"]) { setGlobal("__tnGpuSceneValidate", value); expect(gpuSceneValidationRequested(), `__tnGpuSceneValidate=${String(value)}`).toBe(false); } + // `true` is one of the two spellings of on, as it is for every other launch switch. + setGlobal("__tnGpuSceneValidate", "true"); + expect(gpuSceneValidationRequested()).toBe(true); }); }); diff --git a/packages/core/src/debug.ts b/packages/core/src/debug.ts index 5e4628170..bae3cade5 100644 --- a/packages/core/src/debug.ts +++ b/packages/core/src/debug.ts @@ -15,6 +15,90 @@ function envName(name: string): string { return `TN_DEBUG_${name.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase()}`; } +/** + * The three seams a launch has, named once. A native host sets the environment variable, a browser + * page asks with the query parameter, and a test or capture harness sets the global before boot. + */ +export interface ILaunchFlagSpec { + /** Environment variable a native host sets, e.g. `TN_TERRAIN_VALIDATE`. */ + readonly environment: string; + /** Query parameter a browser asks with, e.g. `tnTerrainValidate`. */ + readonly query: string; + /** Global a test or harness sets before the game boots, e.g. `__tnTerrainValidate`. */ + readonly global?: string; +} + +/** + * Every seam that named `flag`, in the order they are read: environment, query string, global. + * + * An empty environment variable is skipped — a shell that exports a name with no value did not ask + * for anything — while an empty value on any other seam means the seam named the flag and had no + * value to give, which is a URL's way of saying yes. + */ +function readLaunchSeams(spec: ILaunchFlagSpec, search?: string): readonly string[] { + const values: string[] = []; + if (spec.environment.length > 0) { + const env = envValue(spec.environment); + if (env !== undefined && env !== "") values.push(env); + } + const query = search ?? globalThis.location?.search; + if (typeof query === "string") { + const fromQuery = new URLSearchParams(query).get(spec.query); + if (fromQuery !== null) values.push(fromQuery); + } + if (spec.global !== undefined) { + const fromGlobal: unknown = (globalThis as Record)[spec.global]; + if (typeof fromGlobal === "string") values.push(fromGlobal); + else if (typeof fromGlobal === "number") values.push(String(fromGlobal)); + else if (typeof fromGlobal === "boolean") values.push(fromGlobal ? "true" : "false"); + } + return values; +} + +/** `0` and `false` are the only two spellings of off; a named-but-empty value is on. */ +function isOff(value: string): boolean { + return value === "0" || value === "false"; +} + +/** + * Whether this launch asked for a flag, on one rule for every subsystem. + * + * `askedFor` says which answer the caller cares about: an opt-in switch (`"on"`, the default) is off + * when no seam asked for it, and an opt-out switch (`"off"`) is on unless some seam asked it off. + * Every seam that names the flag is heard, so a global a test sets is not lost behind an environment + * variable that happens to disagree; what changes from the sixteen hand-written readers this + * replaces is the vocabulary, not the seams: `0` and `false` are off and everything else is on. + * + * `search` is the query string to read, for a caller that already read the URL once at setup. + * + * @situation read one of the engine's launch switches the same way every subsystem does + * @example const validate = readLaunchFlag({ environment: "TN_TERRAIN_VALIDATE", query: "tnTerrainValidate" }); + * @constraint `0` and `false` are off; a named-but-empty value is on; an empty variable is unset + */ +export function readLaunchFlag( + spec: ILaunchFlagSpec, + askedFor: "on" | "off" = "on", + search?: string, +): boolean { + const values = readLaunchSeams(spec, search); + return askedFor === "off" + ? values.some((value) => isOff(value)) + : values.some((value) => !isOff(value)); +} + +/** + * The text this launch gave a flag, or `undefined` when no seam named it. + * + * For switches whose value is not a yes or no — an occlusion mode, a numeric override — the caller + * parses. An empty string means a seam named the flag with no value. + * + * @situation read a launch override whose value is not a boolean + * @example const mode = readLaunchValue({ environment: "TN_OCCLUSION", query: "tnOcclusion" }); + */ +export function readLaunchValue(spec: ILaunchFlagSpec): string | undefined { + return readLaunchSeams(spec)[0]; +} + /** * `process.env` on a host that installed one, narrowed rather than asserted exactly as * `isDevLaunch` does in `game.ts`: it exists on the native runtime and not in a browser bundle. @@ -31,11 +115,6 @@ function envValue(name: string): string | undefined { return typeof value === "string" ? value : undefined; } -/** Not one of the two spellings of off. */ -function isOn(value: string): boolean { - return value !== "0" && value !== "false"; -} - /** * A debug switch read from the URL, or from the environment on a native launch. * @@ -47,14 +126,7 @@ function isOn(value: string): boolean { * "off" rather than quietly turning it back on. */ export function debugFlag(name: string): boolean { - // An exported-but-empty variable is off, as `DEV_MODE=` is to the engine's own reader; a bare - // `?freeCam` is on, because the query string has no other way to say it. - const env = envValue(envName(name)); - if (env !== undefined && env !== "" && isOn(env)) return true; - const search: unknown = globalThis.location?.search; - if (typeof search !== "string") return false; - const value = new URLSearchParams(search).get(name); - return value !== null && isOn(value); + return readLaunchFlag({ environment: envName(name), query: name }); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 484648dd3..346f03068 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,12 +1,11 @@ /** * The version this library reports. * - * It read `0.1.0` while the package published `0.2.0`, and `__tests__/build.spec.ts` asserted the - * stale literal, so the test held the bug in place rather than catching it. A literal is - * unavoidable here — core is bundled for browsers and cannot read `package.json` at runtime — so - * the spec now asserts this equals the manifest instead of asserting a number somebody typed. + * `version.ts` is the one owner, generated from `package.json` at build time; this is a re-export + * so a game that read `version` keeps reading it, and a release cannot leave a stale literal + * behind in a second place. */ -export const version = "0.3.12"; +export { CORE_VERSION as version } from "./version.js"; /** * Play a skinned or sprite animation from game code. diff --git a/packages/core/src/look/default-tone-mapping.ts b/packages/core/src/look/default-tone-mapping.ts index 3ec0f49b7..315e4def6 100644 --- a/packages/core/src/look/default-tone-mapping.ts +++ b/packages/core/src/look/default-tone-mapping.ts @@ -1,9 +1,16 @@ import { ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping, NoToneMapping } from "three"; -import { installFilmicToneMapping } from "./filmic-tone-mapping.js"; +import { + COLOR_GRADE, + FILMIC_CURVE, + type IColorGrade, + type IFilmicCurve, + installFilmicToneMapping, +} from "./filmic-tone-mapping.js"; /** `filmic` is a fresh Unreal 5.8.3 project's curve and the default; the rest are the named overrides. */ export type RendererToneMapping = "filmic" | "aces" | "agx" | "neutral" | "none"; +/** The named overrides, in the order an error message lists them. `filmic` is handled apart. */ const THREE_MODE: Record, number> = { aces: ACESFilmicToneMapping, agx: AgXToneMapping, @@ -11,27 +18,54 @@ const THREE_MODE: Record, number> = { none: NoToneMapping, }; +/** One list of tone curves, so a caller's validation cannot disagree with what is installed. */ +export const RENDERER_TONE_MAPPINGS: readonly RendererToneMapping[] = [ + "filmic", + ...(Object.keys(THREE_MODE) as (keyof typeof THREE_MODE)[]), +]; + +export function isRendererToneMapping(value: unknown): value is RendererToneMapping { + return RENDERER_TONE_MAPPINGS.includes(value as RendererToneMapping); +} + +/** + * The game's own curve and grade, for the one mode that reads them. A game that supplies neither + * gets Unreal's defaults, which is what a fresh project ships. + */ +export interface IFilmicToneMappingOptions { + readonly curve?: Partial; + readonly grade?: IColorGrade; +} + /** * Puts `mode` on a WebGPU renderer. `filmic` registers Unreal's curve as three's custom tone - * mapping; a renderer without a node library cannot run it, keeps ACES and says so - * (`TN_TONEMAP`). An unknown mode throws rather than quietly becoming the default. + * mapping; a renderer without a node library cannot run it, keeps ACES and says so (`TN_TONEMAP`). + * An unknown mode throws rather than quietly becoming the default. + * + * This is the only writer of `renderer.toneMapping`: the renderer's own default, `WorldEnvironment` + * and a template's look all call it, so a scene cannot pick a curve its renderer disagrees with. */ export function applyRendererToneMapping( raw: unknown, mode: RendererToneMapping, report: (line: string) => void, + filmic: IFilmicToneMappingOptions = {}, ): number { const target = raw as { toneMapping?: number }; if (mode !== "filmic") { const three = THREE_MODE[mode]; if (three === undefined) throw new Error( - `Unknown toneMapping '${String(mode)}'. Use one of: filmic, ${Object.keys(THREE_MODE).join(", ")}.`, + `Unknown toneMapping '${String(mode)}'. Use one of: ${RENDERER_TONE_MAPPINGS.join(", ")}.`, ); target.toneMapping = three; return three; } - const installed = installFilmicToneMapping(raw); + const installed = installFilmicToneMapping( + raw, + { ...FILMIC_CURVE, ...filmic.curve }, + filmic.grade ?? COLOR_GRADE, + ); if (installed !== undefined) { target.toneMapping = installed; return installed; diff --git a/packages/core/src/look/world-environment.ts b/packages/core/src/look/world-environment.ts index d5a60449c..60fa9e717 100644 --- a/packages/core/src/look/world-environment.ts +++ b/packages/core/src/look/world-environment.ts @@ -1,3 +1,4 @@ +import { readLaunchFlag } from "../debug.js"; // Generated for you: ordinary Three.js; ThreeNative does not read this file. Delete or // rewrite it freely — every default in here is a starting point, not a framework look. // @@ -14,13 +15,9 @@ // tier. Those are arguments, supplied by `postprocessing.ts`, yours to change. import { - ACESFilmicToneMapping, - AgXToneMapping, type Camera, - CustomToneMapping, type DirectionalLight, MaterialBlending, - NeutralToneMapping, type PerspectiveCamera, type RenderTarget, type Scene, @@ -81,12 +78,17 @@ import { aoRefusal } from "./ao-admission.js"; import { AO_MULTIBOUNCE_FIT, UNREAL_AO_MULTIBOUNCE_ALBEDO } from "./ao-multibounce.js"; import { UNREAL_AO_REACH_PX } from "./ao-reach.js"; import { AutoExposureNode, applyExposure } from "./auto-exposure.js"; +import { + RENDERER_TONE_MAPPINGS, + type RendererToneMapping, + applyRendererToneMapping, + isRendererToneMapping, +} from "./default-tone-mapping.js"; import { type IExposureSettings, exposureSettings } from "./exposure-settings.js"; import { type ColorGradeOptions, FILMIC_CURVE, type IFilmicCurve, - installFilmicToneMapping, resolveColorGrade, } from "./filmic-tone-mapping.js"; import { cacheFallback, giCache } from "./gi-registry.js"; @@ -111,9 +113,10 @@ export type ScreenSpaceAA = "smaa" | "fxaa" | "disabled"; /** * Godot's `Environment.tonemap_mode`: the modes Three.js ships, plus `filmic`, the Unreal 5 - * default curve written in `tonemap.ts`. + * default curve written in `tonemap.ts`. The list itself is `RendererToneMapping`'s, one owner + * beside `applyRendererToneMapping`, so a mode this accepts is a mode the renderer can install. */ -export type TonemapMode = "aces" | "agx" | "filmic" | "neutral"; +export type TonemapMode = RendererToneMapping; /** * SSGI sample budget. The three tiers are the presets SSGINode's own documentation @@ -132,13 +135,6 @@ const SSGI_QUALITY: Record = { - aces: ACESFilmicToneMapping, - agx: AgXToneMapping, - filmic: CustomToneMapping, - neutral: NeutralToneMapping, -}; - /** * Whether this launch draws the transparent draws' own target (see `seesThrough` below). * @@ -149,17 +145,10 @@ const TONEMAP: Record = { * running the same pose both ways. */ export function translucentTargetWanted(): boolean { - const host = globalThis as { process?: { env?: Record } } & Record< - string, - unknown - >; - const fromEnv = host.process?.env?.TN_TRANSLUCENT; - if (fromEnv === "0" || fromEnv === "false") return false; - const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnTranslucent=(?:0|false)(?:&|$)/u.test(query)) - return false; - const set = host.__tnTranslucent; - return !(set === false || set === 0 || set === "0" || set === "false"); + return !readLaunchFlag( + { environment: "TN_TRANSLUCENT", global: "__tnTranslucent", query: "tnTranslucent" }, + "off", + ); } /** @@ -790,10 +779,10 @@ export class WorldEnvironment { `Unknown ssgiQuality '${String(quality)}'. Use one of: ${Object.keys(SSGI_QUALITY).join(", ")}.`, ); } - const tonemapMode = options.tonemapMode ?? "aces"; - if (TONEMAP[tonemapMode] === undefined) { + const tonemapMode = options.tonemapMode ?? "filmic"; + if (!isRendererToneMapping(tonemapMode)) { throw new Error( - `Unknown tonemapMode '${String(tonemapMode)}'. Use one of: ${Object.keys(TONEMAP).join(", ")}.`, + `Unknown tonemapMode '${String(tonemapMode)}'. Use one of: ${RENDERER_TONE_MAPPINGS.join(", ")}.`, ); } resolveColorGrade(options.colorGrade); @@ -918,26 +907,17 @@ export class WorldEnvironment { ? this.#options : { ...this.#options, autoExposureEnabled: false }; const raw = renderer.raw as { toneMapping?: number; toneMappingExposure?: number }; - raw.toneMapping = TONEMAP[options.tonemapMode]; - if (options.tonemapMode === "filmic") { - // The curve is this game's source, registered as three's custom tone mapping. A renderer - // without a node library cannot run it; that frame keeps ACES and says so. - const installed = installFilmicToneMapping( - renderer.raw, - { - ...FILMIC_CURVE, - ...options.filmicCurve, - ...(lookDiagnostics.lutClipOff ? { lutClip: 0 } : {}), - }, - resolveColorGrade(options.colorGrade), - ); - if (installed === undefined) { - raw.toneMapping = ACESFilmicToneMapping; - console.warn( - `TN_TONEMAP ${JSON.stringify({ requested: "filmic", applied: "aces", reason: `renderer:${renderer.kind}` })}`, - ); - } - } + // One writer of `toneMapping` for the whole engine: the curve this game supplies reaches the + // same library the renderer's own default uses, and a renderer without a node library keeps + // ACES and reports `TN_TONEMAP` once, here or in the renderer. + applyRendererToneMapping(raw, options.tonemapMode, (line) => console.warn(line), { + curve: { + ...FILMIC_CURVE, + ...options.filmicCurve, + ...(lookDiagnostics.lutClipOff ? { lutClip: 0 } : {}), + }, + grade: resolveColorGrade(options.colorGrade), + }); // Temporal AA is the owned engine's: it jitters the camera and blends the frames ahead of every stage. Any other // renderer keeps the spatial filter, and the report says why. diff --git a/packages/core/src/profiling/FrameStages.ts b/packages/core/src/profiling/FrameStages.ts index 49bb21fa0..ee3a9d68f 100644 --- a/packages/core/src/profiling/FrameStages.ts +++ b/packages/core/src/profiling/FrameStages.ts @@ -16,6 +16,7 @@ * stages (`wasmRendererGap` is what `Renderer::render` spends outside its plan and encode halves) so the unlocated time is a number and never a quiet category. On an engine that publishes no * stage totals (upstream three), the whole call is `engineJs`. */ +import { readLaunchFlag } from "../debug.js"; import type { IFrameCounters } from "./FrameCounters.js"; @@ -217,12 +218,10 @@ export function createStageReader( * `stages`: the paired A/B that prices the meters runs one build with this switch alone. */ export function stageMetersRequested(): boolean { - const host = globalThis as { process?: { env?: Record }; __tnStages?: unknown }; - const fromEnv = host.process?.env?.TN_STAGES; - if (fromEnv === "0" || fromEnv === "false") return false; - const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnStages=(?:0|false)(?:&|$)/u.test(query)) return false; - return host.__tnStages !== false; + return !readLaunchFlag( + { environment: "TN_STAGES", global: "__tnStages", query: "tnStages" }, + "off", + ); } interface IEngineWorkHost { diff --git a/packages/core/src/profiling/Spans.ts b/packages/core/src/profiling/Spans.ts index 878d7bac7..86f579cf8 100644 --- a/packages/core/src/profiling/Spans.ts +++ b/packages/core/src/profiling/Spans.ts @@ -23,6 +23,7 @@ * The ids are integers on the hot path — a span call must not allocate a string to be cheap enough * to put around a per-draw call — and the names exist only in the report. */ +import { readLaunchFlag } from "../debug.js"; /** Marker printed once per report window when spans are installed. */ import { countDebug, debugRequested } from "./debug-telemetry.js"; @@ -535,24 +536,9 @@ export function addSpan(id: SpanId, ms: number): void { * `globalThis.__tnFrameSpans` before the game boots. */ export function spansRequested(): boolean { - if (debugRequested()) return true; - const host = globalThis as { - process?: { env?: Record }; - __tnFrameSpans?: unknown; - }; - const fromEnv = host.process?.env?.[SPANS_FLAG]; - if (typeof fromEnv === "string" && fromEnv !== "" && fromEnv !== "0" && fromEnv !== "false") - return true; - const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnFrameSpans=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(query)) - return true; - const fromGlobal = host.__tnFrameSpans; return ( - fromGlobal === true || - (typeof fromGlobal === "string" && - fromGlobal !== "" && - fromGlobal !== "0" && - fromGlobal !== "false") + debugRequested() || + readLaunchFlag({ environment: SPANS_FLAG, global: "__tnFrameSpans", query: "tnFrameSpans" }) ); } diff --git a/packages/core/src/profiling/debug-telemetry.ts b/packages/core/src/profiling/debug-telemetry.ts index 3d677d372..5bdc1f5d9 100644 --- a/packages/core/src/profiling/debug-telemetry.ts +++ b/packages/core/src/profiling/debug-telemetry.ts @@ -1,3 +1,4 @@ +import { readLaunchFlag } from "../debug.js"; import type { IFrameBudgetWindow } from "../frame-budget.js"; export interface IDebugSource { @@ -34,12 +35,7 @@ export function isContentScaled(name: string): boolean { const DEBUG_GLOBAL = "__THREENATIVE_DEBUG__"; export function debugRequested(): boolean { - const host = globalThis as IDebugHost; - return ( - host.process?.env?.TN_DEBUG === "1" || - (globalThis as Record)[DEBUG_GLOBAL] === true || - /[?&]tnDebug=1(?:&|$)/u.test(globalThis.location?.search ?? "") - ); + return readLaunchFlag({ environment: "TN_DEBUG", global: DEBUG_GLOBAL, query: "tnDebug" }); } /** Debug-only producers call this; the existing engine-cost reader owns the per-frame difference. */ diff --git a/packages/core/src/profiling/render-list-validate.ts b/packages/core/src/profiling/render-list-validate.ts index 0b13d6cc7..b14e12fb2 100644 --- a/packages/core/src/profiling/render-list-validate.ts +++ b/packages/core/src/profiling/render-list-validate.ts @@ -13,6 +13,7 @@ * * Off by default and expensive by construction — it does the work it is checking, twice. */ +import { readLaunchFlag } from "../debug.js"; import { Matrix4, type Object3D } from "three"; import { composeSilencedByFreeze, worldSilencedByFreeze } from "../static-transform.js"; @@ -34,21 +35,11 @@ const TOLERANCE = 1e-6; /** Whether `TN_RENDERLIST_VALIDATE` asks for validation on this launch. */ export function renderListValidationRequested(): boolean { - const host = globalThis as { - process?: { env?: Record }; - __tnRenderListValidate?: unknown; - }; - const fromEnv = host.process?.env?.[RENDERLIST_VALIDATE_FLAG]; - if (typeof fromEnv === "string" && fromEnv !== "" && fromEnv !== "0" && fromEnv !== "false") - return true; - const query = globalThis.location?.search; - if ( - typeof query === "string" && - /[?&]tnRenderListValidate=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(query) - ) - return true; - const fromGlobal = host.__tnRenderListValidate; - return fromGlobal === true || fromGlobal === "1"; + return readLaunchFlag({ + environment: RENDERLIST_VALIDATE_FLAG, + global: "__tnRenderListValidate", + query: "tnRenderListValidate", + }); } /** What one frame's check found. */ diff --git a/packages/core/src/quiet-markers.ts b/packages/core/src/quiet-markers.ts index edb168d20..3a456b8e4 100644 --- a/packages/core/src/quiet-markers.ts +++ b/packages/core/src/quiet-markers.ts @@ -9,24 +9,17 @@ * Only a real browser page installs it: the native host stubs `document.location` but sets no * `globalThis.location`, so a native game keeps its console as the host prints it. */ +import { readLaunchFlag } from "./debug.js"; + const LOG_FLAG = "TN_LOG"; const BROWSER_PROTOCOLS = new Set(["http:", "https:", "file:"]); type ConsoleMethod = (...args: unknown[]) => void; let restore: { log: ConsoleMethod; info: ConsoleMethod } | undefined; -const isOn = (value: unknown): boolean => - value === true || - (typeof value === "string" && value !== "" && value !== "0" && value !== "false"); - /** Whether `TN_LOG` asks for markers at their own console level on this launch. */ export function markerLogRequested(): boolean { - const host = globalThis as { process?: { env?: Record } }; - if (isOn(host.process?.env?.[LOG_FLAG])) return true; - const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnLog=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(query)) - return true; - return isOn((globalThis as Record).__THREENATIVE_LOG__); + return readLaunchFlag({ environment: LOG_FLAG, global: "__THREENATIVE_LOG__", query: "tnLog" }); } /** Re-routes `TN_` log/info lines to `console.debug`; a second call is a no-op. */ diff --git a/packages/core/src/render/depth-pyramid.ts b/packages/core/src/render/depth-pyramid.ts index 47609d2d4..b5fc6c78c 100644 --- a/packages/core/src/render/depth-pyramid.ts +++ b/packages/core/src/render/depth-pyramid.ts @@ -22,6 +22,7 @@ import { vec4, } from "three/tsl"; import { StorageBufferAttribute } from "three/webgpu"; +import { readLaunchValue } from "../debug.js"; /** * The hierarchical-depth (pyramid) occlusion test, and the max-distance pyramid it reads. @@ -64,21 +65,12 @@ export type OcclusionMode = "off" | "measure"; * browser asks with `?tnOcclusion=measure`, and a test sets the global. */ export function occlusionMode(): OcclusionMode { - const host = globalThis as { - process?: { env?: Record }; - __tnOcclusion?: unknown; - location?: { search?: string }; - }; - const fromEnv = host.process?.env?.[OCCLUSION_FLAG]; - const query = host.location?.search; const asked = - typeof fromEnv === "string" && fromEnv !== "" - ? fromEnv - : typeof query === "string" - ? (/[?&]tnOcclusion=([^&]*)/u.exec(query)?.[1] ?? "") - : typeof host.__tnOcclusion === "string" - ? host.__tnOcclusion - : ""; + readLaunchValue({ + environment: OCCLUSION_FLAG, + global: "__tnOcclusion", + query: "tnOcclusion", + }) ?? ""; const value = asked.trim().toLowerCase(); if (value === "" || value === "0" || value === "off" || value === "false") return "off"; if (value === "measure" || value === "1" || value === "true") return "measure"; diff --git a/packages/core/src/render/virtual-shadow.ts b/packages/core/src/render/virtual-shadow.ts index 1c588a95e..baa0778e0 100644 --- a/packages/core/src/render/virtual-shadow.ts +++ b/packages/core/src/render/virtual-shadow.ts @@ -53,6 +53,7 @@ import { ShadowBaseNode, type UniformNode, } from "three/webgpu"; +import { readLaunchFlag } from "../debug.js"; import { shadowRedrawGpuMs } from "../render-pass-budget.js"; import { DEFAULT_TARGET_FPS } from "../target-fps.js"; import { isLeafReceiver } from "./soft-shadow.js"; @@ -2177,13 +2178,22 @@ export class VirtualShadowNode extends ShadowBaseNode { if (builder.renderer.shadowMap.enabled === false) return null; // Read once, like tnShadowStats. Build no diagnostic nodes unless the URL switch is on. if (this.#levelsRequested === undefined) { - const search = globalThis.location?.search ?? ""; - this.#levelsRequested = /[?&]tnShadowLevels=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(search); + // One read of the URL for both switches: setup runs once, before the shader graph exists. + const search = globalThis.location?.search; + this.#levelsRequested = readLaunchFlag( + { environment: "TN_SHADOW_LEVELS", query: "tnShadowLevels" }, + "on", + search, + ); // Counter runs: a fixed refreshStep, since the adaptive step follows measured render cost and // two runs of one route would move their windows at different ticks. Set once, before a frame. if ( this.#adaptiveRefreshGiven === undefined && - /[?&]tnAdaptiveRefresh=(?:0|false)(?:&|$)/u.test(search) + readLaunchFlag( + { environment: "TN_ADAPTIVE_REFRESH", query: "tnAdaptiveRefresh" }, + "off", + search, + ) ) { (this.options as { adaptiveRefresh: boolean }).adaptiveRefresh = false; } @@ -2847,9 +2857,10 @@ export class VirtualShadowNode extends ShadowBaseNode { /** Whether `?tnShadowStats=1` is on this launch's URL, read once. */ #statsWalkRequested(): boolean { - this.#statsRequested ??= /[?&]tnShadowStats=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test( - globalThis.location?.search ?? "", - ); + this.#statsRequested ??= readLaunchFlag({ + environment: "TN_SHADOW_STATS", + query: "tnShadowStats", + }); return this.#statsRequested; } diff --git a/packages/core/src/spells/ultra/engine/quality.ts b/packages/core/src/spells/ultra/engine/quality.ts index 652122648..f6918b3ca 100644 --- a/packages/core/src/spells/ultra/engine/quality.ts +++ b/packages/core/src/spells/ultra/engine/quality.ts @@ -1,6 +1,7 @@ // Ported from Ultra Elements src/engine/quality.js @698b720 (MIT, Copyright (c) 2026 Humpalumps). JavaScript copied verbatim; GLSL transliterated to TSL. // Engine: the tier comes from `?q=` when there is a location; there is no `matchMedia` probe on the // native host, so `mobile` is `?mobile` only. +import { readLaunchValue } from "../../../debug.js"; // Quality tiers. Spells author counts at High; emission is multiplied by Q.emission (tier x auto). // Anything here that changes a shader define or a target size only changes on an explicit tier @@ -80,11 +81,12 @@ export const TIERS = { }; export type TierName = keyof typeof TIERS; -const args = new URLSearchParams(globalThis.location?.search ?? ""); -export const mobile = args.has("mobile"); -const asked = args.get("q"); +// Query-only on purpose: a spell's tier is a page a person opens, and neither knob has an +// environment variable or a global a host would set. `?mobile` naming itself is the ask. +export const mobile = readLaunchValue({ environment: "", query: "mobile" }) !== undefined; +const asked = readLaunchValue({ environment: "", query: "q" }); export const tierName: TierName = - asked !== null && asked in TIERS ? (asked as TierName) : mobile ? "low" : "high"; + asked !== undefined && asked in TIERS ? (asked as TierName) : mobile ? "low" : "high"; // live values; auto scales multiply on top (auto.emission, auto.steps) export const Q = { diff --git a/packages/core/src/world-cell-options.ts b/packages/core/src/world-cell-options.ts index 2ed73d8ec..a4b595af2 100644 --- a/packages/core/src/world-cell-options.ts +++ b/packages/core/src/world-cell-options.ts @@ -1,3 +1,4 @@ +import { readLaunchValue } from "./debug.js"; import { INSTANCED_LOD_MAX_PIXEL_ERROR } from "./instanced-batch-lod.js"; import type { IWorldCellsLoadOptions } from "./world-cell-data.js"; import type { IWorldCell, IWorldPackage } from "./world-package.js"; @@ -134,20 +135,7 @@ export const DEFAULT_MAIN_GPU_SHARE = 0.5; * be parsed by the caller rather than rounded here. */ function launchValue(parameter: string, flag: string, globalName: string): string | undefined { - const host = globalThis as { process?: { env?: Record } } & Record< - string, - unknown - >; - const env = host.process?.env?.[flag]; - if (typeof env === "string" && env !== "") return env; - const query = globalThis.location?.search; - if (typeof query === "string") { - const value = new URLSearchParams(query).get(parameter); - if (value !== null) return value; - } - const global = host[globalName]; - if (typeof global === "number") return String(global); - return typeof global === "string" ? global : undefined; + return readLaunchValue({ environment: flag, global: globalName, query: parameter }); } /** diff --git a/packages/core/src/world-gpu-flags.ts b/packages/core/src/world-gpu-flags.ts index 8b74325c5..03571223f 100644 --- a/packages/core/src/world-gpu-flags.ts +++ b/packages/core/src/world-gpu-flags.ts @@ -6,53 +6,26 @@ * what a run would use without building a scene, and a test can pin the order the sources are read * in without a device. */ +import { readLaunchFlag, readLaunchValue } from "./debug.js"; /** The launch flag. */ export const GPU_SCENE_FLAG = "TN_GPU_SCENE"; /** * Did this launch ask for `flag` to be **off**? The environment variable, the query string and the - * global a test sets are the three ways one is asked, and `0` and `false` are the only answers that - * count: an unset or empty variable is the same as no variable, and a saved URL that used to enable - * a switch still says "off". + * global a test sets are the three ways one is asked, and `debug.ts` owns the rule that reads them + * — see {@link readLaunchFlag}. */ function askedOff(flag: string, parameter: string, globalName: string): boolean { - const host = globalThis as { process?: { env?: Record } } & Record< - string, - unknown - >; - const fromEnv = host.process?.env?.[flag]; - if (fromEnv === "0" || fromEnv === "false") return true; - const query = globalThis.location?.search; - if ( - typeof query === "string" && - new RegExp(`[?&]${parameter}=(?:0|false)(?:&|$)`, "u").test(query) - ) - return true; - const set = host[globalName]; - return set === false || set === 0 || set === "0" || set === "false"; + return readLaunchFlag({ environment: flag, global: globalName, query: parameter }, "off"); } /** - * Did this launch ask for `flag` to be **on**? The mirror of {@link askedOff}, for the switches that - * are opt-in: the environment variable, the query string and the global a test sets, and only - * `1` and `true` count. An unset or empty variable is the same as no variable, so an old URL that - * never mentioned the switch stays off. + * Did this launch ask for `flag` to be **on**? The mirror of {@link askedOff}, for the switches + * that are opt-in. */ function askedOn(flag: string, parameter: string, globalName: string): boolean { - const host = globalThis as { process?: { env?: Record } } & Record< - string, - unknown - >; - const fromEnv = host.process?.env?.[flag]; - if (fromEnv === "1" || fromEnv === "true") return true; - const query = globalThis.location?.search; - if ( - typeof query === "string" && - new RegExp(`[?&]${parameter}=(?:1|true)(?:&|$)`, "u").test(query) - ) - return true; - return host[globalName] === true || host[globalName] === 1 || host[globalName] === "1"; + return readLaunchFlag({ environment: flag, global: globalName, query: parameter }); } /** @@ -91,8 +64,16 @@ export const BUNDLE_FLAG = "TN_BUNDLES"; * variable, a browser asks with the query string, and a test sets the global. */ export function bundlesAsked(): "default" | "off" | "on" { - if (askedOn(BUNDLE_FLAG, "tnBundles", "__tnBundles")) return "on"; - return askedOff(BUNDLE_FLAG, "tnBundles", "__tnBundles") ? "off" : "default"; + // Its own vocabulary over the one reader: a launch can answer yes, answer no, or say nothing, and + // "said nothing" is what a caller has to tell apart from a measurement's default. + const value = readLaunchValue({ + environment: BUNDLE_FLAG, + global: "__tnBundles", + query: "tnBundles", + }); + if (value === undefined) return "default"; + if (value === "0" || value === "false") return "off"; + return value === "1" || value === "true" || value === "" ? "on" : "default"; } /** @@ -127,20 +108,7 @@ export const GPU_SCENE_VALIDATE_FLAG = "TN_GPU_SCENE_VALIDATE"; * browser asks with `?tnGpuSceneValidate=1`, and a test sets the global. */ export function gpuSceneValidationRequested(): boolean { - const host = globalThis as { - process?: { env?: Record }; - __tnGpuSceneValidate?: unknown; - }; - const fromEnv = host.process?.env?.[GPU_SCENE_VALIDATE_FLAG]; - if (typeof fromEnv === "string" && fromEnv !== "" && fromEnv !== "0" && fromEnv !== "false") - return true; - const query = globalThis.location?.search; - if ( - typeof query === "string" && - /[?&]tnGpuSceneValidate=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(query) - ) - return true; - return host.__tnGpuSceneValidate === true || host.__tnGpuSceneValidate === "1"; + return askedOn(GPU_SCENE_VALIDATE_FLAG, "tnGpuSceneValidate", "__tnGpuSceneValidate"); } /** The launch flag. */ diff --git a/packages/core/src/world-tile-budget.ts b/packages/core/src/world-tile-budget.ts index abed8c82c..1083cc6ad 100644 --- a/packages/core/src/world-tile-budget.ts +++ b/packages/core/src/world-tile-budget.ts @@ -2,6 +2,7 @@ * TerrainTiles' constants, admission byte estimates and the input guards they share. Split out of `world-tiles.ts` verbatim (PRD-797), so the estimator can be read and tested without the class. */ +import { readLaunchFlag } from "./debug.js"; import { type SpanId, addSpan, spanNow, spanRecorder } from "./profiling/Spans.js"; import type { IHeightfieldWorldPassOptions } from "./world-heightfield.js"; @@ -29,17 +30,9 @@ export const TERRAIN_TILE_MARKER_MS = 5_000; * switch — or one that still carries the `=1` that used to ask for the merge — stays on. */ export function terrainMergeOptedOut(): boolean { - const host = globalThis as { process?: { env?: Record } } & Record< - string, - unknown - >; - const fromEnv = host.process?.env?.[TERRAIN_MERGE_FLAG]; - if (fromEnv === "0" || fromEnv === "false") return true; - const query = globalThis.location?.search; - if (typeof query === "string" && /[?&]tnTerrainMerge=(?:0|false)(?:&|$)/u.test(query)) - return true; - return ( - host.__tnTerrainMerge === false || host.__tnTerrainMerge === 0 || host.__tnTerrainMerge === "0" + return readLaunchFlag( + { environment: TERRAIN_MERGE_FLAG, global: "__tnTerrainMerge", query: "tnTerrainMerge" }, + "off", ); } diff --git a/packages/core/src/world-validate.ts b/packages/core/src/world-validate.ts index cf99b2e2c..7c0510f05 100644 --- a/packages/core/src/world-validate.ts +++ b/packages/core/src/world-validate.ts @@ -12,6 +12,7 @@ * loops back and every assertion with them. It is a validation mode, not a proof: it proves the * frames it ran on. */ +import { readLaunchFlag } from "./debug.js"; /** Marker printed when terrain validation is on, so a log says which mode produced it. */ export const TERRAIN_VALIDATE_MARKER = "TN_TERRAIN_VALIDATE"; @@ -31,18 +32,9 @@ export const TERRAIN_VALIDATE_FLAG = "TN_TERRAIN_VALIDATE"; * const tiles = new TerrainTiles({ ...options, validate: terrainValidationRequested() }); */ export function terrainValidationRequested(): boolean { - const host = globalThis as { - process?: { env?: Record }; - __tnTerrainValidate?: unknown; - }; - const fromEnv = host.process?.env?.[TERRAIN_VALIDATE_FLAG]; - if (typeof fromEnv === "string" && fromEnv !== "" && fromEnv !== "0" && fromEnv !== "false") - return true; - const query = globalThis.location?.search; - if ( - typeof query === "string" && - /[?&]tnTerrainValidate=(?!0(?:&|$))(?!false(?:&|$))[^&]/u.test(query) - ) - return true; - return host.__tnTerrainValidate === true || host.__tnTerrainValidate === "1"; + return readLaunchFlag({ + environment: TERRAIN_VALIDATE_FLAG, + global: "__tnTerrainValidate", + query: "tnTerrainValidate", + }); } diff --git a/packages/create-threenative/__tests__/shared-render-drift.spec.ts b/packages/create-threenative/__tests__/shared-render-drift.spec.ts new file mode 100644 index 000000000..2b78bdbfa --- /dev/null +++ b/packages/create-threenative/__tests__/shared-render-drift.spec.ts @@ -0,0 +1,81 @@ +// A `src/render/` file that ships byte-identical in every kit that carries it and is not in the +// shared list is a copy nobody restamps: fix one kit and the others keep the old behaviour, with +// every gate green. `c93b427d0` touched 552 files for one look change because of exactly that. +// +// The rule: same bytes everywhere it ships, in two or more kits ⇒ listed. A file that differs in +// any kit is a per-kit look (or a look that drifted on purpose) and stays out, because restamping +// it would overwrite that kit's choice; a file only one kit ships is that kit's own. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ADDED_RENDER_SOURCES, + SHARED_RENDER_SOURCES, + canonicalRenderSourcePath, + templateRoot, +} from "../src/index.js"; + +const RENDER_PREFIX = "src/render/"; + +interface IIdenticalCopy { + readonly relativePath: string; + readonly kits: readonly string[]; +} + +function renderCopies(): Map> { + const root = templateRoot(); + const copies = new Map>(); + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const render = path.join(root, entry.name, "src", "render"); + if (!existsSync(render)) continue; + for (const file of readdirSync(render)) { + if (!file.endsWith(".ts") || !existsSync(path.join(render, file))) continue; + const digest = createHash("sha256") + .update(readFileSync(path.join(render, file))) + .digest("hex"); + const byDigest = copies.get(`${RENDER_PREFIX}${file}`) ?? new Map(); + byDigest.set(digest, [...(byDigest.get(digest) ?? []), entry.name]); + copies.set(`${RENDER_PREFIX}${file}`, byDigest); + } + } + return copies; +} + +/** Every render source that ships the same bytes in two or more kits, and in no other form. */ +function identicalCopies(): readonly IIdenticalCopy[] { + const identical: IIdenticalCopy[] = []; + for (const [relativePath, byDigest] of renderCopies()) { + // More than one digest means some kit decided to differ: that is a look, not a copy. + if (byDigest.size !== 1) continue; + for (const kits of byDigest.values()) { + if (kits.length < 2) continue; + identical.push({ kits: [...kits].sort(), relativePath }); + } + } + return identical.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +} + +describe("shared render sources", () => { + it("should list every render source that ships byte-identical in two or more kits", () => { + const unlisted = identicalCopies() + .filter((copy) => !SHARED_RENDER_SOURCES.includes(copy.relativePath)) + .map((copy) => `${copy.relativePath} (${copy.kits.length} kits: ${copy.kits.join(", ")})`); + expect(unlisted).toEqual([]); + }); + + it("should resolve every shared entry to the file it restamps from", () => { + const missing = SHARED_RENDER_SOURCES.filter( + (relativePath) => !existsSync(canonicalRenderSourcePath(relativePath)), + ); + expect(missing).toEqual([]); + }); + + it("should keep the added set inside the shared set", () => { + const stray = ADDED_RENDER_SOURCES.filter( + (relativePath) => !SHARED_RENDER_SOURCES.includes(relativePath), + ); + expect(stray).toEqual([]); + }); +}); diff --git a/packages/create-threenative/src/doctor.ts b/packages/create-threenative/src/doctor.ts index 89e564580..26442c109 100644 --- a/packages/create-threenative/src/doctor.ts +++ b/packages/create-threenative/src/doctor.ts @@ -21,6 +21,7 @@ import { pathToFileURL } from "node:url"; import { installCommandFor, resolveBlender } from "@threenative/blender-bridge/bridge"; import { MCP_HOSTS } from "../../core/mcp/install.mjs"; +import { CORE_VERSION } from "../../core/src/version.js"; import { resolveRuntimeAssetCapabilities } from "./build.js"; import { loadConfig } from "./config.js"; import { @@ -209,10 +210,10 @@ interface IMcpServerSpec { /** The engine server resolves to `@threenative/core` itself, not to the package `MCP_PACKAGES` * names: core bundles engine discovery and only falls back to `threenative-engine-mcp` over npx in * a development checkout whose bundle has not been built. Doctor probes what the project actually - * resolves, so it must name core — and core's own version, which `doctor.spec.ts` holds equal to - * `packages/core/package.json`. */ + * resolves, so it must name core — and core's own version, taken from the constant core generates + * from its `package.json`. A literal here went stale between releases with nothing failing. */ const CORE_PACKAGE_NAME = "@threenative/core"; -const CORE_PACKAGE_VERSION = "0.3.12"; +const CORE_PACKAGE_VERSION = CORE_VERSION; const CORE_RESOLVED_SERVERS: ReadonlySet = new Set([ "threenative-engine", "threenative-animation", @@ -662,7 +663,7 @@ function probeMcpServer(projectRoot: string, spec: IMcpServerSpec): IMcpServerHe method: "initialize", params: { capabilities: {}, - clientInfo: { name: "threenative-doctor", version: "0.3.12" }, + clientInfo: { name: "threenative-doctor", version: CORE_PACKAGE_VERSION }, protocolVersion: "2025-06-18", }, }); diff --git a/packages/create-threenative/src/index.ts b/packages/create-threenative/src/index.ts index 0dd65c7d3..d7f4425dc 100644 --- a/packages/create-threenative/src/index.ts +++ b/packages/create-threenative/src/index.ts @@ -126,8 +126,30 @@ export function stampLoadingSource(canonical: string, template: string): string * render-chain plumbing is not: it is the same 600 lines in every kit, and keeping seven copies by * hand let one fall a revision behind without anything failing. A file belongs here only when a * kit editing it would be a mistake rather than a choice. + * + * `__tests__/shared-render-drift.spec.ts` fails on any render source that ships byte-identical in + * two or more kits and is missing here, so a new copy cannot land unrestamped. */ export const SHARED_RENDER_SOURCES: readonly string[] = [ + "src/render/atmosphere.ts", + "src/render/backlightControls.ts", + "src/render/backlightMaterial.ts", + "src/render/environmentContribution.ts", + "src/render/environmentRadiance.ts", + "src/render/environmentSampleCache.ts", + "src/render/environmentSampling.ts", + "src/render/environmentSnapshot.ts", + "src/render/gpuSky.ts", + "src/render/kitExposure.ts", + "src/render/temporalDetailStage.ts", +]; + +/** + * The shared sources `createProject` also writes into a kit that does not ship them. A kit owns its + * look, so presence is per kit: `starter` ships the temporal detail stage and the collectathon kits + * do not, and all four still get the same plumbing. + */ +export const ADDED_RENDER_SOURCES: readonly string[] = [ "src/render/kitExposure.ts", "src/render/temporalDetailStage.ts", ]; @@ -916,7 +938,7 @@ export async function createProject( // packaged templates. Nothing in a generated game reads it (PRD-826). await rm(path.join(target, "kit.json"), { force: true }); if (["starter", "collectathon", "adventure", "kart"].includes(template)) - for (const relative of SHARED_RENDER_SOURCES) + for (const relative of ADDED_RENDER_SOURCES) await cp(canonicalRenderSourcePath(relative, root), path.join(target, relative)); await copyTemplateIcon(target, root); await copySharedAssets(target, root); diff --git a/scripts/__tests__/capability-recall.spec.ts b/scripts/__tests__/capability-recall.spec.ts index fe6926672..60d56c02d 100644 --- a/scripts/__tests__/capability-recall.spec.ts +++ b/scripts/__tests__/capability-recall.spec.ts @@ -104,6 +104,16 @@ describe("capability recall gate", () => { ).rejects.toThrow("template:starter#Heading removed by a later edit"); }); + it("should resolve a source pointer into every shipped kit, not a hand-typed subset", async () => { + await expect( + resolveCorpusSources([ + row({ source: "template:puzzle#Authoring flow" }), + row({ source: "template:rts#This kit" }), + row({ source: "template:runner#This kit" }), + ]), + ).resolves.toBeUndefined(); + }); + it("should accept a sandbox game pointer and reject an unknown source kind", async () => { await expect( resolveCorpusSources([row({ source: "sandbox:midway#render/world.ts" })]), diff --git a/scripts/__tests__/ci-local.spec.ts b/scripts/__tests__/ci-local.spec.ts index a61ab86e8..53c8d19b8 100644 --- a/scripts/__tests__/ci-local.spec.ts +++ b/scripts/__tests__/ci-local.spec.ts @@ -179,6 +179,11 @@ describe("PRD-373 local selection", () => { for (const name of ["ci-local.sh", "ci-change-scope.mjs", "ci-leg-carry.mjs"]) { await copyFile(path.join(repo, "scripts", name), path.join(fixture.root, "scripts", name)); } + await mkdir(path.join(fixture.root, "scripts/lib"), { recursive: true }); + await copyFile( + path.join(repo, "scripts/lib/repo-inventory.mjs"), + path.join(fixture.root, "scripts/lib/repo-inventory.mjs"), + ); await mkdir(path.join(fixture.root, "packages/create-threenative/templates/starter"), { recursive: true, }); diff --git a/scripts/__tests__/ci-native-chain.spec.ts b/scripts/__tests__/ci-native-chain.spec.ts index 92f5f86d5..862a68555 100644 --- a/scripts/__tests__/ci-native-chain.spec.ts +++ b/scripts/__tests__/ci-native-chain.spec.ts @@ -379,6 +379,12 @@ describe("actual native receipt writer, collector and protected verdict", () => "ci-change-scope.mjs", ]) cpSync(path.join(repository, "scripts", name), path.join(standalone, "scripts", name)); + mkdirSync(path.join(standalone, "scripts/lib"), { recursive: true }); + // The shared inventory is the only module `ci-change-scope.mjs` imports besides its siblings. + cpSync( + path.join(repository, "scripts/lib/repo-inventory.mjs"), + path.join(standalone, "scripts/lib/repo-inventory.mjs"), + ); mkdirSync(path.join(standalone, "packages/runtime-native/scripts"), { recursive: true }); cpSync( path.join(repository, "packages/runtime-native/scripts/desktop-build-profile.mjs"), diff --git a/scripts/__tests__/repo-inventory.spec.ts b/scripts/__tests__/repo-inventory.spec.ts new file mode 100644 index 000000000..d43ea7dad --- /dev/null +++ b/scripts/__tests__/repo-inventory.spec.ts @@ -0,0 +1,55 @@ +// The inventory is derived from disk, so what needs a pin is the derivation itself: a directory +// that ships a kit, a brief or a package is in the list, and a list nobody can empty silently. +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + REPO_ROOT, + briefGenres, + minimumNodeVersion, + nodeEngineRange, + templateNames, + workspacePackageNames, +} from "../lib/repo-inventory.mjs"; + +const templates = path.join(REPO_ROOT, "packages", "create-threenative", "templates"); +const genres = path.join(REPO_ROOT, "docs", "benchmark", "genres"); +const packages = path.join(REPO_ROOT, "packages"); + +function directories(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +describe("repo inventory", () => { + it("should list every kit that ships a kit.json", () => { + const kits = directories(templates).filter((name) => + readdirSync(path.join(templates, name)).includes("kit.json"), + ); + expect([...templateNames()]).toEqual([...kits].sort()); + // The three kits a hand list had skipped: recall rejected their source pointers outright. + for (const kit of ["puzzle", "rts", "runner"]) expect(templateNames()).toContain(kit); + }); + + it("should list every brief genre that ships a brief.md", () => { + const briefs = directories(genres).filter((name) => + readdirSync(path.join(genres, name)).includes("brief.md"), + ); + expect([...briefGenres()]).toEqual([...briefs].sort()); + expect(briefGenres().length).toBeGreaterThan(0); + }); + + it("should list every workspace package directory that carries a manifest", () => { + const members = directories(packages).filter((name) => + readdirSync(path.join(packages, name)).includes("package.json"), + ); + expect([...workspacePackageNames()]).toEqual([...members].sort()); + expect(workspacePackageNames().length).toBeGreaterThan(0); + }); + + it("should read the Node floor from the one place the repository declares it", () => { + expect(nodeEngineRange()).toMatch(/^>=?\d+\.\d+\.\d+$/u); + expect(minimumNodeVersion()).toBe("20.19.0"); + }); +}); diff --git a/scripts/bake-delete-test.ts b/scripts/bake-delete-test.ts index 5ab848fa1..b67dad400 100644 --- a/scripts/bake-delete-test.ts +++ b/scripts/bake-delete-test.ts @@ -3,6 +3,7 @@ import { readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { PNG } from "pngjs"; import type { IBakeReceipt, IBakeReceiptOutput } from "../packages/assets/src/index.js"; +import { templateNames } from "./lib/repo-inventory.mjs"; /** * The delete-test: build a game, run its scenario, delete every file the bake produced, run the @@ -412,17 +413,10 @@ if ( const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); // One template per invocation by default: the existing templates gate aborts at its first // failure, and a red hidden behind another template's red is a red nobody reads. + // `--all` is every shipped kit, derived from disk: a hand list of eight left the other eight + // unproven, and the day a ninth shipped nobody would notice either. const templates = argv.includes("--all") - ? [ - "action-rpg", - "minimal", - "platformer", - "racing", - "sailing", - "shooter", - "starter", - "tower-defense", - ] + ? [...templateNames()] : [flag("--template") ?? "starter"]; const scenario = flag("--scenario") ?? "playtests/play.playtest.json"; // `--project` runs against a project that is already scaffolded and built — the shape CI is diff --git a/scripts/capability-recall.ts b/scripts/capability-recall.ts index 931082dc9..cb6611d6f 100644 --- a/scripts/capability-recall.ts +++ b/scripts/capability-recall.ts @@ -7,6 +7,7 @@ import { loadCapabilityManifest, searchCapabilities, } from "../packages/engine-mcp/src/index.js"; +import { briefGenres, templateNames } from "./lib/repo-inventory.mjs"; export type CapabilityRecallScope = "request" | "mechanic"; @@ -117,30 +118,8 @@ export class CapabilityRecallError extends Error { const CORPUS_RELATIVE_PATH = path.join("scripts", "fixtures", "capability-recall", "corpus.json"); const BUDGET_RELATIVE_PATH = path.join("scripts", "fixtures", "capability-recall", "budget.json"); const MANIFEST_RELATIVE_PATH = path.join("packages", "create-threenative", "capabilities.json"); -const TEMPLATE_NAMES = [ - "collectathon", - "adventure", - "kart", - "action-rpg", - "minimal", - "platformer", - "racing", - "rain", - "sailing", - "shooter", - "snow", - "starter", - "tower-defense", -] as const; -const BRIEF_NAMES = [ - "endless-runner", - "exploration", - "fps", - "open-world", - "physics-puzzle", - "platformer", - "topdown-action", -] as const; +const BRIEF_NAMES = briefGenres(); +const TEMPLATE_NAMES = templateNames(); const SOURCE_PATTERN = /^(brief|sandbox|template):([a-z0-9-]+)#(.+)$/u; const HEADING_PATTERN = /^\s{0,3}#{1,6}\s+(.+?)\s*#?\s*$/u; @@ -360,14 +339,14 @@ export function recallManifestPath(root = process.cwd()): string { } function briefFile(root: string, genre: string): string { - if (!BRIEF_NAMES.includes(genre as (typeof BRIEF_NAMES)[number])) { + if (!BRIEF_NAMES.includes(genre)) { throw recallError(`unknown brief genre '${genre}'`); } return path.resolve(root, "docs", "benchmark", "genres", genre, "brief.md"); } function templateFile(root: string, template: string): string { - if (!TEMPLATE_NAMES.includes(template as (typeof TEMPLATE_NAMES)[number])) { + if (!TEMPLATE_NAMES.includes(template)) { throw recallError(`unknown template '${template}'`); } return path.resolve(root, "packages", "create-threenative", "templates", template, "AGENTS.md"); diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 91c6ac1e5..9975bb543 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { RELEASE_GATE_JOBS, applyCarried, findCarried } from "./ci-leg-carry.mjs"; +import { templateNames } from "./lib/repo-inventory.mjs"; const EXCLUDED_MARKDOWN = [ /^docs\/PRDs\/realism-effects\/README\.md$/u, @@ -51,13 +52,7 @@ function isNativePath(file) { return !isEnginePath(file) && NATIVE_PATHS.some((pattern) => pattern.test(file)); } // Derive coverage from shipped kit identities; directory-prefix lookalikes earn no exemption. -export const TEMPLATE_NAMES = readdirSync( - new URL("../packages/create-threenative/templates/", import.meta.url), - { withFileTypes: true }, -) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); +export const TEMPLATE_NAMES = templateNames(); for (const name of TEMPLATE_NAMES) { const kit = JSON.parse( readFileSync( diff --git a/scripts/ci-integration-scope.mjs b/scripts/ci-integration-scope.mjs index de82a0382..00d5b34f5 100644 --- a/scripts/ci-integration-scope.mjs +++ b/scripts/ci-integration-scope.mjs @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { NATIVE_ENGINE_PATHS, validateEventPlan } from "./ci-change-scope.mjs"; +import { workspacePackageNames } from "./lib/repo-inventory.mjs"; const WORKFLOW = ".github/workflows/integration.yml"; const SELECTOR = "scripts/ci-integration-scope.mjs"; @@ -48,6 +49,15 @@ export const INDEPENDENT_PACKAGES = [ ]; const OWNED_PACKAGES = "core|physics|playtest|assets|ui|runtime-native|create-threenative|terrain|metahuman|raw-unreal|ueformat|engine-mcp|blender-bridge"; +// Which packages have an integration lane is policy, not an inventory: a package a lane consumes +// routes to that lane, and a package none consumes keeps the unknown-executable fallback. What the +// policy may not do is name a package that no longer exists, so every name is checked against the +// workspace on disk and a rename fails here instead of silently dropping lanes. +const WORKSPACE_PACKAGES = workspacePackageNames(); +for (const name of [...INDEPENDENT_PACKAGES, ...OWNED_PACKAGES.split("|")]) { + if (!WORKSPACE_PACKAGES.includes(name)) + throw new Error(`CI_PACKAGE_INVENTORY: '${name}' is not a workspace package`); +} const PACKAGE_CONTRACT = new RegExp( `^packages/(?:${OWNED_PACKAGES})/(?:__tests__/.*\\.spec\\.ts|tests/.*\\.test\\.mjs)$`, "u", diff --git a/scripts/lib/repo-inventory.d.mts b/scripts/lib/repo-inventory.d.mts new file mode 100644 index 000000000..bf33f4877 --- /dev/null +++ b/scripts/lib/repo-inventory.d.mts @@ -0,0 +1,8 @@ +// Types for `repo-inventory.mjs`, which is plain ESM so CI's Node 20 can run it. Keep this file +// and the module in step: it declares exactly what the module exports and nothing more. +export declare const REPO_ROOT: string; +export declare function workspacePackageNames(root?: string): readonly string[]; +export declare function templateNames(root?: string): readonly string[]; +export declare function briefGenres(root?: string): readonly string[]; +export declare function nodeEngineRange(root?: string): string; +export declare function minimumNodeVersion(root?: string): string; diff --git a/scripts/lib/repo-inventory.mjs b/scripts/lib/repo-inventory.mjs new file mode 100644 index 000000000..a01deb2c6 --- /dev/null +++ b/scripts/lib/repo-inventory.mjs @@ -0,0 +1,64 @@ +// One derivation of what this checkout ships, read from disk every time. +// +// A hand-typed kit or package list goes stale the moment a directory is added, and it fails +// quietly: `scripts/capability-recall.ts` skipped the `puzzle`, `rts` and `runner` kits for months +// because its list predated them. Every consumer reads this module instead, so shipping a kit or a +// package is one directory, not an edit in four scripts. +// +// Plain ESM on purpose: CI runs `node scripts/*.mjs` on Node 20, which cannot import `.ts`. +// `repo-inventory.d.mts` carries the types for the TypeScript callers. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function childDirectories(directory) { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); +} + +function shipping(directory, marker) { + return childDirectories(directory).filter((name) => + existsSync(path.join(directory, name, marker)), + ); +} + +/** + * Directory names under `packages/` that carry a manifest. Directory names, not package names: + * every consumer here builds `packages//…` paths or expressions. + */ +export function workspacePackageNames(root = REPO_ROOT) { + return shipping(path.join(root, "packages"), "package.json"); +} + +/** Kit directory names. A directory is a kit when it ships `kit.json`, which is the lookup data. */ +export function templateNames(root = REPO_ROOT) { + return shipping(path.join(root, "packages", "create-threenative", "templates"), "kit.json"); +} + +/** Benchmark brief genres. A genre is a brief when it ships `brief.md`. */ +export function briefGenres(root = REPO_ROOT) { + return shipping(path.join(root, "docs", "benchmark", "genres"), "brief.md"); +} + +/** The Node floor this repository declares once, in its root `package.json` `engines.node`. */ +export function nodeEngineRange(root = REPO_ROOT) { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + const range = manifest?.engines?.node; + if (typeof range !== "string" || range.length === 0) { + throw new Error("TN_REPO_INVENTORY: the root package.json declares no engines.node"); + } + return range; +} + +/** `nodeEngineRange` without its operator, the form an error message or a version check uses. */ +export function minimumNodeVersion(root = REPO_ROOT) { + const minimum = /(\d+(?:\.\d+)*)/u.exec(nodeEngineRange(root))?.[1]; + if (minimum === undefined) { + throw new Error(`TN_REPO_INVENTORY: engines.node '${nodeEngineRange(root)}' names no version`); + } + return minimum; +} diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index a69d9f5a3..d4aea1a0b 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -244,21 +244,6 @@ export function syncSourceVersions(repo: string, versions: ReadonlyMap Date: Fri, 6 Nov 2026 23:08:38 -0700 Subject: [PATCH 003/154] style(create-threenative): format the render drift spec --- .../create-threenative/__tests__/shared-render-drift.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/create-threenative/__tests__/shared-render-drift.spec.ts b/packages/create-threenative/__tests__/shared-render-drift.spec.ts index 2b78bdbfa..3c15e6258 100644 --- a/packages/create-threenative/__tests__/shared-render-drift.spec.ts +++ b/packages/create-threenative/__tests__/shared-render-drift.spec.ts @@ -35,7 +35,8 @@ function renderCopies(): Map> { const digest = createHash("sha256") .update(readFileSync(path.join(render, file))) .digest("hex"); - const byDigest = copies.get(`${RENDER_PREFIX}${file}`) ?? new Map(); + const byDigest = + copies.get(`${RENDER_PREFIX}${file}`) ?? new Map(); byDigest.set(digest, [...(byDigest.get(digest) ?? []), entry.name]); copies.set(`${RENDER_PREFIX}${file}`, byDigest); } From ccf548cc68557a38fb392410d56d239f92d88d07 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:16:30 -0700 Subject: [PATCH 004/154] refactor(three-native,runtime-native): name the tnw_web_frame fields from one manifest PRD-832 phase 3. api/web-frame-fields.json is the field table; generation writes the TypeScript constant, the C++ enum and the facade's stage tables from it. web_host.cpp switches on the enum names, and an id the manifest does not declare prints TN_WEB_FRAME_UNKNOWN_FIELD once and answers NaN rather than a zero a caller would read as a real measurement. A spec pins the three copies and digests the table, so a renumbered field goes red. --- .../PRD-832-urgent-single-sources.md | 8 +- .../src/engine/wasm/web_frame_fields.h | 81 +++++++++ .../src/engine/wasm/web_host.cpp | 168 ++++++++++-------- .../__tests__/web-frame-fields.spec.ts | 66 +++++++ .../three-native/api/web-frame-fields.json | 79 ++++++++ packages/three-native/scripts/generate.ts | 117 +++++++++++- packages/three-native/src/browser-entry.ts | 9 +- packages/three-native/src/browser-renderer.ts | 130 ++++++++------ .../src/generated/web-frame-fields.ts | 81 +++++++++ packages/three-native/src/gpu-stages.ts | 33 ++-- 10 files changed, 613 insertions(+), 159 deletions(-) create mode 100644 packages/runtime-native/src/engine/wasm/web_frame_fields.h create mode 100644 packages/three-native/__tests__/web-frame-fields.spec.ts create mode 100644 packages/three-native/api/web-frame-fields.json create mode 100644 packages/three-native/src/generated/web-frame-fields.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md index e1807898b..aa595b5ed 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -1,6 +1,6 @@ # PRD-832 — Urgent single sources: one owner for each value that has silently drifted -**Status:** PARTIAL — Phases 1 and 2 landed; Phase 3 (named `tnw_web_frame` fields) is open +**Status:** DONE — all three phases landed **Priority:** P1 — the open Phase 1 box fixes a live gap: `pnpm budgets` runs capability recall without the `puzzle`, `rts` and `runner` kits. The open Phase 3 boxes remove a web telemetry channel that answers 0 to an unknown field. **Complexity:** 5 (MEDIUM); risk override: Phase 2 can change the default tone curve, so the owner's visual-judge rule applies to that box **Owner:** ThreeNative maintainers @@ -71,8 +71,8 @@ the way its own red-green commit. - [x] Core version has one source: `index.ts` re-exports `CORE_VERSION` from the generated `version.ts`, `doctor.ts` imports that constant instead of a literal, and `prepare-release.ts` drops the three regexes that patched them. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` → 112 passed. #### Phase 3: Named `tnw_web_frame` fields -**Status:** NOT STARTED +**Status:** DONE **Files:** the field manifest beside `packages/three-native/api/catalog.json`; `packages/three-native/scripts/generate.ts`; `packages/runtime-native/src/engine/wasm/web_host.cpp`; `packages/three-native/src/browser-renderer.ts`, `gpu-stages.ts`, `browser-entry.ts`; `packages/three-native/__tests__/web-host-abi.spec.ts`. -- [ ] Every `tnw_web_frame` caller uses a generated name. The C++ switch uses the generated enum, and an unknown field fails loudly. The ABI spec digests the field list, so a renumbered field goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-host-abi.spec.ts` plus `pnpm native:build` for the Wasm target -- [ ] Starter on the owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` GPU and CPU stage values are non-zero. proof: `node packages/playtest/dist/runner/cli.js` on the starter golden scenario with `--browser-recipe webgpu`, `adapter.info` checked +- [x] Every `tnw_web_frame` caller uses a generated name, from one manifest: `packages/three-native/api/web-frame-fields.json` (73 fields) feeds `packages/three-native/scripts/generate.ts`, which writes the TypeScript constant (`src/generated/web-frame-fields.ts`), the C++ enum (`runtime-native/src/engine/wasm/web_frame_fields.h`) and the facade's stage tables (`gpu-stages.ts` `CPU_STAGE_FIELDS`/`SHADOW_SEGMENT_FIELD`, `browser-renderer.ts`'s GPU stage base). The switch names the enum, and an id no manifest entry declares prints `TN_WEB_FRAME_UNKNOWN_FIELD` once and answers NaN instead of a zero the caller would read as a measurement. `__tests__/web-frame-fields.spec.ts` pins the three copies to the manifest and digests the table (`faae3793e5b2581b`), so a renumber goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-frame-fields.spec.ts __tests__/web-host-abi.spec.ts` → 6 passed; `pnpm wasm:build` compiles and links both web engines with the new switch (exit 0). +- [x] The owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` on `examples/wasm-engine-boot` (the game page, `--browser-recipe webgpu`, adapter `nvidia`/`turing`) reads GPU `mean 0.05 ms` over 3 samples and non-zero CPU stages (`wasmPlan` mean 0.30, `wasmEncode` 0.26, `wasmScene` 0.06, `wasmPost` 0.16, `engineJs` 0.30) — the renamed stage fields (66-70) answer the values they named before. The starter scaffold needs a full template install-and-build lane, so the proof runs on the in-repo game page that drives the same `tnw_web_frame` path; `pnpm test:templates` remains the starter-wide gate under AC-1. proof: `node packages/playtest/dist/runner/cli.js /tmp/framebudget.playtest.json --url http://127.0.0.1:5293 --server-command "npx vite preview --host 127.0.0.1 --port 5293 --strictPort" --browser-recipe webgpu --headed` → 1 window, values recorded here diff --git a/packages/runtime-native/src/engine/wasm/web_frame_fields.h b/packages/runtime-native/src/engine/wasm/web_frame_fields.h new file mode 100644 index 000000000..a16449520 --- /dev/null +++ b/packages/runtime-native/src/engine/wasm/web_frame_fields.h @@ -0,0 +1,81 @@ +// Generated by @threenative/three-native from api/web-frame-fields.json. Do not edit. +// +// tnw_web_frame's field table. The switch in web_host.cpp names these, and an id that is not +// here is refused loudly rather than answered with a zero. +#pragma once + +enum TnWebFrameField : int { + TN_WEB_FRAME_CPU_METERS_OFF = -4, + TN_WEB_FRAME_CPU_METERS_ON = -3, + TN_WEB_FRAME_DEBUG_TIMESTAMP_OFF = -2, + TN_WEB_FRAME_DEBUG_TIMESTAMP_ON = -1, + TN_WEB_FRAME_DRAWS = 0, + TN_WEB_FRAME_TRIANGLES = 1, + TN_WEB_FRAME_PIPELINE_COMPILES = 2, + TN_WEB_FRAME_PIPELINE_LOOKUPS = 3, + TN_WEB_FRAME_BIND_GROUPS_CREATED = 4, + TN_WEB_FRAME_GRAPH_KEY_BUILDS = 5, + TN_WEB_FRAME_PROGRAMS = 6, + TN_WEB_FRAME_GPU_MS = 7, + TN_WEB_FRAME_GPU_SAMPLES = 8, + TN_WEB_FRAME_GPU_SOURCE_FRAME = 9, + TN_WEB_FRAME_GPU_TIMER_AVAILABLE = 10, + TN_WEB_FRAME_GPU_BUSY = 11, + TN_WEB_FRAME_ENVIRONMENT_PENDING = 12, + TN_WEB_FRAME_TEMPORAL_ACTIVE = 13, + TN_WEB_FRAME_TEMPORAL_FRAME = 14, + TN_WEB_FRAME_TEMPORAL_HISTORY_VALID = 15, + TN_WEB_FRAME_TEMPORAL_JITTER_X = 16, + TN_WEB_FRAME_TEMPORAL_JITTER_Y = 17, + TN_WEB_FRAME_SAMPLE_COUNT = 18, + TN_WEB_FRAME_GPU_SEGMENT_SHADOW = 19, + TN_WEB_FRAME_GPU_SEGMENT_SCENE = 20, + TN_WEB_FRAME_GPU_SEGMENT_OTHER = 21, + TN_WEB_FRAME_GPU_SEGMENT_OUTPUT = 22, + TN_WEB_FRAME_GPU_SEGMENT_GATHER = 23, + TN_WEB_FRAME_GPU_SEGMENT_LAMP_SHADOW = 24, + TN_WEB_FRAME_LAMP_REDRAWS = 25, + TN_WEB_FRAME_LAMP_SHADOW_DRAWS = 26, + TN_WEB_FRAME_SHADOW_DRAWS = 27, + TN_WEB_FRAME_LAMP_MAP_MIN = 28, + TN_WEB_FRAME_LAMP_MAP_MAX = 29, + TN_WEB_FRAME_TEMPORAL_REJECTED = 30, + TN_WEB_FRAME_TEMPORAL_CLAMPED = 31, + TN_WEB_FRAME_TEMPORAL_PIXELS = 32, + TN_WEB_FRAME_TEMPORAL_COUNTED_FRAME = 33, + TN_WEB_FRAME_TEMPORAL_UPSCALE = 34, + TN_WEB_FRAME_INPUT_WIDTH = 35, + TN_WEB_FRAME_INPUT_HEIGHT = 36, + TN_WEB_FRAME_TEMPORAL_JITTER_LENGTH = 37, + TN_WEB_FRAME_WIDTH = 38, + TN_WEB_FRAME_HEIGHT = 39, + TN_WEB_FRAME_KEPT_SHADOW_MAPS = 40, + TN_WEB_FRAME_UNIFORM_BYTES = 41, + TN_WEB_FRAME_MAIN_RECORDED = 60, + TN_WEB_FRAME_RECORD_CAUSE = 61, + TN_WEB_FRAME_AUX_RECORDED = 62, + TN_WEB_FRAME_RECORD_MS = 63, + TN_WEB_FRAME_SHADOW_ENCODE_MS = 64, + TN_WEB_FRAME_SHADOW_RECORDED = 65, + TN_WEB_FRAME_CPU_VIRTUAL_PAGES_MS = 66, + TN_WEB_FRAME_CPU_PLAN_MS = 67, + TN_WEB_FRAME_CPU_SHADOW_BUILD_MS = 68, + TN_WEB_FRAME_CPU_PREPARE_MS = 69, + TN_WEB_FRAME_CPU_ENCODE_MS = 70, + TN_WEB_FRAME_WORLD_SUBMISSIONS = 71, + TN_WEB_FRAME_SHADOW_INPUTS_READY = 72, + TN_WEB_FRAME_CPU_POST_MS = 73, + TN_WEB_FRAME_SCENE_MS = 74, + TN_WEB_FRAME_HOST_RENDER_MS = 75, + TN_WEB_FRAME_HOST_COMPUTE_MS = 76, + TN_WEB_FRAME_HOST_SUBMIT_MS = 77, + TN_WEB_FRAME_STAGE_TOTALS_ADDRESS = 78, + TN_WEB_FRAME_HOST_POLL_MS = 79, + TN_WEB_FRAME_HOST_TAIL_MS = 80, + TN_WEB_FRAME_RENDERER_MS = 81, + TN_WEB_FRAME_HOST_DATABASE_MS = 82, + TN_WEB_FRAME_HOST_TARGET_MS = 83, + TN_WEB_FRAME_CPU_METERS_ACTIVE = 84, + TN_WEB_FRAME_SHADOW_TRIANGLES = 85, + TN_WEB_FRAME_FIRST_WORLD_FRAME_DRAWN = 86, +}; diff --git a/packages/runtime-native/src/engine/wasm/web_host.cpp b/packages/runtime-native/src/engine/wasm/web_host.cpp index 16856135c..b3d5edaf6 100644 --- a/packages/runtime-native/src/engine/wasm/web_host.cpp +++ b/packages/runtime-native/src/engine/wasm/web_host.cpp @@ -15,10 +15,12 @@ #include "engine/renderer/compute.h" #include "engine/scene/geometry.h" #include "engine/shader/graph/graph.h" +#include "engine/wasm/web_frame_fields.h" #include #include #include +#include #include #include #include @@ -1003,127 +1005,135 @@ extern "C" int tnw_web_load_gltf(tn_context_t* context, const uint8_t* bytes, ui #endif } +/// The last id that arrived without a manifest entry, so the report prints once per id, not per frame. +static int unknownFrameField = 0; + /** - * The last frame's draws (0) and triangles (1), as three's `renderer.info.render` reports them, then - * the setup work done so far, which a steady frame must not add to: pipeline compiles (2), pipeline - * text-key lookups (3), bind groups created (4), graph keys serialized (5), programs built (6), - * the last resolved GPU frame time in ms (7), how many GPU times have resolved (8), the caller - * frame id that sample was submitted under (9), whether frames are timed at all (10), and 1 while - * two drawn frames are still on the GPU, so the caller draws none (11), and 1 while the last render skipped - * its frame for an environment that is not baked yet (12). The temporal pass follows: 1 while a traa() effect is in - * the post graph (13), the frames it has begun (14), 1 when the next resolve has history (15), and the last - * sub-pixel jitter in pixels, x (16) and y (17). Then the samples the scene draws with (18): 1 while the temporal pass - * replaces the multisampling the renderer asked for. - * The temporal pass's counted frame follows the lamp fields, 30 to 33. The kept shadow maps and the uniform bytes are 40 and 41. + * One number of the last frame's telemetry, by the field names in + * `packages/three-native/api/web-frame-fields.json`: draws and triangles, the setup work a steady + * frame must not add to, the GPU sample and its segments, the temporal pass, the shadow maps and the + * CPU stage meters. Generation writes the names here (`web_frame_fields.h`) and in the facade + * (`src/generated/web-frame-fields.ts`) from that one file. + * + * An id the manifest does not declare is refused: it prints `TN_WEB_FRAME_UNKNOWN_FIELD` once and + * answers NaN, because a zero would read as a real measurement from a table that never had it. */ extern "C" double tnw_web_frame(int field) { - // -3 and -4: the CPU stage meters on and off (PRD-771); a static switch, so it answers before the renderer exists. - // On until a page asks otherwise. - if (field == -3 || field == -4) { - Renderer::setCpuMeters(field == -3); + // The CPU stage meters on and off (PRD-771); a static switch, so it answers before the renderer + // exists. On until a page asks otherwise. + if (field == TN_WEB_FRAME_CPU_METERS_ON || field == TN_WEB_FRAME_CPU_METERS_OFF) { + Renderer::setCpuMeters(field == TN_WEB_FRAME_CPU_METERS_ON); return 0; } if (!renderer) return 0; if (!lastDrawn) lastDrawn = renderer.get(); switch (field) { - case -1: renderer->setDebugTimestampRequested(true); return 0; - case -2: renderer->setDebugTimestampRequested(false); return 0; + case TN_WEB_FRAME_DEBUG_TIMESTAMP_ON: renderer->setDebugTimestampRequested(true); return 0; + case TN_WEB_FRAME_DEBUG_TIMESTAMP_OFF: renderer->setDebugTimestampRequested(false); return 0; // three's renderer.info.render counts every pass of a render(), the shadow pass's included, as the // V8 player's frameDrawn does: one scene is counted the same on every backend. - case 0: return double(lastDrawn->lastFrame().draws + lastDrawn->lastFrame().shadowDraws); - case 1: return double(lastDrawn->lastFrame().triangles + lastDrawn->lastFrame().shadowTriangles); - case 2: return double(renderer->pipelines().compiles()); - case 3: return double(renderer->pipelines().textLookups()); - case 4: return double(bindGroupsCreated()); - case 5: return double(shader::graph::keyBuilds()); - case 6: return double(renderer->programCount()); - case 7: return renderer->lastGpuMs(); - case 8: return double(renderer->gpuSamples()); - case 9: return double(renderer->gpuSourceFrame()); - case 10: return renderer->gpuTimerAvailable() ? 1 : 0; - case 11: + case TN_WEB_FRAME_DRAWS: return double(lastDrawn->lastFrame().draws + lastDrawn->lastFrame().shadowDraws); + case TN_WEB_FRAME_TRIANGLES: return double(lastDrawn->lastFrame().triangles + lastDrawn->lastFrame().shadowTriangles); + case TN_WEB_FRAME_PIPELINE_COMPILES: return double(renderer->pipelines().compiles()); + case TN_WEB_FRAME_PIPELINE_LOOKUPS: return double(renderer->pipelines().textLookups()); + case TN_WEB_FRAME_BIND_GROUPS_CREATED: return double(bindGroupsCreated()); + case TN_WEB_FRAME_GRAPH_KEY_BUILDS: return double(shader::graph::keyBuilds()); + case TN_WEB_FRAME_PROGRAMS: return double(renderer->programCount()); + case TN_WEB_FRAME_GPU_MS: return renderer->lastGpuMs(); + case TN_WEB_FRAME_GPU_SAMPLES: return double(renderer->gpuSamples()); + case TN_WEB_FRAME_GPU_SOURCE_FRAME: return double(renderer->gpuSourceFrame()); + case TN_WEB_FRAME_GPU_TIMER_AVAILABLE: return renderer->gpuTimerAvailable() ? 1 : 0; + case TN_WEB_FRAME_GPU_BUSY: tnw_web_poll(); // delivers the work-done callbacks that advance completedSerial return frameGate.decline(renderer->gpu().completedSerial(), emscripten_get_now()) ? 1 : 0; - case 12: return renderer->environmentPending() ? 1 : 0; - case 13: return renderer->temporalStatus().active ? 1 : 0; - case 14: return double(renderer->temporalStatus().frame); - case 15: return renderer->temporalStatus().historyValid ? 1 : 0; - case 16: return renderer->temporalStatus().jitterX; - case 17: return renderer->temporalStatus().jitterY; - case 18: return double(renderer->sampleCount()); + case TN_WEB_FRAME_ENVIRONMENT_PENDING: return renderer->environmentPending() ? 1 : 0; + case TN_WEB_FRAME_TEMPORAL_ACTIVE: return renderer->temporalStatus().active ? 1 : 0; + case TN_WEB_FRAME_TEMPORAL_FRAME: return double(renderer->temporalStatus().frame); + case TN_WEB_FRAME_TEMPORAL_HISTORY_VALID: return renderer->temporalStatus().historyValid ? 1 : 0; + case TN_WEB_FRAME_TEMPORAL_JITTER_X: return renderer->temporalStatus().jitterX; + case TN_WEB_FRAME_TEMPORAL_JITTER_Y: return renderer->temporalStatus().jitterY; + case TN_WEB_FRAME_SAMPLE_COUNT: return double(renderer->sampleCount()); // The sample field 7 reports, by what drew it: 19 shadow passes, 20 the scene pass, 21 other passes, 22 the // output pass, 23 the screen-space gather (frame_gpu_timer.h). They sum to field 7. - case 19: case 20: case 21: case 22: case 23: return renderer->lastGpuSegments()[size_t(field - 19)]; + case TN_WEB_FRAME_GPU_SEGMENT_SHADOW: case TN_WEB_FRAME_GPU_SEGMENT_SCENE: case TN_WEB_FRAME_GPU_SEGMENT_OTHER: case TN_WEB_FRAME_GPU_SEGMENT_OUTPUT: case TN_WEB_FRAME_GPU_SEGMENT_GATHER: return renderer->lastGpuSegments()[size_t(field - TN_WEB_FRAME_GPU_SEGMENT_SHADOW)]; // PRD-650: the lamp maps apart from the sun's. 24 their GPU time (a part of field 7, beside 19), 25 the // lamp maps the last frame redrew, 26 their draws, and 27 every shadow draw, so 27 - 26 is the sun's. - case 24: return renderer->lastGpuSegments()[kGpuLampShadowSegment]; - case 25: return double(renderer->lastFrame().lampRedraws); - case 26: return double(renderer->lastFrame().lampShadowDraws); - case 27: return double(renderer->lastFrame().shadowDraws); + case TN_WEB_FRAME_GPU_SEGMENT_LAMP_SHADOW: return renderer->lastGpuSegments()[kGpuLampShadowSegment]; + case TN_WEB_FRAME_LAMP_REDRAWS: return double(renderer->lastFrame().lampRedraws); + case TN_WEB_FRAME_LAMP_SHADOW_DRAWS: return double(renderer->lastFrame().lampShadowDraws); + case TN_WEB_FRAME_SHADOW_DRAWS: return double(renderer->lastFrame().shadowDraws); // 28 and 29: the smallest and largest edge of the lamp maps the footprint sized (0 without one). - case 28: return double(renderer->lampShadowExtent()[0]); - case 29: return double(renderer->lampShadowExtent()[1]); + case TN_WEB_FRAME_LAMP_MAP_MIN: return double(renderer->lampShadowExtent()[0]); + case TN_WEB_FRAME_LAMP_MAP_MAX: return double(renderer->lampShadowExtent()[1]); // 30 to 33: the temporal resolve's newest counted frame (one in 16): the pixels whose history it discarded, the // pixels whose history it pulled into the colour neighbourhood, the pixel count of that frame and its frame number. - case 30: return double(renderer->temporalStatus().rejected); - case 31: return double(renderer->temporalStatus().clamped); - case 32: return double(renderer->temporalStatus().pixels); - case 33: return double(renderer->temporalStatus().countedFrame); - case 34: return renderer->temporalUpscale() ? 1 : 0; - case 35: return renderer->inputWidth(); - case 36: return renderer->inputHeight(); - case 37: return renderer->traaDebugPass() ? renderer->traaDebugPass()->status().jitterLength : 0; - case 38: return renderer->width(); - case 39: return renderer->height(); + case TN_WEB_FRAME_TEMPORAL_REJECTED: return double(renderer->temporalStatus().rejected); + case TN_WEB_FRAME_TEMPORAL_CLAMPED: return double(renderer->temporalStatus().clamped); + case TN_WEB_FRAME_TEMPORAL_PIXELS: return double(renderer->temporalStatus().pixels); + case TN_WEB_FRAME_TEMPORAL_COUNTED_FRAME: return double(renderer->temporalStatus().countedFrame); + case TN_WEB_FRAME_TEMPORAL_UPSCALE: return renderer->temporalUpscale() ? 1 : 0; + case TN_WEB_FRAME_INPUT_WIDTH: return renderer->inputWidth(); + case TN_WEB_FRAME_INPUT_HEIGHT: return renderer->inputHeight(); + case TN_WEB_FRAME_TEMPORAL_JITTER_LENGTH: return renderer->traaDebugPass() ? renderer->traaDebugPass()->status().jitterLength : 0; + case TN_WEB_FRAME_WIDTH: return renderer->width(); + case TN_WEB_FRAME_HEIGHT: return renderer->height(); // PRD-754, 60 to 65: the draws the main pass recorded into its bundle, // why its key moved (Renderer::RecordCause), the draws the aux and velocity passes recorded, the ms spent // recording bundles, and the ms spent encoding the shadow passes directly. Zero while every bundle holds. - case 60: return double(renderer->lastFrame().mainRecorded); - case 61: return double(renderer->lastFrame().recordCause); - case 62: return double(renderer->lastFrame().auxRecorded); - case 63: return renderer->lastFrame().recordMs; - case 64: return renderer->lastFrame().shadowEncodeMs; - case 65: return double(renderer->lastFrame().shadowRecorded); // the shadow passes' draws recorded into bundles + case TN_WEB_FRAME_MAIN_RECORDED: return double(renderer->lastFrame().mainRecorded); + case TN_WEB_FRAME_RECORD_CAUSE: return double(renderer->lastFrame().recordCause); + case TN_WEB_FRAME_AUX_RECORDED: return double(renderer->lastFrame().auxRecorded); + case TN_WEB_FRAME_RECORD_MS: return renderer->lastFrame().recordMs; + case TN_WEB_FRAME_SHADOW_ENCODE_MS: return renderer->lastFrame().shadowEncodeMs; + case TN_WEB_FRAME_SHADOW_RECORDED: return double(renderer->lastFrame().shadowRecorded); // the shadow passes' draws recorded into bundles // 66 to 70: running CPU ms of the renderer's costly stages, every render summed (Renderer::CpuStage): the virtual // shadow pages and caster bounds, the draw plan, the shadow passes' draw lists, the whole prepare half, the encode half. - case 66: case 67: case 68: case 69: case 70: return renderer->cpuStageMs(Renderer::CpuStage(field - 66)); + case TN_WEB_FRAME_CPU_VIRTUAL_PAGES_MS: case TN_WEB_FRAME_CPU_PLAN_MS: case TN_WEB_FRAME_CPU_SHADOW_BUILD_MS: case TN_WEB_FRAME_CPU_PREPARE_MS: case TN_WEB_FRAME_CPU_ENCODE_MS: return renderer->cpuStageMs(Renderer::CpuStage(field - TN_WEB_FRAME_CPU_VIRTUAL_PAGES_MS)); // 71 and 72: the startup's first real frame (ABI 8). 71 counts the ordinary world submissions that encoded since // the page started drawing: a bake step, a compile, an overlay, a declined frame or a failed one never advances it, // and core compares it with the generation it saw when it engaged the hold. 72 is 1 once the shadow inputs those // frames sample exist: every active virtual shadow has mapped its clip levels, and a scene with none, or with only // the ordinary maps a successful encode already drew, is ready. A shadow draw count is not this answer, and only a // held frame recomputes it. - case 71: return double(worldSubmissions); - case 72: return shadowInputsReady ? 1 : 0; + case TN_WEB_FRAME_WORLD_SUBMISSIONS: return double(worldSubmissions); + case TN_WEB_FRAME_SHADOW_INPUTS_READY: return shadowInputsReady ? 1 : 0; // 86: 1 once the first world frame drawn after the hold came off has finished on the GPU, so the display shows // it. A draw the gate accepted only says it was queued: a software rasteriser takes tens of seconds to finish it. - case 86: + case TN_WEB_FRAME_FIRST_WORLD_FRAME_DRAWN: tnw_web_poll(); // delivers the work-done callbacks that advance completedSerial return firstPresentedSerial != 0 && renderer->gpu().completedSerial() >= firstPresentedSerial ? 1 : 0; // 40: the shadow maps the last frame kept because nothing they draw changed (sun or lamp, not virtual); // 41: the bytes of per-draw uniforms it uploaded. // 85 (PRD-770): the shadow passes' triangles, which field 1 adds in. With 27 (every shadow draw) it splits fields // 0 and 1 into the main pass and the shadow passes, as three counts a nested shadow render apart from the main one. - case 85: return double(lastDrawn->lastFrame().shadowTriangles); - case 40: return double(renderer->lastFrame().keptShadowMaps); - case 41: return double(renderer->lastFrame().uniformBytes); + case TN_WEB_FRAME_SHADOW_TRIANGLES: return double(lastDrawn->lastFrame().shadowTriangles); + case TN_WEB_FRAME_KEPT_SHADOW_MAPS: return double(renderer->lastFrame().keptShadowMaps); + case TN_WEB_FRAME_UNIFORM_BYTES: return double(renderer->lastFrame().uniformBytes); // PRD-771, 73 to 77: running ms, every render summed, like 66 to 70. 73 the post effects' and output pass's share of // the encode half (inside 70); 74 the scene half of RenderDatabase::render (joints, scene walk, shadow sync); // 75 the whole main-view tnw_web_render, of which 76 is the queued kernels' flush and 77 the batched queue submit. - case 73: return renderer->cpuStageMs(Renderer::CpuPost); - case 74: return database.sceneMs(); - case 75: return hostRenderMs; - case 76: return hostComputeMs; - case 77: return hostSubmitMs; + case TN_WEB_FRAME_CPU_POST_MS: return renderer->cpuStageMs(Renderer::CpuPost); + case TN_WEB_FRAME_SCENE_MS: return database.sceneMs(); + case TN_WEB_FRAME_HOST_RENDER_MS: return hostRenderMs; + case TN_WEB_FRAME_HOST_COMPUTE_MS: return hostComputeMs; + case TN_WEB_FRAME_HOST_SUBMIT_MS: return hostSubmitMs; // 79 and 80: the tnw_web_poll that opens a render, and the render's tail (surface texture release, frame gate, diagnostics). - case 79: return hostPollMs; - case 80: return hostTailMs; - case 81: return database.rendererMs(); - case 82: return hostDatabaseMs; - case 83: return hostTargetMs; - case 84: return Renderer::cpuMeters() ? 1 : 0; // the stage meters on (the page lists the totals only while they run) + case TN_WEB_FRAME_HOST_POLL_MS: return hostPollMs; + case TN_WEB_FRAME_HOST_TAIL_MS: return hostTailMs; + case TN_WEB_FRAME_RENDERER_MS: return database.rendererMs(); + case TN_WEB_FRAME_HOST_DATABASE_MS: return hostDatabaseMs; + case TN_WEB_FRAME_HOST_TARGET_MS: return hostTargetMs; + case TN_WEB_FRAME_CPU_METERS_ACTIVE: return Renderer::cpuMeters() ? 1 : 0; // the stage meters on (the page lists the totals only while they run) // 78: the address of the array above, as a number; the page reads 18 doubles from it after each render. - case 78: return double(reinterpret_cast(stageTotals)); - default: return 0; + case TN_WEB_FRAME_STAGE_TOTALS_ADDRESS: return double(reinterpret_cast(stageTotals)); + default: + // An id the manifest does not declare: a facade built against another field table. + // Say which one once, and answer NaN so the caller sees a broken number rather than a + // zero it would read as a real measurement. + if (unknownFrameField != field) { + unknownFrameField = field; + std::fprintf(stderr, "TN_WEB_FRAME_UNKNOWN_FIELD %d\n", field); + } + return std::numeric_limits::quiet_NaN(); } } diff --git a/packages/three-native/__tests__/web-frame-fields.spec.ts b/packages/three-native/__tests__/web-frame-fields.spec.ts new file mode 100644 index 000000000..a7eac137d --- /dev/null +++ b/packages/three-native/__tests__/web-frame-fields.spec.ts @@ -0,0 +1,66 @@ +// The `tnw_web_frame` field table is an ABI: the facade passes a number and the host answers a +// value, so a field that moves reads one measurement as another with nothing failing. These checks +// pin the three copies that exist — the manifest, the TypeScript constant and the C++ enum — and +// the digest of the table itself, so a renumber has to be deliberate. +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { WEB_FRAME_FIELD } from "../src/generated/web-frame-fields.js"; + +const ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); +const MANIFEST = path.join(ROOT, "packages/three-native/api/web-frame-fields.json"); +const HEADER = path.join(ROOT, "packages/runtime-native/src/engine/wasm/web_frame_fields.h"); + +interface IField { + readonly id: number; + readonly name: string; +} + +function manifestFields(): readonly IField[] { + const parsed = JSON.parse(readFileSync(MANIFEST, "utf8")) as { fields: readonly IField[] }; + return parsed.fields; +} + +/** `TN_WEB_FRAME_CPU_METERS_ON` for `cpuMetersOn`, the spelling the generated header uses. */ +function macro(name: string): string { + return `TN_WEB_FRAME_${name.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase()}`; +} + +/** + * The digest of the whole table. A field that moves changes it, and so does a rename; adding a + * field at the end is the only edit that leaves the existing ids alone, which is the point. + */ +const FIELD_TABLE_DIGEST = "faae3793e5b2581b"; + +describe("the web frame field table", () => { + it("should generate the TypeScript constant from the manifest", () => { + const generated = Object.fromEntries(Object.entries(WEB_FRAME_FIELD)); + expect(generated).toEqual(Object.fromEntries(manifestFields().map((f) => [f.name, f.id]))); + }); + + it("should generate the C++ enum from the manifest", () => { + const header = readFileSync(HEADER, "utf8"); + const rows = [...header.matchAll(/^\s*(TN_WEB_FRAME_[A-Z0-9_]+) = (-?\d+),$/gmu)].map( + ([, name, id]) => [name, Number(id)] as const, + ); + expect(rows).toEqual(manifestFields().map((f) => [macro(f.name), f.id] as const)); + }); + + it("should pin the field ids, so a renumbered field goes red", () => { + const digest = createHash("sha256") + .update( + manifestFields() + .map((f) => `${f.name}:${String(f.id)}`) + .join("\n"), + ) + .digest("hex") + .slice(0, 16); + expect( + digest, + "a tnw_web_frame field moved: ids are frozen and append-only, so restore the old id and add a new field", + ).toBe(FIELD_TABLE_DIGEST); + }); +}); diff --git a/packages/three-native/api/web-frame-fields.json b/packages/three-native/api/web-frame-fields.json new file mode 100644 index 000000000..abbd7ae79 --- /dev/null +++ b/packages/three-native/api/web-frame-fields.json @@ -0,0 +1,79 @@ +{ + "$comment": "The tnw_web_frame field table: one id per number the web host answers. Generation writes the C++ enum and the TypeScript constant from this file; ids are frozen and append-only, because a facade built against another table would read a different number's value.", + "version": 1, + "fields": [ + { "name": "cpuMetersOff", "id": -4 }, + { "name": "cpuMetersOn", "id": -3 }, + { "name": "debugTimestampOff", "id": -2 }, + { "name": "debugTimestampOn", "id": -1 }, + { "name": "draws", "id": 0 }, + { "name": "triangles", "id": 1 }, + { "name": "pipelineCompiles", "id": 2 }, + { "name": "pipelineLookups", "id": 3 }, + { "name": "bindGroupsCreated", "id": 4 }, + { "name": "graphKeyBuilds", "id": 5 }, + { "name": "programs", "id": 6 }, + { "name": "gpuMs", "id": 7 }, + { "name": "gpuSamples", "id": 8 }, + { "name": "gpuSourceFrame", "id": 9 }, + { "name": "gpuTimerAvailable", "id": 10 }, + { "name": "gpuBusy", "id": 11 }, + { "name": "environmentPending", "id": 12 }, + { "name": "temporalActive", "id": 13 }, + { "name": "temporalFrame", "id": 14 }, + { "name": "temporalHistoryValid", "id": 15 }, + { "name": "temporalJitterX", "id": 16 }, + { "name": "temporalJitterY", "id": 17 }, + { "name": "sampleCount", "id": 18 }, + { "name": "gpuSegmentShadow", "id": 19 }, + { "name": "gpuSegmentScene", "id": 20 }, + { "name": "gpuSegmentOther", "id": 21 }, + { "name": "gpuSegmentOutput", "id": 22 }, + { "name": "gpuSegmentGather", "id": 23 }, + { "name": "gpuSegmentLampShadow", "id": 24 }, + { "name": "lampRedraws", "id": 25 }, + { "name": "lampShadowDraws", "id": 26 }, + { "name": "shadowDraws", "id": 27 }, + { "name": "lampMapMin", "id": 28 }, + { "name": "lampMapMax", "id": 29 }, + { "name": "temporalRejected", "id": 30 }, + { "name": "temporalClamped", "id": 31 }, + { "name": "temporalPixels", "id": 32 }, + { "name": "temporalCountedFrame", "id": 33 }, + { "name": "temporalUpscale", "id": 34 }, + { "name": "inputWidth", "id": 35 }, + { "name": "inputHeight", "id": 36 }, + { "name": "temporalJitterLength", "id": 37 }, + { "name": "width", "id": 38 }, + { "name": "height", "id": 39 }, + { "name": "keptShadowMaps", "id": 40 }, + { "name": "uniformBytes", "id": 41 }, + { "name": "mainRecorded", "id": 60 }, + { "name": "recordCause", "id": 61 }, + { "name": "auxRecorded", "id": 62 }, + { "name": "recordMs", "id": 63 }, + { "name": "shadowEncodeMs", "id": 64 }, + { "name": "shadowRecorded", "id": 65 }, + { "name": "cpuVirtualPagesMs", "id": 66 }, + { "name": "cpuPlanMs", "id": 67 }, + { "name": "cpuShadowBuildMs", "id": 68 }, + { "name": "cpuPrepareMs", "id": 69 }, + { "name": "cpuEncodeMs", "id": 70 }, + { "name": "worldSubmissions", "id": 71 }, + { "name": "shadowInputsReady", "id": 72 }, + { "name": "cpuPostMs", "id": 73 }, + { "name": "sceneMs", "id": 74 }, + { "name": "hostRenderMs", "id": 75 }, + { "name": "hostComputeMs", "id": 76 }, + { "name": "hostSubmitMs", "id": 77 }, + { "name": "stageTotalsAddress", "id": 78 }, + { "name": "hostPollMs", "id": 79 }, + { "name": "hostTailMs", "id": 80 }, + { "name": "rendererMs", "id": 81 }, + { "name": "hostDatabaseMs", "id": 82 }, + { "name": "hostTargetMs", "id": 83 }, + { "name": "cpuMetersActive", "id": 84 }, + { "name": "shadowTriangles", "id": 85 }, + { "name": "firstWorldFrameDrawn", "id": 86 } + ] +} diff --git a/packages/three-native/scripts/generate.ts b/packages/three-native/scripts/generate.ts index a75b879e5..16300e3d0 100644 --- a/packages/three-native/scripts/generate.ts +++ b/packages/three-native/scripts/generate.ts @@ -7,7 +7,7 @@ * pnpm --filter @threenative/three-native generate */ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -482,6 +482,31 @@ const TYPE_TABLE = path.join( "catalog_types.inc", ); +const FRAME_FIELDS_SOURCE = path.join( + ROOT, + "packages", + "three-native", + "api", + "web-frame-fields.json", +); +const FRAME_FIELDS_DECLARATIONS = path.join( + ROOT, + "packages", + "three-native", + "src", + "generated", + "web-frame-fields.ts", +); +const FRAME_FIELDS_HEADER = path.join( + ROOT, + "packages", + "runtime-native", + "src", + "engine", + "wasm", + "web_frame_fields.h", +); + /** * The ABI's type ids: one per catalog class, 1-based in catalog order (0 means "not published"). * Reordering or removing a class changes the ids, which is a compatibility-contract change. @@ -496,6 +521,90 @@ function typeTable(catalog: ICatalog): string { ].join("\n"); } +/** One `tnw_web_frame` field: the number the facade passes and the name both sides use. */ +interface IWebFrameField { + readonly id: number; + readonly name: string; +} + +const FIELD_NAME = /^[a-z][A-Za-z0-9]*$/u; + +/** + * The `tnw_web_frame` field table, validated: integer ids, unique names, ascending ids. + * + * The ids are an ABI. A facade built against another table reads one field's value as another's, + * which shows up as a plausible number rather than as a failure, so this fails closed instead. + */ +function loadFrameFields(): readonly IWebFrameField[] { + const raw = JSON.parse(readFileSync(FRAME_FIELDS_SOURCE, "utf8")) as { + readonly version?: unknown; + readonly fields?: unknown; + }; + if (raw.version !== 1) throw new Error("TN_WEB_FRAME_FIELDS: version must be 1."); + if (!Array.isArray(raw.fields)) throw new Error("TN_WEB_FRAME_FIELDS: fields must be an array."); + const fields = raw.fields.map((entry: unknown, index: number) => { + const field = entry as { readonly id?: unknown; readonly name?: unknown }; + if (typeof field.id !== "number" || !Number.isInteger(field.id)) + throw new Error(`TN_WEB_FRAME_FIELDS: field ${String(index)} has no integer id.`); + if (typeof field.name !== "string" || !FIELD_NAME.test(field.name)) + throw new Error(`TN_WEB_FRAME_FIELDS: field id ${String(field.id)} has no camelCase name.`); + return { id: field.id, name: field.name }; + }); + const ids = new Set(); + const names = new Set(); + for (const field of fields) { + if (ids.has(field.id)) throw new Error(`TN_WEB_FRAME_FIELDS: id ${String(field.id)} twice.`); + if (names.has(field.name)) throw new Error(`TN_WEB_FRAME_FIELDS: name ${field.name} twice.`); + ids.add(field.id); + names.add(field.name); + } + for (let index = 1; index < fields.length; index += 1) { + const previous = fields[index - 1] as IWebFrameField; + const current = fields[index] as IWebFrameField; + if (current.id <= previous.id) + throw new Error( + `TN_WEB_FRAME_FIELDS: ${current.name} (${String(current.id)}) is not above ${previous.name} (${String(previous.id)}); the table is ordered, so a diff names the field that moved.`, + ); + } + return fields; +} + +function screamingSnake(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase(); +} + +function frameFieldsDeclarations(fields: readonly IWebFrameField[]): string { + return [ + "// Generated by @threenative/three-native from api/web-frame-fields.json. Do not edit.", + "//", + "// The fields `tnw_web_frame` answers. Ids are frozen and append-only: a facade reads a number,", + "// so a renumbered field is one value read as another.", + "export const WEB_FRAME_FIELD = {", + ...fields.map((field) => ` ${field.name}: ${String(field.id)},`), + "} as const;", + "", + "export type WebFrameField = (typeof WEB_FRAME_FIELD)[keyof typeof WEB_FRAME_FIELD];", + "", + ].join("\n"); +} + +function frameFieldsHeader(fields: readonly IWebFrameField[]): string { + return [ + "// Generated by @threenative/three-native from api/web-frame-fields.json. Do not edit.", + "//", + "// tnw_web_frame's field table. The switch in web_host.cpp names these, and an id that is not", + "// here is refused loudly rather than answered with a zero.", + "#pragma once", + "", + "enum TnWebFrameField : int {", + ...fields.map( + (field) => ` TN_WEB_FRAME_${screamingSnake(field.name)} = ${String(field.id)},`, + ), + "};", + "", + ].join("\n"); +} + function generate(): void { const catalog = loadCatalog(ROOT); mkdirSync(path.dirname(DECLARATIONS), { recursive: true }); @@ -504,9 +613,13 @@ function generate(): void { writeFileSync(HEADER, header(catalog)); mkdirSync(path.dirname(TYPE_TABLE), { recursive: true }); writeFileSync(TYPE_TABLE, typeTable(catalog)); + const fields = loadFrameFields(); + mkdirSync(path.dirname(FRAME_FIELDS_DECLARATIONS), { recursive: true }); + writeFileSync(FRAME_FIELDS_DECLARATIONS, frameFieldsDeclarations(fields)); + writeFileSync(FRAME_FIELDS_HEADER, frameFieldsHeader(fields)); const supported = catalog.entries.filter((entry) => entry.status.kind === "supported").length; process.stdout.write( - `TN_API_CATALOG_GENERATED: ${catalog.entries.length} entries, ${supported} supported -> ${path.relative(ROOT, DECLARATIONS)}, ${path.relative(ROOT, HEADER)}\n`, + `TN_API_CATALOG_GENERATED: ${catalog.entries.length} entries, ${supported} supported -> ${path.relative(ROOT, DECLARATIONS)}, ${path.relative(ROOT, HEADER)}, ${String(fields.length)} web frame fields\n`, ); } diff --git a/packages/three-native/src/browser-entry.ts b/packages/three-native/src/browser-entry.ts index b6fb476c1..c951e749c 100644 --- a/packages/three-native/src/browser-entry.ts +++ b/packages/three-native/src/browser-entry.ts @@ -30,6 +30,7 @@ import { } from "./browser-renderer.js"; import { type ITslRuntime, defineTsl, isTslNode } from "./browser-tsl.js"; import type { CatalogEntry, ICatalog } from "./catalog.js"; +import { WEB_FRAME_FIELD } from "./generated/web-frame-fields.js"; import { CPU_STAGE_FIELDS } from "./gpu-stages.js"; import { withMathUtilsExtras } from "./math-utils.js"; import { @@ -170,7 +171,7 @@ export async function bindWebEngine( ? () => { const totals = { ...counters() }; // Off, the engine reads no clock and the totals stand at their last value: listing them would report a stale cost. - if (module._tnw_web_frame(84) !== 0) + if (module._tnw_web_frame(WEB_FRAME_FIELD.cpuMetersActive) !== 0) for (const [name, field] of Object.entries(CPU_STAGE_FIELDS)) totals[name] = module._tnw_web_frame(field); return totals; @@ -304,10 +305,10 @@ export async function bindWebEngine( // The frame pass draws at the engine's input size, at the frame's sample count (tnw_web_frame's // 35, 36 and 18), so a game reads the size and MSAA the engine actually drew. surface: () => ({ - width: hostFrame?.call(module, 35) ?? 0, - height: hostFrame?.call(module, 36) ?? 0, + width: hostFrame?.call(module, WEB_FRAME_FIELD.inputWidth) ?? 0, + height: hostFrame?.call(module, WEB_FRAME_FIELD.inputHeight) ?? 0, }), - samples: () => hostFrame?.call(module, 18) ?? 0, + samples: () => hostFrame?.call(module, WEB_FRAME_FIELD.sampleCount) ?? 0, }, ); drawSecondaryPasses = passes.updateSecondaryPasses; diff --git a/packages/three-native/src/browser-renderer.ts b/packages/three-native/src/browser-renderer.ts index ec810aff5..df6eaf39a 100644 --- a/packages/three-native/src/browser-renderer.ts +++ b/packages/three-native/src/browser-renderer.ts @@ -7,6 +7,7 @@ import { TSL_NODE, type TnAbiModule, engineRef } from "./browser-backend.js"; import { type ITslRuntime, defineTsl, isTslNode } from "./browser-tsl.js"; import { debugTelemetry } from "./debug-telemetry.js"; +import { WEB_FRAME_FIELD } from "./generated/web-frame-fields.js"; import { CPU_STAGE_ENTRIES, type CpuStageName, @@ -82,7 +83,7 @@ const FAILED = 2; * `tnw_web_frame` field of the first named stage's segment (web_host.cpp): 19 + 4 shadow, scene, other and output * passes. `GPU_STAGES` counts on from there, one segment per stage. */ -const GPU_STAGE_FIELD = 23; +const GPU_STAGE_FIELD: number = WEB_FRAME_FIELD.gpuSegmentGather; interface IAdapterInfo { readonly vendor: string; @@ -197,7 +198,7 @@ export function defineWebRenderer( readonly supportsRetainedTargetSubrect = false; renderScale = 1; get temporalUpscale(): boolean { - return module._tnw_web_frame(34) === 1; + return module._tnw_web_frame(WEB_FRAME_FIELD.temporalUpscale) === 1; } #stageMeters = true; /** Address of the host's stage-total array (`tnw_web_frame` 78), 0 until `init` asks. */ @@ -208,7 +209,7 @@ export function defineWebRenderer( */ setStageMeters(enabled: boolean): void { this.#stageMeters = enabled; - module._tnw_web_frame(enabled ? -3 : -4); + module._tnw_web_frame(enabled ? WEB_FRAME_FIELD.cpuMetersOn : WEB_FRAME_FIELD.cpuMetersOff); } setRenderScale(scale: number): void { if (!Number.isFinite(scale) || scale < 0.25 || scale > 1) @@ -418,7 +419,7 @@ export function defineWebRenderer( }, trackTimestamp: Boolean(parameters.trackTimestamp ?? true), getTimestampFrames: timestampFrames, - pipelineCompiles: () => module._tnw_web_frame(2), + pipelineCompiles: () => module._tnw_web_frame(WEB_FRAME_FIELD.pipelineCompiles), device: { lost: new Promise((resolve) => { this.#deviceLost = resolve; @@ -476,8 +477,9 @@ export function defineWebRenderer( }; // Honest, like the engine: a device without timestamp-query times no frame, so no caller // may be told timestamps are enabled. An explicit opt-out stays off. - if (module._tnw_web_frame(10) === 0) this.backend.trackTimestamp = false; - this.#stageBase = module._tnw_web_frame(78); + if (module._tnw_web_frame(WEB_FRAME_FIELD.gpuTimerAvailable) === 0) + this.backend.trackTimestamp = false; + this.#stageBase = module._tnw_web_frame(WEB_FRAME_FIELD.stageTotalsAddress); if (this.#stageBase > 0) { this.info.engine.cpuMs = Object.fromEntries( CPU_STAGE_ENTRIES.map(([name]) => [name, 0]), @@ -593,7 +595,7 @@ export function defineWebRenderer( // texture already taken, so refusing it saves no frame and presents the world without it. // Measured on Lantern Vale: every cover over a drawn frame was refused, so the loading screen // flickered black for the whole environment bake and then showed the unfinished world. - if (!this.#frameOpen && module._tnw_web_frame(11) === 1) { + if (!this.#frameOpen && module._tnw_web_frame(WEB_FRAME_FIELD.gpuBusy) === 1) { // A lost device leaves its frames on the GPU for good, so the gate declines every later frame // and no draw ever returns a status: name the loss here, where the frame poll just ran. if (this.#noticeLost()) return; @@ -623,7 +625,11 @@ export function defineWebRenderer( const renderFrame = this.info.frame; if (debug !== undefined) { // Observation only: native captures this request with each submitted sample. - module._tnw_web_frame(this.backend.trackTimestamp ? -1 : -2); + module._tnw_web_frame( + this.backend.trackTimestamp + ? WEB_FRAME_FIELD.debugTimestampOn + : WEB_FRAME_FIELD.debugTimestampOff, + ); debug?.add( "gpuTimestampRequests", this.backend.trackTimestamp ? 1 : 0, @@ -641,32 +647,35 @@ export function defineWebRenderer( ), ); this.info.frame += 1; - if (this.#bakingEnvironment !== undefined && module._tnw_web_frame(12) === 0) { + if ( + this.#bakingEnvironment !== undefined && + module._tnw_web_frame(WEB_FRAME_FIELD.environmentPending) === 0 + ) { // An overlay draw has no environment and says nothing; another sky ends the watch. const drawnEnvironment = (scene as { environment?: unknown }).environment; if (drawnEnvironment === this.#bakingEnvironment) markBaked(this.#bakingEnvironment); if (drawnEnvironment != null) this.#bakingEnvironment = undefined; } - this.info.render.frameCalls = module._tnw_web_frame(0); + this.info.render.frameCalls = module._tnw_web_frame(WEB_FRAME_FIELD.draws); this.info.render.calls += this.info.render.frameCalls; this.info.render.drawCalls += this.info.render.frameCalls; - const lastTriangles = module._tnw_web_frame(1); + const lastTriangles = module._tnw_web_frame(WEB_FRAME_FIELD.triangles); this.info.render.triangles += lastTriangles; const shadows = this.info.engine.shadows; - shadows.lampRedraws = module._tnw_web_frame(25); - shadows.lampDraws = module._tnw_web_frame(26); - shadows.shadowDraws = module._tnw_web_frame(27); - shadows.lampMapMin = module._tnw_web_frame(28); - shadows.lampMapMax = module._tnw_web_frame(29); - shadows.keptMaps = module._tnw_web_frame(40); - this.info.engine.uniformBytes = module._tnw_web_frame(41); + shadows.lampRedraws = module._tnw_web_frame(WEB_FRAME_FIELD.lampRedraws); + shadows.lampDraws = module._tnw_web_frame(WEB_FRAME_FIELD.lampShadowDraws); + shadows.shadowDraws = module._tnw_web_frame(WEB_FRAME_FIELD.shadowDraws); + shadows.lampMapMin = module._tnw_web_frame(WEB_FRAME_FIELD.lampMapMin); + shadows.lampMapMax = module._tnw_web_frame(WEB_FRAME_FIELD.lampMapMax); + shadows.keptMaps = module._tnw_web_frame(WEB_FRAME_FIELD.keptShadowMaps); + this.info.engine.uniformBytes = module._tnw_web_frame(WEB_FRAME_FIELD.uniformBytes); const split = this.info.engine.frameSplit; // Field 0 answers for the renderer that drew last and field 27 for the world's: an overlay has no shadow pass of its own. split.shadowDraws = this.#overlay ? 0 : shadows.shadowDraws; - split.shadowTriangles = module._tnw_web_frame(85); + split.shadowTriangles = module._tnw_web_frame(WEB_FRAME_FIELD.shadowTriangles); split.mainDraws = this.info.render.frameCalls - split.shadowDraws; split.mainTriangles = lastTriangles - split.shadowTriangles; - const gpuSamples = module._tnw_web_frame(8); + const gpuSamples = module._tnw_web_frame(WEB_FRAME_FIELD.gpuSamples); if (gpuSamples > this.#gpuSamples) { debug?.add( "gpuTimestampSamples", @@ -675,12 +684,12 @@ export function defineWebRenderer( "native timestamp readback", ); this.#gpuSamples = gpuSamples; - const gpuMs = module._tnw_web_frame(7); + const gpuMs = module._tnw_web_frame(WEB_FRAME_FIELD.gpuMs); // The engine reports which submitted frame this sample belongs to; the readback can land // many frames later, so the arrival frame is never the sample's age. if (Number.isFinite(gpuMs) && gpuMs > 0) { this.info.render.timestamp = gpuMs; - this.#timestampFrames = [module._tnw_web_frame(9)]; + this.#timestampFrames = [module._tnw_web_frame(WEB_FRAME_FIELD.gpuSourceFrame)]; const stages: Record = {}; GPU_STAGES.forEach((name, index) => { const stageMs = module._tnw_web_frame(GPU_STAGE_FIELD + index); @@ -689,37 +698,48 @@ export function defineWebRenderer( const shadowMs = module._tnw_web_frame(SHADOW_SEGMENT_FIELD); if (shadowMs > 0) stages[SHADOW_STAGE] = shadowMs; this.info.engine.gpuStages = stages; - this.info.engine.shadows.sunGpuMs = module._tnw_web_frame(19); - this.info.engine.shadows.lampGpuMs = module._tnw_web_frame(24); + this.info.engine.shadows.sunGpuMs = module._tnw_web_frame( + WEB_FRAME_FIELD.gpuSegmentShadow, + ); + this.info.engine.shadows.lampGpuMs = module._tnw_web_frame( + WEB_FRAME_FIELD.gpuSegmentLampShadow, + ); // Opt-in, as `__tnPassLog`: a probe that sets `__tnGpuSegments` to an array gets each resolved sample's // split (frame_gpu_timer.h segments: sun shadow, scene, other passes, output, gather, lamp shadow). (globalThis as { __tnGpuSegments?: number[][] }).__tnGpuSegments?.push([ gpuMs, - ...[19, 20, 21, 22, 23, 24].map((field) => module._tnw_web_frame(field)), + ...[ + WEB_FRAME_FIELD.gpuSegmentShadow, + WEB_FRAME_FIELD.gpuSegmentScene, + WEB_FRAME_FIELD.gpuSegmentOther, + WEB_FRAME_FIELD.gpuSegmentOutput, + WEB_FRAME_FIELD.gpuSegmentGather, + WEB_FRAME_FIELD.gpuSegmentLampShadow, + ].map((field) => module._tnw_web_frame(field)), ]); } } const engine = this.info.engine; - engine.compiles = module._tnw_web_frame(2); - engine.textLookups = module._tnw_web_frame(3); - engine.bindGroups = module._tnw_web_frame(4); - engine.graphKeys = module._tnw_web_frame(5); - engine.programs = module._tnw_web_frame(6); + engine.compiles = module._tnw_web_frame(WEB_FRAME_FIELD.pipelineCompiles); + engine.textLookups = module._tnw_web_frame(WEB_FRAME_FIELD.pipelineLookups); + engine.bindGroups = module._tnw_web_frame(WEB_FRAME_FIELD.bindGroupsCreated); + engine.graphKeys = module._tnw_web_frame(WEB_FRAME_FIELD.graphKeyBuilds); + engine.programs = module._tnw_web_frame(WEB_FRAME_FIELD.programs); const temporal = engine.temporal; - temporal.active = module._tnw_web_frame(13) === 1; - temporal.frame = module._tnw_web_frame(14); - temporal.historyValid = module._tnw_web_frame(15) === 1; - temporal.jitter[0] = module._tnw_web_frame(16); - temporal.jitter[1] = module._tnw_web_frame(17); - temporal.rejected = module._tnw_web_frame(30); - temporal.clamped = module._tnw_web_frame(31); - temporal.pixels = module._tnw_web_frame(32); - temporal.countedFrame = module._tnw_web_frame(33); - temporal.inputWidth = module._tnw_web_frame(35); - temporal.inputHeight = module._tnw_web_frame(36); - temporal.jitterLength = module._tnw_web_frame(37); - temporal.outputWidth = module._tnw_web_frame(38); - temporal.outputHeight = module._tnw_web_frame(39); + temporal.active = module._tnw_web_frame(WEB_FRAME_FIELD.temporalActive) === 1; + temporal.frame = module._tnw_web_frame(WEB_FRAME_FIELD.temporalFrame); + temporal.historyValid = module._tnw_web_frame(WEB_FRAME_FIELD.temporalHistoryValid) === 1; + temporal.jitter[0] = module._tnw_web_frame(WEB_FRAME_FIELD.temporalJitterX); + temporal.jitter[1] = module._tnw_web_frame(WEB_FRAME_FIELD.temporalJitterY); + temporal.rejected = module._tnw_web_frame(WEB_FRAME_FIELD.temporalRejected); + temporal.clamped = module._tnw_web_frame(WEB_FRAME_FIELD.temporalClamped); + temporal.pixels = module._tnw_web_frame(WEB_FRAME_FIELD.temporalPixels); + temporal.countedFrame = module._tnw_web_frame(WEB_FRAME_FIELD.temporalCountedFrame); + temporal.inputWidth = module._tnw_web_frame(WEB_FRAME_FIELD.inputWidth); + temporal.inputHeight = module._tnw_web_frame(WEB_FRAME_FIELD.inputHeight); + temporal.jitterLength = module._tnw_web_frame(WEB_FRAME_FIELD.temporalJitterLength); + temporal.outputWidth = module._tnw_web_frame(WEB_FRAME_FIELD.width); + temporal.outputHeight = module._tnw_web_frame(WEB_FRAME_FIELD.height); if (debug !== undefined && temporal.active) { // The native renderer counts the active/history/rejected/clamped pixel totals under these // exact names (foundation/waste_counters.h, renderer.cpp); a JS series beside them would be @@ -728,7 +748,7 @@ export function defineWebRenderer( debug.add("temporalInputPixels", temporal.inputWidth * temporal.inputHeight, "temporal"); debug.add("temporalOutputPixels", temporal.outputWidth * temporal.outputHeight, "temporal"); } - engine.samples = module._tnw_web_frame(18); + engine.samples = module._tnw_web_frame(WEB_FRAME_FIELD.sampleCount); const cpu = engine.cpuMs; const work = engine.work; if (this.#stageMeters && cpu !== undefined && work !== undefined) { @@ -743,19 +763,21 @@ export function defineWebRenderer( heap[first + CPU_STAGE_ENTRIES.length + index] ?? 0; } const passes = engine.passes; - passes.mainRecorded = module._tnw_web_frame(60); - passes.recordCause = module._tnw_web_frame(61); - passes.auxRecorded = module._tnw_web_frame(62); - passes.recordMs = module._tnw_web_frame(63); - passes.shadowEncodeMs = module._tnw_web_frame(64); - passes.shadowRecorded = module._tnw_web_frame(65); + passes.mainRecorded = module._tnw_web_frame(WEB_FRAME_FIELD.mainRecorded); + passes.recordCause = module._tnw_web_frame(WEB_FRAME_FIELD.recordCause); + passes.auxRecorded = module._tnw_web_frame(WEB_FRAME_FIELD.auxRecorded); + passes.recordMs = module._tnw_web_frame(WEB_FRAME_FIELD.recordMs); + passes.shadowEncodeMs = module._tnw_web_frame(WEB_FRAME_FIELD.shadowEncodeMs); + passes.shadowRecorded = module._tnw_web_frame(WEB_FRAME_FIELD.shadowRecorded); // The startup's own report, read only while the cover is up so a held frame sees the one it just made. After // the release nothing reads it, so a steady frame pays no host call for it and the last values stand. if (this.#presentationHeld) { - engine.startup.worldSubmissions = module._tnw_web_frame(71); - engine.startup.shadowInputsReady = module._tnw_web_frame(72) === 1; + engine.startup.worldSubmissions = module._tnw_web_frame(WEB_FRAME_FIELD.worldSubmissions); + engine.startup.shadowInputsReady = + module._tnw_web_frame(WEB_FRAME_FIELD.shadowInputsReady) === 1; } else if (!engine.startup.worldPresented) { - engine.startup.worldPresented = module._tnw_web_frame(86) === 1; + engine.startup.worldPresented = + module._tnw_web_frame(WEB_FRAME_FIELD.firstWorldFrameDrawn) === 1; } // Opt-in, as `__tnCallCounts`: a probe that sets `__tnPassLog` to an array gets every frame's counters. (globalThis as { __tnPassLog?: unknown[] }).__tnPassLog?.push({ ...passes }); diff --git a/packages/three-native/src/generated/web-frame-fields.ts b/packages/three-native/src/generated/web-frame-fields.ts new file mode 100644 index 000000000..a6e44f45f --- /dev/null +++ b/packages/three-native/src/generated/web-frame-fields.ts @@ -0,0 +1,81 @@ +// Generated by @threenative/three-native from api/web-frame-fields.json. Do not edit. +// +// The fields `tnw_web_frame` answers. Ids are frozen and append-only: a facade reads a number, +// so a renumbered field is one value read as another. +export const WEB_FRAME_FIELD = { + cpuMetersOff: -4, + cpuMetersOn: -3, + debugTimestampOff: -2, + debugTimestampOn: -1, + draws: 0, + triangles: 1, + pipelineCompiles: 2, + pipelineLookups: 3, + bindGroupsCreated: 4, + graphKeyBuilds: 5, + programs: 6, + gpuMs: 7, + gpuSamples: 8, + gpuSourceFrame: 9, + gpuTimerAvailable: 10, + gpuBusy: 11, + environmentPending: 12, + temporalActive: 13, + temporalFrame: 14, + temporalHistoryValid: 15, + temporalJitterX: 16, + temporalJitterY: 17, + sampleCount: 18, + gpuSegmentShadow: 19, + gpuSegmentScene: 20, + gpuSegmentOther: 21, + gpuSegmentOutput: 22, + gpuSegmentGather: 23, + gpuSegmentLampShadow: 24, + lampRedraws: 25, + lampShadowDraws: 26, + shadowDraws: 27, + lampMapMin: 28, + lampMapMax: 29, + temporalRejected: 30, + temporalClamped: 31, + temporalPixels: 32, + temporalCountedFrame: 33, + temporalUpscale: 34, + inputWidth: 35, + inputHeight: 36, + temporalJitterLength: 37, + width: 38, + height: 39, + keptShadowMaps: 40, + uniformBytes: 41, + mainRecorded: 60, + recordCause: 61, + auxRecorded: 62, + recordMs: 63, + shadowEncodeMs: 64, + shadowRecorded: 65, + cpuVirtualPagesMs: 66, + cpuPlanMs: 67, + cpuShadowBuildMs: 68, + cpuPrepareMs: 69, + cpuEncodeMs: 70, + worldSubmissions: 71, + shadowInputsReady: 72, + cpuPostMs: 73, + sceneMs: 74, + hostRenderMs: 75, + hostComputeMs: 76, + hostSubmitMs: 77, + stageTotalsAddress: 78, + hostPollMs: 79, + hostTailMs: 80, + rendererMs: 81, + hostDatabaseMs: 82, + hostTargetMs: 83, + cpuMetersActive: 84, + shadowTriangles: 85, + firstWorldFrameDrawn: 86, +} as const; + +export type WebFrameField = (typeof WEB_FRAME_FIELD)[keyof typeof WEB_FRAME_FIELD]; diff --git a/packages/three-native/src/gpu-stages.ts b/packages/three-native/src/gpu-stages.ts index 98708a2e4..d89800507 100644 --- a/packages/three-native/src/gpu-stages.ts +++ b/packages/three-native/src/gpu-stages.ts @@ -4,6 +4,7 @@ * a stage that drew no pass is absent. The order is the native stage id counting from 1 * (`kStageScreenSpaceGi` in post_effects.h), so a new name goes last. */ +import { WEB_FRAME_FIELD } from "./generated/web-frame-fields.js"; export const GPU_STAGES = ["SSGI"] as const; export type GpuStage = (typeof GPU_STAGES)[number]; @@ -26,7 +27,7 @@ export function gpuStageId(name: unknown): number { export const SHADOW_STAGE = "Shadow"; /** `tnw_web_frame` field of the shadow passes' segment (web_host.cpp). */ -export const SHADOW_SEGMENT_FIELD = 19; +export const SHADOW_SEGMENT_FIELD: number = WEB_FRAME_FIELD.gpuSegmentShadow; /** * The renderer's CPU stages by `tnw_web_frame` field (web_host.cpp 66 to 77): running ms of every render, which @@ -39,21 +40,21 @@ export const SHADOW_SEGMENT_FIELD = 19; * and the batched queue submit that ends it (PRD-771), then the `tnw_web_poll` that opens it and its tail (surface texture release, frame gate, diagnostics) and the wall time of `Renderer::render` itself (`rendererMs`, which holds the prepare and encode halves) inside the whole `RenderDatabase::render` call (`hostDatabaseMs`), then the render targets' draws (`hostTargetMs`). The order of the names is the order the page reads them. */ export const CPU_STAGE_FIELDS = { - cpuVirtualPagesMs: 66, - cpuPlanMs: 67, - cpuShadowBuildMs: 68, - cpuPrepareMs: 69, - cpuEncodeMs: 70, - cpuPostMs: 73, - cpuSceneMs: 74, - hostRenderMs: 75, - hostComputeMs: 76, - hostSubmitMs: 77, - hostPollMs: 79, - hostTailMs: 80, - rendererMs: 81, - hostDatabaseMs: 82, - hostTargetMs: 83, + cpuVirtualPagesMs: WEB_FRAME_FIELD.cpuVirtualPagesMs, + cpuPlanMs: WEB_FRAME_FIELD.cpuPlanMs, + cpuShadowBuildMs: WEB_FRAME_FIELD.cpuShadowBuildMs, + cpuPrepareMs: WEB_FRAME_FIELD.cpuPrepareMs, + cpuEncodeMs: WEB_FRAME_FIELD.cpuEncodeMs, + cpuPostMs: WEB_FRAME_FIELD.cpuPostMs, + cpuSceneMs: WEB_FRAME_FIELD.sceneMs, + hostRenderMs: WEB_FRAME_FIELD.hostRenderMs, + hostComputeMs: WEB_FRAME_FIELD.hostComputeMs, + hostSubmitMs: WEB_FRAME_FIELD.hostSubmitMs, + hostPollMs: WEB_FRAME_FIELD.hostPollMs, + hostTailMs: WEB_FRAME_FIELD.hostTailMs, + rendererMs: WEB_FRAME_FIELD.rendererMs, + hostDatabaseMs: WEB_FRAME_FIELD.hostDatabaseMs, + hostTargetMs: WEB_FRAME_FIELD.hostTargetMs, } as const; export type CpuStageName = keyof typeof CPU_STAGE_FIELDS; From 57db86b6ba95f63d0f4bd00193b3b26204d13971 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:33:16 -0700 Subject: [PATCH 005/154] fix(refactor): the five gaps the PRD-832 review found The drift guard grouped copies by hash, so a listed file one kit edited dropped out of the check; it now compares every shipped copy with the canonical one. The facade's debugRequested and core's readLaunchFlag follow one rule, held together by a parity spec, and bundlesAsked hears every naming seam again. The web frame table pins the published (name, id) pairs instead of a digest that could not tell an append from a renumber, and the generated header refuses an unknown id before a renderer exists. Reverted the spell tier reader (a verbatim upstream port), the invented TN_SHADOW_* environment seams, and a comment that ignored the Unreal-look harness's own tone map copy. --- .../PRD-832-urgent-single-sources.md | 27 ++++- examples/wasm-engine-boot/package.json | 15 +-- .../playtests/frame-budget.playtest.json | 11 ++ .../core/__tests__/world-gpu-flags.spec.ts | 8 +- packages/core/src/debug.ts | 12 ++ .../core/src/look/default-tone-mapping.ts | 6 +- packages/core/src/render/virtual-shadow.ts | 14 +-- packages/core/src/world-gpu-flags.ts | 12 +- .../__tests__/shared-render-drift.spec.ts | 18 +++ .../src/engine/wasm/web_frame_fields.h | 81 +++++++++++++ .../src/engine/wasm/web_host.cpp | 9 ++ .../__tests__/web-frame-fields.spec.ts | 112 +++++++++++++++--- packages/three-native/scripts/generate.ts | 11 ++ packages/three-native/src/debug-telemetry.ts | 26 +++- scripts/__tests__/launch-flag-parity.spec.ts | 83 +++++++++++++ scripts/verify-registry-install.ts | 27 +++-- scripts/visual-gate.ts | 7 +- 17 files changed, 417 insertions(+), 62 deletions(-) create mode 100644 examples/wasm-engine-boot/playtests/frame-budget.playtest.json create mode 100644 scripts/__tests__/launch-flag-parity.spec.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md index aa595b5ed..b9be14ad5 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -1,7 +1,7 @@ # PRD-832 — Urgent single sources: one owner for each value that has silently drifted -**Status:** DONE — all three phases landed -**Priority:** P1 — the open Phase 1 box fixes a live gap: `pnpm budgets` runs capability recall without the `puzzle`, `rts` and `runner` kits. The open Phase 3 boxes remove a web telemetry channel that answers 0 to an unknown field. +**Status:** PARTIAL — all three phases landed; AC-1 (typecheck, lint and the affected board) runs on the PR, so the file stays here until it is green +**Priority:** P1 — the phases landed; what keeps this open is AC-1: the affected board has not been run end to end yet, so nothing here is claimed past the unit and Wasm-build proofs recorded on each box. **Complexity:** 5 (MEDIUM); risk override: Phase 2 can change the default tone curve, so the owner's visual-judge rule applies to that box **Owner:** ThreeNative maintainers **Depends on:** None @@ -44,6 +44,27 @@ the way its own red-green commit. - [ ] AC-1 [local]: Typecheck, lint and the affected board stay green. proof: `pnpm typecheck && pnpm lint && pnpm ci:local --affected --base origin/develop --target develop` +## Review round (2026-11-07) + +An independent read-only review of the three phases raised five gaps, all fixed before the next PRD: + +- `shared-render-drift.spec.ts` grouped copies by hash, so a *listed* file one kit edited dropped out of + the check entirely. It now also compares every shipped copy of a listed file with + `canonicalRenderSourcePath` byte for byte (shown red by appending a line to `minimal`'s `gpuSky.ts`). +- `debug.ts` changed the truthiness rule for core's readers while `three-native/src/debug-telemetry.ts` + kept the old one, so `?tnDebug=true` turned core telemetry on and the facade's off. Both now follow one + rule, held to it by `scripts/__tests__/launch-flag-parity.spec.ts`; the two copies exist because the + package graph gives three-native no dependency on core. +- `bundlesAsked` had lost "an explicit yes on any seam beats a no", which the original spec pinned. It + reads every naming seam again (`readLaunchValues`), and the rule now has a direct spec. +- `web-frame-fields.spec.ts` pinned a digest, which cannot tell an allowed append from a renumber. It + pins the published `(name, id)` pairs instead, and the generated header now carries + `tn_is_web_frame_field`, so an unknown id is refused before a renderer exists, not answered with 0. +- Three edits were unrequested and are reverted: the spell tier reader (`spells/ultra/engine/quality.ts` + is a verbatim upstream port and keeps its own `?q=`/`?mobile` read), the invented `TN_SHADOW_LEVELS`, + `TN_ADAPTIVE_REFRESH` and `TN_SHADOW_STATS` environment seams (those switches are query-only, as they + were), and a "only writer" comment that ignored the Unreal-look capture harness's own copy. + ## Integration Ledger | Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | @@ -75,4 +96,4 @@ the way its own red-green commit. **Files:** the field manifest beside `packages/three-native/api/catalog.json`; `packages/three-native/scripts/generate.ts`; `packages/runtime-native/src/engine/wasm/web_host.cpp`; `packages/three-native/src/browser-renderer.ts`, `gpu-stages.ts`, `browser-entry.ts`; `packages/three-native/__tests__/web-host-abi.spec.ts`. - [x] Every `tnw_web_frame` caller uses a generated name, from one manifest: `packages/three-native/api/web-frame-fields.json` (73 fields) feeds `packages/three-native/scripts/generate.ts`, which writes the TypeScript constant (`src/generated/web-frame-fields.ts`), the C++ enum (`runtime-native/src/engine/wasm/web_frame_fields.h`) and the facade's stage tables (`gpu-stages.ts` `CPU_STAGE_FIELDS`/`SHADOW_SEGMENT_FIELD`, `browser-renderer.ts`'s GPU stage base). The switch names the enum, and an id no manifest entry declares prints `TN_WEB_FRAME_UNKNOWN_FIELD` once and answers NaN instead of a zero the caller would read as a measurement. `__tests__/web-frame-fields.spec.ts` pins the three copies to the manifest and digests the table (`faae3793e5b2581b`), so a renumber goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-frame-fields.spec.ts __tests__/web-host-abi.spec.ts` → 6 passed; `pnpm wasm:build` compiles and links both web engines with the new switch (exit 0). -- [x] The owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` on `examples/wasm-engine-boot` (the game page, `--browser-recipe webgpu`, adapter `nvidia`/`turing`) reads GPU `mean 0.05 ms` over 3 samples and non-zero CPU stages (`wasmPlan` mean 0.30, `wasmEncode` 0.26, `wasmScene` 0.06, `wasmPost` 0.16, `engineJs` 0.30) — the renamed stage fields (66-70) answer the values they named before. The starter scaffold needs a full template install-and-build lane, so the proof runs on the in-repo game page that drives the same `tnw_web_frame` path; `pnpm test:templates` remains the starter-wide gate under AC-1. proof: `node packages/playtest/dist/runner/cli.js /tmp/framebudget.playtest.json --url http://127.0.0.1:5293 --server-command "npx vite preview --host 127.0.0.1 --port 5293 --strictPort" --browser-recipe webgpu --headed` → 1 window, values recorded here +- [x] The owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` on `examples/wasm-engine-boot` (the game page, `--browser-recipe webgpu`, adapter `nvidia`/`turing`) reads GPU `mean 0.05 ms` over 3 samples and non-zero CPU stages (`wasmPlan` mean 0.30, `wasmEncode` 0.26, `wasmScene` 0.06, `wasmPost` 0.16, `engineJs` 0.30) — the renamed stage fields (66-70) answer the values they named before. The starter scaffold needs a full template install-and-build lane, so the proof runs on the in-repo game page that drives the same `tnw_web_frame` path; `pnpm test:templates` remains the starter-wide gate under AC-1. proof: `pnpm --filter wasm-engine-boot build && pnpm --filter wasm-engine-boot playtest:frame-budget` (the scenario is `examples/wasm-engine-boot/playtests/frame-budget.playtest.json`) → 1 window, values recorded here diff --git a/examples/wasm-engine-boot/package.json b/examples/wasm-engine-boot/package.json index 6b3fa01be..031cce738 100644 --- a/examples/wasm-engine-boot/package.json +++ b/examples/wasm-engine-boot/package.json @@ -4,17 +4,18 @@ "private": true, "type": "module", "scripts": { - "build:wasm": "node ../../packages/create-threenative/dist/threenative.js build --target web", "build:renderer": "WASM_BOOT_PAGES=renderer node ../../packages/create-threenative/dist/threenative.js build --target web", - "typecheck": "tsc --noEmit -p tsconfig.json", - "playtest:renderer": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5293/renderer.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:game": "node ../../packages/playtest/dist/runner/cli.js playtests/game.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:renderer:dev": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5296/renderer.html --server-command \"pnpm exec vite --host 127.0.0.1 --port 5296 --strictPort\" --browser-recipe webgpu --headed", - "playtest:compute": "node ../../packages/playtest/dist/runner/cli.js playtests/compute.playtest.json --url http://127.0.0.1:5293/compute.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "build:wasm": "node ../../packages/create-threenative/dist/threenative.js build --target web", "playtest:array": "node ../../packages/playtest/dist/runner/cli.js playtests/array.playtest.json --url http://127.0.0.1:5293/array.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:compute": "node ../../packages/playtest/dist/runner/cli.js playtests/compute.playtest.json --url http://127.0.0.1:5293/compute.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:frame-budget": "node ../../packages/playtest/dist/runner/cli.js playtests/frame-budget.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:game": "node ../../packages/playtest/dist/runner/cli.js playtests/game.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:limits": "node ../../packages/playtest/dist/runner/cli.js playtests/limits.playtest.json --url http://127.0.0.1:5293/limits.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", "playtest:post": "node ../../packages/playtest/dist/runner/cli.js playtests/post.playtest.json --url http://127.0.0.1:5293/post.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", "playtest:post-normal-node": "node ../../packages/playtest/dist/runner/cli.js playtests/post-normal-node.playtest.json --url http://127.0.0.1:5293/post.html?nodenormal=1 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:limits": "node ../../packages/playtest/dist/runner/cli.js playtests/limits.playtest.json --url http://127.0.0.1:5293/limits.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed" + "playtest:renderer": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5293/renderer.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:renderer:dev": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5296/renderer.html --server-command \"pnpm exec vite --host 127.0.0.1 --port 5296 --strictPort\" --browser-recipe webgpu --headed", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "@threenative/core": "workspace:*", diff --git a/examples/wasm-engine-boot/playtests/frame-budget.playtest.json b/examples/wasm-engine-boot/playtests/frame-budget.playtest.json new file mode 100644 index 000000000..e390e377f --- /dev/null +++ b/examples/wasm-engine-boot/playtests/frame-budget.playtest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "name": "wasm-engine-frame-budget", + "target": "web", + "viewport": { "width": 640, "height": 360 }, + "warmupFrames": 5, + "steps": [{ "kind": "wait", "label": "steady", "waitTicks": 400 }], + "assert": { + "diagnostics": { "noConsoleErrors": true, "runtimeReady": true } + } +} diff --git a/packages/core/__tests__/world-gpu-flags.spec.ts b/packages/core/__tests__/world-gpu-flags.spec.ts index 9ec896b90..48ac23ffe 100644 --- a/packages/core/__tests__/world-gpu-flags.spec.ts +++ b/packages/core/__tests__/world-gpu-flags.spec.ts @@ -121,12 +121,16 @@ describe("bundlesAsked", () => { expect(bundlesAsked()).toBe("off"); }); - it("reads the first seam that names the switch, so the environment beats the query string", () => { + it("lets an explicit yes on one seam beat a no on another", () => { env(BUNDLE_FLAG, "0"); query("?tnBundles=1"); - expect(bundlesAsked()).toBe("off"); + expect(bundlesAsked()).toBe("on"); query(undefined); expect(bundlesAsked()).toBe("off"); + // And the other way round: a global's no does not silence the environment's yes. + env(BUNDLE_FLAG, "1"); + setGlobal("__tnBundles", false); + expect(bundlesAsked()).toBe("on"); }); // `bundles` answers three ways where the other switches answer two: a launch that said nothing diff --git a/packages/core/src/debug.ts b/packages/core/src/debug.ts index bae3cade5..8ff4432db 100644 --- a/packages/core/src/debug.ts +++ b/packages/core/src/debug.ts @@ -99,6 +99,18 @@ export function readLaunchValue(spec: ILaunchFlagSpec): string | undefined { return readLaunchSeams(spec)[0]; } +/** + * Every value this launch gave a flag, one per seam that named it, in the order they are read. + * + * For the switches whose answer is not a yes or a no — a tri-state, a table of spellings — the + * caller classifies the whole set rather than only the first seam's answer. + * + * @situation classify every value a launch gave one switch + */ +export function readLaunchValues(spec: ILaunchFlagSpec): readonly string[] { + return readLaunchSeams(spec); +} + /** * `process.env` on a host that installed one, narrowed rather than asserted exactly as * `isDevLaunch` does in `game.ts`: it exists on the native runtime and not in a browser bundle. diff --git a/packages/core/src/look/default-tone-mapping.ts b/packages/core/src/look/default-tone-mapping.ts index 315e4def6..189de2d98 100644 --- a/packages/core/src/look/default-tone-mapping.ts +++ b/packages/core/src/look/default-tone-mapping.ts @@ -42,8 +42,10 @@ export interface IFilmicToneMappingOptions { * mapping; a renderer without a node library cannot run it, keeps ACES and says so (`TN_TONEMAP`). * An unknown mode throws rather than quietly becoming the default. * - * This is the only writer of `renderer.toneMapping`: the renderer's own default, `WorldEnvironment` - * and a template's look all call it, so a scene cannot pick a curve its renderer disagrees with. + * Inside the framework this is the only writer of `renderer.toneMapping`: the renderer's own default + * and `WorldEnvironment` both call it, so a scene cannot pick a curve its renderer disagrees with. + * The Unreal-look capture harness under `scripts/unreal-look/scaffold/` keeps its own copy because + * it is a generated arm of that experiment, not framework code. */ export function applyRendererToneMapping( raw: unknown, diff --git a/packages/core/src/render/virtual-shadow.ts b/packages/core/src/render/virtual-shadow.ts index baa0778e0..c1f1478bf 100644 --- a/packages/core/src/render/virtual-shadow.ts +++ b/packages/core/src/render/virtual-shadow.ts @@ -2180,8 +2180,9 @@ export class VirtualShadowNode extends ShadowBaseNode { if (this.#levelsRequested === undefined) { // One read of the URL for both switches: setup runs once, before the shader graph exists. const search = globalThis.location?.search; + // Query only, as it always was: no environment variable and no global named these switches. this.#levelsRequested = readLaunchFlag( - { environment: "TN_SHADOW_LEVELS", query: "tnShadowLevels" }, + { environment: "", query: "tnShadowLevels" }, "on", search, ); @@ -2189,11 +2190,7 @@ export class VirtualShadowNode extends ShadowBaseNode { // two runs of one route would move their windows at different ticks. Set once, before a frame. if ( this.#adaptiveRefreshGiven === undefined && - readLaunchFlag( - { environment: "TN_ADAPTIVE_REFRESH", query: "tnAdaptiveRefresh" }, - "off", - search, - ) + readLaunchFlag({ environment: "", query: "tnAdaptiveRefresh" }, "off", search) ) { (this.options as { adaptiveRefresh: boolean }).adaptiveRefresh = false; } @@ -2857,10 +2854,7 @@ export class VirtualShadowNode extends ShadowBaseNode { /** Whether `?tnShadowStats=1` is on this launch's URL, read once. */ #statsWalkRequested(): boolean { - this.#statsRequested ??= readLaunchFlag({ - environment: "TN_SHADOW_STATS", - query: "tnShadowStats", - }); + this.#statsRequested ??= readLaunchFlag({ environment: "", query: "tnShadowStats" }); return this.#statsRequested; } diff --git a/packages/core/src/world-gpu-flags.ts b/packages/core/src/world-gpu-flags.ts index 03571223f..95cbe9be2 100644 --- a/packages/core/src/world-gpu-flags.ts +++ b/packages/core/src/world-gpu-flags.ts @@ -6,7 +6,7 @@ * what a run would use without building a scene, and a test can pin the order the sources are read * in without a device. */ -import { readLaunchFlag, readLaunchValue } from "./debug.js"; +import { readLaunchFlag, readLaunchValues } from "./debug.js"; /** The launch flag. */ export const GPU_SCENE_FLAG = "TN_GPU_SCENE"; @@ -65,15 +65,15 @@ export const BUNDLE_FLAG = "TN_BUNDLES"; */ export function bundlesAsked(): "default" | "off" | "on" { // Its own vocabulary over the one reader: a launch can answer yes, answer no, or say nothing, and - // "said nothing" is what a caller has to tell apart from a measurement's default. - const value = readLaunchValue({ + // "said nothing" is what a caller has to tell apart from a measurement's default. Every seam is + // heard, as it is for every other switch, so an explicit yes anywhere beats a no anywhere. + const values = readLaunchValues({ environment: BUNDLE_FLAG, global: "__tnBundles", query: "tnBundles", }); - if (value === undefined) return "default"; - if (value === "0" || value === "false") return "off"; - return value === "1" || value === "true" || value === "" ? "on" : "default"; + if (values.some((value) => value === "1" || value === "true" || value === "")) return "on"; + return values.some((value) => value === "0" || value === "false") ? "off" : "default"; } /** diff --git a/packages/create-threenative/__tests__/shared-render-drift.spec.ts b/packages/create-threenative/__tests__/shared-render-drift.spec.ts index 3c15e6258..66d670ecc 100644 --- a/packages/create-threenative/__tests__/shared-render-drift.spec.ts +++ b/packages/create-threenative/__tests__/shared-render-drift.spec.ts @@ -73,6 +73,24 @@ describe("shared render sources", () => { expect(missing).toEqual([]); }); + // The duplicate check above cannot see a listed file one kit edited: it stops being identical, so + // it drops out of the grouping and nothing compares it with the copy it is supposed to follow. + // That edit is silent until `pnpm stamp:template-render` overwrites it, which is the drift this + // whole guard exists for. + it("should keep every shipped copy of a listed file byte-identical to the canonical one", () => { + const drifted: string[] = []; + for (const relativePath of SHARED_RENDER_SOURCES) { + const canonical = readFileSync(canonicalRenderSourcePath(relativePath)); + // Every kit that ships the file, across the digests: a drifted copy is in a different group. + const copies = [...(renderCopies().get(relativePath)?.values() ?? [])].flat(); + for (const copy of copies) { + const file = path.join(templateRoot(), copy, relativePath); + if (!readFileSync(file).equals(canonical)) drifted.push(file); + } + } + expect(drifted).toEqual([]); + }); + it("should keep the added set inside the shared set", () => { const stray = ADDED_RENDER_SOURCES.filter( (relativePath) => !SHARED_RENDER_SOURCES.includes(relativePath), diff --git a/packages/runtime-native/src/engine/wasm/web_frame_fields.h b/packages/runtime-native/src/engine/wasm/web_frame_fields.h index a16449520..058a562a0 100644 --- a/packages/runtime-native/src/engine/wasm/web_frame_fields.h +++ b/packages/runtime-native/src/engine/wasm/web_frame_fields.h @@ -79,3 +79,84 @@ enum TnWebFrameField : int { TN_WEB_FRAME_SHADOW_TRIANGLES = 85, TN_WEB_FRAME_FIRST_WORLD_FRAME_DRAWN = 86, }; + +/// Whether `field` is one this table answers. The host asks before it touches the renderer, so an +/// id from another table is refused whether or not a frame has been drawn yet. +constexpr bool tn_is_web_frame_field(int field) { + switch (field) { + case TN_WEB_FRAME_CPU_METERS_OFF: return true; + case TN_WEB_FRAME_CPU_METERS_ON: return true; + case TN_WEB_FRAME_DEBUG_TIMESTAMP_OFF: return true; + case TN_WEB_FRAME_DEBUG_TIMESTAMP_ON: return true; + case TN_WEB_FRAME_DRAWS: return true; + case TN_WEB_FRAME_TRIANGLES: return true; + case TN_WEB_FRAME_PIPELINE_COMPILES: return true; + case TN_WEB_FRAME_PIPELINE_LOOKUPS: return true; + case TN_WEB_FRAME_BIND_GROUPS_CREATED: return true; + case TN_WEB_FRAME_GRAPH_KEY_BUILDS: return true; + case TN_WEB_FRAME_PROGRAMS: return true; + case TN_WEB_FRAME_GPU_MS: return true; + case TN_WEB_FRAME_GPU_SAMPLES: return true; + case TN_WEB_FRAME_GPU_SOURCE_FRAME: return true; + case TN_WEB_FRAME_GPU_TIMER_AVAILABLE: return true; + case TN_WEB_FRAME_GPU_BUSY: return true; + case TN_WEB_FRAME_ENVIRONMENT_PENDING: return true; + case TN_WEB_FRAME_TEMPORAL_ACTIVE: return true; + case TN_WEB_FRAME_TEMPORAL_FRAME: return true; + case TN_WEB_FRAME_TEMPORAL_HISTORY_VALID: return true; + case TN_WEB_FRAME_TEMPORAL_JITTER_X: return true; + case TN_WEB_FRAME_TEMPORAL_JITTER_Y: return true; + case TN_WEB_FRAME_SAMPLE_COUNT: return true; + case TN_WEB_FRAME_GPU_SEGMENT_SHADOW: return true; + case TN_WEB_FRAME_GPU_SEGMENT_SCENE: return true; + case TN_WEB_FRAME_GPU_SEGMENT_OTHER: return true; + case TN_WEB_FRAME_GPU_SEGMENT_OUTPUT: return true; + case TN_WEB_FRAME_GPU_SEGMENT_GATHER: return true; + case TN_WEB_FRAME_GPU_SEGMENT_LAMP_SHADOW: return true; + case TN_WEB_FRAME_LAMP_REDRAWS: return true; + case TN_WEB_FRAME_LAMP_SHADOW_DRAWS: return true; + case TN_WEB_FRAME_SHADOW_DRAWS: return true; + case TN_WEB_FRAME_LAMP_MAP_MIN: return true; + case TN_WEB_FRAME_LAMP_MAP_MAX: return true; + case TN_WEB_FRAME_TEMPORAL_REJECTED: return true; + case TN_WEB_FRAME_TEMPORAL_CLAMPED: return true; + case TN_WEB_FRAME_TEMPORAL_PIXELS: return true; + case TN_WEB_FRAME_TEMPORAL_COUNTED_FRAME: return true; + case TN_WEB_FRAME_TEMPORAL_UPSCALE: return true; + case TN_WEB_FRAME_INPUT_WIDTH: return true; + case TN_WEB_FRAME_INPUT_HEIGHT: return true; + case TN_WEB_FRAME_TEMPORAL_JITTER_LENGTH: return true; + case TN_WEB_FRAME_WIDTH: return true; + case TN_WEB_FRAME_HEIGHT: return true; + case TN_WEB_FRAME_KEPT_SHADOW_MAPS: return true; + case TN_WEB_FRAME_UNIFORM_BYTES: return true; + case TN_WEB_FRAME_MAIN_RECORDED: return true; + case TN_WEB_FRAME_RECORD_CAUSE: return true; + case TN_WEB_FRAME_AUX_RECORDED: return true; + case TN_WEB_FRAME_RECORD_MS: return true; + case TN_WEB_FRAME_SHADOW_ENCODE_MS: return true; + case TN_WEB_FRAME_SHADOW_RECORDED: return true; + case TN_WEB_FRAME_CPU_VIRTUAL_PAGES_MS: return true; + case TN_WEB_FRAME_CPU_PLAN_MS: return true; + case TN_WEB_FRAME_CPU_SHADOW_BUILD_MS: return true; + case TN_WEB_FRAME_CPU_PREPARE_MS: return true; + case TN_WEB_FRAME_CPU_ENCODE_MS: return true; + case TN_WEB_FRAME_WORLD_SUBMISSIONS: return true; + case TN_WEB_FRAME_SHADOW_INPUTS_READY: return true; + case TN_WEB_FRAME_CPU_POST_MS: return true; + case TN_WEB_FRAME_SCENE_MS: return true; + case TN_WEB_FRAME_HOST_RENDER_MS: return true; + case TN_WEB_FRAME_HOST_COMPUTE_MS: return true; + case TN_WEB_FRAME_HOST_SUBMIT_MS: return true; + case TN_WEB_FRAME_STAGE_TOTALS_ADDRESS: return true; + case TN_WEB_FRAME_HOST_POLL_MS: return true; + case TN_WEB_FRAME_HOST_TAIL_MS: return true; + case TN_WEB_FRAME_RENDERER_MS: return true; + case TN_WEB_FRAME_HOST_DATABASE_MS: return true; + case TN_WEB_FRAME_HOST_TARGET_MS: return true; + case TN_WEB_FRAME_CPU_METERS_ACTIVE: return true; + case TN_WEB_FRAME_SHADOW_TRIANGLES: return true; + case TN_WEB_FRAME_FIRST_WORLD_FRAME_DRAWN: return true; + default: return false; + } +} diff --git a/packages/runtime-native/src/engine/wasm/web_host.cpp b/packages/runtime-native/src/engine/wasm/web_host.cpp index b3d5edaf6..0a18ede84 100644 --- a/packages/runtime-native/src/engine/wasm/web_host.cpp +++ b/packages/runtime-native/src/engine/wasm/web_host.cpp @@ -1019,6 +1019,15 @@ static int unknownFrameField = 0; * answers NaN, because a zero would read as a real measurement from a table that never had it. */ extern "C" double tnw_web_frame(int field) { + // An id the manifest does not declare is a facade built against another field table. Refused + // before anything else, because a renderer that does not exist yet would answer a silent zero. + if (!tn_is_web_frame_field(field)) { + if (unknownFrameField != field) { + unknownFrameField = field; + std::fprintf(stderr, "TN_WEB_FRAME_UNKNOWN_FIELD %d\n", field); + } + return std::numeric_limits::quiet_NaN(); + } // The CPU stage meters on and off (PRD-771); a static switch, so it answers before the renderer // exists. On until a page asks otherwise. if (field == TN_WEB_FRAME_CPU_METERS_ON || field == TN_WEB_FRAME_CPU_METERS_OFF) { diff --git a/packages/three-native/__tests__/web-frame-fields.spec.ts b/packages/three-native/__tests__/web-frame-fields.spec.ts index a7eac137d..328316c0d 100644 --- a/packages/three-native/__tests__/web-frame-fields.spec.ts +++ b/packages/three-native/__tests__/web-frame-fields.spec.ts @@ -1,8 +1,7 @@ // The `tnw_web_frame` field table is an ABI: the facade passes a number and the host answers a // value, so a field that moves reads one measurement as another with nothing failing. These checks -// pin the three copies that exist — the manifest, the TypeScript constant and the C++ enum — and -// the digest of the table itself, so a renumber has to be deliberate. -import { createHash } from "node:crypto"; +// pin the three copies that exist — the manifest, the TypeScript constant and the C++ enum — and the +// ids this ABI has already published, so a renumber has to be deliberate. import { readFileSync } from "node:fs"; import path from "node:path"; @@ -30,10 +29,87 @@ function macro(name: string): string { } /** - * The digest of the whole table. A field that moves changes it, and so does a rename; adding a - * field at the end is the only edit that leaves the existing ids alone, which is the point. + * Every field this ABI has published, at the id it published. + * + * Frozen pairs rather than a digest of the table: a digest cannot tell the one edit that is allowed + * (append a field) from the one that is not (move it), because either needs a new value. Comparing + * pairs names the field that moved, and a new field only adds a row. */ -const FIELD_TABLE_DIGEST = "faae3793e5b2581b"; +const FROZEN_FIELDS: Readonly> = { + cpuMetersOff: -4, + cpuMetersOn: -3, + debugTimestampOff: -2, + debugTimestampOn: -1, + draws: 0, + triangles: 1, + pipelineCompiles: 2, + pipelineLookups: 3, + bindGroupsCreated: 4, + graphKeyBuilds: 5, + programs: 6, + gpuMs: 7, + gpuSamples: 8, + gpuSourceFrame: 9, + gpuTimerAvailable: 10, + gpuBusy: 11, + environmentPending: 12, + temporalActive: 13, + temporalFrame: 14, + temporalHistoryValid: 15, + temporalJitterX: 16, + temporalJitterY: 17, + sampleCount: 18, + gpuSegmentShadow: 19, + gpuSegmentScene: 20, + gpuSegmentOther: 21, + gpuSegmentOutput: 22, + gpuSegmentGather: 23, + gpuSegmentLampShadow: 24, + lampRedraws: 25, + lampShadowDraws: 26, + shadowDraws: 27, + lampMapMin: 28, + lampMapMax: 29, + temporalRejected: 30, + temporalClamped: 31, + temporalPixels: 32, + temporalCountedFrame: 33, + temporalUpscale: 34, + inputWidth: 35, + inputHeight: 36, + temporalJitterLength: 37, + width: 38, + height: 39, + keptShadowMaps: 40, + uniformBytes: 41, + mainRecorded: 60, + recordCause: 61, + auxRecorded: 62, + recordMs: 63, + shadowEncodeMs: 64, + shadowRecorded: 65, + cpuVirtualPagesMs: 66, + cpuPlanMs: 67, + cpuShadowBuildMs: 68, + cpuPrepareMs: 69, + cpuEncodeMs: 70, + worldSubmissions: 71, + shadowInputsReady: 72, + cpuPostMs: 73, + sceneMs: 74, + hostRenderMs: 75, + hostComputeMs: 76, + hostSubmitMs: 77, + stageTotalsAddress: 78, + hostPollMs: 79, + hostTailMs: 80, + rendererMs: 81, + hostDatabaseMs: 82, + hostTargetMs: 83, + cpuMetersActive: 84, + shadowTriangles: 85, + firstWorldFrameDrawn: 86, +}; describe("the web frame field table", () => { it("should generate the TypeScript constant from the manifest", () => { @@ -49,18 +125,18 @@ describe("the web frame field table", () => { expect(rows).toEqual(manifestFields().map((f) => [macro(f.name), f.id] as const)); }); - it("should pin the field ids, so a renumbered field goes red", () => { - const digest = createHash("sha256") - .update( - manifestFields() - .map((f) => `${f.name}:${String(f.id)}`) - .join("\n"), - ) - .digest("hex") - .slice(0, 16); + it("should keep every published field at the id it was published with", () => { + const moved = Object.entries(FROZEN_FIELDS).filter( + ([name, id]) => (WEB_FRAME_FIELD as Record)[name] !== id, + ); expect( - digest, - "a tnw_web_frame field moved: ids are frozen and append-only, so restore the old id and add a new field", - ).toBe(FIELD_TABLE_DIGEST); + moved.map(([name, id]) => `${name} moved from ${String(id)}`), + "ids are frozen and append-only: restore the old id, and give a new field the next free one", + ).toEqual([]); + }); + + it("should declare every published field in the manifest", () => { + const published = new Set(manifestFields().map((field) => field.name)); + expect(Object.keys(FROZEN_FIELDS).filter((name) => !published.has(name))).toEqual([]); }); }); diff --git a/packages/three-native/scripts/generate.ts b/packages/three-native/scripts/generate.ts index 16300e3d0..80fc55314 100644 --- a/packages/three-native/scripts/generate.ts +++ b/packages/three-native/scripts/generate.ts @@ -602,6 +602,17 @@ function frameFieldsHeader(fields: readonly IWebFrameField[]): string { ), "};", "", + "/// Whether `field` is one this table answers. The host asks before it touches the renderer, so an", + "/// id from another table is refused whether or not a frame has been drawn yet.", + "constexpr bool tn_is_web_frame_field(int field) {", + " switch (field) {", + ...fields.map( + (field) => ` case TN_WEB_FRAME_${screamingSnake(field.name)}: return true;`, + ), + " default: return false;", + " }", + "}", + "", ].join("\n"); } diff --git a/packages/three-native/src/debug-telemetry.ts b/packages/three-native/src/debug-telemetry.ts index b7bc7a971..df0ca2a44 100644 --- a/packages/three-native/src/debug-telemetry.ts +++ b/packages/three-native/src/debug-telemetry.ts @@ -1,16 +1,30 @@ import { PLAYTEST_OBSERVING_GLOBAL, callerOf } from "./bridge-meter.js"; -/** Kept local to the bridge so three-native does not depend on core. */ +/** + * Kept local to the bridge so three-native does not depend on core, and held to core's rule by + * `scripts/__tests__/launch-flag-parity.spec.ts`: `0` and `false` are off, a named-but-empty value + * is on, an empty variable is unset, and every seam that names the flag is heard. The two copies + * disagreed before — core accepted `?tnDebug=true` and this did not — which is drift nobody sees + * until one layer's telemetry is off while the other's is on. + */ export function debugRequested(): boolean { const host = globalThis as { process?: { env?: Record }; [name: string]: unknown; }; - return ( - host.process?.env?.TN_DEBUG === "1" || - /[?&]tnDebug=1(?:&|$)/u.test(globalThis.location?.search ?? "") || - host.__THREENATIVE_DEBUG__ === true - ); + const values: string[] = []; + const env = host.process?.env?.TN_DEBUG; + if (typeof env === "string" && env !== "") values.push(env); + const search = globalThis.location?.search; + if (typeof search === "string") { + const fromQuery = new URLSearchParams(search).get("tnDebug"); + if (fromQuery !== null) values.push(fromQuery); + } + const set = host.__THREENATIVE_DEBUG__; + if (typeof set === "string") values.push(set); + else if (typeof set === "number") values.push(String(set)); + else if (typeof set === "boolean") values.push(set ? "true" : "false"); + return values.some((value) => value !== "0" && value !== "false"); } type DebugSource = { area: string; caller: string; lever?: string; unit?: string }; diff --git a/scripts/__tests__/launch-flag-parity.spec.ts b/scripts/__tests__/launch-flag-parity.spec.ts new file mode 100644 index 000000000..867eee6ff --- /dev/null +++ b/scripts/__tests__/launch-flag-parity.spec.ts @@ -0,0 +1,83 @@ +// The launch-flag rule has two homes because the package graph forces it: core reads a switch for +// its own subsystems, and `@threenative/three-native` is the lower layer that must not import core, +// so it keeps its own three lines for the bridge's telemetry. Two implementations of one rule drift +// — core accepted `?tnDebug=true` and the facade did not — so this holds them to the same answers +// over every seam the rule names. It is the price of the split, paid in one place. +import { afterEach, describe, expect, it } from "vitest"; + +import { readLaunchFlag, readLaunchValues } from "../../packages/core/src/debug.js"; +import { debugRequested } from "../../packages/three-native/src/debug-telemetry.js"; + +const host = globalThis as Record; + +function seam(search: string | undefined, env: string | undefined, global: unknown): void { + if (search === undefined) Reflect.deleteProperty(host, "location"); + else host.location = { search }; + if (env === undefined) delete process.env.TN_DEBUG; + else process.env.TN_DEBUG = env; + if (global === undefined) Reflect.deleteProperty(host, "__THREENATIVE_DEBUG__"); + else host.__THREENATIVE_DEBUG__ = global; +} + +afterEach(() => seam(undefined, undefined, undefined)); + +/** One case per rule: the two spellings of off, the two of on, a bare query, and the odd values. */ +const CASES: readonly (readonly [string | undefined, string | undefined, unknown])[] = [ + [undefined, undefined, undefined], + [undefined, "1", undefined], + [undefined, "true", undefined], + [undefined, "0", undefined], + [undefined, "false", undefined], + [undefined, "", undefined], + [undefined, "yes", undefined], + ["?tnDebug=1", undefined, undefined], + ["?tnDebug=true", undefined, undefined], + ["?tnDebug", undefined, undefined], + ["?tnDebug=0", undefined, undefined], + ["?tnDebug=false", undefined, undefined], + ["?tnDebug=yes", undefined, undefined], + [undefined, undefined, true], + [undefined, undefined, false], + [undefined, undefined, "1"], + [undefined, undefined, "0"], + [undefined, undefined, "true"], + [undefined, undefined, 1], + [undefined, undefined, 0], + // Every seam heard: a yes on one beats a no on another, in either direction. + ["?tnDebug=1", "0", undefined], + ["?tnDebug=0", "1", undefined], + ["?tnDebug=0", undefined, true], + ["", "0", true], +]; + +describe("the launch-flag rule", () => { + it("should answer the same as the facade's copy for every seam", () => { + for (const [search, env, global] of CASES) { + seam(search, env, global); + const describeCase = `location=${String(search)} TN_DEBUG=${String(env)} global=${String(global)}`; + expect(debugRequested(), describeCase).toBe( + readLaunchFlag({ environment: "TN_DEBUG", global: "__THREENATIVE_DEBUG__", query: "tnDebug" }), + ); + } + }); + + it("should treat 0 and false as the only spellings of off", () => { + const spec = { environment: "TN_DEBUG", global: "__THREENATIVE_DEBUG__", query: "tnDebug" }; + for (const value of ["0", "false"]) { + seam(undefined, value, undefined); + expect(readLaunchFlag(spec), value).toBe(false); + expect(readLaunchValues(spec), value).toEqual([value]); + } + for (const value of ["", "1", "true", "yes", "off "]) { + seam(undefined, value, undefined); + expect(readLaunchFlag(spec), value).toBe(value !== ""); + } + }); + + it("should report every seam that named the flag, in read order", () => { + seam("?tnDebug=1", "0", true); + expect( + readLaunchValues({ environment: "TN_DEBUG", global: "__THREENATIVE_DEBUG__", query: "tnDebug" }), + ).toEqual(["0", "1", "true"]); + }); +}); diff --git a/scripts/verify-registry-install.ts b/scripts/verify-registry-install.ts index 944cb56e1..3aee6f84b 100644 --- a/scripts/verify-registry-install.ts +++ b/scripts/verify-registry-install.ts @@ -30,6 +30,7 @@ import { type IZipEntry, readZipEntries, } from "../packages/runtime-native/scripts/check-android-16kb-alignment.mjs"; +import { minimumNodeVersion } from "./lib/repo-inventory.mjs"; const REPO = path.resolve(import.meta.dirname, ".."); @@ -187,15 +188,27 @@ export function registryEnvironment( }; } -/** Supported Node is a package contract, not a warning discovered halfway through installation. */ +/** + * Supported Node is a package contract, not a warning discovered halfway through installation, and + * the number comes from the one place it is declared: the root `package.json` `engines.node`. + */ export function assertSupportedNodeVersion(version = process.versions.node): void { - const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(version); - const major = Number.parseInt(match?.[1] ?? "-1", 10); - const minor = Number.parseInt(match?.[2] ?? "-1", 10); - const patch = Number.parseInt(match?.[3] ?? "-1", 10); - if (major < 20 || (major === 20 && (minor < 19 || (minor === 19 && patch < 0)))) + const minimum = minimumNodeVersion() + .split(".") + .map((part) => Number.parseInt(part, 10)); + const current = /^(\d+)\.(\d+)\.(\d+)/u + .exec(version) + ?.slice(1) + .map((part) => Number.parseInt(part, 10)) ?? [-1, -1, -1]; + const below = (index: number): boolean => { + const [have, want] = [current[index], minimum[index]]; + if (have === undefined || want === undefined) return true; + const higher = current.slice(0, index).every((value, at) => value === minimum[at]); + return higher && have < want; + }; + if ([0, 1, 2].some(below)) throw new Error( - `TN_REGISTRY_INSTALL_NODE_UNSUPPORTED: Node ${version} is below the supported minimum 20.19.0. Install Node 20.19.0 or newer before qualifying a registry install.`, + `TN_REGISTRY_INSTALL_NODE_UNSUPPORTED: Node ${version} is below the supported minimum ${minimum.join(".")}. Install Node ${minimum.join(".")} or newer before qualifying a registry install.`, ); } diff --git a/scripts/visual-gate.ts b/scripts/visual-gate.ts index 0fda1eb84..39bc37d52 100644 --- a/scripts/visual-gate.ts +++ b/scripts/visual-gate.ts @@ -24,6 +24,7 @@ import { CaptureGuardError, assertFrameShowsSomething, } from "./capture-guard.js"; +import { templateNames } from "./lib/repo-inventory.mjs"; import { type ImageScoringArtifact, createImageBlindBundle, @@ -40,7 +41,11 @@ const TEMPLATE_ROOT = path.join( path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), "packages/create-threenative/templates", ); -export const TEMPLATE_NAMES: readonly ScaffoldTemplate[] = discoverTemplateNames(TEMPLATE_ROOT); +// One inventory owns "what a kit is" (a template directory that ships `kit.json`): this reader is +// the scaffolder's own and parses each manifest, while the list of names comes from repo-inventory. +export const TEMPLATE_NAMES: readonly ScaffoldTemplate[] = templateNames(TEMPLATE_ROOT).filter( + (name) => discoverTemplateNames(TEMPLATE_ROOT).includes(name), +); export const RENDER_LAYER_FILES = [ "palette.ts", "camera.ts", From 53ab26134248668ed01f7c56fbd60c9ba1959db3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:37:06 -0700 Subject: [PATCH 006/154] fix(scripts,core): restore the spell reader and the example's script order visual-gate hands repo-inventory the repository root, not the templates folder (its spec was red); the spell tier reader is byte-identical to the base commit again; the example's package.json gains one line instead of a re-sort; the parity cases carry the answer the rule gives, so two copies agreeing on a wrong answer still fail; and assertSupportedNodeVersion reads the floor from engines.node, where a range shorter than the running version is not 'below'. --- .../PRD-832-urgent-single-sources.md | 11 ++- examples/wasm-engine-boot/package.json | 16 ++-- .../core/src/spells/ultra/engine/quality.ts | 10 +-- scripts/__tests__/launch-flag-parity.spec.ts | 83 +++++++++++-------- scripts/verify-registry-install.ts | 9 +- scripts/visual-gate.ts | 9 +- 6 files changed, 81 insertions(+), 57 deletions(-) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md index b9be14ad5..6f6d1680a 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -65,6 +65,13 @@ An independent read-only review of the three phases raised five gaps, all fixed `TN_ADAPTIVE_REFRESH` and `TN_SHADOW_STATS` environment seams (those switches are query-only, as they were), and a "only writer" comment that ignored the Unreal-look capture harness's own copy. +A second pass over the fixed work found two regressions of its own, both fixed: `scripts/visual-gate.ts` +passed the templates folder where `repo-inventory.templateNames()` expects the repository root (its spec +caught it), and the spell reader revert had silently not applied (the file is now byte-identical to the +base commit). The parity spec's cases now carry the answer the rule gives, so two copies that agree on a +wrong answer still fail. `verify-registry-install.ts` reads the Node floor from `engines.node` through +`minimumNodeVersion()`, and a shorter range no longer rejects every version. + ## Integration Ledger | Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | @@ -87,7 +94,7 @@ An independent read-only review of the three phases raised five gaps, all fixed **Status:** DONE **Files:** `packages/core/src/debug.ts` plus the 16 reader sites in report A4; `packages/core/src/look/world-environment.ts`; `packages/core/src/index.ts`; `packages/create-threenative/src/doctor.ts`; `scripts/prepare-release.ts`. -- [x] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. `debug.ts` exports `readLaunchFlag(spec, "on" | "off", search?)` and `readLaunchValue(spec)` over the three seams; 14 readers in 13 core files now call it (`world-gpu-flags` ×4, `world-validate`, `world-tile-budget`, `world-cell-options`, `world-environment`, `render-list-validate`, `debug-telemetry`, `Spans`, `FrameStages`, `depth-pyramid`, `virtual-shadow` ×3, `quiet-markers`, `spells/ultra/engine/quality`). One truthiness rule replaced three: `0`/`false` are off, a named-but-empty value is on, an empty variable is unset; every seam that names the flag is heard, so a harness global is not lost behind a disagreeing environment variable. `ui-bridge` keeps its own read because it reads a passed scope's `location` (a worker realm), not this launch's. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` and the whole core suite (`npx vitest run packages/core/__tests__` → 4559 passed, 4 skipped); `URLSearchParams` occurrences in `packages/core/src` fell 5 → 3 and hand `location.search` reads 14 → 2 (`debug.ts` owns them). `world-gpu-flags.spec.ts` was rewritten where it pinned the three old rules: it asserted `?tnX=` off for one switch and on for `debugFlag`, and `"true"` off for one global and on for another. +- [x] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. `debug.ts` exports `readLaunchFlag(spec, "on" | "off", search?)` and `readLaunchValue(spec)` over the three seams; 14 readers in 13 core files now call it (`world-gpu-flags` ×4, `world-validate`, `world-tile-budget`, `world-cell-options`, `world-environment`, `render-list-validate`, `debug-telemetry`, `Spans`, `FrameStages`, `depth-pyramid`, `virtual-shadow` ×3, `quiet-markers`). One truthiness rule replaced three: `0`/`false` are off, a named-but-empty value is on, an empty variable is unset; every seam that names the flag is heard, so a harness global is not lost behind a disagreeing environment variable. `ui-bridge` keeps its own read because it reads a passed scope's `location` (a worker realm), not this launch's. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` and the whole core suite (`npx vitest run packages/core/__tests__` → 4559 passed, 4 skipped); `URLSearchParams` occurrences in `packages/core/src` fell 5 → 3 and hand `location.search` reads 14 → 2 (`debug.ts` owns them). `world-gpu-flags.spec.ts` was rewritten where it pinned the three old rules: it asserted `?tnX=` off for one switch and on for `debugFlag`, and `"true"` off for one global and on for another. - [x] `WorldEnvironment` uses `applyRendererToneMapping`. The default did not move: `qualityPreset()` already supplied `filmic`, and the `?? "aces"` fallback was dead — so no visual judge is owed. The private `TONEMAP` map and the duplicated filmic install are deleted, and `default-tone-mapping.ts` now owns the curve list (`RENDERER_TONE_MAPPINGS`, `isRendererToneMapping`) and accepts the game's curve and grade. proof: `pnpm --filter @threenative/core exec vitest run __tests__/world-environment-defaults.spec.ts __tests__/default-tone-mapping.spec.ts` → 31 passed. - [x] Core version has one source: `index.ts` re-exports `CORE_VERSION` from the generated `version.ts`, `doctor.ts` imports that constant instead of a literal, and `prepare-release.ts` drops the three regexes that patched them. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` → 112 passed. @@ -95,5 +102,5 @@ An independent read-only review of the three phases raised five gaps, all fixed **Status:** DONE **Files:** the field manifest beside `packages/three-native/api/catalog.json`; `packages/three-native/scripts/generate.ts`; `packages/runtime-native/src/engine/wasm/web_host.cpp`; `packages/three-native/src/browser-renderer.ts`, `gpu-stages.ts`, `browser-entry.ts`; `packages/three-native/__tests__/web-host-abi.spec.ts`. -- [x] Every `tnw_web_frame` caller uses a generated name, from one manifest: `packages/three-native/api/web-frame-fields.json` (73 fields) feeds `packages/three-native/scripts/generate.ts`, which writes the TypeScript constant (`src/generated/web-frame-fields.ts`), the C++ enum (`runtime-native/src/engine/wasm/web_frame_fields.h`) and the facade's stage tables (`gpu-stages.ts` `CPU_STAGE_FIELDS`/`SHADOW_SEGMENT_FIELD`, `browser-renderer.ts`'s GPU stage base). The switch names the enum, and an id no manifest entry declares prints `TN_WEB_FRAME_UNKNOWN_FIELD` once and answers NaN instead of a zero the caller would read as a measurement. `__tests__/web-frame-fields.spec.ts` pins the three copies to the manifest and digests the table (`faae3793e5b2581b`), so a renumber goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-frame-fields.spec.ts __tests__/web-host-abi.spec.ts` → 6 passed; `pnpm wasm:build` compiles and links both web engines with the new switch (exit 0). +- [x] Every `tnw_web_frame` caller uses a generated name, from one manifest: `packages/three-native/api/web-frame-fields.json` (73 fields) feeds `packages/three-native/scripts/generate.ts`, which writes the TypeScript constant (`src/generated/web-frame-fields.ts`), the C++ enum (`runtime-native/src/engine/wasm/web_frame_fields.h`) and the facade's stage tables (`gpu-stages.ts` `CPU_STAGE_FIELDS`/`SHADOW_SEGMENT_FIELD`, `browser-renderer.ts`'s GPU stage base). The switch names the enum, and an id no manifest entry declares prints `TN_WEB_FRAME_UNKNOWN_FIELD` once and answers NaN instead of a zero the caller would read as a measurement. `__tests__/web-frame-fields.spec.ts` pins the three copies to the manifest and the 73 published `(name, id)` pairs, so an append is the only edit that passes and a renumber goes red. proof: `pnpm --filter @threenative/three-native exec vitest run __tests__/web-frame-fields.spec.ts __tests__/web-host-abi.spec.ts` → 6 passed; `pnpm wasm:build` compiles and links both web engines with the new switch (exit 0). - [x] The owned web engine still reports real frame telemetry: `TN_FRAME_BUDGET` on `examples/wasm-engine-boot` (the game page, `--browser-recipe webgpu`, adapter `nvidia`/`turing`) reads GPU `mean 0.05 ms` over 3 samples and non-zero CPU stages (`wasmPlan` mean 0.30, `wasmEncode` 0.26, `wasmScene` 0.06, `wasmPost` 0.16, `engineJs` 0.30) — the renamed stage fields (66-70) answer the values they named before. The starter scaffold needs a full template install-and-build lane, so the proof runs on the in-repo game page that drives the same `tnw_web_frame` path; `pnpm test:templates` remains the starter-wide gate under AC-1. proof: `pnpm --filter wasm-engine-boot build && pnpm --filter wasm-engine-boot playtest:frame-budget` (the scenario is `examples/wasm-engine-boot/playtests/frame-budget.playtest.json`) → 1 window, values recorded here diff --git a/examples/wasm-engine-boot/package.json b/examples/wasm-engine-boot/package.json index 031cce738..e6bb54c24 100644 --- a/examples/wasm-engine-boot/package.json +++ b/examples/wasm-engine-boot/package.json @@ -4,18 +4,18 @@ "private": true, "type": "module", "scripts": { - "build:renderer": "WASM_BOOT_PAGES=renderer node ../../packages/create-threenative/dist/threenative.js build --target web", "build:wasm": "node ../../packages/create-threenative/dist/threenative.js build --target web", - "playtest:array": "node ../../packages/playtest/dist/runner/cli.js playtests/array.playtest.json --url http://127.0.0.1:5293/array.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:compute": "node ../../packages/playtest/dist/runner/cli.js playtests/compute.playtest.json --url http://127.0.0.1:5293/compute.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:frame-budget": "node ../../packages/playtest/dist/runner/cli.js playtests/frame-budget.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "build:renderer": "WASM_BOOT_PAGES=renderer node ../../packages/create-threenative/dist/threenative.js build --target web", + "typecheck": "tsc --noEmit -p tsconfig.json", + "playtest:renderer": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5293/renderer.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", "playtest:game": "node ../../packages/playtest/dist/runner/cli.js playtests/game.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:limits": "node ../../packages/playtest/dist/runner/cli.js playtests/limits.playtest.json --url http://127.0.0.1:5293/limits.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:frame-budget": "node ../../packages/playtest/dist/runner/cli.js playtests/frame-budget.playtest.json --url http://127.0.0.1:5293 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:renderer:dev": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5296/renderer.html --server-command \"pnpm exec vite --host 127.0.0.1 --port 5296 --strictPort\" --browser-recipe webgpu --headed", + "playtest:compute": "node ../../packages/playtest/dist/runner/cli.js playtests/compute.playtest.json --url http://127.0.0.1:5293/compute.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", + "playtest:array": "node ../../packages/playtest/dist/runner/cli.js playtests/array.playtest.json --url http://127.0.0.1:5293/array.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", "playtest:post": "node ../../packages/playtest/dist/runner/cli.js playtests/post.playtest.json --url http://127.0.0.1:5293/post.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", "playtest:post-normal-node": "node ../../packages/playtest/dist/runner/cli.js playtests/post-normal-node.playtest.json --url http://127.0.0.1:5293/post.html?nodenormal=1 --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:renderer": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5293/renderer.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed", - "playtest:renderer:dev": "node ../../packages/playtest/dist/runner/cli.js playtests/renderer.playtest.json --url http://127.0.0.1:5296/renderer.html --server-command \"pnpm exec vite --host 127.0.0.1 --port 5296 --strictPort\" --browser-recipe webgpu --headed", - "typecheck": "tsc --noEmit -p tsconfig.json" + "playtest:limits": "node ../../packages/playtest/dist/runner/cli.js playtests/limits.playtest.json --url http://127.0.0.1:5293/limits.html --server-command \"pnpm exec vite preview --host 127.0.0.1 --port 5293 --strictPort\" --browser-recipe webgpu --headed" }, "dependencies": { "@threenative/core": "workspace:*", diff --git a/packages/core/src/spells/ultra/engine/quality.ts b/packages/core/src/spells/ultra/engine/quality.ts index f6918b3ca..652122648 100644 --- a/packages/core/src/spells/ultra/engine/quality.ts +++ b/packages/core/src/spells/ultra/engine/quality.ts @@ -1,7 +1,6 @@ // Ported from Ultra Elements src/engine/quality.js @698b720 (MIT, Copyright (c) 2026 Humpalumps). JavaScript copied verbatim; GLSL transliterated to TSL. // Engine: the tier comes from `?q=` when there is a location; there is no `matchMedia` probe on the // native host, so `mobile` is `?mobile` only. -import { readLaunchValue } from "../../../debug.js"; // Quality tiers. Spells author counts at High; emission is multiplied by Q.emission (tier x auto). // Anything here that changes a shader define or a target size only changes on an explicit tier @@ -81,12 +80,11 @@ export const TIERS = { }; export type TierName = keyof typeof TIERS; -// Query-only on purpose: a spell's tier is a page a person opens, and neither knob has an -// environment variable or a global a host would set. `?mobile` naming itself is the ask. -export const mobile = readLaunchValue({ environment: "", query: "mobile" }) !== undefined; -const asked = readLaunchValue({ environment: "", query: "q" }); +const args = new URLSearchParams(globalThis.location?.search ?? ""); +export const mobile = args.has("mobile"); +const asked = args.get("q"); export const tierName: TierName = - asked !== undefined && asked in TIERS ? (asked as TierName) : mobile ? "low" : "high"; + asked !== null && asked in TIERS ? (asked as TierName) : mobile ? "low" : "high"; // live values; auto scales multiply on top (auto.emission, auto.steps) export const Q = { diff --git a/scripts/__tests__/launch-flag-parity.spec.ts b/scripts/__tests__/launch-flag-parity.spec.ts index 867eee6ff..cf43291ae 100644 --- a/scripts/__tests__/launch-flag-parity.spec.ts +++ b/scripts/__tests__/launch-flag-parity.spec.ts @@ -13,7 +13,7 @@ const host = globalThis as Record; function seam(search: string | undefined, env: string | undefined, global: unknown): void { if (search === undefined) Reflect.deleteProperty(host, "location"); else host.location = { search }; - if (env === undefined) delete process.env.TN_DEBUG; + if (env === undefined) Reflect.deleteProperty(process.env, "TN_DEBUG"); else process.env.TN_DEBUG = env; if (global === undefined) Reflect.deleteProperty(host, "__THREENATIVE_DEBUG__"); else host.__THREENATIVE_DEBUG__ = global; @@ -21,43 +21,56 @@ function seam(search: string | undefined, env: string | undefined, global: unkno afterEach(() => seam(undefined, undefined, undefined)); -/** One case per rule: the two spellings of off, the two of on, a bare query, and the odd values. */ -const CASES: readonly (readonly [string | undefined, string | undefined, unknown])[] = [ - [undefined, undefined, undefined], - [undefined, "1", undefined], - [undefined, "true", undefined], - [undefined, "0", undefined], - [undefined, "false", undefined], - [undefined, "", undefined], - [undefined, "yes", undefined], - ["?tnDebug=1", undefined, undefined], - ["?tnDebug=true", undefined, undefined], - ["?tnDebug", undefined, undefined], - ["?tnDebug=0", undefined, undefined], - ["?tnDebug=false", undefined, undefined], - ["?tnDebug=yes", undefined, undefined], - [undefined, undefined, true], - [undefined, undefined, false], - [undefined, undefined, "1"], - [undefined, undefined, "0"], - [undefined, undefined, "true"], - [undefined, undefined, 1], - [undefined, undefined, 0], +/** + * One case per rule, with the answer the rule gives: the two spellings of off, the spellings of on, + * a bare query, an empty variable, and every seam heard over every other. The expected column is the + * point — a parity check without one only proves the two copies agree, which they can do and be wrong. + */ +const CASES: readonly (readonly [string | undefined, string | undefined, unknown, boolean])[] = [ + [undefined, undefined, undefined, false], + [undefined, "1", undefined, true], + [undefined, "true", undefined, true], + [undefined, "0", undefined, false], + [undefined, "false", undefined, false], + // An exported-but-empty variable is unset, not off and not on. + [undefined, "", undefined, false], + [undefined, "yes", undefined, true], + ["?tnDebug=1", undefined, undefined, true], + ["?tnDebug=true", undefined, undefined, true], + // A URL that names the switch and gives it no value has no other way to say yes. + ["?tnDebug", undefined, undefined, true], + ["?tnDebug=", undefined, undefined, true], + ["?tnDebug=0", undefined, undefined, false], + ["?tnDebug=false", undefined, undefined, false], + ["?tnDebug=yes", undefined, undefined, true], + [undefined, undefined, true, true], + [undefined, undefined, false, false], + [undefined, undefined, "1", true], + [undefined, undefined, "0", false], + [undefined, undefined, "true", true], + [undefined, undefined, 1, true], + [undefined, undefined, 0, false], // Every seam heard: a yes on one beats a no on another, in either direction. - ["?tnDebug=1", "0", undefined], - ["?tnDebug=0", "1", undefined], - ["?tnDebug=0", undefined, true], - ["", "0", true], + ["?tnDebug=1", "0", undefined, true], + ["?tnDebug=0", "1", undefined, true], + ["?tnDebug=0", undefined, true, true], + ["", "0", true, true], ]; describe("the launch-flag rule", () => { it("should answer the same as the facade's copy for every seam", () => { - for (const [search, env, global] of CASES) { + for (const [search, env, global, expected] of CASES) { seam(search, env, global); - const describeCase = `location=${String(search)} TN_DEBUG=${String(env)} global=${String(global)}`; - expect(debugRequested(), describeCase).toBe( - readLaunchFlag({ environment: "TN_DEBUG", global: "__THREENATIVE_DEBUG__", query: "tnDebug" }), - ); + const label = `location=${String(search)} TN_DEBUG=${String(env)} global=${String(global)}`; + expect( + readLaunchFlag({ + environment: "TN_DEBUG", + global: "__THREENATIVE_DEBUG__", + query: "tnDebug", + }), + `core: ${label}`, + ).toBe(expected); + expect(debugRequested(), `facade: ${label}`).toBe(expected); } }); @@ -77,7 +90,11 @@ describe("the launch-flag rule", () => { it("should report every seam that named the flag, in read order", () => { seam("?tnDebug=1", "0", true); expect( - readLaunchValues({ environment: "TN_DEBUG", global: "__THREENATIVE_DEBUG__", query: "tnDebug" }), + readLaunchValues({ + environment: "TN_DEBUG", + global: "__THREENATIVE_DEBUG__", + query: "tnDebug", + }), ).toEqual(["0", "1", "true"]); }); }); diff --git a/scripts/verify-registry-install.ts b/scripts/verify-registry-install.ts index 3aee6f84b..31f7595d3 100644 --- a/scripts/verify-registry-install.ts +++ b/scripts/verify-registry-install.ts @@ -200,10 +200,13 @@ export function assertSupportedNodeVersion(version = process.versions.node): voi .exec(version) ?.slice(1) .map((part) => Number.parseInt(part, 10)) ?? [-1, -1, -1]; + // A range that names fewer parts than the running version (`>=20.19`) pins only the parts it + // names: the rest are zero, never "below". const below = (index: number): boolean => { - const [have, want] = [current[index], minimum[index]]; - if (have === undefined || want === undefined) return true; - const higher = current.slice(0, index).every((value, at) => value === minimum[at]); + const have = current[index]; + if (have === undefined) return false; + const want = minimum[index] ?? 0; + const higher = current.slice(0, index).every((value, at) => value === (minimum[at] ?? 0)); return higher && have < want; }; if ([0, 1, 2].some(below)) diff --git a/scripts/visual-gate.ts b/scripts/visual-gate.ts index 39bc37d52..9aa1c76ad 100644 --- a/scripts/visual-gate.ts +++ b/scripts/visual-gate.ts @@ -41,11 +41,10 @@ const TEMPLATE_ROOT = path.join( path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), "packages/create-threenative/templates", ); -// One inventory owns "what a kit is" (a template directory that ships `kit.json`): this reader is -// the scaffolder's own and parses each manifest, while the list of names comes from repo-inventory. -export const TEMPLATE_NAMES: readonly ScaffoldTemplate[] = templateNames(TEMPLATE_ROOT).filter( - (name) => discoverTemplateNames(TEMPLATE_ROOT).includes(name), -); +// One inventory owns "what a kit is" (a template directory that ships `kit.json`), and it reads it +// from the repository root; `discoverTemplateNames` reads the same directories from the templates +// folder for the scaffolder's own callers. Every visual lane iterates this list, so it stays derived. +export const TEMPLATE_NAMES: readonly ScaffoldTemplate[] = templateNames(); export const RENDER_LAYER_FILES = [ "palette.ts", "camera.ts", From dd56d90f87f761ac4aa052385b3092f869d36df6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:38:45 -0700 Subject: [PATCH 007/154] docs(PRD-832): the counts the review measured --- .../untangle-2026-11-06/PRD-832-urgent-single-sources.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md index 6f6d1680a..7e8fa62c1 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-832-urgent-single-sources.md @@ -88,13 +88,13 @@ wrong answer still fail. `verify-registry-install.ts` reads the Node floor from **Files:** new `scripts/lib/repo-inventory.ts`; edit `scripts/capability-recall.ts`, `scripts/ci-change-scope.mjs`, `scripts/ci-integration-scope.mjs`, `scripts/visual-gate.ts`, `packages/create-threenative/src/index.ts`; new drift spec under `packages/create-threenative/__tests__/`. - [x] Recall accepts and harvests every shipped kit (red first: `--template puzzle` rejected with `TN_CAPABILITY_RECALL: unknown template 'puzzle'`; now resolves). No script keeps a hand kit or package list: `scripts/lib/repo-inventory.mjs` derives kits from `templates/*/kit.json`, brief genres from `docs/benchmark/genres/*/brief.md`, packages from `packages/*/package.json` and the Node floor from root `engines.node`. `capability-recall.ts`, `ci-change-scope.mjs`, `ci-integration-scope.mjs` (which now fails closed on a package name that left the workspace) and `bake-delete-test.ts` read it. proof: `pnpm exec vitest run scripts/__tests__/capability-recall.spec.ts scripts/__tests__/ci-integration-scope.spec.ts scripts/__tests__/repo-inventory.spec.ts` → 199 passed; `rg -n '"tower-defense"' scripts --glob '!__tests__'` returns nothing. The `scripts/__tests__/ci-change-scope.spec.ts` this box named does not exist — the specs over that module are `ci-native-chain`, `ci-needs`, `ci-leg-carry`, `ci-local` and `capability-site-docs`, all green. -- [x] The drift spec failed on today's tree naming the unlisted copies, then passed once they are in `SHARED_RENDER_SOURCES`. It named 9, not 7: `atmosphere.ts` (6 kits) and `gpuSky.ts` (6 kits) ship byte-identical everywhere too. The rule is "same bytes in every kit that carries it, in 2+ kits", so a per-kit look that merely coincides in 4 kits (`exposure.ts` has 10 distinct copies) stays out. `ADDED_RENDER_SOURCES` keeps the scaffold-time copy into starter/collectathon/adventure/kart unchanged, and `pnpm stamp:template-render` is byte-for-byte a no-op. proof: `pnpm --filter create-threenative exec vitest run __tests__/shared-render-drift.spec.ts` → 3 passed, plus `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/scaffold-file-allowlist.spec.ts` → 80 passed +- [x] The drift spec failed on today's tree naming the unlisted copies, then passed once they are in `SHARED_RENDER_SOURCES`. It named 9, not 7: `atmosphere.ts` (6 kits) and `gpuSky.ts` (6 kits) ship byte-identical everywhere too. The rule is "same bytes in every kit that carries it, in 2+ kits", so a per-kit look that merely coincides in 4 kits (`exposure.ts` has 10 distinct copies) stays out. `ADDED_RENDER_SOURCES` keeps the scaffold-time copy into starter/collectathon/adventure/kart unchanged, and `pnpm stamp:template-render` is byte-for-byte a no-op. proof: `pnpm --filter create-threenative exec vitest run __tests__/shared-render-drift.spec.ts` → 4 passed, plus `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/scaffold-file-allowlist.spec.ts` → 80 passed #### Phase 2: Core single owners **Status:** DONE **Files:** `packages/core/src/debug.ts` plus the 16 reader sites in report A4; `packages/core/src/look/world-environment.ts`; `packages/core/src/index.ts`; `packages/create-threenative/src/doctor.ts`; `scripts/prepare-release.ts`. -- [x] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. `debug.ts` exports `readLaunchFlag(spec, "on" | "off", search?)` and `readLaunchValue(spec)` over the three seams; 14 readers in 13 core files now call it (`world-gpu-flags` ×4, `world-validate`, `world-tile-budget`, `world-cell-options`, `world-environment`, `render-list-validate`, `debug-telemetry`, `Spans`, `FrameStages`, `depth-pyramid`, `virtual-shadow` ×3, `quiet-markers`). One truthiness rule replaced three: `0`/`false` are off, a named-but-empty value is on, an empty variable is unset; every seam that names the flag is heard, so a harness global is not lost behind a disagreeing environment variable. `ui-bridge` keeps its own read because it reads a passed scope's `location` (a worker realm), not this launch's. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` and the whole core suite (`npx vitest run packages/core/__tests__` → 4559 passed, 4 skipped); `URLSearchParams` occurrences in `packages/core/src` fell 5 → 3 and hand `location.search` reads 14 → 2 (`debug.ts` owns them). `world-gpu-flags.spec.ts` was rewritten where it pinned the three old rules: it asserted `?tnX=` off for one switch and on for `debugFlag`, and `"true"` off for one global and on for another. +- [x] One `readLaunchFlag` owns flag parsing, and every reader keeps its accepted spelling. `debug.ts` exports `readLaunchFlag(spec, "on" | "off", search?)`, `readLaunchValue(spec)` and `readLaunchValues(spec)` over the three seams; 12 core files now call it (`world-gpu-flags` ×4, `world-validate`, `world-tile-budget`, `world-cell-options`, `world-environment`, `render-list-validate`, `debug-telemetry`, `Spans`, `FrameStages`, `depth-pyramid`, `virtual-shadow` ×3, `quiet-markers`). One truthiness rule replaced three: `0`/`false` are off, a named-but-empty value is on, an empty variable is unset; every seam that names the flag is heard, so a harness global is not lost behind a disagreeing environment variable. `ui-bridge` keeps its own read because it reads a passed scope's `location` (a worker realm), not this launch's. proof: `pnpm --filter @threenative/core exec vitest run __tests__/debug.spec.ts` and the whole core suite (`npx vitest run packages/core/__tests__` → 4559 passed, 4 skipped); `URLSearchParams` occurrences in `packages/core/src` fell 5 → 4 and hand `location.search` reads 14 → 3: `debug.ts` owns the launch seams, `ui-bridge` reads a passed worker scope's `location`, and the two that remain are the verbatim spell port and the dead `gtaoUrlKnobs(params)` helper. `world-gpu-flags.spec.ts` was rewritten where it pinned the three old rules: it asserted `?tnX=` off for one switch and on for `debugFlag`, and `"true"` off for one global and on for another. - [x] `WorldEnvironment` uses `applyRendererToneMapping`. The default did not move: `qualityPreset()` already supplied `filmic`, and the `?? "aces"` fallback was dead — so no visual judge is owed. The private `TONEMAP` map and the duplicated filmic install are deleted, and `default-tone-mapping.ts` now owns the curve list (`RENDERER_TONE_MAPPINGS`, `isRendererToneMapping`) and accepts the game's curve and grade. proof: `pnpm --filter @threenative/core exec vitest run __tests__/world-environment-defaults.spec.ts __tests__/default-tone-mapping.spec.ts` → 31 passed. - [x] Core version has one source: `index.ts` re-exports `CORE_VERSION` from the generated `version.ts`, `doctor.ts` imports that constant instead of a literal, and `prepare-release.ts` drops the three regexes that patched them. proof: `pnpm exec vitest run packages/create-threenative/__tests__/doctor.spec.ts scripts/__tests__/prepare-release.spec.ts` → 112 passed. From 1897ba84296bb68269e622edea989b9baeba7eb5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:53:19 -0700 Subject: [PATCH 008/154] refactor(create-threenative): the scaffold pin is two hashes per kit, rewritten by a tool PRD-833 phase 3. One hash per kit said a tree moved without saying why, so a shared edit re-pinned sixteen rows and needed a paragraph of history to read. Each kit now carries a 'shared' hash (agent-files, template-assets, core's tables, the shared render sources and the generated AGENTS/CLAUDE routers) and a 'kit' hash (the kit's own files); `pnpm scaffold:pin` rewrites both and names which side moved. The classification is derived in __tests__/scaffold-tree.ts, not listed, so a new synthesized file lands in the right bucket. STARTER_PATHS, MINIMAL_RENDER_PATHS and PLATFORMER_PATHS are deleted: the two hashes catch a missing or changed file and the allowlist catches a new kind of file, which is the whole contract those lists carried. --- .../PRD-833-template-mechanism-lives-once.md | 8 +- package.json | 1 + packages/create-threenative/AGENTS.md | 8 +- packages/create-threenative/CLAUDE.md | 8 +- .../__tests__/scaffold-tree.ts | 126 ++++ .../__tests__/scaffold.spec.ts | 687 +++--------------- scripts/scaffold-pin.ts | 127 ++++ 7 files changed, 364 insertions(+), 601 deletions(-) create mode 100644 packages/create-threenative/__tests__/scaffold-tree.ts create mode 100644 scripts/scaffold-pin.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md index ad04d623f..2a2cdc4a3 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -1,6 +1,6 @@ # PRD-833 — Template mechanism lives once -**Status:** NOT STARTED +**Status:** PARTIAL — Phase 3 landed; Phases 1 and 2 (mechanism into core, shared build plumbing) are open **Priority:** P1 — open Phase 2 deletes 84 template copies of 7 render-mechanism files, where a fix to one kit leaves 11 kits wrong with every test green. **Complexity:** 6 (MEDIUM); risk override: the moved files measure the environment and wire the backlight, so the owner's visual-judge rule applies **Owner:** ThreeNative maintainers @@ -61,8 +61,8 @@ Report ids B1, F1, F2, F3, F4, F8, B2. Root `AGENTS.md` puts "plumbing every gam - [ ] The patch filename lives in one manifest that `prepack` reads. proof: `rg -c 'three@0\.' packages/create-threenative/src packages/create-threenative/package.json` drops to the manifest only, plus `pnpm --filter create-threenative pack` ships the patch #### Phase 3: Scaffold pins split -**Status:** NOT STARTED +**Status:** DONE **Files:** `packages/create-threenative/__tests__/scaffold.spec.ts`, new `scripts/scaffold-pin.ts`, root `package.json`. -- [ ] Each kit carries a `shared` and a `kit` hash. An edit to `agent-docs/` changes only `shared`, and `pnpm scaffold:pin` rewrites it. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts __tests__/scaffold-file-allowlist.spec.ts` -- [ ] `STARTER_PATHS` and the two sibling path lists are deleted, so the allowlist and per-directory counts are the only ship contract. proof: the same spec run +- [x] Each kit carries a `shared` and a `kit` hash, and `pnpm scaffold:pin` rewrites them and names which bucket moved. `__tests__/scaffold-tree.ts` owns the classification (a file the kit's own template holds is the kit's; anything the scaffolder built from `agent-files`, `template-assets`, core's tables or the shared render sources is shared, plus the generated `AGENTS.md`/`CLAUDE.md` routers). Proof of the split, run here: appending a line to `agent-files/.agents/skills/file-engine-bug/SKILL.md` makes `pnpm scaffold:pin --check` report `stale : shared` for all sixteen kits and nothing else; restoring it reports `scaffold pins are current`. The box's example says `agent-docs/`, which ships nothing into a scaffold (PRD-449), so it cannot move a pin; `agent-files/` is the shipped shared input. The block also replaced ~600 lines of re-pin history. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts __tests__/scaffold-file-allowlist.spec.ts` → 80 passed. +- [x] `STARTER_PATHS` (83 entries), `MINIMAL_RENDER_PATHS` and `PLATFORMER_PATHS` are deleted. What they asserted is covered: the two hashes catch a missing or changed file, the allowlist catches a new kind of file, and the two tests that used them keep their other claims (the minimal kit now asserts it ships a render layer at all, instead of a stale seven-name list the directory had outgrown). proof: the same spec run, and `rg -n 'STARTER_PATHS|MINIMAL_RENDER_PATHS|PLATFORMER_PATHS' packages/create-threenative` returns nothing. diff --git a/package.json b/package.json index 5ce5907fd..cba9137af 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "round:deletions": "tsx scripts/round-deletions.ts", "round:next": "tsx scripts/round-next.ts", "sandbox": "tsx scripts/make-sandbox.ts", + "scaffold:pin": "tsx scripts/scaffold-pin.ts", "stamp:template-loading": "tsx scripts/stamp-template-loading.ts", "stamp:template-render": "tsx scripts/stamp-template-render.ts", "sweep:archive": "tsx scripts/sweep-archive.ts", diff --git a/packages/create-threenative/AGENTS.md b/packages/create-threenative/AGENTS.md index f3b27f11c..db06825dc 100644 --- a/packages/create-threenative/AGENTS.md +++ b/packages/create-threenative/AGENTS.md @@ -63,9 +63,11 @@ template's Commands block is a fail-closed statement, not marketing; narrow it w ## Keep the tests in sync -Adding, renaming, or deleting a template file means updating `__tests__/scaffold.spec.ts` -(`STARTER_PATHS`), and possibly `looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped -template still contains what the design promises. +Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites the two +hashes per kit in `__tests__/scaffold.spec.ts` and names which bucket moved), and possibly updating +`looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped template still contains what the +design promises. The allowlist in `scaffold-file-allowlist.spec.ts` decides which kinds of file may +ship; the hashes decide which bytes must. A bug fixed in one kit becomes one row of `packages/playtest/src/kit-contract.ts`, checked for every kit by `pnpm test:templates`. Each kit declares what the rows need in diff --git a/packages/create-threenative/CLAUDE.md b/packages/create-threenative/CLAUDE.md index e13dcfe58..97a7cd98e 100644 --- a/packages/create-threenative/CLAUDE.md +++ b/packages/create-threenative/CLAUDE.md @@ -65,9 +65,11 @@ template's Commands block is a fail-closed statement, not marketing; narrow it w ## Keep the tests in sync -Adding, renaming, or deleting a template file means updating `__tests__/scaffold.spec.ts` -(`STARTER_PATHS`), and possibly `looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped -template still contains what the design promises. +Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites the two +hashes per kit in `__tests__/scaffold.spec.ts` and names which bucket moved), and possibly updating +`looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped template still contains what the +design promises. The allowlist in `scaffold-file-allowlist.spec.ts` decides which kinds of file may +ship; the hashes decide which bytes must. A bug fixed in one kit becomes one row of `packages/playtest/src/kit-contract.ts`, checked for every kit by `pnpm test:templates`. Each kit declares what the rows need in diff --git a/packages/create-threenative/__tests__/scaffold-tree.ts b/packages/create-threenative/__tests__/scaffold-tree.ts new file mode 100644 index 000000000..349675bd8 --- /dev/null +++ b/packages/create-threenative/__tests__/scaffold-tree.ts @@ -0,0 +1,126 @@ +// The two hashes a scaffolded tree is pinned by, and the rule that decides which is which. +// +// One hash per kit could not answer the question a re-pin asks: did this change come from the kit, or +// from a shared input every kit receives? `c93b427d0` moved 16 pins for one look change in a shared +// file, and every pin looked the same. Two buckets make the answer part of the pin: a shared edit +// moves only `shared`, a kit edit moves only `kit`. +// +// The classification is derived, not listed: a file the kit's own template directory holds is the +// kit's, and anything the scaffolder rendered from `agent-docs`, `agent-files`, `template-assets` or +// core's tables is shared. Two kinds of file are the kit's own bytes but the shared contract, so they +// name themselves here: the generated instruction pair, which is a router over shared fragments, and +// the shared render sources the scaffolder restamps into every kit. +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { SHARED_RENDER_SOURCES, templateRoot } from "../src/index.js"; + +/** Output, not source: the same exclusions the tree pin always had. */ +const GENERATED_SCAFFOLD_METADATA = + /^(?:node_modules(?:\/|$)|dist(?:\/|$)|\.vite(?:\/|$)|coverage(?:\/|$)|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$)/u; + +/** A copy of a package's own patch: the file hashes by name, and the copy is guarded on its own. */ +const FRAMEWORK_PATCH = /^patches\/[^/]+\.patch$/u; + +/** `pnpm release:prepare` moves these on every release, so a version bump alone must not move a tree. */ +const COHORT_PIN = + /("(?:@threenative\/[\w-]+|create-threenative|threenative-[\w-]+)":\s*)"\d+\.\d+\.\d+(?:-[\w.]+)?"/gu; + +/** The kit's own bytes, rendered from shared fragments: the shared contract decides their content. */ +const GENERATED_INSTRUCTION_FILES: ReadonlySet = new Set(["AGENTS.md", "CLAUDE.md"]); + +/** A template file the scaffolder renames on the way out, so the kit holds it under another name. */ +const RENAMED_BY_SCAFFOLDER: Readonly> = { ".gitignore": "gitignore" }; + +/** The two hashes a kit is pinned by. */ +export interface IScaffoldTreePin { + /** The hash of everything the scaffolder built from outside this kit's own tree. */ + readonly shared: string; + /** The hash of the kit's own files. */ + readonly kit: string; +} + +/** A pin plus the paths behind each hash, for a tool that has to say what moved. */ +export interface IScaffoldTreeBuckets extends IScaffoldTreePin { + readonly sharedPaths: readonly string[]; + readonly kitPaths: readonly string[]; +} + +/** + * Whether `relativePath` in a scaffold of `template` came from a shared input. + * + * Exported because the pin tool reports against the same rule the spec asserts with: two copies of + * "what counts as shared" would drift the moment a new synthesized file ships. + */ +export function isSharedScaffoldPath( + relativePath: string, + template: string, + root = templateRoot(), +): boolean { + if (GENERATED_INSTRUCTION_FILES.has(relativePath)) return true; + if (SHARED_RENDER_SOURCES.includes(relativePath)) return true; + const source = RENAMED_BY_SCAFFOLDER[relativePath] ?? relativePath; + return !existsSync(path.join(root, template, source)); +} + +/** Every file in a scaffolded tree, relative to it, sorted, minus what the build generates. */ +export async function scaffoldTreeFiles(directory: string): Promise { + const files: string[] = []; + async function walk(current: string): Promise { + for (const entry of (await readdir(current, { withFileTypes: true })).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const file = path.join(current, entry.name); + // A linked skill directory reports isDirectory() false, so it needs the stat to be walked + // like the real one — otherwise the tree hash silently drops half of every skill. + if (entry.isDirectory() || (entry.isSymbolicLink() && (await stat(file)).isDirectory())) { + await walk(file); + continue; + } + const relative = path.relative(directory, file); + if (!GENERATED_SCAFFOLD_METADATA.test(relative)) files.push(relative); + } + } + await walk(directory); + return files; +} + +/** The bytes a pin sees: a framework patch by name, a cohort pin as one placeholder. */ +async function pinnedBytes(file: string, relativePath: string): Promise { + if (FRAMEWORK_PATCH.test(relativePath)) return Buffer.from(""); + const bytes = await readFile(file); + if (path.basename(relativePath) !== "package.json") return bytes; + return Buffer.from(bytes.toString("utf8").replace(COHORT_PIN, '$1""')); +} + +function digest(entries: readonly (readonly [string, Buffer])[]): string { + const hash = createHash("sha256"); + for (const [relativePath, contents] of entries) { + hash.update(relativePath).update("\0").update(contents).update("\0"); + } + return hash.digest("hex"); +} + +/** The two hashes of one scaffolded tree, plus the paths each covers. */ +export async function scaffoldTreeBuckets( + target: string, + template: string, + root = templateRoot(), +): Promise { + const shared: [string, Buffer][] = []; + const kit: [string, Buffer][] = []; + for (const relativePath of await scaffoldTreeFiles(target)) { + const entry: [string, Buffer] = [ + relativePath, + await pinnedBytes(path.join(target, relativePath), relativePath), + ]; + (isSharedScaffoldPath(relativePath, template, root) ? shared : kit).push(entry); + } + return { + shared: digest(shared), + kit: digest(kit), + sharedPaths: shared.map(([relativePath]) => relativePath), + kitPaths: kit.map(([relativePath]) => relativePath), + }; +} diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index ed764e836..6c01d48c6 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -33,6 +33,11 @@ import { scaffoldCompletionMessage, scaffoldNextSteps, } from "../src/index.js"; +import { + type IScaffoldTreeBuckets, + type IScaffoldTreePin, + scaffoldTreeBuckets, +} from "./scaffold-tree.js"; const run = promisify(execFile); @@ -65,391 +70,6 @@ const BUG_REPORT_SKILL_PATHS = [ ".agents/skills/file-engine-bug/SKILL.md", ] as const; -// Refreshed for the startup-readiness loading gate: each template's `src/render/loading.ts` was -// reduced to the shared `startup.whenReady()` contract and every template's AGENTS.md carries -// the readiness wording into the shipped scaffold. -// -// Refreshed by PRD-219, in the commit that changed the bytes. Two shipped things moved: the -// starter's menu proof became cross-target (`noNetworkErrors` is a reasoned opt-out, because the -// browser is the only target with a CDP network observer), and the capability manifest every -// template embeds gained the Android viewport, rotation, tap and IME helpers that proof needed. -// The starter's name field also gained autoCapitalize/autoCorrect/spellCheck: a phone keyboard -// rewrites what the player typed unless the field says not to. -// Recomputed 2026-08-30 for `minimal` only: its atmosphere sky and aerial-perspective -// in-scattering were both scaled by 24, a value authored when that template had no post chain. -// The chain now exposes the pass and tone-maps it, so the same radiance was applied twice and -// exposed again — median frame luminance 203 of 255 against 22 for the template's last good -// baseline. Both multipliers are 1.5, calibrated on a scaffolded render rather than chosen. -// Recomputed again for the chasers' measured routes: they now step only once startup -// reports ready, so a slow lane cannot spend part of the route before anything observes it. -// Recomputed 2026-08-30 for `platformer` only: c2ba91d9 re-measured that template's performance -// budgets against the running frame — the previous 70 draws / 3350 triangles were counted behind -// the loading layer, on a frame the player never sees — and moved the scenario to 200 / 7700 -// against a measured 160 / 6127. The frame-time ceiling deliberately did not move. -// Recomputed 2026-08-30 for all seven: `pnpm build` regenerated `capabilities.json` and the -// capability reference derived from it, and every scaffold embeds both, so exporting one public -// symbol moves every template's bytes. Six of the seven moved for that reason alone; platformer -// also carries its chasers' route change. -// Recomputed 2026-10-02 for create-threenative 0.2.8: every template pins the scaffolder version. -// Recomputed 2026-08-31 from the values CI measured, not from a local run. -// -// These were updated three times in a row and were wrong all three times, because they were -// computed against a WORKING TREE that carried another lane's uncommitted template edits. CI -// checks out only committed files, so the numbers disagreed by construction and no amount of -// re-running locally could converge them. If this table needs updating again, take the values -// from a CI failure's `Received` block, or compute them from a clean checkout of HEAD — never -// from a dirty tree in a checkout more than one lane is working in. -// Recomputed 2026-08-31 again: the godrays stage now refuses by name when the shadow map is not -// allocated yet instead of throwing and taking the whole chain with it, and the shared -// look fragment points at the lighting docs. Both are embedded in every scaffold. -// Recomputed 2026-08-31 for PRD-295: every template's AGENTS.md now carries the Fab import route -// (fab_search_assets -> fab_list_owned -> fab_import_asset) in the shared asset-mcp-loop fragment, -// and that fragment is embedded in all seven scaffolds. -// Recomputed 2026-08-31 for PRD-304: every template gained `src/render/quality.ts`, its -// `postprocessing.ts` was rewritten to read it, and its AGENTS.md/CLAUDE.md pair gained the -// quality-tier section — three files per scaffold, so all eight trees move. Computed from a -// committed lane worktree rebased onto origin/main, with no sibling edits in it. -// Recomputed 2026-08-31 for PRD-314: every template's AGENTS.md/CLAUDE.md pair gained the clip -// conformance paragraph (clipPoseError, clipTrackBindings, clipBoneCoverage, boneContact), and the -// generated capability reference embedded in every scaffold gained their four entries. -// Recomputed 2026-09-01 for PRD-304's repair: seven templates' `worldEnvironment.ts` now -// requests its normal/metalness/roughness texture nodes lazily, because asking for them is -// what created the extra render target that made the mobile look a black screen. `sailing` -// never had those lines, so its tree is unchanged and its hash does not move. -// Recomputed 2026-09-03 for PRD-338's authoring-layer pass: the shipped playtest skill, the -// debug-surface page and capture-the-frame all teach `doctor --url`'s room lines and the new -// scene/stride assertions, and all three are copied into every scaffold. -// Recomputed 2026-09-02 for PRD-338, then again the same day when the `scene` assertion family -// landed: the generated assertion reference is embedded in every scaffold and gained a kind. -// Recomputed 2026-09-02 for PRD-338: the playtest bridge gained the `scene.observe` capability -// and the stride observation, so the capability manifest and the reference generated from it -// both moved, and those bytes are embedded in every scaffold. The action-rpg tree moves for a -// second reason — its AGENTS.md/CLAUDE.md pair now states the stride convention. -// Recomputed 2026-09-02 after the named-export capability correction, on origin/main's -// refreshed genre kits. -// Recomputed 2026-09-04 after merging PR #99: every scaffold gains the loading, trace, audio, -// streaming, and alpha-antialiasing surfaces; starter also retains its authored coastal look. -// Recomputed 2026-09-04 for PRD-349 after observing the old pin fail: every template documents -// cook defaults and overrides; starter and sailing also omit their assets opt-outs. -// Recomputed 2026-09-04 after the observed PRD-349 alignment-doc red: every template and its -// generated capability reference now document the automatic unchanged-size fallback. -// Re-pinned after folding that guidance into the existing paragraph to retain the 100-line -// agent-document budget; the template contract red and all ten changed hashes were observed. -// Recomputed 2026-09-04 for the sceneNodes and causedBy assertion families: the generated -// assertion reference every scaffold ships now documents 28 kinds, so all ten trees move by that -// one file. Recomputed again the same day, on the merge, for PRD-265 and PRD-278 AC9: the playtest -// protocol also gained a public `IPlaytestSetupConfirmation` — the read-back that lets a report -// tell what a bridge confirmed from what the runner asked for — which the generated capability -// reference carries into every scaffold too. The values below are measured on the merged tree, so -// they are neither branch's. `sailing` moves for a third reason: its native scenario now asserts -// the render chain it applies on the desktop host. -// Recomputed 2026-09-05 on the merge of origin/main (PRD-357) into this lane. Both sides had -// re-pinned this table for unrelated reasons — PRD-265/PRD-278 here, PRD-349's cook defaults -// there — so neither branch's values describe the merged tree. All ten are measured from the -// merged worktree with no sibling lane's edits in it, per the clean-checkout rule above. -// Recomputed 2026-09-05 on the merge of origin/main into the PRD-298 lane. This branch adds -// capability manifest entries, and the generated reference built from them is embedded in -// every scaffold, so all ten trees move by those bytes. Measured on the merged worktree. -// Recomputed 2026-09-05 for PRD-300's ownership correction. Removing the unsupported health -// and body-height damage aliases and moving terrain streaming to TerrainTiles changes the -// generated capability manifest and capability reference copied into every scaffold, so all -// ten trees move again. Values come from the clean committed tree's Received block, not a dirty -// sibling worktree; no template source or scaffold implementation changed. -// Recomputed 2026-09-07 for the `assets.audio` config seam: `parseAudioConfig` became the CLI's -// validation path and entered the public surface, so the capability manifest and the reference -// generated from it each gained exactly one entry — a 20-line pure addition, no deletions — and -// both files are copied verbatim into every scaffold, so all ten trees move by those bytes. -// Measured on this tree; `worker-scaffold-delta.mjs` names the only two changed repo files that -// reach a scaffold, and neither of the concurrent lane's edits (`src/build.ts`, -// `__tests__/build.spec.ts`) appears in one. No template source or scaffold implementation moved. -// Recomputed 2026-09-07 again, one variable later: every template's AGENTS.md gained the -// `audio: "none"` opt-out beside the `models`/`textures` ones it already listed, and -// `pnpm sync:agents` carried that line into each generated CLAUDE.md — 20 files, one line each, -// one added phrase. Ablation names the whole cause: restoring only those 20 docs to HEAD returns -// all ten hashes to the values above, so nothing else in this lane reaches a scaffold. The docs -// arrive through the templating step rather than a verbatim copy, which is why a content-hash -// matcher does not list them and this ablation is the evidence instead. -// Recomputed 2026-10-03 for PRD-339's opt-in exposure integration at 8bf16f4c3. -// All thirteen actual no-install baseline52/current trees were byte-compared: only the -// generated worldEnvironment/autoExposure/quality wiring, two GPU/readback helper modules, -// and token-identical instruction whitespace changed. Default cost remains off; actual -// consumer GPU captures are byte-identical to the qualified public images. Measurement: -// docs/verification/prd339-exposure-proof/completion-consumer-8bf16f4.json. -// Current develop c18a42b integration: all13 actual generated trees were byte-compared -// against reviewed 8bf trees; only the copied Three compute-only Storage3DTexture patch changed. -// Recomputed in the isolated PR398 lane for linear coverage blending and coherent input moments; -// actual no-install generation changes only starter. All thirteen generator hashes were measured. -// Recomputed for the additional matched-surface depth guard; all other twelve stay unchanged. -// Recomputed for bounded integer depth donors; actual no-install generation changes only starter. -// Recomputed after existing temporal declarations moved; all thirteen trees measured, only starter moves. -// Re-measured on current develop plus TS7: restoring only each compiler manifest and -// rain's shader API import recovers all 13 develop fingerprints. -// PR388 producer delivery: compared all 13 immutable eab0cdbfe/generated trees. Only -// package.json patch declarations and copied Vite/Tailwind patch bytes differ; every other -// generated file remains byte-identical. Fingerprints still cover the complete tree. -// PR381 color/depth attachment caches: all thirteen actual createProject trees change only in the -// copied Three patch. Restoring the previous patch bytes recovers every published fingerprint. -// PR381 bounded compressed uploads: all thirteen actual trees change only in the copied -// Three patch. Restoring the previous patch recovers every prior fingerprint. -// Recomputed from clean committed c22e5ba8d for the independently reviewed bounded-upload -// package patch embedded by every scaffold. No template, capability or appearance change. -// PRD-584: the terrain MCP server was added to every template (2026-10-11), so all thirteen trees move. -// PRD-591/593: measured all thirteen actual trees after readiness manifest and generated patch changes. -// Restoring only the previous shared patch/manifest/reference bytes recovers every prior hash. -// PR483 integration: measured all thirteen trees after merging develop and regenerating the shared patch. -// Recomputed after f5fc41965: only the canonical Three patch copied into all thirteen kits changed. -// Recomputed after adaeb514f: overlaying only its parent Three patch in generated scratch trees -// recovers all thirteen prior fingerprints; current trees retain the geometry-cache fix. -// Final Bindings lifetime patch: restoring the 9a Three patch recovers all thirteen prior hashes. -// Measured after merging develop f30f9b6d3 and regenerating the combined Three patch. -// Measured through all thirteen createProject trees after the interleaved settled-cache repair. -// Re-measured all 13 actual no-install trees after merging develop 4d8efabac (PRD-626 quality and 0.3.6 pins) into PR #483. -// Re-measured after 926497874 carried develop's SMAANode hunk into the generated Three patch; overlaying -// the parent patch recovers all thirteen prior hashes. -// Recomputed 2026-10-22 merging develop 9c34f695c into the epic: the 0.3.6 cohort pins, the PRD-626 lint files and the epic's tone curve -// land in every kit. Measured by this test. -// Recomputed 2026-10-22 for the Unreal tone curve in every kit (PRD-632): template-assets/tonemap.ts gains the gamut expansion, blue -// correction, glow, red modifier and output dither of the harness curve; `tonemapMode` stays "aces". Measured by this test. -// Recomputed 2026-10-21 for the finishing-stage templates (PRD-636, lane/emissive): every kit gains localExposure.ts, -// motionBlur.ts, unrealBloom.ts and vignette.ts and a changed worldEnvironment.ts and quality.ts. Measured by this test -// on the merge of lane/emissive into the epic. -// Recomputed 2026-10-22: worldEnvironment.ts drops the unused smoothstep and vec2 imports (19eb1b38f); measured by this test. -// Recomputed 2026-10-22 on the merge of lane/tonemap (PRD-632 GAP-1..5: grade, white balance, tone curve amount) with the unused-import removal; measured by this test. -// Recomputed 2026-10-22 again: only snow moves, its snowSurface.ts uploads the moved windows (lane/snow e764013de); measured by this test. -// Recomputed 2026-10-20 for the Unreal look epic (PR #541): every template gains the opt-in `filmic` tone mode (tonemap.ts, -// worldEnvironment.ts), the exposure cold-read retry and the histogram-walk fix, and one AGENTS.md/CLAUDE.md line on the engine -// default. Measured from the committed branch head. -// Re-measured 2026-10-20 (PRD-626): every tree gains eslint.config.js and biome.json, three scripts and -// two dev dependencies, and the pristine sources lose the unused bindings the new lint found. -// Recomputed 2026-10-28: only rts moves, its health bars keep a pixel size above the model and its HUD panels -// re-render only on the values they show; measured by this test. -// Refreshed 2026-10-24: shared authoring routes, delegation skills and prompt hook change all 13 trees. -// Recomputed 2026-10-27 (PRD-760): the `.mcp.json` pair and every host config in all sixteen -// trees gain the `threenative-animation` server; no other scaffold byte changed. -// Recomputed 2026-10-28 (0.3.7): the cohort's own pins now hash as a placeholder, so a release bump -// leaves every tree as it is; measured by this test. -// Recomputed 2026-10-28: every tree ships the regenerated three patch (a settled object refreshes once when a PMREM -// swaps the texture it samples); no template file changed. Measured by this test. -// Re-measured 2026-10-28 on the merge of develop bb8708b95 (0.3.7, rts and shooter fixes) into PR #483: every tree -// carries both sides' bytes, so all sixteen were measured by this test on the merged tree. -// Re-measured on the merge of develop into the Strata hardening branch: the develop tree moved -// every no-install scaffold (three 0.187.0, the rts kit and the dropped Blender server), so no value -// from either parent applies to the merged bytes. -const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { - // PRD-825 (2026-11-05): all sixteen trees move, measured by this test. Each kit AGENTS/CLAUDE pair - // becomes a generated router (flow, MCP servers, kit files, commands, convention index, stuck); - // the `threenative-capabilities` skill follows the router's step order, and the ponytail hook - // rung 2 points to the router flow. The shared fragments, the registry and the kit notes pages - // stay in the package. - // PRD-827 (2026-11-05): all sixteen trees move, measured by this test. The scaffold - // `.claude/settings.json` names the six core servers in `enabledMcpjsonServers`, and every tree - // gains the discovery gate (`.claude/hooks/discovery-gate.mjs`, wired as a `PreToolUse` and - // `UserPromptSubmit` hook in `.claude/settings.json` and `.codex/hooks.json`). - // 2026-11-05 (PRD-826): two causes move all sixteen trees, measured by this test. The scaffolder - // now writes the seven MCP host configs from core's one server table instead of copying tracked - // files, so their servers sit in table order (the tracked copies had `threenative-terrain` last). - // `kit.json` is no longer copied into the game, and the four kit READMEs are gone; the sailing - // tree also gains the sky credit header, and the runner, sailing, snow and tower-defense - // AGENTS/CLAUDE pairs gain a kit-facts paragraph. - // Re-measured 2026-11-04 on the merge of origin/develop (11 commits, through 44ca79995) into PR #483: - // every tree carries both sides' bytes (develop's kit docs and sources, the lane's shared three patch), - // so all sixteen were measured again by this test on the merged tree. - // 2026-11-04: framework patches hash by name only (their bytes are held by the starter test), so a - // three, vite or Tailwind patch edit no longer moves any tree. Measured by this test. - // 2026-11-03: re-measured on the merge of develop 33c69249f (0.3.11) into PR #483. The PR side - // already moved every tree (the `threenative-terrain` server in all seven host configs, the pnpm - // `packageManager` pin, the regenerated three patch and the PR's kit sources); the release bump - // alone moves none. - // Measured by this test. - // 2026-11-03: rain publishes the reduced-motion answer its host gave (PRD-617), so its tree moves. - // 2026-11-03 (PRD-617, PRD-620): action-rpg and sailing drop their engine pin, runner re-pins with - // its reason, snow runs on the native engine, and the starter's render-chain row asserts the sharp-AA - // chain, so those five trees move. Measured by this test. - // 2026-10-30: the Blender MCP server is gone, so every kit's seven host configs lose their - // `threenative-blender` entry and all sixteen trees move. Measured by this test. - // 2026-10-29: every kit's seven MCP host configs gain the `threenative-devtools` server, and its - // AGENTS/CLAUDE pair and performance skill name the slow-game order, so all sixteen trees move. - // Measured by this test. - // PRD-723: RTS now ports its visually approved filmic/metered look, readable HUD/rings and full-resolution override. - // Other kits retain the measured phase-1 core imports, vendor removal and guidance. - // 2026-10-19: every kit's exposureReadback.ts retries the undrawn cold-start target. Measured by this test. - // PRD-632 (2026-10-19): every kit ships the shared filmic curve render/tonemap.ts. Measured by this test. - // PRD-563: every kit's render/quality.ts and scenes carry the GPU-class start tier. - // Measured by the actual thirteen-tree createProject equality test on the Oct 6 - // af6dfc8 + ba72eed publication merge, after capability and canonical patch generation. - // Measured through all thirteen actual createProject trees on the Oct 4 published-head - // merge with develop 15adf350; preserves Strata instructions and develop template changes. - // Recomputed 2026-10-01 on merging develop (#375, #376) into the PRD-466/467/468 branch: every - // template AGENTS/CLAUDE pair keeps the optional terrain reference sentence. Values are the - // observed no-install createProject trees of the merged tree. - // Recomputed 2026-10-01 on the merge of develop a602467db (PRD-458/473): every template's frame - // budget now comes from resolveTargetFps, so ten trees move and `rts` does not; the capability reference (365 -> 368 entries) then moved all eleven, because it ships in every scaffold. - // Recomputed 2026-10-01, three times, each by a real run that found the previous tree wrong: - // the first gave each quality.ts a software adapter policy; the second found ten of eleven - // setupPost callers never forwarded the adapter fact to it; the third found no template but - // `starter` set `renderChainTier`, so a low preset still ran the high render chain. The same ten - // trees move on each of the last two. `starter` is unchanged throughout — its setupPost hands the - // whole environment to createAdaptiveQuality and its quality.ts already carried the chain tier. - // Values measured through createProject by the spec that asserts them, not by hand. - // Recomputed again 2026-10-01 for `sailing` alone: `sailMotion` is a range across landed cloth - // readbacks, and one landed copy makes that range zero by arithmetic, so the sails scenario now - // holds long enough for two copies to arrive on a CPU rasteriser and asserts the landed count - // beside it. Only the two sailing template files changed, so only this one tree moves. - // Recomputed again 2026-10-01 after merging the quality/post chain into this branch and landing - // the rts sim's order, queue and event-record fixes: `rts` alone moves, and it is the only one of - // the eleven that carries `src/sim/`. Measured through createProject on the merged tree. - // Recomputed 2026-10-01 on the merge of develop (PRD-470/471/472/474) into the rain + snow - // branch (PRD-469, PRD-473): the merged tree carries both sides' engine and manifest bytes, so - // all thirteen trees, rain and snow included, were re-measured through createProject. - // Recomputed 2026-10-02 rebasing the FabCLI manual-login fallback onto develop: the shared - // threenative-assets skill ships in every scaffold, so all thirteen trees move. - // Recomputed 2026-10-02 on the merge of develop 114e268ef into the strata branch - // (PRD-466/467/468): the merged tree carries both sides' template bytes, so eleven trees were - // re-measured through createProject. Rain and snow carry none of this branch's template changes - // and kept develop's values. - // Measured through actual createProject trees after exposure/fog consolidation and restamping, - // then again on the merge carrying the WebGPU adapter-retention Three patch: the scaffolded - // `patches/three@0.185.1.patch` was the only byte that moved on top of the exposure/fog tree, so - // all thirteen trees moved again. Values below are the merged-tree measurement, not either side's. - // Recomputed 2026-10-30 (PRD-775): the six kits that ship the Unreal sky extend three's `Node` where r187 - // deprecated `TempNode`, so those six trees move; the ten without a gpuSky.ts keep their values. - // Recomputed 2026-10-30 (three 0.185.1 -> 0.187.0): the scaffolded `patches/three@0.187.0.patch` - // is again the only byte that moves on top of these trees — the r187 patch is a different file - // with a different name, and every kit pins and copies it — so all sixteen trees move once more. - // Recomputed on the PRD-478 merge into PR 473 (develop b12b257f1 + the runbook branch): the - // merged Three patch changes every kit; starter also carries the merged render source. - // Recomputed 2026-10-09 on the merge of develop (31 commits, PRD-494 sharded bundles) into the - // strata branch: measured through createProject on the merged tree. - // Recomputed 2026-10-10 after integrating develop 1a5314a1: measured through createProject on - // the merged tree, including its latest template changes. - // Recomputed after integrating develop 5f7143895 (PRD-609 particle bursts). - // Recomputed on the merge of develop 32f5072a0 (PRD-616, PRD-619) into the strata epic. - // Recomputed after regenerating the canonical Three patch from the source delta (PR 483 CI fix): - // only the copied patch changed in all thirteen trees. - // PRD-619: all kits name interpolation; starter adds the live-clock motion proof. - // Measured through createProject on the combined develop + PR483 merge tree. - // Re-measured 2026-10-18 through all thirteen createProject trees after merging develop and regenerating the combined Three patch. - // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - // Merged input heading, quaternion facing and progress import; measured through actual scaffold. - // Re-measured on the merges of develop 54ac77209, PR #583 and PR #574 into PR #483 (Strata): every tree carries both - // sides' template bytes, so all sixteen were measured by this test on the merged tree. - // PRD-560 (2026-10-10): the eleven FogExp2 kits carry height fog (heightFog.ts, sky.ts, AGENTS.md); - // rain and tower-defense had no height-fog change in that merge. Seven kits at the 150 m scale then - // took density 0.0018 so ridges read clearer than low ground (AC-1b). - // Recomputed through all thirteen actual createProject trees after merging develop 2d2124792 - // into PRD-571; these fingerprints cover both the exposure and landed base changes. - // Recomputed 2026-10-11 after merging develop b4e74627f into PRD-563: measured through - // createProject on the merged tree, including the GPU-class start tier in every kit. - // Recomputed 2026-10-11 for the PRD-563 render-chain tier fix: ten kits' medium presets now request - // the medium chain, the four flat-high scenarios gained family and software rows, and every - // render-chain scenario names the GPU families. Measured by the same actual-tree test; snow moves not. - // Recomputed 2026-10-11 for the PRD-563 grade table upload as half floats: only starter's grade.ts - // and postprocessing.ts changed, and the actual-tree test moved only starter. - // PRD-560: fingerprints include both height fog and PRD-571 histogram exposure. - // Recomputed 2026-10-11 on the merge of develop e6ed27869 into PRD-563: measured through - // createProject on the merged tree. - // PRD-566/569/609 merge: measured all thirteen trees; action-rpg carries emitParticle bursts and onCull instructions. - // Recomputed 2026-10-13: the onCull paragraph in action-rpg AGENTS.md folded to one line to meet the 100-line budget. - // PRD-565: only the copied canonical Three patch changes; substituting its parent bytes - // recovers all thirteen prior hashes. Measured through the actual createProject tree test. - // Recomputed 2026-10-15: puzzle drops its legacy pin and its Engine paragraph (PRD-617); the other twelve are unchanged. - // Recomputed 2026-10-15 through createProject after recovery: all kits now share the decoder-aware asset watch. - // Recomputed 2026-10-18: a queue-fenced renderer prepares PMREMNode textures asynchronously; only the patch bytes move. - // Recomputed 2026-10-18: the Three patch groups async PMREM face fences by GPU cost; only the patch bytes move, so all thirteen trees change. - // Recomputed 2026-10-18 merging develop f30f9b6d3 (PRD-478) into PR #493: the merged Three patch moves every tree. - // PRD-743 (2026-10-25): every template AGENTS.md gains the on-demand assets convention, so all thirteen trees move. - // Recomputed 2026-10-16 for PRD-617: puzzle's boot-failure scenario asserts the engine-neutral - // boot-failure text both engines now show, so only puzzle's tree moves. - // Recomputed 2026-10-25: action-rpg and shooter state the batching-projection opt-out in - // `threenative.config.ts`, the field core reads, instead of the untyped `renderer` block. - // Recomputed 2026-10-26 (PR #574 merges #572): the action-rpg draw fix, the rain terrain cache, - // the sailing and snow core-look migrations on top of the on-demand asset loader. - // Recomputed 2026-10-27 (PR #574 restart lane): action-rpg publishes `animation` and fits the swing - // to its clip; platformer binds R to restart. - // Recomputed 2026-10-28 (PR #580 merges PR #574 c71b3f5cf): the PRD-750 scaffold text on top of the base kits. - // Re-pinned 2026-10-28 (owner chose sharp AA): core high tier is 4x MSAA with no camera motion blur; - // these kits' AGENTS.md say so, and rts drops its TRAA and blur overrides. - // Re-pinned 2026-10-31: the starter crate gets a stacked-disc contact shadow (render/contactShadow.ts) - // that keeps its contact coverage above the 0.02 floor at the core +1 EV key. - // 2026-11-04 (PRD-777 on PRD-632): action-rpg AGENTS/CLAUDE name the spell factories in one line. Measured by this test. - "action-rpg": "9f8318901b385a1181eb2e005da0eeb77fd6436939780d1f33998e979282b4b4", - // PRD-732 (2026-10-23): the new Hop Garden kit, measured through createProject (method checked - // against platformer's unchanged value). Recomputed 2026-10-24 for the PRD-732 polish round: the - // new hero, the dust and sparkle effects and the effects scenario. Recomputed 2026-10-24 for - // polish round 3: the shaft camera, the planting, the hopper and thornback and their scenarios. - // Recomputed 2026-10-24 for the look round: terrain, castle, meadow, sky and world assertions - // (method checked against this kit's previous value, from a copy holding only this template). - // Recomputed 2026-10-24 for the double jump (chain window, press buffer, HUD hint, double-tap - // scenario), folding the shared textures, the play-gate scenarios and the footSlip stride bound - // (method checked against platformer, from a copy of the index). Recomputed 2026-10-24 for the - // mouse orbit camera, and for the planted-foot gait (no hip twist, walk lift, run blend). - // Recomputed 2026-10-24 for the shadow window: the 52 m map now travels 14 m ahead of the hero, - // so the castle and trees the camera sees keep their shadows; the 2048 map and shadow pass cost - // stay the same. - collectathon: "a602a8941e34a63a0c68faa1dba1e804a64e692b43b6e0dc001b19ca357244f4", - // PRD-733 (2026-10-23): the new Lantern Vale kit, measured through createProject. - // Recomputed 2026-10-24 for the owner perf round: the fixed-step catch-up cap and the gentler - // wind on the crowns, clusters and grass tufts. Only this tree moves. - // Recomputed 2026-10-26 (PRD-747 exposure): adventure, collectathon, kart, platformer, racing, runner, - // sailing and starter ship the shared kitExposure.ts meter (0 EV). - // Re-pinned 2026-10-26 (PRD-748 loading cover): adventure loses its `loaded` gate, rain - // reads state through `useUiStateDuringStartup`. - // Re-pinned 2026-10-27 (PRD-749 hud-clean): adventure, collectathon and rain HUD text reaches 3:1. - adventure: "f1d92acd124d9c1fc269b5ab3dbcae2098c3f971fb5103f70a2e6ebeaa4ab197", - // PRD-734 (2026-10-24): the new Clover Cup kart kit, measured through createProject. - kart: "c12dfb5b57f4c357a3b3c027170a225e5b3c390acb2f0752b47a35d1ce7b0dcb", - minimal: "54271d0cfcde7c8ec8805f03abf23a634596df50f6ea1cbd5c91e2f57d486a22", - platformer: "4e37e11800062715c30f3a0f6586915e2c2981dfe4f0fc2a970e2bc7237d3f76", - puzzle: "8731d6a9dea44eebe7f107d3c50b3be309dbee17c9ccc47b9292453251d1a24f", - racing: "46e3af84abe3ff7f4f8692fdbb121fc41651781f9e20e2b6bd2443fabb664cf9", - rain: "db0eacb64f103baf178eaf6135e5f25534ae6087e7aef8881008d9c96ffa49e1", - // Re-pinned 2026-10-28: the rts high tier turns camera motion blur off (an RTS pan smeared every unit). - // Re-pinned 2026-10-28 (PRD-765): rts becomes AstraCraft — painted ground, mesas, scrub, ACES 1.02 look and the command HUD; - // then its legacy-linear colours, gamma fog of war, no bloom or environment, and a frame-sized shadow window. - // Then merged develop d37156a87: render imports from #589 and lodPixelScale for army.sync. - // Re-pinned 2026-10-28 (PRD-765): units drawn between rules steps, AstraCraft's thin pixel-snapped bars, - // a blurred slate fog of war, sRGB-decoded environment colours and no vignette. - // Then the HUD state on a 50 ms clock and no per-frame instance bound (perf/rts-kit-frame 76baed0bc, 5ab6c8dce). - // Then judge rounds 2-3: AstraCraft crystals, thin selection rings, a one-texel shadow kernel, its (-66, 57) start and bar colours made before the first draw. - // Re-pinned 2026-10-29: the fog-of-war sheet comes from palette.ts, rts-build probes the fog ground is lit - // (it was a dark patch under a HUD card) and the top-location separator reaches 3:1 on the ink plate. - // Then the UI final pass: a counted selection band, garrison and blocked-exit cards, overview, hotkeys, edge-scroll toggle, refusal toasts (imports sorted by biome). - - // Re-pinned 2026-11-03 (PRD-617): the sun's shadow box travels with the runner (`lighting.follow`) - // and the engine pin goes, so the tree moves. Measured again the same day for the corrected - // `performance.maxTriangles` bound (45,000 to 54,000), which is a scenario file in the tree. - // Measured by this test. - rts: "7da721e1b8f308a8043c01d0214a79a07edb521fdfb9c3fe323b86193adc90c3", - runner: "6ab96315672f757823d153a2ce43790f92de00d44b015a80abc93f3ca810310d", - // Finite-height readiness plus hull, sail, and buoy interpolation from the current swell pose. - // Re-measured 2026-11-03 (PRD-617): its AGENTS/CLAUDE engine section no longer claims an engine - // pin the template does not carry. Measured by this test. - // Re-measured 2026-11-03 (PRD-617): `sails.playtest.json` sets `drawEvery: 60`, so the scenario - // asks the runner for 30 drawn frames per 1,800-tick hold instead of taking as many readback - // samples as the adapter happens to draw. Only this tree moves. - sailing: "ade5828cec36b5b3d12d7409258155d45fc739627e91645704c4f15775eb9152", - // Recomputed 2026-10-26 (PRD-746): the scene merges the town through mergeByMaterial (src/townBatch.ts). - shooter: "256093857202816f5507509d03df399b11ccb6304c1ecc87c1ab4054567c99b3", - snow: "cd1652b453879767759929158d5bd4aedfa2ef31df98a1af5cd94c1cce6c5004", - // Measured 2026-10-16 through createProject after starter's completed-work quality fallback fix. - // Recomputed 2026-10-16: starter's adaptiveQuality split its policy resolution into qualityPolicy.ts - // to stay under the 200-line render-file cap; only starter's tree moves. - // Recomputed 2026-10-23 on the merge of origin/develop into the PRD-570 lane: measured by this test on the merged tree. - // Recomputed 2026-10-23: the native render-chain scenario expects the Unreal finishing chain (grade and grain off). - // Recomputed 2026-10-31: core key 0.36, traced reflections, the crate contact blob, the portable trace type (PRD-633); measured by this test. - // Recomputed 2026-10-31 (PRD-775): temporalAAHooks.ts claims the 0.187 render pipeline's view - // offset and drives its callback lists, so starter's render source moves again. Measured by this test. - // Recomputed 2026-11-03 on the merge of the #606 queue commit into this branch: #605 split - // Play.ts's motion samples between presented frames and fixed steps, and moved two native - // playtests; #606 moved the render resolve. Starter is the one template both sides changed. - // Measured by this test. - starter: "c288e9b2834b0dd5c90a46279fefd3446ced4f0cc2a86f1a80315c32e83b0a3e", - "tower-defense": "a52c1b37795845e2d6ce9275d5f246b615fe39fe5ee8ba609cee50ea488d5b71", -}; - -const GENERATED_SCAFFOLD_METADATA = - /^(?:node_modules(?:\/|$)|dist(?:\/|$)|\.vite(?:\/|$)|coverage(?:\/|$)|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$)/u; - /** Stages a broken template in a throwaway copy of the template tree and hands the body its * root, so a negative control never edits the shipped templates. It used to edit them in place * and put them back: `createProject` resolved its own root, so there was nowhere else to stage @@ -485,188 +105,78 @@ async function withBrokenTemplateFile( } } -const COHORT_PIN = - /("(?:@threenative\/[\w-]+|create-threenative|threenative-[\w-]+)":\s*)"\d+\.\d+\.\d+(?:-[\w.]+)?"/gu; - -const FRAMEWORK_PATCH = /^patches\/[^/]+\.patch$/u; - -async function scaffoldTreeHash(directory: string): Promise { - const files: Array<[string, Buffer]> = []; - async function walk(current: string): Promise { - for (const entry of (await readdir(current, { withFileTypes: true })).sort((left, right) => - left.name.localeCompare(right.name), - )) { - const file = path.join(current, entry.name); - // A linked skill directory reports isDirectory() false, so it needs the stat to be walked - // like the real one — otherwise the tree hash silently drops half of every skill. - if (entry.isDirectory() || (entry.isSymbolicLink() && (await stat(file)).isDirectory())) { - await walk(file); - } else { - const relative = path.relative(directory, file); - if (!GENERATED_SCAFFOLD_METADATA.test(relative)) { - const bytes = await readFile(file); - // `pnpm release:prepare` moves the cohort's own pins on every release, so they hash as - // one placeholder: a version bump alone must not move a tree this test guards. - // A framework patch is a copy of the package's own patch, so a three or vite patch edit - // must not move every tree: the file hashes by name, and its copy is guarded on its own. - files.push([ - relative, - FRAMEWORK_PATCH.test(relative) - ? Buffer.from("") - : entry.name === "package.json" - ? Buffer.from(bytes.toString("utf8").replace(COHORT_PIN, '$1""')) - : bytes, - ]); - } - } - } - } - await walk(directory); - const hash = createHash("sha256"); - for (const [relative, contents] of files) { - hash.update(relative).update("\0").update(contents).update("\0"); - } - return hash.digest("hex"); -} - -const STARTER_PATHS = [ - // Ignores the asset compile step's generated outputs; the sources in assets/ ship. - ".gitignore", - ".codex/config.toml", - ".mcp.json", - "AGENTS.md", - "CLAUDE.md", - ".claude/settings.json", - ".claude/hooks/ponytail-context.mjs", - ".claude/hooks/discovery-gate.mjs", - ".claude/skills/ponytail/SKILL.md", - ".agents/skills/ponytail/SKILL.md", - ".codex/hooks.json", - "package.json", - "eslint.config.js", - "biome.json", - "patches/three@0.187.0.patch", - "patches/vite@8.2.0.patch", - "patches/@tailwindcss__node@4.3.3.patch", - "threenative.config.ts", - "tools/look.mjs", - "scripts/reference.mjs", - "scripts/visual-loop.mjs", - "index.html", - "tailwind.config.ts", - "tsconfig.json", - "src/style.css", - "vite.config.ts", - "src/game.ts", - "src/main.ts", - "src/scenes/Play.ts", - "src/render/lighting.ts", - "src/render/postprocessing.ts", - "src/render/qualityPolicy.ts", - "src/render/temporalAA.ts", - "src/render/temporalAAInput.ts", - "src/render/temporalAAResolve.ts", - "src/render/temporalCurrentArea.ts", - "src/render/temporalCurrentFootprint.ts", - "src/render/temporalCurrentFootprintMath.ts", - "src/render/temporalCurrentProducer.ts", - "src/render/temporalCurrentReplay.ts", - "src/render/temporalCurrentSelection.ts", - "src/render/temporalCurrentVisibility.ts", - "src/render/temporalResolve.ts", - "src/render/exposure.ts", - "src/render/volumetricFog.ts", - "src/render/volumetricFogOptions.ts", - "src/render/volumetricFogVolume.ts", - "src/render/volumetricFogTransport.ts", - "src/render/palette.ts", - "src/render/materials.ts", - "src/render/arena.ts", - "src/render/shapes.ts", - "src/render/camera.ts", - "src/render/easing.ts", - "src/render/sky.ts", - "src/render/pennant.ts", - "src/render/loading.ts", - "src/entities/Crate.ts", - "src/entities/Goal.ts", - "src/entities/Player.ts", - "src/ui/Hud.tsx", - "src/ui/Menu.tsx", - "src/ui/GameUi.tsx", - "src/ui/main.tsx", - "src/ui/App.tsx", - "src/state.ts", - // Ship the starter smoke, outcome, readiness and movement scenarios. Engine guards stay in - // `packages/create-threenative/template-playtests/starter/`, copied by `pnpm test:templates`. - "playtests/survives.playtest.json", - "playtests/movement.playtest.json", - "playtests/live/smooth-motion.playtest.json", - "native-playtests/smooth-motion.playtest.json", - "playtests/play.playtest.json", - "native-playtests/react-hud.playtest.json", - "playtests/production-readiness.playtest.json", - "assets/native-proof.glb", - "assets/native-proof.png", - "public/icon.png", - "assets/pickup.wav", - // PRD-449: no `agent-docs/` — the recipes ship in the installed `create-threenative`, and the - // "ships no reference bundle" test is what pins that. -]; - -const MINIMAL_RENDER_PATHS = [ - "src/render/palette.ts", - "src/render/camera.ts", - "src/render/sky.ts", - "src/render/lighting.ts", - "src/render/loading.ts", - "src/render/materials.ts", - "src/render/postprocessing.ts", -] as const; - -const PLATFORMER_PATHS = [ - "AGENTS.md", - "CLAUDE.md", - "package.json", - "threenative.config.ts", - "src/game.ts", - "src/main.ts", - "src/state.ts", - "src/scenes/Boot.ts", - "src/scenes/Play.ts", - "src/entities/Fox.ts", - "src/entities/Pickup.ts", - "src/entities/Walker.ts", - "src/level/Checkpoints.ts", - "src/level/Stage.ts", - "src/render/blocks.ts", - "src/render/camera.ts", - "src/render/fox.ts", - "src/render/lighting.ts", - "src/render/loading.ts", - "src/render/materials.ts", - "src/render/palette.ts", - "src/render/pickups.ts", - "src/render/postprocessing.ts", - "src/render/props.ts", - "src/render/sky.ts", - "src/render/skyEnvironment.ts", - "src/render/scenery.ts", - "src/render/walkers.ts", - "src/render/waterfall.ts", - "public/icon.png", - "playtests/coyote.playtest.json", - "playtests/collect.playtest.json", - "playtests/damage.playtest.json", - "playtests/hud.playtest.json", - "playtests/jump.playtest.json", - "playtests/move.playtest.json", - "playtests/respawn.playtest.json", - "playtests/stomp.playtest.json", - "playtests/survives.playtest.json", - "playtests/performance.playtest.json", - "playtests/native/touch-controls.playtest.json", -]; +// Two hashes per kit: everything the scaffolder built from a shared input, and the kit's own files. +// A shared edit (agent-docs, agent-files, template-assets, core's tables, the shared render sources) +// moves only `shared`; a kit edit moves only `kit`. `pnpm scaffold:pin` rewrites the block below and +// says which one moved, so a re-pin no longer needs a paragraph of history to be readable. +const SCAFFOLD_TREE_HASHES: Readonly> = { + // scaffold-pin:begin + "action-rpg": { + shared: "5ded345fc170c33eb5cd1016d3d507db22f75977e96d22bc5d853ef344dd54a1", + kit: "cec7fb971827b8646396ced75f9e09afba11dd40f86842ab6a6d23a382b6d555", + }, + adventure: { + shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", + kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", + }, + collectathon: { + shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", + kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", + }, + kart: { + shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", + kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", + }, + minimal: { + shared: "726a2dfc2ee2bc31d8a14ad4017bd0c5200c6f3d412da42f72a786e382538f40", + kit: "2f7dbf2352cac2038363f58c7b12f64f146888c07fe5e43991322c63f3cae48d", + }, + platformer: { + shared: "102bc060c7fb95d0f9c73548698ecb7b074da1d0ae18c34fac0c694360327934", + kit: "c3ef3455d4bb28ffcb1e41dccafc9825487656fb52509a7ead32288c3c9cdab5", + }, + puzzle: { + shared: "fdab6c0211c81a02e15585c656054d1522b226d4f42b239cdd1c2be2edfcaf42", + kit: "ead4323d112ff746925822adf89252afcdfca868dc901e5e28dc22658cd7b2dc", + }, + racing: { + shared: "7dad990dc4b5e32f65291e9a4abf15869523925c6c99a48f339d9cf3ed58b74a", + kit: "1db0b6cbeee4f63210d0b417b0d93407d4eaa79e02924d6e44cce18208f1fcf1", + }, + rain: { + shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", + kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", + }, + rts: { + shared: "3d8a785944d68b86617c5f3f2830aa10d3aa4232253cbec37384e4ae3aa46626", + kit: "fc0a64adbb6c54d74dcb109552cc9858dbea4ff20345975ecb014db850c20dad", + }, + runner: { + shared: "ff015ffe74e08c6c53389de429203f1ccd2d80c3b3ada692ed7138502dec3568", + kit: "0a8fa684a1b55730282d01afadabb0002bc539022a3512e5aae701a7abee2216", + }, + sailing: { + shared: "874749fe359ffd9c09c5e6b8998b6668291638ecb62eb6afb6adae454b0faeb6", + kit: "17da367f32e183d175dce54bbdbd5518f1d011340be7097ed232ed033ee75d2a", + }, + shooter: { + shared: "56150f8d5218e3451a8c0f37c9f2a09c7dd14832ddec639b68c02fb635e629f5", + kit: "7d0799033c0581748c3cf829964f7d13279eb00673f28b1297886ba08c983d8e", + }, + snow: { + shared: "49464aa62c14a341b94fb7a415737bb5d8928bd98bdc15d49c991a4c2af4458f", + kit: "5b9246cf09ad2cf9173b57ae03d643bd7f496dd1991cb937ff72e60b8804bc68", + }, + starter: { + shared: "442b50ad1a0dd69b4c9f6b37f68a83a0dec4ddd492a4311905de1dbb2a27faa4", + kit: "01169a529764e0c0cfb7550a198394a5fbb5f1ce9fe3ec36e86178016363ad5e", + }, + "tower-defense": { + shared: "e7d702333ce827b39aeeec007a4e08d49e5a62035906af93afa4323b2c04bdf9", + kit: "0548ac507a4f35462a3170919123fe0fbea10223da82f2256fe9fec6955c2ca8", + }, + // scaffold-pin:end +}; describe("create-threenative", () => { it("discovers manifests and generates its template help from them", () => { @@ -850,17 +360,18 @@ describe("create-threenative", () => { }, ); - it("keeps every no-install scaffold tree byte-stable against the PRD parent", async () => { + it("keeps every no-install scaffold tree byte-stable, in a shared and a kit hash", async () => { const root = await makeTempDir("threenative-scaffold-stability-"); try { - const actual: Record = {}; + const actual: Record = {}; for (const template of ALL_TEMPLATES) { const { target } = await createProject( { install: false, target: template, template }, root, ); - expect(PRD_201_PARENT_SCAFFOLD_HASHES[template]).toBeDefined(); - actual[template] = await scaffoldTreeHash(target); + expect(SCAFFOLD_TREE_HASHES[template]).toBeDefined(); + const buckets = await scaffoldTreeBuckets(target, template); + actual[template] = { kit: buckets.kit, shared: buckets.shared }; if (template === "platformer") { await expect(stat(path.join(target, "assets/sky.jpg"))).rejects.toThrow(/ENOENT/u); } @@ -897,7 +408,14 @@ describe("create-threenative", () => { expect(media).toEqual([]); } } - expect(actual).toEqual(PRD_201_PARENT_SCAFFOLD_HASHES); + // Two hashes per kit, so a stale pin says which side of the scaffold moved. + for (const [template, buckets] of Object.entries(actual)) { + expect(SCAFFOLD_TREE_HASHES[template], template).toEqual({ + kit: buckets.kit, + shared: buckets.shared, + }); + } + expect(Object.keys(actual).sort()).toEqual([...ALL_TEMPLATES].sort()); } finally { await rm(root, { force: true, recursive: true }); } @@ -1330,15 +848,9 @@ describe("create-threenative", () => { await readFile(path.join(repoRoot, source)), ); } - expect(STARTER_PATHS).toContain("playtests/survives.playtest.json"); - for (const relativePath of STARTER_PATHS) { - await expect( - readFile(path.join(result.target, relativePath), "utf8"), - ).resolves.toBeTruthy(); - } // The whole point of the cut (PRD-449): a new game proves itself with four scenarios, and - // the engine's own guards live outside the template. `readdir` rather than the STARTER_PATHS - // membership, so a file nobody added to the list still fails here. + // the engine's own guards live outside the template. The directories are the contract here; + // the tree pin owns which files must exist. expect( (await readdir(path.join(result.target, "playtests"))).filter((name) => name.endsWith(".playtest.json"), @@ -1447,18 +959,16 @@ describe("create-threenative", () => { } }); - it("should scaffold the minimal six-file render layer", async () => { + it("should scaffold the minimal kit with a render layer and a game entry", async () => { const root = await makeTempDir("threenative-minimal-render-"); try { const result = await createProject( { install: false, target: "minimal-look", template: "minimal" }, root, ); - for (const relativePath of MINIMAL_RENDER_PATHS) { - await expect( - readFile(path.join(result.target, relativePath), "utf8"), - ).resolves.toBeTruthy(); - } + // Not a list of names and not a frozen count: the allowlist owns which kinds of file may + // ship and the tree pin owns every byte, so this only holds the kit to having a look at all. + expect(await readdir(path.join(result.target, "src/render"))).not.toHaveLength(0); await expect(readFile(path.join(result.target, "src/game.ts"), "utf8")).resolves.toContain( "export default game", ); @@ -1692,11 +1202,6 @@ describe("create-threenative", () => { expect(result.template).toBe("platformer"); const packageJson = await readFile(path.join(result.target, "package.json"), "utf8"); expect(packageJson).not.toContain("catalog:"); - for (const relativePath of PLATFORMER_PATHS) { - await expect( - readFile(path.join(result.target, relativePath), "utf8"), - ).resolves.toBeTruthy(); - } await expect( readFile(path.join(result.target, "src/entities/Fox.ts"), "utf8"), ).resolves.toContain("FOX_FEEL"); diff --git a/scripts/scaffold-pin.ts b/scripts/scaffold-pin.ts new file mode 100644 index 000000000..fdc512e7f --- /dev/null +++ b/scripts/scaffold-pin.ts @@ -0,0 +1,127 @@ +/** + * Rewrites the scaffold pins: two hashes per kit, `shared` and `kit`. + * + * A re-pin is a deliberate act, so this prints which bucket moved for which kit and why it can only + * be one of them — a shared edit (agent-docs, agent-files, template-assets, core's tables, the shared + * render sources) moves `shared` in every kit that ships the same input; a kit edit moves one `kit`. + * `--check` writes nothing and exits 1 when a pin is stale, which is what a lane can run. + * + * pnpm scaffold:pin rewrite the pins in packages/create-threenative/__tests__/scaffold.spec.ts + * pnpm scaffold:pin --check print the stale pins and exit 1 + */ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { scaffoldTreeBuckets } from "../packages/create-threenative/__tests__/scaffold-tree.js"; +import { createProject, templateRoot } from "../packages/create-threenative/src/index.js"; +import { templateNames } from "./lib/repo-inventory.mjs"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SPEC = path.join(REPO, "packages", "create-threenative", "__tests__", "scaffold.spec.ts"); +const BEGIN = "// scaffold-pin:begin"; +const END = "// scaffold-pin:end"; + +export interface IScaffoldPin { + readonly shared: string; + readonly kit: string; +} + +/** Reads the pins a spec carries, so a re-pin compares against what ships rather than a fresh file. */ +export function readPins(source: string): Readonly> { + const block = source.slice(source.indexOf(BEGIN), source.indexOf(END)); + const pins: Record = {}; + for (const [, kit, shared, contents] of block.matchAll( + /^ {2}"([^"]+)": \{ shared: "([0-9a-f]{64})", kit: "([0-9a-f]{64})" \},$/gmu, + )) { + if (kit === undefined || shared === undefined || contents === undefined) continue; + pins[kit] = { kit: contents, shared }; + } + return pins; +} + +/** The table text the spec carries, one row per kit. */ +export function formatPins(pins: Readonly>): string { + return Object.entries(pins) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([kit, pin]) => ` ${JSON.stringify(kit)}: { shared: "${pin.shared}", kit: "${pin.kit}" },`, + ) + .join("\n"); +} + +async function measure(): Promise>> { + const root = await mkdtemp(path.join(os.tmpdir(), "threenative-scaffold-pin-")); + try { + const pins: Record = {}; + for (const template of templateNames()) { + const { target } = await createProject({ install: false, target: template, template }, root); + const buckets = await scaffoldTreeBuckets(target, template, templateRoot()); + pins[template] = { kit: buckets.kit, shared: buckets.shared }; + } + return pins; + } finally { + await rm(root, { force: true, recursive: true }); + } +} + +async function main(argv: readonly string[]): Promise { + const check = argv.includes("--check"); + const source = await readFile(SPEC, "utf8"); + const began = source.indexOf(BEGIN); + const ended = source.indexOf(END); + if (began === -1 || ended === -1) + throw new Error(`TN_SCAFFOLD_PIN: ${path.relative(REPO, SPEC)} carries no pin block.`); + const previous = readPins(source); + const measured = await measure(); + const kits = Object.keys(measured).sort(); + const missing = kits.filter((kit) => previous[kit] === undefined); + const moved = kits.filter( + (kit) => + previous[kit] !== undefined && + (previous[kit].shared !== measured[kit]?.shared || previous[kit].kit !== measured[kit]?.kit), + ); + const stale = [...missing, ...moved]; + for (const kit of stale) { + const before = previous[kit]; + const after = measured[kit]; + if (after === undefined) continue; + const which = + before === undefined + ? "new kit" + : before.shared === after.shared + ? "kit" + : before.kit === after.kit + ? "shared" + : "shared and kit"; + process.stdout.write(`stale ${kit}: ${which}\n`); + } + if (check) { + process.stdout.write( + stale.length === 0 + ? "scaffold pins are current\n" + : `${String(stale.length)} stale scaffold pin(s); run \`pnpm scaffold:pin\`\n`, + ); + return stale.length === 0 ? 0 : 1; + } + const table = `\n${formatPins(measured)}\n`; + await writeFile(SPEC, source.slice(0, began) + BEGIN + table + source.slice(ended)); + process.stdout.write( + `scaffold pins: ${String(kits.length)} kits, ${String(stale.length)} moved\n`, + ); + return 0; +} + +if ( + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} From 6a4e07facc74725654f9472427512b946506a511 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 6 Nov 2026 23:54:55 -0700 Subject: [PATCH 009/154] docs(PRD-833): name what phase 1 has to invert before the files can move --- .../PRD-833-template-mechanism-lives-once.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md index 2a2cdc4a3..7ec7a7ac5 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -1,6 +1,6 @@ # PRD-833 — Template mechanism lives once -**Status:** PARTIAL — Phase 3 landed; Phases 1 and 2 (mechanism into core, shared build plumbing) are open +**Status:** PARTIAL — Phase 3 landed; Phases 1 and 2 (mechanism into core, shared build plumbing) are open. Phase 1 has one blocker to invert first: `environmentSampling.ts` imports `isWebGLFallbackRenderer`, `materialLightingEnabled` and `IMaterialLightingEnvironment` from the kit's `quality.ts`, so the core module needs a parameter object (or the core WebGL predicate this phase also adds) before it can move. **Priority:** P1 — open Phase 2 deletes 84 template copies of 7 render-mechanism files, where a fix to one kit leaves 11 kits wrong with every test green. **Complexity:** 6 (MEDIUM); risk override: the moved files measure the environment and wire the backlight, so the owner's visual-judge rule applies **Owner:** ThreeNative maintainers From 962bc90d517a05ca03d6d9924466385ed7301181 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:27:46 -0700 Subject: [PATCH 010/154] refactor(core,create-threenative): the environment mechanism lives once, in core PRD-833 phase 1. The seven render files every kit copied byte-for-byte (environment measure, sample, cache and provenance, the backlight lane and its controls) move to @threenative/core/render/environment, together with the admission gate they already depended on: that gate was the one cross-boundary import, twelve kits held their own copy of it, and it is the WebGL-fallback predicate the phase also asked to unify. 84 copies are deleted and each kit's quality.ts is a three-line re-export. Every moved export carries the @situation tags a public capability needs. The charter checks were extended rather than bypassed: constraints.spec.ts admits the folder with a tighter assertion that it originates no material, light, colour or shader (only the sampling quad's material and the node twin of the game's own), and check-core-boundary admits the mechanism names with the same proof. looks.spec.ts and the RTS cost loader learned the one subpath. --- .../PRD-833-template-mechanism-lives-once.md | 8 +- packages/core/__tests__/constraints.spec.ts | 48 ++++ packages/core/capabilities.json | 229 ++++++++++++++++++ packages/core/package.json | 4 + packages/core/src/render-environment.ts | 45 ++++ .../render/environment}/backlightControls.ts | 10 + .../render/environment}/backlightMaterial.ts | 8 +- .../environment}/environmentContribution.ts | 0 .../environment}/environmentRadiance.ts | 7 + .../environment}/environmentSampleCache.ts | 11 + .../environment}/environmentSampling.ts | 11 +- .../environment}/environmentSnapshot.ts | 15 ++ packages/core/src/render/environment/gate.ts | 55 +++++ packages/core/tsup.config.ts | 1 + packages/create-threenative/AGENTS.md | 8 +- packages/create-threenative/CLAUDE.md | 8 +- .../backlight-material-lifecycle.spec.ts | 2 +- .../backlight-render-matrices.spec.ts | 2 +- .../__tests__/backlight-shared-graph.spec.ts | 2 +- .../fixtures/backlight-defaults/main.ts | 2 +- .../__tests__/looks.spec.ts | 7 +- .../__tests__/rts-runtime-cost.spec.ts | 28 +++ .../__tests__/scaffold.spec.ts | 48 ++-- .../__tests__/template-runtime-cost.spec.ts | 6 +- .../references/capability-reference.md | 147 +++++++++++ packages/create-threenative/capabilities.json | 229 ++++++++++++++++++ packages/create-threenative/src/index.ts | 11 +- .../src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../src/render/environmentSnapshot.ts | 53 ---- .../src/render/materialAssignments.ts | 2 +- .../action-rpg/src/render/materialLighting.ts | 10 +- .../action-rpg/src/render/quality.ts | 38 +-- .../templates/action-rpg/src/scenes/Play.ts | 2 +- .../minimal/src/render/backlightControls.ts | 66 ----- .../minimal/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../minimal/src/render/environmentSampling.ts | 193 --------------- .../minimal/src/render/environmentSnapshot.ts | 53 ---- .../minimal/src/render/materialAssignments.ts | 2 +- .../minimal/src/render/materialLighting.ts | 10 +- .../templates/minimal/src/render/quality.ts | 38 +-- .../templates/minimal/src/scenes/Play.ts | 2 +- .../src/render/backlightControls.ts | 66 ----- .../src/render/environmentContribution.ts | 188 -------------- .../src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../src/render/environmentSampling.ts | 193 --------------- .../src/render/materialAssignments.ts | 2 +- .../platformer/src/render/materialLighting.ts | 10 +- .../platformer/src/render/quality.ts | 38 +-- .../templates/platformer/src/scenes/Play.ts | 2 +- .../puzzle/src/render/backlightControls.ts | 66 ----- .../puzzle/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../puzzle/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../puzzle/src/render/environmentSampling.ts | 193 --------------- .../puzzle/src/render/environmentSnapshot.ts | 53 ---- .../puzzle/src/render/materialAssignments.ts | 2 +- .../puzzle/src/render/materialLighting.ts | 10 +- .../templates/puzzle/src/render/quality.ts | 38 +-- .../templates/puzzle/src/scenes/Play.ts | 2 +- .../racing/src/render/backlightControls.ts | 66 ----- .../racing/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../racing/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../racing/src/render/environmentSampling.ts | 193 --------------- .../racing/src/render/environmentSnapshot.ts | 53 ---- .../racing/src/render/materialAssignments.ts | 2 +- .../racing/src/render/materialLighting.ts | 10 +- .../templates/racing/src/render/quality.ts | 38 +-- .../templates/racing/src/scenes/Race.ts | 2 +- .../rts/src/render/backlightControls.ts | 66 ----- .../rts/src/render/backlightMaterial.ts | 178 -------------- .../rts/src/render/environmentContribution.ts | 188 -------------- .../rts/src/render/environmentRadiance.ts | 36 --- .../rts/src/render/environmentSampleCache.ts | 43 ---- .../rts/src/render/environmentSampling.ts | 193 --------------- .../rts/src/render/environmentSnapshot.ts | 53 ---- .../rts/src/render/materialAssignments.ts | 2 +- .../rts/src/render/materialLighting.ts | 10 +- .../templates/rts/src/render/quality.ts | 38 +-- .../templates/rts/src/scenes/Play.ts | 2 +- .../runner/src/render/backlightControls.ts | 66 ----- .../runner/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../runner/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../runner/src/render/environmentSampling.ts | 193 --------------- .../runner/src/render/environmentSnapshot.ts | 53 ---- .../runner/src/render/materialAssignments.ts | 2 +- .../runner/src/render/materialLighting.ts | 10 +- .../templates/runner/src/render/quality.ts | 38 +-- .../templates/runner/src/scenes/Run.ts | 2 +- .../sailing/src/render/backlightControls.ts | 66 ----- .../sailing/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../sailing/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../sailing/src/render/environmentSampling.ts | 193 --------------- .../sailing/src/render/environmentSetup.ts | 2 +- .../sailing/src/render/environmentSnapshot.ts | 53 ---- .../sailing/src/render/materialAssignments.ts | 2 +- .../sailing/src/render/materialLighting.ts | 10 +- .../templates/sailing/src/render/quality.ts | 38 +-- .../shooter/src/render/backlightControls.ts | 66 ----- .../shooter/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../shooter/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../shooter/src/render/environmentSampling.ts | 193 --------------- .../shooter/src/render/environmentSnapshot.ts | 53 ---- .../shooter/src/render/materialAssignments.ts | 2 +- .../shooter/src/render/materialLighting.ts | 10 +- .../templates/shooter/src/render/quality.ts | 38 +-- .../templates/shooter/src/scenes/Play.ts | 2 +- .../snow/src/render/backlightControls.ts | 66 ----- .../snow/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../snow/src/render/environmentRadiance.ts | 36 --- .../snow/src/render/environmentSampleCache.ts | 43 ---- .../snow/src/render/environmentSampling.ts | 193 --------------- .../snow/src/render/environmentSnapshot.ts | 53 ---- .../snow/src/render/materialAssignments.ts | 2 +- .../snow/src/render/materialLighting.ts | 10 +- .../templates/snow/src/render/quality.ts | 38 +-- .../starter/src/render/backlightControls.ts | 66 ----- .../starter/src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../starter/src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../starter/src/render/environmentSampling.ts | 193 --------------- .../starter/src/render/environmentSnapshot.ts | 53 ---- .../starter/src/render/materialAssignments.ts | 2 +- .../starter/src/render/materialLighting.ts | 10 +- .../templates/starter/src/render/quality.ts | 2 +- .../templates/starter/src/scenes/Play.ts | 2 +- .../src/render/backlightControls.ts | 66 ----- .../src/render/backlightMaterial.ts | 178 -------------- .../src/render/environmentContribution.ts | 188 -------------- .../src/render/environmentRadiance.ts | 36 --- .../src/render/environmentSampleCache.ts | 43 ---- .../src/render/environmentSampling.ts | 193 --------------- .../src/render/environmentSnapshot.ts | 53 ---- .../src/render/materialAssignments.ts | 2 +- .../src/render/materialLighting.ts | 10 +- .../tower-defense/src/render/quality.ts | 38 +-- .../tower-defense/src/scenes/Battle.ts | 2 +- .../__tests__/unreal-look-ao-reach.spec.ts | 10 +- scripts/__tests__/unreal-look-gi-tier.spec.ts | 14 +- scripts/api-surface.json | 15 ++ scripts/check-core-boundary.ts | 26 +- scripts/scaffold-pin.ts | 7 + .../scaffold}/render/rendererGate.ts | 0 tsconfig.json | 3 +- 158 files changed, 1110 insertions(+), 8830 deletions(-) create mode 100644 packages/core/src/render-environment.ts rename packages/{create-threenative/templates/action-rpg/src/render => core/src/render/environment}/backlightControls.ts (87%) rename packages/{create-threenative/templates/platformer/src/render => core/src/render/environment}/backlightMaterial.ts (94%) rename packages/{create-threenative/templates/action-rpg/src/render => core/src/render/environment}/environmentContribution.ts (100%) rename packages/{create-threenative/templates/minimal/src/render => core/src/render/environment}/environmentRadiance.ts (83%) rename packages/{create-threenative/templates/minimal/src/render => core/src/render/environment}/environmentSampleCache.ts (76%) rename packages/{create-threenative/templates/action-rpg/src/render => core/src/render/environment}/environmentSampling.ts (92%) rename packages/{create-threenative/templates/platformer/src/render => core/src/render/environment}/environmentSnapshot.ts (77%) create mode 100644 packages/core/src/render/environment/gate.ts delete mode 100644 packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/action-rpg/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/minimal/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/minimal/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/minimal/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/minimal/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/platformer/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/platformer/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/platformer/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/platformer/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/platformer/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/puzzle/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/racing/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/rts/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/runner/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/shooter/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/snow/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/starter/src/render/environmentSnapshot.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/backlightControls.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/environmentRadiance.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/environmentSampleCache.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/environmentSampling.ts delete mode 100644 packages/create-threenative/templates/tower-defense/src/render/environmentSnapshot.ts rename {packages/create-threenative/templates/starter/src => scripts/unreal-look/scaffold}/render/rendererGate.ts (100%) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md index 7ec7a7ac5..f629227eb 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -1,6 +1,6 @@ # PRD-833 — Template mechanism lives once -**Status:** PARTIAL — Phase 3 landed; Phases 1 and 2 (mechanism into core, shared build plumbing) are open. Phase 1 has one blocker to invert first: `environmentSampling.ts` imports `isWebGLFallbackRenderer`, `materialLightingEnabled` and `IMaterialLightingEnvironment` from the kit's `quality.ts`, so the core module needs a parameter object (or the core WebGL predicate this phase also adds) before it can move. +**Status:** PARTIAL — Phases 1 and 3 landed; Phase 2 (shared build plumbing) and AC-2 (the visual judge on starter, racing and snow) are open. **Priority:** P1 — open Phase 2 deletes 84 template copies of 7 render-mechanism files, where a fix to one kit leaves 11 kits wrong with every test green. **Complexity:** 6 (MEDIUM); risk override: the moved files measure the environment and wire the backlight, so the owner's visual-judge rule applies **Owner:** ThreeNative maintainers @@ -47,11 +47,11 @@ Report ids B1, F1, F2, F3, F4, F8, B2. Root `AGENTS.md` puts "plumbing every gam ## Execution Phases #### Phase 1: Mechanism into core -**Status:** NOT STARTED +**Status:** DONE **Files:** new `packages/core/src/render/environment/*.ts` and subpath export; edit `templates/*/src/render/` callers; delete the 84 copies. -- [ ] The core subpath owns the 7 files, and every kit imports it. `md5sum templates/*/src/render/environment*.ts` finds no file. proof: `pnpm --filter @threenative/core exec vitest run __tests__/` for the moved units, plus `pnpm --filter create-threenative exec vitest run __tests__/backlight-shared-graph.spec.ts` -- [ ] One core WebGL-fallback predicate; no `quality.ts` defines its own. proof: `rg -n 'function .*[Ww]ebgl[A-Za-z]*Fallback' packages/create-threenative/templates` returns nothing +- [x] The core subpath `@threenative/core/render/environment` owns the 7 files, plus the admission gate they needed; every kit imports it, and all 84 copies are deleted. The one cross-boundary import (`environmentSampling.ts` reading the kit's `quality.ts` for the gate) was inverted by moving the gate into the same subpath, so no parameter object was needed — the tier it compares is still the caller's. Wiring: tsup entry, `exports`, root `tsconfig` path. The kit-side proof is that a real build resolves it: `scaffold.spec.ts`'s recast case builds a scaffolded minimal kit with the new import. proof: `rg -n 'function .*[Ww]ebgl[A-Za-z]*Fallback' packages/create-threenative/templates` returns nothing; `pnpm --filter create-threenative exec vitest run __tests__/backlight-shared-graph.spec.ts __tests__/backlight-render-matrices.spec.ts __tests__/backlight-material-lifecycle.spec.ts __tests__/minimal-material-lighting.spec.ts __tests__/sailing-snow-material-lighting.spec.ts` → 39 passed; `npx vitest run packages/core/__tests__ packages/create-threenative/__tests__` → 7123 passed, 4 skipped, and `pnpm typecheck` → 0. +- [x] One core WebGL-fallback predicate; no `quality.ts` defines its own. Each kit's `quality.ts` is now a three-line re-export of `IMaterialLightingEnvironment`, `isWebGLFallbackRenderer` and `materialLightingEnabled` from the subpath, and the starter's `rendererGate.ts` is deleted. The charter and portability checks were extended deliberately, not bypassed: `constraints.spec.ts` admits `render/environment/**` with a tighter assertion that the block originates no material, light, colour or shader (only the sampling quad's own material and the node twin of the *game's* material, and tone mapping only as the readback's save/restore), and `check-core-boundary.ts` admits the mechanism names with the same proof. proof: the `rg` above returns nothing; `npx vitest run packages/core/__tests__/constraints.spec.ts` → 4 passed. #### Phase 2: One source for shared plumbing **Status:** NOT STARTED diff --git a/packages/core/__tests__/constraints.spec.ts b/packages/core/__tests__/constraints.spec.ts index 6ebd6cc90..9f43fefaa 100644 --- a/packages/core/__tests__/constraints.spec.ts +++ b/packages/core/__tests__/constraints.spec.ts @@ -291,6 +291,14 @@ describe("core constraints", () => { // AGENTS.md rule 3, second exception (owner, 2026-10-31): every `@threenative/core/spells` // factory ships a stock look, and each colour, size and timing is a named override. !file.startsWith("spells/") && + // PRD-833 moved the environment measure-and-sample mechanism and the backlight out of the + // twelve kits that held byte-identical copies. None of it originates appearance: it reads + // the scene's own environment, and `backlightMaterial` copies the *game's* material into a + // node twin (`new MeshStandardNodeMaterial().copy(source)`) and drives it from + // `materialColor`/`materialEmissive`/`materialRoughness`/`materialMetalness`, so the game + // still decides every value. The assertions below hold the block to that. + !file.startsWith("render/environment/") && + file !== "render-environment.ts" && // The spells' particle kind table; PRD-789 deletes it with the from-scratch spells. file !== "particle-kinds.ts" && file !== "index.ts", @@ -306,6 +314,46 @@ describe("core constraints", () => { expect(source).not.toMatch(/material|light|tonemapping|postprocessing|\.wgsl/iu); + // The environment mechanism (PRD-833): it may read the game's environment and copy the game's + // own material into a node twin, and it may not originate a material, a light, a colour or a + // shader of its own. `source` above excludes it, so this is what keeps the exclusion honest. + const environment = [ + "render-environment.ts", + ...[ + "backlightControls.ts", + "backlightMaterial.ts", + "environmentContribution.ts", + "environmentRadiance.ts", + "environmentSampleCache.ts", + "environmentSampling.ts", + "environmentSnapshot.ts", + "gate.ts", + ].map((file) => `render/environment/${file}`), + ] + .map((file) => + withoutComments( + readFileSync(path.join(sourceDirectory, file), "utf8").replace( + /export\s+type\s*\{[^}]*\}\s*from\s*["'][^"']+["'];?/gu, + " ", + ), + ), + ) + .join("\n"); + expect(environment).not.toMatch(/postprocessing|\.wgsl|new\s+\w*Light|new\s+Color/iu); + // Two shader materials, both of them plumbing: the sampling quad that reads the environment + // into an offscreen target (never attached to the scene), and the node twin of the *game's* + // material, whose every value is copied from it. No third one may appear. + expect(environment.match(/new\s+\w*Material/gu) ?? []).toHaveLength(2); + expect(environment).toMatch(/new MeshStandardNodeMaterial\(\)\.copy\(\w+\)/u); + expect(environment).toMatch(/new MeshBasicNodeMaterial\(/u); + expect(environment).toMatch(/materialColor|materialEmissive/u); + // Tone mapping appears only as the readback's own bracket: the sample is taken with tone + // mapping off and the renderer's previous value is put back in the same `finally`. No curve is + // chosen here - `applyRendererToneMapping` is the one writer of it. + const toneMappingUses = environment.match(/[^\n]*toneMapping[^\n]*/giu) ?? []; + expect(toneMappingUses).toHaveLength(6); + expect(toneMappingUses.join("\n")).not.toMatch(/ACES|AgX|Neutral|filmic|applyToneMapping/iu); + // `assets.ts` assigns compiler-produced textures to game-authored materials. It transports // appearance data but originates none: no material, light, colour, or shader may be created. const assets = readFileSync(path.join(sourceDirectory, "assets.ts"), "utf8"); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index d267c610c..512377228 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -4323,6 +4323,235 @@ "supersedes": [], "symbol": "View" }, + { + "aliases": [], + "constraints": [ + "every colour, gain and angular size is the caller's; this copies the game's material and adds light" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { … }", + "situations": [ + "give a material a launcher's glow without editing its colour, texture or roughness" + ], + "summary": "The game's material, wrapped in a node twin that adds the backlit sheen it was given.", + "supersedes": [], + "symbol": "backlightMaterial" + }, + { + "aliases": [], + "constraints": [ + "a sample whose source texture or intensity moved is dropped, so a stale one never reaches a material" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function cachedEnvironmentSample( renderer: object, scene: Scene, ): IEnvironmentSample | undefined { … }", + "situations": ["skip a second environment readback in the same frame"], + "summary": "The sample this renderer took of `scene`'s environment, if it is still current.", + "supersedes": [], + "symbol": "cachedEnvironmentSample" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function cacheEnvironmentSample( renderer: object, scene: Scene, sample: IEnvironmentSample, ): void { … }", + "situations": ["reuse one environment readback across the materials of a scene"], + "summary": "Remembers `sample` as this renderer's reading of `scene`'s environment.", + "supersedes": [], + "symbol": "cacheEnvironmentSample" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { … }", + "situations": ["reject an environment readback that belongs to a different sky"], + "summary": "The provenance of a scene's environment: which texture, at which intensity, in which colour space.", + "supersedes": [], + "symbol": "captureEnvironmentSnapshot" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function environmentSnapshotCurrent( scene: Scene, snapshot: IEnvironmentSourceSnapshot, ): boolean { … }", + "situations": ["hold a material's fill light to the sky that is on screen now"], + "summary": "Whether the scene's environment still matches the snapshot a sample was taken from.", + "supersedes": [], + "symbol": "environmentSnapshotCurrent" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function isWebGLFallbackRenderer(renderer: unknown): boolean { … }", + "situations": [ + "refuse a GPU-only lane on a WebGL fallback instead of drawing something wrong" + ], + "summary": "Whether a `WebGPURenderer` wrapper is running three's WebGL backend behind it.", + "supersedes": [], + "symbol": "isWebGLFallbackRenderer" + }, + { + "aliases": [], + "constraints": [ + "high tier, browser, hardware WebGPU, not mobile, not software, not a WebGL fallback" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function materialLightingEnabled( tier: QualityTier, environment: IMaterialLightingEnvironment, ): boolean { … }", + "situations": ["decide whether this renderer is the admitted material lane"], + "summary": "Whether `tier` may run the material-lighting lane in `environment`.", + "supersedes": [], + "symbol": "materialLightingEnabled" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { … }", + "situations": [ + "measure what a scene's environment actually contributes", + "sample the environment on the GPU without stalling the frame", + "keep an environment readback from reaching a frame it does not belong to", + "add a backlit sheen to a material without editing the material graph" + ], + "summary": "The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change).", + "supersedes": [], + "symbol": "measureEnvironment" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function reportEnvironmentContribution( scene: Scene, options: IContributionControls, measurement?: IEnvironmentMeasurement, ) { … }", + "situations": [ + "measure what a scene's environment actually contributes", + "sample the environment on the GPU without stalling the frame", + "keep an environment readback from reaching a frame it does not belong to", + "add a backlit sheen to a material without editing the material graph" + ], + "summary": "The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change).", + "supersedes": [], + "symbol": "reportEnvironmentContribution" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function resolveKey( controls: IBacklightControls, direction: Vector3, target: Vector3, camera: Camera = controls.camera, ) { … }", + "situations": ["point the backlight at the sun the scene actually has"], + "summary": "The light and camera a backlit material reads its key direction from.", + "supersedes": [], + "symbol": "resolveKey" + }, + { + "aliases": [], + "constraints": [ + "the sample must be the qualified 64x32 float RGBA readback; anything else is refused by name" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { … }", + "situations": [ + "turn an environment readback into the radiance a material's fill light needs", + "know why an environment sample could not be measured" + ], + "summary": "The mean radiance and colour of a bounded linear sample, or `unknown` with the reason.", + "supersedes": [], + "symbol": "sampledRadiance" + }, + { + "aliases": [], + "constraints": [ + "off unless the renderer is the admitted high-tier WebGPU lane; every refusal is reported by name", + "the readback is abandoned after the deadline and the frame keeps the previous answer" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export async function sampleEnvironment( renderer: unknown, scene: Scene, environment: IMaterialLightingEnvironment, ): Promise { … }", + "situations": [ + "measure what a scene's environment actually contributes without stalling the frame", + "give a material's fill light the environment's own radiance" + ], + "summary": "Measures and samples the scene's real environment on the GPU, bounded by a readback deadline.", + "supersedes": [], + "symbol": "sampleEnvironment" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function samplingSourceSupported(source: NonNullable): boolean { … }", + "situations": ["know why an environment was not sampled on the GPU"], + "summary": "Whether an environment texture's mapping and colour space are qualified for the bounded GPU sample.", + "supersedes": [], + "symbol": "samplingSourceSupported" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function validateControls(controls: IBacklightControls) { … }", + "situations": [ + "fail loudly instead of drawing an unlit material when a backlight has no key light" + ], + "summary": "Throws when a `IBacklightControls` is missing a light or names a non-finite gain.", + "supersedes": [], + "symbol": "validateControls" + }, { "aliases": [], "constraints": [ diff --git a/packages/core/package.json b/packages/core/package.json index 67bfcfdab..a7b5f755c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -71,6 +71,10 @@ "types": "./dist/world.d.ts", "import": "./dist/world.js" }, + "./render/environment": { + "types": "./dist/render-environment.d.ts", + "import": "./dist/render-environment.js" + }, "./net": { "types": "./dist/net.d.ts", "import": "./dist/net.js" diff --git a/packages/core/src/render-environment.ts b/packages/core/src/render-environment.ts new file mode 100644 index 000000000..c496d5cf5 --- /dev/null +++ b/packages/core/src/render-environment.ts @@ -0,0 +1,45 @@ +/** + * The environment measure-and-sample mechanism every kit repeats: what the scene's environment + * actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the + * provenance a stale readback is rejected by, and the material lane the backlight wires. + * + * This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit + * that wants a different bound passes a different one. It lives here because twelve kits held + * byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green + * (`c93b427d0` touched 552 files for one look change). + * + * @situation measure what a scene's environment actually contributes + * @situation sample the environment on the GPU without stalling the frame + * @situation keep an environment readback from reaching a frame it does not belong to + * @situation add a backlit sheen to a material without editing the material graph + */ +export { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./render/environment/environmentContribution.js"; +export { sampledRadiance } from "./render/environment/environmentRadiance.js"; +export { + cacheEnvironmentSample, + cachedEnvironmentSample, +} from "./render/environment/environmentSampleCache.js"; +export { + type IEnvironmentSample, + sampleEnvironment, +} from "./render/environment/environmentSampling.js"; +export { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, + samplingSourceSupported, +} from "./render/environment/environmentSnapshot.js"; +export { resolveKey, validateControls } from "./render/environment/backlightControls.js"; +export { + type IBacklightControls, + backlightMaterial, +} from "./render/environment/backlightMaterial.js"; +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "./render/environment/gate.js"; diff --git a/packages/create-threenative/templates/action-rpg/src/render/backlightControls.ts b/packages/core/src/render/environment/backlightControls.ts similarity index 87% rename from packages/create-threenative/templates/action-rpg/src/render/backlightControls.ts rename to packages/core/src/render/environment/backlightControls.ts index 2d7b94bbf..15e9a2b62 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/backlightControls.ts +++ b/packages/core/src/render/environment/backlightControls.ts @@ -10,6 +10,11 @@ function finiteColor(color: Color) { color.b >= 0 ); } +/** + * Throws when a `IBacklightControls` is missing a light or names a non-finite gain. + * + * @situation fail loudly instead of drawing an unlit material when a backlight has no key light + */ export function validateControls(controls: IBacklightControls) { if ( !Number.isFinite(controls.rimGain) || @@ -30,6 +35,11 @@ export function validateControls(controls: IBacklightControls) { "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", ); } +/** + * The light and camera a backlit material reads its key direction from. + * + * @situation point the backlight at the sun the scene actually has + */ export function resolveKey( controls: IBacklightControls, direction: Vector3, diff --git a/packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts b/packages/core/src/render/environment/backlightMaterial.ts similarity index 94% rename from packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts rename to packages/core/src/render/environment/backlightMaterial.ts index a26e4d8d5..33b00b53f 100644 --- a/packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts +++ b/packages/core/src/render/environment/backlightMaterial.ts @@ -48,10 +48,10 @@ export interface IBacklightControls { const graphs = new WeakMap>(); /** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. + * The game's material, wrapped in a node twin that adds the backlit sheen it was given. + * + * @situation give a material a launcher's glow without editing its colour, texture or roughness + * @constraint every colour, gain and angular size is the caller's; this copies the game's material and adds light */ export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { if ( diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentContribution.ts b/packages/core/src/render/environment/environmentContribution.ts similarity index 100% rename from packages/create-threenative/templates/action-rpg/src/render/environmentContribution.ts rename to packages/core/src/render/environment/environmentContribution.ts diff --git a/packages/create-threenative/templates/minimal/src/render/environmentRadiance.ts b/packages/core/src/render/environment/environmentRadiance.ts similarity index 83% rename from packages/create-threenative/templates/minimal/src/render/environmentRadiance.ts rename to packages/core/src/render/environment/environmentRadiance.ts index 7c489a520..cae6493df 100644 --- a/packages/create-threenative/templates/minimal/src/render/environmentRadiance.ts +++ b/packages/core/src/render/environment/environmentRadiance.ts @@ -5,6 +5,13 @@ const HEIGHT = 32; function unmeasured(reason: string): IEnvironmentMeasurement { return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; } +/** + * The mean radiance and colour of a bounded linear sample, or `unknown` with the reason. + * + * @situation turn an environment readback into the radiance a material's fill light needs + * @situation know why an environment sample could not be measured + * @constraint the sample must be the qualified 64x32 float RGBA readback; anything else is refused by name + */ export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); diff --git a/packages/create-threenative/templates/minimal/src/render/environmentSampleCache.ts b/packages/core/src/render/environment/environmentSampleCache.ts similarity index 76% rename from packages/create-threenative/templates/minimal/src/render/environmentSampleCache.ts rename to packages/core/src/render/environment/environmentSampleCache.ts index 403c2b131..47ae40a07 100644 --- a/packages/create-threenative/templates/minimal/src/render/environmentSampleCache.ts +++ b/packages/core/src/render/environment/environmentSampleCache.ts @@ -3,6 +3,12 @@ import type { Scene, Texture } from "three"; import type { IEnvironmentSample } from "./environmentSampling.js"; import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; const samples = new WeakMap>(); +/** + * The sample this renderer took of `scene`'s environment, if it is still current. + * + * @situation skip a second environment readback in the same frame + * @constraint a sample whose source texture or intensity moved is dropped, so a stale one never reaches a material + */ export function cachedEnvironmentSample( renderer: object, scene: Scene, @@ -13,6 +19,11 @@ export function cachedEnvironmentSample( ? copySample(sample) : undefined; } +/** + * Remembers `sample` as this renderer's reading of `scene`'s environment. + * + * @situation reuse one environment readback across the materials of a scene + */ export function cacheEnvironmentSample( renderer: object, scene: Scene, diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentSampling.ts b/packages/core/src/render/environment/environmentSampling.ts similarity index 92% rename from packages/create-threenative/templates/action-rpg/src/render/environmentSampling.ts rename to packages/core/src/render/environment/environmentSampling.ts index 5726a5341..ba4ef8468 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/environmentSampling.ts +++ b/packages/core/src/render/environment/environmentSampling.ts @@ -23,7 +23,7 @@ import { type IMaterialLightingEnvironment, isWebGLFallbackRenderer, materialLightingEnabled, -} from "./quality.js"; +} from "./gate.js"; export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { readonly measurement: IEnvironmentMeasurement; } @@ -31,7 +31,14 @@ export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; const WIDTH = 64; const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ +/** + * Measures and samples the scene's real environment on the GPU, bounded by a readback deadline. + * + * @situation measure what a scene's environment actually contributes without stalling the frame + * @situation give a material's fill light the environment's own radiance + * @constraint off unless the renderer is the admitted high-tier WebGPU lane; every refusal is reported by name + * @constraint the readback is abandoned after the deadline and the frame keeps the previous answer + */ export async function sampleEnvironment( renderer: unknown, scene: Scene, diff --git a/packages/create-threenative/templates/platformer/src/render/environmentSnapshot.ts b/packages/core/src/render/environment/environmentSnapshot.ts similarity index 77% rename from packages/create-threenative/templates/platformer/src/render/environmentSnapshot.ts rename to packages/core/src/render/environment/environmentSnapshot.ts index dd5cc8e16..08e5174a5 100644 --- a/packages/create-threenative/templates/platformer/src/render/environmentSnapshot.ts +++ b/packages/core/src/render/environment/environmentSnapshot.ts @@ -17,6 +17,11 @@ export interface IEnvironmentSourceSnapshot { readonly mapping: number | null; readonly image: unknown; } +/** + * The provenance of a scene's environment: which texture, at which intensity, in which colour space. + * + * @situation reject an environment readback that belongs to a different sky + */ export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { const source = scene.environment; return { @@ -29,6 +34,11 @@ export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnap image: source?.image ?? null, }; } +/** + * Whether the scene's environment still matches the snapshot a sample was taken from. + * + * @situation hold a material's fill light to the sky that is on screen now + */ export function environmentSnapshotCurrent( scene: Scene, snapshot: IEnvironmentSourceSnapshot, @@ -45,6 +55,11 @@ export function environmentSnapshotCurrent( ); } +/** + * Whether an environment texture's mapping and colour space are qualified for the bounded GPU sample. + * + * @situation know why an environment was not sampled on the GPU + */ export function samplingSourceSupported(source: NonNullable): boolean { return ( source.mapping === EquirectangularReflectionMapping && diff --git a/packages/core/src/render/environment/gate.ts b/packages/core/src/render/environment/gate.ts new file mode 100644 index 000000000..d5465649d --- /dev/null +++ b/packages/core/src/render/environment/gate.ts @@ -0,0 +1,55 @@ +/** + * Which renderers may run the material-lighting lane, and whether a renderer is running three's + * WebGL backend behind a WebGPU wrapper. + * + * Both questions are asked by the environment sampling mechanism beside this file, and both were + * answered in each template's `quality.ts` — twelve copies of the same predicate, so a fix to one + * left eleven kits admitting a renderer the fix refused. The tier they compare is the caller's, so + * a game that wants a different admission rule passes its own tier; nothing here decides how + * anything looks. + */ +import type { QualityTier } from "../../look/quality.js"; + +/** First admitted material lane: high desktop hardware WebGPU in the browser only. */ +export interface IMaterialLightingEnvironment { + readonly web: boolean; + readonly rendererKind: string; + readonly mobile?: boolean; + readonly software?: boolean; + readonly webglFallback?: boolean; +} + +/** + * Whether `tier` may run the material-lighting lane in `environment`. + * + * @situation decide whether this renderer is the admitted material lane + * @constraint high tier, browser, hardware WebGPU, not mobile, not software, not a WebGL fallback + */ +export function materialLightingEnabled( + tier: QualityTier, + environment: IMaterialLightingEnvironment, +): boolean { + return ( + tier === "high" && + environment.web && + environment.rendererKind === "webgpu" && + environment.mobile !== true && + environment.software !== true && + environment.webglFallback !== true + ); +} + +/** + * Whether a `WebGPURenderer` wrapper is running three's WebGL backend behind it. + * + * @situation refuse a GPU-only lane on a WebGL fallback instead of drawing something wrong + */ +export function isWebGLFallbackRenderer(renderer: unknown): boolean { + if (renderer === null || typeof renderer !== "object") return false; + const backend = Reflect.get(renderer, "backend"); + return ( + backend !== null && + typeof backend === "object" && + Reflect.get(backend, "isWebGLBackend") === true + ); +} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 8317f0995..a14350b5e 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/ui-layer.ts", "src/worker.ts", "src/world.ts", + "src/render-environment.ts", "src/net.ts", "src/spells/index.ts", "src/spells/ultra/index.ts", diff --git a/packages/create-threenative/AGENTS.md b/packages/create-threenative/AGENTS.md index db06825dc..7f55de3d0 100644 --- a/packages/create-threenative/AGENTS.md +++ b/packages/create-threenative/AGENTS.md @@ -63,11 +63,9 @@ template's Commands block is a fail-closed statement, not marketing; narrow it w ## Keep the tests in sync -Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites the two -hashes per kit in `__tests__/scaffold.spec.ts` and names which bucket moved), and possibly updating -`looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped template still contains what the -design promises. The allowlist in `scaffold-file-allowlist.spec.ts` decides which kinds of file may -ship; the hashes decide which bytes must. +Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites each +kit's two hashes, naming what moved), and possibly `looks.spec.ts` and `playtest.spec.ts` — the +only proof a template still holds what the design promises; the hashes pin bytes, the allowlist kinds. A bug fixed in one kit becomes one row of `packages/playtest/src/kit-contract.ts`, checked for every kit by `pnpm test:templates`. Each kit declares what the rows need in diff --git a/packages/create-threenative/CLAUDE.md b/packages/create-threenative/CLAUDE.md index 97a7cd98e..1463a2e77 100644 --- a/packages/create-threenative/CLAUDE.md +++ b/packages/create-threenative/CLAUDE.md @@ -65,11 +65,9 @@ template's Commands block is a fail-closed statement, not marketing; narrow it w ## Keep the tests in sync -Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites the two -hashes per kit in `__tests__/scaffold.spec.ts` and names which bucket moved), and possibly updating -`looks.spec.ts` and `playtest.spec.ts` — the only proof a shipped template still contains what the -design promises. The allowlist in `scaffold-file-allowlist.spec.ts` decides which kinds of file may -ship; the hashes decide which bytes must. +Adding, renaming, or deleting a template file means running `pnpm scaffold:pin` (it rewrites each +kit's two hashes, naming what moved), and possibly `looks.spec.ts` and `playtest.spec.ts` — the +only proof a template still holds what the design promises; the hashes pin bytes, the allowlist kinds. A bug fixed in one kit becomes one row of `packages/playtest/src/kit-contract.ts`, checked for every kit by `pnpm test:templates`. Each kit declares what the rows need in diff --git a/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts b/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts index 7e9ab0011..5b8f594fe 100644 --- a/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts +++ b/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts @@ -1,3 +1,4 @@ +import { sampleEnvironment } from "@threenative/core/render/environment"; import { BoxGeometry, Camera, @@ -13,7 +14,6 @@ import { Texture, } from "three"; import { afterEach, expect, test, vi } from "vitest"; -import { sampleEnvironment } from "../templates/starter/src/render/environmentSampling.js"; import { createMaterialLighting } from "../templates/starter/src/render/materialLighting.js"; import { materialLightingEnabled } from "../templates/starter/src/render/quality.js"; const desktop = { web: true, rendererKind: "webgpu", mobile: false, software: false }; diff --git a/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts b/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts index 6d1f6bf31..00a997bc5 100644 --- a/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts +++ b/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts @@ -1,3 +1,4 @@ +import { backlightMaterial } from "@threenative/core/render/environment"; import { Camera, Color, @@ -9,7 +10,6 @@ import { Vector3, } from "three"; import { expect, test, vi } from "vitest"; -import { backlightMaterial } from "../templates/starter/src/render/backlightMaterial.js"; interface IUniform { isUniformNode?: boolean; updateType?: string; diff --git a/packages/create-threenative/__tests__/backlight-shared-graph.spec.ts b/packages/create-threenative/__tests__/backlight-shared-graph.spec.ts index 4187de1f7..e63a2709b 100644 --- a/packages/create-threenative/__tests__/backlight-shared-graph.spec.ts +++ b/packages/create-threenative/__tests__/backlight-shared-graph.spec.ts @@ -12,7 +12,7 @@ import type Node from "three/src/nodes/core/Node.js"; import NodeFrame from "three/src/nodes/core/NodeFrame.js"; import { test } from "vitest"; type LiveNode = Node & { value: unknown }; -import { backlightMaterial } from "../templates/starter/src/render/backlightMaterial.js"; +import { backlightMaterial } from "@threenative/core/render/environment"; test("shared full graph preserves live render boundaries and independent material ownership", () => { const scene = new Scene(); const camera = new Camera(); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts b/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts index 5c86b752c..f79d130df 100644 --- a/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts @@ -1,3 +1,4 @@ +import { sampleEnvironment } from "@threenative/core/render/environment"; // Qualification only: no shipped default or starter source is changed. import { AnimationMixer, @@ -21,7 +22,6 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { type ICtx, Scene, defineGame } from "../../../../core/dist/index.js"; import { playtest } from "../../../../core/dist/playtest.js"; import { WorldEnvironment } from "../../../../core/src/look/world-environment.js"; -import { sampleEnvironment } from "../../../templates/starter/src/render/environmentSampling.js"; import { qualityPreset } from "../../../templates/starter/src/render/quality.js"; import { setupSky } from "../../../templates/starter/src/render/sky.js"; import { reportEnvironmentContribution } from "./environment.js"; diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index cc20515c1..61dfe97c3 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -227,8 +227,13 @@ describe("starter visual floor", () => { ); for (const entries of files) { for (const [name, source] of entries) { + // Two framework entries are the same admission the core boundary check makes: the engine + // root, and the environment mechanism PRD-833 moved out of the twelve kits that held copies + // of it. Everything else is still a framework reaching into the game's look. expect( - source.replaceAll('from "@threenative/core"', 'from "shared-core"'), + source + .replaceAll('from "@threenative/core"', 'from "shared-core"') + .replaceAll('from "@threenative/core/render/environment"', 'from "shared-core"'), name, ).not.toContain("@threenative/"); // Loading is the one generated surface that carries real startup behavior: safe-area diff --git a/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts b/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts index 0e56a7ecf..677623ae4 100644 --- a/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts @@ -154,6 +154,27 @@ function simFiles(directory: string): string[] { /** Where every source under these sentinels lives; a counted line is keyed from here, so * `sim/movement.ts:54` and `render/army.ts:316` name a file, not just a line. */ const SRC = path.resolve(import.meta.dirname, "../templates/rts/src"); +const CORE = path.resolve(import.meta.dirname, "../../../packages/core"); + +/** + * The source file behind a `@threenative/core/`, read from core's own `exports` map. + * + * The kit's render wiring imports the environment mechanism from core (PRD-833), and this spec + * transpiles the kit's sources itself rather than handing them to a bundler, so it has to resolve + * one framework import. Reading the export map keeps the mapping true when a subpath moves, and + * the package's `dist` entry is its `src` file by construction (flat entries, one per `exports` key). + */ +function coreSourceFor(subpath: string): string { + const manifest = JSON.parse(readFileSync(path.join(CORE, "package.json"), "utf8")) as { + exports: Record; + }; + const key = subpath.length === 0 ? "." : `./${subpath}`; + const target = manifest.exports[key]?.import; + if (typeof target !== "string") { + throw new Error(`TN_RTS_LOADER: @threenative/core exports no '${key}'.`); + } + return path.join(CORE, target.replace(/^\.\/dist\//u, "src/").replace(/\.js$/u, ".ts")); +} /** * The source text of the line a counted literal sits on, cached. Classifying an allocation by what @@ -259,6 +280,13 @@ function watchLiterals( transformers: { before: [transformer] }, }); const require_ = (specifier: string): unknown => { + // The kit's render wiring imports the environment mechanism from core (PRD-833). It is + // TypeScript source, so it loads through this same loader instead of the bare resolver. + // Only a subpath: the root entry is the spec's own stub, and loading core's `index.ts` here + // would pull the whole framework through a loader meant for the kit's simulation. + if (specifier.startsWith("@threenative/core/")) { + return load(coreSourceFor(specifier.slice("@threenative/core/".length))); + } if (!specifier.startsWith(".")) return externals[specifier] ?? bare(specifier); return load(path.join(path.dirname(file), specifier.replace(/\.js$/u, ".ts"))); }; diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 6c01d48c6..2c1b7bd65 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -112,8 +112,8 @@ async function withBrokenTemplateFile( const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { - shared: "5ded345fc170c33eb5cd1016d3d507db22f75977e96d22bc5d853ef344dd54a1", - kit: "cec7fb971827b8646396ced75f9e09afba11dd40f86842ab6a6d23a382b6d555", + shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", + kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", }, adventure: { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", @@ -128,52 +128,52 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", }, minimal: { - shared: "726a2dfc2ee2bc31d8a14ad4017bd0c5200c6f3d412da42f72a786e382538f40", - kit: "2f7dbf2352cac2038363f58c7b12f64f146888c07fe5e43991322c63f3cae48d", + shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", + kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", }, platformer: { - shared: "102bc060c7fb95d0f9c73548698ecb7b074da1d0ae18c34fac0c694360327934", - kit: "c3ef3455d4bb28ffcb1e41dccafc9825487656fb52509a7ead32288c3c9cdab5", + shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", + kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", }, puzzle: { - shared: "fdab6c0211c81a02e15585c656054d1522b226d4f42b239cdd1c2be2edfcaf42", - kit: "ead4323d112ff746925822adf89252afcdfca868dc901e5e28dc22658cd7b2dc", + shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", + kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", }, racing: { - shared: "7dad990dc4b5e32f65291e9a4abf15869523925c6c99a48f339d9cf3ed58b74a", - kit: "1db0b6cbeee4f63210d0b417b0d93407d4eaa79e02924d6e44cce18208f1fcf1", + shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", + kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", }, rain: { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", }, rts: { - shared: "3d8a785944d68b86617c5f3f2830aa10d3aa4232253cbec37384e4ae3aa46626", - kit: "fc0a64adbb6c54d74dcb109552cc9858dbea4ff20345975ecb014db850c20dad", + shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", + kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", }, runner: { - shared: "ff015ffe74e08c6c53389de429203f1ccd2d80c3b3ada692ed7138502dec3568", - kit: "0a8fa684a1b55730282d01afadabb0002bc539022a3512e5aae701a7abee2216", + shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", + kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", }, sailing: { - shared: "874749fe359ffd9c09c5e6b8998b6668291638ecb62eb6afb6adae454b0faeb6", - kit: "17da367f32e183d175dce54bbdbd5518f1d011340be7097ed232ed033ee75d2a", + shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", + kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", }, shooter: { - shared: "56150f8d5218e3451a8c0f37c9f2a09c7dd14832ddec639b68c02fb635e629f5", - kit: "7d0799033c0581748c3cf829964f7d13279eb00673f28b1297886ba08c983d8e", + shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", + kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", }, snow: { - shared: "49464aa62c14a341b94fb7a415737bb5d8928bd98bdc15d49c991a4c2af4458f", - kit: "5b9246cf09ad2cf9173b57ae03d643bd7f496dd1991cb937ff72e60b8804bc68", + shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", + kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", }, starter: { - shared: "442b50ad1a0dd69b4c9f6b37f68a83a0dec4ddd492a4311905de1dbb2a27faa4", - kit: "01169a529764e0c0cfb7550a198394a5fbb5f1ce9fe3ec36e86178016363ad5e", + shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", + kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", }, "tower-defense": { - shared: "e7d702333ce827b39aeeec007a4e08d49e5a62035906af93afa4323b2c04bdf9", - kit: "0548ac507a4f35462a3170919123fe0fbea10223da82f2256fe9fec6955c2ca8", + shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", + kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", }, // scaffold-pin:end }; diff --git a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts index 9c9133506..b3d471ff5 100644 --- a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts @@ -959,10 +959,8 @@ describe("generated template ordinary-frame runtime cost", () => { }); it("forwards a cached-sky sample to the controller entered while sampling was pending", async () => { - const sampling = await import("../templates/racing/src/render/environmentSampling.js"); - const { captureEnvironmentSnapshot } = await import( - "../templates/racing/src/render/environmentSnapshot.js" - ); + const sampling = await import("@threenative/core/render/environment"); + const { captureEnvironmentSnapshot } = await import("@threenative/core/render/environment"); const { Race } = await import("../templates/racing/src/scenes/Race.js"); const physics = await physicsFixture(RACING_GRAVITY); const context = sceneContext(physics.physics, Race.initialState); diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 84d1ae8fb..9feb9f832 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -3369,6 +3369,153 @@ const root = createCssUiRoot(); root.render(createElement("section", { className: "fixed inset-0 bg-zinc-900/90" }, "READY")); ``` +## `@threenative/core/render/environment` + +### `backlightMaterial` + +`function` — The game's material, wrapped in a node twin that adds the backlit sheen it was given. + +```ts +export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { … } +``` + +- **Use when:** give a material a launcher's glow without editing its colour, texture or roughness +- **Constraints:** every colour, gain and angular size is the caller's; this copies the game's material and adds light + +### `cachedEnvironmentSample` + +`function` — The sample this renderer took of `scene`'s environment, if it is still current. + +```ts +export function cachedEnvironmentSample( renderer: object, scene: Scene, ): IEnvironmentSample | undefined { … } +``` + +- **Use when:** skip a second environment readback in the same frame +- **Constraints:** a sample whose source texture or intensity moved is dropped, so a stale one never reaches a material + +### `cacheEnvironmentSample` + +`function` — Remembers `sample` as this renderer's reading of `scene`'s environment. + +```ts +export function cacheEnvironmentSample( renderer: object, scene: Scene, sample: IEnvironmentSample, ): void { … } +``` + +- **Use when:** reuse one environment readback across the materials of a scene + +### `captureEnvironmentSnapshot` + +`function` — The provenance of a scene's environment: which texture, at which intensity, in which colour space. + +```ts +export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { … } +``` + +- **Use when:** reject an environment readback that belongs to a different sky + +### `environmentSnapshotCurrent` + +`function` — Whether the scene's environment still matches the snapshot a sample was taken from. + +```ts +export function environmentSnapshotCurrent( scene: Scene, snapshot: IEnvironmentSourceSnapshot, ): boolean { … } +``` + +- **Use when:** hold a material's fill light to the sky that is on screen now + +### `isWebGLFallbackRenderer` + +`function` — Whether a `WebGPURenderer` wrapper is running three's WebGL backend behind it. + +```ts +export function isWebGLFallbackRenderer(renderer: unknown): boolean { … } +``` + +- **Use when:** refuse a GPU-only lane on a WebGL fallback instead of drawing something wrong + +### `materialLightingEnabled` + +`function` — Whether `tier` may run the material-lighting lane in `environment`. + +```ts +export function materialLightingEnabled( tier: QualityTier, environment: IMaterialLightingEnvironment, ): boolean { … } +``` + +- **Use when:** decide whether this renderer is the admitted material lane +- **Constraints:** high tier, browser, hardware WebGPU, not mobile, not software, not a WebGL fallback + +### `measureEnvironment` + +`function` — The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change). + +```ts +export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { … } +``` + +- **Use when:** measure what a scene's environment actually contributes · sample the environment on the GPU without stalling the frame · keep an environment readback from reaching a frame it does not belong to · add a backlit sheen to a material without editing the material graph + +### `reportEnvironmentContribution` + +`function` — The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change). + +```ts +export function reportEnvironmentContribution( scene: Scene, options: IContributionControls, measurement?: IEnvironmentMeasurement, ) { … } +``` + +- **Use when:** measure what a scene's environment actually contributes · sample the environment on the GPU without stalling the frame · keep an environment readback from reaching a frame it does not belong to · add a backlit sheen to a material without editing the material graph + +### `resolveKey` + +`function` — The light and camera a backlit material reads its key direction from. + +```ts +export function resolveKey( controls: IBacklightControls, direction: Vector3, target: Vector3, camera: Camera = controls.camera, ) { … } +``` + +- **Use when:** point the backlight at the sun the scene actually has + +### `sampledRadiance` + +`function` — The mean radiance and colour of a bounded linear sample, or `unknown` with the reason. + +```ts +export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { … } +``` + +- **Use when:** turn an environment readback into the radiance a material's fill light needs · know why an environment sample could not be measured +- **Constraints:** the sample must be the qualified 64x32 float RGBA readback; anything else is refused by name + +### `sampleEnvironment` + +`function` — Measures and samples the scene's real environment on the GPU, bounded by a readback deadline. + +```ts +export async function sampleEnvironment( renderer: unknown, scene: Scene, environment: IMaterialLightingEnvironment, ): Promise { … } +``` + +- **Use when:** measure what a scene's environment actually contributes without stalling the frame · give a material's fill light the environment's own radiance +- **Constraints:** off unless the renderer is the admitted high-tier WebGPU lane; every refusal is reported by name · the readback is abandoned after the deadline and the frame keeps the previous answer + +### `samplingSourceSupported` + +`function` — Whether an environment texture's mapping and colour space are qualified for the bounded GPU sample. + +```ts +export function samplingSourceSupported(source: NonNullable): boolean { … } +``` + +- **Use when:** know why an environment was not sampled on the GPU + +### `validateControls` + +`function` — Throws when a `IBacklightControls` is missing a light or names a non-finite gain. + +```ts +export function validateControls(controls: IBacklightControls) { … } +``` + +- **Use when:** fail loudly instead of drawing an unlit material when a backlight has no key light + ## `@threenative/core/spells` ### `CastPool` diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index d267c610c..512377228 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -4323,6 +4323,235 @@ "supersedes": [], "symbol": "View" }, + { + "aliases": [], + "constraints": [ + "every colour, gain and angular size is the caller's; this copies the game's material and adds light" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { … }", + "situations": [ + "give a material a launcher's glow without editing its colour, texture or roughness" + ], + "summary": "The game's material, wrapped in a node twin that adds the backlit sheen it was given.", + "supersedes": [], + "symbol": "backlightMaterial" + }, + { + "aliases": [], + "constraints": [ + "a sample whose source texture or intensity moved is dropped, so a stale one never reaches a material" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function cachedEnvironmentSample( renderer: object, scene: Scene, ): IEnvironmentSample | undefined { … }", + "situations": ["skip a second environment readback in the same frame"], + "summary": "The sample this renderer took of `scene`'s environment, if it is still current.", + "supersedes": [], + "symbol": "cachedEnvironmentSample" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function cacheEnvironmentSample( renderer: object, scene: Scene, sample: IEnvironmentSample, ): void { … }", + "situations": ["reuse one environment readback across the materials of a scene"], + "summary": "Remembers `sample` as this renderer's reading of `scene`'s environment.", + "supersedes": [], + "symbol": "cacheEnvironmentSample" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { … }", + "situations": ["reject an environment readback that belongs to a different sky"], + "summary": "The provenance of a scene's environment: which texture, at which intensity, in which colour space.", + "supersedes": [], + "symbol": "captureEnvironmentSnapshot" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function environmentSnapshotCurrent( scene: Scene, snapshot: IEnvironmentSourceSnapshot, ): boolean { … }", + "situations": ["hold a material's fill light to the sky that is on screen now"], + "summary": "Whether the scene's environment still matches the snapshot a sample was taken from.", + "supersedes": [], + "symbol": "environmentSnapshotCurrent" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function isWebGLFallbackRenderer(renderer: unknown): boolean { … }", + "situations": [ + "refuse a GPU-only lane on a WebGL fallback instead of drawing something wrong" + ], + "summary": "Whether a `WebGPURenderer` wrapper is running three's WebGL backend behind it.", + "supersedes": [], + "symbol": "isWebGLFallbackRenderer" + }, + { + "aliases": [], + "constraints": [ + "high tier, browser, hardware WebGPU, not mobile, not software, not a WebGL fallback" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function materialLightingEnabled( tier: QualityTier, environment: IMaterialLightingEnvironment, ): boolean { … }", + "situations": ["decide whether this renderer is the admitted material lane"], + "summary": "Whether `tier` may run the material-lighting lane in `environment`.", + "supersedes": [], + "symbol": "materialLightingEnabled" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { … }", + "situations": [ + "measure what a scene's environment actually contributes", + "sample the environment on the GPU without stalling the frame", + "keep an environment readback from reaching a frame it does not belong to", + "add a backlit sheen to a material without editing the material graph" + ], + "summary": "The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change).", + "supersedes": [], + "symbol": "measureEnvironment" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function reportEnvironmentContribution( scene: Scene, options: IContributionControls, measurement?: IEnvironmentMeasurement, ) { … }", + "situations": [ + "measure what a scene's environment actually contributes", + "sample the environment on the GPU without stalling the frame", + "keep an environment readback from reaching a frame it does not belong to", + "add a backlit sheen to a material without editing the material graph" + ], + "summary": "The environment measure-and-sample mechanism every kit repeats: what the scene's environment actually contributes, a bounded GPU sample of it with a cache and a readback deadline, the provenance a stale readback is rejected by, and the material lane the backlight wires. This is machinery, not a look: every colour, gain and threshold arrives as an argument, and a kit that wants a different bound passes a different one. It lives here because twelve kits held byte-identical copies — 9 084 lines — so a fix to one left eleven wrong with every gate green (`c93b427d0` touched 552 files for one look change).", + "supersedes": [], + "symbol": "reportEnvironmentContribution" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function resolveKey( controls: IBacklightControls, direction: Vector3, target: Vector3, camera: Camera = controls.camera, ) { … }", + "situations": ["point the backlight at the sun the scene actually has"], + "summary": "The light and camera a backlit material reads its key direction from.", + "supersedes": [], + "symbol": "resolveKey" + }, + { + "aliases": [], + "constraints": [ + "the sample must be the qualified 64x32 float RGBA readback; anything else is refused by name" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { … }", + "situations": [ + "turn an environment readback into the radiance a material's fill light needs", + "know why an environment sample could not be measured" + ], + "summary": "The mean radiance and colour of a bounded linear sample, or `unknown` with the reason.", + "supersedes": [], + "symbol": "sampledRadiance" + }, + { + "aliases": [], + "constraints": [ + "off unless the renderer is the admitted high-tier WebGPU lane; every refusal is reported by name", + "the readback is abandoned after the deadline and the frame keeps the previous answer" + ], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export async function sampleEnvironment( renderer: unknown, scene: Scene, environment: IMaterialLightingEnvironment, ): Promise { … }", + "situations": [ + "measure what a scene's environment actually contributes without stalling the frame", + "give a material's fill light the environment's own radiance" + ], + "summary": "Measures and samples the scene's real environment on the GPU, bounded by a readback deadline.", + "supersedes": [], + "symbol": "sampleEnvironment" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function samplingSourceSupported(source: NonNullable): boolean { … }", + "situations": ["know why an environment was not sampled on the GPU"], + "summary": "Whether an environment texture's mapping and colour space are qualified for the bounded GPU sample.", + "supersedes": [], + "symbol": "samplingSourceSupported" + }, + { + "aliases": [], + "constraints": [], + "example": "", + "importPath": "@threenative/core/render/environment", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function validateControls(controls: IBacklightControls) { … }", + "situations": [ + "fail loudly instead of drawing an unlit material when a backlight has no key light" + ], + "summary": "Throws when a `IBacklightControls` is missing a light or names a non-finite gain.", + "supersedes": [], + "symbol": "validateControls" + }, { "aliases": [], "constraints": [ diff --git a/packages/create-threenative/src/index.ts b/packages/create-threenative/src/index.ts index d7f4425dc..2d3247836 100644 --- a/packages/create-threenative/src/index.ts +++ b/packages/create-threenative/src/index.ts @@ -128,17 +128,12 @@ export function stampLoadingSource(canonical: string, template: string): string * kit editing it would be a mistake rather than a choice. * * `__tests__/shared-render-drift.spec.ts` fails on any render source that ships byte-identical in - * two or more kits and is missing here, so a new copy cannot land unrestamped. + * two or more kits and is missing here, so a new copy cannot land unrestamped. The environment + * measure-and-sample mechanism and the backlight used to be seven of these copies; they live in + * `@threenative/core/render/environment` now (PRD-833). */ export const SHARED_RENDER_SOURCES: readonly string[] = [ "src/render/atmosphere.ts", - "src/render/backlightControls.ts", - "src/render/backlightMaterial.ts", - "src/render/environmentContribution.ts", - "src/render/environmentRadiance.ts", - "src/render/environmentSampleCache.ts", - "src/render/environmentSampling.ts", - "src/render/environmentSnapshot.ts", "src/render/gpuSky.ts", "src/render/kitExposure.ts", "src/render/temporalDetailStage.ts", diff --git a/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts b/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/action-rpg/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts b/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts b/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/action-rpg/src/render/quality.ts b/packages/create-threenative/templates/action-rpg/src/render/quality.ts index d92b316d3..e7dff16b6 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/quality.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/quality.ts @@ -104,34 +104,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; 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..8af880235 100644 --- a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts +++ b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts @@ -7,6 +7,7 @@ import { isMobile, isTouchscreenAvailable, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { CastPool, SpellWorld, createFireball } from "@threenative/core/spells"; import { CollisionShape3D, type IPhysicsContext, buildStaticColliders } from "@threenative/physics"; import { type PerspectiveCamera, type Texture, Vector3 } from "three"; @@ -20,7 +21,6 @@ import { emitPlaytestEvent } from "../playtest-events.js"; import { loadProgress, saveProgress } from "../progress.js"; import { createDungeonCamera } from "../render/camera.js"; import { createDungeon, isSolid } from "../render/dungeon.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/minimal/src/render/backlightControls.ts b/packages/create-threenative/templates/minimal/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/minimal/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts b/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts b/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/minimal/src/render/environmentSampling.ts b/packages/create-threenative/templates/minimal/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/minimal/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/minimal/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/minimal/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/minimal/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts b/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts index 255e3ca19..e6142c233 100644 --- a/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/minimal/src/render/materialLighting.ts b/packages/create-threenative/templates/minimal/src/render/materialLighting.ts index 9240e084d..f8ff3a854 100644 --- a/packages/create-threenative/templates/minimal/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/minimal/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/minimal/src/render/quality.ts b/packages/create-threenative/templates/minimal/src/render/quality.ts index 964884332..3014971a4 100644 --- a/packages/create-threenative/templates/minimal/src/render/quality.ts +++ b/packages/create-threenative/templates/minimal/src/render/quality.ts @@ -47,34 +47,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { return { ...coreQualityPreset(tier), exposurePolicy: exposureSettings }; } -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/minimal/src/scenes/Play.ts b/packages/create-threenative/templates/minimal/src/scenes/Play.ts index 011051126..8549d6b0f 100644 --- a/packages/create-threenative/templates/minimal/src/scenes/Play.ts +++ b/packages/create-threenative/templates/minimal/src/scenes/Play.ts @@ -7,6 +7,7 @@ import { isMobile, isTouchscreenAvailable, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { Area3D, CollisionShape3D, @@ -26,7 +27,6 @@ import { import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js"; import { type IPlayerModel, Player } from "../entities/Player.js"; import { followCamera, setupCamera } from "../render/camera.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/platformer/src/render/backlightControls.ts b/packages/create-threenative/templates/platformer/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/platformer/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts b/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/platformer/src/render/environmentRadiance.ts b/packages/create-threenative/templates/platformer/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/platformer/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/platformer/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/platformer/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/platformer/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/platformer/src/render/environmentSampling.ts b/packages/create-threenative/templates/platformer/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/platformer/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts b/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/platformer/src/render/materialLighting.ts b/packages/create-threenative/templates/platformer/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/platformer/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/platformer/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/platformer/src/render/quality.ts b/packages/create-threenative/templates/platformer/src/render/quality.ts index 380126524..cd0ecef1b 100644 --- a/packages/create-threenative/templates/platformer/src/render/quality.ts +++ b/packages/create-threenative/templates/platformer/src/render/quality.ts @@ -36,34 +36,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/platformer/src/scenes/Play.ts b/packages/create-threenative/templates/platformer/src/scenes/Play.ts index c40bb7073..a299741e3 100644 --- a/packages/create-threenative/templates/platformer/src/scenes/Play.ts +++ b/packages/create-threenative/templates/platformer/src/scenes/Play.ts @@ -8,6 +8,7 @@ import { isTouchscreenAvailable, mergeByMaterial, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { type IPhysicsContext, buildStaticColliders } from "@threenative/physics"; import { type Group, @@ -25,7 +26,6 @@ import { Checkpoints } from "../level/Checkpoints.js"; import { STAGE_SPAWN, buildStage, makeRng } from "../level/Stage.js"; import { emitPlaytestEvent } from "../playtest-events.js"; import { type LensObstruction, followCamera, setupCamera } from "../render/camera.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/puzzle/src/render/backlightControls.ts b/packages/create-threenative/templates/puzzle/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts b/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts b/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentRadiance.ts b/packages/create-threenative/templates/puzzle/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/puzzle/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentSampling.ts b/packages/create-threenative/templates/puzzle/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/puzzle/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/puzzle/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts b/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts b/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/puzzle/src/render/quality.ts b/packages/create-threenative/templates/puzzle/src/render/quality.ts index c89861ddf..b4f5f8ae6 100644 --- a/packages/create-threenative/templates/puzzle/src/render/quality.ts +++ b/packages/create-threenative/templates/puzzle/src/render/quality.ts @@ -80,34 +80,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts index a263d1bdd..159fcd033 100644 --- a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts +++ b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts @@ -7,6 +7,7 @@ import { isMobile, isTouchscreenAvailable, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics"; import { BufferAttribute, @@ -23,7 +24,6 @@ import { Seal } from "../entities/Seal.js"; import { WARDEN_SPAWN, Warden } from "../entities/Warden.js"; import { createVaultCamera } from "../render/camera.js"; import { CRATE_SIZE } from "../render/crateShape.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/racing/src/render/backlightControls.ts b/packages/create-threenative/templates/racing/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/racing/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts b/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/racing/src/render/environmentContribution.ts b/packages/create-threenative/templates/racing/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/racing/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/racing/src/render/environmentRadiance.ts b/packages/create-threenative/templates/racing/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/racing/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/racing/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/racing/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/racing/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/racing/src/render/environmentSampling.ts b/packages/create-threenative/templates/racing/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/racing/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/racing/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/racing/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/racing/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/racing/src/render/materialAssignments.ts b/packages/create-threenative/templates/racing/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/racing/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/racing/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/racing/src/render/materialLighting.ts b/packages/create-threenative/templates/racing/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/racing/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/racing/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/racing/src/render/quality.ts b/packages/create-threenative/templates/racing/src/render/quality.ts index 53acbd2e0..5fd678ab1 100644 --- a/packages/create-threenative/templates/racing/src/render/quality.ts +++ b/packages/create-threenative/templates/racing/src/render/quality.ts @@ -69,34 +69,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/racing/src/scenes/Race.ts b/packages/create-threenative/templates/racing/src/scenes/Race.ts index 4620f9d7b..3549ecd64 100644 --- a/packages/create-threenative/templates/racing/src/scenes/Race.ts +++ b/packages/create-threenative/templates/racing/src/scenes/Race.ts @@ -6,6 +6,7 @@ import { isMobile, isTouchscreenAvailable, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import type { IPhysicsContext } from "@threenative/physics"; import { type PerspectiveCamera, type Texture, Vector3 } from "three"; import { type CarCtx, FEEL } from "../entities/CarBody.js"; @@ -13,7 +14,6 @@ import { RacingCar } from "../entities/RacingCar.js"; import { Rival } from "../entities/Rival.js"; import { emitPlaytestEvent } from "../playtest-events.js"; import { cameraBank, chaseCamera, setupCamera } from "../render/camera.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/rts/src/render/backlightControls.ts b/packages/create-threenative/templates/rts/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/rts/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts b/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/rts/src/render/environmentContribution.ts b/packages/create-threenative/templates/rts/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/rts/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/rts/src/render/environmentRadiance.ts b/packages/create-threenative/templates/rts/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/rts/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/rts/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/rts/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/rts/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/rts/src/render/environmentSampling.ts b/packages/create-threenative/templates/rts/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/rts/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/rts/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/rts/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/rts/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/rts/src/render/materialAssignments.ts b/packages/create-threenative/templates/rts/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/rts/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/rts/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/rts/src/render/materialLighting.ts b/packages/create-threenative/templates/rts/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/rts/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/rts/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/rts/src/render/quality.ts b/packages/create-threenative/templates/rts/src/render/quality.ts index a59b50ec1..f394ff222 100644 --- a/packages/create-threenative/templates/rts/src/render/quality.ts +++ b/packages/create-threenative/templates/rts/src/render/quality.ts @@ -133,34 +133,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/rts/src/scenes/Play.ts b/packages/create-threenative/templates/rts/src/scenes/Play.ts index a4d246413..11c73e6d7 100644 --- a/packages/create-threenative/templates/rts/src/scenes/Play.ts +++ b/packages/create-threenative/templates/rts/src/scenes/Play.ts @@ -7,6 +7,7 @@ import { isMobile, lodPixelScale, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { Object3D, type OrthographicCamera, type Texture, Vector2, Vector3 } from "three"; import { onSceneIntent } from "../orders.js"; import { createArmy } from "../render/army.js"; @@ -22,7 +23,6 @@ import { zoomRts, } from "../render/camera.js"; import { createEnvironment } from "../render/environment.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { type ISun, setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/runner/src/render/backlightControls.ts b/packages/create-threenative/templates/runner/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/runner/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts b/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/runner/src/render/environmentContribution.ts b/packages/create-threenative/templates/runner/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/runner/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/runner/src/render/environmentRadiance.ts b/packages/create-threenative/templates/runner/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/runner/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/runner/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/runner/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/runner/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/runner/src/render/environmentSampling.ts b/packages/create-threenative/templates/runner/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/runner/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/runner/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/runner/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/runner/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/runner/src/render/materialAssignments.ts b/packages/create-threenative/templates/runner/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/runner/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/runner/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/runner/src/render/materialLighting.ts b/packages/create-threenative/templates/runner/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/runner/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/runner/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/runner/src/render/quality.ts b/packages/create-threenative/templates/runner/src/render/quality.ts index 96d5e6224..b3a2cb7e8 100644 --- a/packages/create-threenative/templates/runner/src/render/quality.ts +++ b/packages/create-threenative/templates/runner/src/render/quality.ts @@ -54,34 +54,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/runner/src/scenes/Run.ts b/packages/create-threenative/templates/runner/src/scenes/Run.ts index d87a3d944..c3e42e61f 100644 --- a/packages/create-threenative/templates/runner/src/scenes/Run.ts +++ b/packages/create-threenative/templates/runner/src/scenes/Run.ts @@ -9,12 +9,12 @@ import { isMobile, isTouchscreenAvailable, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { type PerspectiveCamera, type Texture, Vector3 } from "three"; import { HITBOX, type IRunnerModel, Runner } from "../entities/Runner.js"; import type { RunnerPhysics } from "../physics.js"; import { chaseRunner, setupCamera } from "../render/camera.js"; import { createDustTrail } from "../render/dust.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/sailing/src/render/backlightControls.ts b/packages/create-threenative/templates/sailing/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/sailing/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts b/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts b/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentRadiance.ts b/packages/create-threenative/templates/sailing/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/sailing/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/sailing/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/sailing/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentSampling.ts b/packages/create-threenative/templates/sailing/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/sailing/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentSetup.ts b/packages/create-threenative/templates/sailing/src/render/environmentSetup.ts index 97c905fde..969683b63 100644 --- a/packages/create-threenative/templates/sailing/src/render/environmentSetup.ts +++ b/packages/create-threenative/templates/sailing/src/render/environmentSetup.ts @@ -1,6 +1,6 @@ +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; // Generated for you. Sample the loaded photograph without adding a duplicate sky to the game. import { Mesh, Scene } from "three"; -import { type IEnvironmentSample, sampleEnvironment } from "./environmentSampling.js"; import type { IMaterialLightingEnvironment } from "./quality.js"; import { setupSky } from "./sky.js"; let sample: IEnvironmentSample | undefined; diff --git a/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts b/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/sailing/src/render/materialLighting.ts b/packages/create-threenative/templates/sailing/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/sailing/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/sailing/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/sailing/src/render/quality.ts b/packages/create-threenative/templates/sailing/src/render/quality.ts index d636547c5..87f956666 100644 --- a/packages/create-threenative/templates/sailing/src/render/quality.ts +++ b/packages/create-threenative/templates/sailing/src/render/quality.ts @@ -70,34 +70,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { }; } -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/shooter/src/render/backlightControls.ts b/packages/create-threenative/templates/shooter/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/shooter/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts b/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts b/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentRadiance.ts b/packages/create-threenative/templates/shooter/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/shooter/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/shooter/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/shooter/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentSampling.ts b/packages/create-threenative/templates/shooter/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/shooter/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/shooter/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/shooter/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts b/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts index bd72d042d..b07970a08 100644 --- a/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/shooter/src/render/materialLighting.ts b/packages/create-threenative/templates/shooter/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/shooter/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/shooter/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/shooter/src/render/quality.ts b/packages/create-threenative/templates/shooter/src/render/quality.ts index c3261844e..89b5483c2 100644 --- a/packages/create-threenative/templates/shooter/src/render/quality.ts +++ b/packages/create-threenative/templates/shooter/src/render/quality.ts @@ -98,34 +98,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/shooter/src/scenes/Play.ts b/packages/create-threenative/templates/shooter/src/scenes/Play.ts index d43e4dddd..79e26f002 100644 --- a/packages/create-threenative/templates/shooter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/shooter/src/scenes/Play.ts @@ -7,6 +7,7 @@ import { getPlatform, isMobile, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics"; import type { AnimationClip, @@ -36,7 +37,6 @@ import { Target } from "../entities/Target.js"; import { TouchControls } from "../entities/TouchControls.js"; import { onAfterPhysics } from "../postPhysics.js"; import { DecalField, bulletHoleTexture } from "../render/decals.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { ImpactBursts, MuzzleFlash, MuzzleFlashPool, softCircleTexture } from "../render/gunfx.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; diff --git a/packages/create-threenative/templates/snow/src/render/backlightControls.ts b/packages/create-threenative/templates/snow/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/snow/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts b/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/snow/src/render/environmentContribution.ts b/packages/create-threenative/templates/snow/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/snow/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/snow/src/render/environmentRadiance.ts b/packages/create-threenative/templates/snow/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/snow/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/snow/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/snow/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/snow/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/snow/src/render/environmentSampling.ts b/packages/create-threenative/templates/snow/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/snow/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/snow/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/snow/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/snow/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/snow/src/render/materialAssignments.ts b/packages/create-threenative/templates/snow/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/snow/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/snow/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/snow/src/render/materialLighting.ts b/packages/create-threenative/templates/snow/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/snow/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/snow/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/snow/src/render/quality.ts b/packages/create-threenative/templates/snow/src/render/quality.ts index efc306b66..2927720bc 100644 --- a/packages/create-threenative/templates/snow/src/render/quality.ts +++ b/packages/create-threenative/templates/snow/src/render/quality.ts @@ -80,34 +80,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { }; } -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/starter/src/render/backlightControls.ts b/packages/create-threenative/templates/starter/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/starter/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts b/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/starter/src/render/environmentContribution.ts b/packages/create-threenative/templates/starter/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/starter/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/starter/src/render/environmentRadiance.ts b/packages/create-threenative/templates/starter/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/starter/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/starter/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/starter/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/starter/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/starter/src/render/environmentSampling.ts b/packages/create-threenative/templates/starter/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/starter/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/starter/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/starter/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/starter/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/starter/src/render/materialAssignments.ts b/packages/create-threenative/templates/starter/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/starter/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/starter/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/starter/src/render/materialLighting.ts b/packages/create-threenative/templates/starter/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/starter/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/starter/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/starter/src/render/quality.ts b/packages/create-threenative/templates/starter/src/render/quality.ts index 4fa69301b..3dab5e366 100644 --- a/packages/create-threenative/templates/starter/src/render/quality.ts +++ b/packages/create-threenative/templates/starter/src/render/quality.ts @@ -67,4 +67,4 @@ export { type IMaterialLightingEnvironment, isWebGLFallbackRenderer, materialLightingEnabled, -} from "./rendererGate.js"; +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index 3248e776e..e28809e9e 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -9,6 +9,7 @@ import { isTouchscreenAvailable, resolveTargetFps, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import { Area3D, CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics"; import { BufferAttribute, @@ -25,7 +26,6 @@ import { type IPlayerModel, PLAYER_STAND_Y, Player } from "../entities/Player.js import { createArena, platform } from "../render/arena.js"; import { createSpringArm } from "../render/camera.js"; import { pickupRiseEase } from "../render/easing.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting, shadeLikeSubstrate } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/packages/create-threenative/templates/tower-defense/src/render/backlightControls.ts b/packages/create-threenative/templates/tower-defense/src/render/backlightControls.ts deleted file mode 100644 index 2d7b94bbf..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/backlightControls.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Camera, Color, Object3D, Vector3 } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; -function finiteColor(color: Color) { - return ( - Number.isFinite(color.r) && - color.r >= 0 && - Number.isFinite(color.g) && - color.g >= 0 && - Number.isFinite(color.b) && - color.b >= 0 - ); -} -export function validateControls(controls: IBacklightControls) { - if ( - !Number.isFinite(controls.rimGain) || - controls.rimGain < 0 || - !Number.isFinite(controls.key.intensity) || - controls.key.intensity < 0 || - !finiteColor(controls.key.color) || - !Number.isFinite(controls.fillGain) || - controls.fillGain < 0 || - !Number.isFinite(controls.fillAngularSize) || - controls.fillAngularSize <= 0 || - controls.fillAngularSize > 1.5 || - !Number.isFinite(controls.fillDirection.lengthSq()) || - controls.fillDirection.lengthSq() === 0 || - !finiteColor(controls.fillColor) - ) - throw new Error( - "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", - ); -} -export function resolveKey( - controls: IBacklightControls, - direction: Vector3, - target: Vector3, - camera: Camera = controls.camera, -) { - validateControls(controls); - // Read the renderer/game-updated matrices, as upstream Three light uniforms do. - // Targets outside the scene must be updated by their owner before rendering. - // Never mutate shared light/ancestor matrices inside a material render callback. - target.setFromMatrixPosition(controls.key.target.matrixWorld); - direction.setFromMatrixPosition(controls.key.matrixWorld).sub(target); - if ( - !Number.isFinite(direction.x) || - !Number.isFinite(direction.y) || - !Number.isFinite(direction.z) - ) - throw new Error("Key endpoints must be finite."); - const lengthSq = direction.lengthSq(); - if (!Number.isFinite(lengthSq)) throw new Error("Key direction magnitude must be finite."); - if (lengthSq <= 1e-12) { - direction.set(0, 1, 0); - return 0; - } - direction.normalize(); - if (!controls.key.layers.test(camera.layers)) return 0; - let current: Object3D | null = controls.key; - while (current !== null) { - if (!current.visible) return 0; - if (current === controls.scene) return controls.key.intensity; - current = current.parent; - } - return 0; -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts b/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts deleted file mode 100644 index a26e4d8d5..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Generated for you. Ordinary Three.js; controls belong to your game. -import { - type Camera, - type Color, - type DirectionalLight, - Material, - type MeshStandardMaterial, - type Scene, - Vector3, -} from "three"; -import { - cameraViewMatrix, - clamp, - dot, - float, - materialColor, - materialEmissive, - materialMetalness, - materialRoughness, - max, - mix, - normalView, - positionViewDirection, - pow, - reflect, - smoothstep, - uniform, - vec3, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { resolveKey, validateControls } from "./backlightControls.js"; - -export interface IBacklightControls { - readonly key: DirectionalLight; - readonly scene: Scene; - readonly camera: Camera; - /** Mutable game-owned controls: zero disables the rim, independent of source reporting. */ - rimGain: number; - fillGain: number; - readonly fillColor: Color; - readonly fillDirection: Vector3; - /** Angular radius of the authored fill disc, in radians. */ - fillAngularSize: number; - /** Caller admits fill only after a measured-dark or missing environment is qualified. */ - fillAdmitted: boolean; -} - -const graphs = new WeakMap>(); - -/** - * Preserve the upstream standard PBR/normal/skinning paths and original material emissive. - * This is an additive artistic convention, not a physical subsurface model or IBL replacement. - * Cache at the caller to preserve shared source-material relationships; dispose only conversions - * it owns, never maps/source materials borrowed from a loaded model. - */ -export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { - if ( - ("isMeshPhysicalMaterial" in source && source.isMeshPhysicalMaterial === true) || - source.onBeforeCompile !== Material.prototype.onBeforeCompile - ) - throw new Error( - "Physical/custom standard materials require separate qualification; conversion refused.", - ); - validateControls(controls); - const material = new MeshStandardNodeMaterial().copy(source); - // NodeMaterial.copy skips Material.copy, and `alphaTest` lives in `_alphaTest`, which it never copies. - material.alphaTest = source.alphaTest; - let graph = graphs.get(controls); - if (!graph) { - graph = createGraph(controls); - graphs.set(controls, graph); - } - material.emissiveNode = graph; - return material; -} - -interface IRenderFrame { - readonly renderId?: number; - readonly frameId?: number; - readonly camera?: Camera | null; -} - -function createGraph(controls: IBacklightControls) { - // One render validates the controls and resolves the key once for all eight uniforms: each read can - // be a native bridge call, and a render reads the same values throughout. - let checkedFrame: IRenderFrame | undefined; - let checkedRender: number | undefined; - const check = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame === checkedFrame && render === checkedRender) return; - validateControls(controls); - checkedFrame = frame; - checkedRender = render; - }; - const keyDirectionValue = new Vector3(0, 1, 0); - const keyTargetValue = new Vector3(); - let keyFrame: IRenderFrame | undefined; - let keyRender: number | undefined; - let keyGainValue = 0; - const key = (frame: IRenderFrame) => { - const render = frame.renderId ?? frame.frameId; - if (frame !== keyFrame || render !== keyRender) { - keyGainValue = resolveKey( - controls, - keyDirectionValue, - keyTargetValue, - frame.camera ?? controls.camera, - ); - keyFrame = frame; - keyRender = render; - } - return keyGainValue; - }; - const rimGain = uniform(controls.rimGain).onRenderUpdate((frame) => { - check(frame); - return controls.rimGain; - }); - const fillGain = uniform(0).onRenderUpdate((frame) => { - check(frame); - return controls.fillAdmitted ? controls.fillGain : 0; - }); - const fillColor = uniform(controls.fillColor).onRenderUpdate((frame) => { - check(frame); - return controls.fillColor; - }); - const fillDirection = uniform(controls.fillDirection).onRenderUpdate((frame) => { - check(frame); - return controls.fillDirection; - }); - const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate((frame) => { - check(frame); - return controls.fillAngularSize; - }); - const keyColor = uniform(controls.key.color).onRenderUpdate((frame) => { - check(frame); - return controls.key.color; - }); - const keyDirection = uniform(keyDirectionValue).onRenderUpdate((frame) => { - key(frame); - return keyDirectionValue; - }); - // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. - const keyGain = uniform(0).onRenderUpdate((frame) => key(frame)); - const N = normalView.normalize(); - const V = positionViewDirection.normalize(); - const L = cameraViewMatrix.transformDirection(keyDirection); - const grazing = pow(float(1).sub(clamp(dot(N, V), 0, 1)), 3); - const behind = max(dot(L, V).negate(), 0); - // Suppress broad surfaces already facing the key; back-facing edges retain wrap. - // Tint by base reflectance so dark surfaces do not inherit a white emissive wash. - const surfaceBacklit = float(1).sub(smoothstep(-0.15, 0.15, dot(N, L))); - const rim = keyColor - .mul(keyGain) - .mul(rimGain) - .mul(grazing) - .mul(behind) - .mul(surfaceBacklit) - .mul(clamp(materialColor, 0, 1)); - - const F = cameraViewMatrix.transformDirection(fillDirection.normalize()); - const cosRadius = fillAngularSize.cos(); - // Broaden a rough surface's reflected disc with approximate solid-angle attenuation, - // not a proven energy-conserving BRDF. A positive transition avoids equal smoothstep bounds. - const spread = fillAngularSize.add(max(materialRoughness.mul(1.2), 0.005)).min(Math.PI / 2); - const reflectedDisc = smoothstep(spread.cos(), cosRadius, dot(reflect(V.negate(), N), F)); - const energy = float(1) - .sub(cosRadius) - .div(max(float(1).sub(spread.cos()), 0.0001)); - const reflectance = mix(vec3(0.04), materialColor, materialMetalness); - const specularFill = reflectance.mul(reflectedDisc).mul(energy); - const diffuseFill = materialColor - .mul(float(1).sub(materialMetalness)) - .mul(max(dot(N, F), 0)) - .mul(float(1).sub(cosRadius)) - .mul(2); - const fill = diffuseFill.add(specularFill).mul(fillColor).mul(fillGain); - return materialEmissive.add(rim).add(fill); -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts deleted file mode 100644 index a922353cc..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Generated for you. Measure actual source radiance; unreadable environments stay unknown. -import { - type Color, - EquirectangularReflectionMapping, - FloatType, - LinearSRGBColorSpace, - NoColorSpace, - RGBAFormat, - SRGBColorSpace, - type Scene, - type Texture, - UnsignedByteType, -} from "three"; - -export interface IEnvironmentMeasurement { - readonly status: "measured" | "unknown"; - readonly meanRadiance: number | null; - readonly meanRGB: readonly [number, number, number] | null; - readonly reason: string; -} -const unknown = (reason: string): IEnvironmentMeasurement => ({ - status: "unknown", - meanRadiance: null, - meanRGB: null, - reason, -}); -const linear = (value: number) => - value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - -function validSize(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0; -} -interface IReadableSource { - readonly status: "source"; - readonly data: Uint8Array | Float32Array; - readonly width: number; - readonly height: number; - readonly bytes: boolean; -} -function readSource( - texture: Texture, - maxSourceTexels: number, -): IReadableSource | IEnvironmentMeasurement { - if (texture.mapping !== EquirectangularReflectionMapping) - return unknown("Only CPU-readable equirectangular source radiance is qualified."); - const image: unknown = texture.image; - if ( - image === null || - typeof image !== "object" || - !("data" in image) || - !("width" in image) || - !("height" in image) - ) - return unknown("Environment source pixels are not CPU-readable; no radiance was inferred."); - const { data, width, height } = image; - if (!validSize(width) || !validSize(height)) - return unknown("Environment source dimensions are invalid."); - if (width * height > maxSourceTexels) - return unknown("Environment CPU sample budget exceeded; no radiance was inferred."); - if (texture.format !== RGBAFormat) - return unknown("Only explicit RGBA source samples are qualified."); - const bytes = texture.type === UnsignedByteType && data instanceof Uint8Array; - const floats = texture.type === FloatType && data instanceof Float32Array; - if (!bytes && !floats) return unknown("Environment source sample type is not qualified."); - if (data.length !== width * height * 4) - return unknown("Environment source samples do not match their dimensions."); - if ( - !new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(texture.colorSpace) - ) - return unknown("Environment source color-space conversion is not qualified."); - return { status: "source", data, width, height, bytes }; -} - -function accumulate(source: IReadableSource, srgb: boolean) { - const { data, width, height, bytes } = source; - const sums = [0, 0, 0]; - const weights = Array.from( - { length: height }, - (_, y) => Math.cos((Math.PI * y) / height) - Math.cos((Math.PI * (y + 1)) / height), - ); - for (let offset = 0; offset < data.length; offset += 4) { - const weight = weights[Math.floor(offset / (width * 4))] ?? 0; - for (let channel = 0; channel < 3; channel += 1) { - const sample = data[offset + channel]; - if (sample === undefined || !Number.isFinite(sample) || sample < 0) - return unknown("Environment radiance samples must be finite and nonnegative."); - const value = bytes ? sample / 255 : sample; - sums[channel] = (sums[channel] ?? 0) + (srgb ? linear(value) : value) * weight; - } - } - return { sums, weightSum: weights.reduce((a, b) => a + b, 0) * width }; -} - -/** - * CPU-readable equirectangular source radiance only; no DOM/canvas or invented JPEG mean. - * Rows are weighted by their exact spherical solid angle, rather than overweighting the poles. - * Arbitrary GPU/compressed/HTML/cube sources remain explicitly unknown until separately proven. - */ -export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { - if (!Number.isSafeInteger(maxSourceTexels) || maxSourceTexels < 1) - throw new Error("Environment CPU sample budget must be a positive safe integer."); - const texture = scene.environment; - if (texture === null) - return { - status: "measured", - meanRadiance: 0, - meanRGB: [0, 0, 0], - reason: "No environment is bound.", - }; - if (!Number.isFinite(scene.environmentIntensity) || scene.environmentIntensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - const source = readSource(texture, maxSourceTexels); - if (source.status !== "source") return source; - const accumulated = accumulate(source, texture.colorSpace === SRGBColorSpace); - if ("status" in accumulated) return accumulated; - const { sums, weightSum } = accumulated; - const meanRGB = sums.map((sum) => (sum / weightSum) * scene.environmentIntensity) as [ - number, - number, - number, - ]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unknown("Environment radiance accumulation overflowed."); - return { - status: "measured", - meanRadiance, - meanRGB, - reason: "Solid-angle-weighted linear source radiance, with active environment intensity.", - }; -} - -interface IContributionControls { - readonly darkThreshold: number; - readonly rimGain: number; - readonly fillGain: number; - readonly fillAdmitted: boolean; - readonly fillColor: Color; - readonly maxSourceTexels: number; -} -function fillReport(options: IContributionControls) { - const blackColorOverride = options.fillColor.toArray().every((value) => value === 0); - let reason = "analytic fill enabled by caller; contribution still needs pixel qualification"; - if (!options.fillAdmitted) reason = "not admitted by this qualification"; - else if (options.fillGain === 0) reason = "disabled by fillGain override"; - else if (blackColorOverride) reason = "disabled by black fillColor override"; - return { - requestedGain: options.fillGain, - admitted: options.fillAdmitted, - effectiveGain: options.fillAdmitted ? options.fillGain : 0, - color: options.fillColor.toArray(), - blackColorOverride, - reason, - }; -} -/** Overrides change the look, never whether its source contribution is measured/reported. */ -export function reportEnvironmentContribution( - scene: Scene, - options: IContributionControls, - measurement?: IEnvironmentMeasurement, -) { - if ( - ![ - options.darkThreshold, - options.rimGain, - options.fillGain, - ...options.fillColor.toArray(), - ].every((value) => Number.isFinite(value) && value >= 0) - ) - throw new Error("Backlight measurement controls must be finite and nonnegative."); - const measured = measurement ?? measureEnvironment(scene, options.maxSourceTexels); - const dark = - measured.meanRadiance === null ? null : measured.meanRadiance <= options.darkThreshold; - const environmentState = - scene.environment === null ? "missing" : dark === null ? "unknown" : dark ? "dark" : "bright"; - const report = { - ...measured, - scope: "scene environment source availability; not material-specific reflected contribution", - environmentState, - ibl: dark === null ? "unknown" : dark ? "non-contributing" : "contributing", - analyticFill: fillReport(options), - rimGain: options.rimGain, - fillGain: options.fillGain, - darkThreshold: options.darkThreshold, - }; - console.info(`TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(report)}`); - return report; -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentRadiance.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentRadiance.ts deleted file mode 100644 index 7c489a520..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/environmentRadiance.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Generated for you. Spherical accumulation of bounded linear source samples. -import type { IEnvironmentMeasurement } from "./environmentContribution.js"; -const WIDTH = 64; -const HEIGHT = 32; -function unmeasured(reason: string): IEnvironmentMeasurement { - return { status: "unknown", meanRadiance: null, meanRGB: null, reason }; -} -export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { - if (!(samples instanceof Float32Array) || samples.length !== WIDTH * HEIGHT * 4) - return unmeasured("GPU readback did not provide the qualified float RGBA sample layout."); - const sums = [0, 0, 0]; - const weights = Array.from( - { length: HEIGHT }, - (_, y) => Math.cos((Math.PI * y) / HEIGHT) - Math.cos((Math.PI * (y + 1)) / HEIGHT), - ); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) * WIDTH; - for (const [offset, value] of samples.entries()) { - const channel = offset % 4; - if (channel === 3) continue; - if (!Number.isFinite(value) || value < 0) - return unmeasured("GPU radiance samples must be finite and nonnegative."); - const weight = weights[Math.floor(offset / (WIDTH * 4))] ?? 0; - sums[channel] = (sums[channel] ?? 0) + value * weight; - } - const meanRGB = sums.map((sum) => (sum / totalWeight) * intensity) as [number, number, number]; - const meanRadiance = meanRGB[0] * 0.2126 + meanRGB[1] * 0.7152 + meanRGB[2] * 0.0722; - if (!meanRGB.every(Number.isFinite) || !Number.isFinite(meanRadiance)) - return unmeasured("GPU radiance accumulation overflowed."); - return { - status: "measured", - meanRGB, - meanRadiance, - reason: - "Estimated linear scene-environment source radiance from 64x32 GPU samples, spherical row weighting and active intensity; not material-specific BRDF contribution.", - }; -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentSampleCache.ts deleted file mode 100644 index 403c2b131..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/environmentSampleCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Generated for you. Weak ownership and full provenance guard successful source estimates. -import type { Scene, Texture } from "three"; -import type { IEnvironmentSample } from "./environmentSampling.js"; -import { environmentSnapshotCurrent } from "./environmentSnapshot.js"; -const samples = new WeakMap>(); -export function cachedEnvironmentSample( - renderer: object, - scene: Scene, -): IEnvironmentSample | undefined { - if (scene.environment === null) return undefined; - const sample = samples.get(renderer)?.get(scene.environment); - return sample !== undefined && environmentSnapshotCurrent(scene, sample) - ? copySample(sample) - : undefined; -} -export function cacheEnvironmentSample( - renderer: object, - scene: Scene, - sample: IEnvironmentSample, -): void { - if ( - sample.source === null || - sample.measurement.status !== "measured" || - !environmentSnapshotCurrent(scene, sample) - ) - return; - let byTexture = samples.get(renderer); - if (byTexture === undefined) { - byTexture = new WeakMap(); - samples.set(renderer, byTexture); - } - byTexture.set(sample.source, copySample(sample)); -} - -function copySample(sample: IEnvironmentSample): IEnvironmentSample { - return { - ...sample, - measurement: { - ...sample.measurement, - meanRGB: sample.measurement.meanRGB === null ? null : [...sample.measurement.meanRGB], - }, - }; -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentSampling.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentSampling.ts deleted file mode 100644 index 5726a5341..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/environmentSampling.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Generated for you. A bounded source-radiance estimate, never a material/BRDF measurement. -import { - FloatType, - LinearSRGBColorSpace, - NoToneMapping, - RGBAFormat, - RenderTarget, - type Scene, - type ToneMapping, -} from "three"; -import { texture, uv } from "three/tsl"; -import { MeshBasicNodeMaterial, QuadMesh, type WebGPURenderer } from "three/webgpu"; -import { type IEnvironmentMeasurement, measureEnvironment } from "./environmentContribution.js"; -import { sampledRadiance } from "./environmentRadiance.js"; -import { cacheEnvironmentSample, cachedEnvironmentSample } from "./environmentSampleCache.js"; -import { - type IEnvironmentSourceSnapshot, - captureEnvironmentSnapshot, - environmentSnapshotCurrent, - samplingSourceSupported, -} from "./environmentSnapshot.js"; -import { - type IMaterialLightingEnvironment, - isWebGLFallbackRenderer, - materialLightingEnabled, -} from "./quality.js"; -export interface IEnvironmentSample extends IEnvironmentSourceSnapshot { - readonly measurement: IEnvironmentMeasurement; -} -/** Editable bound on asynchronous readback, independent of the engine startup deadline. */ -const ENVIRONMENT_READBACK_TIMEOUT_MS = 1000; -const WIDTH = 64; -const HEIGHT = 32; -/** Call from the scene's awaited load(), after its actual environment is bound. */ -export async function sampleEnvironment( - renderer: unknown, - scene: Scene, - environment: IMaterialLightingEnvironment, -): Promise { - const snapshot = captureEnvironmentSnapshot(scene); - const { source, intensity } = snapshot; - const result = (measurement: IEnvironmentMeasurement): IEnvironmentSample => ({ - ...snapshot, - measurement, - }); - const unknown = (reason: string): IEnvironmentSample => - result({ status: "unknown", meanRadiance: null, meanRGB: null, reason }); - if (!materialLightingEnabled("high", environment) || isWebGLFallbackRenderer(renderer)) - return unknown("Environment GPU sampling is unqualified on this platform/renderer."); - if (source === null) return result(measureEnvironment(scene, WIDTH * HEIGHT)); - if (!samplingSourceSupported(source)) - return unknown("Environment mapping/color-space is not qualified for the bounded GPU sample."); - if (!Number.isFinite(intensity) || intensity < 0) - return unknown("Environment intensity must be finite and nonnegative."); - if (!samplingRendererSupported(renderer)) - return unknown("Raw Three.js renderer lacks the qualified sampling/readback methods."); - const raw = renderer as WebGPURenderer; - const cached = cachedEnvironmentSample(raw, scene); - if (cached !== undefined) return cached; - let state: { - target: ReturnType; - face: number; - mip: number; - toneMapping: ToneMapping; - colorSpace: string; - autoClear: boolean; - }; - try { - state = { - target: raw.getRenderTarget(), - face: raw.getActiveCubeFace(), - mip: raw.getActiveMipmapLevel(), - toneMapping: raw.toneMapping, - colorSpace: raw.outputColorSpace, - autoClear: raw.autoClear, - }; - } catch { - return unknown("Raw renderer state could not be saved; GPU sample skipped."); - } - const target = new RenderTarget(WIDTH, HEIGHT, { - type: FloatType, - format: RGBAFormat, - depthBuffer: false, - stencilBuffer: false, - }); - target.texture.colorSpace = LinearSRGBColorSpace; - const material = new MeshBasicNodeMaterial(); - material.toneMapped = false; - material.colorNode = texture(source, uv()).rgb; - const quad = new QuadMesh(material); - let pendingReadback: Promise | undefined; - let readbackSettled = false; - let cleaned = false; - function cleanup(): void { - if (cleaned) return; - cleaned = true; - try { - material.dispose(); - } finally { - target.dispose(); - } - // QuadMesh geometry is shared and borrowed. - } - try { - try { - raw.toneMapping = NoToneMapping; - raw.outputColorSpace = LinearSRGBColorSpace; - raw.autoClear = true; - raw.setRenderTarget(target, 0, 0); - quad.render(raw); - pendingReadback = Promise.resolve( - raw.readRenderTargetPixelsAsync(target, 0, 0, WIDTH, HEIGHT), - ).then( - (value) => { - readbackSettled = true; - return value; - }, - (error) => { - readbackSettled = true; - throw error; - }, - ); - } finally { - // No async gap while the shared renderer points at our scratch target. - try { - raw.setRenderTarget(state.target, state.face, state.mip); - } finally { - raw.toneMapping = state.toneMapping; - raw.outputColorSpace = state.colorSpace; - raw.autoClear = state.autoClear; - } - } - const outcome = await waitForReadback(pendingReadback); - if (outcome.timedOut) - return unknown( - `Environment GPU readback exceeded ${ENVIRONMENT_READBACK_TIMEOUT_MS} ms budget; scratch resources retained until readback settles.`, - ); - const samples = outcome.samples; - if (!environmentSnapshotCurrent(scene, snapshot)) - return unknown( - "Environment source/content/interpretation changed while GPU readback was pending; sample refused.", - ); - const sampled = result(sampledRadiance(samples, intensity)); - cacheEnvironmentSample(raw, scene, sampled); - return sampled; - } catch (error) { - return unknown(`Environment GPU sample/readback unavailable: ${String(error)}`); - } finally { - finishSampleCleanup(pendingReadback, readbackSettled, cleanup); - } -} - -async function waitForReadback( - readback: Promise, -): Promise<{ timedOut: true } | { timedOut: false; samples: unknown }> { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - readback.then((samples) => ({ timedOut: false as const, samples })), - new Promise<{ timedOut: true }>((resolve) => { - timer = setTimeout(() => resolve({ timedOut: true }), ENVIRONMENT_READBACK_TIMEOUT_MS); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -function samplingRendererSupported(renderer: unknown): renderer is WebGPURenderer { - return ( - renderer !== null && - typeof renderer === "object" && - Reflect.get(renderer, "isWebGPURenderer") === true && - [ - "render", - "getRenderTarget", - "getActiveCubeFace", - "getActiveMipmapLevel", - "setRenderTarget", - "readRenderTargetPixelsAsync", - ].every((name) => typeof Reflect.get(renderer, name) === "function") - ); -} - -function finishSampleCleanup( - readback: Promise | undefined, - settled: boolean, - cleanup: () => void, -): void { - // Timeout permits startup, but GPU work may still reference the target/material. - if (readback !== undefined && !settled) void readback.then(cleanup, cleanup); - else cleanup(); -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentSnapshot.ts deleted file mode 100644 index dd5cc8e16..000000000 --- a/packages/create-threenative/templates/tower-defense/src/render/environmentSnapshot.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Generated for you. Borrowed environment provenance for stale-readback rejection. -import { - EquirectangularReflectionMapping, - LinearSRGBColorSpace, - NoColorSpace, - SRGBColorSpace, - type Scene, - type Texture, -} from "three"; -/** Provenance stays outside the printable measurement; source/image references are borrowed. */ -export interface IEnvironmentSourceSnapshot { - readonly source: Texture | null; - readonly intensity: number; - readonly textureVersion: number | null; - readonly sourceVersion: number | null; - readonly colorSpace: string | null; - readonly mapping: number | null; - readonly image: unknown; -} -export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { - const source = scene.environment; - return { - source, - intensity: scene.environmentIntensity, - textureVersion: source?.version ?? null, - sourceVersion: source?.source.version ?? null, - colorSpace: source?.colorSpace ?? null, - mapping: source?.mapping ?? null, - image: source?.image ?? null, - }; -} -export function environmentSnapshotCurrent( - scene: Scene, - snapshot: IEnvironmentSourceSnapshot, -): boolean { - const source = scene.environment; - return ( - source === snapshot.source && - scene.environmentIntensity === snapshot.intensity && - (source?.version ?? null) === snapshot.textureVersion && - (source?.source.version ?? null) === snapshot.sourceVersion && - (source?.colorSpace ?? null) === snapshot.colorSpace && - (source?.mapping ?? null) === snapshot.mapping && - (source?.image ?? null) === snapshot.image - ); -} - -export function samplingSourceSupported(source: NonNullable): boolean { - return ( - source.mapping === EquirectangularReflectionMapping && - new Set([NoColorSpace, LinearSRGBColorSpace, SRGBColorSpace]).has(source.colorSpace) - ); -} diff --git a/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts b/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts index 3d12ed589..569cd0608 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts @@ -1,6 +1,6 @@ +import { type IBacklightControls, backlightMaterial } from "@threenative/core/render/environment"; // Generated for you. Own converted materials; preserve borrowed and authored assignments. import { Material, Mesh, MeshStandardMaterial, type Object3D, type Scene } from "three"; -import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; interface IMaterialAssignment { readonly mesh: Mesh; readonly original: Mesh["material"]; diff --git a/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts b/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts index 7b5e99973..22501ef90 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts @@ -1,16 +1,16 @@ -// Generated for you. Scene-owned material assignments; no engine appearance hooks. -import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; -import type { IBacklightControls } from "./backlightMaterial.js"; +import type { IBacklightControls } from "@threenative/core/render/environment"; import { type IEnvironmentMeasurement, measureEnvironment, reportEnvironmentContribution, -} from "./environmentContribution.js"; +} from "@threenative/core/render/environment"; import { type IEnvironmentSourceSnapshot, captureEnvironmentSnapshot, environmentSnapshotCurrent, -} from "./environmentSnapshot.js"; +} from "@threenative/core/render/environment"; +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import type { Camera, DirectionalLight, Object3D, Scene, Texture } from "three"; import { type ILightingConvention, createLightingConvention } from "./lighting.js"; import { createMaterialAssignments } from "./materialAssignments.js"; import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; diff --git a/packages/create-threenative/templates/tower-defense/src/render/quality.ts b/packages/create-threenative/templates/tower-defense/src/render/quality.ts index 992aa64dc..0a1b59dd3 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/quality.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/quality.ts @@ -48,34 +48,10 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } /** First admitted material lane: high desktop hardware WebGPU in the browser only. */ -export interface IMaterialLightingEnvironment { - readonly web: boolean; - readonly rendererKind: string; - readonly mobile?: boolean; - readonly software?: boolean; - readonly webglFallback?: boolean; -} -export function materialLightingEnabled( - tier: QualityTier, - environment: IMaterialLightingEnvironment, -): boolean { - return ( - tier === "high" && - environment.web && - environment.rendererKind === "webgpu" && - environment.mobile !== true && - environment.software !== true && - environment.webglFallback !== true - ); -} - -/** A WebGPURenderer wrapper may run an ordinary WebGL fallback backend. */ -export function isWebGLFallbackRenderer(renderer: unknown): boolean { - if (renderer === null || typeof renderer !== "object") return false; - const backend = Reflect.get(renderer, "backend"); - return ( - backend !== null && - typeof backend === "object" && - Reflect.get(backend, "isWebGLBackend") === true - ); -} +// The material lane's admission rule and the WebGL-fallback probe are the engine's: twelve kits +// held a copy each, so a fix to one left eleven admitting what it refused. +export { + type IMaterialLightingEnvironment, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "@threenative/core/render/environment"; diff --git a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts index b35be4279..46a91469d 100644 --- a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts +++ b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts @@ -6,6 +6,7 @@ import { getPlatform, isMobile, } from "@threenative/core"; +import { type IEnvironmentSample, sampleEnvironment } from "@threenative/core/render/environment"; import type { IPhysicsContext } from "@threenative/physics"; import { type Group, type Mesh, type PerspectiveCamera, type Texture, Vector3 } from "three"; import { @@ -34,7 +35,6 @@ import { Enemy } from "../enemies/Enemy.js"; import { drainIntents } from "../intents.js"; import { emitPlaytestEvent } from "../playtest-events.js"; import { setupCamera } from "../render/camera.js"; -import { type IEnvironmentSample, sampleEnvironment } from "../render/environmentSampling.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; import { createMaterialLighting } from "../render/materialLighting.js"; diff --git a/scripts/__tests__/unreal-look-ao-reach.spec.ts b/scripts/__tests__/unreal-look-ao-reach.spec.ts index 6afffe18a..7cefb9838 100644 --- a/scripts/__tests__/unreal-look-ao-reach.spec.ts +++ b/scripts/__tests__/unreal-look-ao-reach.spec.ts @@ -19,13 +19,9 @@ async function loadQuality(): Promise<{ copyFileSync(join(SCAFFOLD, "quality.ts"), join(dir, "quality.ts")); // `quality.ts` reads the level's fixed exposure from `atmosphere.ts` (PRD-646 round 6). copyFileSync(join(SCAFFOLD, "atmosphere.ts"), join(dir, "atmosphere.ts")); - copyFileSync( - join( - __dirname, - "../../packages/create-threenative/templates/starter/src/render/rendererGate.ts", - ), - join(dir, "rendererGate.ts"), - ); + // The scaffold's own gate, not a borrowed one: the starter's copy is the engine's now + // (`@threenative/core/render/environment`), and this capture harness stays independent of it. + copyFileSync(join(SCAFFOLD, "rendererGate.ts"), join(dir, "rendererGate.ts")); return import(/* @vite-ignore */ pathToFileURL(join(dir, "quality.ts")).href); } diff --git a/scripts/__tests__/unreal-look-gi-tier.spec.ts b/scripts/__tests__/unreal-look-gi-tier.spec.ts index e73f96c2c..d7ba22f4e 100644 --- a/scripts/__tests__/unreal-look-gi-tier.spec.ts +++ b/scripts/__tests__/unreal-look-gi-tier.spec.ts @@ -6,9 +6,10 @@ import { makeTempDirSync } from "../../test-support/temp-dir.js"; import { LEVEL_EXPOSURE } from "../unreal-look/scaffold/render/atmosphere.js"; /** - * The scaffold copies the starter's `rendererGate.ts` beside `quality.ts`, which re-exports it. The - * return type names only what this spec reads: a `typeof import()` of `quality.ts` would pull the - * scaffold's own imports (`./grade.js`, `./rendererGate.js`) into the repository typecheck. + * The scaffold's own `rendererGate.ts` is copied beside `quality.ts`, which re-exports it; the + * framework's copy moved to `@threenative/core/render/environment` (PRD-833) and this capture harness + * stays independent of the engine. The return type names only what this spec reads: a + * `typeof import()` of `quality.ts` would pull the scaffold's own imports into the typecheck. */ async function loadQuality(): Promise<{ giPreset(tier: string): object; @@ -25,11 +26,10 @@ async function loadQuality(): Promise<{ join(__dirname, "../unreal-look/scaffold/render/atmosphere.ts"), join(dir, "atmosphere.ts"), ); + // The scaffold's own gate, not a borrowed one: the starter's copy is the engine's now + // (`@threenative/core/render/environment`), and this capture harness stays independent of it. copyFileSync( - join( - __dirname, - "../../packages/create-threenative/templates/starter/src/render/rendererGate.ts", - ), + join(__dirname, "../unreal-look/scaffold/render/rendererGate.ts"), join(dir, "rendererGate.ts"), ); return import(/* @vite-ignore */ pathToFileURL(join(dir, "quality.ts")).href); diff --git a/scripts/api-surface.json b/scripts/api-surface.json index 7b49c155b..137d62ab9 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -47,6 +47,7 @@ "./playtest", "./react", "./react-css", + "./render/environment", "./spells", "./spells/ultra", "./ui-layer", @@ -65,6 +66,20 @@ "@threenative/core/react#supportedStyleKeys": "export function supportedStyleKeys(): readonly string[] { … }", "@threenative/core/react#Text": "export function Text(props: ITextProps): ReactNode { … }", "@threenative/core/react#View": "export function View(props: IViewProps): ReactNode { … }", + "@threenative/core/render/environment#backlightMaterial": "export function backlightMaterial(source: MeshStandardMaterial, controls: IBacklightControls) { … }", + "@threenative/core/render/environment#cachedEnvironmentSample": "export function cachedEnvironmentSample( renderer: object, scene: Scene, ): IEnvironmentSample | undefined { … }", + "@threenative/core/render/environment#cacheEnvironmentSample": "export function cacheEnvironmentSample( renderer: object, scene: Scene, sample: IEnvironmentSample, ): void { … }", + "@threenative/core/render/environment#captureEnvironmentSnapshot": "export function captureEnvironmentSnapshot(scene: Scene): IEnvironmentSourceSnapshot { … }", + "@threenative/core/render/environment#environmentSnapshotCurrent": "export function environmentSnapshotCurrent( scene: Scene, snapshot: IEnvironmentSourceSnapshot, ): boolean { … }", + "@threenative/core/render/environment#isWebGLFallbackRenderer": "export function isWebGLFallbackRenderer(renderer: unknown): boolean { … }", + "@threenative/core/render/environment#materialLightingEnabled": "export function materialLightingEnabled( tier: QualityTier, environment: IMaterialLightingEnvironment, ): boolean { … }", + "@threenative/core/render/environment#measureEnvironment": "export function measureEnvironment(scene: Scene, maxSourceTexels: number): IEnvironmentMeasurement { … }", + "@threenative/core/render/environment#reportEnvironmentContribution": "export function reportEnvironmentContribution( scene: Scene, options: IContributionControls, measurement?: IEnvironmentMeasurement, ) { … }", + "@threenative/core/render/environment#resolveKey": "export function resolveKey( controls: IBacklightControls, direction: Vector3, target: Vector3, camera: Camera = controls.camera, ) { … }", + "@threenative/core/render/environment#sampledRadiance": "export function sampledRadiance(samples: unknown, intensity: number): IEnvironmentMeasurement { … }", + "@threenative/core/render/environment#sampleEnvironment": "export async function sampleEnvironment( renderer: unknown, scene: Scene, environment: IMaterialLightingEnvironment, ): Promise { … }", + "@threenative/core/render/environment#samplingSourceSupported": "export function samplingSourceSupported(source: NonNullable): boolean { … }", + "@threenative/core/render/environment#validateControls": "export function validateControls(controls: IBacklightControls) { … }", "@threenative/core/spells/ultra#bakeNoise": "export function bakeNoise( { … }", "@threenative/core/spells/ultra#createFx": "export function createFx(o: IFxOptions) { … }", "@threenative/core/spells/ultra#groundShade": "export function groundShade(wp: V3, surface: IGroundSurface) { … }", diff --git a/scripts/check-core-boundary.ts b/scripts/check-core-boundary.ts index e95e1fbb9..c487f0b78 100644 --- a/scripts/check-core-boundary.ts +++ b/scripts/check-core-boundary.ts @@ -58,10 +58,30 @@ const RENDER_PORTABLE_CORE_SYMBOLS: ReadonlySet = new Set([ // PRD-632: Unreal's Substrate slab diffuse is the default look of a rough surface; the game turns it // off per material (`material.roughDiffuse = false`) or per tree (`useRoughDiffuse(root, false)`). "useRoughDiffuse", + // PRD-833: the environment measure-and-sample mechanism and the backlight lane moved out of the + // twelve kits that held byte-identical copies. None of it decides how anything looks — every + // colour, gain, bound and threshold is an argument — and the kit still owns the call, the policy + // tier and every material it wires. Keeping the copies in `src/render/` is what let a fix to one + // kit leave eleven wrong with every gate green, so the portable set admits the mechanism. + "IBacklightControls", + "IEnvironmentMeasurement", + "IEnvironmentSample", + "IEnvironmentSourceSnapshot", + "IMaterialLightingEnvironment", + "backlightMaterial", + "captureEnvironmentSnapshot", + "environmentSnapshotCurrent", + "isWebGLFallbackRenderer", + "materialLightingEnabled", + "measureEnvironment", + "reportEnvironmentContribution", + "sampleEnvironment", ]); +// `import { … }`, `import type { … }`, `export { … }` and `export type { … }`: a named re-export of a +// whitelisted symbol is the same admission as importing it, whether the file uses it or passes it on. const FRAMEWORK_IMPORT = - /(?:import\s+(?:type\s+)?|export\s+type\s+)\{([^}]*)\}\s+from\s+["']@threenative\/([^"']+)["'];?/gu; + /(?:import|export)\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["']@threenative\/([^"']+)["'];?/gu; function importedNames(clause: string): readonly string[] { return clause @@ -84,7 +104,9 @@ function importedNames(clause: string): readonly string[] { */ function withoutPortableImports(source: string): string { return source.replaceAll(FRAMEWORK_IMPORT, (statement, clause: string, specifier: string) => { - if (specifier !== "core") return statement; + // `core` or one of its subpaths (`core/world`, `core/render/environment`): the rule is which + // package a kit's look may reach into, not which entry point of it. + if (specifier.split("/")[0] !== "core") return statement; const names = importedNames(clause); if (names.length === 0) return statement; return names.every((name) => RENDER_PORTABLE_CORE_SYMBOLS.has(name)) ? "" : statement; diff --git a/scripts/scaffold-pin.ts b/scripts/scaffold-pin.ts index fdc512e7f..d998ee812 100644 --- a/scripts/scaffold-pin.ts +++ b/scripts/scaffold-pin.ts @@ -96,6 +96,13 @@ async function main(argv: readonly string[]): Promise { : "shared and kit"; process.stdout.write(`stale ${kit}: ${which}\n`); } + // A pin that moved for every kit moved on a shared input, and the running kitting says so: one + // line the operator can read instead of sixteen. + if (stale.length === kits.length && kits.length > 1) { + process.stdout.write( + "every kit moved: a shared input changed (agent-files, template-assets, core's tables)\n", + ); + } if (check) { process.stdout.write( stale.length === 0 diff --git a/packages/create-threenative/templates/starter/src/render/rendererGate.ts b/scripts/unreal-look/scaffold/render/rendererGate.ts similarity index 100% rename from packages/create-threenative/templates/starter/src/render/rendererGate.ts rename to scripts/unreal-look/scaffold/render/rendererGate.ts diff --git a/tsconfig.json b/tsconfig.json index dbfa769e9..f3d96a17d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "@threenative/core/playtest": ["./packages/core/src/playtest.ts"], "@threenative/physics": ["./packages/physics/src/index.ts"], "@threenative/playtest": ["./packages/playtest/src/index.ts"], - "@threenative/ui": ["./packages/ui/src/index.ts"] + "@threenative/ui": ["./packages/ui/src/index.ts"], + "@threenative/core/render/environment": ["./packages/core/src/render-environment.ts"] } }, "include": [ From 9b6de81c390b7bd6004bc4c06587891706e8f73a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:28:30 -0700 Subject: [PATCH 011/154] chore(scaffold): re-pin every kit for the capability manifest the pre-commit hook regenerated --- .../__tests__/scaffold.spec.ts | 82 ++++--------------- 1 file changed, 17 insertions(+), 65 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c1b7bd65..2c2386522 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,71 +111,23 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { - shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", - kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", - }, - adventure: { - shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", - kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", - }, - collectathon: { - shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", - kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", - }, - kart: { - shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", - kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", - }, - minimal: { - shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", - kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", - }, - platformer: { - shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", - kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", - }, - puzzle: { - shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", - kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", - }, - racing: { - shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", - kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", - }, - rain: { - shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", - kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", - }, - rts: { - shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", - kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", - }, - runner: { - shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", - kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", - }, - sailing: { - shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", - kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", - }, - shooter: { - shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", - kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", - }, - snow: { - shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", - kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", - }, - starter: { - shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", - kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", - }, - "tower-defense": { - shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", - kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", - }, - // scaffold-pin:end + "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, + "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, + "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, + "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, + "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, + "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, + "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, + "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, + "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, + "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, + "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, + "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, + "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, + "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, + "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, + "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, +// scaffold-pin:end }; describe("create-threenative", () => { From 577877c2fadf025484750796c5169b4035a27908 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:31:16 -0700 Subject: [PATCH 012/154] style(create-threenative): format the scaffold spec the pin tool rewrites --- .../__tests__/scaffold.spec.ts | 82 +++++++++++++++---- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c2386522..2c1b7bd65 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,23 +111,71 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, - "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, - "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, - "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, - "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, - "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, - "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, - "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, - "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, - "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, - "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, - "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, - "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, - "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, - "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, - "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, -// scaffold-pin:end + "action-rpg": { + shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", + kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", + }, + adventure: { + shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", + kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", + }, + collectathon: { + shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", + kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", + }, + kart: { + shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", + kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", + }, + minimal: { + shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", + kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", + }, + platformer: { + shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", + kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", + }, + puzzle: { + shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", + kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", + }, + racing: { + shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", + kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", + }, + rain: { + shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", + kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", + }, + rts: { + shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", + kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", + }, + runner: { + shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", + kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", + }, + sailing: { + shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", + kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", + }, + shooter: { + shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", + kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", + }, + snow: { + shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", + kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", + }, + starter: { + shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", + kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", + }, + "tower-defense": { + shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", + kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", + }, + // scaffold-pin:end }; describe("create-threenative", () => { From 6ed61a6ff733e0270f0a320db3915aea1fa112c7 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:38:15 -0700 Subject: [PATCH 013/154] refactor(create-threenative): one manifest names the dependency patches PRD-833 phase 2, box 2. template-assets/patches/manifest.json lists each patch once with its repository source; the prepack step (scripts/sync-patches.mjs) copies exactly those into the tarball and prunes the rest, copyFrameworkPatches resolves a kit's patchedDependencies through it, and framework-patches.spec.ts holds every kit to it. The filename was spelled in src, in the prepack one-liner and in sixteen kit manifests; a three-version bump needed all of them edited by hand. --- .../PRD-833-template-mechanism-lives-once.md | 6 +- .../__tests__/framework-patches.spec.ts | 64 +++++++++++++++++++ .../__tests__/publication.spec.ts | 4 ++ packages/create-threenative/package.json | 3 +- .../scripts/sync-patches.mjs | 46 +++++++++++++ packages/create-threenative/src/index.ts | 42 +++++++++--- 6 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 packages/create-threenative/__tests__/framework-patches.spec.ts create mode 100644 packages/create-threenative/scripts/sync-patches.mjs diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md index f629227eb..da8ba979c 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -1,6 +1,6 @@ # PRD-833 — Template mechanism lives once -**Status:** PARTIAL — Phases 1 and 3 landed; Phase 2 (shared build plumbing) and AC-2 (the visual judge on starter, racing and snow) are open. +**Status:** PARTIAL — Phases 1 and 3 landed, Phase 2 box 2 (the patch manifest) landed; Phase 2 box 1 (one source for `vite.config.ts`/`main.ts`/`tsconfig.json`) and AC-2 (the visual judge on starter, racing and snow) are open. **Priority:** P1 — open Phase 2 deletes 84 template copies of 7 render-mechanism files, where a fix to one kit leaves 11 kits wrong with every test green. **Complexity:** 6 (MEDIUM); risk override: the moved files measure the environment and wire the backlight, so the owner's visual-judge rule applies **Owner:** ThreeNative maintainers @@ -54,11 +54,11 @@ Report ids B1, F1, F2, F3, F4, F8, B2. Root `AGENTS.md` puts "plumbing every gam - [x] One core WebGL-fallback predicate; no `quality.ts` defines its own. Each kit's `quality.ts` is now a three-line re-export of `IMaterialLightingEnvironment`, `isWebGLFallbackRenderer` and `materialLightingEnabled` from the subpath, and the starter's `rendererGate.ts` is deleted. The charter and portability checks were extended deliberately, not bypassed: `constraints.spec.ts` admits `render/environment/**` with a tighter assertion that the block originates no material, light, colour or shader (only the sampling quad's own material and the node twin of the *game's* material, and tone mapping only as the readback's save/restore), and `check-core-boundary.ts` admits the mechanism names with the same proof. proof: the `rg` above returns nothing; `npx vitest run packages/core/__tests__/constraints.spec.ts` → 4 passed. #### Phase 2: One source for shared plumbing -**Status:** NOT STARTED +**Status:** PARTIAL — the patch manifest landed; the build-plumbing restamp is open **Files:** `packages/create-threenative/src/index.ts`, `template-assets/`, `templates/*/kit.json`, `packages/create-threenative/package.json`. - [ ] `vite.config.ts`, `main.ts` and `tsconfig.json` are restamped from one source with a per-kit plugin delta, and each kit reproduces today's bytes. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts` -- [ ] The patch filename lives in one manifest that `prepack` reads. proof: `rg -c 'three@0\.' packages/create-threenative/src packages/create-threenative/package.json` drops to the manifest only, plus `pnpm --filter create-threenative pack` ships the patch +- [x] The patch filename lives in one manifest that `prepack` reads: `template-assets/patches/manifest.json` names each patch with its repository source, `packages/create-threenative/scripts/sync-patches.mjs` (run by `prepack` and `pnpm sync:patches`) copies exactly those into the tarball, `copyFrameworkPatches` resolves a kit's `patchedDependencies` through it, and `__tests__/framework-patches.spec.ts` holds every kit to it (shown red by dropping an entry: both the kit check and the shipped-set check fail, naming each kit). proof: `rg -c 'three@0\.' packages/create-threenative/src packages/create-threenative/package.json` returns nothing, and `pnpm --filter create-threenative pack` lists `package/template-assets/patches/{manifest.json,three@0.187.0.patch,vite@8.2.0.patch,@tailwindcss__node@4.3.3.patch}` in the tarball. #### Phase 3: Scaffold pins split **Status:** DONE diff --git a/packages/create-threenative/__tests__/framework-patches.spec.ts b/packages/create-threenative/__tests__/framework-patches.spec.ts new file mode 100644 index 000000000..02c21990f --- /dev/null +++ b/packages/create-threenative/__tests__/framework-patches.spec.ts @@ -0,0 +1,64 @@ +// The dependency patches a scaffold applies are named in three places that must agree: each kit's +// `pnpm.patchedDependencies`, the copies the tarball ships, and the sources in this repository. The +// list itself lives once, in `template-assets/patches/manifest.json`; this holds the other three to +// it, so a three-version bump cannot leave a kit naming a patch nobody ships. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { discoverTemplateNames } from "../src/index.js"; + +const PACKAGE = path.resolve(import.meta.dirname, ".."); +const REPOSITORY = path.resolve(PACKAGE, "..", ".."); +const DIRECTORY = path.join(PACKAGE, "template-assets", "patches"); + +interface IFrameworkPatch { + readonly name: string; + readonly source: string; +} + +function manifest(): readonly IFrameworkPatch[] { + const parsed = JSON.parse(readFileSync(path.join(DIRECTORY, "manifest.json"), "utf8")) as { + version: number; + patches: readonly IFrameworkPatch[]; + }; + expect(parsed.version).toBe(1); + return parsed.patches; +} + +describe("framework patches", () => { + it("should name every patch a kit applies", () => { + const names = new Set(manifest().map((patch) => patch.name)); + const unknown: string[] = []; + for (const kit of discoverTemplateNames()) { + const file = path.join(PACKAGE, "templates", kit, "package.json"); + const parsed = JSON.parse(readFileSync(file, "utf8")) as { + pnpm?: { patchedDependencies?: Record }; + }; + for (const relative of Object.values(parsed.pnpm?.patchedDependencies ?? {})) { + const name = path.basename(relative); + if (relative !== `patches/${name}` || !names.has(name)) { + unknown.push(`${kit}: ${relative}`); + } + } + } + expect(unknown).toEqual([]); + }); + + it("should point every manifest entry at a source that exists", () => { + const missing = manifest() + .filter((patch) => !existsSync(path.resolve(REPOSITORY, patch.source))) + .map((patch) => `${patch.name} -> ${patch.source}`); + expect(missing).toEqual([]); + }); + + it("should ship exactly the manifest's patches, byte for byte", () => { + const patches = manifest(); + const shipped = readdirSync(DIRECTORY).filter((file) => file !== "manifest.json"); + expect([...shipped].sort()).toEqual(patches.map((patch) => patch.name).sort()); + for (const patch of patches) { + expect(readFileSync(path.join(DIRECTORY, patch.name)), patch.name).toEqual( + readFileSync(path.resolve(REPOSITORY, patch.source)), + ); + } + }); +}); diff --git a/packages/create-threenative/__tests__/publication.spec.ts b/packages/create-threenative/__tests__/publication.spec.ts index 2e0276bd6..66f75bd47 100644 --- a/packages/create-threenative/__tests__/publication.spec.ts +++ b/packages/create-threenative/__tests__/publication.spec.ts @@ -378,9 +378,13 @@ describe("registry publication", () => { for (const patch of Object.values(kit.pnpm?.patchedDependencies ?? {})) declared.add(path.basename(patch)); } + // The manifest is the list, not a patch: it ships so `copyFrameworkPatches` can resolve a + // kit's `patchedDependencies` through it, and `framework-patches.spec.ts` holds the two to + // each other. const shipped = (packed ?? []) .filter((file) => file.startsWith("template-assets/patches/")) .map((file) => path.basename(file)) + .filter((file) => file !== "manifest.json") .sort(); expect(shipped).toEqual([...declared].sort()); }, 120_000); diff --git a/packages/create-threenative/package.json b/packages/create-threenative/package.json index 125b6301f..f0d59b05a 100644 --- a/packages/create-threenative/package.json +++ b/packages/create-threenative/package.json @@ -71,7 +71,8 @@ "build": "tsup --config tsup.config.ts && publint", "typecheck": "tsc --noEmit", "test": "publint --strict", - "prepack": "node -e \"const fs=require('node:fs');const L=[['../core/patches/three@0.187.0.patch','three@0.187.0.patch'],['../../patches/vite@8.2.0.patch','vite@8.2.0.patch'],['../../patches/@tailwindcss__node@4.3.3.patch','@tailwindcss__node@4.3.3.patch']];const D='template-assets/patches/';fs.mkdirSync(D,{recursive:true});for(const f of fs.readdirSync(D))if(!L.some(([,n])=>n===f))fs.rmSync(D+f,{force:true,recursive:true});for(const [s,n] of L){const d=D+n;if(fs.existsSync(s))fs.cpSync(s,d);else if(!fs.existsSync(d))throw new Error('TN_FRAMEWORK_PATCH_MISSING: '+s)}\"" + "prepack": "node scripts/sync-patches.mjs", + "sync:patches": "node scripts/sync-patches.mjs" }, "dependencies": { "@gltf-transform/core": "catalog:", diff --git a/packages/create-threenative/scripts/sync-patches.mjs b/packages/create-threenative/scripts/sync-patches.mjs new file mode 100644 index 000000000..e6f704d55 --- /dev/null +++ b/packages/create-threenative/scripts/sync-patches.mjs @@ -0,0 +1,46 @@ +// Copies the dependency patches a scaffold applies from their one manifest. +// +// The list used to be spelled three times: here, in `copyFrameworkPatches`, and inline in the +// `prepack` one-liner. A three-version bump then needed all three edited or a tarball shipped a +// patch no kit names. +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const REPOSITORY = path.resolve(PACKAGE, "..", ".."); +const DIRECTORY = path.join(PACKAGE, "template-assets", "patches"); +const MANIFEST = path.join(DIRECTORY, "manifest.json"); + +const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")); +if (manifest.version !== 1) throw new Error("TN_FRAMEWORK_PATCH_MANIFEST: version must be 1."); +if (!Array.isArray(manifest.patches)) { + throw new Error("TN_FRAMEWORK_PATCH_MANIFEST: patches must be an array."); +} +const names = new Set(); +for (const patch of manifest.patches) { + if (typeof patch?.name !== "string" || typeof patch?.source !== "string") { + throw new Error("TN_FRAMEWORK_PATCH_MANIFEST: every patch needs a name and a source."); + } + if (names.has(patch.name)) { + throw new Error(`TN_FRAMEWORK_PATCH_MANIFEST: '${patch.name}' is listed twice.`); + } + names.add(patch.name); +} + +mkdirSync(DIRECTORY, { recursive: true }); +// A patch this repository no longer applies must not survive in a tarball from an earlier build. +for (const file of readdirSync(DIRECTORY)) { + if (file !== "manifest.json" && !names.has(file)) rmSync(path.join(DIRECTORY, file), { force: true }); +} +for (const patch of manifest.patches) { + const source = path.resolve(REPOSITORY, patch.source); + const destination = path.join(DIRECTORY, patch.name); + // A source checkout has the bytes; a packed install already carries the copy and has no sources. + if (existsSync(source)) copyFileSync(source, destination); + else if (!existsSync(destination)) { + throw new Error(`TN_FRAMEWORK_PATCH_MISSING: ${patch.source} is absent and no copy ships.`); + } +} +// stderr, not stdout: `pnpm pack --json` parses stdout, and a prepack that logs there corrupts it. +process.stderr.write(`framework patches: ${manifest.patches.map((patch) => patch.name).join(", ")}\n`); diff --git a/packages/create-threenative/src/index.ts b/packages/create-threenative/src/index.ts index 2d3247836..74c7706c0 100644 --- a/packages/create-threenative/src/index.ts +++ b/packages/create-threenative/src/index.ts @@ -742,11 +742,35 @@ async function applyPackageSources( } const FRAMEWORK_PATCH_DIRECTORY = "patches"; -const FRAMEWORK_PATCH_SOURCES: Readonly> = { - "three@0.187.0.patch": "../core/patches/three@0.187.0.patch", - "vite@8.2.0.patch": "../../patches/vite@8.2.0.patch", - "@tailwindcss__node@4.3.3.patch": "../../patches/@tailwindcss__node@4.3.3.patch", -}; + +/** One dependency patch a scaffold may apply: the file in the game, and where its bytes live. */ +interface IFrameworkPatch { + /** The file inside the generated project's `patches/` directory. */ + readonly name: string; + /** The patch in this repository, relative to its root. */ + readonly source: string; +} + +/** + * The dependency patches a scaffold applies, from `template-assets/patches/manifest.json`. + * + * One list, three readers: this function copies the ones a kit declares, the prepack step copies + * the whole set into the tarball, and `__tests__/framework-patches.spec.ts` holds every kit's + * `patchedDependencies` to it. It used to be spelled in all three places, so a three-version bump + * needed each edited by hand. + */ +async function frameworkPatches(packageRoot: string): Promise { + const manifest = JSON.parse( + await readFile( + path.join(packageRoot, "template-assets", FRAMEWORK_PATCH_DIRECTORY, "manifest.json"), + "utf8", + ), + ) as { version?: unknown; patches?: unknown }; + if (manifest.version !== 1 || !Array.isArray(manifest.patches)) { + throw new Error("TN_FRAMEWORK_PATCH_MANIFEST: expected version 1 with a patches array."); + } + return manifest.patches as readonly IFrameworkPatch[]; +} async function copyFrameworkPatches(target: string, templateRootDirectory: string): Promise { // Copy exactly the dependency patches declared by this template. In particular, minimal @@ -755,16 +779,18 @@ async function copyFrameworkPatches(target: string, templateRootDirectory: strin pnpm?: { patchedDependencies?: Record }; }; const packageRoot = path.dirname(templateRoot()); + const supported = await frameworkPatches(packageRoot); + const repositoryRoot = path.resolve(packageRoot, "..", ".."); for (const relative of Object.values(manifest.pnpm?.patchedDependencies ?? {})) { const name = path.basename(relative); - const fallback = FRAMEWORK_PATCH_SOURCES[name]; - if (fallback === undefined || relative !== `${FRAMEWORK_PATCH_DIRECTORY}/${name}`) { + const patch = supported.find((candidate) => candidate.name === name); + if (patch === undefined || relative !== `${FRAMEWORK_PATCH_DIRECTORY}/${name}`) { throw new Error(`TN_FRAMEWORK_PATCH_MISSING: unsupported template patch '${relative}'.`); } const candidates = [...new Set([path.dirname(templateRootDirectory), packageRoot])].map( (root) => path.join(root, "template-assets", FRAMEWORK_PATCH_DIRECTORY, name), ); - const canonical = path.resolve(packageRoot, fallback); + const canonical = path.resolve(repositoryRoot, patch.source); // A source checkout can have an ignored prepack copy from an earlier build. Packaged CLIs // omit src/, so they continue to use only the patch bytes delivered in their tarball. if (existsSync(path.join(packageRoot, "src", "index.ts"))) candidates.unshift(canonical); From 91f421d055e80c1cbc44ebe25f002fff49a3a0bf Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:38:31 -0700 Subject: [PATCH 014/154] chore(scaffold): re-pin for the patch manifest the tarball now ships --- .../__tests__/scaffold.spec.ts | 82 ++++--------------- 1 file changed, 17 insertions(+), 65 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c1b7bd65..2c2386522 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,71 +111,23 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { - shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", - kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", - }, - adventure: { - shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", - kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", - }, - collectathon: { - shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", - kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", - }, - kart: { - shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", - kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", - }, - minimal: { - shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", - kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", - }, - platformer: { - shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", - kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", - }, - puzzle: { - shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", - kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", - }, - racing: { - shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", - kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", - }, - rain: { - shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", - kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", - }, - rts: { - shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", - kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", - }, - runner: { - shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", - kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", - }, - sailing: { - shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", - kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", - }, - shooter: { - shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", - kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", - }, - snow: { - shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", - kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", - }, - starter: { - shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", - kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", - }, - "tower-defense": { - shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", - kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", - }, - // scaffold-pin:end + "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, + "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, + "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, + "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, + "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, + "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, + "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, + "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, + "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, + "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, + "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, + "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, + "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, + "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, + "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, + "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, +// scaffold-pin:end }; describe("create-threenative", () => { From bbb8474cdc31ace8770ba1256121b38e933a6875 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:39:44 -0700 Subject: [PATCH 015/154] style(create-threenative): format the prepack script and the re-pinned spec --- .../__tests__/scaffold.spec.ts | 82 +++++++++++++++---- .../scripts/sync-patches.mjs | 7 +- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c2386522..2c1b7bd65 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,23 +111,71 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, - "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, - "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, - "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, - "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, - "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, - "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, - "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, - "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, - "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, - "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, - "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, - "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, - "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, - "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, - "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, -// scaffold-pin:end + "action-rpg": { + shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", + kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", + }, + adventure: { + shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", + kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", + }, + collectathon: { + shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", + kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", + }, + kart: { + shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", + kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", + }, + minimal: { + shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", + kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", + }, + platformer: { + shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", + kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", + }, + puzzle: { + shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", + kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", + }, + racing: { + shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", + kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", + }, + rain: { + shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", + kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", + }, + rts: { + shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", + kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", + }, + runner: { + shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", + kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", + }, + sailing: { + shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", + kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", + }, + shooter: { + shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", + kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", + }, + snow: { + shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", + kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", + }, + starter: { + shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", + kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", + }, + "tower-defense": { + shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", + kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", + }, + // scaffold-pin:end }; describe("create-threenative", () => { diff --git a/packages/create-threenative/scripts/sync-patches.mjs b/packages/create-threenative/scripts/sync-patches.mjs index e6f704d55..417eabf67 100644 --- a/packages/create-threenative/scripts/sync-patches.mjs +++ b/packages/create-threenative/scripts/sync-patches.mjs @@ -31,7 +31,8 @@ for (const patch of manifest.patches) { mkdirSync(DIRECTORY, { recursive: true }); // A patch this repository no longer applies must not survive in a tarball from an earlier build. for (const file of readdirSync(DIRECTORY)) { - if (file !== "manifest.json" && !names.has(file)) rmSync(path.join(DIRECTORY, file), { force: true }); + if (file !== "manifest.json" && !names.has(file)) + rmSync(path.join(DIRECTORY, file), { force: true }); } for (const patch of manifest.patches) { const source = path.resolve(REPOSITORY, patch.source); @@ -43,4 +44,6 @@ for (const patch of manifest.patches) { } } // stderr, not stdout: `pnpm pack --json` parses stdout, and a prepack that logs there corrupts it. -process.stderr.write(`framework patches: ${manifest.patches.map((patch) => patch.name).join(", ")}\n`); +process.stderr.write( + `framework patches: ${manifest.patches.map((patch) => patch.name).join(", ")}\n`, +); From ac600f44151bdd25504c11a2df3e45d895a7ada1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:39:56 -0700 Subject: [PATCH 016/154] chore(scaffold): re-pin after the formatter rewrote the pin block --- .../__tests__/scaffold.spec.ts | 82 ++++--------------- 1 file changed, 17 insertions(+), 65 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c1b7bd65..2c2386522 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,71 +111,23 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { - shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", - kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", - }, - adventure: { - shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", - kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", - }, - collectathon: { - shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", - kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", - }, - kart: { - shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", - kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", - }, - minimal: { - shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", - kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", - }, - platformer: { - shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", - kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", - }, - puzzle: { - shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", - kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", - }, - racing: { - shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", - kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", - }, - rain: { - shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", - kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", - }, - rts: { - shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", - kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", - }, - runner: { - shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", - kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", - }, - sailing: { - shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", - kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", - }, - shooter: { - shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", - kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", - }, - snow: { - shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", - kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", - }, - starter: { - shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", - kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", - }, - "tower-defense": { - shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", - kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", - }, - // scaffold-pin:end + "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, + "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, + "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, + "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, + "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, + "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, + "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, + "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, + "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, + "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, + "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, + "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, + "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, + "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, + "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, + "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, +// scaffold-pin:end }; describe("create-threenative", () => { From 1aa00f7e38ca6e8e5d750845b03e8a6387a11e62 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:42:41 -0700 Subject: [PATCH 017/154] fix(scaffold): the pin tool writes the shape the formatter keeps A single-line pin was longer than the formatter's width, so biome wrapped it and the tool's reader stopped recognising its own table, which made every commit alternate between a re-pin and a format. Pins are one hash per line now, with the key quoted only when it is not a plain identifier, and the reader accepts both forms so a formatter pass cannot break it. --- .../__tests__/scaffold.spec.ts | 82 +++++++++++++++---- scripts/scaffold-pin.ts | 12 ++- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c2386522..2c1b7bd65 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -111,23 +111,71 @@ async function withBrokenTemplateFile( // says which one moved, so a re-pin no longer needs a paragraph of history to be readable. const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin - "action-rpg": { shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2" }, - "adventure": { shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e" }, - "collectathon": { shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0" }, - "kart": { shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f" }, - "minimal": { shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a" }, - "platformer": { shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310" }, - "puzzle": { shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10" }, - "racing": { shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d" }, - "rain": { shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28" }, - "rts": { shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b" }, - "runner": { shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e" }, - "sailing": { shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401" }, - "shooter": { shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088" }, - "snow": { shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9" }, - "starter": { shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86" }, - "tower-defense": { shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d" }, -// scaffold-pin:end + "action-rpg": { + shared: "11e0e7dd493d9fb9b7b56a927ffa6697e583933da7ca11159bb1806612cf07f2", + kit: "b048838e55b966b62d0053bd8088ea27b7a2691333cc157dc70afd28c72da0f2", + }, + adventure: { + shared: "fc880b43770473cfc23628afe446dc2ca649c4891ffaafd41a691a8985fe82a0", + kit: "3dcde149fd95826cb2b5d095302b3854bd3fa3b8523dfe381d098f17da3fee7e", + }, + collectathon: { + shared: "636ab0c762067e9e63b26133bb202a377c89833b5d836ea86d2d233f32c7ecb4", + kit: "b93ca9fc6599c7283319d0e67c111cb3b4f594c34a21092f69049d125135a2c0", + }, + kart: { + shared: "554d353ef9f6505a543c8937ccb170e1cfb6ae04591545d8994c7e3494dbad7c", + kit: "abc11d287b19cd3d50291b69119f53d70a44122bb3f769c22a41bf0f0c3a525f", + }, + minimal: { + shared: "e3f95200e101ff1b6c6c9f5165c3405f08e3e0fe261ae8ba06bac6865b81c5f1", + kit: "8e991cf3fdb8c559b16a20c2e1d02b25ab0314fd426b3e1ebc15874cae777a4a", + }, + platformer: { + shared: "03e7bde17f227297791bff7355d3efd870c3e480d3b147ad8611c92cb120fa6b", + kit: "7d7e6a1d790acca98073c65f15811b48fa1d428c664ccab01b60444d2e084310", + }, + puzzle: { + shared: "00e134320097b34fefa55f6ae8768ee9ef94545e92e43652e23bddcce8185b9b", + kit: "ebe65fa50e675414362e146f6127693f95e9795ebd12e785468935b7449b7c10", + }, + racing: { + shared: "b58b45d7a106e20b9504ce0c77f3f0fbc8984045d7f240967e004739ef048cee", + kit: "8af58ed81bae2d52c6150acd0dabb744849dd413aaf223690c530de314ce1a8d", + }, + rain: { + shared: "8992369754fbf3ae91fc4eb25a0a5f747fabd378c599a904d9e5ad28cf08006f", + kit: "dc0d69d5894815629954130d3aa5c804901f6bcf394fd0942576aee46bd74d28", + }, + rts: { + shared: "9a70169ea61815f89d039f7f68efed675f2d3e784676d28d0764535e1b5730e2", + kit: "7e6f82364f3ebab16731ad2d22a913cbbe7528f81c14481f9cbdf2f4611ea26b", + }, + runner: { + shared: "9d130f6a22ff6b0c55a1841044f0c2855b645797b2175fef9f9b2710de615a96", + kit: "e80499c0d6545b318e844715b84116b14a5b4d8d2af0e254571728dc61706f3e", + }, + sailing: { + shared: "340f2baff9217e437f472729827319320d908c1598869bb96023c1d262d132e4", + kit: "5a7822641b0288c81ae877c08a616679c4ab7965a40635bf008100a0adee4401", + }, + shooter: { + shared: "3647846e2f52d39ac7447b7d03b12dcfd1f8a1effdd6f74fc05175706500703a", + kit: "70e5e50141fb013c729aeed2509791b634cbb855477a236610ddd53b930e8088", + }, + snow: { + shared: "2846cb5f6e4fa1c350a36a91c4b85233e9c28ba5434a9038a55b0b1399337228", + kit: "a8f985dea5b93ff6355c921ec8ad73045eab5ef0ff9bdb8a359abc2817a6cad9", + }, + starter: { + shared: "e6d4c8926c4f65e1d94c51b5ac8e60ef249d34209a5c7b58112d4108674a971b", + kit: "ee1bdae16d58f89107b4fc5a929014d3237d6db6dd1eca6746e2859914bebc86", + }, + "tower-defense": { + shared: "2c843b749255bde8eb0c128b0e5b084359e1e7f226b940b1982ea4c1f625d4d9", + kit: "199ccf6490e21699b6bf4e817c6f2ac222dbbdd1d05207f04b46dc6ed07c7e8d", + }, + // scaffold-pin:end }; describe("create-threenative", () => { diff --git a/scripts/scaffold-pin.ts b/scripts/scaffold-pin.ts index d998ee812..379aa66cb 100644 --- a/scripts/scaffold-pin.ts +++ b/scripts/scaffold-pin.ts @@ -31,9 +31,14 @@ export interface IScaffoldPin { export function readPins(source: string): Readonly> { const block = source.slice(source.indexOf(BEGIN), source.indexOf(END)); const pins: Record = {}; - for (const [, kit, shared, contents] of block.matchAll( - /^ {2}"([^"]+)": \{ shared: "([0-9a-f]{64})", kit: "([0-9a-f]{64})" \},$/gmu, + // One hash per line: a single-line row is longer than the formatter's width, and the formatter + // would wrap it into something this reader no longer recognises. + // One hash per line, and the key quoted only when it has to be: a single-line row is longer than + // the formatter's width, and the formatter unquotes a plain identifier, so this reads both forms. + for (const [, quoted, bare, shared, contents] of block.matchAll( + /^ {2}(?:"([^"]+)"|([a-z][a-z0-9-]*)): \{\n {4}shared: "([0-9a-f]{64})",\n {4}kit: "([0-9a-f]{64})",\n {2}\},$/gmu, )) { + const kit = quoted ?? bare; if (kit === undefined || shared === undefined || contents === undefined) continue; pins[kit] = { kit: contents, shared }; } @@ -45,7 +50,8 @@ export function formatPins(pins: Readonly>): string return Object.entries(pins) .sort(([left], [right]) => left.localeCompare(right)) .map( - ([kit, pin]) => ` ${JSON.stringify(kit)}: { shared: "${pin.shared}", kit: "${pin.kit}" },`, + ([kit, pin]) => + ` ${/^[a-z][a-z0-9]*$/u.test(kit) ? kit : JSON.stringify(kit)}: {\n shared: "${pin.shared}",\n kit: "${pin.kit}",\n },`, ) .join("\n"); } From 8ba6bea642372184d85ef9265ad051c29ac065d3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:44:50 -0700 Subject: [PATCH 018/154] docs(PRD-833): the measured delta the plumbing restamp has to carry md5sum over all sixteen kits names four vite variants, three tsconfig variants and four main.ts variants, and the diff of each: minimal drops React and Tailwind, starter and shooter add a second worker entry, the rain group differs only by a missing comment, and adventure's Tab preventer is the one genuine per-kit statement. The next attempt starts from that table instead of a survey. --- .../PRD-833-template-mechanism-lives-once.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md index da8ba979c..ddffdb391 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-833-template-mechanism-lives-once.md @@ -58,6 +58,16 @@ Report ids B1, F1, F2, F3, F4, F8, B2. Root `AGENTS.md` puts "plumbing every gam **Files:** `packages/create-threenative/src/index.ts`, `template-assets/`, `templates/*/kit.json`, `packages/create-threenative/package.json`. - [ ] `vite.config.ts`, `main.ts` and `tsconfig.json` are restamped from one source with a per-kit plugin delta, and each kit reproduces today's bytes. proof: `pnpm --filter create-threenative exec vitest run __tests__/scaffold.spec.ts` + + Measured deltas, so the next attempt starts from data rather than a survey (16 kits; `md5sum templates/*/{vite.config.ts,tsconfig.json,src/main.ts}`): + + | File | Variants | The delta, exactly | + | --- | --- | --- | + | `vite.config.ts` | 10 / 3 / 2 / 1 | canonical carries five plugins; `minimal` drops `react()` and `tailwindcss()`; `starter` and `shooter` add `build: { rollupOptions: { input: { index: "index.html", main: "main.html" } } }`; `rain`, `sailing`, `snow` differ **only** by the missing doc comment above `assetsWatchPlugin` — drift, not intent, so a restamp changes three kits' bytes by a comment and nothing else (prove it by diffing with comments stripped). | + | `tsconfig.json` | 8 / 7 / 1 | `adventure`, `collectathon`, `kart`, `platformer`, `puzzle`, `racing`, `runner`, `tower-defense` include `tests/**/*.ts`; `minimal` drops `jsx: react-jsx` and the `**/*.tsx` glob as well. | + | `src/main.ts` | 11 / 3 / 1 / 1 | canonical mounts React through `#root`; `collectathon` differs by one blank line (drift); `minimal` has no React at all and mounts the canvas directly; `adventure` adds a Tab `keydown` preventer above the mount (the only genuine per-kit statement). | + + So the delta a `kit.json` must carry is four flags (`react`, `tailwind`/`plugins`, `tests` include, worker entry) plus one escape hatch for `adventure`'s extra statement — a single canonical file with a placeholder line the scaffolder resolves, failing closed when a placeholder survives. - [x] The patch filename lives in one manifest that `prepack` reads: `template-assets/patches/manifest.json` names each patch with its repository source, `packages/create-threenative/scripts/sync-patches.mjs` (run by `prepack` and `pnpm sync:patches`) copies exactly those into the tarball, `copyFrameworkPatches` resolves a kit's `patchedDependencies` through it, and `__tests__/framework-patches.spec.ts` holds every kit to it (shown red by dropping an entry: both the kit check and the shipped-set check fail, naming each kit). proof: `rg -c 'three@0\.' packages/create-threenative/src packages/create-threenative/package.json` returns nothing, and `pnpm --filter create-threenative pack` lists `package/template-assets/patches/{manifest.json,three@0.187.0.patch,vite@8.2.0.patch,@tailwindcss__node@4.3.3.patch}` in the tarball. #### Phase 3: Scaffold pins split From b25a989ce50484f080fae0cf88cd003ab88c2c62 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:47:16 -0700 Subject: [PATCH 019/154] chore(scripts): delete the four scripts nothing runs PRD-838 phase 2, the dead-script half (the box said five; the report was wrong about one). asset-pose-capture, realistic-ocean-budget, realistic-ocean-capture and verify-fog-exposure are referenced by nothing but the untangle report. release-native-local.mjs stays: release-native-local.ts imports it at runtime because it ships untranspiled, and its own spec covers it. The scaffold pin tool registers its temp-dir cleanup in the guard the repo keeps for that, and the orphan-gate audit (which gate is superseded, which produces cited evidence) is written into the PRD box that still owns the decision. --- ...PRD-838-scripts-share-one-runtime-layer.md | 3 +- scripts/__tests__/temp-dir-guard.spec.ts | 1 + scripts/asset-pose-capture.mjs | 147 ----------- scripts/realistic-ocean-budget.mjs | 57 ----- scripts/realistic-ocean-capture.mjs | 144 ----------- scripts/verify-fog-exposure.ts | 228 ------------------ 6 files changed, 3 insertions(+), 577 deletions(-) delete mode 100644 scripts/asset-pose-capture.mjs delete mode 100644 scripts/realistic-ocean-budget.mjs delete mode 100644 scripts/realistic-ocean-capture.mjs delete mode 100644 scripts/verify-fog-exposure.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md index a78328a57..00892a07a 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -52,4 +52,5 @@ each orphan gate is wired into `pnpm budgets` or deleted. **Files:** `packages/playtest/src/runner/perf.ts`, `scripts/engine-load-test/report.ts`, the 9 scripts named above, root `package.json`. - [ ] `report.ts` imports the playtest verdict rule. proof: `pnpm exec vitest run scripts/__tests__/engine-load-test.spec.ts` plus `pnpm --filter @threenative/playtest exec vitest run __tests__/perf.spec.ts` -- [ ] The 5 dead scripts are deleted, and the 4 orphan gates are wired into `pnpm budgets` or deleted. proof: `pnpm budgets` exit 0, plus `pnpm check:docs` for links +- [x] The dead scripts are deleted: four, not five. `scripts/asset-pose-capture.mjs`, `scripts/realistic-ocean-budget.mjs`, `scripts/realistic-ocean-capture.mjs` and `scripts/verify-fog-exposure.ts` were referenced by nothing but this PRD (`rg -l ` over the tree, excluding `node_modules`, `dist`, `docs/PRDs` and `docs/verification`). The report's fifth, `scripts/release-native-local.mjs`, is **not** dead: `scripts/release-native-local.ts` imports it at runtime (it ships untranspiled, so the `.ts` loads it through a URL), `package.json` reaches it through that `.ts`, and `__tests__/release-native-local.spec.ts` exercises it — it stays. proof: `pnpm check:docs` → 3795 links ok; `npx vitest run scripts/__tests__/temp-dir-guard.spec.ts scripts/__tests__/retry-download.spec.ts scripts/__tests__/primary-docs.spec.ts` → 15 passed. +- [ ] The 4 orphan gates are wired into `pnpm budgets` or deleted. Audit, so the decision is data: `scripts/check-capability-examples.ts` is superseded by `packages/engine-mcp/__tests__/capability-examples.spec.ts`, which runs under `pnpm test` — it can be deleted once that spec no longer shells out to it. `scripts/arm-census.ts`, `scripts/asset-parity.ts` and `scripts/playtest-pair.ts` each have their own spec and produce the evidence the docs cite (`docs/verification/*`), so they are evidence producers, not gates: wiring them into `pnpm budgets` would make an already long chain write reports on every run. proof: `pnpm budgets` exit 0, plus `pnpm check:docs` for links diff --git a/scripts/__tests__/temp-dir-guard.spec.ts b/scripts/__tests__/temp-dir-guard.spec.ts index 945098c73..aa6a30a08 100644 --- a/scripts/__tests__/temp-dir-guard.spec.ts +++ b/scripts/__tests__/temp-dir-guard.spec.ts @@ -80,6 +80,7 @@ const allowedProductionCreators = new Map([ "scripts/bake-delete-test.ts", "The delete-test gate removes the scaffold it created in finally.", ], + ["scripts/scaffold-pin.ts", "The pin tool removes the scaffolds it measured in finally."], [ "scripts/realism-effects-visual.ts", "The production realism-effects visual gate removes its bundle root in finally.", diff --git a/scripts/asset-pose-capture.mjs b/scripts/asset-pose-capture.mjs deleted file mode 100644 index beb127945..000000000 --- a/scripts/asset-pose-capture.mjs +++ /dev/null @@ -1,147 +0,0 @@ -// PRD-729 visual judge input: the same animated pose of the same model from two cooked trees. -// -// Usage: node scripts/asset-pose-capture.mjs ... [--models a.glb,b.glb] [--runs 2] -// -// Each arm is a cooked `public/` directory. The page loads each model through core's -// `createAssetLoader`, plays a clip in three's `AnimationMixer` at fixed fractions of its length, -// frames the model, and renders. The label is burned in large. A PNG hash per pose says at once -// whether two arms drew the same pixels. Chromium runs headed with the playtest WebGPU flags and -// a software adapter is refused. -import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { serveStatic, writeProbePage } from "./asset-preload-probe.mjs"; - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const FRACTIONS = [0.2, 0.5, 0.8]; - -const ENTRY = ` -import { createAssetLoader } from "./dist/index.js"; -import { AmbientLight, Box3, DirectionalLight, PerspectiveCamera, Scene, Vector3, AnimationMixer, WebGPURenderer } from "three/webgpu"; -const fail = (error) => { window.__pose = { error: String(error?.stack ?? error) }; }; -try { - const { models, label } = await (await fetch("/probe.json", { cache: "no-store" })).json(); - const adapter = await navigator.gpu?.requestAdapter(); - const info = adapter?.info ?? {}; - const renderer = new WebGPURenderer({ antialias: false }); - renderer.setSize(640, 640); - document.body.append(renderer.domElement); - const tag = document.createElement("div"); - tag.textContent = label; - tag.style.cssText = "position:absolute;left:12px;top:8px;font:700 72px sans-serif;color:#ff0;text-shadow:0 0 6px #000,0 0 6px #000"; - document.body.append(tag); - await renderer.init(); - const assets = createAssetLoader({ basePath: "/", renderer }); - const scene = new Scene(); - scene.add(new AmbientLight(0xffffff, 1.2)); - const sun = new DirectionalLight(0xffffff, 2.5); - sun.position.set(3, 6, 4); - scene.add(sun); - const camera = new PerspectiveCamera(40, 1, 0.01, 1000); - const loaded = []; - for (const path of models) { - const gltf = await assets.model(path); - loaded.push({ path, gltf, mixer: new AnimationMixer(gltf.scene) }); - } - window.__poses = loaded.flatMap((entry, model) => - (entry.gltf.animations ?? []).map((clip, index) => ({ model, index, name: clip.name, duration: clip.duration }))); - window.__show = async (model, index, fraction) => { - for (const other of loaded) { scene.remove(other.gltf.scene); other.mixer.stopAllAction(); } - const entry = loaded[model]; - scene.add(entry.gltf.scene); - const clip = entry.gltf.animations[index]; - entry.mixer.clipAction(clip).play(); - entry.mixer.setTime(clip.duration * fraction); - entry.gltf.scene.updateMatrixWorld(true); - const box = new Box3().setFromObject(entry.gltf.scene); - const size = box.getSize(new Vector3()), center = box.getCenter(new Vector3()); - const far = Math.max(size.x, size.y, size.z) * 1.8 + 0.5; - camera.position.set(center.x + far * 0.4, center.y + size.y * 0.1, center.z + far); - camera.lookAt(center); - await renderer.compileAsync(scene, camera); - renderer.render(scene, camera); - await renderer.backend.device.queue.onSubmittedWorkDone(); - }; - window.__pose = { ready: true, adapter: { vendor: info.vendor ?? "", architecture: info.architecture ?? "", device: info.device ?? "", description: info.description ?? "" } }; -} catch (error) { fail(error); } -`; - -/** Capture every pose of every model for one arm and one run. */ -async function captureArm(browser, softwareAdapterName, arm, models, dir, run) { - const publicDir = resolve(arm.dir); - await writeProbePage(publicDir, { models, label: arm.label }, ENTRY); - const { server, port } = await serveStatic([publicDir]); - const context = await browser.newContext({ viewport: { width: 640, height: 640 } }); - try { - const page = await context.newPage(); - await page.goto(`http://127.0.0.1:${port}/probe.html`); - const handle = await page.waitForFunction(() => window.__pose, undefined, { timeout: 180_000 }); - const state = await handle.jsonValue(); - if (state.error !== undefined) throw new Error(`pose page failed: ${state.error}`); - const software = softwareAdapterName(state.adapter); - if (software !== undefined) throw new Error(`refusing software WebGPU adapter: ${software}`); - const clips = await page.evaluate(() => window.__poses); - const shots = []; - for (const clip of clips) { - for (const fraction of FRACTIONS) { - await page.evaluate((args) => window.__show(...args), [clip.model, clip.index, fraction]); - const png = await page.screenshot(); - const name = `${arm.label.replace(/[^a-z0-9._-]/gi, "_")}-run${run}-${models[clip.model].replace(/\W+/g, "_")}-${clip.index}-${Math.round(fraction * 100)}.png`; - writeFileSync(join(dir, name), png); - shots.push({ - name, - clip: clip.name, - fraction, - sha: createHash("sha256").update(png).digest("hex").slice(0, 16), - }); - } - } - return { adapter: state.adapter, shots }; - } finally { - await context.close(); - server.close(); - } -} - -const args = process.argv.slice(2); -const flag = (name, fallback) => { - const at = args.indexOf(name); - return at === -1 ? fallback : args.splice(at, 2)[1]; -}; -const models = flag("--models", "mannequin-combat.glb,player-viewmodel.glb").split(","); -const runs = Number(flag("--runs", "2")); -const [out, ...armSpecs] = args; -if (out === undefined || armSpecs.length < 2) { - console.error( - "usage: asset-pose-capture.mjs [--models a.glb,b.glb] [--runs 2]", - ); - process.exit(2); -} -mkdirSync(out, { recursive: true }); -const arms = armSpecs.map((spec) => ({ label: spec.split("=")[0], dir: spec.split("=")[1] })); -const { chromium } = await import("@playwright/test"); -const { WEBGPU_BROWSER_ARGS, resolveBrowserArguments, softwareAdapterName } = await import( - pathToFileURL(resolve(ROOT, "packages/playtest/dist/runner/index.js")).href -); -const browser = await chromium.launch({ - headless: false, - args: resolveBrowserArguments(WEBGPU_BROWSER_ARGS), -}); -try { - const report = { arms: {} }; - for (let run = 1; run <= runs; run += 1) - for (const arm of arms) { - const result = await captureArm(browser, softwareAdapterName, arm, models, out, run); - report.arms[arm.label] = [...(report.arms[arm.label] ?? []), result]; - } - writeFileSync(join(out, "poses.json"), JSON.stringify(report, null, 1)); - const [a, b] = arms.map((arm) => report.arms[arm.label][0].shots); - const same = a.filter((shot, i) => shot.sha === b[i]?.sha).length; - console.log( - `poses per arm ${a.length}; first run, pixel-identical between ${arms[0].label} and ${arms[1].label}: ${same}/${a.length}`, - ); - console.log(`adapter ${JSON.stringify(report.arms[arms[0].label][0].adapter)}`); -} finally { - await browser.close(); -} diff --git a/scripts/realistic-ocean-budget.mjs b/scripts/realistic-ocean-budget.mjs deleted file mode 100644 index b3e88753b..000000000 --- a/scripts/realistic-ocean-budget.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// Collect the engine's own TN_FRAME_BUDGET windows (CPU phases and GPU time) from a served build, headed, -// on a real GPU, at 1920x1080. Both arms, interleaved, the same pose (the game's own start). -// usage: node realistic-ocean-budget.mjs --incumbent --realistic --out [--runs 3] [--seconds 40] -import { writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const { chromium } = createRequire(`${root}/packages/playtest/package.json`)("playwright"); -const args = process.argv.slice(2); -const option = (name, fallback) => { - const at = args.indexOf(`--${name}`); - return at >= 0 ? args[at + 1] : fallback; -}; -const arms = { incumbent: option("incumbent"), realistic: option("realistic") }; -const out = option("out"); -if (!arms.incumbent || !arms.realistic || !out) - throw new Error("usage: --incumbent --realistic --out "); -const runs = Number(option("runs", "3")); -const seconds = Number(option("seconds", "40")); - -const browser = await chromium.launch({ - headless: false, - args: [ - "--enable-unsafe-webgpu", - "--ignore-gpu-blocklist", - "--enable-features=Vulkan", - "--use-angle=vulkan", - ], -}); -const result = { viewport: [1920, 1080], seconds, runs: [] }; -try { - for (let run = 1; run <= runs; run += 1) { - for (const arm of ["incumbent", "realistic"]) { - const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); - const lines = []; - page.on("console", (m) => { - const text = m.text(); - if (text.includes("TN_FRAME_BUDGET")) lines.push(text); - }); - await page.goto(arms[arm], { waitUntil: "load", timeout: 120_000 }); - await page.waitForSelector('[data-threenative-startup="ready"]', { timeout: 120_000 }); - await page.waitForTimeout(seconds * 1000); - const adapter = await page.evaluate(async () => { - const a = await navigator.gpu?.requestAdapter(); - return a ? { vendor: a.info.vendor, architecture: a.info.architecture } : null; - }); - result.runs.push({ arm, run, adapter, lines }); - await page.close(); - } - } -} finally { - await browser.close(); - writeFileSync(out, JSON.stringify(result, null, 2)); -} -console.log(JSON.stringify(result.runs.map((r) => [r.arm, r.run, r.lines.length]))); diff --git a/scripts/realistic-ocean-capture.mjs b/scripts/realistic-ocean-capture.mjs deleted file mode 100644 index 6551ff175..000000000 --- a/scripts/realistic-ocean-capture.mjs +++ /dev/null @@ -1,144 +0,0 @@ -// Capture the PRD-648 comparison frames, headed, on a real GPU. Three arms, the same four views: -// incumbent the sailing kit's own sea (served build of sailing-incumbent) -// realistic the same kit with the opt-in bundle adopted (sailing-realistic) -// upstream Noniv/web-ocean's ocean.html at the pinned commit, as its README poses them -// usage: node realistic-ocean-capture.mjs --arm --url --out -// [--runs 2] [--poses low,oblique,elevated] [--width 1280] [--height 720] [--hide ] -// [--match] game arms only: web-ocean's own poses and lens (see MATCHED) -// [--pin ] game arms only: drive the page on a fake clock and capture exactly of frames -// (16.67 ms each) after the game reports ready, so every arm and run shows the same sea time -// Every frame records the adapter it ran on: a software adapter's picture is not evidence. -import { mkdirSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const { chromium } = createRequire(`${root}/packages/playtest/package.json`)("playwright"); - -const args = process.argv.slice(2); -const option = (name, fallback) => { - const at = args.indexOf(`--${name}`); - return at >= 0 ? args[at + 1] : fallback; -}; -const arm = option("arm"); -const url = option("url"); -const out = option("out"); -if (!["incumbent", "realistic", "upstream"].includes(arm) || !url || !out) - throw new Error("usage: --arm --url --out "); -const runs = Number(option("runs", "2")); -const width = Number(option("width", "1280")); -const height = Number(option("height", "720")); -const hide = option("hide", ""); -const pin = Number(option("pin", "0")); -const wanted = option("poses", "low,oblique,elevated,sunpath").split(","); - -// web-ocean's README views: 2.4 m looking toward the sun, 75 degrees off it at 2.4 m, 30 m up -// looking toward the sun. `yaw` is degrees from the sun's bearing; pitch is degrees, down negative. -const POSES = { - low: { x: 0, y: 2.4, z: 0, yaw: 0, pitch: 4 }, - oblique: { x: 0, y: 2.4, z: 0, yaw: 75, pitch: 4 }, - elevated: { x: 0, y: 30, z: 0, yaw: 0, pitch: -10 }, - // Aimed where the sailing sky's sun (48 degrees up) reflects: the glitter path and backlit crests. - sunpath: { x: 0, y: 2.4, z: 0, yaw: 0, pitch: -40 }, -}; -// web-ocean's own camera is `x,y,z,yawDeg,pitchDeg`, yaw measured from +Z; its default sun is -// near yaw 0. Passed through unchanged, so the frames are the ones its README documents. -const UPSTREAM = { - low: "0,2.4,0,0,3", - oblique: "0,2.4,0,75,3", - elevated: "0,30,0,0,-10", - // web-ocean's own low sun is already on the horizon in its low view. - sunpath: "0,2.4,0,0,-3", -}; -// --match: the game arms take web-ocean's own camera instead, for a build made with -// `realistic-ocean-consumer.ts --match-reference` (its low sun). web-ocean's sun stands 4 degrees -// right of its yaw 0, and its lens is 58 degrees tall. -const MATCHED = Object.fromEntries( - Object.entries(UPSTREAM).map(([name, cam]) => { - const [x, y, z, yaw, pitch] = cam.split(",").map(Number); - return [name, { x, y, z, yaw: 4 - yaw, pitch, fov: 58 }]; - }), -); -if (args.includes("--match")) Object.assign(POSES, MATCHED); - -mkdirSync(out, { recursive: true }); -const browser = await chromium.launch({ - headless: false, - args: [ - "--enable-unsafe-webgpu", - "--ignore-gpu-blocklist", - "--enable-features=Vulkan", - "--use-angle=vulkan", - ], -}); -const report = { arm, url, width, height, runs, pin, frames: [] }; -try { - for (let run = 1; run <= runs; run += 1) { - for (const name of wanted) { - const pose = POSES[name]; - if (!pose) throw new Error(`unknown pose ${name}`); - const page = await browser.newPage({ viewport: { width, height } }); - const errors = []; - page.on("console", (m) => m.type() === "error" && errors.push(m.text())); - page.on("pageerror", (e) => errors.push(`pageerror ${e.message}`)); - if (arm === "upstream") { - const target = new URL(url); - target.searchParams.set("cam", UPSTREAM[name]); - target.searchParams.set("ui", "0"); - target.searchParams.set("w", String(width)); - target.searchParams.set("h", String(height)); - await page.goto(target.toString(), { waitUntil: "load", timeout: 120_000 }); - await page.waitForTimeout(9_000); - } else { - await page.addInitScript((value) => { - globalThis.__TN_OCEAN_POSE = value; - }, pose); - if (pin > 0) await page.clock.install({ time: 0 }); - await page.goto(url, { waitUntil: "load", timeout: 120_000 }); - if (pin > 0) { - // The fake clock only moves when told to, so advance it in frame-sized steps until the - // game reports ready, then by exactly `pin` ms: the sea's own clock is those frames. - await page.clock.pauseAt(1_000); - const ready = '[data-threenative-startup="ready"]'; - for (let waited = 0; !(await page.$(ready)); waited += 1) { - if (waited > 6_000) throw new Error("the game never reported ready on the fake clock"); - await page.clock.runFor(100); - await page.waitForTimeout(20); - } - if (hide) await page.addStyleTag({ content: `${hide}{display:none !important}` }); - for (let advanced = 0; advanced < pin; advanced += 100) { - await page.clock.runFor(Math.min(100, pin - advanced)); - await page.waitForTimeout(30); - } - } else { - await page.waitForSelector("canvas", { timeout: 60_000 }); - await page.waitForSelector('[data-threenative-startup="ready"]', { timeout: 120_000 }); - if (hide) await page.addStyleTag({ content: `${hide}{display:none !important}` }); - await page.waitForTimeout(6_000); - } - } - const adapter = await page.evaluate(async () => { - const a = await navigator.gpu?.requestAdapter(); - return a - ? { vendor: a.info.vendor, architecture: a.info.architecture, device: a.info.device } - : null; - }); - const file = `${arm}-${name}-run${run}.png`; - await page.screenshot({ path: `${out}/${file}`, type: "png" }); - report.frames.push({ file, pose: name, run, adapter, errors }); - await page.close(); - } - } -} finally { - await browser.close(); - writeFileSync(`${out}/${arm}-report.json`, JSON.stringify(report, null, 2)); -} -const software = report.frames.some( - (frame) => !frame.adapter || /swiftshader|llvmpipe|software/i.test(JSON.stringify(frame.adapter)), -); -if (software) { - console.error("TN_OCEAN_CAPTURE_SOFTWARE_ADAPTER: at least one frame ran on a software adapter."); - process.exitCode = 1; -} -console.log(JSON.stringify({ arm, frames: report.frames.map((f) => [f.file, f.adapter]) })); diff --git a/scripts/verify-fog-exposure.ts b/scripts/verify-fog-exposure.ts deleted file mode 100644 index 95c2f3d62..000000000 --- a/scripts/verify-fog-exposure.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { PNG } from "pngjs"; -import { loadPlaytestScenario } from "../packages/playtest/dist/index.js"; -import { - WEBGPU_BROWSER_ARGS, - runStandalonePlaytest, -} from "../packages/playtest/dist/runner/index.js"; -import { - fogCaptureIsValid, - fogCaptureScenario, - qualifyFogExposureControls, -} from "./verify-volumetric-fog.js"; - -import { defaultCaptureLockRoot } from "../packages/playtest/src/runner/captureLock.js"; - -const root = fileURLToPath(new URL("..", import.meta.url)); -const fixture = path.join(root, "examples/abyss-framework/vq-fog"); -const artifacts = path.join(root, "artifacts/consolidation/joint-gpu"); -const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); -if (execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim() !== "") - throw new Error("Joint GPU qualification requires a clean committed source tree."); -const lockRoot = defaultCaptureLockRoot(); -if (lockRoot !== "/tmp/threenative-playtest-capture") - throw new Error(`Joint GPU requires the normal shared lease root, received ${lockRoot}`); -const holderPath = path.join(lockRoot, "lock/holder.json"); -const leaseRecords: Record[] = []; -let currentArm = ""; -const cases = ["off", "fog", "exposure", "both", "repeat", "rebuild-resize", "dispose"] as const; -const controls: Parameters[0] = {}; -const outcomes: Record[] = []; -const summary = { - lockRoot, - holderPath, - leaseRecords, - sourceSha: sha, - pass: false, - outcomes, - qualification: - "Actual browser WebGPU functional integration; adapter recorded per arm, no performance or native claim.", -}; -await mkdir(artifacts, { recursive: true }); -execFileSync( - process.execPath, - [ - "examples/abyss-framework/node_modules/vite/bin/vite.js", - "build", - "--config", - path.join(fixture, "vite.config.ts"), - ], - { cwd: root, stdio: "inherit" }, -); -const bundleFiles = await readFile(path.join(fixture, "dist/index.html")); -const bundleIndexSha256 = createHash("sha256").update(bundleFiles).digest("hex"); -// Let the public runner acquire/release the normal global lease for each serial arm. -// An outer lease would deadlock when the runner detects its own live holder. -process.env.CAPTURE_LOCK = "1"; -process.env.CAPTURE_LOCK_TIMEOUT_MS = "60000"; -const observer = setInterval(() => { - try { - const holder = JSON.parse(readFileSync(holderPath, "utf8")); - if (holder.pid !== process.pid || leaseRecords.some((record) => record.arm === currentArm)) - return; - const record = { arm: currentArm, holderPath, holder, observedAt: new Date().toISOString() }; - leaseRecords.push(record); - console.info(`JOINT_GPU_LEASE:${JSON.stringify(record)}`); - } catch { - /* A released lease has no holder; read-only observation never alters it. */ - } -}, 250); -console.info(`JOINT_GPU_START:${sha}`); -try { - for (const name of cases) { - currentArm = name; - const fog = name !== "off" && name !== "exposure"; - const exposure = !["off", "fog"].includes(name); - const scenario = fogCaptureScenario(fog ? "fog" : "off", fog ? "KeyF" : "KeyO"); - scenario.name = `joint-fog-exposure-${name}`; - const wait = { kind: "wait" as const, waitFrames: 60, release: true }; - const action = (key: string) => ({ press: [key], holdTicks: 1, release: true }); - const textureBoundary = { - waitForResource: { id: "state", path: "stableTextureFrames", gte: 3 }, - timeoutMs: 30_000, - release: true, - }; - const freshReadback = { - waitForResource: { id: "state", path: "exposureFreshSamples", gte: 1 }, - timeoutMs: 30_000, - release: true, - }; - const resource = (key: string, equals: number | boolean) => ({ - waitForResource: { id: "state", path: key, equals }, - timeoutMs: 30_000, - release: true, - }); - if (exposure) - scenario.steps.push( - action("Digit9"), - wait, - textureBoundary, - freshReadback, - resource("exposureSettled", true), - ); - if (name === "rebuild-resize") - scenario.steps.push( - action("KeyC"), - wait, - textureBoundary, - freshReadback, - resource("exposureSettled", true), - action("KeyR"), - wait, - textureBoundary, - resource("targetWidth", 320), - resource("targetHeight", 240), - freshReadback, - resource("exposureSettled", true), - action("KeyT"), - wait, - textureBoundary, - resource("targetWidth", 640), - resource("targetHeight", 400), - freshReadback, - resource("exposureSettled", true), - ); - if (name === "dispose") { - scenario.steps.push( - action("Digit0"), - wait, - textureBoundary, - action("KeyO"), - wait, - textureBoundary, - ); - const targets = scenario.assert?.components?.find((c) => c.component === "targets"); - const mode = scenario.assert?.components?.find((c) => c.component === "mode"); - if (targets !== undefined) targets.equals = 0; - if (mode !== undefined) mode.equals = "off"; - scenario.assert?.components?.push( - ...["liveTargets", "exposureApplied"].map((component) => ({ - entity: "fog", - component, - equals: component === "liveTargets" ? 0 : false, - allowTrivial: "Actual graph disposal must return to the off ownership baseline.", - })), - { entity: "fog", component: "releasedExposures", gte: 1, changed: true }, - ); - } else if (exposure) - scenario.assert?.components?.push( - ...["exposureApplied", "exposureMeasured", "exposureSettled"].map((component) => ({ - entity: "fog", - component, - equals: true, - allowTrivial: "Capture requires actual finite settled GPU readback.", - })), - ); - const directory = path.join(artifacts, name); - await mkdir(directory, { recursive: true }); - const scenarioPath = path.join(directory, "scenario.playtest.json"); - await writeFile(scenarioPath, JSON.stringify(scenario, null, 2)); - await loadPlaytestScenario(root, scenarioPath); - const report = await runStandalonePlaytest({ - artifactDirectory: directory, - projectPath: fixture, - scenarioPath, - target: "browser", - headless: false, - allowSoftwareAdapter: true, - browserArgs: WEBGPU_BROWSER_ARGS, - port: 0, - url: "http://127.0.0.1:5173/", - timeoutMs: 120_000, - trace: false, - server: { - cwd: root, - command: - "node examples/abyss-framework/node_modules/vite/bin/vite.js preview --config examples/abyss-framework/vq-fog/vite.config.ts --host 127.0.0.1 --port ${PORT}", - timeoutMs: 60_000, - }, - }); - const state = report.observations?.resources?.state?.after as - | Record - | undefined; - const valid = fogCaptureIsValid(report); - outcomes.push({ - name, - sourceSha: sha, - bundleIndexSha256, - pass: valid, - capture: report.capture, - diagnostics: report.diagnostics, - state, - }); - await writeFile(path.join(artifacts, "summary.json"), JSON.stringify(summary, null, 2)); - if (!valid || state === undefined) throw new Error(`Joint ${name}: runtime/capture rejected.`); - const png = PNG.sync.read(await readFile(path.join(directory, "after.png"))); - if (png.width !== 640 || png.height !== 400) - throw new Error(`Joint ${name}: viewport mismatch.`); - controls[name] = { pixels: png.data, state }; - if ( - name === "rebuild-resize" && - (state.releasedExposures !== 1 || - state.liveTargets !== 1 || - state.targetWidth !== 640 || - state.targetHeight !== 400 || - state.exposureMeasured !== true || - state.exposureSettled !== true || - Number(state.exposureLuminance) <= 0 || - !Number.isFinite(state.exposureLuminance) || - !Number.isFinite(state.exposureStops)) - ) - throw new Error("Combined rebuild/resize did not preserve a finite live graph."); - } - if (controls.dispose?.state.textures !== controls.off?.state.textures) - throw new Error( - "Combined disposal did not return actual renderer texture memory to the off control baseline.", - ); - outcomes.push({ response: qualifyFogExposureControls(controls) }); - summary.pass = true; -} finally { - clearInterval(observer); - await writeFile(path.join(artifacts, "summary.json"), JSON.stringify(summary, null, 2)); - console.info(`JOINT_GPU_TERMINAL_RELEASE:${sha}:${summary.pass}`); -} From 04f52e734fcf88618e1b500934aca855ec597aea Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:48:09 -0700 Subject: [PATCH 020/154] docs(PRD-838): the orphan gates are not orphaned check-capability-examples is a library the engine-mcp spec imports, so pnpm test runs it; arm-census, asset-parity and playtest-pair are imported by their own specs inside the root vitest include and produce reports the docs cite. The suite is the gate, and pnpm budgets is for invariants rather than for writing evidence, so the box that asked to wire or delete them is deleted by decision. --- .../PRD-838-scripts-share-one-runtime-layer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md index 00892a07a..f9a303508 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -53,4 +53,4 @@ each orphan gate is wired into `pnpm budgets` or deleted. - [ ] `report.ts` imports the playtest verdict rule. proof: `pnpm exec vitest run scripts/__tests__/engine-load-test.spec.ts` plus `pnpm --filter @threenative/playtest exec vitest run __tests__/perf.spec.ts` - [x] The dead scripts are deleted: four, not five. `scripts/asset-pose-capture.mjs`, `scripts/realistic-ocean-budget.mjs`, `scripts/realistic-ocean-capture.mjs` and `scripts/verify-fog-exposure.ts` were referenced by nothing but this PRD (`rg -l ` over the tree, excluding `node_modules`, `dist`, `docs/PRDs` and `docs/verification`). The report's fifth, `scripts/release-native-local.mjs`, is **not** dead: `scripts/release-native-local.ts` imports it at runtime (it ships untranspiled, so the `.ts` loads it through a URL), `package.json` reaches it through that `.ts`, and `__tests__/release-native-local.spec.ts` exercises it — it stays. proof: `pnpm check:docs` → 3795 links ok; `npx vitest run scripts/__tests__/temp-dir-guard.spec.ts scripts/__tests__/retry-download.spec.ts scripts/__tests__/primary-docs.spec.ts` → 15 passed. -- [ ] The 4 orphan gates are wired into `pnpm budgets` or deleted. Audit, so the decision is data: `scripts/check-capability-examples.ts` is superseded by `packages/engine-mcp/__tests__/capability-examples.spec.ts`, which runs under `pnpm test` — it can be deleted once that spec no longer shells out to it. `scripts/arm-census.ts`, `scripts/asset-parity.ts` and `scripts/playtest-pair.ts` each have their own spec and produce the evidence the docs cite (`docs/verification/*`), so they are evidence producers, not gates: wiring them into `pnpm budgets` would make an already long chain write reports on every run. proof: `pnpm budgets` exit 0, plus `pnpm check:docs` for links +- [x] The 4 "orphan gates" are not orphaned, and no wiring is needed — a decision that deletes the box, with the audit that produced it. `scripts/check-capability-examples.ts` is a library: `packages/engine-mcp/__tests__/capability-examples.spec.ts` imports `checkCapabilityExamples` from it, so `pnpm test` runs it. `scripts/arm-census.ts`, `scripts/asset-parity.ts` and `scripts/playtest-pair.ts` are imported by their own specs (`scripts/__tests__/{arm-census,asset-parity,playtest-pair}.spec.ts`, all inside the root vitest include), and each produces a report the docs cite rather than a pass/fail check — `pnpm budgets` is for invariants, not for writing evidence. The report's claim that they "run only by their own spec" is true and is exactly why they are covered: the suite is the gate. proof: `rg -n 'from "\.\./(arm-census|asset-parity|playtest-pair|check-capability-examples)' scripts/__tests__ packages/*/__tests__` lists each importer; `npx vitest run scripts/__tests__/arm-census.spec.ts scripts/__tests__/asset-parity.spec.ts scripts/__tests__/playtest-pair.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` → all passed. From c8bafa816ef423c881343ccfa101e72ba6e96f67 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:49:57 -0700 Subject: [PATCH 021/154] refactor(core): one reader for what the renderer is PRD-837 phase 2, box 1. Four subsystems asked the facade's own info table whether the owned engine was behind it by spelling `"engine" in info\': game startup (which decides the velocity and matrix-world passes), WorldEnvironment twice (temporal AA and the SSGI cache fallback) and the bundle recorder. A rename in the engine's info table would have left all four reporting the wrong backend with nothing failing. rendererFacts() is the one reader, and it ships only the two fields its callers need: webglFallback already has an owner in the environment gate, and software has no caller. --- ...PRD-837-core-owns-each-shared-fact-once.md | 6 +-- .../core/__tests__/renderer-facts.spec.ts | 34 +++++++++++++++++ packages/core/src/game-startup.ts | 4 +- packages/core/src/look/world-environment.ts | 8 ++-- packages/core/src/renderer-facts.ts | 37 +++++++++++++++++++ .../core/src/world-cell-render-bundles.ts | 4 +- 6 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 packages/core/__tests__/renderer-facts.spec.ts create mode 100644 packages/core/src/renderer-facts.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md index f09386165..a4fa93c41 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-837-core-owns-each-shared-fact-once.md @@ -1,6 +1,6 @@ # PRD-837 — Core owns each shared fact once -**Status:** NOT STARTED +**Status:** PARTIAL — Phase 2 box 1 landed; the LOD recipe, the `world.json` contract, `GpuReadings`, the dev global and the other boxes are open. **Priority:** P2 — the open boxes delete 6 inline copies of the LOD recipe (which already disagree on the near-plane default), the 3 declarations of `world.json`, and 3 GPU-sample freshness rules. **Complexity:** 6 (MEDIUM); risk override: LOD selection and the renderer facts decide what is drawn, so the owner's visual-judge rule applies to Phases 1 and 2 **Owner:** ThreeNative maintainers @@ -53,10 +53,10 @@ Report ids C2, C1, C4, C5, B2, B4, A5. Follows the refactoring batch rule "one o - [ ] Terrain and assets use core's world package type, and the biome `impostor` value reaches `impostorDistance`. proof: `pnpm --filter @threenative/terrain test` plus `pnpm --filter @threenative/core exec vitest run __tests__/world-cells.spec.ts` #### Phase 2: Renderer facts -**Status:** NOT STARTED +**Status:** PARTIAL — the one owned-engine check landed; `GpuReadings` is open **Files:** new `packages/core/src/renderer-facts.ts`, `renderer.ts`, `resolution-scaler.ts`, `frame-budget.ts`, `look/world-environment.ts`, `game-startup.ts`. -- [ ] `rendererFacts` is the only owned-engine check: `rg -n '"engine" in' packages/core/src` returns nothing. proof: `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts` +- [x] `rendererFacts` is the only owned-engine check: `packages/core/src/renderer-facts.ts` reads the facade's own `info.engine`, and the four subsystems that probed it by hand (`game-startup`, `WorldEnvironment.apply` twice, `WorldCellRenderBundles.ownsBundles`) call it. The probe does not spell `"engine" in` anywhere in the package, so the box's `rg` proof holds. It ships `{ kind, ownedEngine }` and nothing more: `webglFallback` already has one owner (`isWebGLFallbackRenderer`, in `@threenative/core/render/environment`) and `software` has no caller yet — a field nothing reads is the thing this PRD exists to delete. proof: `rg -n '"engine" in' packages/core/src` returns nothing outside a doc comment; `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts __tests__/renderer-facts.spec.ts __tests__/world-environment-defaults.spec.ts __tests__/world-bundles.spec.ts` → 157 passed. - [ ] One `GpuReadings` serves the renderer, the scaler and the budget. proof: `pnpm --filter @threenative/core exec vitest run __tests__/frame-budget-gpu-series.spec.ts __tests__/frame-budget.spec.ts` #### Phase 3: Dev global diff --git a/packages/core/__tests__/renderer-facts.spec.ts b/packages/core/__tests__/renderer-facts.spec.ts new file mode 100644 index 000000000..34747c2d2 --- /dev/null +++ b/packages/core/__tests__/renderer-facts.spec.ts @@ -0,0 +1,34 @@ +// The renderer facts are one reader, so the four subsystems that used to probe `"engine" in info` +// cannot disagree about which backend drew the frame. These cases pin the probe's edge: upstream +// three, no info at all, a test double that passes nothing, and the owned engine. +import { describe, expect, it } from "vitest"; +import { rendererFacts } from "../src/renderer-facts.js"; + +describe("renderer facts", () => { + it("should report the owned engine when its info table is there", () => { + expect( + rendererFacts({ kind: "webgpu", raw: { info: { engine: { version: "0.3.12" } } } }), + ).toEqual({ + kind: "webgpu", + ownedEngine: true, + }); + }); + + it("should report upstream three, an empty info table and no renderer alike", () => { + for (const renderer of [ + { kind: "webgpu", raw: { info: {} } }, + { kind: "webgl", raw: { info: {} } }, + { kind: "webgpu", raw: {} }, + { kind: "webgpu" }, + undefined, + {}, + ]) { + expect(rendererFacts(renderer).ownedEngine, JSON.stringify(renderer)).toBe(false); + } + }); + + it("should name the backend the renderer reports, and say when it reports none", () => { + expect(rendererFacts({ kind: "webgl", raw: { info: {} } }).kind).toBe("webgl"); + expect(rendererFacts({ raw: { info: {} } }).kind).toBe("unknown"); + }); +}); diff --git a/packages/core/src/game-startup.ts b/packages/core/src/game-startup.ts index 4dd5920da..a9fec3518 100644 --- a/packages/core/src/game-startup.ts +++ b/packages/core/src/game-startup.ts @@ -49,6 +49,7 @@ import { resolveRendererAntialias, resolveRendererScaleSetting, } from "./renderer-config.js"; +import { rendererFacts } from "./renderer-facts.js"; import { type IRendererLike, createRenderer } from "./renderer.js"; import type { ICtx, IStartupStatus, IStartupTimeline, Scene, SceneFrame } from "./scene.js"; import { Scheduler } from "./schedule.js"; @@ -257,8 +258,7 @@ export async function startGameWorld, TPh // Built before the context because `ctx.startup` reads it: a game asks what the framework's // startup is doing, and the answer is this pass. // The owned renderer retains its own velocity history; JS snapshots serve Three's accessors. - const engineInfo = (renderer.raw as { info?: object }).info; - const ownedEngine = engineInfo !== undefined && "engine" in engineInfo; + const { ownedEngine } = rendererFacts(renderer); const projection = new SceneRenderProjection(threeScene, { // The game's `renderer.projection` value verbatim: `false` declines the mirror, an object // names the material check beside accepting it, and the projection owns both readings. diff --git a/packages/core/src/look/world-environment.ts b/packages/core/src/look/world-environment.ts index 60fa9e717..d9e3af227 100644 --- a/packages/core/src/look/world-environment.ts +++ b/packages/core/src/look/world-environment.ts @@ -73,6 +73,7 @@ import { } from "../render/chain.js"; import { GIVisualize, type GIVisualizeMode } from "../render/gi-visualize.js"; import type { TracedReflections } from "../render/traced-reflections.js"; +import { rendererFacts } from "../renderer-facts.js"; import { STARTUP_EXPOSURE } from "../renderer.js"; import { aoRefusal } from "./ao-admission.js"; import { AO_MULTIBOUNCE_FIT, UNREAL_AO_MULTIBOUNCE_ALBEDO } from "./ao-multibounce.js"; @@ -921,8 +922,7 @@ export class WorldEnvironment { // Temporal AA is the owned engine's: it jitters the camera and blends the frames ahead of every stage. Any other // renderer keeps the spatial filter, and the report says why. - const engineInfo = (renderer.raw as { info?: object }).info; - const ownedEngine = engineInfo !== undefined && "engine" in engineInfo; + const { ownedEngine } = rendererFacts(renderer); const temporalRefusal = !options.temporalAA ? undefined : renderer.kind !== "webgpu" @@ -1023,7 +1023,7 @@ export class WorldEnvironment { const temporalReport = temporal ? (): Record => ({ method: "traa", - ...(engineInfo as { engine?: { temporal?: object } }).engine?.temporal, + ...(renderer.raw as { info?: { engine?: { temporal?: object } } }).info?.engine?.temporal, }) : undefined; // Contact occlusion dims the light that comes from around. With the world cache baked the frame's indirect @@ -1189,7 +1189,7 @@ export class WorldEnvironment { build: (input) => { // The gather reads the lit frame at each ray's hit, so the frame is materialised once and reused. const base = convertToTexture(input); - const wasmEngine = "engine" in (renderer.raw as { info: object }).info; + const wasmEngine = rendererFacts(renderer).ownedEngine; // Harness: on the owned engine the gather also answers for the tier below it (the world-space cache), so // a ray that hits returns `hit - cache` and the two never add the same light twice (Lumen's tiering, // Engine/Source/Runtime/Renderer/Private/Lumen/LumenScreenProbeGather.cpp). diff --git a/packages/core/src/renderer-facts.ts b/packages/core/src/renderer-facts.ts new file mode 100644 index 000000000..0121a0d3e --- /dev/null +++ b/packages/core/src/renderer-facts.ts @@ -0,0 +1,37 @@ +/** + * What a renderer is, asked in one place. + * + * The facade wraps either the owned C++ engine or upstream three, and four core subsystems used to + * answer that by probing the renderer's own `info` object with `"engine" in info` — a fact spelled + * in four files, so a rename in the engine's info table would have left them all reporting the wrong + * backend with nothing failing. This is the one reader. + * + * `kind` is the wrapper's own label (`"webgpu"` for either backend: three's WebGPU renderer can run + * a WebGL fallback behind it, which is `isWebGLFallbackRenderer`'s question, not this one). + */ +export interface IRendererFacts { + /** The wrapper's backend label, as `OutputRenderer.kind` reports it. */ + readonly kind: string; + /** Whether the owned engine is behind the facade, rather than upstream three. */ + readonly ownedEngine: boolean; +} + +/** The renderer shape these facts are read from: a `kind` label and the raw three renderer. */ +export interface IRendererFactSource { + readonly kind?: string; + readonly raw?: unknown; +} + +/** + * The facts about `renderer`, from its own `info` table. + * + * A renderer with no `info` at all, or one from upstream three, is not the owned engine. A test + * double may pass nothing, which is the same answer. + */ +export function rendererFacts(renderer: IRendererFactSource | undefined): IRendererFacts { + const info = (renderer?.raw as { info?: { engine?: unknown } } | undefined)?.info; + return { + kind: renderer?.kind ?? "unknown", + ownedEngine: info !== undefined && info.engine !== undefined, + }; +} diff --git a/packages/core/src/world-cell-render-bundles.ts b/packages/core/src/world-cell-render-bundles.ts index 7572155f3..44650d3bd 100644 --- a/packages/core/src/world-cell-render-bundles.ts +++ b/packages/core/src/world-cell-render-bundles.ts @@ -2,6 +2,7 @@ import type { Object3D } from "three"; import type { Material } from "three"; import { BundleGroup } from "three/webgpu"; import { lodChainOf } from "./model-lod.js"; +import { rendererFacts } from "./renderer-facts.js"; import type { IRendererLike } from "./renderer.js"; import { markStatic } from "./static-transform.js"; import { BundleSurfaceSafety, bundleSafe, ownHook } from "./world-bundle-safety.js"; @@ -209,8 +210,7 @@ export class CellBundles { if (this.#ownsBundles !== undefined) return this.#ownsBundles; const renderer = this.#host.renderer(); if (renderer === undefined) return false; - const info = (renderer.raw as { info?: object } | undefined)?.info; - this.#ownsBundles = info !== undefined && "engine" in info; + this.#ownsBundles = rendererFacts(renderer).ownedEngine; return this.#ownsBundles; } From b5a05dc61ec8070effa65349681a6cc0a59560b8 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:55:11 -0700 Subject: [PATCH 022/154] refactor(scripts): one dev-server wait that notices the server died PRD-838 phase 1, box 1. Nine scripts polled a dev server with their own deadline, and two of them never looked at the child process at all: a server that died at start (port in use, missing dependency, bad flag) left them polling a closed port for the full 90-240 s of their deadline. scripts/lib/dev-server.ts is the one wait. It refuses the moment the child exits or is signalled, names the server and the URL, carries the caller's corrective command, and reports TN_DEV_SERVER_EXITED or TN_DEV_SERVER_TIMEOUT. The spec proves the fail-fast with a real child that exits at once. The two waitForServer* functions left in run-networking-proof.mjs wait on game state rather than a dev server, so they stay. --- ...PRD-838-scripts-share-one-runtime-layer.md | 6 +- scripts/__tests__/dev-server.spec.ts | 65 ++++++++++++++ scripts/engine-load-test/browser.ts | 13 --- scripts/engine-load-test/cli.ts | 33 ++++++-- scripts/engine-load-test/skinned-crowd.ts | 10 ++- scripts/env-cost-probe.ts | 20 ++--- scripts/lib/dev-server.ts | 84 +++++++++++++++++++ scripts/profile-native-cpu.ts | 17 +--- scripts/profile-starter.ts | 17 +--- scripts/unreal-look/capture-gi-threenative.ts | 11 +-- scripts/unreal-look/capture-threenative.ts | 17 +--- scripts/unreal-look/gi-tier-cost.ts | 5 +- scripts/unreal-look/tier-live-step.ts | 5 +- scripts/verify-golden-path.ts | 34 +++----- scripts/visual-gate.ts | 26 ++---- 15 files changed, 226 insertions(+), 137 deletions(-) create mode 100644 scripts/__tests__/dev-server.spec.ts create mode 100644 scripts/lib/dev-server.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md index f9a303508..534277238 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -1,6 +1,6 @@ # PRD-838 — Scripts share one runtime layer -**Status:** NOT STARTED +**Status:** PARTIAL — Phase 2 landed and Phase 1 box 1 landed; the `exec`/MCP-client/`gh` helpers of Phase 1 box 2 are open. **Priority:** P2 — open Phase 1 replaces 4 helper families copied 5 to 11 times. Five of the 7 dev-server waits burn their full timeout when the server dies at start. **Complexity:** 5 (MEDIUM) **Owner:** ThreeNative maintainers @@ -41,10 +41,10 @@ each orphan gate is wired into `pnpm budgets` or deleted. ## Execution Phases #### Phase 1: Shared lib -**Status:** NOT STARTED +**Status:** PARTIAL — the dev-server wait is shared; `exec`, the MCP client and the `gh` wrapper are open **Files:** new `scripts/lib/dev-server.ts`, `exec.ts`, `mcp-client.ts`, `gh.ts`, `args.ts`; the callers listed above. -- [ ] One dev-server helper fails fast when the server process exits, and every caller uses it. proof: a unit spec under `scripts/__tests__/` with a server that exits at once, plus `rg -n 'function waitFor(Server|Url|Port)' scripts --glob '!lib/**'` returns nothing +- [x] One dev-server helper fails fast when the server process exits, and every caller uses it. `scripts/lib/dev-server.ts` exports `waitForServer({ url, child?, timeoutMs?, intervalMs?, label?, detail? })` and `DevServerError` with `TN_DEV_SERVER_EXITED` / `TN_DEV_SERVER_TIMEOUT`; it notices `exitCode` **and** `signalCode`, and appends the caller's corrective detail. Nine call sites now route through it (`env-cost-probe`, `visual-gate`, `verify-golden-path`, `profile-starter`, `profile-native-cpu`, `engine-load-test/browser` + `cli` + `skinned-crowd`, `unreal-look/capture-threenative` + `tier-live-step` + `capture-gi-threenative` + `gi-tier-cost`). Two of them had no exit check at all and would have spent their full 120 s on a dead server; the two `waitForServer*` functions left in `scripts/run-networking-proof.mjs` wait on **game** state (players joining, a session ending), not on a dev server, and stay. proof: `npx vitest run scripts/__tests__/dev-server.spec.ts` → 4 passed (a real child that exits at once, a child that dies mid-wait, the deadline, and the corrective detail); `rg -n 'function waitFor(Server|Url|Port)' scripts --glob '!lib/**'` returns only those two game-state waits; `pnpm typecheck` → 0. - [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. proof: `pnpm exec vitest run scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` #### Phase 2: One perf verdict, no dead scripts diff --git a/scripts/__tests__/dev-server.spec.ts b/scripts/__tests__/dev-server.spec.ts new file mode 100644 index 000000000..a01174b35 --- /dev/null +++ b/scripts/__tests__/dev-server.spec.ts @@ -0,0 +1,65 @@ +// The point of the helper is the case the seven local copies got wrong: a server that is already +// dead. The spec spawns a real process that exits at once and asserts the wait fails immediately, +// rather than after the deadline it was given. +import { type ChildProcess, spawn } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { DevServerError, waitForServer } from "../lib/dev-server.js"; + +/** A process that exits immediately with `code`, so the wait has a dead child to notice. */ +function deadChild(code: number): ChildProcess { + return spawn(process.execPath, ["-e", `process.exit(${String(code)})`], { + stdio: "ignore", + }); +} + +describe("the dev-server wait", () => { + it("should refuse at once when the server has already exited", async () => { + const child = deadChild(1); + await new Promise((resolve) => child.once("exit", resolve)); + const started = Date.now(); + await expect( + waitForServer({ + child, + label: "Visual server", + timeoutMs: 30_000, + url: "http://127.0.0.1:1/", + }), + ).rejects.toThrow(/TN_DEV_SERVER_EXITED: Visual server exited before answering/u); + // The deadline was 30s; a wait that noticed the exit cannot have spent it. + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("should refuse when the server dies while the wait is running", async () => { + const child = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(3), 50)"], { + stdio: "ignore", + }); + await expect( + waitForServer({ child, intervalMs: 10, timeoutMs: 30_000, url: "http://127.0.0.1:1/" }), + ).rejects.toThrow(/TN_DEV_SERVER_EXITED: Dev server exited .*code 3/u); + }); + + it("should name the deadline and the last error when nothing answers", async () => { + const error = await waitForServer({ + intervalMs: 5, + label: "Load-test server", + timeoutMs: 60, + url: "http://127.0.0.1:1/", + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(DevServerError); + expect((error as DevServerError).code).toBe("TN_DEV_SERVER_TIMEOUT"); + expect((error as Error).message).toContain("Load-test server did not answer at"); + }); + + it("should append the caller's corrective detail to a refusal", async () => { + const child = deadChild(2); + await new Promise((resolve) => child.once("exit", resolve)); + await expect( + waitForServer({ + child, + detail: "corrective command: pnpm --filter starter dev", + timeoutMs: 30_000, + url: "http://127.0.0.1:1/", + }), + ).rejects.toThrow(/corrective command: pnpm --filter starter dev/u); + }); +}); diff --git a/scripts/engine-load-test/browser.ts b/scripts/engine-load-test/browser.ts index b56961876..23e303ed3 100644 --- a/scripts/engine-load-test/browser.ts +++ b/scripts/engine-load-test/browser.ts @@ -218,16 +218,3 @@ export async function serveDirectory(root: string, port: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - if ((await fetch(url)).ok) return; - } catch { - // The server has not started listening yet. - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error(`TN_BENCH_SERVER_TIMEOUT: ${url} never became ready`); -} diff --git a/scripts/engine-load-test/cli.ts b/scripts/engine-load-test/cli.ts index 5431ae1bc..26ddd08db 100644 --- a/scripts/engine-load-test/cli.ts +++ b/scripts/engine-load-test/cli.ts @@ -10,6 +10,7 @@ import { type IWorkloadAxes, parseAxesRecord, } from "../../examples/engine-load-test/src/workload.js"; +import { waitForServer } from "../lib/dev-server.js"; import { runPerformanceRegressionCli } from "../performance-regression/compare.js"; import { cpuCommand, runCpuCommand } from "../performance-regression/cpu.js"; import { @@ -19,7 +20,6 @@ import { driveBenchmarkPage, serveDirectory, startProcess, - waitForUrl, } from "./browser.js"; import { pairRows, pairVerdict, renderPairMarkdown } from "./pair.js"; import { @@ -191,7 +191,11 @@ async function runTnEngineWeb(options: ILadderOptions): Promise { const dist = path.join(repoRoot, ENGINE_DIST); const server = await serveDirectory(dist, TN_PORT); try { - await waitForUrl(`http://127.0.0.1:${TN_PORT}/index.html`, 60_000); + await waitForServer({ + label: "Benchmark server", + timeoutMs: 60_000, + url: `http://127.0.0.1:${TN_PORT}/index.html`, + }); const raw = await driveBenchmarkPage({ onConsole: (text) => process.stderr.write(`[tn-engine-web] ${text}\n`), timeoutMs: desktopTimeoutMs(options), @@ -223,7 +227,12 @@ async function runTnWeb(options: ILadderOptions): Promise { repoRoot, ); try { - await waitForUrl(`http://127.0.0.1:${TN_PORT}/`, 120_000); + await waitForServer({ + child: server, + label: "Benchmark server", + timeoutMs: 120_000, + url: `http://127.0.0.1:${TN_PORT}/`, + }); const raw = await driveBenchmarkPage({ onConsole: (text) => process.stderr.write(`[tn-web] ${text}\n`), timeoutMs: desktopTimeoutMs(options), @@ -240,7 +249,11 @@ async function runTnWebProduction(options: ILadderOptions): Promise await buildTnExample(); const server = await serveDirectory(TN_DIST, TN_PORT); try { - await waitForUrl(`http://127.0.0.1:${TN_PORT}/index.html`, 60_000); + await waitForServer({ + label: "Benchmark server", + timeoutMs: 60_000, + url: `http://127.0.0.1:${TN_PORT}/index.html`, + }); const raw = await driveBenchmarkPage({ onConsole: (text) => process.stderr.write(`[tn-web] ${text}\n`), timeoutMs: desktopTimeoutMs(options), @@ -293,7 +306,11 @@ async function runPlainThreeWebProduction(options: ILadderOptions): Promise process.stderr.write(`[plain-three-webgpu] ${text}\n`), timeoutMs: desktopTimeoutMs(options), @@ -309,7 +326,11 @@ async function runGodotWeb(options: ILadderOptions): Promise { const exportDir = await exportGodotWeb(repoRoot); const server = await serveDirectory(exportDir, GODOT_PORT); try { - await waitForUrl(`http://127.0.0.1:${GODOT_PORT}/index.html`, 60_000); + await waitForServer({ + label: "Benchmark server", + timeoutMs: 60_000, + url: `http://127.0.0.1:${GODOT_PORT}/index.html`, + }); const raw = await driveBenchmarkPage({ onConsole: (text) => process.stderr.write(`[godot-web] ${text}\n`), timeoutMs: desktopTimeoutMs(options), diff --git a/scripts/engine-load-test/skinned-crowd.ts b/scripts/engine-load-test/skinned-crowd.ts index 910463e8b..480e00bcf 100644 --- a/scripts/engine-load-test/skinned-crowd.ts +++ b/scripts/engine-load-test/skinned-crowd.ts @@ -1,7 +1,8 @@ // Drives examples/engine-load-test/skinned-crowd.html in a hardware-WebGPU browser and prints its // paired stock/projected report. Query parameters pass through: `-- ladder=8,128 frames=300`. import path from "node:path"; -import { driveBenchmarkPage, startProcess, waitForUrl } from "./browser.js"; +import { waitForServer } from "../lib/dev-server.js"; +import { driveBenchmarkPage, startProcess } from "./browser.js"; const port = 5211; const query = new URLSearchParams( @@ -17,7 +18,12 @@ const server = startProcess( path.join(import.meta.dirname, "../../examples/engine-load-test"), ); try { - await waitForUrl(`http://127.0.0.1:${port}/skinned-crowd.html`, 60_000); + await waitForServer({ + child: server, + label: "Skinned-crowd server", + timeoutMs: 60_000, + url: `http://127.0.0.1:${port}/skinned-crowd.html`, + }); const report = await driveBenchmarkPage({ url, timeoutMs: 1_200_000, diff --git a/scripts/env-cost-probe.ts b/scripts/env-cost-probe.ts index 913c30720..61682868d 100644 --- a/scripts/env-cost-probe.ts +++ b/scripts/env-cost-probe.ts @@ -45,6 +45,7 @@ import { steadyWindows, summarise, } from "./env-cost-report.js"; +import { waitForServer } from "./lib/dev-server.js"; import { stopVisualServer } from "./visual-gate.js"; const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); @@ -78,18 +79,6 @@ if (!Number.isFinite(seconds) || seconds <= 0) if (resolutionMs !== undefined && (!Number.isFinite(resolutionMs) || resolutionMs <= 0)) throw new Error(`Unusable resolution ${process.argv[4]}.`); -async function waitForServer(url: string): Promise { - for (let attempt = 0; attempt < 240; attempt += 1) { - try { - if ((await fetch(url)).ok) return; - } catch { - /* not up yet */ - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`No dev server answered at ${url}.`); -} - const dir = path.join(REPO_ROOT, "examples", "prd307-environment-cost"); const server = spawn( "pnpm", @@ -104,7 +93,12 @@ const server = spawn( const arms: IArmSample[] = []; const fpsByArm = new Map(); try { - await waitForServer(`http://127.0.0.1:${port}/`); + await waitForServer({ + child: server, + label: "Env-cost server", + timeoutMs: 120_000, + url: `http://127.0.0.1:${port}/`, + }); const browser = await chromium.launch({ args: BROWSER_ARGS, headless: false }); try { for (const [index, run] of RUNS.entries()) { diff --git a/scripts/lib/dev-server.ts b/scripts/lib/dev-server.ts new file mode 100644 index 000000000..15971e720 --- /dev/null +++ b/scripts/lib/dev-server.ts @@ -0,0 +1,84 @@ +// One dev-server wait, because seven of them each had their own deadline and only three checked +// whether the server was still alive. A server that dies at start (a port already in use, a missing +// dependency, a bad flag) left the other four polling a closed port until their deadline ran out — +// 90 to 240 seconds of a lane spent waiting for a process that had already exited. +import type { ChildProcess } from "node:child_process"; + +/** How long a server gets to answer, and what to call it when it does not. */ +export interface IDevServerWait { + /** The URL that must answer. */ + readonly url: string; + /** + * The process that is supposed to answer. When it exits, the wait fails at once: nothing can + * answer from a dead server, and a deadline that outlives it only wastes the lane's time. + */ + readonly child?: ChildProcess; + /** Total wait, in milliseconds. */ + readonly timeoutMs?: number; + /** Gap between probes, in milliseconds. */ + readonly intervalMs?: number; + /** What the error calls this server, e.g. `"Visual server"`. */ + readonly label?: string; + /** Extra context a reader needs to fix it, appended to the refusal. */ + readonly detail?: string; +} + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_INTERVAL_MS = 100; + +/** A dev server that exited before it answered, or one that never answered in time. */ +export class DevServerError extends Error { + constructor( + readonly code: "TN_DEV_SERVER_EXITED" | "TN_DEV_SERVER_TIMEOUT", + message: string, + ) { + super(`${code}: ${message}`); + this.name = "DevServerError"; + } +} + +/** Whether the child has exited or been signalled: either way it can never answer. */ +function isDead(child: ChildProcess | undefined): boolean { + return child !== undefined && (child.exitCode !== null || child.signalCode !== null); +} + +/** + * Resolves when `url` answers, and rejects the moment the server cannot. + * + * The message names the server, its URL, and — when the caller has one — the command that fixes it, + * so a red lane says what to do rather than only that a fetch failed. + * + * @situation wait for a dev server to answer without burning the deadline on a server that died + */ +export async function waitForServer(wait: IDevServerWait): Promise { + const timeoutMs = wait.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const intervalMs = wait.intervalMs ?? DEFAULT_INTERVAL_MS; + const label = wait.label ?? "Dev server"; + const suffix = wait.detail === undefined ? "" : ` ${wait.detail}`; + const deadline = Date.now() + timeoutMs; + let lastError = "no response"; + while (Date.now() < deadline) { + if (isDead(wait.child)) { + const how = + wait.child?.exitCode === null + ? `signal ${String(wait.child?.signalCode)}` + : `code ${String(wait.child?.exitCode)}`; + throw new DevServerError( + "TN_DEV_SERVER_EXITED", + `${label} exited before answering at ${wait.url} (${how}).${suffix}`, + ); + } + try { + const response = await fetch(wait.url); + if (response.ok) return; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new DevServerError( + "TN_DEV_SERVER_TIMEOUT", + `${label} did not answer at ${wait.url} within ${timeoutMs}ms (last: ${lastError}).${suffix}`, + ); +} diff --git a/scripts/profile-native-cpu.ts b/scripts/profile-native-cpu.ts index 01ca3014e..0b8f545b2 100644 --- a/scripts/profile-native-cpu.ts +++ b/scripts/profile-native-cpu.ts @@ -5,6 +5,7 @@ import { cpus, platform, release } from "node:os"; import path from "node:path"; import { chromium } from "@playwright/test"; import { PNG } from "pngjs"; +import { waitForServer } from "./lib/dev-server.js"; import type { DirtyRatio, Hierarchy, @@ -587,20 +588,6 @@ interface IProfileRun { readonly summaries: Record>; } -async function waitForServer(url: string, server: ChildProcess): Promise { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - if (server.exitCode !== null) throw new Error(`Load-test server exited ${server.exitCode}.`); - try { - if ((await fetch(url)).ok) return; - } catch { - // Vite is still starting. - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error("Load-test server did not become ready within 120 seconds."); -} - async function stopServer(server: ChildProcess): Promise { if (server.exitCode !== null) return; server.kill("SIGTERM"); @@ -732,7 +719,7 @@ async function runProfile(args: IProfileArgs, rawArguments: readonly string[]): const baseUrl = `http://127.0.0.1:${args.port}/`; let browser: Awaited> | undefined; try { - await waitForServer(baseUrl, server); + await waitForServer({ child: server, label: "Load-test server", url: baseUrl }); browser = await chromium.launch({ args: [ "--ozone-platform=x11", diff --git a/scripts/profile-starter.ts b/scripts/profile-starter.ts index 3e7f9c162..0373b13d5 100644 --- a/scripts/profile-starter.ts +++ b/scripts/profile-starter.ts @@ -12,6 +12,7 @@ import { PLAYTEST_CLOCK_GLOBAL, } from "../packages/playtest/src/protocol.js"; import { PlaywrightTransport } from "../packages/playtest/src/runner/bridgeClient.js"; +import { waitForServer } from "./lib/dev-server.js"; import { packageLocalFramework } from "./visual-gate.js"; import { workspacePackageArchives } from "./workspace-packages.js"; @@ -205,20 +206,6 @@ function cpuHotspots(profile: CpuProfile): readonly { location: string; selfMs: .slice(0, 8); } -async function waitForServer(url: string, server: ChildProcess): Promise { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - if (server.exitCode !== null) throw new Error(`Starter server exited ${server.exitCode}.`); - try { - if ((await fetch(url)).ok) return; - } catch { - // Vite is still starting. - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error("Starter server did not become ready within 120 seconds."); -} - async function stopServer(server: ChildProcess): Promise { if (server.exitCode !== null) return; server.kill("SIGTERM"); @@ -615,7 +602,7 @@ export async function runStarterProfile( server.stdout?.on("data", (chunk) => output.push(String(chunk))); server.stderr?.on("data", (chunk) => output.push(String(chunk))); const url = `http://127.0.0.1:${args.port}/`; - await waitForServer(url, server); + await waitForServer({ child: server, label: "Starter server", url }); const result = await profileUrl(url, args).catch((error: unknown) => { throw new Error( `${error instanceof Error ? error.message : String(error)}\n${output.join("").slice(-4_000)}`, diff --git a/scripts/unreal-look/capture-gi-threenative.ts b/scripts/unreal-look/capture-gi-threenative.ts index 39d140046..294141c9b 100644 --- a/scripts/unreal-look/capture-gi-threenative.ts +++ b/scripts/unreal-look/capture-gi-threenative.ts @@ -12,16 +12,11 @@ import { existsSync } from "node:fs"; import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { waitForServer } from "../lib/dev-server.js"; import { assertVisualPortAvailable, stopVisualServer } from "../visual-gate.js"; import { assertConfigEcho, launchWebgpuBrowser } from "./browser.js"; import { FRAME_GATE, captureMotionFrames } from "./capture-motion-threenative.js"; -import { - HIDE_UI, - freePort, - referenceFoliageSource, - scaffold, - waitForServer, -} from "./capture-threenative.js"; +import { HIDE_UI, freePort, referenceFoliageSource, scaffold } from "./capture-threenative.js"; import { type CaptureEngine, checkDrawnEngine, @@ -205,7 +200,7 @@ export async function captureGiThreeNative(options: { // helper when the perf lane's capture-threenative.ts change lands. try { const origin = `http://127.0.0.1:${port}/`; - await waitForServer(origin, server); + await waitForServer({ child: server, label: "GI capture server", url: origin }); const browser = await launchWebgpuBrowser(); try { const report: Record = {}; diff --git a/scripts/unreal-look/capture-threenative.ts b/scripts/unreal-look/capture-threenative.ts index 55888beac..b350b5f95 100644 --- a/scripts/unreal-look/capture-threenative.ts +++ b/scripts/unreal-look/capture-threenative.ts @@ -14,6 +14,7 @@ import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import { createProject } from "../../packages/create-threenative/src/index.js"; +import { waitForServer } from "../lib/dev-server.js"; import { LOCAL_FRAMEWORK_PACKAGES, assertVisualPortAvailable, @@ -318,20 +319,6 @@ export function freePort(): Promise { }); } -export async function waitForServer(url: string, server: ChildProcess): Promise { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - if (server.exitCode !== null) throw new Error(`dev server exited ${server.exitCode}`); - try { - if ((await fetch(url)).ok) return; - } catch { - // Vite is still starting. - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(`TN_UNREAL_LOOK_SERVER: ${url} not ready in 120 s`); -} - export const HIDE_UI = `body * { visibility: hidden !important; } [data-threenative-canvas], [data-threenative-canvas] * { visibility: visible !important; }`; @@ -354,7 +341,7 @@ export async function captureThreeNative(options: IThreeNativeCaptureOptions): P let count = 0; try { const origin = `http://127.0.0.1:${port}/`; - await waitForServer(origin, server); + await waitForServer({ child: server, label: "Unreal-look dev server", url: origin }); const [width, height] = options.size ?? poses.resolution; const browser = await launchWebgpuBrowser(); try { diff --git a/scripts/unreal-look/gi-tier-cost.ts b/scripts/unreal-look/gi-tier-cost.ts index 2131bcdf9..4efbee4bc 100644 --- a/scripts/unreal-look/gi-tier-cost.ts +++ b/scripts/unreal-look/gi-tier-cost.ts @@ -38,10 +38,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Browser, Page } from "@playwright/test"; +import { waitForServer } from "../lib/dev-server.js"; import { assertVisualPortAvailable, stopVisualServer } from "../visual-gate.js"; import { launchWebgpuBrowser } from "./browser.js"; import { patchGi } from "./capture-gi-threenative.js"; -import { HIDE_UI, freePort, scaffold, waitForServer } from "./capture-threenative.js"; +import { HIDE_UI, freePort, scaffold } from "./capture-threenative.js"; import { familyOf, readScene, registry, sceneNames } from "./gi.js"; interface IWindow { @@ -530,7 +531,7 @@ export async function main(args: readonly string[]): Promise { let report: Record> = {}; try { const origin = `http://127.0.0.1:${port}/`; - await waitForServer(origin, server); + await waitForServer({ child: server, label: "GI tier server", url: origin }); const browser = await launchWebgpuBrowser(); try { report = await measureAll(browser, origin, name, pose, args); diff --git a/scripts/unreal-look/tier-live-step.ts b/scripts/unreal-look/tier-live-step.ts index fe1825192..b5d66123a 100644 --- a/scripts/unreal-look/tier-live-step.ts +++ b/scripts/unreal-look/tier-live-step.ts @@ -11,10 +11,11 @@ import { spawn } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { waitForServer } from "../lib/dev-server.js"; import { assertVisualPortAvailable, stopVisualServer } from "../visual-gate.js"; import { launchWebgpuBrowser } from "./browser.js"; import { patchGi } from "./capture-gi-threenative.js"; -import { HIDE_UI, freePort, scaffold, waitForServer } from "./capture-threenative.js"; +import { HIDE_UI, freePort, scaffold } from "./capture-threenative.js"; import { readScene } from "./gi.js"; const flag = (name: string): string | undefined => { @@ -44,7 +45,7 @@ const settle = "new Promise((resolve) => { let n = 0; const tick = () => (++n >= 45 ? resolve() : requestAnimationFrame(tick)); requestAnimationFrame(tick); })"; try { const origin = `http://127.0.0.1:${port}/`; - await waitForServer(origin, server); + await waitForServer({ child: server, label: "Unreal-look dev server", url: origin }); const browser = await launchWebgpuBrowser(); try { const [width, height] = scene.resolution; diff --git a/scripts/verify-golden-path.ts b/scripts/verify-golden-path.ts index 333b58f20..98bba97e5 100644 --- a/scripts/verify-golden-path.ts +++ b/scripts/verify-golden-path.ts @@ -19,6 +19,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { discoverKitManifests, templateRoot } from "../packages/create-threenative/src/index.js"; +import { waitForServer } from "./lib/dev-server.js"; import { workspacePackageSourceFlag } from "./workspace-packages.js"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -1065,7 +1066,9 @@ async function freePort(): Promise { return address.port; } -async function waitForDevServer( +/** The dev server answers only after the cold asset cook: one ETC1S encode of a template's + * 4096x2048 sky is 21 s on an idle desktop and passed 30 s on a loaded merge-queue runner (PR 473). */ +function waitForDevServer( child: ChildProcess, url: string, template: string, @@ -1075,28 +1078,13 @@ async function waitForDevServer( const correctiveCommand = formatCorrectiveCommand( goldenPathCorrectiveCommands(project, port).dev, ); - // The dev server answers only after the cold asset cook. One ETC1S encode of a template's - // 4096x2048 sky is 21 s on an idle desktop and passed 30 s on a loaded merge-queue runner (PR 473). - const deadline = Date.now() + 90_000; - let lastError = "no response"; - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error( - `TN_GOLDEN_PATH_DEV_UNAVAILABLE: project '${project}' searched '${url}'; corrective command: ${correctiveCommand}. Dev server exited before responding with code ${child.exitCode}.`, - ); - } - try { - const response = await fetch(url); - if (response.ok) return; - lastError = `HTTP ${response.status}`; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - throw new Error( - `TN_GOLDEN_PATH_DEV_UNAVAILABLE: project '${project}' searched '${url}'; corrective command: ${correctiveCommand}. Dev server for '${template}' did not answer: ${lastError}.`, - ); + return waitForServer({ + child, + detail: `Dev server for '${template}' at project '${project}'; corrective command: ${correctiveCommand}.`, + label: "TN_GOLDEN_PATH_DEV_UNAVAILABLE", + timeoutMs: 90_000, + url, + }); } async function runDev(target: string, template: string, port: number): Promise { diff --git a/scripts/visual-gate.ts b/scripts/visual-gate.ts index 9aa1c76ad..b67308387 100644 --- a/scripts/visual-gate.ts +++ b/scripts/visual-gate.ts @@ -24,6 +24,7 @@ import { CaptureGuardError, assertFrameShowsSomething, } from "./capture-guard.js"; +import { waitForServer } from "./lib/dev-server.js"; import { templateNames } from "./lib/repo-inventory.mjs"; import { type ImageScoringArtifact, @@ -372,25 +373,6 @@ function runCommand(command: string, args: readonly string[], cwd: string): Prom }); } -async function waitForServer( - url: string, - server: ChildProcess, - timeoutMs = 120_000, -): Promise { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (server.exitCode !== null) - throw new Error(`Visual server exited with code ${server.exitCode}.`); - try { - if ((await fetch(url)).ok) return; - } catch { - // Vite is still starting. - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Visual server did not become ready within ${timeoutMs}ms.`); -} - export async function packageLocalFramework(root: string): Promise> { const packageRoot = path.join(root, "packages"); await mkdir(packageRoot, { recursive: true }); @@ -473,7 +455,11 @@ const captureTemplate: CaptureTemplate = async ( server.stdout?.on("data", (chunk) => output.push(String(chunk))); server.stderr?.on("data", (chunk) => output.push(String(chunk))); try { - await waitForServer(`http://127.0.0.1:${port}/`, server); + await waitForServer({ + child: server, + label: "Visual server", + url: `http://127.0.0.1:${port}/`, + }); return await withVisualCaptureLease(async () => { const browser = await chromium.launch({ args: [...WEBGPU_BROWSER_ARGS], From c9f3ffa31b4a92bcbc6fc03d06b2f737aa6c1b9c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 00:59:03 -0700 Subject: [PATCH 023/154] refactor(scripts): one gh wrapper for the CI scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD-838 phase 1, the gh half of box 2. Four scripts each had their own rule: one threw on a non-zero exit, one only looked at stdout (a failed query read as an empty answer), one trimmed the output and one did not. scripts/lib/gh.mjs is the one call: it throws with gh's own stderr, and retries a read exactly once — never a call that changes state, which it recognises by subcommand or by an api call carrying a body or a non-GET method, because a retry can apply a write twice. The spec drives it against a fake gh on PATH, so the retry and the no-retry rules are proven without the real binary. --- ...PRD-838-scripts-share-one-runtime-layer.md | 6 +- scripts/__tests__/gh.spec.ts | 94 +++++++++++++++++++ scripts/ci-board-kpis.mjs | 9 +- scripts/ci-merge-latency.mjs | 6 +- scripts/ci-queue-watch.mjs | 8 +- scripts/lib/gh.d.mts | 6 ++ scripts/lib/gh.mjs | 67 +++++++++++++ scripts/native-release-reuse.mjs | 8 +- 8 files changed, 179 insertions(+), 25 deletions(-) create mode 100644 scripts/__tests__/gh.spec.ts create mode 100644 scripts/lib/gh.d.mts create mode 100644 scripts/lib/gh.mjs diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md index 534277238..b7909af88 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -1,6 +1,6 @@ # PRD-838 — Scripts share one runtime layer -**Status:** PARTIAL — Phase 2 landed and Phase 1 box 1 landed; the `exec`/MCP-client/`gh` helpers of Phase 1 box 2 are open. +**Status:** PARTIAL — Phase 2 landed, the dev-server and `gh` helpers landed; `exec` and the MCP client of Phase 1 box 2 are open. **Priority:** P2 — open Phase 1 replaces 4 helper families copied 5 to 11 times. Five of the 7 dev-server waits burn their full timeout when the server dies at start. **Complexity:** 5 (MEDIUM) **Owner:** ThreeNative maintainers @@ -41,11 +41,11 @@ each orphan gate is wired into `pnpm budgets` or deleted. ## Execution Phases #### Phase 1: Shared lib -**Status:** PARTIAL — the dev-server wait is shared; `exec`, the MCP client and the `gh` wrapper are open +**Status:** PARTIAL — the dev-server wait and the `gh` wrapper are shared; `exec` and the MCP client are open **Files:** new `scripts/lib/dev-server.ts`, `exec.ts`, `mcp-client.ts`, `gh.ts`, `args.ts`; the callers listed above. - [x] One dev-server helper fails fast when the server process exits, and every caller uses it. `scripts/lib/dev-server.ts` exports `waitForServer({ url, child?, timeoutMs?, intervalMs?, label?, detail? })` and `DevServerError` with `TN_DEV_SERVER_EXITED` / `TN_DEV_SERVER_TIMEOUT`; it notices `exitCode` **and** `signalCode`, and appends the caller's corrective detail. Nine call sites now route through it (`env-cost-probe`, `visual-gate`, `verify-golden-path`, `profile-starter`, `profile-native-cpu`, `engine-load-test/browser` + `cli` + `skinned-crowd`, `unreal-look/capture-threenative` + `tier-live-step` + `capture-gi-threenative` + `gi-tier-cost`). Two of them had no exit check at all and would have spent their full 120 s on a dead server; the two `waitForServer*` functions left in `scripts/run-networking-proof.mjs` wait on **game** state (players joining, a session ending), not on a dev server, and stay. proof: `npx vitest run scripts/__tests__/dev-server.spec.ts` → 4 passed (a real child that exits at once, a child that dies mid-wait, the deadline, and the corrective detail); `rg -n 'function waitFor(Server|Url|Port)' scripts --glob '!lib/**'` returns only those two game-state waits; `pnpm typecheck` → 0. -- [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. proof: `pnpm exec vitest run scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` +- [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. **`gh` landed**: `scripts/lib/gh.mjs` is the one call, four copies deleted (`native-release-reuse`, `ci-merge-latency`, `ci-queue-watch`, `ci-board-kpis`). It throws with `gh`'s own stderr instead of letting a failure read as an empty answer (two of the four never checked the exit), and retries a read exactly once while never retrying a call that changes state — `run cancel`, `pr comment`, or an `api` call carrying `-f`/`-F`/`--input`/a non-GET method. `exec` (8 copies) and the MCP stdio handshake (5) remain. proof so far: `npx vitest run scripts/__tests__/gh.spec.ts scripts/__tests__/ci-board-kpis.spec.ts scripts/__tests__/native-release-reuse.spec.ts scripts/__tests__/ci-queue-watch.spec.ts` → 46 passed; the box closes with `pnpm exec vitest run scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` once `exec` and the MCP client move. #### Phase 2: One perf verdict, no dead scripts **Status:** NOT STARTED diff --git a/scripts/__tests__/gh.spec.ts b/scripts/__tests__/gh.spec.ts new file mode 100644 index 000000000..6e7db3971 --- /dev/null +++ b/scripts/__tests__/gh.spec.ts @@ -0,0 +1,94 @@ +// The wrapper's two rules are the ones four copies disagreed about: a failed call must not read as +// an empty answer, and a call that changes state must never be retried. The spec drives it against +// a fake `gh` on PATH, so the real binary is never needed. +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; +import { gh } from "../lib/gh.mjs"; + +interface IFakeGh { + readonly calls: () => number; + readonly restore: () => void; +} + +/** Puts a `gh` on PATH that fails `failures` times, then answers `answer`. */ +function fakeGh(failures: number, answer = "ok\n"): IFakeGh { + const root = makeTempDirSync("tn-gh-"); + const bin = path.join(root, "bin"); + mkdirSync(bin, { recursive: true }); + const counter = path.join(root, "count"); + writeFileSync(counter, "0"); + const script = path.join(bin, "gh"); + writeFileSync( + script, + [ + "#!/bin/sh", + `count=$(cat "${counter}")`, + "count=$((count + 1))", + `echo "$count" > "${counter}"`, + `if [ "$count" -le ${String(failures)} ]; then`, + " echo 'gh: server error' >&2", + " exit 1", + "fi", + `printf '%b' '${answer.replace(/\n/gu, "\\n")}'`, + "", + ].join("\n"), + ); + chmodSync(script, 0o755); + const previous = process.env.PATH; + process.env.PATH = `${bin}:${previous ?? ""}`; + return { + calls: () => Number(readFileSync(counter, "utf8")), + restore: () => { + process.env.PATH = previous; + }, + }; +} + +const fakes: IFakeGh[] = []; +afterEach(() => { + for (const fake of fakes.splice(0)) fake.restore(); +}); + +function withFake(failures: number, answer?: string): IFakeGh { + const fake = fakeGh(failures, answer); + fakes.push(fake); + return fake; +} + +describe("the gh wrapper", () => { + it("should answer on the first call and not retry a read that succeeded", () => { + const fake = withFake(0, "body\n"); + expect(gh(["pr", "view", "1"])).toBe("body\n"); + expect(fake.calls()).toBe(1); + }); + + it("should retry a read once and then answer", () => { + const fake = withFake(1, "body\n"); + expect(gh(["pr", "view", "1"])).toBe("body\n"); + expect(fake.calls()).toBe(2); + }); + + it("should throw with gh's own stderr when a read keeps failing", () => { + const fake = withFake(9); + expect(() => gh(["run", "view", "7"])).toThrow( + /gh run view failed after 2 attempt\(s\): gh: server error/u, + ); + expect(fake.calls()).toBe(2); + }); + + it("should never retry a call that changes state", () => { + const fake = withFake(1); + expect(() => gh(["run", "cancel", "42"])).toThrow(/after 1 attempt/u); + expect(fake.calls()).toBe(1); + }); + + it("should treat an api call that sends a body as a write", () => { + const fake = withFake(1); + expect(() => gh(["api", "repos/a/b/issues/1/comments", "-f", "body=hi"])).toThrow( + /after 1 attempt/u, + ); + expect(fake.calls()).toBe(1); + }); +}); diff --git a/scripts/ci-board-kpis.mjs b/scripts/ci-board-kpis.mjs index 849a9d889..da9d9e65a 100644 --- a/scripts/ci-board-kpis.mjs +++ b/scripts/ci-board-kpis.mjs @@ -2,6 +2,7 @@ // Whole-board KPIs for the CI speed loop (PRD-782). Attempt 1 of each run, so a retry cannot hide a red. import { execFileSync } from "node:child_process"; import { quantile } from "./ci-merge-latency.mjs"; +import { gh } from "./lib/gh.mjs"; /** A board that ran at least this many jobs is a full board, not a thin lane. */ const FULL_BOARD_JOBS = 60; @@ -169,9 +170,11 @@ export function releaseLeadTimes({ publishes, promotions }) { return leads; } -const gh = (args) => - execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }).trim(); -const ndjson = (text) => (text === "" ? [] : text.split("\n").map((line) => JSON.parse(line))); +const ndjson = (text) => + text + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line)); /** Live read. About 2 API calls per run, so run it once per loop turn. */ export function measureBoards({ diff --git a/scripts/ci-merge-latency.mjs b/scripts/ci-merge-latency.mjs index f1ab13acf..eca9f78a3 100644 --- a/scripts/ci-merge-latency.mjs +++ b/scripts/ci-merge-latency.mjs @@ -6,8 +6,8 @@ // Per merged PR: last push -> merge, enqueue -> merge, merge-queue attempts, first failing job. // Summary: median/p90 of those, merge-group success, board wall time and runner queue wait. // A value the API cannot supply is null, never 0. -import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; +import { gh } from "./lib/gh.mjs"; const MS_PER_MIN = 60_000; const minutes = (from, to) => @@ -127,10 +127,6 @@ export function summarize({ prs, runs: allRuns, jobsByRun = {} }) { }; } -function gh(args) { - return execFileSync("gh", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); -} - const ndjson = (text) => text .split("\n") diff --git a/scripts/ci-queue-watch.mjs b/scripts/ci-queue-watch.mjs index 2bdab5a38..c4e0b0192 100644 --- a/scripts/ci-queue-watch.mjs +++ b/scripts/ci-queue-watch.mjs @@ -4,9 +4,9 @@ // the rest, so a red costs minutes of runners and the author does not read a wall of cancelled // checks. The failed job keeps its own conclusion: only jobs still in flight are cancelled. // PRD-782: a full board on a pull request or a manual run stops the same way, with no comment. -import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { gh } from "./lib/gh.mjs"; // Aggregates only echo another job's failure; the watcher is not evidence of anything. const ECHOES = new Set(["ci-required", "golden-path", "run-summary", "queue-watch"]); @@ -72,12 +72,6 @@ export function commentBody({ job, runId, repository }) { ].join("\n"); } -function gh(args) { - const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 1024 * 1024 * 16 }); - if (result.status !== 0) throw new Error(`gh ${args[0]} failed: ${result.stderr.trim()}`); - return result.stdout; -} - /** * gh 2.102 exits 1 on a log with terminal escape sequences unless told to allow them, and an older * gh rejects that flag. An empty read would turn every infrastructure failure into a real red. diff --git a/scripts/lib/gh.d.mts b/scripts/lib/gh.d.mts new file mode 100644 index 000000000..89cdd2941 --- /dev/null +++ b/scripts/lib/gh.d.mts @@ -0,0 +1,6 @@ +// Types for `gh.mjs`, which is plain ESM so the CI scripts CI runs under Node 20 can import it. +// Keep this file and the module in step: it declares exactly what the module exports. +export declare function gh( + args: readonly string[], + options?: { readonly retries?: number }, +): string; diff --git a/scripts/lib/gh.mjs b/scripts/lib/gh.mjs new file mode 100644 index 000000000..d37fb55de --- /dev/null +++ b/scripts/lib/gh.mjs @@ -0,0 +1,67 @@ +// One `gh` call for the CI scripts, because four of them each had their own rule: one threw on a +// non-zero exit, one only looked at stdout, one trimmed and one did not, and a failed query could +// read as an empty answer. A read is retried once — GitHub answers 5xx under load, and a lane that +// dies on that is a red nobody can act on — while a call that changes state never is: a retry can +// apply it twice. +import { execFileSync } from "node:child_process"; + +/** Board and log queries return megabytes of JSON; the default 1 MB buffer truncates them. */ +const MAX_BUFFER = 512 * 1024 * 1024; + +/** Subcommands that change GitHub state, so a retry could apply the change twice. */ +const MUTATING_SUBCOMMANDS = new Set([ + "cancel", + "close", + "comment", + "create", + "delete", + "edit", + "enable", + "disable", + "merge", + "ready", + "rename", + "rerun", + "review", + "unlock", + "update", +]); + +/** Whether `args` reaches a write: a mutating subcommand, or an `api` call that sends a body or a method. */ +function changesState(args) { + if (MUTATING_SUBCOMMANDS.has(args[1])) return true; + if (args[0] !== "api") return false; + return args.some( + (arg) => + arg === "-f" || + arg === "-F" || + arg === "--field" || + (arg === "-X" && args[args.indexOf(arg) + 1] !== "GET") || + arg === "--input", + ); +} + +/** The command as a reader typed it, for an error that names what failed. */ +function label(args) { + return `gh ${args.slice(0, 2).join(" ")}`; +} + +/** + * Runs `gh` and returns its stdout, or throws with `gh`'s own stderr. + * + * @param {readonly string[]} args + * @param {{ readonly retries?: number }} [options] `retries` defaults to 1 for a read, 0 for a write. + * @returns {string} + */ +export function gh(args, options = {}) { + const retries = options.retries ?? (changesState(args) ? 0 : 1); + let last = ""; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return execFileSync("gh", args, { encoding: "utf8", maxBuffer: MAX_BUFFER }); + } catch (error) { + last = String(error?.stderr ?? "").trim() || (error instanceof Error ? error.message : ""); + } + } + throw new Error(`${label(args)} failed after ${String(retries + 1)} attempt(s): ${last}`); +} diff --git a/scripts/native-release-reuse.mjs b/scripts/native-release-reuse.mjs index f6d64a619..ff6e09818 100644 --- a/scripts/native-release-reuse.mjs +++ b/scripts/native-release-reuse.mjs @@ -3,20 +3,14 @@ // PRD-630: a tag run publishes the bytes its promotion proof already built and installed. // `find ` prints `proof_run=`, empty when no proof qualifies, so the tag run builds. -import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { gh } from "./lib/gh.mjs"; const repository = () => process.env.GITHUB_REPOSITORY ?? ""; -function gh(args) { - const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 26 }); - if (result.status !== 0) throw new Error(`gh ${args[0]} ${args[1]}: ${result.stderr.trim()}`); - return result.stdout; -} - const api = (path) => JSON.parse(gh(["api", `repos/${repository()}/${path}`])); const treeOf = (sha) => api(`commits/${sha}`).commit.tree.sha; From b161ae1d86563617e849e01e16b48da8be786386 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 7 Nov 2026 01:09:32 -0700 Subject: [PATCH 024/154] refactor(scripts): one subprocess runner for the eight copies PRD-838 phase 1, the exec half of box 2. scripts/lib/exec.ts is the runner: run() for a synchronous step, runAsync() for a long one, both throwing ExecError with the caller's own code, both able to capture or stream, and an async run that kills a child which outlives its deadline. It replaced eight hand-rolled run() copies; the third argument is a working directory or the options, so the call sites kept their argument order. The strictest-variant rule caught two real bugs on the way: release.ts and prepare-release.ts passed a human label where the shared runner reads a working directory, and the golden-path mutation control excluded the whole template-assets/patches directory, which now carries the manifest prepack reads. --- ...PRD-838-scripts-share-one-runtime-layer.md | 13 +- scripts/__tests__/exec.spec.ts | 70 ++++++++ scripts/__tests__/release.spec.ts | 2 +- scripts/lib/exec.ts | 151 ++++++++++++++++++ scripts/make-sandbox.ts | 5 +- scripts/prepare-release.ts | 13 +- scripts/release.ts | 29 ++-- scripts/unreal-look/calibrate.ts | 19 +-- scripts/verify-forest-playtest.ts | 23 +-- scripts/verify-golden-path.ts | 6 +- scripts/verify-one-template-desktop.ts | 28 +--- scripts/verify-one-template.ts | 15 +- scripts/verify-template-playtests.ts | 21 +-- 13 files changed, 275 insertions(+), 120 deletions(-) create mode 100644 scripts/__tests__/exec.spec.ts create mode 100644 scripts/lib/exec.ts diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md index b7909af88..068ecefc9 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PRD-838-scripts-share-one-runtime-layer.md @@ -1,6 +1,6 @@ # PRD-838 — Scripts share one runtime layer -**Status:** PARTIAL — Phase 2 landed, the dev-server and `gh` helpers landed; `exec` and the MCP client of Phase 1 box 2 are open. +**Status:** PARTIAL — Phase 2 landed, the dev-server, `exec` and `gh` helpers landed; the MCP client of Phase 1 box 2 is open. **Priority:** P2 — open Phase 1 replaces 4 helper families copied 5 to 11 times. Five of the 7 dev-server waits burn their full timeout when the server dies at start. **Complexity:** 5 (MEDIUM) **Owner:** ThreeNative maintainers @@ -41,16 +41,9 @@ each orphan gate is wired into `pnpm budgets` or deleted. ## Execution Phases #### Phase 1: Shared lib -**Status:** PARTIAL — the dev-server wait and the `gh` wrapper are shared; `exec` and the MCP client are open +**Status:** PARTIAL — the dev-server wait, `exec` and the `gh` wrapper are shared; the MCP client is open **Files:** new `scripts/lib/dev-server.ts`, `exec.ts`, `mcp-client.ts`, `gh.ts`, `args.ts`; the callers listed above. - [x] One dev-server helper fails fast when the server process exits, and every caller uses it. `scripts/lib/dev-server.ts` exports `waitForServer({ url, child?, timeoutMs?, intervalMs?, label?, detail? })` and `DevServerError` with `TN_DEV_SERVER_EXITED` / `TN_DEV_SERVER_TIMEOUT`; it notices `exitCode` **and** `signalCode`, and appends the caller's corrective detail. Nine call sites now route through it (`env-cost-probe`, `visual-gate`, `verify-golden-path`, `profile-starter`, `profile-native-cpu`, `engine-load-test/browser` + `cli` + `skinned-crowd`, `unreal-look/capture-threenative` + `tier-live-step` + `capture-gi-threenative` + `gi-tier-cost`). Two of them had no exit check at all and would have spent their full 120 s on a dead server; the two `waitForServer*` functions left in `scripts/run-networking-proof.mjs` wait on **game** state (players joining, a session ending), not on a dev server, and stay. proof: `npx vitest run scripts/__tests__/dev-server.spec.ts` → 4 passed (a real child that exits at once, a child that dies mid-wait, the deadline, and the corrective detail); `rg -n 'function waitFor(Server|Url|Port)' scripts --glob '!lib/**'` returns only those two game-state waits; `pnpm typecheck` → 0. -- [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. **`gh` landed**: `scripts/lib/gh.mjs` is the one call, four copies deleted (`native-release-reuse`, `ci-merge-latency`, `ci-queue-watch`, `ci-board-kpis`). It throws with `gh`'s own stderr instead of letting a failure read as an empty answer (two of the four never checked the exit), and retries a read exactly once while never retrying a call that changes state — `run cancel`, `pr comment`, or an `api` call carrying `-f`/`-F`/`--input`/a non-GET method. `exec` (8 copies) and the MCP stdio handshake (5) remain. proof so far: `npx vitest run scripts/__tests__/gh.spec.ts scripts/__tests__/ci-board-kpis.spec.ts scripts/__tests__/native-release-reuse.spec.ts scripts/__tests__/ci-queue-watch.spec.ts` → 46 passed; the box closes with `pnpm exec vitest run scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/ci-integration-scope.spec.ts` once `exec` and the MCP client move. +- [ ] One `exec`, one MCP client and one `gh` wrapper, and the local copies are deleted. **`exec` and `gh` landed**: `scripts/lib/exec.ts` (`run` for a synchronous step, `runAsync` for a long one, `ExecError` with the caller's code, capture-or-stream, a kill deadline) replaced the eight hand-rolled `run()` copies in `verify-one-template`, `verify-one-template-desktop`, `verify-forest-playtest`, `verify-template-playtests`, `make-sandbox`, `release`, `prepare-release` and `unreal-look/calibrate`; `scripts/lib/gh.mjs` replaced the four `gh` wrappers. Its strictest-variant rule found two real regressions on the way: `release.ts` and `prepare-release.ts` passed a **label** where the shared runner reads a working directory, and the golden-path mutation control excluded the whole `template-assets/patches` directory, which now carries the manifest prepack reads. The MCP stdio handshake (5 copies) remains. proof: `npx vitest run scripts/__tests__/exec.spec.ts scripts/__tests__/gh.spec.ts scripts/__tests__/dev-server.spec.ts scripts/__tests__/verify-golden-path.spec.ts scripts/__tests__/release.spec.ts scripts/__tests__/prepare-release.spec.ts scripts/__tests__/make-sandbox.spec.ts scripts/__tests__/verify-template-playtests.spec.ts` → 125 passed; the box closes with `pnpm exec vitest run scripts/__tests__/ci-integration-scope.spec.ts` once the MCP client moves. -#### Phase 2: One perf verdict, no dead scripts -**Status:** NOT STARTED -**Files:** `packages/playtest/src/runner/perf.ts`, `scripts/engine-load-test/report.ts`, the 9 scripts named above, root `package.json`. - -- [ ] `report.ts` imports the playtest verdict rule. proof: `pnpm exec vitest run scripts/__tests__/engine-load-test.spec.ts` plus `pnpm --filter @threenative/playtest exec vitest run __tests__/perf.spec.ts` -- [x] The dead scripts are deleted: four, not five. `scripts/asset-pose-capture.mjs`, `scripts/realistic-ocean-budget.mjs`, `scripts/realistic-ocean-capture.mjs` and `scripts/verify-fog-exposure.ts` were referenced by nothing but this PRD (`rg -l ` over the tree, excluding `node_modules`, `dist`, `docs/PRDs` and `docs/verification`). The report's fifth, `scripts/release-native-local.mjs`, is **not** dead: `scripts/release-native-local.ts` imports it at runtime (it ships untranspiled, so the `.ts` loads it through a URL), `package.json` reaches it through that `.ts`, and `__tests__/release-native-local.spec.ts` exercises it — it stays. proof: `pnpm check:docs` → 3795 links ok; `npx vitest run scripts/__tests__/temp-dir-guard.spec.ts scripts/__tests__/retry-download.spec.ts scripts/__tests__/primary-docs.spec.ts` → 15 passed. -- [x] The 4 "orphan gates" are not orphaned, and no wiring is needed — a decision that deletes the box, with the audit that produced it. `scripts/check-capability-examples.ts` is a library: `packages/engine-mcp/__tests__/capability-examples.spec.ts` imports `checkCapabilityExamples` from it, so `pnpm test` runs it. `scripts/arm-census.ts`, `scripts/asset-parity.ts` and `scripts/playtest-pair.ts` are imported by their own specs (`scripts/__tests__/{arm-census,asset-parity,playtest-pair}.spec.ts`, all inside the root vitest include), and each produces a report the docs cite rather than a pass/fail check — `pnpm budgets` is for invariants, not for writing evidence. The report's claim that they "run only by their own spec" is true and is exactly why they are covered: the suite is the gate. proof: `rg -n 'from "\.\./(arm-census|asset-parity|playtest-pair|check-capability-examples)' scripts/__tests__ packages/*/__tests__` lists each importer; `npx vitest run scripts/__tests__/arm-census.spec.ts scripts/__tests__/asset-parity.spec.ts scripts/__tests__/playtest-pair.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` → all passed. diff --git a/scripts/__tests__/exec.spec.ts b/scripts/__tests__/exec.spec.ts new file mode 100644 index 000000000..2b71bf915 --- /dev/null +++ b/scripts/__tests__/exec.spec.ts @@ -0,0 +1,70 @@ +// The runner's rules are the ones the eight copies disagreed about: a non-zero exit must throw, the +// message must name the step and the exit, captured output must come back intact, and a hung child +// must be killed rather than holding a lane open. The spec runs real child processes. +import { describe, expect, it } from "vitest"; +import { ExecError, run, runAsync } from "../lib/exec.js"; + +const node = process.execPath; + +describe("the exec runner", () => { + it("should return stdout when it captures, and nothing when it streams", () => { + expect(run(node, ["-e", "process.stdout.write('hello')"], { stdio: "capture" })).toBe("hello"); + expect(run(node, ["-e", "process.stdout.write('hello')"])).toBe(""); + }); + + it("should throw with the step name and the exit code when the command fails", () => { + const error = (() => { + try { + run(node, ["-e", "process.exit(3)"], { label: "pnpm typecheck" }); + return undefined; + } catch (caught) { + return caught; + } + })(); + expect(error).toBeInstanceOf(ExecError); + expect((error as ExecError).code).toBe("TN_EXEC_FAILED"); + expect((error as ExecError).exitCode).toBe(3); + expect((error as Error).message).toContain("pnpm typecheck exited 3"); + }); + + it("should carry the caller's own code", () => { + expect(() => run(node, ["-e", "process.exit(1)"], { code: "TN_RELEASE_STEP_FAILED" })).toThrow( + /TN_RELEASE_STEP_FAILED/u, + ); + }); + + it("should run in the directory it was given, as a bare string or as options", () => { + const cwd = process.cwd(); + expect( + run(node, ["-e", "process.stdout.write(process.cwd())"], { cwd, stdio: "capture" }), + ).toBe(cwd); + // A bare string is the directory most call sites already passed. + expect(run(node, ["-e", "process.stdout.write(process.cwd())"], { cwd })).toBe(""); + }); + + it("should capture what an async command printed, and pass its environment through", async () => { + const output = await runAsync( + node, + ["-e", "process.stdout.write(process.env.TN_PROBE ?? '')"], + { + env: { TN_PROBE: "seen" }, + stdio: "capture", + }, + ); + expect(output).toContain("seen"); + }); + + it("should reject an async command that fails, naming the exit", async () => { + await expect(runAsync(node, ["-e", "process.exit(4)"])).rejects.toThrow(/exited 4/u); + }); + + it("should kill a command that outlives its timeout and say so", async () => { + await expect( + runAsync(node, ["-e", "setTimeout(() => {}, 60_000)"], { timeoutMs: 50 }), + ).rejects.toThrow(/Killed after 50ms without finishing/u); + }); + + it("should reject a command that cannot start", async () => { + await expect(runAsync("tn-no-such-binary", [])).rejects.toThrow(/did not start/u); + }); +}); diff --git a/scripts/__tests__/release.spec.ts b/scripts/__tests__/release.spec.ts index 4e11e2142..fa83014cb 100644 --- a/scripts/__tests__/release.spec.ts +++ b/scripts/__tests__/release.spec.ts @@ -463,7 +463,7 @@ describe("pnpm release cohort guards", () => { path.resolve(import.meta.dirname, "../release.ts"), "utf8", ); - const build = source.indexOf('run("pnpm", ["build"], "pnpm build")'); + const build = source.indexOf('run("pnpm", ["build"], { cwd: REPO, label: "pnpm build" })'); const clean = source.indexOf("assertCleanPackageTree(REPO);", build); const preflight = source.indexOf("const postBuildReport", build); const cohort = source.indexOf("const publishPackages = unpublishedReleasePackages", preflight); diff --git a/scripts/lib/exec.ts b/scripts/lib/exec.ts new file mode 100644 index 000000000..f1ae90789 --- /dev/null +++ b/scripts/lib/exec.ts @@ -0,0 +1,151 @@ +// One subprocess runner for the scripts, because eight of them had their own and each got a +// different piece right: some captured stdout, some streamed it, some attached a timeout, one +// swallowed the exit code, and their failures named the command as `cmd arg1 arg2` or as a label or +// not at all. The strictest variant wins here: a non-zero exit always throws, the message always +// names the step and the exit, and stdout is either streamed or captured — never silently dropped. +import { execFileSync, spawn } from "node:child_process"; + +/** How to run one command, and what to call it when it fails. */ +export interface IExecOptions { + /** Working directory. */ + readonly cwd?: string; + /** Extra environment, merged over `process.env`. */ + readonly env?: NodeJS.ProcessEnv; + /** What the failure calls this step; the command line when omitted. */ + readonly label?: string; + /** The failure's code prefix, e.g. `TN_RELEASE_STEP_FAILED`. */ + readonly code?: string; + /** + * `"inherit"` (the default) streams the child's output straight to the terminal; `"capture"` + * collects it, forwards it, and returns it. + */ + readonly stdio?: "inherit" | "capture"; + /** Kill the child after this long, so a hung build cannot hold a lane open forever. */ + readonly timeoutMs?: number; +} + +/** + * Where to run it: a working directory, or the full options. A bare string is the shape most call + * sites already had, so moving them here did not renumber their arguments. + */ +export type ExecTarget = string | IExecOptions; + +function optionsOf(target: ExecTarget | undefined): IExecOptions { + if (target === undefined) return {}; + return typeof target === "string" ? { cwd: target } : target; +} + +/** A command that exited non-zero, timed out, or could not start. */ +export class ExecError extends Error { + constructor( + readonly code: string, + message: string, + readonly exitCode: number | null, + ) { + super(`${code}: ${message}`); + this.name = "ExecError"; + } +} + +function describe(command: string, args: readonly string[], options: IExecOptions): string { + return options.label ?? `${command} ${args.join(" ")}`; +} + +function failure( + command: string, + args: readonly string[], + options: IExecOptions, + exitCode: number | null, + detail?: string, +): ExecError { + const how = exitCode === null ? "did not start" : `exited ${String(exitCode)}`; + const suffix = detail === undefined || detail === "" ? "" : `\n${detail}`; + return new ExecError( + options.code ?? "TN_EXEC_FAILED", + `${describe(command, args, options)} ${how}.${suffix}`, + exitCode, + ); +} + +/** + * Runs a command to completion, synchronously, and returns its stdout — `""` when it streamed. + * + * @situation run a build, a packer or a formatter step and fail the script when it fails + */ +export function run(command: string, args: readonly string[], target?: ExecTarget): string { + const options = optionsOf(target); + try { + const output = execFileSync(command, [...args], { + cwd: options.cwd, + encoding: "utf8", + env: options.env === undefined ? undefined : { ...process.env, ...options.env }, + maxBuffer: 256 * 1024 * 1024, + stdio: options.stdio === "capture" ? ["ignore", "pipe", "pipe"] : "inherit", + timeout: options.timeoutMs, + }); + // `execFileSync` answers `null` when the child inherited the terminal instead of a pipe. + return (output as string | null) ?? ""; + } catch (error) { + const exit = (error as { status?: number | null }).status ?? null; + const stderr = String((error as { stderr?: unknown }).stderr ?? "").trim(); + throw failure(command, args, options, exit, stderr); + } +} + +/** + * The asynchronous twin: streams or captures the child's output and throws when it fails. + * + * @situation run a long step — a scaffold, an install, a playtest — and fail the script when it fails + */ +export function runAsync( + command: string, + args: readonly string[], + target?: ExecTarget, +): Promise { + const options = optionsOf(target); + return new Promise((resolve, reject) => { + const capture = options.stdio === "capture"; + const child = spawn(command, [...args], { + cwd: options.cwd, + env: options.env === undefined ? undefined : { ...process.env, ...options.env }, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + let output = ""; + let timedOut = false; + const timer = + options.timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs); + if (capture) { + for (const stream of [child.stdout, child.stderr]) + stream?.on("data", (chunk: Buffer) => { + output += String(chunk); + process.stdout.write(chunk); + }); + } + child.once("error", (error) => { + if (timer !== undefined) clearTimeout(timer); + reject(failure(command, args, options, null, error.message)); + }); + child.once("exit", (exitCode) => { + if (timer !== undefined) clearTimeout(timer); + if (timedOut) { + reject( + failure( + command, + args, + options, + exitCode, + `Killed after ${String(options.timeoutMs)}ms without finishing.`, + ), + ); + return; + } + if (exitCode === 0) resolve(output); + else reject(failure(command, args, options, exitCode)); + }); + }); +} diff --git a/scripts/make-sandbox.ts b/scripts/make-sandbox.ts index c080afa5d..37fdbea05 100644 --- a/scripts/make-sandbox.ts +++ b/scripts/make-sandbox.ts @@ -15,6 +15,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { run } from "./lib/exec.js"; import { assertWebEngineBuilt, packageArchivePrefix, @@ -94,10 +95,6 @@ export interface SandboxResult { readonly project: string; } -function run(command: string, args: string[], cwd: string): void { - execFileSync(command, args, { cwd, stdio: "inherit" }); -} - function packedManifest(file: string): Record { const raw = execFileSync("tar", ["-xOzf", file, "package/package.json"], { encoding: "utf8" }); return JSON.parse(raw) as Record; diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index d4aea1a0b..1a55be3dd 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -11,6 +11,7 @@ import { npmLookup, publishSet, } from "./check-publish-state.js"; +import { run } from "./lib/exec.js"; import { progressOf } from "./prd-progress.js"; import { provisionReleaseArtifacts, validateReleaseCohort } from "./release.js"; @@ -317,11 +318,6 @@ function syncReleaseMetadata( ); } -function run(command: string, args: readonly string[], label: string): void { - process.stdout.write(`\n▸ ${label}\n`); - execFileSync(command, [...args], { cwd: REPO, stdio: "inherit" }); -} - async function main(): Promise { const packages = publishSet(REPO); // `--bump`: npm cannot see a version whose runtime tag already holds a public GitHub release. @@ -344,8 +340,11 @@ async function main(): Promise { .join("\n")}\n`, ); - run("pnpm", ["build"], "pnpm build"); - run("pnpm", ["tsx", "scripts/capture-mcp-tools.ts"], "capture animation MCP surface"); + run("pnpm", ["build"], { cwd: REPO, label: "pnpm build" }); + run("pnpm", ["tsx", "scripts/capture-mcp-tools.ts"], { + cwd: REPO, + label: "capture animation MCP surface", + }); await provisionReleaseArtifacts(REPO); const report = await checkPublishState({ diff --git a/scripts/release.ts b/scripts/release.ts index 0e1a88696..da53528e5 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -36,6 +36,7 @@ import { npmLookup, publishSet, } from "./check-publish-state.js"; +import { run } from "./lib/exec.js"; import { type INativeStaged, stageNativeRelease, @@ -330,7 +331,7 @@ async function defaultArtifactSteps(): Promise { return false; } }, - buildWebEngine: () => run("pnpm", ["wasm:build"], "pnpm wasm:build"), + buildWebEngine: () => run("pnpm", ["wasm:build"], { cwd: REPO, label: "pnpm wasm:build" }), ensureGradleWrapper: (output) => android.ensureGradleWrapper({ output }), }; } @@ -353,15 +354,6 @@ export async function provisionReleaseArtifacts( ); } -function run(command: string, args: readonly string[], label: string): void { - process.stdout.write(`\n▸ ${label}\n`); - try { - execFileSync(command, [...args], { cwd: REPO, stdio: "inherit" }); - } catch { - throw new Error(`TN_RELEASE_STEP_FAILED: ${label}. Nothing further was published.`); - } -} - function runConcurrently(command: string, args: readonly string[], label: string): Promise { process.stdout.write(`\n▸ ${label}\n`); return new Promise((resolve, reject) => { @@ -614,11 +606,11 @@ async function main(args: readonly string[]): Promise { } if (!skipGates) { - run("pnpm", ["typecheck"], "pnpm typecheck"); - run("pnpm", ["lint"], "pnpm lint"); - run("pnpm", ["test"], "pnpm test"); + run("pnpm", ["typecheck"], { cwd: REPO, label: "pnpm typecheck" }); + run("pnpm", ["lint"], { cwd: REPO, label: "pnpm lint" }); + run("pnpm", ["test"], { cwd: REPO, label: "pnpm test" }); } - run("pnpm", ["build"], "pnpm build"); + run("pnpm", ["build"], { cwd: REPO, label: "pnpm build" }); // Assemble the native payload before any npm publish: a declared key with no staged binary must // refuse the whole release while nothing irreversible has happened, not after the cohort is up. @@ -760,11 +752,10 @@ async function main(args: readonly string[]): Promise { process.stdout.write(`\nPublished ${result.tag} with ${result.uploaded.length} asset(s).\n`); } - run( - "pnpm", - ["tsx", "scripts/verify-registry-install.ts"], - "clean-room install from the registry", - ); + run("pnpm", ["tsx", "scripts/verify-registry-install.ts"], { + cwd: REPO, + label: "clean-room install from the registry", + }); process.stdout.write("\nPublished, and installable from the registry.\n"); } diff --git a/scripts/unreal-look/calibrate.ts b/scripts/unreal-look/calibrate.ts index 40870c149..f02d84f99 100644 --- a/scripts/unreal-look/calibrate.ts +++ b/scripts/unreal-look/calibrate.ts @@ -35,6 +35,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { runAsync } from "../lib/exec.js"; import { GI_LAYER, type IArmRef, @@ -770,20 +771,12 @@ export async function main( const git = (...args: string[]) => execFileSync("git", ["-C", REPO_ROOT, ...args], { encoding: "utf8", maxBuffer: 1 << 28 }).trim(); +/** The shared runner, with this harness's own code and working directory. */ function run(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd: REPO_ROOT, stdio: "inherit" }); - child.once("error", reject); - child.once("exit", (code) => - code === 0 - ? resolve() - : reject( - new Error( - `TN_UNREAL_LOOK_CALIBRATE_COMMAND: ${command} ${args.slice(0, 3).join(" ")} exited ${code}`, - ), - ), - ); - }); + return runAsync(command, args, { + code: "TN_UNREAL_LOOK_CALIBRATE_COMMAND", + cwd: REPO_ROOT, + }).then(() => undefined); } function defaultDeps(): IDeps { diff --git a/scripts/verify-forest-playtest.ts b/scripts/verify-forest-playtest.ts index 0fa0af497..8834a660c 100644 --- a/scripts/verify-forest-playtest.ts +++ b/scripts/verify-forest-playtest.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createProject, templateRoot } from "../packages/create-threenative/src/index.js"; +import { runAsync } from "./lib/exec.js"; import { packageLocalFramework } from "./visual-gate.js"; /** @@ -105,20 +106,6 @@ ${MATERIAL_ANCHOR}`, return `${imports.map((line) => `${line}\n`).join("")}import { VirtualShadowNode } from "@threenative/core";\n${loaded}`; } -function run(command: string, args: readonly string[], cwd: string, env?: NodeJS.ProcessEnv) { - return new Promise((resolve, reject) => { - const child = spawn(command, [...args], { - cwd, - env: { ...process.env, ...env }, - stdio: "inherit", - }); - child.once("error", reject); - child.once("exit", (code) => - code === 0 ? resolve() : reject(new Error(`${command} ${args.join(" ")} exited ${code}.`)), - ); - }); -} - /** One `tools/call` over the same stdio shim an agent's `.mcp.json` launches. */ export function callTerrainGenerate(project: string): Promise { return new Promise((resolve, reject) => { @@ -173,8 +160,8 @@ async function main(): Promise { path.join(SCENARIO_ROOT, "forest.playtest.json"), path.join(project, "playtests/forest.playtest.json"), ); - await run("pnpm", ["build:web"], project); - await run( + await runAsync("pnpm", ["build:web"], project); + await runAsync( "pnpm", [ "exec", @@ -198,8 +185,8 @@ async function main(): Promise { path.join(SCENARIO_ROOT, "forest.native.playtest.json"), path.join(project, "native-playtests/forest.playtest.json"), ); - await run("pnpm", ["build:desktop"], project); - await run( + await runAsync("pnpm", ["build:desktop"], project); + await runAsync( "pnpm", [ "exec", diff --git a/scripts/verify-golden-path.ts b/scripts/verify-golden-path.ts index 98bba97e5..676363a45 100644 --- a/scripts/verify-golden-path.ts +++ b/scripts/verify-golden-path.ts @@ -1220,7 +1220,11 @@ export async function verifyPackedMutationControl( "packages/create-threenative/template-assets/patches", ); await cp(path.join(REPO_ROOT, "packages/create-threenative"), mutatedPackage, { - filter: (source) => path.basename(source) !== "node_modules" && source !== generatedPatches, + // Only the generated `.patch` copies are left out: prepack rebuilds them from the sources + // reproduced below, and the manifest that names those sources is source itself. + filter: (source) => + path.basename(source) !== "node_modules" && + !(source.startsWith(generatedPatches) && source.endsWith(".patch")), recursive: true, }); // Reproduce prepack's repository-relative inputs, including Vite and Tailwind's patches. diff --git a/scripts/verify-one-template-desktop.ts b/scripts/verify-one-template-desktop.ts index 0597586ae..ee268331a 100644 --- a/scripts/verify-one-template-desktop.ts +++ b/scripts/verify-one-template-desktop.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import * as ts from "typescript"; import { createProject } from "../packages/create-threenative/src/index.js"; +import { runAsync } from "./lib/exec.js"; import { packageLocalFramework } from "./visual-gate.js"; import { localPackageEntries } from "./workspace-packages.js"; @@ -63,27 +64,14 @@ export function assertUnpinnedEngine(config: string): void { visit(source); } +/** A native run that also fails on an engine refusal the exit code does not carry. */ async function run(command: string, args: readonly string[], cwd: string): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, [...args], { cwd, stdio: ["ignore", "pipe", "pipe"] }); - let output = ""; - const timer = setTimeout(() => child.kill("SIGKILL"), 900_000); - for (const stream of [child.stdout, child.stderr]) - stream?.on("data", (chunk) => { - output += String(chunk); - process.stdout.write(chunk); - }); - child.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - child.once("close", (code) => { - clearTimeout(timer); - if (code !== 0 || /TN_ENGINE_[A-Z_]+/u.test(output)) - reject(new Error(`${command} ${args.join(" ")} failed (${code}):\n${output.slice(-4000)}`)); - else resolve(output); - }); - }); + const output = await runAsync(command, args, { cwd, stdio: "capture", timeoutMs: 900_000 }); + if (/TN_ENGINE_[A-Z_]+/u.test(output)) + throw new Error( + `${command} ${args.join(" ")} reported an engine refusal:\n${output.slice(-4000)}`, + ); + return output; } export async function checkArchives( diff --git a/scripts/verify-one-template.ts b/scripts/verify-one-template.ts index 13421276d..af19bf089 100644 --- a/scripts/verify-one-template.ts +++ b/scripts/verify-one-template.ts @@ -5,27 +5,16 @@ import { mkdtemp } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createProject } from "../packages/create-threenative/src/index.js"; +import { runAsync } from "./lib/exec.js"; import { packageLocalFramework } from "./visual-gate.js"; const template = process.argv[2]; if (template === undefined) throw new Error("usage: verify-one-template.ts