diff --git a/docs/PRDs/rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md b/docs/PRDs/rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md index c0f8766225..d739ceb866 100644 --- a/docs/PRDs/rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md +++ b/docs/PRDs/rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md @@ -4,7 +4,7 @@ prd_contract: v1 # PRD-345 — a backlit subject is not a hole in the sky -**Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch: +**Status:** PARTIAL — generated material defaults integrated across conventional templates; twelve final matched web desktop pairs are palette-stable, including corrected racing. All-template gameplay/full-root/native appearance and rain admission remain open. Originally filed 2026-09-03, measured at `43d03e6a`. Batch: [docs/PRDs/AAA-visuals](aaa-visuals-notes.md). **Ships as generated user source, not as a package** — it decides how things look, and rule 1(b) vetoes 1(a) at any size. Source studied: [TheLongSilence](https://github.com/achimala/TheLongSilence) `src/gfx/greeble.js:37`, the @@ -80,25 +80,260 @@ instruction-budget vitest spec, not in `pnpm budgets` — check it before adding package owns a lighting term the framework owns the look. - No area lights, no clustered lighting, no IBL replacement. +## Implementation phases (2026-10-03 qualification) + +Current starter source deliberately uses one sun plus photographed IBL, without an ambient or +hemisphere stacked over it. Some other templates already have explicit rim/fill lights. This +work must qualify the material convention against that actual source and each template's palette, +not substitute the historical four-light description for current code. Tracker G09 is row 13, +P1, PRD-345. The workbook remains unchanged. + +### Phase 1 — isolated material and measurement qualification + +- [x] Implement bounded game-owned grazing/backlight and directional analytic-fill terms with explicit `rimGain`/fill overrides, preserving original emissive and material maps. proof: retained `docs/verification/prd345/qualification.json`, CPU fidelity/live-control checks, real WebGPU positive/zero controls; generated defaults remain separately open +- [x] Measure the environment actually in use and report contribution even with overrides zero; unsupported measurement must report unknown, never invent a mean or infer black from an unreadable image. proof: real 4096×2048 sky GPU estimate 0.547838 versus full-source oracle 0.547144; near-black/override marker controls and `docs/verification/prd345/actual-starter-proof.json.gz`; unsupported/timeouts fail closed + +### Phase 2 — admit measured generated defaults + +- [ ] Apply only qualified conventions to the actual starter/material source and all required templates, preserving per-template appearance, low/mobile/software fallbacks and named overrides. proof: matched per-template screenshots and actual frame/startup cost +- [x] Add the concise convention/override rows to generated instructions with mirrors kept in sync. proof: all thirteen template rows, explicit rain exception, primary-docs/instruction-budget seventeen tests and scaffold/doc eighty-three tests pass; unchanged instruction caps + +### Phase 3 — clean starter and platform validation + +- [ ] Verify generated clean starters/simple scenes and template visual gates including dark environment/no-sun and backlit character controls. proof: `pnpm test:templates`, tone crops and red-green captures +- [ ] Complete required root and native qualification without claiming unexecuted targets. proof: `pnpm typecheck && pnpm lint && pnpm test` and targeted native fixture + +## Qualification design + +The initial scope is isolated generated Three.js source and fixtures. No engine look helper, +core composer, dependency/Three patch, scaffold generator or exposure source changes are admitted. +`MeshStandardNodeMaterial` is the upstream portable TSL counterpart of the current standard +material: use its existing PBR/normal/maps and preserve `materialEmissive`. WebGPU does not offer +`onBeforeCompile` as a portable way to augment a standard material, so a WebGL-only shader string +patch is excluded. Conversion and material identity/animation must be checked, especially for +the starter mannequin's skinned material, before integration. + +The proposed grazing term uses the view/normal Fresnel edge with a gate for the key behind the +subject, tinted by the authored key. It must not brighten a front-lit face, discard the original +emissive, or make all orientations equally bright. The analytic fill uses game-authored key/fill directions, colours and fill angular size, +with a roughness-aware reflected response as well as directional diffuse. Its black-colour +override is named alongside gain. This is needed for the metallic mannequin as well as the +dielectric props. Use live key uniforms so moving/removing the key removes its contribution. +The analytic fill is directional and low gain; +its black override must remove the effect while keeping the measured report. Bright photographed +IBL is the existing baseline, so stacking a fill that flattens it is a rejection condition. + +The report requires actual linear-radiance samples of `scene.environment`, including active +intensity and texture color space. A hard-coded asset mean, requested exposure, an unreadable +HTML image, or a background mean is not that measurement. First qualify known byte/float +DataTextures deterministically. For arbitrary photographed environments, a small once-at-setup +GPU sample/readback needs explicit capability and startup-cost proof; unsupported platforms +must report unknown and retain the qualified conservative fallback. No native readback/buffer +contract edits are in this lane. The full original reporting criterion stays open until proven. Source mean describes available +scene radiance, not the contribution of every material: explicit material environment maps and +`envMapIntensity` need separate treatment. Missing, measured-dark, measured-bright and unreadable +sources remain distinct. Existing rain/snow authored fill is retained; never stack a generic floor +over it without measurement. + +Material qualification includes the actual loaded animated mannequin, not just exported prop +materials. Cache converted supported standard materials to preserve sharing and array slots, +retain upstream skinning/normal/PBR paths, and verify maps/transparency/sidedness/emissive/disposal. +Unsupported physical/custom/basic materials remain intact with honest exclusions. + +The CPU path refuses unreadable, unsupported or over-budget textures; the photographed JPEG +remains unknown. Real WebGPU runs now qualify the actual animated starter mannequin at fixed +poses. Regional `assert.tone` adds physical PNG crops and same-image reference comparisons; +legacy whole-frame assertions retain their existing metrics and acquisition path. Native regional +capture remains unimplemented and fails closed. The actual starter integration below is qualified on web desktop WebGPU; all-template/native acceptance remains open. + +### Retained visual and red-green proof + +All captures use the same character, camera `[0, 1.35, 6]`, content and 1280 × 720 buffer on web +WebGPU, NVIDIA Turing. The fixed end pose is 90 ticks / 1.5 seconds; the rest capture preserves +that pose. Crop coordinates were frozen before assertion captures in the fixture's +`proof-crops.json`. The retained report aliases preserve decoded RGBA hashes and identify +byte-identical end/rest images; compressed reports retain their full original fields. + +| Control | Before | Enabled | +| --- | --- | --- | +| Backlit character, measured black IBL, authored sun | ![Zero rim](../../verification/prd345/rim-zero.png) | ![Enabled rim](../../verification/prd345/rim-enabled.png) | +| No sun, measured black IBL | ![Black fill](../../verification/prd345/fill-black.png) | ![Enabled fill](../../verification/prd345/fill-enabled.png) | + +Run `node --import tsx packages/create-threenative/__tests__/fixtures/backlight-defaults/qualify.mjs docs/verification/prd345`. +Result: PASS actual decoded-PNG/report agreement and strict scene/camera/pose provenance. +Enabled rim edge/body p99 is 52/2 (margin 50, required 30); zero rim is 7/0 and fails exactly +`tone.0.compare` and `tone.1.compare`. The bounded paired-image `TN_RIM_CONTRIBUTION` report +prints for both arms: available edge uplift 45, body uplift 2, margin uplift 43. This is a fixture +pixel measurement, not a runtime BRDF energy claim. Enabled no-sun fill p1/p99 is 0/43; black +fill is 0/0 and fails exactly the two p99 assertions. The strict serialized +`TN_ENVIRONMENT_CONTRIBUTION` verifier passes the actual zero-radiance map and fails the omitted +marker with `TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE`; measurement survives named overrides. +Full reports and qualification are retained in [the evidence bundle](../../verification/prd345/qualification.json). + +Actual engine GPU timestamp windows 3–5 average about 11.36 ms baseline and 12.10 ms enabled +(+0.74 ms / about 6.5%). Each window spans 60 frames with 7–8 fresh timestamp samples. These +cost runs use the initial static pose. Private-display FPS, shader compilation completion, +startup, native/mobile and per-template cost remain unqualified. Initial broad rim was rejected +for floor wash; the retained selective rim gates that response by surface orientation. Front-lit, +bright photographed IBL, dark and missing environment control runs pass their fixture gates. + +Use one matched backlit subject/camera/content/resolution for baseline, enabled rim, and +`rimGain: 0`; a second matched dark-environment/no-sun pair for enabled fill and black fill. +Retain silhouette-edge/body crop coordinates before inspecting results. Repeat front-lit/bright +IBL control to reject washout. Record draw/compile/startup and actual GPU timestamp windows, +with existing material cost as baseline. No default is admitted from a metric alone. + +### Actual generated starter defaults (first tranche) + +`starter/src/render/lighting.ts` now names editable `rimGain: 0.12`, dim cool fill and its black +colour/gain overrides. The authored sun and photographed sky remain intact. Supported standard +materials are converted after the cloned animated character, arena and props attach; maps, +emissive and material sharing remain borrowed. Physical/custom/node materials remain original. +Original assignments restore on quality fallback and exit, including in-place array edits. +The convention is admitted only for high-tier web desktop hardware WebGPU. Native, mobile, +software and WebGL fallback preserve original materials, even when high is pinned. This is generated +editable game source; there is no core engine appearance hook or bare-engine automatic-look claim. + +A once-at-load 64×32 GPU estimate reads the actual environment in linear space, with spherical +weights and active intensity. It restores renderer state before awaiting readback, times out after +1000 ms without blocking boot, and defers scratch disposal until pending GPU work settles. Successful +estimates cache weakly by renderer/texture and complete source provenance. Unknown results retry; +changed versions, source image, colour space, mapping or intensity invalidate old admission. +The starter sky is measured bright, so the analytic fill stays **off** rather than washing out its IBL. + +Actual clean starters were generated through `createProject`, with identical cooked assets and +explicit local framework dependencies. Existing `camera.main` setup controls placed both arms at +`[-6, 1.45, -2.7]`, looking at `[-2, 1.25, 0]`, without changing lighting or advancing the tick-60 +character pose. Camera, player transform, clock, render chain and adapter equality are checked. +The original front-lit opening is nearly unchanged; the photographed IBL makes the backlit change +subtle. These unscaled exact-pixel crops are x600/y285/w80/h263 from each 1280×720 full screenshot: + +| Original generated starter | Material convention enabled by default | +| --- | --- | +| ![Actual starter before](../../verification/prd345/actual-starter-backlit-before.webp) | ![Actual starter after](../../verification/prd345/actual-starter-backlit-after.webp) | + +[Retained measured camera/pose/source/startup proof](../../verification/prd345/actual-starter-proof.json.gz) +names full source PNG hashes. Full before/after frames also remain in the capture artifacts and +Library comparison. WebP crops are lossless and checked against original decoded RGBA. Five +materials convert; the custom finish-flag material is explicitly excluded. Both default views +submit 57 draws / 32,862 triangles. The actual sky estimate agrees with independent full JPEG +spherical-linear decode within 0.13%. Initial standalone sampling took 398.7 ms including first +compilation/readback. Matched single dev runs observed load-to-ready 1945.7 ms before / 2330.2 ms +after; these are observations, not repeatable performance statistics or first-frame acceptance. +[Hardware cached restart](../../verification/prd345/cached-restart.json.gz) reuses the same texture +with **zero additional readbacks** and retains the measured report; `game.goto` returned in 21.6 ms, +which is not a full-readiness timing claim. + +Validation: full final clean generated starter TypeScript passes. Lifecycle 28/28, existing quality +40/40, looks 19/19 and primary-docs/instruction-budget 16/16 pass. Independent review caught and +verified repairs for array downshift/recovery edits, stale same-texture updates, live override +reporting, hung readback and actual WebGL-backend fallback. All thirteen scaffold fingerprints were measured by the existing gate after this isolated +extension; combined exposure bytes must be remeasured at merge. Required native execution, +final-head startup/per-frame budgets and complete templates/root gates remain unfinished; this +draft stays partial. + +## Bounded startup and plural-template follow-up + +The published starter checkpoint `bd5d9d599` has a balanced ABBA series of 80 fresh Chromium +launches, 40 per arm, with no failures and unchanged generated trees. Nearest-rank engine-readiness +p95 is 2664.5 → 2905.6 ms (+241.1 ms, 9.05%); scene-load-to-ready p95 is 2067.9 → 2289.6 ms. +Medians are 2548.55 → 2743.75 ms. This is an empirical fresh-process comparison: page/HTTP caches +are fresh, while OS and driver shader caches are uncontrolled. It proves no shader-cold or +compile-completion claim. The dynamic-enrollment/template extension requires its own final-head +qualification; these results describe the named published checkpoint only. +[Summary](../../benchmark/prd345/startup-p95-bd5d9d599/summary.json), +[all samples](../../benchmark/prd345/startup-p95-bd5d9d599/samples.json.gz), and +[frozen source manifests](../../benchmark/prd345/startup-p95-bd5d9d599/frozen-trees.json.gz) +retain every attempted launch and measured timeline. + +Minimal now has a matched generated-source default-camera pair at 1280×720, hardware NVIDIA Turing +WebGPU, identical fixed clock/camera/backend/MSAA and cooked assets. Both source builds and +captures pass. Five standard materials are converted; actual bright photo IBL suppresses extra +fill. Pixel inspection retains the original sunny palette and avoids a floor wash; the effect in +this front-lit opening is subtle. The original difficult-backlight controls above remain the +isolated visible improvement proof. + +| Minimal default opening | Before | After | +| --- | --- | --- | +| Matched generated source | ![Before](../../benchmark/prd345/template-matrix/minimal/before.png) | ![After](../../benchmark/prd345/template-matrix/minimal/after.png) | + +Eleven actual generated template pairs now pass strict matched readiness captures and retain +their authored palettes: minimal, starter, platformer, runner, shooter, snow, action-RPG, RTS, +tower-defense, puzzle and sailing. The full unscaled PNG pairs and complete source/runtime +manifests are [retained here](../../benchmark/prd345/template-matrix/README.md). Earlier +worker/WASM/readiness failures remain retained beside successful fresh retries; doctor results +were not treated as acceptance. Fixed gameplay clock does not fix wall-time flag/smoke phases, +which are disclosed and not claimed as improvements. Dynamic enrollment covers newly spawned +RTS armies, action-RPG loot, runner chunks and tower/enemy upgrade/recycling; thirty-three +lifecycle tests cover restoration, sharing and owned disposal. + +The final runtime checkpoint `dca2d8d4b` now qualifies all twelve conventional matched boot +pairs. Racing controls traced the road/shadow regression to `updateWorldMatrix` inside the +render-time material callback: the identical expression with those two writes removed restores +the original road and shadows. Sampling-only and upstream material-copy controls independently +preserve baseline pixels. Callbacks now read the matrices maintained by the engine/game boundary; +34 focused lifecycle/matrix tests verify that rendering cannot mutate shared light matrices. +[Final full pairs and source/runtime manifests](../../benchmark/prd345/final-template-matrix/README.md) +and [retained rejected candidate and controls](../../benchmark/prd345/racing-regression/README.md) +preserve the causal evidence. Full gameplay acceptance remains distinct from these boot captures. + +The current-source final ABBA startup series passes 80/80 launches, 40 per arm, with unchanged +source trees. Ready p95 is 2649.7 → 2831.0 ms (+181.3 ms, 6.84%); medians are 2560.3 → 2760.0 ms. +Scene-load-to-ready p95 is 2036.9 → 2241.7 ms (+204.8 ms). Fresh process/context/page and HTTP +cache are controlled; OS/filesystem and driver shader caches are not. This is not shader-cold, +compile-complete or a precise population-tail estimate. +[Summary and complete retained samples](../../benchmark/prd345/startup-p95-dca2d8d4b/summary.json). + +Current-source difficult-lighting fixture qualification passes all six intended positive/negative +arms on NVIDIA Turing hardware WebGPU. Edge p99 is 53 with rim versus 7 without; body p99 is 2 +versus 0. Zero-rim and black-fill controls fail only their intended tone assertions; omitted +report fails exactly the marker assertion. [Full frames and raw qualification](../../benchmark/prd345/final-fixture/README.md). +Actual GPU timestamp windows 3–5 measure dark fill 11.0467 → 11.7467 ms (+0.700 ms, 6.34%). +Backlit 11.6033 → 11.2267 ms is a noisy negative delta, not a speedup claim. Each arm has only +23 asynchronous timestamp samples across three 60-frame windows in one serial run. +[Raw cost windows](../../benchmark/prd345/final-cost/README.md). + +Affected CPU verification passes 65 files / 1011 tests; root typecheck and lint pass. The isolated +native host and V8/QuickJS contract prerequisites build, and actual native unit tests pass +1531 with 62 skipped. None of those results admits native appearance. The initial full-root +attempt retained 21 missing-native-binary failures; the complete root gate must be rerun after +prerequisites and capture jobs finish. All thirteen generated gameplay gates and matched native +conservative-fallback capture are in progress; no unexecuted result is claimed. + +The [explicit per-template mechanism map](../../benchmark/prd345/final-template-matrix/mechanisms.md) +distinguishes useful authored illumination from the requested new terms. Puzzle/tower additions +are disabled; runner/snow analytic fill is disabled. Their authored directional rim/hemisphere +sources are not mathematically equivalent to the grazing/disc expressions. This is an open +qualification requirement, not an automatic exception or a reason to stack duplicate ambient. +Isolated replacement candidates are not admitted defaults until actual behavior, palette and +performance proof passes. + +Rain is a custom raymarched shader with authored flash rim/hemisphere fill and cloud render-target +reflections. Ordinary standard-material conversion does not apply; its cloud radiance remains +unknown until separately sampled. Its explicit instruction exception is not blanket all-template +acceptance. Native retains original materials and no GPU sampling; native appearance admission +remains unqualified. Bare-engine automatic material treatment is not claimed: the repository's +source-first architecture requires an explicit coherent policy change before package hooks. + ## Acceptance criteria -1. **A backlit subject has a lit limb.** A playtest scenario poses a template's character between +- [x] **A backlit subject has a lit limb.** A playtest scenario poses a template's character between proof: `docs/verification/prd345/qualification.json; actual starter mannequin: edge-enabled-acceptance1 passes, edge-rim-zero-acceptance1 fails only tone margin; paired TN_RIM pixel uplift reported for both arms` the camera and the key light; `assert.tone` (PRD-341) over a crop of the silhouette edge asserts a p99 above the body's p99 by a stated margin. *Red-green:* set `rimGain: 0` in the scenario's setup; the same assertion fails, and the run still prints the measured rim contribution — the proof that measurement survives the override. Paste both. -2. **A dark environment is reported, not hidden.** A scenario with a near-black environment asserts +- [x] **A dark environment is reported, not hidden.** A scenario with a near-black environment asserts proof: `docs/verification/prd345/qualification.json; marker positive passes; omitted marker fails exactly TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE; near-black DataTexture reports actual source mean0` `TN_ENVIRONMENT_CONTRIBUTION` names IBL as non-contributing and names the analytic fill. *Red-green:* delete the report; the marker assertion goes red. -3. **The scene is not flat without a sun.** The same scenario with the key light removed asserts, +- [x] **The scene is not flat without a sun.** The same scenario with the key light removed asserts, proof: `docs/verification/prd345/qualification.json; fill-enabled-acceptance1 passes p1/p99 range, fill-fill-black-acceptance1 fails exactly body p99 at0; key absent in both` via `assert.tone`, that `p1` and `p99` remain separated — a scene lit only by the fill still has range. *Red-green:* set the analytic fill to black; the range assertion goes red. -4. **Every template has both, and says so.** `scripts/__tests__/primary-docs.spec.ts` and the +- [ ] **Every template has both, and says so.** `scripts/__tests__/primary-docs.spec.ts` and the proof: `primary-docs and generated convention rows` templates' own gate assert each template's `AGENTS.md` names both conventions and both override names. -5. **The templates gate is green on the templates that currently pass it.** Note the known red lane: +- [ ] **The templates gate is green on the templates that currently pass it.** Note the known red lane: proof: `complete template gate with explicit lane outcomes` the templates gate aborts at the first failing template, so the shooter's deterministic capture red will hide everything after it. Fix or skip past it deliberately and say which — never report a template as passing because the gate stopped before reaching it. @@ -107,3 +342,5 @@ instruction-budget vitest spec, not in `pnpm budgets` — check it before adding Anything in `packages/`, and any change to the tonemapper — PRD-339 and PRD-343 own exposure and the white point. + +Historical GPU evidence limitation: lease ownership is scoped to process TMPDIR; durable-temp runs used private namespaces and manual own-job serialization. Shared global exclusion was not established. See benchmark PRD-345 capture coordination notes; future timing uses an explicit outer shared lease. Original acceptance remains partial. diff --git a/docs/benchmark/prd345/final-cost/README.md b/docs/benchmark/prd345/final-cost/README.md new file mode 100644 index 0000000000..fd2f2237e9 --- /dev/null +++ b/docs/benchmark/prd345/final-cost/README.md @@ -0,0 +1,7 @@ +# Final Cost + +Actual engine GPU timestamp windows 3–5: dark 11.0467→11.7467 ms (+0.700 ms, 6.34%); backlit 11.6033→11.2267 ms is noise, not a speedup claim. Only 23 asynchronous timestamp samples per arm across three 60-frame windows in one serial run. Raw windows, provenance and full frames retained. No compile-complete or display-FPS claim. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/final-cost/backlit-baseline/capture-session.json.gz b/docs/benchmark/prd345/final-cost/backlit-baseline/capture-session.json.gz new file mode 100644 index 0000000000..fc4bd86f92 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-baseline/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/backlit-baseline/capture.json.gz b/docs/benchmark/prd345/final-cost/backlit-baseline/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-baseline/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/backlit-baseline/steady.png b/docs/benchmark/prd345/final-cost/backlit-baseline/steady.png new file mode 100644 index 0000000000..de49c3e8cb Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-baseline/steady.png differ diff --git a/docs/benchmark/prd345/final-cost/backlit-baseline/windows.json.gz b/docs/benchmark/prd345/final-cost/backlit-baseline/windows.json.gz new file mode 100644 index 0000000000..e104c516b3 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-baseline/windows.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/backlit-enabled/capture-session.json.gz b/docs/benchmark/prd345/final-cost/backlit-enabled/capture-session.json.gz new file mode 100644 index 0000000000..5921018f54 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-enabled/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/backlit-enabled/capture.json.gz b/docs/benchmark/prd345/final-cost/backlit-enabled/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-enabled/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/backlit-enabled/steady.png b/docs/benchmark/prd345/final-cost/backlit-enabled/steady.png new file mode 100644 index 0000000000..77b151a065 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-enabled/steady.png differ diff --git a/docs/benchmark/prd345/final-cost/backlit-enabled/windows.json.gz b/docs/benchmark/prd345/final-cost/backlit-enabled/windows.json.gz new file mode 100644 index 0000000000..5a6a6cba50 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/backlit-enabled/windows.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-baseline/capture-session.json.gz b/docs/benchmark/prd345/final-cost/dark-baseline/capture-session.json.gz new file mode 100644 index 0000000000..39b6d11dc3 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-baseline/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-baseline/capture.json.gz b/docs/benchmark/prd345/final-cost/dark-baseline/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-baseline/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-baseline/steady.png b/docs/benchmark/prd345/final-cost/dark-baseline/steady.png new file mode 100644 index 0000000000..4e05ccbba7 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-baseline/steady.png differ diff --git a/docs/benchmark/prd345/final-cost/dark-baseline/windows.json.gz b/docs/benchmark/prd345/final-cost/dark-baseline/windows.json.gz new file mode 100644 index 0000000000..706389a642 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-baseline/windows.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-enabled/capture-session.json.gz b/docs/benchmark/prd345/final-cost/dark-enabled/capture-session.json.gz new file mode 100644 index 0000000000..35543db649 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-enabled/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-enabled/capture.json.gz b/docs/benchmark/prd345/final-cost/dark-enabled/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-enabled/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/dark-enabled/steady.png b/docs/benchmark/prd345/final-cost/dark-enabled/steady.png new file mode 100644 index 0000000000..740cae799b Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-enabled/steady.png differ diff --git a/docs/benchmark/prd345/final-cost/dark-enabled/windows.json.gz b/docs/benchmark/prd345/final-cost/dark-enabled/windows.json.gz new file mode 100644 index 0000000000..0bc260f686 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/dark-enabled/windows.json.gz differ diff --git a/docs/benchmark/prd345/final-cost/summary.json.gz b/docs/benchmark/prd345/final-cost/summary.json.gz new file mode 100644 index 0000000000..389a32e104 Binary files /dev/null and b/docs/benchmark/prd345/final-cost/summary.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/README.md b/docs/benchmark/prd345/final-fixture/README.md new file mode 100644 index 0000000000..2ae522721e --- /dev/null +++ b/docs/benchmark/prd345/final-fixture/README.md @@ -0,0 +1,7 @@ +# Final Fixture + +Six current-source controlled arms qualify the intended rim, dark-fill and report red/green checks on hardware NVIDIA Turing WebGPU at 1280×720. All full PNGs, raw reports and qualification are retained. Root inspected the end-pose pixels. This is web fixture qualification, not native or all-template gameplay acceptance. The previous software-adapter harness failure is retained separately and never treated as passing. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/after.png new file mode 100644 index 0000000000..0be8a4979d Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/console.json.gz new file mode 100644 index 0000000000..3054eb3013 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-end.png new file mode 100644 index 0000000000..0be8a4979d Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-mid.png new file mode 100644 index 0000000000..303af8e86b Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-rest.png new file mode 100644 index 0000000000..0be8a4979d Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..10c4822c6e Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-enabled-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/after.png new file mode 100644 index 0000000000..65f4843ba7 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/console.json.gz new file mode 100644 index 0000000000..f40d860cdc Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-end.png new file mode 100644 index 0000000000..65f4843ba7 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-mid.png new file mode 100644 index 0000000000..e2a9cfdc4b Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-rest.png new file mode 100644 index 0000000000..65f4843ba7 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/report.json.gz new file mode 100644 index 0000000000..19e845856c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/edge-rim-zero-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/console.json.gz new file mode 100644 index 0000000000..5040d9fb31 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..f85a5d7726 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-enabled-acceptance1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/console.json.gz new file mode 100644 index 0000000000..2217fa5056 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/report.json.gz new file mode 100644 index 0000000000..df7621d824 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/edge-rim-zero-acceptance1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/console.json.gz new file mode 100644 index 0000000000..98cc343f75 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..442612fcfa Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-enabled-acceptance1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/console.json.gz new file mode 100644 index 0000000000..200e02616d Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/report.json.gz new file mode 100644 index 0000000000..15aeffbd1c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/fill-fill-black-acceptance1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/console.json.gz new file mode 100644 index 0000000000..ba2c6f4411 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/report.json.gz new file mode 100644 index 0000000000..821a73d54d Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-omitted-proof1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/after.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/after.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/capture.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/capture.json.gz new file mode 100644 index 0000000000..3de63caccd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/console.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/console.json.gz new file mode 100644 index 0000000000..986a081525 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-end.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-end.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-mid.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-mid.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-rest.png b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-rest.png new file mode 100644 index 0000000000..3ddab50b98 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/report.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/report.json.gz new file mode 100644 index 0000000000..ff20c992dd Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/runtime-trace.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/runtime-trace.json.gz new file mode 100644 index 0000000000..df842b4ac4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/marker-positive-proof1/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/failed-software-harness/runs.json.gz b/docs/benchmark/prd345/final-fixture/failed-software-harness/runs.json.gz new file mode 100644 index 0000000000..3a24fdf285 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/failed-software-harness/runs.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/after.png new file mode 100644 index 0000000000..2810af065e Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/console.json.gz new file mode 100644 index 0000000000..da4e5c634b Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-end.png new file mode 100644 index 0000000000..2810af065e Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-mid.png new file mode 100644 index 0000000000..5256ac42ab Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-rest.png new file mode 100644 index 0000000000..2810af065e Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..7351ae2a74 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-enabled-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/after.png b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/after.png new file mode 100644 index 0000000000..b15a942006 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/capture.json.gz b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/console.json.gz b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/console.json.gz new file mode 100644 index 0000000000..895c0318ba Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-end.png b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-end.png new file mode 100644 index 0000000000..b15a942006 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-mid.png b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-mid.png new file mode 100644 index 0000000000..ad3d9fd7a9 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-rest.png b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-rest.png new file mode 100644 index 0000000000..b15a942006 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/report.json.gz b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/report.json.gz new file mode 100644 index 0000000000..da76f5d39f Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/fill-fill-black-acceptance1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/after.png b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/after.png new file mode 100644 index 0000000000..0feb66c31a Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/capture.json.gz b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/console.json.gz b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/console.json.gz new file mode 100644 index 0000000000..f0e9899160 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-end.png b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-end.png new file mode 100644 index 0000000000..0feb66c31a Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-mid.png b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-mid.png new file mode 100644 index 0000000000..1ac6146863 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-rest.png b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-rest.png new file mode 100644 index 0000000000..0feb66c31a Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/report.json.gz b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/report.json.gz new file mode 100644 index 0000000000..df60a5e9a7 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-omitted-proof1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/after.png b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/after.png new file mode 100644 index 0000000000..f84dcc43e6 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/after.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/capture.json.gz b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/capture.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/console.json.gz b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/console.json.gz new file mode 100644 index 0000000000..da4e5c634b Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/console.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-end.png b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-end.png new file mode 100644 index 0000000000..f84dcc43e6 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-end.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-mid.png b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-mid.png new file mode 100644 index 0000000000..5c5f6079b0 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-mid.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-rest.png b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-rest.png new file mode 100644 index 0000000000..f84dcc43e6 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/pose-rest.png differ diff --git a/docs/benchmark/prd345/final-fixture/marker-positive-proof1/report.json.gz b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/report.json.gz new file mode 100644 index 0000000000..bf3db9f8e0 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/marker-positive-proof1/report.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/qualification.json.gz b/docs/benchmark/prd345/final-fixture/qualification.json.gz new file mode 100644 index 0000000000..6c955e9bd4 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/qualification.json.gz differ diff --git a/docs/benchmark/prd345/final-fixture/runs.json.gz b/docs/benchmark/prd345/final-fixture/runs.json.gz new file mode 100644 index 0000000000..be41b50e18 Binary files /dev/null and b/docs/benchmark/prd345/final-fixture/runs.json.gz differ diff --git a/docs/benchmark/prd345/final-gates/README.md b/docs/benchmark/prd345/final-gates/README.md new file mode 100644 index 0000000000..79e6643842 --- /dev/null +++ b/docs/benchmark/prd345/final-gates/README.md @@ -0,0 +1,9 @@ +# Final gate logs + +Root typecheck and lint pass. Affected CPU verification passes 65 files / 1011 tests. The isolated native host and V8/QuickJS prerequisites build; actual native unit tests pass 1531, with 62 skipped. Native unit/build results do not qualify native appearance. + +The retained full-root attempt completed docs/build then failed on 21 absent native test binaries. Its raw failure remains retained; prerequisites were built afterward. Full root verification, all-thirteen generated gameplay and matched native fallback remain pending. No skipped lane is reported as passing. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/final-gates/backlight-final-create-suite.log.gz b/docs/benchmark/prd345/final-gates/backlight-final-create-suite.log.gz new file mode 100644 index 0000000000..15a3f37b08 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-final-create-suite.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-native-build.log.gz b/docs/benchmark/prd345/final-gates/backlight-native-build.log.gz new file mode 100644 index 0000000000..f1ffc9a1c7 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-native-build.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-native-contract-build.log.gz b/docs/benchmark/prd345/final-gates/backlight-native-contract-build.log.gz new file mode 100644 index 0000000000..36d5842278 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-native-contract-build.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-native-unit-gate.log.gz b/docs/benchmark/prd345/final-gates/backlight-native-unit-gate.log.gz new file mode 100644 index 0000000000..d2e1f90341 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-native-unit-gate.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-quickjs-build.log.gz b/docs/benchmark/prd345/final-gates/backlight-quickjs-build.log.gz new file mode 100644 index 0000000000..40d6a0ac57 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-quickjs-build.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-root-lint-final.log.gz b/docs/benchmark/prd345/final-gates/backlight-root-lint-final.log.gz new file mode 100644 index 0000000000..e2f75c2d08 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-root-lint-final.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-root-test2.log.gz b/docs/benchmark/prd345/final-gates/backlight-root-test2.log.gz new file mode 100644 index 0000000000..3a7f204a32 Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-root-test2.log.gz differ diff --git a/docs/benchmark/prd345/final-gates/backlight-root-typecheck3.log.gz b/docs/benchmark/prd345/final-gates/backlight-root-typecheck3.log.gz new file mode 100644 index 0000000000..aab344a96c Binary files /dev/null and b/docs/benchmark/prd345/final-gates/backlight-root-typecheck3.log.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/README.md b/docs/benchmark/prd345/final-template-matrix/README.md new file mode 100644 index 0000000000..70177bdf3b --- /dev/null +++ b/docs/benchmark/prd345/final-template-matrix/README.md @@ -0,0 +1,45 @@ +# Final PRD-345 default template captures + +All twelve conventional templates pass strict matched hardware WebGPU readiness captures +at the fixed tick-60 default camera: 1280×720, NVIDIA Turing, 4× MSAA, identical per-pair +camera/game state/clock/backend/buffer and cooked assets. Candidate generated runtime +source matches checkpoint `dca2d8d4b`, including read-only light matrices. All 24 full PNGs +were personally inspected by root and a separate reviewer. The original authored palettes +and surrounding geometry/shadows remain stable. Bright photographed IBL suppresses extra +analytic fill; difficult backlit/black-environment controls are separately qualified. + +This custom boot capture is not a full gameplay/playtest matrix, native proof, pipeline +compile-completion claim or a final per-frame/startup performance budget. Source manifests, +readiness session, runtime snapshots and adapter provenance are preserved in each gzip +report and the complete matrix. Earlier attempts and rejected racing frames remain in +separate historical evidence; they were not overwritten or silently filtered. + +Platformer flags and runner smoke can vary with presentation time despite matched gameplay +clock. Tower-defense HUD hover differs slightly. These differences are disclosed, not +claimed as lighting improvements or exact whole-frame pixel equivalence. Puzzle/tower +extra material rim/fill remain zero to preserve authored lights; runner/snow extra fill +remains zero. Custom RTS terrain/water and snow materials stay original. Rain's custom +raymarched shader is explicitly unqualified; native/mobile/software/WebGL retain originals. + +| Template | Before | After | +| --- | --- | --- | +| racing | ![Before](racing/before.png) | ![After](racing/after.png) | +| minimal | ![Before](minimal/before.png) | ![After](minimal/after.png) | +| starter | ![Before](starter/before.png) | ![After](starter/after.png) | +| platformer | ![Before](platformer/before.png) | ![After](platformer/after.png) | +| runner | ![Before](runner/before.png) | ![After](runner/after.png) | +| shooter | ![Before](shooter/before.png) | ![After](shooter/after.png) | +| action-rpg | ![Before](action-rpg/before.png) | ![After](action-rpg/after.png) | +| rts | ![Before](rts/before.png) | ![After](rts/after.png) | +| tower-defense | ![Before](tower-defense/before.png) | ![After](tower-defense/after.png) | +| puzzle | ![Before](puzzle/before.png) | ![After](puzzle/after.png) | +| sailing | ![Before](sailing/before.png) | ![After](sailing/after.png) | +| snow | ![Before](snow/before.png) | ![After](snow/after.png) | + +[Effective mechanisms, named zero controls and remaining original acceptance](mechanisms.md) +separate authored illumination from the new grazing/disc terms; palette-preserving boot proof +does not establish equivalent difficult-light behavior in every template. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/final-template-matrix/action-rpg/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/action-rpg/after-result.json.gz new file mode 100644 index 0000000000..c8b00fe533 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/action-rpg/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/action-rpg/after.png b/docs/benchmark/prd345/final-template-matrix/action-rpg/after.png new file mode 100644 index 0000000000..4e8578691b Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/action-rpg/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/action-rpg/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/action-rpg/before-result.json.gz new file mode 100644 index 0000000000..3df5aaf754 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/action-rpg/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/action-rpg/before.png b/docs/benchmark/prd345/final-template-matrix/action-rpg/before.png new file mode 100644 index 0000000000..b33983ba43 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/action-rpg/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/comparison-racing-puzzle-sailing.png b/docs/benchmark/prd345/final-template-matrix/comparison-racing-puzzle-sailing.png new file mode 100644 index 0000000000..690ac46794 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/comparison-racing-puzzle-sailing.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/matrix.full.json.gz b/docs/benchmark/prd345/final-template-matrix/matrix.full.json.gz new file mode 100644 index 0000000000..3957f5c51c Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/matrix.full.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/mechanisms.md b/docs/benchmark/prd345/final-template-matrix/mechanisms.md new file mode 100644 index 0000000000..583064ab4a --- /dev/null +++ b/docs/benchmark/prd345/final-template-matrix/mechanisms.md @@ -0,0 +1,36 @@ +# Effective default mechanism map + +All paths are relative to `packages/create-threenative/templates/`. Each conventional new +expression is actual source in `src/render/backlightMaterial.ts`, with live named `rimGain`, +`fillGain`, black `fillColor`, direction and angular-size controls. Reporting survives zero +controls. The rim is view grazing × key-behind × surface-backlit; fill is directional diffuse +plus a roughness-broadened reflected disc. Admission is hardware high-tier desktop WebGPU. + +| Template | Existing authored source and behavior | New default rim / fill | Effective opening and proof limit | +| --- | --- | --- | --- | +| minimal | `src/render/lighting.ts`: sun4.5; `sky.ts`: photo IBL | .12 / 1 | Measured bright IBL suppresses extra fill; static-material scene | +| starter | `lighting.ts`: sun4.5; `sky.ts`: photo IBL | .12 / 1 | Measured bright IBL suppresses fill; actual mannequin supplies global difficult-light controls | +| action-rpg | `lighting.ts`: sun4.5; photo IBL; `dungeon.ts`: torch points | .12 / 1 | Bright-source fill off; spawned loot enrolled | +| platformer | `lighting.ts`: following sun4.2; photo IBL | .12 / 1 | Bright-source fill off | +| shooter | `lighting.ts`: sun4.5; photo IBL | .12 / 1 | Bright-source fill off | +| racing | `lighting.ts`: sun4.5; photo IBL | .12 / 1 | Bright-source fill off; read-only matrix fix preserves road/shadows | +| rts | `lighting.ts`: following sun4.5; photo IBL | .12 / 1 | Bright-source fill off; dynamic armies enrolled, custom terrain/water excluded | +| sailing | `lighting.ts`: palette sun3.4; photo IBL | .12 / 1 | Bright-source fill off | +| runner | `lighting.ts`: sun4.5 plus camera directional fill .9 | .12 / 0 (actual `Run.ts` override) | Original fill retained; not analytic-disc equivalence | +| snow | `lighting.ts`: storm sun3.3, hemisphere2.2, camera directional fill .45 | .12 / 0 | Original storm fill retained; custom snow material excluded; no analytic-disc equivalence | +| puzzle | `lighting.ts`: key.8, hemisphere.6, cold directional rim.24; vault lantern/seal points | 0 / 0 | Measured missing environment zero, added terms disabled; originals not key-gated grazing/disc equivalence | +| tower-defense | `lighting.ts`: key3.4, hemisphere1.5, cold directional rim.9; photo IBL | 0 / 0 | Originals preserved; authored directional rim/hemisphere not requested expressions | +| rain | `lighting.ts` forwards key/fill/flash into actual custom `world-shader.ts`; cloud `uSky` camera pass | Not integrated | Existing hemisphere-like fill and flash diffuse are not grazing/disc; cloud-source mean unknown | + +Twelve matched full opening pairs establish authored-palette preservation. The starter mannequin +fixture supplies positive/zero rim, positive/black fill and omitted-report red-green controls. +Those global controls do not prove difficult-light behavior in every template. Bright-source +reports prove why fill is suppressed, not dark/no-sun behavior in every template. + +Existing directional rim/hemisphere lights are useful authored illumination, but are not +mathematically equivalent to the requested terms. All-template acceptance remains open for +puzzle/tower disabled additions, runner/snow analytic-fill omission, rain and enabled native +appearance. Do not stack duplicate ambient merely to make new gains nonzero. A coherent +replacement or qualified authored alternative requires actual behavioral/palette proof and +preserved original acceptance. Native/mobile/software/WebGL original-material fallback is +preservation evidence, not enabled-mechanism acceptance. diff --git a/docs/benchmark/prd345/final-template-matrix/minimal/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/minimal/after-result.json.gz new file mode 100644 index 0000000000..363580801d Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/minimal/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/minimal/after.png b/docs/benchmark/prd345/final-template-matrix/minimal/after.png new file mode 100644 index 0000000000..554fba671b Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/minimal/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/minimal/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/minimal/before-result.json.gz new file mode 100644 index 0000000000..a7594dd8c4 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/minimal/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/minimal/before.png b/docs/benchmark/prd345/final-template-matrix/minimal/before.png new file mode 100644 index 0000000000..b3a5aea94b Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/minimal/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/platformer/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/platformer/after-result.json.gz new file mode 100644 index 0000000000..1d45b77781 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/platformer/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/platformer/after.png b/docs/benchmark/prd345/final-template-matrix/platformer/after.png new file mode 100644 index 0000000000..4b19890f4c Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/platformer/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/platformer/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/platformer/before-result.json.gz new file mode 100644 index 0000000000..b1b8484711 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/platformer/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/platformer/before.png b/docs/benchmark/prd345/final-template-matrix/platformer/before.png new file mode 100644 index 0000000000..0c59657069 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/platformer/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/puzzle/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/puzzle/after-result.json.gz new file mode 100644 index 0000000000..569dc03a73 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/puzzle/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/puzzle/after.png b/docs/benchmark/prd345/final-template-matrix/puzzle/after.png new file mode 100644 index 0000000000..e35a417540 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/puzzle/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/puzzle/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/puzzle/before-result.json.gz new file mode 100644 index 0000000000..8d50fafbcc Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/puzzle/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/puzzle/before.png b/docs/benchmark/prd345/final-template-matrix/puzzle/before.png new file mode 100644 index 0000000000..7c64f0c4a1 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/puzzle/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/racing/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/racing/after-result.json.gz new file mode 100644 index 0000000000..839c5ff69b Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/racing/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/racing/after.png b/docs/benchmark/prd345/final-template-matrix/racing/after.png new file mode 100644 index 0000000000..25647f0e31 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/racing/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/racing/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/racing/before-result.json.gz new file mode 100644 index 0000000000..db3b6eb942 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/racing/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/racing/before.png b/docs/benchmark/prd345/final-template-matrix/racing/before.png new file mode 100644 index 0000000000..721abb5dac Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/racing/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/rts/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/rts/after-result.json.gz new file mode 100644 index 0000000000..643e00bbe2 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/rts/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/rts/after.png b/docs/benchmark/prd345/final-template-matrix/rts/after.png new file mode 100644 index 0000000000..8144af4fd0 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/rts/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/rts/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/rts/before-result.json.gz new file mode 100644 index 0000000000..e81a10b241 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/rts/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/rts/before.png b/docs/benchmark/prd345/final-template-matrix/rts/before.png new file mode 100644 index 0000000000..1df481bbc1 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/rts/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/runner/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/runner/after-result.json.gz new file mode 100644 index 0000000000..e7f9dd3754 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/runner/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/runner/after.png b/docs/benchmark/prd345/final-template-matrix/runner/after.png new file mode 100644 index 0000000000..3658e4db35 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/runner/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/runner/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/runner/before-result.json.gz new file mode 100644 index 0000000000..616f3a6d78 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/runner/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/runner/before.png b/docs/benchmark/prd345/final-template-matrix/runner/before.png new file mode 100644 index 0000000000..8330d07517 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/runner/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/sailing/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/sailing/after-result.json.gz new file mode 100644 index 0000000000..e236893130 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/sailing/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/sailing/after.png b/docs/benchmark/prd345/final-template-matrix/sailing/after.png new file mode 100644 index 0000000000..ac911a5f8c Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/sailing/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/sailing/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/sailing/before-result.json.gz new file mode 100644 index 0000000000..8ad6c52747 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/sailing/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/sailing/before.png b/docs/benchmark/prd345/final-template-matrix/sailing/before.png new file mode 100644 index 0000000000..af5f3961d9 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/sailing/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/shooter/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/shooter/after-result.json.gz new file mode 100644 index 0000000000..fd1d5d0ac0 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/shooter/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/shooter/after.png b/docs/benchmark/prd345/final-template-matrix/shooter/after.png new file mode 100644 index 0000000000..2713d4b13e Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/shooter/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/shooter/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/shooter/before-result.json.gz new file mode 100644 index 0000000000..48874f7bbe Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/shooter/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/shooter/before.png b/docs/benchmark/prd345/final-template-matrix/shooter/before.png new file mode 100644 index 0000000000..2be412b5d4 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/shooter/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/snow/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/snow/after-result.json.gz new file mode 100644 index 0000000000..e15e773a01 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/snow/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/snow/after.png b/docs/benchmark/prd345/final-template-matrix/snow/after.png new file mode 100644 index 0000000000..6b4dc5edcc Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/snow/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/snow/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/snow/before-result.json.gz new file mode 100644 index 0000000000..7c7181f2f2 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/snow/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/snow/before.png b/docs/benchmark/prd345/final-template-matrix/snow/before.png new file mode 100644 index 0000000000..66e9864dcd Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/snow/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/starter/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/starter/after-result.json.gz new file mode 100644 index 0000000000..9ea53bfc30 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/starter/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/starter/after.png b/docs/benchmark/prd345/final-template-matrix/starter/after.png new file mode 100644 index 0000000000..da00e8907c Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/starter/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/starter/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/starter/before-result.json.gz new file mode 100644 index 0000000000..547836715a Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/starter/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/starter/before.png b/docs/benchmark/prd345/final-template-matrix/starter/before.png new file mode 100644 index 0000000000..90d7a03035 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/starter/before.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/tower-defense/after-result.json.gz b/docs/benchmark/prd345/final-template-matrix/tower-defense/after-result.json.gz new file mode 100644 index 0000000000..9ea132cf32 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/tower-defense/after-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/tower-defense/after.png b/docs/benchmark/prd345/final-template-matrix/tower-defense/after.png new file mode 100644 index 0000000000..1f05c9f0b5 Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/tower-defense/after.png differ diff --git a/docs/benchmark/prd345/final-template-matrix/tower-defense/before-result.json.gz b/docs/benchmark/prd345/final-template-matrix/tower-defense/before-result.json.gz new file mode 100644 index 0000000000..ad9ea0985e Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/tower-defense/before-result.json.gz differ diff --git a/docs/benchmark/prd345/final-template-matrix/tower-defense/before.png b/docs/benchmark/prd345/final-template-matrix/tower-defense/before.png new file mode 100644 index 0000000000..4f90957e5f Binary files /dev/null and b/docs/benchmark/prd345/final-template-matrix/tower-defense/before.png differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/README.md b/docs/benchmark/prd345/generated-gameplay-native-fallback/README.md new file mode 100644 index 0000000000..25ea0f85f9 --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/README.md @@ -0,0 +1,11 @@ +# Generated gameplay and native fallback + +All 13 actual clean generated starters used the same frozen local framework archives and forced capture lease. Eleven passed their original full gameplay suite. Shooter failed its unchanged 33 ms performance p95 limit at 52 ms (352 performance samples; 603 observed frames); snow failed at 34 ms (223 performance samples; 660 observed frames). Those failures are retained, with matched baseline controls pending. All 13 booted independently. This is not an all-template gameplay acceptance pass. Full raw reports are compressed here; every original image and report remains in the durable local gameplay5 tree, with SHA-256 image/report inventory. + +The native opening before/after pair uses actual native WebGPU on NVIDIA RTX 2080, 1280×720, matched camera and terminal tick 86. The candidate reports enabled=false, converted=0, unknown mean radiance, and platform-unqualified fallback. These pictures demonstrate preservation of the original appearance, not enabled native lighting qualification. Original native render-chain gameplay attempts remain blocked by unsupported visibility observations; the separate compatible opening witness does not replace those gates. Raw reports, observations, derived proof and both full images are retained here. Both native reports disclose the antialias stage dropped with `build:Image is not defined`, and network diagnostics explicitly waived because native has no observer. These limitations remain open. Independent review personally inspected both full images and confirmed the matched camera/projection, tick, sun, fog, object count and material census. Single-launch ready timings are not p95 measurements. + +The root full suite separately failed two canonical fixture-path checks before its unit phase; the fixture correction and its seven passing tests are recorded in ../packed-fixture-canonical-root. Original acceptance remains partial. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-artifact-manifest.json b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-artifact-manifest.json new file mode 100644 index 0000000000..57fb1f594f --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-artifact-manifest.json @@ -0,0 +1,2717 @@ +[ + { + "path": "action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/after.png", + "bytes": 918653, + "sha256": "24b11e0fe52bff4ec5ff4e1ca7e1d90ee0e0ff6f8e4d7b1c2b10c5c5b045c563" + }, + { + "path": "action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/console.json", + "bytes": 5726, + "sha256": "51f4e7798aca1ff1deb476b92aff899dc0ef3fe7d82582a2e99f1d3c2e253556" + }, + { + "path": "action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/after.png", + "bytes": 919802, + "sha256": "b20433534e07cfe8f42060d638581ac48c9c35f7a8fed5eb17da7f3532f1155e" + }, + { + "path": "action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/console.json", + "bytes": 5726, + "sha256": "3b8a249d0f5ffa86567f7b5131e906ee61a8ce7aaebe4699cccfe644734c6ab7" + }, + { + "path": "action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/after.png", + "bytes": 909346, + "sha256": "2d03d91ece0762695c597ebb96ad746fe29e1a6ad5b86d3b1dbfc8fbdd4af56d" + }, + { + "path": "action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/console.json", + "bytes": 5726, + "sha256": "3b0e72565e15d96fb535cb9bc73260d9cb68ebd7156fdaa1dd1cc673a84bd59e" + }, + { + "path": "action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/after.png", + "bytes": 2154147, + "sha256": "866fafbe0d7ca9ad19a6d83af275336ba78a635780ece29354203c70c5fb46f0" + }, + { + "path": "action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/console.json", + "bytes": 47843, + "sha256": "d816e41bb647807d3db97c02d62a83c37a8b2c75c28b8216663fd8ac3ebc9052" + }, + { + "path": "action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/after.png", + "bytes": 946158, + "sha256": "0f1d44d859a4adc8e467a19ede381dfcb3342f06a53d145613c5d24ae0ce7141" + }, + { + "path": "action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/console.json", + "bytes": 10074, + "sha256": "f31048c9add8222026e2998294989488b08e90ef0048db7e84e4a4a70186f9e7" + }, + { + "path": "action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/after.png", + "bytes": 914716, + "sha256": "6273515717dde626178999f79faf3867f6c87eeddb1e2e9861df7a2bd00ed347" + }, + { + "path": "action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/console.json", + "bytes": 5726, + "sha256": "ecf2db09b175fc84e5f22dbc9cd11757497bb22ea7ea0b2eba48d40ef64069c5" + }, + { + "path": "action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/after.png", + "bytes": 950373, + "sha256": "e5d3193d57334af62ab7d0e18f42e2f1fb50929cba993ed9e59b3bfa01b15929" + }, + { + "path": "action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json", + "bytes": 5490, + "sha256": "d1f6e0af12c9f53827e507c4ac5eae38ebd0093bbd6889d3ae3aa05cffeec4ed" + }, + { + "path": "action-rpg/artifacts/playtest/08-playtests-win.playtest.json/after.png", + "bytes": 934286, + "sha256": "67617c973a2d4c926fd4cf2b35aa1a090a4348728e4ac0deeea4db185f01d268" + }, + { + "path": "action-rpg/artifacts/playtest/08-playtests-win.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "action-rpg/artifacts/playtest/08-playtests-win.playtest.json/console.json", + "bytes": 5726, + "sha256": "6e592abbb7735bdfd153c8bd967fd1986a39636cdae016105f5355fb029ff083" + }, + { + "path": "action-rpg/artifacts/playtest/after.png", + "bytes": 194883, + "sha256": "338c0fbbeef11816d4065a20e4481153935602bf2ce9653b237621eabdb22d6b" + }, + { + "path": "action-rpg/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "action-rpg/artifacts/playtest/console.json", + "bytes": 6117, + "sha256": "10672f19d9266d770bf8d906b0f356df03025154e357aa8f1197210cb0697d00" + }, + { + "path": "minimal/artifacts/playtest/01-playtests-play.playtest.json/after.png", + "bytes": 1897559, + "sha256": "e361b34c21df30d569ea0c208cb6ee993e1c015f1354a663bca9e2c5df20bad5" + }, + { + "path": "minimal/artifacts/playtest/01-playtests-play.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "minimal/artifacts/playtest/01-playtests-play.playtest.json/console.json", + "bytes": 12394, + "sha256": "f029c328db7310ed655c9fa1bfac8a19caa394a093d0a5b39c582085ecc15231" + }, + { + "path": "minimal/artifacts/playtest/02-playtests-sky.playtest.json/after.png", + "bytes": 861599, + "sha256": "8c1b6a8177a6845beef01ae074edfbb6f3b77c4502fd626651d4b2ba1ae442d5" + }, + { + "path": "minimal/artifacts/playtest/02-playtests-sky.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "minimal/artifacts/playtest/02-playtests-sky.playtest.json/console.json", + "bytes": 4084, + "sha256": "93264f7c7aba25a0c10183e713e9014c2cca98bbb12a491d440f67751418f1c4" + }, + { + "path": "minimal/artifacts/playtest/03-playtests-survives.playtest.json/after.png", + "bytes": 879528, + "sha256": "c56dd8afc83e50b1e864e051063d6243cf4772e3eafefd2317eed0472cce1028" + }, + { + "path": "minimal/artifacts/playtest/03-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "minimal/artifacts/playtest/03-playtests-survives.playtest.json/console.json", + "bytes": 4084, + "sha256": "93264f7c7aba25a0c10183e713e9014c2cca98bbb12a491d440f67751418f1c4" + }, + { + "path": "minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/after.png", + "bytes": 874601, + "sha256": "08c50378490ad55349ae715f71beda85fe650100aa15690fb4a7273cb466da13" + }, + { + "path": "minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/console.json", + "bytes": 3848, + "sha256": "2de154546326e06419a5fba8fa061412aa3deb7fbcbf0d6619e40a5edbcbd132" + }, + { + "path": "minimal/artifacts/playtest/after.png", + "bytes": 239311, + "sha256": "52c6b46354db43aa0d0affc8f4ad3b53ab0ed9b235bc34cc4acf4bc9d2e3775b" + }, + { + "path": "minimal/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "minimal/artifacts/playtest/console.json", + "bytes": 4277, + "sha256": "b5aeb0a0ea974264762328f85c3a244e739edc82130b69e3fe1bf551b1865fe3" + }, + { + "path": "platformer/artifacts/playtest/01-playtests-branding.playtest.json/after.png", + "bytes": 641154, + "sha256": "5f7b02468889d20347a71daee5958bbfe1e6fd013d7038295a18ebac96505e2d" + }, + { + "path": "platformer/artifacts/playtest/01-playtests-branding.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/01-playtests-branding.playtest.json/console.json", + "bytes": 5010, + "sha256": "6958a5e1fe077482c79a86060ca1a5c06108462b3c19f16cc3e7620596e90d75" + }, + { + "path": "platformer/artifacts/playtest/02-playtests-buffer.playtest.json/after.png", + "bytes": 693814, + "sha256": "128422dd20aadc0004b468d9c7a5cbca121ed12b6f02fefb2b1d5c0e63261399" + }, + { + "path": "platformer/artifacts/playtest/02-playtests-buffer.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/02-playtests-buffer.playtest.json/console.json", + "bytes": 5010, + "sha256": "d0dda6087b1a69e5566d2109e07f38742459796c5aa43f34aa416372f0c87191" + }, + { + "path": "platformer/artifacts/playtest/03-playtests-collect.playtest.json/after.png", + "bytes": 721478, + "sha256": "3fc243dfe34a5867bd011cf262d4123cf6ae1974c07dc7caef01995ce8637f99" + }, + { + "path": "platformer/artifacts/playtest/03-playtests-collect.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/03-playtests-collect.playtest.json/console.json", + "bytes": 5010, + "sha256": "aced131cc9ea6505fdb9d9476ecee8a972a7224c578db18e89c38cb079e59661" + }, + { + "path": "platformer/artifacts/playtest/04-playtests-coyote.playtest.json/after.png", + "bytes": 653862, + "sha256": "ecd44b4b5aadd82ff340f5b71adc01c092cb85b61f9e633aef2a0f1c617379ce" + }, + { + "path": "platformer/artifacts/playtest/04-playtests-coyote.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/04-playtests-coyote.playtest.json/console.json", + "bytes": 5010, + "sha256": "c6d9d609607bad2a3d5dfbb4d9810e0ef83221196841b0bdd09623176752423f" + }, + { + "path": "platformer/artifacts/playtest/05-playtests-damage.playtest.json/after.png", + "bytes": 695582, + "sha256": "5f38b42bcf062c9bb7c661817926b603634a117b5827a2303b796462c563e4f7" + }, + { + "path": "platformer/artifacts/playtest/05-playtests-damage.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/05-playtests-damage.playtest.json/console.json", + "bytes": 5010, + "sha256": "22e249d0be868f9a436f8820e67b688dd3221e18f2405e1b7c90640154beeaa1" + }, + { + "path": "platformer/artifacts/playtest/06-playtests-dash.playtest.json/after.png", + "bytes": 746627, + "sha256": "7db18899a07bc0b13d5b45fde3ff7919a751a0fded8b8561e197f77876360d7c" + }, + { + "path": "platformer/artifacts/playtest/06-playtests-dash.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/06-playtests-dash.playtest.json/console.json", + "bytes": 5010, + "sha256": "4fa5a6003211779fdfb5312084e6d7057a71a89675327a908dacc998d4748b6b" + }, + { + "path": "platformer/artifacts/playtest/07-playtests-hud.playtest.json/after.png", + "bytes": 760487, + "sha256": "a0c2555e5d62f40635ade17d665417560bd633ea7294501d9188bf0aee2df74e" + }, + { + "path": "platformer/artifacts/playtest/07-playtests-hud.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/07-playtests-hud.playtest.json/console.json", + "bytes": 5010, + "sha256": "2cac47b9b6a8c6eb38b081b73095bdbd117164f97b3a7f6105c4628a2e73bf3b" + }, + { + "path": "platformer/artifacts/playtest/08-playtests-jump.playtest.json/after.png", + "bytes": 683702, + "sha256": "05d07024726b09166d1ccc4ec7ba907b80bf21cad1237303f8480292d4ad89a1" + }, + { + "path": "platformer/artifacts/playtest/08-playtests-jump.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/08-playtests-jump.playtest.json/console.json", + "bytes": 5010, + "sha256": "3e058f27f22400d198fd081835565ce757e6f80c98f49399df24b5d8db41a83c" + }, + { + "path": "platformer/artifacts/playtest/09-playtests-move.playtest.json/after.png", + "bytes": 721590, + "sha256": "fd897b5b62dae4e9f57665e71b02f58ec149e7175406589b6860333f2062660c" + }, + { + "path": "platformer/artifacts/playtest/09-playtests-move.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/09-playtests-move.playtest.json/console.json", + "bytes": 5010, + "sha256": "c17139c72a874e851b4e03ee1324f9d97b05b19bc564c7bb8af92ede70f3a35f" + }, + { + "path": "platformer/artifacts/playtest/10-playtests-performance.playtest.json/after.png", + "bytes": 1377279, + "sha256": "90b42ce927feb3eabe825ba296a8944b775e8e024b2f9fdd3c77f9ebdb3f5e62" + }, + { + "path": "platformer/artifacts/playtest/10-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "platformer/artifacts/playtest/10-playtests-performance.playtest.json/console.json", + "bytes": 80298, + "sha256": "a70bdf601fc02154bfc6980328e25d4081c158ad8d11b4d4d8644df284511f3d" + }, + { + "path": "platformer/artifacts/playtest/11-playtests-respawn.playtest.json/after.png", + "bytes": 745585, + "sha256": "ad59d872c1b93969e0d2ffb83f9a91e414a07793f4a9bfc4489a53dfa50578b3" + }, + { + "path": "platformer/artifacts/playtest/11-playtests-respawn.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/11-playtests-respawn.playtest.json/console.json", + "bytes": 5010, + "sha256": "87ee7bf811cf0ecfc55acfb5bbf79576d5d39fe7ccdeb28fdd1e3e17ddcb20b4" + }, + { + "path": "platformer/artifacts/playtest/12-playtests-stomp.playtest.json/after.png", + "bytes": 689347, + "sha256": "8d24c1aff156a0c103ebcd17a21ef8977a6926e54cd16febd2a8b199c41a526f" + }, + { + "path": "platformer/artifacts/playtest/12-playtests-stomp.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/12-playtests-stomp.playtest.json/console.json", + "bytes": 5010, + "sha256": "80bd16aff05ed76fa4c60327d3f4f0aac62e3147e630333a2bb617ca1bb5f94c" + }, + { + "path": "platformer/artifacts/playtest/13-playtests-survives.playtest.json/after.png", + "bytes": 341600, + "sha256": "50f029f275bf151bbfedd40004045abe747702e45afd33a748678880e5b6287d" + }, + { + "path": "platformer/artifacts/playtest/13-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/13-playtests-survives.playtest.json/console.json", + "bytes": 5010, + "sha256": "45ee3e07953d3921002aca73311056eb5d95b13827cacff4bed3d48740d66182" + }, + { + "path": "platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/after.png", + "bytes": 465733, + "sha256": "005c0bd35fd6e717e53d7187043426ee51516e67d80713e9c76013a16d61f9e9" + }, + { + "path": "platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/console.json", + "bytes": 4774, + "sha256": "95f3ceedc36c9b75f8e8ff71aec34bebaa9a99ba313a11fba83d07b28a9b5d87" + }, + { + "path": "platformer/artifacts/playtest/15-playtests-world.playtest.json/after.png", + "bytes": 644133, + "sha256": "2a0ab07b4dd7463d19648d39cf37f68f29b3f49398052e5ab7836246a21b83a3" + }, + { + "path": "platformer/artifacts/playtest/15-playtests-world.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "platformer/artifacts/playtest/15-playtests-world.playtest.json/console.json", + "bytes": 5010, + "sha256": "cc5152704ff0715337da9f90b71ee1768fe621e7c42ee2dd264be67352ce1dfa" + }, + { + "path": "platformer/artifacts/playtest/after.png", + "bytes": 212743, + "sha256": "65bf6bdc5dc2b5811d67118c091ce0a267bf1c62aeb772f694781b8eff093620" + }, + { + "path": "platformer/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "platformer/artifacts/playtest/console.json", + "bytes": 5401, + "sha256": "6649f57b8c71b6af524dcf9ac01c7a45b1d5d1759bd603043e65698c90bcf02a" + }, + { + "path": "puzzle/artifacts/playtest/01-playtests-axis.playtest.json/after.png", + "bytes": 471294, + "sha256": "1420d534fca837e0fd41fe76fe765a97ea249cf78c684cd84890d458f50904c2" + }, + { + "path": "puzzle/artifacts/playtest/01-playtests-axis.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/01-playtests-axis.playtest.json/console.json", + "bytes": 4672, + "sha256": "ef0902b12cc063ab5d23e4aa8b8c2a78b3d884313d29a8e4fc8ed88717b6cfbb" + }, + { + "path": "puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/after.png", + "bytes": 478489, + "sha256": "79a80ece762044092bf8158a435599e6b60e5d7cc9b848fc8efd527b4343704d" + }, + { + "path": "puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/console.json", + "bytes": 4672, + "sha256": "1101feb9ab0dc5fe5f8f69c19dae3bba5fa8c332d172aa7739f01586057f5a70" + }, + { + "path": "puzzle/artifacts/playtest/03-playtests-boot-failure.playtest.json/after.png", + "bytes": 12782, + "sha256": "f9d814180c9095c4e6b1e7077f513f22370e19c187f829cc9f534903a6a7cdb5" + }, + { + "path": "puzzle/artifacts/playtest/03-playtests-boot-failure.playtest.json/console.json", + "bytes": 308, + "sha256": "c0223ae408a253fee3a74bf6cefb3c6e80e175158eb51eeff5733f62cc5b87eb" + }, + { + "path": "puzzle/artifacts/playtest/04-playtests-caused.playtest.json/after.png", + "bytes": 505289, + "sha256": "9072909b9ac851f2f0998509a946254fda04e49739fa10e6d22894d91f408f8d" + }, + { + "path": "puzzle/artifacts/playtest/04-playtests-caused.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/04-playtests-caused.playtest.json/console.json", + "bytes": 4672, + "sha256": "943efc7a62c7560191adbf79f7d67b8be2fdf973b9b977d2eb19fee714a18e3a" + }, + { + "path": "puzzle/artifacts/playtest/05-playtests-lit.playtest.json/after.png", + "bytes": 476230, + "sha256": "f60ea8dabea0f76a0db03cf46f899abb1bde2db34a9c949b7f4a27c92b6e396d" + }, + { + "path": "puzzle/artifacts/playtest/05-playtests-lit.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/05-playtests-lit.playtest.json/console.json", + "bytes": 4672, + "sha256": "b7222832811484d15169c4ef36bfa4b6e947c13cda5be20a3a91ebf2519cd98a" + }, + { + "path": "puzzle/artifacts/playtest/06-playtests-pause.playtest.json/after.png", + "bytes": 478270, + "sha256": "2a41cf72ae989447e1e5e4aa5775bd4971468af4921836566af0f5e820b83804" + }, + { + "path": "puzzle/artifacts/playtest/06-playtests-pause.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/06-playtests-pause.playtest.json/console.json", + "bytes": 4672, + "sha256": "70976604b46bacf0eee53e7faf0baa83317ef96465ce6c6a1dc80642d5706bc8" + }, + { + "path": "puzzle/artifacts/playtest/07-playtests-performance.playtest.json/after.png", + "bytes": 861827, + "sha256": "0bd2089d930b16f095380c57feb0bcc8d7bd92131c987661bfe6eb23428ef43c" + }, + { + "path": "puzzle/artifacts/playtest/07-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "puzzle/artifacts/playtest/07-playtests-performance.playtest.json/console.json", + "bytes": 49939, + "sha256": "d38499fc09f42a01380bad878e7a65e0892df692741ec4343ec889eedaa8e797" + }, + { + "path": "puzzle/artifacts/playtest/08-playtests-push.playtest.json/after.png", + "bytes": 476255, + "sha256": "9e6db08b19bc2e0d794a07f882be571ff5e6737437a93389d82bfc47dab1a31a" + }, + { + "path": "puzzle/artifacts/playtest/08-playtests-push.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/08-playtests-push.playtest.json/console.json", + "bytes": 4672, + "sha256": "97a861924652c725655d846cb4f9066c0d77b0dd3a45e76ba7aa85e9028c3503" + }, + { + "path": "puzzle/artifacts/playtest/09-playtests-replay.playtest.json/after.png", + "bytes": 478538, + "sha256": "e5b45331703f33800d7f476f16b4d559dc00d218160a4d6824a3ad7f2e13cf46" + }, + { + "path": "puzzle/artifacts/playtest/09-playtests-replay.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/09-playtests-replay.playtest.json/console.json", + "bytes": 4801, + "sha256": "a1a73b7b7d55f1592b5a3a8a41c8680af9c5ac6867f9b168f8fabe61c642d8ca" + }, + { + "path": "puzzle/artifacts/playtest/10-playtests-restart.playtest.json/after.png", + "bytes": 478709, + "sha256": "7cf31b68c20f553cdd763b1326f188cd334391eddfc26cb07aa5d1d9b03dbc78" + }, + { + "path": "puzzle/artifacts/playtest/10-playtests-restart.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/10-playtests-restart.playtest.json/console.json", + "bytes": 8411, + "sha256": "1dba98257e5de0526d0a739f803a5c62ba6c7d596bd55c9860315a8f1e6a80aa" + }, + { + "path": "puzzle/artifacts/playtest/11-playtests-seal.playtest.json/after.png", + "bytes": 505724, + "sha256": "eea0392065d4ac2e50261c4eefa4d47547e96a6ee4b545c29dbfd191e9229b36" + }, + { + "path": "puzzle/artifacts/playtest/11-playtests-seal.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/11-playtests-seal.playtest.json/console.json", + "bytes": 4672, + "sha256": "fcd73df650c4b785217537e63b75526ea7c3e101858dbed3797fcc5ad8f0f597" + }, + { + "path": "puzzle/artifacts/playtest/12-playtests-settle.playtest.json/after.png", + "bytes": 478262, + "sha256": "9153d7e29fa52fe3f083b6260aa36117c2b4513bba5c210c79ae4b1e3fcf5d0b" + }, + { + "path": "puzzle/artifacts/playtest/12-playtests-settle.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/12-playtests-settle.playtest.json/console.json", + "bytes": 4672, + "sha256": "9aa9e96dfc4734e823612d7aa2c39b4883619762de1a0d39ae397e48b2346b8f" + }, + { + "path": "puzzle/artifacts/playtest/13-playtests-survives.playtest.json/after.png", + "bytes": 476456, + "sha256": "b4053b00c2ab0908f1cf611dffa4e7a76ef7dc09e02fbe97a8db95ff38b72cd7" + }, + { + "path": "puzzle/artifacts/playtest/13-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/13-playtests-survives.playtest.json/console.json", + "bytes": 4672, + "sha256": "74cbd22dbdb30d127028064062e38b5c87b716840bfe3a1e52b10a5b7bbca782" + }, + { + "path": "puzzle/artifacts/playtest/14-playtests-travel.playtest.json/after.png", + "bytes": 476385, + "sha256": "0162a38b572924b310d7618715e373f7f90666b7a7401d45cbd8e6e410e1eca2" + }, + { + "path": "puzzle/artifacts/playtest/14-playtests-travel.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "puzzle/artifacts/playtest/14-playtests-travel.playtest.json/console.json", + "bytes": 4672, + "sha256": "5e3e0093f12240b8dd84d48c7a4f1a2a95422cd088c3ce0aa70e2422cf617bfc" + }, + { + "path": "puzzle/artifacts/playtest/14-playtests-travel.playtest.json/network.json", + "bytes": 3, + "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" + }, + { + "path": "puzzle/artifacts/playtest/14-playtests-travel.playtest.json/runtime-trace.json", + "bytes": 406, + "sha256": "ff86a1fa181a670628785013e17e6231a6a41b9e56d408d342c46535513e9cf5" + }, + { + "path": "puzzle/artifacts/playtest/after.png", + "bytes": 170736, + "sha256": "5cdf217916274dc8d4cfe8d03f5cff369473578216fa3a1f19449f41167ee522" + }, + { + "path": "puzzle/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "puzzle/artifacts/playtest/console.json", + "bytes": 5062, + "sha256": "d9e0e6bbe80fd037e0bc3ad7938da16e55875d3d18f0aa9544f2b6d9a98d7897" + }, + { + "path": "racing/artifacts/playtest/01-playtests-boost.playtest.json/after.png", + "bytes": 704493, + "sha256": "fdb482053c6cc09bdcef7c554374d520f0f03be2d09ddc40d3ea43f2a433bd7d" + }, + { + "path": "racing/artifacts/playtest/01-playtests-boost.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/01-playtests-boost.playtest.json/console.json", + "bytes": 5302, + "sha256": "368714ece43b3272ad76200351a40cfdca1c1ea77d1bc5169b16f4d6e72ff563" + }, + { + "path": "racing/artifacts/playtest/02-playtests-outcome.playtest.json/after.png", + "bytes": 810647, + "sha256": "c1f7086720a0a17f1f666f52230c3ba767f00aadcca86596e68133f5d0065697" + }, + { + "path": "racing/artifacts/playtest/02-playtests-outcome.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/02-playtests-outcome.playtest.json/console.json", + "bytes": 5302, + "sha256": "c8c0f78ed7ba9306349b952cc87f9427d51340f94dce147e30489e676c0bdd8d" + }, + { + "path": "racing/artifacts/playtest/03-playtests-performance.playtest.json/after.png", + "bytes": 1473475, + "sha256": "7d7b63ee13c381dd29bae62355099376da3ee2677387e50f3870caa736b64ad8" + }, + { + "path": "racing/artifacts/playtest/03-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "racing/artifacts/playtest/03-playtests-performance.playtest.json/console.json", + "bytes": 61166, + "sha256": "550965d5bae1552592a0dd9ce29bd99a7194010de768b4affd0c332f7e815240" + }, + { + "path": "racing/artifacts/playtest/04-playtests-ranking.playtest.json/after.png", + "bytes": 751653, + "sha256": "a7606886287c29062b6c1822aeea9635d93974e0f02f2d853ea94fa640cf7605" + }, + { + "path": "racing/artifacts/playtest/04-playtests-ranking.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/04-playtests-ranking.playtest.json/console.json", + "bytes": 5302, + "sha256": "a385524f065fea233573cf11d9e2af9139e1b75ad73309f6d8f2cb6077cb671f" + }, + { + "path": "racing/artifacts/playtest/05-playtests-rescue.playtest.json/after.png", + "bytes": 751336, + "sha256": "ea8d5ef70688cef57794725182e8e714c980bebb47a0d6022fde04639a9ea4f0" + }, + { + "path": "racing/artifacts/playtest/05-playtests-rescue.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/05-playtests-rescue.playtest.json/console.json", + "bytes": 5302, + "sha256": "da9072e9ca713a211c071dbe7aa49e5f37afe02a3f054db599310af2322bbc81" + }, + { + "path": "racing/artifacts/playtest/06-playtests-reverse.playtest.json/after.png", + "bytes": 771939, + "sha256": "eeed8d3e4ff49734ba413ce2508ce7e41d739ff15d8ad77f07f06b6d3cd7b781" + }, + { + "path": "racing/artifacts/playtest/06-playtests-reverse.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/06-playtests-reverse.playtest.json/console.json", + "bytes": 5302, + "sha256": "ac39c092876be25ba316885a6cf670ec16401889d9348048b59f2b68ffc905cd" + }, + { + "path": "racing/artifacts/playtest/07-playtests-shortcut.playtest.json/after.png", + "bytes": 715454, + "sha256": "fcf9080af0b73e18f9b7e8f365d0f62938e8bb7ecd2e3bc5ff8666cca1822572" + }, + { + "path": "racing/artifacts/playtest/07-playtests-shortcut.playtest.json/before.png", + "bytes": 725943, + "sha256": "b3c27711421107484633c219e5dc1679f378bbe3e9bbfecdee26aeb0b19c3a87" + }, + { + "path": "racing/artifacts/playtest/07-playtests-shortcut.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/07-playtests-shortcut.playtest.json/console.json", + "bytes": 5302, + "sha256": "97ed14dd3c23e723524ad09c4ae7dcbcae5eabaf32e0c0fddf50c59748922aa9" + }, + { + "path": "racing/artifacts/playtest/08-playtests-survives.playtest.json/after.png", + "bytes": 719548, + "sha256": "5e5fff3de571a4a14f3be367105b4e0eb5dce691f73e2086a7f89f55bed38404" + }, + { + "path": "racing/artifacts/playtest/08-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/08-playtests-survives.playtest.json/console.json", + "bytes": 5302, + "sha256": "d78983d681435ddb5fab4f66c18406b93898ffc6237b46ccc6ca5678c64c20e4" + }, + { + "path": "racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/after.png", + "bytes": 606166, + "sha256": "dc3a23d75823bfd5cffc4c27cb8042964af19ac251454f6831074df9adb7b9d3" + }, + { + "path": "racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/console.json", + "bytes": 5024, + "sha256": "95012047bbf86ea1f661d9ee2187570480859819c891bad062d82c78295e9785" + }, + { + "path": "racing/artifacts/playtest/after.png", + "bytes": 220066, + "sha256": "82d017b3487ccf11af8d9940b35d73a6e935782783b7f4ed665e6caf937b4120" + }, + { + "path": "racing/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "racing/artifacts/playtest/console.json", + "bytes": 5693, + "sha256": "1af9e4170d4e5a8df28f5f0802f2933fcf5a515fd551f80e99a893d27c5a6186" + }, + { + "path": "rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/after.png", + "bytes": 1389882, + "sha256": "fa9ea4b0c8f77bcc45efce8d1fb1a57bd7ec7a58be36aafc05a69f37c30ab025" + }, + { + "path": "rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/console.json", + "bytes": 3260, + "sha256": "2a0305f4c64cbfd2da5f6d2528eb0268924c526c2b96f03e7e93a5c767eb8c36" + }, + { + "path": "rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/after.png", + "bytes": 1381796, + "sha256": "24892b81a166476926677427cbad671d52db0f3de7759280d76f150c0161e207" + }, + { + "path": "rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/console.json", + "bytes": 3259, + "sha256": "950bda1433622f0c2d3f96fe5921fe73fcf0782b5a7b39834c31b4d74d5d77be" + }, + { + "path": "rain/artifacts/playtest/03-playtests-automation.playtest.json/after.png", + "bytes": 364033, + "sha256": "afc7e2d9d41913dfb0016632c05c5c2f5a5cf2a1fff0a11db1b9ea8e3c8049e1" + }, + { + "path": "rain/artifacts/playtest/03-playtests-automation.playtest.json/capture.json", + "bytes": 1069, + "sha256": "d47a8af813e4e8d3637a0d8c4b94643968609e4b5af45fcc5d3c77185461ad41" + }, + { + "path": "rain/artifacts/playtest/03-playtests-automation.playtest.json/console.json", + "bytes": 3259, + "sha256": "b789b0b5c684a3e0163a3802ed4fd3dafac23dc0b983d6bf89eb90451e0a5f79" + }, + { + "path": "rain/artifacts/playtest/04-playtests-cinematic.playtest.json/after.png", + "bytes": 1345352, + "sha256": "68dd7be7cc20fe3bcb7b8d3d8cbbf810fd4f2284bf9435073d6c3107f6706a6c" + }, + { + "path": "rain/artifacts/playtest/04-playtests-cinematic.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/04-playtests-cinematic.playtest.json/console.json", + "bytes": 3259, + "sha256": "a28b7354933d9f8a820c575cbc6569431ed8c3d1be4bc615cc943ae7d8215e51" + }, + { + "path": "rain/artifacts/playtest/05-playtests-flash.playtest.json/after.png", + "bytes": 1473572, + "sha256": "2324c9a96f17a76e62012c9674e98e2177f2aac4aaa28e977bca2847c8cef3bb" + }, + { + "path": "rain/artifacts/playtest/05-playtests-flash.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/05-playtests-flash.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/05-playtests-flash.playtest.json/console.json", + "bytes": 3259, + "sha256": "a5614e3533398b05be8eaf1503ff3ab67e39fe65efe0e8a830089a99acd635c7" + }, + { + "path": "rain/artifacts/playtest/06-playtests-fly.playtest.json/after.png", + "bytes": 1343572, + "sha256": "f45d620205c4766393c8aef666da59b5427efd7d8897e33c2fdd25078fef7393" + }, + { + "path": "rain/artifacts/playtest/06-playtests-fly.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/06-playtests-fly.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/06-playtests-fly.playtest.json/console.json", + "bytes": 3259, + "sha256": "edb11cf25efb8aeed811641753ed504d6e17d12ef389262bc743cbb76b64644d" + }, + { + "path": "rain/artifacts/playtest/07-playtests-lightning.playtest.json/after.png", + "bytes": 1371067, + "sha256": "e8f00b2b94d0a9b880a31c3ea1f3ba0bc8296ee7e79dfe9635f68cc0af648aa4" + }, + { + "path": "rain/artifacts/playtest/07-playtests-lightning.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/07-playtests-lightning.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/07-playtests-lightning.playtest.json/console.json", + "bytes": 3259, + "sha256": "3f35571665f7fb98128778d1f0048e96da28fc1b69dab791933dad330dc6f104" + }, + { + "path": "rain/artifacts/playtest/07-playtests-lightning.playtest.json/strike.png", + "bytes": 1393336, + "sha256": "799dfdfa1b7999cf383203cca87eeb05cc47c4df56f6d871c5a0792c8c1c08ca" + }, + { + "path": "rain/artifacts/playtest/08-playtests-loading.playtest.json/after.png", + "bytes": 861601, + "sha256": "93351cde45b6ae5c940985a3e1f8da6419903caf1517efe94fe5cadcb27351b4" + }, + { + "path": "rain/artifacts/playtest/08-playtests-loading.playtest.json/capture.json", + "bytes": 1070, + "sha256": "c0faa5c42be171d779acb67b1c080e8f7f4242992c5bb5ac3e37adf106ab1f00" + }, + { + "path": "rain/artifacts/playtest/08-playtests-loading.playtest.json/console.json", + "bytes": 3259, + "sha256": "b55687dcbe2d4d3298f07881505e1ba1ec7d4fb0cfbec6769fd583be1168cf7b" + }, + { + "path": "rain/artifacts/playtest/09-playtests-paused.playtest.json/after.png", + "bytes": 1376301, + "sha256": "767a8389814abc1c76b205491b159744b4a51d94308f0d9c9872397e0b3575c9" + }, + { + "path": "rain/artifacts/playtest/09-playtests-paused.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/09-playtests-paused.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/09-playtests-paused.playtest.json/console.json", + "bytes": 3259, + "sha256": "2b5e21dbcaf682e065d333ceea8b605b1f29b028549a44d67916658d70047e15" + }, + { + "path": "rain/artifacts/playtest/10-playtests-performance.playtest.json/after.png", + "bytes": 2163265, + "sha256": "3c9d7500c185cb526394a2da010309adc7be07ce1a74ceca113b468b479fb23e" + }, + { + "path": "rain/artifacts/playtest/10-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "rain/artifacts/playtest/10-playtests-performance.playtest.json/console.json", + "bytes": 23595, + "sha256": "eba92a1ed868be7e9540cbe31da44408ac7e0cd2088cb973d18858d2fd7778b3" + }, + { + "path": "rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/after.png", + "bytes": 1378439, + "sha256": "ebc23d26cbe252ead44b55473d1c0ecabd70468e7350502f70936d27300d612e" + }, + { + "path": "rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/console.json", + "bytes": 3259, + "sha256": "3c051bab757b6f4e6351a8cedbb9019a649dea25199eb90c859c51e12ac60fa9" + }, + { + "path": "rain/artifacts/playtest/12-playtests-simulation.playtest.json/after.png", + "bytes": 1005872, + "sha256": "79fd8c9861a3bd2246ed4008472a14872a5cd17266f22a1647a853afacb74c60" + }, + { + "path": "rain/artifacts/playtest/12-playtests-simulation.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rain/artifacts/playtest/12-playtests-simulation.playtest.json/console.json", + "bytes": 3259, + "sha256": "7f4dd7d088c38f441f7bcc079d7d6e1755856c818dc6b0fdbeadf7449552d60c" + }, + { + "path": "rain/artifacts/playtest/13-playtests-storm.playtest.json/after.png", + "bytes": 1453124, + "sha256": "f07216fc6683f454ef8c9b07b30927b5790c6097d88adf5166b67e4933aaa30b" + }, + { + "path": "rain/artifacts/playtest/13-playtests-storm.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/13-playtests-storm.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/13-playtests-storm.playtest.json/console.json", + "bytes": 3259, + "sha256": "7b7944e053da25d00828a508993e87614ff0925d824975bb9af45851abbea4d1" + }, + { + "path": "rain/artifacts/playtest/14-playtests-survives.playtest.json/after.png", + "bytes": 1010139, + "sha256": "4437a1445382d49c6719844bc104de3fe38c29382ef942f44e03e33b1c67d689" + }, + { + "path": "rain/artifacts/playtest/14-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rain/artifacts/playtest/14-playtests-survives.playtest.json/console.json", + "bytes": 3259, + "sha256": "a9e9fed9fa3671e0d4d3c1b8c975ee5e03752d2d08340bcb1b81192b1a9041fb" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/after.png", + "bytes": 1365905, + "sha256": "bb9e8d21f95ab0c244fd242d370b386bf1a4ed5c1c30487ee74e9ea612ace941" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/before.png", + "bytes": 1376047, + "sha256": "3c3814c5b6d4f73d4f1993b0e2118f0c185116a0909e057c14fcc947e1e900e1" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/capture.json", + "bytes": 1070, + "sha256": "e1819e23d4480adb81afef610be26b320906e1122d8ef29c9b37073f2a6ad5d2" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/console.json", + "bytes": 3259, + "sha256": "6d0d15c9832612fb6d8d4b426b1b1f63c614513322dedfc33291b9125167805b" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/tier-balanced.png", + "bytes": 1389597, + "sha256": "07c2fac42f096505e922ae69021cf7e3d808bd165c0c556356fc3290f615327f" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/tier-high.png", + "bytes": 1375520, + "sha256": "9854370451ba34db5efb39bb7d611b7f86a30a58d5ac78866b31566667f64ca3" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/tier-low.png", + "bytes": 1361037, + "sha256": "d546349be35919957540b2cf362338b0bb3a681b2b6e3cd2b0ac356c6c67d18b" + }, + { + "path": "rain/artifacts/playtest/15-playtests-tiers.playtest.json/tier-ultra.png", + "bytes": 1365905, + "sha256": "bb9e8d21f95ab0c244fd242d370b386bf1a4ed5c1c30487ee74e9ea612ace941" + }, + { + "path": "rain/artifacts/playtest/16-playtests-weather.playtest.json/after.png", + "bytes": 1072755, + "sha256": "81a569fca386485a0731b5721602f2b95774a2c1a3c9852ab0cfa2b8bfc5d063" + }, + { + "path": "rain/artifacts/playtest/16-playtests-weather.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rain/artifacts/playtest/16-playtests-weather.playtest.json/console.json", + "bytes": 3259, + "sha256": "dc5aa32f9e1a1f48cf164912b4593cad2f1744b2b74ff2eaa6e9b791208d2a19" + }, + { + "path": "rain/artifacts/playtest/after.png", + "bytes": 274946, + "sha256": "991d37785ffe5322b98dccce6111c8e813c8423c1b6fae272ff6f593113ceb40" + }, + { + "path": "rain/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "rain/artifacts/playtest/console.json", + "bytes": 3650, + "sha256": "95de854ed96ccb5c1273c6667c14eb94f6d54881c62b64330cd8afe57aad3ca7" + }, + { + "path": "rts/artifacts/playtest/01-playtests-performance.playtest.json/after.png", + "bytes": 1897870, + "sha256": "625ef2d86f7f9008a370b96fc903e1e4bdd67a4cc05ac87181864594eb9535e1" + }, + { + "path": "rts/artifacts/playtest/01-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "rts/artifacts/playtest/01-playtests-performance.playtest.json/console.json", + "bytes": 7696, + "sha256": "2228cfa593a89b9905f9f30bfcb1e04b19086ff69b9c63367cf382fecc54bc36" + }, + { + "path": "rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/after.png", + "bytes": 1026432, + "sha256": "c8e70260e4dfb420f90a36c637a0812ae9b66abfd0afb0227d3b28ef99ec6be3" + }, + { + "path": "rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/console.json", + "bytes": 4771, + "sha256": "bac5dbe0fe791d7ce6fb22100dd47b62c9c91ac9273a603bf1457b4d40be4372" + }, + { + "path": "rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/after.png", + "bytes": 1038514, + "sha256": "70554de0e1563c1237985e14bef6498cf7e84a41caf7a40581cd78cba2be0dd7" + }, + { + "path": "rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/console.json", + "bytes": 4771, + "sha256": "ab7c06f2472668eab33d2bcea53625d01d0dc21bae4a3410fa8177638ccbe406" + }, + { + "path": "rts/artifacts/playtest/04-playtests-rts-build.playtest.json/after.png", + "bytes": 1025007, + "sha256": "e0d037d3b4cc5fd4e4834426b02d48a6775f0a07aa495da3c6d7e25cafc7fc43" + }, + { + "path": "rts/artifacts/playtest/04-playtests-rts-build.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/04-playtests-rts-build.playtest.json/console.json", + "bytes": 4771, + "sha256": "9e6ea8d2f9d081d95deb6baeb8d36f48446edc0ec5f018fd5ab6f05a01b11975" + }, + { + "path": "rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/after.png", + "bytes": 1045086, + "sha256": "94f74db5208ab9d370f55c7ff2cad4f267336babf1fa046e24b2eddb70fdfd79" + }, + { + "path": "rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/console.json", + "bytes": 4771, + "sha256": "b9fd70b2794ac2756a2891230fdf058d4bf26c28a0c6ff6aac2df27a0edc271b" + }, + { + "path": "rts/artifacts/playtest/06-playtests-rts-train.playtest.json/after.png", + "bytes": 1023451, + "sha256": "511ec7027513e4a413b7d58d20b5e41f3dd9295dcdc4eb91402ca812cd8cd1c9" + }, + { + "path": "rts/artifacts/playtest/06-playtests-rts-train.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/06-playtests-rts-train.playtest.json/console.json", + "bytes": 4771, + "sha256": "b9fd70b2794ac2756a2891230fdf058d4bf26c28a0c6ff6aac2df27a0edc271b" + }, + { + "path": "rts/artifacts/playtest/07-playtests-survives.playtest.json/after.png", + "bytes": 998877, + "sha256": "0abe62f3ca82ac87b147ac69b4d2bfc75285005985008a14f2a8cf7b4a52f60f" + }, + { + "path": "rts/artifacts/playtest/07-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "rts/artifacts/playtest/07-playtests-survives.playtest.json/console.json", + "bytes": 4771, + "sha256": "eb5103e08f06f6a74c403d2a9117bf98a56eb8a3a0f9133b877bc23f527e136d" + }, + { + "path": "rts/artifacts/playtest/after.png", + "bytes": 253933, + "sha256": "8a8154092940d2b1815dea3e15f00050a7f931cef848aff77af782e94aa7fc08" + }, + { + "path": "rts/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "rts/artifacts/playtest/console.json", + "bytes": 5162, + "sha256": "20432c16d480d66cbc57329c921050c8862ca7f26c8c0fc727403a71a3f1749e" + }, + { + "path": "runner/artifacts/playtest/01-playtests-performance.playtest.json/after.png", + "bytes": 1507613, + "sha256": "e7f4e6e18e886145ee09a301d98c9d707170e37dd9007d2e06e73bff8f1db45d" + }, + { + "path": "runner/artifacts/playtest/01-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "runner/artifacts/playtest/01-playtests-performance.playtest.json/console.json", + "bytes": 38580, + "sha256": "ce753a4b953604183770a5641a2f77dc10a4b142a147f8ba6aba481ec26c719d" + }, + { + "path": "runner/artifacts/playtest/02-playtests-streams.playtest.json/after.png", + "bytes": 750457, + "sha256": "37c994fe31c62dad605b5b2afe47e3c61906b03b10c2d121f1c97e3054ecac13" + }, + { + "path": "runner/artifacts/playtest/02-playtests-streams.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "runner/artifacts/playtest/02-playtests-streams.playtest.json/console.json", + "bytes": 5571, + "sha256": "bf15d3238e83f429fa05c9153369eb5c0cec87c14f1ddf10245f6f7ee99e8b35" + }, + { + "path": "runner/artifacts/playtest/03-playtests-survives.playtest.json/after.png", + "bytes": 741444, + "sha256": "f1ef38b07fc12e577b5d15361b924746ec6316d1374520630a3b465efd882bed" + }, + { + "path": "runner/artifacts/playtest/03-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "runner/artifacts/playtest/03-playtests-survives.playtest.json/console.json", + "bytes": 5571, + "sha256": "d87ffe0a2d2e151b5fe989a2b0184df1a5e66e1bf5c3308d8b928f3297b8bcf6" + }, + { + "path": "runner/artifacts/playtest/after.png", + "bytes": 223321, + "sha256": "8afe2415dd60903b30f249b7808758efc449eedb5f7bb89b19bd41bb08ebc7c6" + }, + { + "path": "runner/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "runner/artifacts/playtest/console.json", + "bytes": 5961, + "sha256": "0506de6f53f60cea3fb047fa78a91c7ce60458f306178016d93c6a10473480e2" + }, + { + "path": "sailing/artifacts/playtest/01-playtests-fail.playtest.json/after.png", + "bytes": 435913, + "sha256": "de4c8d6c8f896aa82d8f2d00e8032d6a2bfc49bf801a2f5b0324ef65a3e5e978" + }, + { + "path": "sailing/artifacts/playtest/01-playtests-fail.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/01-playtests-fail.playtest.json/console.json", + "bytes": 5471, + "sha256": "2d01eb75a19e09985e306312897c6fa35e5ed6aaad2226c5f3c680f60105ae39" + }, + { + "path": "sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/after.png", + "bytes": 452275, + "sha256": "fd6dd94c6209266b23a5de95be89ab2a068ecef0a9a287030c43c8c8573f4d39" + }, + { + "path": "sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/console.json", + "bytes": 5763, + "sha256": "c80bee58c1b300b02c81d044bb3876af6f225cc0d8ec2d4f32dada5eca70a88d" + }, + { + "path": "sailing/artifacts/playtest/03-playtests-float.playtest.json/after.png", + "bytes": 446477, + "sha256": "3b6bae7193e9aa00211d3d676e720281532024e268deb31d894c8b3961529c79" + }, + { + "path": "sailing/artifacts/playtest/03-playtests-float.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/03-playtests-float.playtest.json/console.json", + "bytes": 5471, + "sha256": "8012a73483b25ca0ad79d2e56a1b9b249832a46c91cc91958a686197ad2aca97" + }, + { + "path": "sailing/artifacts/playtest/04-playtests-performance.playtest.json/after.png", + "bytes": 870618, + "sha256": "c271197ded0b85a264575d9d32fc93c2a8a9a441f0f76aeb231a2eb92c0757dc" + }, + { + "path": "sailing/artifacts/playtest/04-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "sailing/artifacts/playtest/04-playtests-performance.playtest.json/console.json", + "bytes": 36998, + "sha256": "dcf173901d4419a9372026ebe42ed55055ceaa63ea6131c3087fc18be04d8fc2" + }, + { + "path": "sailing/artifacts/playtest/05-playtests-sails.playtest.json/after.png", + "bytes": 471273, + "sha256": "3b26ad0043f9013a1b45eb3f1c5fa2083a65fe66a09b3de4899cd428a78468f9" + }, + { + "path": "sailing/artifacts/playtest/05-playtests-sails.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/05-playtests-sails.playtest.json/console.json", + "bytes": 5763, + "sha256": "9575097f51be516374631885a7e672f9329d4cae5936e514dd9980a3202798c3" + }, + { + "path": "sailing/artifacts/playtest/06-playtests-survives.playtest.json/after.png", + "bytes": 472272, + "sha256": "a0ffc47b685f0444702c6fa55607e8abc4099268d7d9c2358e00f6f27cc1b006" + }, + { + "path": "sailing/artifacts/playtest/06-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/06-playtests-survives.playtest.json/console.json", + "bytes": 5471, + "sha256": "f884141fd00efcca6519c2b45e5761af6d6eb16446b51192f49229beaadb1914" + }, + { + "path": "sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/after.png", + "bytes": 497491, + "sha256": "74d031ee78fedd8b732652393eb3738ecdde38259ce7dece61577a4ffee9c3b1" + }, + { + "path": "sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json", + "bytes": 5295, + "sha256": "016b96b70681deb3ddfc7c13268b4962ebe7a480db4f7fa3585832dceb859537" + }, + { + "path": "sailing/artifacts/playtest/08-playtests-win.playtest.json/after.png", + "bytes": 503783, + "sha256": "113c6a7649e4123129937c0a8a07d59e993ae5b764de78718872d6eb2462237a" + }, + { + "path": "sailing/artifacts/playtest/08-playtests-win.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "sailing/artifacts/playtest/08-playtests-win.playtest.json/console.json", + "bytes": 5471, + "sha256": "c8a6de9e57cdb363475e049c0f7a8c1070b61b3a47724ad54079b9b64dab655d" + }, + { + "path": "sailing/artifacts/playtest/after.png", + "bytes": 146799, + "sha256": "3d8fc5f122afde7b4dd11a0db5b667a87a3e1d4bc5920ee44b46666ab68ebe06" + }, + { + "path": "sailing/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "sailing/artifacts/playtest/console.json", + "bytes": 5862, + "sha256": "db7124ff9b99c9333f5413ce287ec2cf0e03963e72be9e2b7196eb69eab1b7ad" + }, + { + "path": "shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/after.png", + "bytes": 853894, + "sha256": "9ee9d740325b1fb4a643836d419e9fb49ad5712b2ea3932ebe18d9c5f7203b63" + }, + { + "path": "shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/console.json", + "bytes": 9780, + "sha256": "c2242b8d840341b25339ba90848476b675642dd1e8914dc3ee795e295e1845e0" + }, + { + "path": "shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/after.png", + "bytes": 844654, + "sha256": "b4a1212a147de8d2b31e7341f5bfa19164202dacc2dd28b251563062769e3897" + }, + { + "path": "shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/console.json", + "bytes": 9780, + "sha256": "7e17647230181502fd911c0338ea4d0abf5877f7f4a9ad3a50b68203990fd05e" + }, + { + "path": "shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/after.png", + "bytes": 843027, + "sha256": "19a0be1009db599b1b898085dfc66b6cb7793ff8d284c23fedac59a793900878" + }, + { + "path": "shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/console.json", + "bytes": 9779, + "sha256": "ec1451a00d796a10a0d64e5cb91fd2b6eea3e34d29a8f44a1731f475c6b6c0e2" + }, + { + "path": "shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/after.png", + "bytes": 764087, + "sha256": "f0b880358016e443cf489c583364389f8f7795bbcdf378cf0bf0dc04334136e9" + }, + { + "path": "shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/console.json", + "bytes": 9779, + "sha256": "5dfd32da188facb533c74106119ac40810cbd8d7cf62663c5c3e78919f9357e7" + }, + { + "path": "shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/after.png", + "bytes": 828060, + "sha256": "b1f9165f1352386e8b8a7926f86270c288712a74ef437ae6a6e05b0d602d4771" + }, + { + "path": "shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/console.json", + "bytes": 9780, + "sha256": "5dd16edfd1fc0e645e59b226862e27e48d382ec1f2b74279b62073947524e578" + }, + { + "path": "shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/after.png", + "bytes": 786700, + "sha256": "56f77af990256a9eaafd14c327f783c32fde9117594b9c6947320aa41537cb49" + }, + { + "path": "shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/console.json", + "bytes": 12730, + "sha256": "18fa2d88957b25fc0964fadcdacd3631be24bdecd75828750f9ed050eb3ddb2e" + }, + { + "path": "shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/after.png", + "bytes": 784728, + "sha256": "9cefd233556ff7d71db85cf540e6a87a4f5aab0b73facb1107674e1933fa2827" + }, + { + "path": "shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/console.json", + "bytes": 9780, + "sha256": "52617bc1aa49f504a8f0cc4aefcf57aec757ab0de6dac45f61e76c434194822e" + }, + { + "path": "shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/after.png", + "bytes": 562923, + "sha256": "92485313a6adf7961a7ffd3c7b98db7795b8e44b9120868d24d653fdf50743c6" + }, + { + "path": "shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/console.json", + "bytes": 12749, + "sha256": "2999bc5f02312e396f387b4d578f7c8190f4e3e21c152eefd99ce12bb68413e6" + }, + { + "path": "shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/after.png", + "bytes": 812587, + "sha256": "1d6b2fe238006d73f8278a68609b4fc45a208d9f4e349168fa2a74f1615924a5" + }, + { + "path": "shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/console.json", + "bytes": 12753, + "sha256": "ac4cd2d2f260118aff084e42a866513d731af7242c427329311e3803bc76a0e5" + }, + { + "path": "shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/after.png", + "bytes": 825330, + "sha256": "21fbf2af7979f304b103d7f6cf415480903315e370c204f6099f55e509c87e26" + }, + { + "path": "shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/console.json", + "bytes": 9778, + "sha256": "7478310453a0b2f24875987f1eadf20cb9f7acb8fdc1244c820170d6e2bdcf4b" + }, + { + "path": "shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/after.png", + "bytes": 855747, + "sha256": "b2dc06fee5776421e7d7066b717f6d4b1aa91f99eb7b6b7f22b5569ff92294c3" + }, + { + "path": "shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/console.json", + "bytes": 18099, + "sha256": "e090d1ca8393a5fa48ecab9de4b2fd53324d1079f12e788ea48f04d271c474b7" + }, + { + "path": "shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/after.png", + "bytes": 824265, + "sha256": "290ff77f584b024b0821389d1a82b144c363c401b20ef7faba8389a7f93a2efe" + }, + { + "path": "shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/console.json", + "bytes": 12754, + "sha256": "8b1beeaad9a9a7a3dedec5da43cc5e1a44108691d348a9fb6289fbf61929e825" + }, + { + "path": "shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/running.png", + "bytes": 824265, + "sha256": "290ff77f584b024b0821389d1a82b144c363c401b20ef7faba8389a7f93a2efe" + }, + { + "path": "shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/standing.png", + "bytes": 837983, + "sha256": "e47728d01e7ead9c8eb0198bcc4f519f853356eed442e54130d9216bbfed50d3" + }, + { + "path": "shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/after.png", + "bytes": 788826, + "sha256": "ee930f7da6ed3d68404fe9d0368ee847a4a899b90b9290b06f2afd5c2970ae19" + }, + { + "path": "shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/console.json", + "bytes": 12727, + "sha256": "89d63d601299e3a53a312401a3484eb2c841b2ab9fd807adb57ed1b3ee96d0eb" + }, + { + "path": "shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/after.png", + "bytes": 862420, + "sha256": "2aa04157151abce7774e0700b937a7e77198b3cab004dc8746ac7dab75bb1f1b" + }, + { + "path": "shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/console.json", + "bytes": 12735, + "sha256": "bfb33a44f9d126156af153cf8b61374fab1ab02c95e29b5cdfcac7fd263cde3b" + }, + { + "path": "shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/after.png", + "bytes": 823367, + "sha256": "ded7bb917eebeffbd3a379ed0430cda669d8844c703622f603e6b962674d554d" + }, + { + "path": "shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/console.json", + "bytes": 9780, + "sha256": "1a21cf6a31359a0ee10a4f4816f84be658354b9c4cd24ae42da822cb8bd7df7b" + }, + { + "path": "shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/after.png", + "bytes": 801641, + "sha256": "7f3abcc86ef213b0001b468c353ac068ae774cdc8ca00022e6583212c108014e" + }, + { + "path": "shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/console.json", + "bytes": 12730, + "sha256": "648cdd1484b28916e0d38ff6584cc05415ffb1bf12eaff2c334ed7fc0c56d401" + }, + { + "path": "shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/after.png", + "bytes": 549097, + "sha256": "d6e8ef2c8f54fa6750cea286b663975607dee78e992b88930fe106daa781f311" + }, + { + "path": "shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/console.json", + "bytes": 12721, + "sha256": "a3feeb54b29273ccd533869c3b17ad7363304f22b6c3abd456c8470d66c76c5d" + }, + { + "path": "shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/after.png", + "bytes": 864443, + "sha256": "4dd7f59d99b0dbcb3560f5614fac65619c4b68a7e0d3ed6811ed4e445f2eb134" + }, + { + "path": "shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/console.json", + "bytes": 12727, + "sha256": "3da768fdee4e405f20cc7b5163152cc7eed33b3bcc59cd0add473075a2945173" + }, + { + "path": "shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/after.png", + "bytes": 823631, + "sha256": "b581af74b8958f0235c4d7b376e040bea9f89210e74e0da5a0c7cd1694f97fb7" + }, + { + "path": "shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/console.json", + "bytes": 9780, + "sha256": "f4e8ea7cd0332bcea736fade5a0db56b236cbf7e5f88feed1237641d27a18688" + }, + { + "path": "shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/after.png", + "bytes": 820416, + "sha256": "a1160bb5b9313de0e01ecccca2265f1b7f4dcce49e622aeaa28ab34c44c63f93" + }, + { + "path": "shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/console.json", + "bytes": 50512, + "sha256": "4cad0be09a354bc0a47637cac50da3c4e5198625d87daaf0b4c71bf2ae2f39f6" + }, + { + "path": "shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/after.png", + "bytes": 819442, + "sha256": "1c3525b22ec398371e196ccd979a3dcc356e9b0b7b6cc876a7e320d5966b66d6" + }, + { + "path": "shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/console.json", + "bytes": 24386, + "sha256": "ea6203cdcf56d40a7b3fbe84fc9f7e9ed28d7caa005e1da11824677b5735cd95" + }, + { + "path": "shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/after.png", + "bytes": 839958, + "sha256": "6edf31a31c985324f9d9ce240033247c820fe3b1e057f9279fa402cf6d3b0c0a" + }, + { + "path": "shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/console.json", + "bytes": 15666, + "sha256": "117a3c001a2fe19e6eca0112e0d5e5c2365800639d9f6ffdf41c0565431b7668" + }, + { + "path": "shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/after.png", + "bytes": 838620, + "sha256": "f5a732c51d4ce0c826b0ee4aad348a081e14c8183c2aadfe0a3eacaef449c48a" + }, + { + "path": "shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/console.json", + "bytes": 15661, + "sha256": "e58e83a8df54dd763ce6e7a1fd4b855427bb894ea6033ae6be61c981b9db3fab" + }, + { + "path": "shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/after.png", + "bytes": 823104, + "sha256": "43996b7d850988eaa8c24e462b8afa47c1ec28930aa8cf2882d8c219b604fc1d" + }, + { + "path": "shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/console.json", + "bytes": 9780, + "sha256": "3fe0af7d41c514503ada4b6b0287fc829aca3f54958fd627a4df50766b1d7191" + }, + { + "path": "shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/after.png", + "bytes": 850996, + "sha256": "177aad544b71580c40b2d62dda5155b04f17c6bc38de2f1266c367fc3f4f9b52" + }, + { + "path": "shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/console.json", + "bytes": 9780, + "sha256": "ef5dd4da3d1232d4b1edb5ac0097a15e157b78384341cc86e8a2fd6fc6ed571d" + }, + { + "path": "shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/after.png", + "bytes": 983993, + "sha256": "2351456acc18872c310edc6ac2bafe22ee96be3127526d0cdbe3e1c287b0a66f" + }, + { + "path": "shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/console.json", + "bytes": 15654, + "sha256": "cade738b504d8fb090172fa3e5fc947860cb0dbb7482c4145bbc0081c0920864" + }, + { + "path": "shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/after.png", + "bytes": 853550, + "sha256": "bfead1fa827ff07e426edb2be32f868ecea8f4f27a86935063b0b5fc3e33086d" + }, + { + "path": "shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/console.json", + "bytes": 9780, + "sha256": "379dac082eeeb80082c645064db4a5fb0eb76fbb8bfe96abddc71d40f8323857" + }, + { + "path": "shooter/artifacts/playtest/28-playtests-input-control.playtest.json/after.png", + "bytes": 750332, + "sha256": "c2d8e7fc06d11f846265d9763630967b203c321f44affeb3cd7409162d9ced6b" + }, + { + "path": "shooter/artifacts/playtest/28-playtests-input-control.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/28-playtests-input-control.playtest.json/console.json", + "bytes": 18100, + "sha256": "1af20809dd610dc781e69902cfcca20758a475e22470cdc62d30f703a9dda0e6" + }, + { + "path": "shooter/artifacts/playtest/28-playtests-input-control.playtest.json/input-look-right.png", + "bytes": 750332, + "sha256": "c2d8e7fc06d11f846265d9763630967b203c321f44affeb3cd7409162d9ced6b" + }, + { + "path": "shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/after.png", + "bytes": 843748, + "sha256": "2299eae08a9e2c5b5037ffce3d9583637916e0c57ac26bfb442bf8edd79efe6d" + }, + { + "path": "shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/console.json", + "bytes": 9779, + "sha256": "22753d2fe049ca31899104fb95043e8612932ca3d6ec9e874f5c9fdf40199d4c" + }, + { + "path": "shooter/artifacts/playtest/30-playtests-performance.playtest.json/after.png", + "bytes": 1601570, + "sha256": "b3949f24bb044e580e8e2cb8b52f50eccb741fbe840eb7ac79cfc3650af59d01" + }, + { + "path": "shooter/artifacts/playtest/30-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "shooter/artifacts/playtest/30-playtests-performance.playtest.json/console.json", + "bytes": 96050, + "sha256": "e1aab9dacc80ba3609d592dd2d3ac855a091fb5190b639b4f0651fee051f3b3d" + }, + { + "path": "shooter/artifacts/playtest/30-playtests-performance.playtest.json/performance.png", + "bytes": 1601570, + "sha256": "b3949f24bb044e580e8e2cb8b52f50eccb741fbe840eb7ac79cfc3650af59d01" + }, + { + "path": "shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/after.png", + "bytes": 823073, + "sha256": "04a1f0819b1d0a919954f048181a2fea971b27805414bf261b17c13f86c8fd8f" + }, + { + "path": "shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/console.json", + "bytes": 9779, + "sha256": "8468818e8c6d7e32a781dbb8dd55cd7a2ca7494bf7cdec589246eec84a64e528" + }, + { + "path": "shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/after.png", + "bytes": 792577, + "sha256": "eee80e7595e8b20cea9da4e228f07dd6d6aa9022ba37805caf681c7982b01d51" + }, + { + "path": "shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/console.json", + "bytes": 9779, + "sha256": "8e47d6df740390d3261c998956e122e802d2f1b2d1693c059671b3d2f2f4b4e9" + }, + { + "path": "shooter/artifacts/playtest/33-playtests-survives.playtest.json/after.png", + "bytes": 841026, + "sha256": "cf6db7a17982be13539341d9c1c3c3be4e5accd17f4c1d52f5f9cb311c0fd1a6" + }, + { + "path": "shooter/artifacts/playtest/33-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/33-playtests-survives.playtest.json/console.json", + "bytes": 9778, + "sha256": "c25ad7621beeae4f41c2f0498096b48ebed7e2e9df9059c0926893e8c6710262" + }, + { + "path": "shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/after.png", + "bytes": 818025, + "sha256": "7a297d0513a96c77e830a06a7d431998c0f6a0321ed481ffde7833ce12d12380" + }, + { + "path": "shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/console.json", + "bytes": 9778, + "sha256": "2f89e8014f875af6d361875d8baf85b4a0dfd7d76bf8fdedf17aef261c1f3441" + }, + { + "path": "shooter/artifacts/playtest/after.png", + "bytes": 253955, + "sha256": "7d919d620fa0875e6bf73a71f168f9ec7d14d73b5b472c2a54c958a77eeb99d4" + }, + { + "path": "shooter/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "shooter/artifacts/playtest/console.json", + "bytes": 10169, + "sha256": "2cf670939aa5e3a45dd82a01caee30b56b5cb8ad6e41f4459211826ee4a9102a" + }, + { + "path": "snow/artifacts/playtest/01-playtests-footsteps.playtest.json/after.png", + "bytes": 415056, + "sha256": "917e41ef08d492fef97e812f386b3e0fee1a35d518dfdb93314848c0e4ae5911" + }, + { + "path": "snow/artifacts/playtest/01-playtests-footsteps.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "snow/artifacts/playtest/01-playtests-footsteps.playtest.json/console.json", + "bytes": 4230, + "sha256": "e834f68b0351d6eeb66f0d8b8d2bb50f055d5466293598e63e10e557df5ccf0a" + }, + { + "path": "snow/artifacts/playtest/02-playtests-performance.playtest.json/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow/artifacts/playtest/02-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "snow/artifacts/playtest/02-playtests-performance.playtest.json/console.json", + "bytes": 55339, + "sha256": "833004f898a62e006068c510e55e9ece7da4b468c75ac82d996a798f5e5bffee" + }, + { + "path": "snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/after.png", + "bytes": 423894, + "sha256": "9f330a4c063fcc53ccee053d67e3d3ddf0895a7f169ae4b0c727ead3981452d6" + }, + { + "path": "snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/console.json", + "bytes": 4230, + "sha256": "bad4b2467347fa8349dc0527c33ec736b0fbe65501d7bbc8ce6bc638d92d6cee" + }, + { + "path": "snow/artifacts/playtest/04-playtests-survives.playtest.json/after.png", + "bytes": 413728, + "sha256": "53c2894f921dc851f9534e2f8edc236754676a5fceb2f47d81e3342ee3e8ed9f" + }, + { + "path": "snow/artifacts/playtest/04-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "snow/artifacts/playtest/04-playtests-survives.playtest.json/console.json", + "bytes": 4230, + "sha256": "0cb664cd09df58532a0313a4183091dbd62897800554b891a8ce0488622bedfb" + }, + { + "path": "snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/after.png", + "bytes": 218335, + "sha256": "912251024d454e37010d4b5067c8f39c1117b241a9864b1693854faa8fe59679" + }, + { + "path": "snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1069, + "sha256": "d47a8af813e4e8d3637a0d8c4b94643968609e4b5af45fcc5d3c77185461ad41" + }, + { + "path": "snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/console.json", + "bytes": 4232, + "sha256": "4a743f438dc62aecaafa6e751093f2038822e0350a6156d0d27b38747a26e3d6" + }, + { + "path": "snow/artifacts/playtest/06-playtests-weather.playtest.json/after.png", + "bytes": 509060, + "sha256": "64e66b17d993ac8142ba70a48d421c1bf264c9801d5248aec75ca9119190d126" + }, + { + "path": "snow/artifacts/playtest/06-playtests-weather.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "snow/artifacts/playtest/06-playtests-weather.playtest.json/console.json", + "bytes": 4230, + "sha256": "d3444d25ec60fe08081715cee21b72d0ab7c6371362be4838599cafd30d82d0e" + }, + { + "path": "snow/artifacts/playtest/after.png", + "bytes": 156060, + "sha256": "65d7e9084202bda8b6f9cc65e602ff12d0b7870b6f3ed8aa3d9f17287972c025" + }, + { + "path": "snow/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "snow/artifacts/playtest/console.json", + "bytes": 4620, + "sha256": "3cf083030e1a6ba1d37ca8aae016c230413d5cb44c8a4c3fa5f74d67b73d8a77" + }, + { + "path": "starter/artifacts/playtest/01-playtests-assets.playtest.json/after.png", + "bytes": 937499, + "sha256": "8380dc1e1bd6579531ec975ee763cb1bb2ff106dd534179b2cc3ae9b694eed36" + }, + { + "path": "starter/artifacts/playtest/01-playtests-assets.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/01-playtests-assets.playtest.json/console.json", + "bytes": 4991, + "sha256": "ae9243d9c2bf9a685dd6c252b480ada3b8b02550e0e7355db820e56779859a94" + }, + { + "path": "starter/artifacts/playtest/02-playtests-boot-failure.playtest.json/after.png", + "bytes": 12839, + "sha256": "30164918c7867c1e62bef48f5dda2592a7e08d20d98b1e79a35c0af443d44e78" + }, + { + "path": "starter/artifacts/playtest/02-playtests-boot-failure.playtest.json/console.json", + "bytes": 308, + "sha256": "c0223ae408a253fee3a74bf6cefb3c6e80e175158eb51eeff5733f62cc5b87eb" + }, + { + "path": "starter/artifacts/playtest/03-playtests-buffer.playtest.json/after.png", + "bytes": 936213, + "sha256": "36e67578b533982e62943808c2a8d06238268ffbfd5642de7774f919cba0ed08" + }, + { + "path": "starter/artifacts/playtest/03-playtests-buffer.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/03-playtests-buffer.playtest.json/console.json", + "bytes": 4991, + "sha256": "62512d62eb79160763dcbc8985f84b34b3c7b5719388293edcfe7f02defc0006" + }, + { + "path": "starter/artifacts/playtest/04-playtests-cloth.playtest.json/after.png", + "bytes": 964595, + "sha256": "03e7271bb46afb67545120327d6d88979eb69a1747b90460b4d2684ae86d70c0" + }, + { + "path": "starter/artifacts/playtest/04-playtests-cloth.playtest.json/before.png", + "bytes": 964618, + "sha256": "10052014fde71b93b5008c3b601dcbee454abc32703ed7d46ad7a5298be167c5" + }, + { + "path": "starter/artifacts/playtest/04-playtests-cloth.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/04-playtests-cloth.playtest.json/console.json", + "bytes": 4991, + "sha256": "7a0b69c3f3b3a7e1c1e909ca7a89df18e31c0aa9a5b8289d0676d24d5b8ffa11" + }, + { + "path": "starter/artifacts/playtest/05-playtests-coyote.playtest.json/after.png", + "bytes": 941458, + "sha256": "15b7584a6e8b3b9272e553a2051f56b66272a018557deb4a9d9d9e369e468a7b" + }, + { + "path": "starter/artifacts/playtest/05-playtests-coyote.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/05-playtests-coyote.playtest.json/console.json", + "bytes": 4991, + "sha256": "e96525f8cd9b94ac51e1c3ba4f31775dd82eaa97b23ac3cf9fcaef6acf15ed7d" + }, + { + "path": "starter/artifacts/playtest/06-playtests-forward.playtest.json/after.png", + "bytes": 961264, + "sha256": "8575de37da6026c5cfd6fb60a10abadbc155b1ecd61a0dae0dfd36a7b04c5c59" + }, + { + "path": "starter/artifacts/playtest/06-playtests-forward.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/06-playtests-forward.playtest.json/console.json", + "bytes": 4991, + "sha256": "8d76eeafab13f956eb420b1cc080c6a0e74b16259205505ca237307e52c9e874" + }, + { + "path": "starter/artifacts/playtest/07-playtests-gameover.playtest.json/after.png", + "bytes": 969695, + "sha256": "efa4ba804d66a58998142f0306e3645348cc3c491cb1d3f72fa54a193edde0df" + }, + { + "path": "starter/artifacts/playtest/07-playtests-gameover.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/07-playtests-gameover.playtest.json/console.json", + "bytes": 4991, + "sha256": "e8e3d99d180cfc7d47cdd271503c398bb1464150323f338c34c14affb8f74b3d" + }, + { + "path": "starter/artifacts/playtest/08-playtests-goal.playtest.json/after.png", + "bytes": 935475, + "sha256": "420da3ee2b9ec1e516ef1e2095786ce2bdaf976307e9aa8aa255f08c066ea32e" + }, + { + "path": "starter/artifacts/playtest/08-playtests-goal.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/08-playtests-goal.playtest.json/console.json", + "bytes": 4991, + "sha256": "62be1577a809780770ababfa2e19523020309c4b1f866d9118a2bda812abafbc" + }, + { + "path": "starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/after.png", + "bytes": 947617, + "sha256": "cf55c31311f2c9c3dae514b1da60a23839913bc2d5fffedae2978593a28a10ea" + }, + { + "path": "starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/console.json", + "bytes": 4991, + "sha256": "b2d236f999381e568f3cc83ef55db1fc0e55a16b5e36ec8e10713d061fa27311" + }, + { + "path": "starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/network.json", + "bytes": 3, + "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" + }, + { + "path": "starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/runtime-trace.json", + "bytes": 408, + "sha256": "a07fce3fea4e9fcfe26f706a7baf0b0878c3db6d82a7e4fb87e144d58add8aab" + }, + { + "path": "starter/artifacts/playtest/10-playtests-look.playtest.json/after.png", + "bytes": 947637, + "sha256": "ef954107e890189111c811dc176e42661ea72cb197c9cb763a5fd18c2c035671" + }, + { + "path": "starter/artifacts/playtest/10-playtests-look.playtest.json/before.png", + "bytes": 964866, + "sha256": "1b011b3087055d906108110a1a2c1360ac4c19740ed0fbbfc46d973e55fd6a7e" + }, + { + "path": "starter/artifacts/playtest/10-playtests-look.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/10-playtests-look.playtest.json/console.json", + "bytes": 4991, + "sha256": "f58d203ddbd5f9c8bf2b0ed799f8cc5a1334fbf62efb4db0880ed1bbbbb7ae1e" + }, + { + "path": "starter/artifacts/playtest/11-playtests-models.playtest.json/after.png", + "bytes": 935797, + "sha256": "33515c6928895100d0a20a2d51e64c3eb584aeac2accf323eab7bb65a230edfd" + }, + { + "path": "starter/artifacts/playtest/11-playtests-models.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/11-playtests-models.playtest.json/console.json", + "bytes": 4991, + "sha256": "e4c75a447a55b325feed031622bae64f2c8203486a4076fa66c96a350e41e227" + }, + { + "path": "starter/artifacts/playtest/12-playtests-monitoring.playtest.json/after.png", + "bytes": 949728, + "sha256": "82b798ba2e68a20a77d0d96067a1a13b6497ff508fef9cc8ce2f588bac2ddb26" + }, + { + "path": "starter/artifacts/playtest/12-playtests-monitoring.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/12-playtests-monitoring.playtest.json/console.json", + "bytes": 4991, + "sha256": "95bdf450b11007389722435253382592463bb5328ba3f3197e2700066ba04e06" + }, + { + "path": "starter/artifacts/playtest/13-playtests-odometer.playtest.json/after.png", + "bytes": 961249, + "sha256": "f660b9b43b2e1e2755f8f06945e739266817a2570de5553b658aae28130d46fe" + }, + { + "path": "starter/artifacts/playtest/13-playtests-odometer.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/13-playtests-odometer.playtest.json/console.json", + "bytes": 4991, + "sha256": "2013d69d871b4d04c03a7e04beb7a1a404c3b52634093aee145666b9ca6174fb" + }, + { + "path": "starter/artifacts/playtest/14-playtests-pause.playtest.json/after.png", + "bytes": 965469, + "sha256": "57fe762f82bc602c8c7712f5936540896b475e1a7f785fb9009c2022fd90d748" + }, + { + "path": "starter/artifacts/playtest/14-playtests-pause.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/14-playtests-pause.playtest.json/console.json", + "bytes": 4991, + "sha256": "198137622bbc51f47a8ed99d7d2a5a5fb09cc56b0b7996ef49ada4d6fb6e0d80" + }, + { + "path": "starter/artifacts/playtest/15-playtests-play.playtest.json/after.png", + "bytes": 2049394, + "sha256": "d30cb4ccd0de9a5a7fd72da43fc3466a97d69657014dd412c0d54025659dbebd" + }, + { + "path": "starter/artifacts/playtest/15-playtests-play.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "starter/artifacts/playtest/15-playtests-play.playtest.json/console.json", + "bytes": 56166, + "sha256": "2a2610c1b166bfda535817dc789d695a01f64327c6a4c45a315ea9c81302b366" + }, + { + "path": "starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/after.png", + "bytes": 1026403, + "sha256": "22f0220913d10b7ffd8f593ee330116253c0f825210024f870422de844eca0ad" + }, + { + "path": "starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/console.json", + "bytes": 9128, + "sha256": "96cc93af20566ac65934f2b4ab98e6807fbf06e2f0e79696911a379d475c339d" + }, + { + "path": "starter/artifacts/playtest/17-playtests-respawn.playtest.json/after.png", + "bytes": 963985, + "sha256": "4c6ef2c66eda3da1a912e67f360a6927da6e6361c900d6ce44bd7838100eba4b" + }, + { + "path": "starter/artifacts/playtest/17-playtests-respawn.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/17-playtests-respawn.playtest.json/console.json", + "bytes": 4991, + "sha256": "0deb2f94b017a149861dd6e919071c749e52b0c848baaec219825e8606ae8fe1" + }, + { + "path": "starter/artifacts/playtest/18-playtests-restart.playtest.json/after.png", + "bytes": 965883, + "sha256": "83782b57eb808ae0fac0dac76e5961a3975c31960afb78a9c024f7689bfd169c" + }, + { + "path": "starter/artifacts/playtest/18-playtests-restart.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/18-playtests-restart.playtest.json/console.json", + "bytes": 9128, + "sha256": "4a4c7688a64e26ded490a34f740c0807c21069c305d68b132ef9f2a2520b8669" + }, + { + "path": "starter/artifacts/playtest/19-playtests-seed.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/19-playtests-seed.playtest.json/console.json", + "bytes": 4991, + "sha256": "72deb01e76961eeb8700a7dcb390d59239ebeddacadf6f7a531949a50543044b" + }, + { + "path": "starter/artifacts/playtest/19-playtests-seed.playtest.json/runtime-trace.json", + "bytes": 407, + "sha256": "04f59e50b0520e6b4ebd20ff3be9ac36b2c1adeafd2b362cfd3abc2c8876dd1d" + }, + { + "path": "starter/artifacts/playtest/20-playtests-survives.playtest.json/after.png", + "bytes": 962221, + "sha256": "7386635603458a4d38d2aa0318ce640248ee40b279c99a1d5b62b75696747c76" + }, + { + "path": "starter/artifacts/playtest/20-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/20-playtests-survives.playtest.json/console.json", + "bytes": 4991, + "sha256": "d5f1a3ec467ccd2d785a0671500496a14336e0b5cb8e5bff979567bbd1232069" + }, + { + "path": "starter/artifacts/playtest/21-playtests-textures.playtest.json/after.png", + "bytes": 936622, + "sha256": "eadf3aa80ffa75479903ae9aadb29170bc701eba4afe9c4041b2ee27d64776b6" + }, + { + "path": "starter/artifacts/playtest/21-playtests-textures.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/21-playtests-textures.playtest.json/console.json", + "bytes": 4991, + "sha256": "e341d3e9999503c907b307241b46b8723628d4bbee806a76a453f197126fb06a" + }, + { + "path": "starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/after.png", + "bytes": 930255, + "sha256": "cebc6f313ea2cbecd6959e6142ad8594f2e2e3846ce7d34065308b02bfa3e18b" + }, + { + "path": "starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/console.json", + "bytes": 4755, + "sha256": "c9c7e0945cd150cf36afc6955a547547b7eec57039abb527adbb588976903e50" + }, + { + "path": "starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/after.png", + "bytes": 966275, + "sha256": "1ff9fd526a1587cf0698e0e0ed617726d2d4d94e7c763dc647d568e37c9ef331" + }, + { + "path": "starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/before.png", + "bytes": 965458, + "sha256": "41dc487e688211ec9593bae9ec6bac5d0bd91d7e9305b8ccad61eb0f6dff118c" + }, + { + "path": "starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/console.json", + "bytes": 4991, + "sha256": "f58d203ddbd5f9c8bf2b0ed799f8cc5a1334fbf62efb4db0880ed1bbbbb7ae1e" + }, + { + "path": "starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/after.png", + "bytes": 967002, + "sha256": "a0344e48d85ea501944cd3159aa8ba5eb45d326c75629a1f14ac0c07ab76d093" + }, + { + "path": "starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/before.png", + "bytes": 964734, + "sha256": "58cc3c9c622dac832cf2f436bc59c762d6a7fb81d4186daf86030d143a59d435" + }, + { + "path": "starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/console.json", + "bytes": 4991, + "sha256": "51e185e3a786bffd0fc6aea2869dc735ef930bfb48f1dec5598474bd4f395b38" + }, + { + "path": "starter/artifacts/playtest/after.png", + "bytes": 244310, + "sha256": "8397a4c93aebb4e6ba030bdf74c6af9d3330e06f0fdd2b25e6753c3bc89a3e13" + }, + { + "path": "starter/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "starter/artifacts/playtest/console.json", + "bytes": 5381, + "sha256": "1b6eda580d0b4dc987449021481eccf723dd88ce22332242e98a4da750e4684f" + }, + { + "path": "tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/after.png", + "bytes": 488262, + "sha256": "f32d5886691a433e2ac436f576ccd8d61469e2247da41153bf5f71389e40cdcc" + }, + { + "path": "tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/console.json", + "bytes": 9237, + "sha256": "25c978c932d26af9dd7eeb73b09ce9c37aa1396a2c1e5bdf1d2c8c6fc2b22019" + }, + { + "path": "tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/after.png", + "bytes": 314004, + "sha256": "f8fdda8ed06010fa3fdf3eb59fc62ac24d5ff27dc60fd0492107e460a39d6325" + }, + { + "path": "tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/console.json", + "bytes": 9781, + "sha256": "e4c3a25ee8af1144ba5e1ce695f5331f2a03f31fec92f6fa3dfaa860af30a550" + }, + { + "path": "tower-defense/artifacts/playtest/03-playtests-look.playtest.json/after.png", + "bytes": 494006, + "sha256": "46ed443f6c6c5a41fcfeb2c2daf66e1f26a19330a49e0e258990a5c468200c7c" + }, + { + "path": "tower-defense/artifacts/playtest/03-playtests-look.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/03-playtests-look.playtest.json/console.json", + "bytes": 9237, + "sha256": "16d5cb813a6256ee54a59b67c8858eb168f0cbd80939dff9a6fe634c71fe6427" + }, + { + "path": "tower-defense/artifacts/playtest/03-playtests-look.playtest.json/fight.png", + "bytes": 494709, + "sha256": "4bada38155584906258f9511b1b51324d0202a248f09c83ea99f0ba2635cea08" + }, + { + "path": "tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/after.png", + "bytes": 873623, + "sha256": "e751006e0df7cb767d318630addc9d318e8720c781c4fd8d964ad703a032ea49" + }, + { + "path": "tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/capture.json", + "bytes": 1132, + "sha256": "0f6a7bee58e6b7afd279a594fe15717f5a5228a723b448f6b01baac0539d2de6" + }, + { + "path": "tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/console.json", + "bytes": 70198, + "sha256": "cd71ceebc4cc121930d46e98cf35ea973ebb6183d2cc3079d30e816e00f732c5" + }, + { + "path": "tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/after.png", + "bytes": 479232, + "sha256": "ecb191281dfa5624ad25190a4de5300f39e7b54ecdf3af3bc4ee42e0e76fd727" + }, + { + "path": "tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/console.json", + "bytes": 8421, + "sha256": "4be94ec663e1349161d6a423a2e1f19e701120eb3e1c7d43632f33913588bc58" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/after.png", + "bytes": 485382, + "sha256": "e7ac33f9d47428edb82b4c7be17cf02576c9dd4da80f5e601d6e7faa566f6a67" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/before.png", + "bytes": 461630, + "sha256": "e03380abb05fb6b9587bedeb0643f35368eb0a8ce005b2921e55bbf302583a99" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/console.json", + "bytes": 8285, + "sha256": "ebeb8fcc17d6c1d16a8455e6a716d28d3fd8b3519d74c07c65ea96209a6ffaee" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/hovered.png", + "bytes": 475514, + "sha256": "1382806f18a15d4b77be66a29d7a9f25ef31f8d5ce70148393b477525a0dbe0e" + }, + { + "path": "tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/touch-held.png", + "bytes": 475004, + "sha256": "43dcd661315dad86f9f695954ea5a1f3238ae12f5a5b1c13732ce01d5d618a5d" + }, + { + "path": "tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/after.png", + "bytes": 500610, + "sha256": "a5c24afbdf48144c32abda757301cb27652f85c704937246a2de66ec172fe6a5" + }, + { + "path": "tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/console.json", + "bytes": 8693, + "sha256": "b64609f17dab67d5208e8b491b44252c7409de6c0b45660521305a943a5ab02f" + }, + { + "path": "tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/strike.png", + "bytes": 471238, + "sha256": "ac1455f31c5f874551f0c19e05a30e39e87fa0388d276e18f4fc91efbc6e382b" + }, + { + "path": "tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/after.png", + "bytes": 422895, + "sha256": "3c8e1e8e18b0f68e3ab01d8411ac3fb85391a233824fea8c28bdb610816c8445" + }, + { + "path": "tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/console.json", + "bytes": 9101, + "sha256": "022dfdc2d01245c29df7545e26cd13ae65acd4b3452a99a59826a5e030412c8d" + }, + { + "path": "tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/after.png", + "bytes": 501497, + "sha256": "49f3020e6ec5c47e832ab9b13f037661d8c6f6ddd60f096f8cdaf5504d822545" + }, + { + "path": "tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/console.json", + "bytes": 9645, + "sha256": "988730a9ed798474ea5fa76eca65e304d44ac93889195be1a31090be54e0d751" + }, + { + "path": "tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/after.png", + "bytes": 465507, + "sha256": "63324fc76f4397984057c66d9566649896d4161f96bd48f0f2e10fcd1edc7b7f" + }, + { + "path": "tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/console.json", + "bytes": 8421, + "sha256": "8c8f712a7d05b74deeb0aabc43aad45c9012271f5a4ac3043d4e060e4bb29ae6" + }, + { + "path": "tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/after.png", + "bytes": 325772, + "sha256": "1e79a4daeb9799e083ff264d802d6a5a754a7f95d902f1470988e4d63fd9078d" + }, + { + "path": "tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/capture.json", + "bytes": 1070, + "sha256": "20d070bfc7a8646eeeb01588f0d41349d49990fe14bb5cf30b06d4816310059c" + }, + { + "path": "tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/console.json", + "bytes": 13164, + "sha256": "c6911181115ff382144c886e286697359f9ff9bd65f33151a2f7bb77a83160cd" + }, + { + "path": "tower-defense/artifacts/playtest/after.png", + "bytes": 146300, + "sha256": "cc07ff4a1033f23537855ce6d711f1c8ef98f1d03218ed482a4e7d10f0d350ec" + }, + { + "path": "tower-defense/artifacts/playtest/capture.json", + "bytes": 1069, + "sha256": "b66a82dbc862a9785c59b385e6aae4c7e3ddde235b2b8680ab72f55c62ec5a7b" + }, + { + "path": "tower-defense/artifacts/playtest/console.json", + "bytes": 8540, + "sha256": "8e99d5f2b88fbd5e22a8fad3aada4c24175bcb3853204e960ea037b0c5161e74" + } +] diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-failure.txt b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-failure.txt new file mode 100644 index 0000000000..e01d2b781e --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-failure.txt @@ -0,0 +1 @@ +Error: TN_TEMPLATE_PLAYTESTS_FAILED: shooter: pnpm test exited 1.; snow: pnpm test exited 1. \ No newline at end of file diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json new file mode 100644 index 0000000000..4249b42d29 --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json @@ -0,0 +1,14 @@ +{ + "create-threenative": "/tmp/p345pk/create-threenative-0.2.8.tgz", + "@threenative/assets": "/tmp/p345pk/threenative-assets-0.3.5.tgz", + "threenative-blender-mcp": "/tmp/p345pk/threenative-blender-mcp-0.1.4.tgz", + "@threenative/core": "/tmp/p345pk/threenative-core-0.3.4.tgz", + "threenative-engine-mcp": "/tmp/p345pk/threenative-engine-mcp-0.2.4.tgz", + "@threenative/metahuman": "/tmp/p345pk/threenative-metahuman-0.1.0.tgz", + "@threenative/physics": "/tmp/p345pk/threenative-physics-0.3.4.tgz", + "@threenative/playtest": "/tmp/p345pk/threenative-playtest-0.3.4.tgz", + "@threenative/raw-unreal": "/tmp/p345pk/threenative-raw-unreal-0.1.4.tgz", + "@threenative/runtime-native": "/tmp/p345pk/threenative-runtime-native-0.3.4.tgz", + "@threenative/ueformat": "/tmp/p345pk/threenative-ueformat-0.1.4.tgz", + "@threenative/ui": "/tmp/p345pk/threenative-ui-0.3.4.tgz" +} diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json.original.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json.original.gz new file mode 100644 index 0000000000..006613aa6a Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-package-sources.json.original.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json new file mode 100644 index 0000000000..9eb93464ed --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json @@ -0,0 +1,73 @@ +{ + "templates": [ + "action-rpg", + "minimal", + "platformer", + "puzzle", + "racing", + "rain", + "rts", + "runner", + "sailing", + "shooter", + "snow", + "starter", + "tower-defense" + ], + "results": [ + { + "pass": true, + "template": "action-rpg" + }, + { + "pass": true, + "template": "minimal" + }, + { + "pass": true, + "template": "platformer" + }, + { + "pass": true, + "template": "puzzle" + }, + { + "pass": true, + "template": "racing" + }, + { + "pass": true, + "template": "rain" + }, + { + "pass": true, + "template": "rts" + }, + { + "pass": true, + "template": "runner" + }, + { + "pass": true, + "template": "sailing" + }, + { + "error": "pnpm test exited 1.", + "pass": false, + "template": "shooter" + }, + { + "error": "pnpm test exited 1.", + "pass": false, + "template": "snow" + }, + { + "pass": true, + "template": "starter" + }, + { + "pass": true, + "template": "tower-defense" + } + ] +} diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json.original.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json.original.gz new file mode 100644 index 0000000000..23bc9f8309 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-results.json.original.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json new file mode 100644 index 0000000000..7567d250bb --- /dev/null +++ b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json @@ -0,0 +1,18 @@ +{ + "templates": [ + "action-rpg", + "minimal", + "platformer", + "puzzle", + "racing", + "rain", + "rts", + "runner", + "sailing", + "shooter", + "snow", + "starter", + "tower-defense" + ], + "started": "2026-10-03T12:04:47.825Z" +} diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json.original.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json.original.gz new file mode 100644 index 0000000000..8a3dcccbd0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay-started.json.original.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay5.log.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay5.log.gz new file mode 100644 index 0000000000..12559fe381 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/gameplay5.log.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-fallback.playtest.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-fallback.playtest.json.gz new file mode 100644 index 0000000000..62827be13e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-fallback.playtest.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-report.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-report.json.gz new file mode 100644 index 0000000000..c5f759c010 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-report.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-snapshots.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-snapshots.json.gz new file mode 100644 index 0000000000..3ec5eed2b2 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after-opening-snapshots.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after.png b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after.png new file mode 100644 index 0000000000..ec92d25ab9 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-after.png differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-fallback.playtest.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-fallback.playtest.json.gz new file mode 100644 index 0000000000..ca52dd80b0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-fallback.playtest.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-report.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-report.json.gz new file mode 100644 index 0000000000..07c09862af Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-report.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-snapshots.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-snapshots.json.gz new file mode 100644 index 0000000000..a0ccb257f3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before-opening-snapshots.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before.png b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before.png new file mode 100644 index 0000000000..15b3eb6896 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-before.png differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-derived-opening-proof.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-derived-opening-proof.json.gz new file mode 100644 index 0000000000..27c24c030e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-derived-opening-proof.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-fallback-comparison.png b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-fallback-comparison.png new file mode 100644 index 0000000000..5015268cde Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-fallback-comparison.png differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/native-run.log.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-run.log.gz new file mode 100644 index 0000000000..38f5f2123e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/native-run.log.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/console.json.gz new file mode 100644 index 0000000000..8a17514e95 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/01-playtests-combat.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/console.json.gz new file mode 100644 index 0000000000..034b383781 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/02-playtests-fail.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/console.json.gz new file mode 100644 index 0000000000..cba3e67959 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/03-playtests-inventory.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..31ec20a63d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/console.json.gz new file mode 100644 index 0000000000..c36803aecb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/05-playtests-progress.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..1aa654c48b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..03644a04f8 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz new file mode 100644 index 0000000000..5a9ba0d73d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..48b6d4b963 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/action-rpg/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/console.json.gz new file mode 100644 index 0000000000..b652c4e9bd Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/01-playtests-play.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/console.json.gz new file mode 100644 index 0000000000..0ac9cdd5e1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/02-playtests-sky.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..0ac9cdd5e1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..93f8f1ab25 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/04-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..5293bd0a79 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/minimal/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/console.json.gz new file mode 100644 index 0000000000..b0edccbe51 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/01-playtests-branding.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/console.json.gz new file mode 100644 index 0000000000..6ce095e549 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/02-playtests-buffer.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/console.json.gz new file mode 100644 index 0000000000..8cf151e5e4 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/03-playtests-collect.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/console.json.gz new file mode 100644 index 0000000000..59c49d5586 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/04-playtests-coyote.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/console.json.gz new file mode 100644 index 0000000000..249d9d7008 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/05-playtests-damage.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/console.json.gz new file mode 100644 index 0000000000..4c46c75355 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/06-playtests-dash.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/console.json.gz new file mode 100644 index 0000000000..a74965548c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/07-playtests-hud.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/console.json.gz new file mode 100644 index 0000000000..732a52ce31 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/08-playtests-jump.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/console.json.gz new file mode 100644 index 0000000000..5156e9cf45 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/09-playtests-move.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..4fad4338ef Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/console.json.gz new file mode 100644 index 0000000000..18846c5363 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/11-playtests-respawn.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/console.json.gz new file mode 100644 index 0000000000..b026f1f9fa Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/12-playtests-stomp.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..3a13b6490d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/console.json.gz new file mode 100644 index 0000000000..32d9207e0e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/14-playtests-touch-controls-web.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/console.json.gz new file mode 100644 index 0000000000..bb5033dffc Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/15-playtests-world.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..ccbceafe22 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/platformer/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/console.json.gz new file mode 100644 index 0000000000..ece9b3dfce Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/01-playtests-axis.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/console.json.gz new file mode 100644 index 0000000000..16917dbfbd Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/02-playtests-blocked.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/03-playtests-boot-failure.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/03-playtests-boot-failure.playtest.json/console.json.gz new file mode 100644 index 0000000000..315627f2b3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/03-playtests-boot-failure.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/console.json.gz new file mode 100644 index 0000000000..9378125eac Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/04-playtests-caused.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/console.json.gz new file mode 100644 index 0000000000..faefe92a2b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/05-playtests-lit.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/console.json.gz new file mode 100644 index 0000000000..ce741133a0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/06-playtests-pause.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..eeedc09fa0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/07-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/console.json.gz new file mode 100644 index 0000000000..b1b960aa24 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/08-playtests-push.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/console.json.gz new file mode 100644 index 0000000000..1c76d510bb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/09-playtests-replay.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/console.json.gz new file mode 100644 index 0000000000..81d41b72c3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/10-playtests-restart.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/console.json.gz new file mode 100644 index 0000000000..aec6a805ff Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/11-playtests-seal.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/console.json.gz new file mode 100644 index 0000000000..3e0d1d1b24 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/12-playtests-settle.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..1699e4959d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/13-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/console.json.gz new file mode 100644 index 0000000000..59ec908376 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/network.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/network.json.gz new file mode 100644 index 0000000000..ddc742c1b7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/network.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/runtime-trace.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/runtime-trace.json.gz new file mode 100644 index 0000000000..95fab98b26 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/14-playtests-travel.playtest.json/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..ffc9487a71 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/puzzle/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/console.json.gz new file mode 100644 index 0000000000..8596fb65a1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/01-playtests-boost.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/console.json.gz new file mode 100644 index 0000000000..968c0aad0e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/02-playtests-outcome.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..3a95d6b5f2 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/03-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/console.json.gz new file mode 100644 index 0000000000..bdf2a45aab Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/04-playtests-ranking.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/console.json.gz new file mode 100644 index 0000000000..a17bb617bc Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/05-playtests-rescue.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/console.json.gz new file mode 100644 index 0000000000..7746c7828a Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/06-playtests-reverse.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/console.json.gz new file mode 100644 index 0000000000..33e69ba357 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/07-playtests-shortcut.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..fdb71bc06e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..7ab2ed2e0a Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/09-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..346919d794 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/racing/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/console.json.gz new file mode 100644 index 0000000000..55d4bf89d8 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/01-playtests-auto-lightning-clear.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/console.json.gz new file mode 100644 index 0000000000..1582d60934 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/02-playtests-auto-lightning.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/capture.json.gz new file mode 100644 index 0000000000..cdf041aaab Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/console.json.gz new file mode 100644 index 0000000000..1bd81c1e04 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/03-playtests-automation.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/console.json.gz new file mode 100644 index 0000000000..ec5a86a3be Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/04-playtests-cinematic.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/console.json.gz new file mode 100644 index 0000000000..ab0e3e0167 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/05-playtests-flash.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/console.json.gz new file mode 100644 index 0000000000..7a0afbe71c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/06-playtests-fly.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/console.json.gz new file mode 100644 index 0000000000..af553cd2c2 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/07-playtests-lightning.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/capture.json.gz new file mode 100644 index 0000000000..c6ec0a65cf Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/console.json.gz new file mode 100644 index 0000000000..ef2bf03150 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/08-playtests-loading.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/console.json.gz new file mode 100644 index 0000000000..19188c851c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/09-playtests-paused.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..fcc3a8baff Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/10-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/console.json.gz new file mode 100644 index 0000000000..ee89899f07 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/11-playtests-reduced-motion.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/console.json.gz new file mode 100644 index 0000000000..0b7ec7ecbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/12-playtests-simulation.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/console.json.gz new file mode 100644 index 0000000000..1ae48418c5 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/13-playtests-storm.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..0f554d2439 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/14-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/capture.json.gz new file mode 100644 index 0000000000..ba22426cbb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/console.json.gz new file mode 100644 index 0000000000..13f9ebe317 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/15-playtests-tiers.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/console.json.gz new file mode 100644 index 0000000000..17d83aa52b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/16-playtests-weather.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..276b4788a6 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rain/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..3762658db6 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/console.json.gz new file mode 100644 index 0000000000..b2333ceabc Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/02-playtests-rts-ai-attack.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/console.json.gz new file mode 100644 index 0000000000..8ebdaba509 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/03-playtests-rts-attack.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/console.json.gz new file mode 100644 index 0000000000..38f5551ae3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/04-playtests-rts-build.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/console.json.gz new file mode 100644 index 0000000000..4e6fb9c061 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/05-playtests-rts-orders.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/console.json.gz new file mode 100644 index 0000000000..4e6fb9c061 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/06-playtests-rts-train.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..21aad78eac Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/07-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..bdb8a0a501 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/rts/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..57e4a4a032 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/01-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/console.json.gz new file mode 100644 index 0000000000..9c1bee034b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/02-playtests-streams.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..369613e8ca Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/03-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..c7dd55853d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/runner/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/console.json.gz new file mode 100644 index 0000000000..efc6eb624e Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/01-playtests-fail.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/console.json.gz new file mode 100644 index 0000000000..8b4351271b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/02-playtests-float-after-the-run-ends.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/console.json.gz new file mode 100644 index 0000000000..01e2deb42b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/03-playtests-float.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..1a80797841 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/console.json.gz new file mode 100644 index 0000000000..894ad220de Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/05-playtests-sails.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..ab1018b27d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/06-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..4a15a7c4f1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/07-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz new file mode 100644 index 0000000000..6f802d08e5 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/08-playtests-win.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..62590caf92 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/sailing/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/console.json.gz new file mode 100644 index 0000000000..fd954123e4 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/01-playtests-aim-alignment.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/console.json.gz new file mode 100644 index 0000000000..2f4452dd80 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/02-playtests-breakable-shatters.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/console.json.gz new file mode 100644 index 0000000000..f4ad86aa57 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/03-playtests-bullet-holes.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/console.json.gz new file mode 100644 index 0000000000..9cc5550b6f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/04-playtests-camera-tracks-body-vertically.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/console.json.gz new file mode 100644 index 0000000000..4109f263f7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/05-playtests-crouch-lowers-the-eye.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/console.json.gz new file mode 100644 index 0000000000..62c07ae5b9 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/06-playtests-death-no-snap.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/console.json.gz new file mode 100644 index 0000000000..ca50850ff5 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/07-playtests-debug-ads.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/console.json.gz new file mode 100644 index 0000000000..2580cb5f4f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/08-playtests-debug-clear.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/console.json.gz new file mode 100644 index 0000000000..50065bde2b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/09-playtests-debug-endurance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/console.json.gz new file mode 100644 index 0000000000..f23021e738 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/10-playtests-debug-fire.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/console.json.gz new file mode 100644 index 0000000000..c4c8f84152 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/11-playtests-debug-restart.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/console.json.gz new file mode 100644 index 0000000000..880298bae7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/12-playtests-enemy-body-is-a-body.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/console.json.gz new file mode 100644 index 0000000000..753eefe62c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/13-playtests-enemy-death-settles.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/console.json.gz new file mode 100644 index 0000000000..3ccd1d134d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/14-playtests-enemy-flinches-when-hit.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/console.json.gz new file mode 100644 index 0000000000..446238136d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/15-playtests-enemy-foot-contact.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/console.json.gz new file mode 100644 index 0000000000..f9067963f7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/16-playtests-enemy-material-is-not-shared.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/console.json.gz new file mode 100644 index 0000000000..c28513985f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/17-playtests-enemy-reaches-walkway.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/console.json.gz new file mode 100644 index 0000000000..2c63c75e9d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/18-playtests-enemy-rifle-grip.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/console.json.gz new file mode 100644 index 0000000000..baf59e68cd Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/19-playtests-enemy-scale.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/console.json.gz new file mode 100644 index 0000000000..9b52acc8d2 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/20-playtests-enemy-stops-without-snapping.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/console.json.gz new file mode 100644 index 0000000000..388d7749a6 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/21-playtests-enemy-stride-matches-speed.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/console.json.gz new file mode 100644 index 0000000000..bb53fd86eb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/22-playtests-enemy-stride-under-crouch.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/console.json.gz new file mode 100644 index 0000000000..aacd462731 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/23-playtests-enemy-uses-crouch-clips.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/console.json.gz new file mode 100644 index 0000000000..b8c6d67342 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/24-playtests-five-soldiers.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/console.json.gz new file mode 100644 index 0000000000..6a81c0f179 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/25-playtests-flash-retires.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/console.json.gz new file mode 100644 index 0000000000..2741f7be39 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/26-playtests-headshot-hits-head.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/console.json.gz new file mode 100644 index 0000000000..816eb91e2f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/27-playtests-hold-to-fire.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/console.json.gz new file mode 100644 index 0000000000..642daf1089 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/28-playtests-input-control.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/console.json.gz new file mode 100644 index 0000000000..d490bd394d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/29-playtests-minimap-state.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..5744047560 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/30-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/console.json.gz new file mode 100644 index 0000000000..2cbfdf2f8f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/31-playtests-round-clock.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/console.json.gz new file mode 100644 index 0000000000..25d7d96901 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/32-playtests-shot-tracks-crosshair.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..970df4df25 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/33-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/console.json.gz new file mode 100644 index 0000000000..a0f1bcfaeb Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/34-playtests-walkway-reachable.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..f7ea58c563 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/shooter/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/console.json.gz new file mode 100644 index 0000000000..4f6d72d96f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/01-playtests-footsteps.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..5f9672f47c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/02-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/console.json.gz new file mode 100644 index 0000000000..94f477c48f Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/03-playtests-snow-physics.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..2cf8eeac30 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/04-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..cdf041aaab Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..522837a68a Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/05-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/console.json.gz new file mode 100644 index 0000000000..6fd6b222dc Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/06-playtests-weather.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..759c31c4e0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/snow/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/console.json.gz new file mode 100644 index 0000000000..0ed9dbaec0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/01-playtests-assets.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/02-playtests-boot-failure.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/02-playtests-boot-failure.playtest.json/console.json.gz new file mode 100644 index 0000000000..315627f2b3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/02-playtests-boot-failure.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/console.json.gz new file mode 100644 index 0000000000..4111657c85 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/03-playtests-buffer.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/console.json.gz new file mode 100644 index 0000000000..b7c0e2a508 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/04-playtests-cloth.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/console.json.gz new file mode 100644 index 0000000000..ef9efb9538 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/05-playtests-coyote.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/console.json.gz new file mode 100644 index 0000000000..8b39e022c8 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/06-playtests-forward.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/console.json.gz new file mode 100644 index 0000000000..72afabefa0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/07-playtests-gameover.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/console.json.gz new file mode 100644 index 0000000000..9f15ed0271 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/08-playtests-goal.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/console.json.gz new file mode 100644 index 0000000000..093b1e8eab Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/network.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/network.json.gz new file mode 100644 index 0000000000..ddc742c1b7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/network.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/runtime-trace.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/runtime-trace.json.gz new file mode 100644 index 0000000000..c9bc4d0d05 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/09-playtests-hot-reload.playtest.json/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/console.json.gz new file mode 100644 index 0000000000..6f6724bbfe Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/10-playtests-look.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/console.json.gz new file mode 100644 index 0000000000..380c3a948d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/11-playtests-models.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/console.json.gz new file mode 100644 index 0000000000..654e735c11 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/12-playtests-monitoring.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/console.json.gz new file mode 100644 index 0000000000..a529f328b6 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/13-playtests-odometer.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/console.json.gz new file mode 100644 index 0000000000..86343f2f14 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/14-playtests-pause.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/console.json.gz new file mode 100644 index 0000000000..57a45c0af1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/15-playtests-play.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/console.json.gz new file mode 100644 index 0000000000..7cacbd7aba Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/16-playtests-production-readiness.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/console.json.gz new file mode 100644 index 0000000000..7370c0cd27 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/17-playtests-respawn.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/console.json.gz new file mode 100644 index 0000000000..ab59cf85dc Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/18-playtests-restart.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/console.json.gz new file mode 100644 index 0000000000..0eb95e736a Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/runtime-trace.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/runtime-trace.json.gz new file mode 100644 index 0000000000..7c3a692926 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/19-playtests-seed.playtest.json/runtime-trace.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..7846f75573 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/20-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/console.json.gz new file mode 100644 index 0000000000..70e6b50de0 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/21-playtests-textures.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/console.json.gz new file mode 100644 index 0000000000..62a8923d6c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/22-playtests-touch-controls.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/console.json.gz new file mode 100644 index 0000000000..6f6724bbfe Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/23-playtests-zoom-pinch.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/console.json.gz new file mode 100644 index 0000000000..7a7812b78b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/24-playtests-zoom-wheel.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..242a5f9b55 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/starter/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/console.json.gz new file mode 100644 index 0000000000..f819317a0c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/01-playtests-arsenal.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/console.json.gz new file mode 100644 index 0000000000..b3ec021921 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/02-playtests-defeat.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/console.json.gz new file mode 100644 index 0000000000..6bb491f1f2 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/03-playtests-look.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz new file mode 100644 index 0000000000..0bffd5898d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz new file mode 100644 index 0000000000..b81a43c09d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/04-playtests-performance.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/console.json.gz new file mode 100644 index 0000000000..69d6a946f9 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/05-playtests-placement.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/console.json.gz new file mode 100644 index 0000000000..cb968a829b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/06-playtests-pointer-placement.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/console.json.gz new file mode 100644 index 0000000000..f5abe880e3 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/07-playtests-strike.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz new file mode 100644 index 0000000000..28d54c899c Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/08-playtests-survives.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/console.json.gz new file mode 100644 index 0000000000..c1e625a72d Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/09-playtests-target-priority.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/console.json.gz new file mode 100644 index 0000000000..855e2afc12 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/10-playtests-upgrade-recycle.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/capture.json.gz new file mode 100644 index 0000000000..81ade2aa9b Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/console.json.gz new file mode 100644 index 0000000000..b1495915f1 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/11-playtests-victory.playtest.json/console.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/capture.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/capture.json.gz new file mode 100644 index 0000000000..63467c7ea7 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/capture.json.gz differ diff --git a/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/console.json.gz b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/console.json.gz new file mode 100644 index 0000000000..4a9993eb06 Binary files /dev/null and b/docs/benchmark/prd345/generated-gameplay-native-fallback/reports/tower-defense/artifacts/playtest/console.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/README.md b/docs/benchmark/prd345/matched-gameplay-performance/README.md new file mode 100644 index 0000000000..fb17e9e25c --- /dev/null +++ b/docs/benchmark/prd345/matched-gameplay-performance/README.md @@ -0,0 +1,16 @@ +# Quiet matched gameplay performance + +Three ABBA cycles per template, six fresh launches per arm, unchanged 1920×1080 performance scenarios and 33 ms p95 limit. Frozen local framework archives, source/assets/scenario/camera helper identity checks retained. Actual hardware WebGPU NVIDIA Turing, high tier. No owned concurrent builds during timing; external host activity and driver/OS caches uncontrolled. Exact camera snapshots were not requested by these unchanged scenarios: camera helper hashes match, but snapshot equality is unmeasured. + +| Template | Original mean per-run p95 (sample sd) | Current mean per-run p95 (sample sd) | Passes original/current | +|---|---|---|---| +| Shooter | 29.23 ms (0.74) | 48.17 ms (2.50) | 6/6 / 0/6 | +| Snow | 33.83 ms (1.26) | 29.72 ms (3.26) | 1/6 / 5/6 | + +Shooter has a repeatable whole-current-source regression in this setup; no individual shader/update cause is established. Candidate reports 102 converted materials, measured bright environment radiance ~0.548, extra fill not admitted. Snow retains authored fill with added fillGain zero and 19 conversions; its original template also frequently fails, and the current template is not uniformly below 33 ms. No threshold was changed, no best sample selected, no full-gameplay green claim. + +Every per-launch outcome/sample count is in summary.json. Full summaries/source manifests/reports are compressed here; all original full PNGs remain in the durable local performance-controls1 tree, inventoried by image-manifest.json. Failed wrapper imports before measurement were retained separately; they produced no measured samples. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/matched-gameplay-performance/image-manifest.json b/docs/benchmark/prd345/matched-gameplay-performance/image-manifest.json new file mode 100644 index 0000000000..9264e0e692 --- /dev/null +++ b/docs/benchmark/prd345/matched-gameplay-performance/image-manifest.json @@ -0,0 +1,182 @@ +[ + { + "path": "shooter-1-1-before/performance.png", + "bytes": 1595629, + "sha256": "dd0cc58bad425a6786a56c08a02d58366aba649426b62b2b1b9a680e1d16cc73" + }, + { + "path": "shooter-1-1-before/after.png", + "bytes": 1595629, + "sha256": "dd0cc58bad425a6786a56c08a02d58366aba649426b62b2b1b9a680e1d16cc73" + }, + { + "path": "shooter-1-2-after/performance.png", + "bytes": 1596822, + "sha256": "4abed3565a116a788af42b4b7c63fda3c7f3144f8191243d78da0e9e2d181f2a" + }, + { + "path": "shooter-1-2-after/after.png", + "bytes": 1596822, + "sha256": "4abed3565a116a788af42b4b7c63fda3c7f3144f8191243d78da0e9e2d181f2a" + }, + { + "path": "shooter-1-3-after/performance.png", + "bytes": 1596195, + "sha256": "d81f3a9fd444264b6dee72ba421b9540ec73f62e1bd62f8b9933e58777567a38" + }, + { + "path": "shooter-1-3-after/after.png", + "bytes": 1596195, + "sha256": "d81f3a9fd444264b6dee72ba421b9540ec73f62e1bd62f8b9933e58777567a38" + }, + { + "path": "shooter-1-4-before/performance.png", + "bytes": 1596229, + "sha256": "341071be7b5deb7d8273fdd2b7d0a88271f158f0332cb310eaf710deeae7f02c" + }, + { + "path": "shooter-1-4-before/after.png", + "bytes": 1596229, + "sha256": "341071be7b5deb7d8273fdd2b7d0a88271f158f0332cb310eaf710deeae7f02c" + }, + { + "path": "shooter-2-1-before/performance.png", + "bytes": 1596978, + "sha256": "d438016fc112f75eeca4508d7f189786ffef751c1f08a83452d14c46f959ff07" + }, + { + "path": "shooter-2-1-before/after.png", + "bytes": 1596978, + "sha256": "d438016fc112f75eeca4508d7f189786ffef751c1f08a83452d14c46f959ff07" + }, + { + "path": "shooter-2-2-after/performance.png", + "bytes": 1598936, + "sha256": "5b4531c294596a165ebe61251080df6e826adc4763736bf018f8d45fd3bd1d31" + }, + { + "path": "shooter-2-2-after/after.png", + "bytes": 1598936, + "sha256": "5b4531c294596a165ebe61251080df6e826adc4763736bf018f8d45fd3bd1d31" + }, + { + "path": "shooter-2-3-after/performance.png", + "bytes": 1597269, + "sha256": "84c7eae6cc0da3bb78bef45328b95bd042f38678105502a66d7dfe31092fa313" + }, + { + "path": "shooter-2-3-after/after.png", + "bytes": 1597269, + "sha256": "84c7eae6cc0da3bb78bef45328b95bd042f38678105502a66d7dfe31092fa313" + }, + { + "path": "shooter-2-4-before/performance.png", + "bytes": 1595893, + "sha256": "c3fcbcc9919755e9e5620fb4f51ffa806868d6d61ca2b4bc7fd23e3f7c02f006" + }, + { + "path": "shooter-2-4-before/after.png", + "bytes": 1595893, + "sha256": "c3fcbcc9919755e9e5620fb4f51ffa806868d6d61ca2b4bc7fd23e3f7c02f006" + }, + { + "path": "shooter-3-1-before/performance.png", + "bytes": 1596118, + "sha256": "ee0c81ff429740f87fc1cf2caf2667743585db84db6fe90a892c90109964648f" + }, + { + "path": "shooter-3-1-before/after.png", + "bytes": 1596118, + "sha256": "ee0c81ff429740f87fc1cf2caf2667743585db84db6fe90a892c90109964648f" + }, + { + "path": "shooter-3-2-after/performance.png", + "bytes": 1599871, + "sha256": "09a099300a8e8ade72c4cb4c1f6341e82ec2a9ec13a857a81c13aecd2bb6a719" + }, + { + "path": "shooter-3-2-after/after.png", + "bytes": 1599871, + "sha256": "09a099300a8e8ade72c4cb4c1f6341e82ec2a9ec13a857a81c13aecd2bb6a719" + }, + { + "path": "shooter-3-3-after/performance.png", + "bytes": 1602833, + "sha256": "cd121414468a792ebda65551e7c3630a8716d13fb1654ae815467abaf433003a" + }, + { + "path": "shooter-3-3-after/after.png", + "bytes": 1602833, + "sha256": "cd121414468a792ebda65551e7c3630a8716d13fb1654ae815467abaf433003a" + }, + { + "path": "shooter-3-4-before/performance.png", + "bytes": 1599879, + "sha256": "c723f2bdb7cd7619d649f394e06356c1e81663b7ab98034e75fa1226397184b5" + }, + { + "path": "shooter-3-4-before/after.png", + "bytes": 1599879, + "sha256": "c723f2bdb7cd7619d649f394e06356c1e81663b7ab98034e75fa1226397184b5" + }, + { + "path": "snow-1-1-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + }, + { + "path": "snow-1-2-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-1-3-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-1-4-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + }, + { + "path": "snow-2-1-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + }, + { + "path": "snow-2-2-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-2-3-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-2-4-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + }, + { + "path": "snow-3-1-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + }, + { + "path": "snow-3-2-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-3-3-after/after.png", + "bytes": 808358, + "sha256": "e6853dd3963c071c8576f96330557b0316f46b36e15cee17c42ec38cc2a1f0a2" + }, + { + "path": "snow-3-4-before/after.png", + "bytes": 811186, + "sha256": "742dff4110b8a1e8b7620411376925fc0202fe4149acdc0a2d0486f0d6870d37" + } +] diff --git a/docs/benchmark/prd345/matched-gameplay-performance/results.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/results.json.gz new file mode 100644 index 0000000000..e479db76ae Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/results.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-1-before-report.json.gz new file mode 100644 index 0000000000..fbef433ef3 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-2-after-report.json.gz new file mode 100644 index 0000000000..f8788ce361 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-3-after-report.json.gz new file mode 100644 index 0000000000..9ce12b2aaa Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-4-before-report.json.gz new file mode 100644 index 0000000000..d6169968c7 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-1-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-1-before-report.json.gz new file mode 100644 index 0000000000..b330ca3241 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-2-after-report.json.gz new file mode 100644 index 0000000000..f72d8b3908 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-3-after-report.json.gz new file mode 100644 index 0000000000..b2920e190c Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-4-before-report.json.gz new file mode 100644 index 0000000000..8c87227d80 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-2-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-1-before-report.json.gz new file mode 100644 index 0000000000..158a752a99 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-2-after-report.json.gz new file mode 100644 index 0000000000..ddbdf7c778 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-3-after-report.json.gz new file mode 100644 index 0000000000..d1bd9f9f33 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-4-before-report.json.gz new file mode 100644 index 0000000000..48bdc36850 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/shooter-3-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-1-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-1-before-report.json.gz new file mode 100644 index 0000000000..e0b29ccd9d Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-1-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-2-after-report.json.gz new file mode 100644 index 0000000000..697c49a8b9 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-1-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-3-after-report.json.gz new file mode 100644 index 0000000000..9edda2efab Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-1-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-4-before-report.json.gz new file mode 100644 index 0000000000..8c1fd236c9 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-1-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-2-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-1-before-report.json.gz new file mode 100644 index 0000000000..23a2bf3c4e Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-2-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-2-after-report.json.gz new file mode 100644 index 0000000000..c0609277e8 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-2-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-3-after-report.json.gz new file mode 100644 index 0000000000..2df1405912 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-2-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-4-before-report.json.gz new file mode 100644 index 0000000000..828608913b Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-2-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-3-1-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-1-before-report.json.gz new file mode 100644 index 0000000000..1a4a90e88e Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-1-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-3-2-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-2-after-report.json.gz new file mode 100644 index 0000000000..f06808d274 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-2-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-3-3-after-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-3-after-report.json.gz new file mode 100644 index 0000000000..bd39730e0e Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-3-after-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/snow-3-4-before-report.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-4-before-report.json.gz new file mode 100644 index 0000000000..ccc3e70265 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/snow-3-4-before-report.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/source-identity.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/source-identity.json.gz new file mode 100644 index 0000000000..cb945f0ede Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/source-identity.json.gz differ diff --git a/docs/benchmark/prd345/matched-gameplay-performance/summary.json b/docs/benchmark/prd345/matched-gameplay-performance/summary.json new file mode 100644 index 0000000000..8786ca1c89 --- /dev/null +++ b/docs/benchmark/prd345/matched-gameplay-performance/summary.json @@ -0,0 +1,302 @@ +{ + "design": "3 ABBA cycles/template; six fresh launches/arm; unchanged 33 ms scenarios; no own concurrent builds; external host activity uncontrolled", + "cameraLimitation": "Camera helper source hashes identical. Existing exact performance scenarios request subject player, not camera, so full camera snapshot equality was not established.", + "groups": { + "shooter": { + "before": { + "values": [ + 30.19999999999709, 28.099999999998545, 29.299999999999272, 28.700000000000728, + 29.400000000001455, 29.700000000000728 + ], + "n": 6, + "passes": 6, + "mean": 29.23333333333297, + "median": 29.350000000000364, + "sampleStdDev": 0.742069179164759, + "min": 28.099999999998545, + "max": 30.19999999999709 + }, + "after": { + "values": [ + 46.400000000001455, 44.70000000000073, 48.20000000000073, 49.89999999999782, + 51.79999999999927, 48 + ], + "n": 6, + "passes": 0, + "mean": 48.166666666666664, + "median": 48.100000000000364, + "sampleStdDev": 2.504928475358542, + "min": 44.70000000000073, + "max": 51.79999999999927 + } + }, + "snow": { + "before": { + "values": [ + 31.5, 34.70000000000073, 34.5, 33.30000000000109, 34.79999999999927, 34.20000000000073 + ], + "n": 6, + "passes": 1, + "mean": 33.833333333333634, + "median": 34.350000000000364, + "sampleStdDev": 1.2643839079435135, + "min": 31.5, + "max": 34.79999999999927 + }, + "after": { + "values": [ + 30, 28, 34.69999999999891, 28.299999999999272, 25.399999999999636, 31.899999999999636 + ], + "n": 6, + "passes": 5, + "mean": 29.716666666666242, + "median": 29.149999999999636, + "sampleStdDev": 3.2639955065326323, + "min": 25.399999999999636, + "max": 34.69999999999891 + } + } + }, + "rows": [ + { + "label": "shooter-1-1-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 30.19999999999709, + "sampleCount": 522, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-1-2-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 46.400000000001455, + "sampleCount": 421, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-1-3-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 44.70000000000073, + "sampleCount": 433, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-1-4-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 28.099999999998545, + "sampleCount": 623, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-2-1-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 29.299999999999272, + "sampleCount": 546, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-2-2-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 48.20000000000073, + "sampleCount": 403, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-2-3-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 49.89999999999782, + "sampleCount": 347, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-2-4-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 28.700000000000728, + "sampleCount": 581, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-3-1-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 29.400000000001455, + "sampleCount": 615, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-3-2-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 51.79999999999927, + "sampleCount": 376, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-3-3-after", + "template": "shooter", + "arm": "after", + "pass": false, + "frameMsP95": 48, + "sampleCount": 361, + "frames": 603, + "runtime": "web" + }, + { + "label": "shooter-3-4-before", + "template": "shooter", + "arm": "before", + "pass": true, + "frameMsP95": 29.700000000000728, + "sampleCount": 522, + "frames": 603, + "runtime": "web" + }, + { + "label": "snow-1-1-before", + "template": "snow", + "arm": "before", + "pass": true, + "frameMsP95": 31.5, + "sampleCount": 227, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-1-2-after", + "template": "snow", + "arm": "after", + "pass": true, + "frameMsP95": 30, + "sampleCount": 232, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-1-3-after", + "template": "snow", + "arm": "after", + "pass": true, + "frameMsP95": 28, + "sampleCount": 228, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-1-4-before", + "template": "snow", + "arm": "before", + "pass": false, + "frameMsP95": 34.70000000000073, + "sampleCount": 224, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-2-1-before", + "template": "snow", + "arm": "before", + "pass": false, + "frameMsP95": 34.5, + "sampleCount": 229, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-2-2-after", + "template": "snow", + "arm": "after", + "pass": false, + "frameMsP95": 34.69999999999891, + "sampleCount": 229, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-2-3-after", + "template": "snow", + "arm": "after", + "pass": true, + "frameMsP95": 28.299999999999272, + "sampleCount": 227, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-2-4-before", + "template": "snow", + "arm": "before", + "pass": false, + "frameMsP95": 33.30000000000109, + "sampleCount": 232, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-3-1-before", + "template": "snow", + "arm": "before", + "pass": false, + "frameMsP95": 34.79999999999927, + "sampleCount": 229, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-3-2-after", + "template": "snow", + "arm": "after", + "pass": true, + "frameMsP95": 25.399999999999636, + "sampleCount": 227, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-3-3-after", + "template": "snow", + "arm": "after", + "pass": true, + "frameMsP95": 31.899999999999636, + "sampleCount": 225, + "frames": 660, + "runtime": "web" + }, + { + "label": "snow-3-4-before", + "template": "snow", + "arm": "before", + "pass": false, + "frameMsP95": 34.20000000000073, + "sampleCount": 226, + "frames": 660, + "runtime": "web" + } + ] +} diff --git a/docs/benchmark/prd345/matched-gameplay-performance/summary.json.gz b/docs/benchmark/prd345/matched-gameplay-performance/summary.json.gz new file mode 100644 index 0000000000..e731b8e766 Binary files /dev/null and b/docs/benchmark/prd345/matched-gameplay-performance/summary.json.gz differ diff --git a/docs/benchmark/prd345/packed-fixture-canonical-root/README.md b/docs/benchmark/prd345/packed-fixture-canonical-root/README.md new file mode 100644 index 0000000000..40c3f043e4 --- /dev/null +++ b/docs/benchmark/prd345/packed-fixture-canonical-root/README.md @@ -0,0 +1,9 @@ +# Packed fixture canonical root + +The normal root suite with a durable symlinked TMPDIR failed two packed MetaHuman consumer identity assertions: Node resolves import.meta URLs to the physical package path, while the fixture expected the symlink spelling. The fixture now resolves its extracted package root with realpath; all existing relative-WASM identity, checksum, lazy-loading and ordering assertions remain unchanged. No runtime package source changed. + +Retained root failure: root-fullgate.log.gz. After correction, the actual packed consumer suite passed all 7 tests with the same TMPDIR (metahuman-canonical-root-green.log.gz). Independent review passed. The full root suite has not yet passed; its unit phase was not reached in this failed run. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/packed-fixture-canonical-root/metahuman-canonical-root-green.log.gz b/docs/benchmark/prd345/packed-fixture-canonical-root/metahuman-canonical-root-green.log.gz new file mode 100644 index 0000000000..88ec1f631d Binary files /dev/null and b/docs/benchmark/prd345/packed-fixture-canonical-root/metahuman-canonical-root-green.log.gz differ diff --git a/docs/benchmark/prd345/packed-fixture-canonical-root/root-fullgate.log.gz b/docs/benchmark/prd345/packed-fixture-canonical-root/root-fullgate.log.gz new file mode 100644 index 0000000000..801a0e0f81 Binary files /dev/null and b/docs/benchmark/prd345/packed-fixture-canonical-root/root-fullgate.log.gz differ diff --git a/docs/benchmark/prd345/racing-regression/README.md b/docs/benchmark/prd345/racing-regression/README.md new file mode 100644 index 0000000000..49a94f0b63 --- /dev/null +++ b/docs/benchmark/prd345/racing-regression/README.md @@ -0,0 +1,9 @@ +# Racing regression controls + +The rejected material callback called `updateWorldMatrix(true, false)` on the shared key and target during renderer traversal. That changed the road and shadow image even at zero rim/fill gain. Sampling-only, bare upstream node-material copy and material-emissive-only controls preserved baseline pixels. The otherwise identical expression without those two writes restored baseline pixels. Final enabled racing frames now retain the original road/shadows with only the subtle car rim. + +Rejected candidate PNGs and raw control reports are retained here. Final admitted boot images are in [the twelve-template matrix](../final-template-matrix/README.md). This causal boot comparison does not prove full gameplay or native appearance acceptance. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/racing-regression/isolation1-sampler-only-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation1-sampler-only-capture.json.gz new file mode 100644 index 0000000000..9a194cea7a Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation1-sampler-only-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/isolation1-skip-sampler-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation1-skip-sampler-capture.json.gz new file mode 100644 index 0000000000..857c878c68 Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation1-skip-sampler-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/isolation2-bare-copy-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation2-bare-copy-capture.json.gz new file mode 100644 index 0000000000..9390176f9f Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation2-bare-copy-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/isolation2-zero-startup-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation2-zero-startup-capture.json.gz new file mode 100644 index 0000000000..195c1e595b Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation2-zero-startup-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/isolation3-emissive-only-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation3-emissive-only-capture.json.gz new file mode 100644 index 0000000000..5c38d2fcb3 Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation3-emissive-only-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/isolation3-readonly-key-capture.json.gz b/docs/benchmark/prd345/racing-regression/isolation3-readonly-key-capture.json.gz new file mode 100644 index 0000000000..6c245bb2ff Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/isolation3-readonly-key-capture.json.gz differ diff --git a/docs/benchmark/prd345/racing-regression/rejected-candidate.png b/docs/benchmark/prd345/racing-regression/rejected-candidate.png new file mode 100644 index 0000000000..28326c5d3b Binary files /dev/null and b/docs/benchmark/prd345/racing-regression/rejected-candidate.png differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/README.md b/docs/benchmark/prd345/signal-owned-resource-cleanup/README.md new file mode 100644 index 0000000000..76397a8d62 --- /dev/null +++ b/docs/benchmark/prd345/signal-owned-resource-cleanup/README.md @@ -0,0 +1,9 @@ +# Owned local signal cleanup + +Normal root gate attempt 2 passed docs/build and corrected packed-consumer tests, then failed the playtest orphan fixture. Known persistent coordination infrastructure was originally counted as growth. Initializing it before the count baseline and adding explicit empty lease/queue checks then exposed a genuine forced-SIGTERM leak: `lock/holder.json` survived. Both failures are retained. + +The local runner now shares one idempotent resource release between signal teardown and normal/error finally. It awaits this run's in-flight display/lease acquisition and calls only their existing exact-owner release handles, after browser/server cleanup, before the unchanged interrupted exit code 2. Startup guards prevent a later URL/preparation await from spawning an owned server/browser after teardown. The mutex protocol and timeout policy are unchanged; no other owner is removed. + +Final real forced/unforced SIGTERM gates both pass with process, directory, lock and queue assertions intact. Existing runner/suite plus pending-acquisition, second-cleanup, release-error and real replacement-holder tests: 226 pass. Runner build/publint pass. Independent review confirms local lifecycle guards and owner release, while identifying an unresolved pre-existing borrowed remote-browser edge: an in-flight newPage/remote preparation may finish after remote teardown. This repair does not claim complete remote cancellation qualification, and does not close a borrowed user browser/context. + +The complete normal root suite still requires a passing rerun; its unit phase was not reached in the retained failed run. No original visual or native acceptance gate is waived. diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-forced.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-forced.log.gz new file mode 100644 index 0000000000..2f79ae3a84 Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-forced.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-unforced.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-unforced.log.gz new file mode 100644 index 0000000000..ea316861b1 Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-final-unforced.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-forced-red.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-forced-red.log.gz new file mode 100644 index 0000000000..e2b3cab1ba Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-forced-red.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-unforced-green.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-unforced-green.log.gz new file mode 100644 index 0000000000..9dde2d668e Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/orphan-unforced-green.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/root-fullgate2.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/root-fullgate2.log.gz new file mode 100644 index 0000000000..e4475cf9b4 Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/root-fullgate2.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-build.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-build.log.gz new file mode 100644 index 0000000000..8b43963e1f Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-build.log.gz differ diff --git a/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-cpu.log.gz b/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-cpu.log.gz new file mode 100644 index 0000000000..8a6ce0c7e1 Binary files /dev/null and b/docs/benchmark/prd345/signal-owned-resource-cleanup/signal-final-cpu.log.gz differ diff --git a/docs/benchmark/prd345/startup-p95-bd5d9d599/frozen-trees.json.gz b/docs/benchmark/prd345/startup-p95-bd5d9d599/frozen-trees.json.gz new file mode 100644 index 0000000000..14a8a55976 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-bd5d9d599/frozen-trees.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-bd5d9d599/samples.json.gz b/docs/benchmark/prd345/startup-p95-bd5d9d599/samples.json.gz new file mode 100644 index 0000000000..5eb47f8505 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-bd5d9d599/samples.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.full.json.gz b/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.full.json.gz new file mode 100644 index 0000000000..cfb3a597a6 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.full.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.json b/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.json new file mode 100644 index 0000000000..045386f290 --- /dev/null +++ b/docs/benchmark/prd345/startup-p95-bd5d9d599/summary.json @@ -0,0 +1,47 @@ +{ + "pass": true, + "unchanged": true, + "arms": { + "before": { + "attempted": 40, + "failures": 0, + "navigationReadyMs": { + "count": 40, + "median": 2548.5499999970198, + "p95": 2664.5, + "maximum": 2949, + "method": "nearest rank ceil(0.95*n), one-based" + }, + "sceneLoadToReadyMs": { + "count": 40, + "median": 1965.7999999970198, + "p95": 2067.9000000059605, + "maximum": 2359.8999999910593, + "method": "nearest rank ceil(0.95*n), one-based" + } + }, + "after": { + "attempted": 40, + "failures": 0, + "navigationReadyMs": { + "count": 40, + "median": 2743.75, + "p95": 2905.5999999940395, + "maximum": 2935.4000000059605, + "method": "nearest rank ceil(0.95*n), one-based" + }, + "sceneLoadToReadyMs": { + "count": 40, + "median": 2148.950000010431, + "p95": 2289.6000000089407, + "maximum": 2353.4000000059605, + "method": "nearest rank ceil(0.95*n), one-based" + } + } + }, + "p95DeltaMs": 241.09999999403954, + "scope": "fresh Chromium process/context/page; engine ready timeline performance.now origin; load-to-ready also retained; no browser launch/lease/server timing", + "cache": "fresh page/HTTP cache; OS filesystem and GPU driver shader caches uncontrolled; NOT shader-cold", + "limitations": "40 samples/arm is empirical engineering evidence, not a precise population-tail estimate. Failures retained; successful-only statistics are unqualified if any failure occurs. No compileComplete or full restart readiness inferred.", + "fullReport": "summary.full.json.gz" +} diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture-session.json.gz new file mode 100644 index 0000000000..e908a45f7e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/001-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture-session.json.gz new file mode 100644 index 0000000000..9ea53d4f60 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/002-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture-session.json.gz new file mode 100644 index 0000000000..ff17e3438c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/003-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture-session.json.gz new file mode 100644 index 0000000000..4979f91c80 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/004-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture-session.json.gz new file mode 100644 index 0000000000..319209f413 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/005-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture-session.json.gz new file mode 100644 index 0000000000..82b6865d2c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/006-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture-session.json.gz new file mode 100644 index 0000000000..e16ded669a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/007-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture-session.json.gz new file mode 100644 index 0000000000..3a2203369f Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/008-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture-session.json.gz new file mode 100644 index 0000000000..f1ee92cd3c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/009-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture-session.json.gz new file mode 100644 index 0000000000..901be0bdac Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/010-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture-session.json.gz new file mode 100644 index 0000000000..232561881b Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/011-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture-session.json.gz new file mode 100644 index 0000000000..899ffc612e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/012-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture-session.json.gz new file mode 100644 index 0000000000..f445089292 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/013-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture-session.json.gz new file mode 100644 index 0000000000..082a7a66fb Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/014-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture-session.json.gz new file mode 100644 index 0000000000..139461184a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/015-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture-session.json.gz new file mode 100644 index 0000000000..4c4965fa1a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/016-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture-session.json.gz new file mode 100644 index 0000000000..cfa86ebf92 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/017-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture-session.json.gz new file mode 100644 index 0000000000..cfc56f1732 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/018-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture-session.json.gz new file mode 100644 index 0000000000..e0ae9b96bb Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/019-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture-session.json.gz new file mode 100644 index 0000000000..6a4365a9dc Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/020-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture-session.json.gz new file mode 100644 index 0000000000..6389018171 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/021-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture-session.json.gz new file mode 100644 index 0000000000..1f30ef7e57 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/022-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture-session.json.gz new file mode 100644 index 0000000000..5e0234a64a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/023-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture-session.json.gz new file mode 100644 index 0000000000..bc8dfb3e49 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/024-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture-session.json.gz new file mode 100644 index 0000000000..0755e691c9 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/025-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture-session.json.gz new file mode 100644 index 0000000000..f20e999f9d Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/026-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture-session.json.gz new file mode 100644 index 0000000000..5b65badcb8 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/027-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture-session.json.gz new file mode 100644 index 0000000000..37fae13ac4 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/028-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture-session.json.gz new file mode 100644 index 0000000000..f760734690 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/029-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture-session.json.gz new file mode 100644 index 0000000000..2dfb4f6306 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/030-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture-session.json.gz new file mode 100644 index 0000000000..322ce70312 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/031-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture-session.json.gz new file mode 100644 index 0000000000..d567e3e9b2 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/032-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture-session.json.gz new file mode 100644 index 0000000000..825b2605d7 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/033-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture-session.json.gz new file mode 100644 index 0000000000..5d5a0b2ced Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/034-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture-session.json.gz new file mode 100644 index 0000000000..39357ab3cf Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/035-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture-session.json.gz new file mode 100644 index 0000000000..e2ce594ba8 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/036-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture-session.json.gz new file mode 100644 index 0000000000..a7a8cc4b03 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/037-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture-session.json.gz new file mode 100644 index 0000000000..32bd210d31 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/038-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture-session.json.gz new file mode 100644 index 0000000000..510dc798a5 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/039-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture-session.json.gz new file mode 100644 index 0000000000..9775ccd160 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/040-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture-session.json.gz new file mode 100644 index 0000000000..2a64a21190 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/041-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture-session.json.gz new file mode 100644 index 0000000000..32dae2a7b2 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/042-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture-session.json.gz new file mode 100644 index 0000000000..6923ed72a4 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/043-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture-session.json.gz new file mode 100644 index 0000000000..79be1e7692 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/044-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture-session.json.gz new file mode 100644 index 0000000000..2b2f302faf Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/045-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture-session.json.gz new file mode 100644 index 0000000000..6d9c03e624 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/046-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture-session.json.gz new file mode 100644 index 0000000000..593c21d38e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/047-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture-session.json.gz new file mode 100644 index 0000000000..38ba9857aa Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/048-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture-session.json.gz new file mode 100644 index 0000000000..6b448689e0 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/049-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture-session.json.gz new file mode 100644 index 0000000000..b9b38c5b37 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/050-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture-session.json.gz new file mode 100644 index 0000000000..955ae92c1e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/051-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture-session.json.gz new file mode 100644 index 0000000000..edd33c3e87 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/052-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture-session.json.gz new file mode 100644 index 0000000000..39315fc52c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/053-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture-session.json.gz new file mode 100644 index 0000000000..4876f7b378 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/054-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture-session.json.gz new file mode 100644 index 0000000000..7d509110c3 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/055-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture-session.json.gz new file mode 100644 index 0000000000..8d92037be0 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/056-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture-session.json.gz new file mode 100644 index 0000000000..7f8a1d3947 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/057-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture-session.json.gz new file mode 100644 index 0000000000..a93c459084 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/058-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture-session.json.gz new file mode 100644 index 0000000000..b923715eca Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/059-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture-session.json.gz new file mode 100644 index 0000000000..3df1875af9 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/060-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture-session.json.gz new file mode 100644 index 0000000000..235db797ad Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/061-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture-session.json.gz new file mode 100644 index 0000000000..a3c2111201 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/062-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture-session.json.gz new file mode 100644 index 0000000000..cf185b0153 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/063-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture-session.json.gz new file mode 100644 index 0000000000..b2a48901c1 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/064-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture-session.json.gz new file mode 100644 index 0000000000..7f5961ff5e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/065-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture-session.json.gz new file mode 100644 index 0000000000..b9502c0739 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/066-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture-session.json.gz new file mode 100644 index 0000000000..d65b784e4a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/067-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture-session.json.gz new file mode 100644 index 0000000000..2938df9773 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/068-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture-session.json.gz new file mode 100644 index 0000000000..852b067937 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/069-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture-session.json.gz new file mode 100644 index 0000000000..12da46011d Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/070-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture-session.json.gz new file mode 100644 index 0000000000..5f9ddbc26f Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/071-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture-session.json.gz new file mode 100644 index 0000000000..7a25357ab9 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/072-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture-session.json.gz new file mode 100644 index 0000000000..232d171d31 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/073-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture-session.json.gz new file mode 100644 index 0000000000..2a58bae706 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/074-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture-session.json.gz new file mode 100644 index 0000000000..5b3dc06fa4 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/075-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture-session.json.gz new file mode 100644 index 0000000000..b7809043f0 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/076-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture-session.json.gz new file mode 100644 index 0000000000..8bd494bf3a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/077-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture-session.json.gz new file mode 100644 index 0000000000..ccc644988a Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/078-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture-session.json.gz new file mode 100644 index 0000000000..01735b4484 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/079-after/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture-session.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture-session.json.gz new file mode 100644 index 0000000000..8a39d88e3e Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture-session.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture.json.gz new file mode 100644 index 0000000000..908337fe8c Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/080-before/capture.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/README.md b/docs/benchmark/prd345/startup-p95-dca2d8d4b/README.md new file mode 100644 index 0000000000..9d5c873389 --- /dev/null +++ b/docs/benchmark/prd345/startup-p95-dca2d8d4b/README.md @@ -0,0 +1,7 @@ +# Startup P95 Dca2D8D4B + +Runtime checkpoint dca2d8d4b, unchanged generated source trees: 80/80 fresh Chromium launches pass, 40 per arm in ABBA order. Ready p95 2649.7→2831.0 ms (+181.3 ms, 6.84%); median 2560.3→2760.0 ms. All samples and frozen manifests retained. Fresh process/context/page and HTTP cache; OS/filesystem and driver shader caches uncontrolled. Not shader-cold, compile-complete or a precise population-tail estimate. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/frozen-trees.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/frozen-trees.json.gz new file mode 100644 index 0000000000..d99147ef91 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/frozen-trees.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/samples.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/samples.json.gz new file mode 100644 index 0000000000..ac69dd6bb2 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/samples.json.gz differ diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json b/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json new file mode 100644 index 0000000000..a895103f7f --- /dev/null +++ b/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json @@ -0,0 +1,46 @@ +{ + "pass": true, + "unchanged": true, + "arms": { + "before": { + "attempted": 40, + "failures": 0, + "navigationReadyMs": { + "count": 40, + "median": 2560.2999999970198, + "p95": 2649.699999988079, + "maximum": 7679.5, + "method": "nearest rank ceil(0.95*n), one-based" + }, + "sceneLoadToReadyMs": { + "count": 40, + "median": 1965.6500000059605, + "p95": 2036.8999999910593, + "maximum": 2297.4000000059605, + "method": "nearest rank ceil(0.95*n), one-based" + } + }, + "after": { + "attempted": 40, + "failures": 0, + "navigationReadyMs": { + "count": 40, + "median": 2760, + "p95": 2831, + "maximum": 8064.79999999702, + "method": "nearest rank ceil(0.95*n), one-based" + }, + "sceneLoadToReadyMs": { + "count": 40, + "median": 2160.2499999925494, + "p95": 2241.7000000029802, + "maximum": 2247.9000000059605, + "method": "nearest rank ceil(0.95*n), one-based" + } + } + }, + "p95DeltaMs": 181.30000001192093, + "scope": "fresh Chromium process/context/page; engine ready timeline performance.now origin; load-to-ready also retained; no browser launch/lease/server timing", + "cache": "fresh page/HTTP cache; OS filesystem and GPU driver shader caches uncontrolled; NOT shader-cold", + "limitations": "40 samples/arm is empirical engineering evidence, not a precise population-tail estimate. Failures retained; successful-only statistics are unqualified if any failure occurs. No compileComplete or full restart readiness inferred." +} diff --git a/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json.gz b/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json.gz new file mode 100644 index 0000000000..1c8b01b544 Binary files /dev/null and b/docs/benchmark/prd345/startup-p95-dca2d8d4b/summary.json.gz differ diff --git a/docs/benchmark/prd345/suite-temp-parent/README.md b/docs/benchmark/prd345/suite-temp-parent/README.md new file mode 100644 index 0000000000..92f2896faa --- /dev/null +++ b/docs/benchmark/prd345/suite-temp-parent/README.md @@ -0,0 +1,17 @@ +# Durable suite temporary parent + +The full verification script now honors explicit `TMPDIR` with the existing `/tmp` default. +Actual allocation/cleanup regression was RED when the script ignored the requested parent. +A second RED exposed continued execution after failed allocation under `set -u` without `-e`. +Directory allocation now fails closed; marker allocation failure removes only its own newly +created empty directory and preserves the failure status. No checks, phase ordering, filters, +permissions or successful cleanup behavior changed. + +Three focused/CI partition specs pass 142 tests. Behavioral tests use a space-bearing parent, +verify exported temporary location and unrelated sentinel preservation, and cover directory +and marker failures. Shell syntax and formatting pass. Independent source review reports no +remaining finding. These are harness tests, not a completed full-root gate. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/suite-temp-parent/backlight-suite-allocation-red.log.gz b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-allocation-red.log.gz new file mode 100644 index 0000000000..8556e46720 Binary files /dev/null and b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-allocation-red.log.gz differ diff --git a/docs/benchmark/prd345/suite-temp-parent/backlight-suite-temp-final-green.log.gz b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-temp-final-green.log.gz new file mode 100644 index 0000000000..c2d599286e Binary files /dev/null and b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-temp-final-green.log.gz differ diff --git a/docs/benchmark/prd345/suite-temp-parent/backlight-suite-tmpdir-red.log.gz b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-tmpdir-red.log.gz new file mode 100644 index 0000000000..58632de3b8 Binary files /dev/null and b/docs/benchmark/prd345/suite-temp-parent/backlight-suite-tmpdir-red.log.gz differ diff --git a/docs/benchmark/prd345/template-matrix/README.md b/docs/benchmark/prd345/template-matrix/README.md new file mode 100644 index 0000000000..eda397a014 --- /dev/null +++ b/docs/benchmark/prd345/template-matrix/README.md @@ -0,0 +1,50 @@ +# PRD-345 generated template comparisons + +These are full, unscaled 1280×720 PNGs from actual generated projects. Each pair uses the +same cooked assets, default scene/camera, fixed tick 60, NVIDIA Turing hardware WebGPU and +4× MSAA. The reports retain source manifests, observed camera/backend/buffer settings, +readiness capture session, runtime snapshot and adapter provenance. The before source is +our isolated checkpoint `8cb5d3e8b`; candidate source is the captured plural-template +working tree recorded in each manifest. Later changes require fresh qualification. + +The driver uses a bounded custom boot/capture scenario. Successful readiness capture is +not a full template gameplay/playtest verdict, native qualification, or proof that every +pipeline has finished compiling. `matrix1.full.json.gz`, `matrix2.full.json.gz` and `matrix3.full.json.gz` retain +failed attempts as well as successful retries. Asset-worker/WASM failures during a +concurrent local dependency rebuild were retained and retried after dependencies froze. + +The default opening views are deliberately modest: bright photographed IBL suppresses +additional analytic fill. Backlit and black-environment positive/negative acceptance +controls are separately retained under `docs/verification/prd345`. Authored puzzle and +tower-defense fill/rim defaults remain zero to preserve their explicit lights; runner and +snow extra fill remains zero. RTS custom node terrain/water and snow custom surfaces stay +original. Flag or smoke phases can vary with wall time despite the same gameplay clock; +those differences are not claimed as improvements or pixel-identical controls. + +| Template | Before | After | +| --- | --- | --- | +| minimal | ![Before](minimal/before.png) | ![After](minimal/after.png) | +| starter | ![Before](starter/before.png) | ![After](starter/after.png) | +| platformer | ![Before](platformer/before.png) | ![After](platformer/after.png) | +| runner | ![Before](runner/before.png) | ![After](runner/after.png) | +| shooter | ![Before](shooter/before.png) | ![After](shooter/after.png) | +| snow | ![Before](snow/before.png) | ![After](snow/after.png) | +| action-rpg | ![Before](action-rpg/before.png) | ![After](action-rpg/after.png) | +| rts | ![Before](rts/before.png) | ![After](rts/after.png) | +| tower-defense | ![Before](tower-defense/before.png) | ![After](tower-defense/after.png) | +| puzzle | ![Before](puzzle/before.png) | ![After](puzzle/after.png) | +| sailing | ![Before](sailing/before.png) | ![After](sailing/after.png) | + +Racing is not admitted: its successful capture shows broad road/shadow differences that +persist with zero rim and original materials restored later. Fresh startup controls +show the delta follows node-material admission, not GPU environment sampling. Its causal +investigation and all original frames remain under the task capture artifacts. +Puzzle and sailing pass fresh strict readiness retries after dependencies froze; the original +failures and doctor results remain retained. Doctor checks alone were not treated as acceptance. +Rain uses a custom raymarched shader, so standard-material conversion does not apply and +cloud radiance remains unknown. Native/mobile/software/WebGL keep original materials; +those fallback policies are covered by CPU checks, with native execution still open. + +## Historical capture coordination limitation + +A reported `held` capture lease establishes ownership within that process's temporary namespace. `defaultCaptureLockRoot()` derives its path from `TMPDIR`; durable-TMPDIR gameplay/native-opening and matched performance runs used private namespaces. We manually serialized our own jobs, but shared global GPU exclusion and external workload isolation were not established for those historical runs. Their measured outcomes remain retained with this limitation; they must not be described as globally exclusive qualification. Future timing runs use an explicit outer existing-API shared `/tmp/threenative-playtest-capture` lease and durable child temps. diff --git a/docs/benchmark/prd345/template-matrix/action-rpg/after-result.json.gz b/docs/benchmark/prd345/template-matrix/action-rpg/after-result.json.gz new file mode 100644 index 0000000000..4dde041e37 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/action-rpg/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/action-rpg/after.png b/docs/benchmark/prd345/template-matrix/action-rpg/after.png new file mode 100644 index 0000000000..0df989539d Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/action-rpg/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/action-rpg/before-result.json.gz b/docs/benchmark/prd345/template-matrix/action-rpg/before-result.json.gz new file mode 100644 index 0000000000..32354d76e0 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/action-rpg/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/action-rpg/before.png b/docs/benchmark/prd345/template-matrix/action-rpg/before.png new file mode 100644 index 0000000000..c5b75c1e3b Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/action-rpg/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/matrix1.full.json.gz b/docs/benchmark/prd345/template-matrix/matrix1.full.json.gz new file mode 100644 index 0000000000..b22d4b0af3 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/matrix1.full.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/matrix2.full.json.gz b/docs/benchmark/prd345/template-matrix/matrix2.full.json.gz new file mode 100644 index 0000000000..8bacb119fc Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/matrix2.full.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/matrix3.full.json.gz b/docs/benchmark/prd345/template-matrix/matrix3.full.json.gz new file mode 100644 index 0000000000..78e96c69a0 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/matrix3.full.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/minimal/after-result.json.gz b/docs/benchmark/prd345/template-matrix/minimal/after-result.json.gz new file mode 100644 index 0000000000..a51179c796 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/minimal/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/minimal/after.png b/docs/benchmark/prd345/template-matrix/minimal/after.png new file mode 100644 index 0000000000..4c8c06cbd1 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/minimal/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/minimal/before-result.json.gz b/docs/benchmark/prd345/template-matrix/minimal/before-result.json.gz new file mode 100644 index 0000000000..4951e551a2 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/minimal/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/minimal/before.png b/docs/benchmark/prd345/template-matrix/minimal/before.png new file mode 100644 index 0000000000..4a786aa04d Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/minimal/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/platformer/after-result.json.gz b/docs/benchmark/prd345/template-matrix/platformer/after-result.json.gz new file mode 100644 index 0000000000..5bf4d8fae0 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/platformer/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/platformer/after.png b/docs/benchmark/prd345/template-matrix/platformer/after.png new file mode 100644 index 0000000000..ba7309fb8e Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/platformer/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/platformer/before-result.json.gz b/docs/benchmark/prd345/template-matrix/platformer/before-result.json.gz new file mode 100644 index 0000000000..54b2f4f1ba Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/platformer/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/platformer/before.png b/docs/benchmark/prd345/template-matrix/platformer/before.png new file mode 100644 index 0000000000..4cc7bb0f8f Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/platformer/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/puzzle/after-result.json.gz b/docs/benchmark/prd345/template-matrix/puzzle/after-result.json.gz new file mode 100644 index 0000000000..7839515d05 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/puzzle/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/puzzle/after.png b/docs/benchmark/prd345/template-matrix/puzzle/after.png new file mode 100644 index 0000000000..2b891ffade Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/puzzle/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/puzzle/before-result.json.gz b/docs/benchmark/prd345/template-matrix/puzzle/before-result.json.gz new file mode 100644 index 0000000000..2903a8a50d Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/puzzle/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/puzzle/before.png b/docs/benchmark/prd345/template-matrix/puzzle/before.png new file mode 100644 index 0000000000..15f5acbc94 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/puzzle/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/rts/after-result.json.gz b/docs/benchmark/prd345/template-matrix/rts/after-result.json.gz new file mode 100644 index 0000000000..1b52b1b77b Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/rts/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/rts/after.png b/docs/benchmark/prd345/template-matrix/rts/after.png new file mode 100644 index 0000000000..79204fd067 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/rts/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/rts/before-result.json.gz b/docs/benchmark/prd345/template-matrix/rts/before-result.json.gz new file mode 100644 index 0000000000..8023140cbd Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/rts/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/rts/before.png b/docs/benchmark/prd345/template-matrix/rts/before.png new file mode 100644 index 0000000000..8c03ace5d7 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/rts/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/runner/after-result.json.gz b/docs/benchmark/prd345/template-matrix/runner/after-result.json.gz new file mode 100644 index 0000000000..196ba79976 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/runner/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/runner/after.png b/docs/benchmark/prd345/template-matrix/runner/after.png new file mode 100644 index 0000000000..ac85b05b35 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/runner/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/runner/before-result.json.gz b/docs/benchmark/prd345/template-matrix/runner/before-result.json.gz new file mode 100644 index 0000000000..9434bea9e2 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/runner/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/runner/before.png b/docs/benchmark/prd345/template-matrix/runner/before.png new file mode 100644 index 0000000000..7a693cfe85 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/runner/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/sailing/after-result.json.gz b/docs/benchmark/prd345/template-matrix/sailing/after-result.json.gz new file mode 100644 index 0000000000..1f43f87b30 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/sailing/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/sailing/after.png b/docs/benchmark/prd345/template-matrix/sailing/after.png new file mode 100644 index 0000000000..ac911a5f8c Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/sailing/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/sailing/before-result.json.gz b/docs/benchmark/prd345/template-matrix/sailing/before-result.json.gz new file mode 100644 index 0000000000..b29e19a7bf Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/sailing/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/sailing/before.png b/docs/benchmark/prd345/template-matrix/sailing/before.png new file mode 100644 index 0000000000..af5f3961d9 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/sailing/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/shooter/after-result.json.gz b/docs/benchmark/prd345/template-matrix/shooter/after-result.json.gz new file mode 100644 index 0000000000..8e9d52dc6c Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/shooter/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/shooter/after.png b/docs/benchmark/prd345/template-matrix/shooter/after.png new file mode 100644 index 0000000000..421293e625 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/shooter/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/shooter/before-result.json.gz b/docs/benchmark/prd345/template-matrix/shooter/before-result.json.gz new file mode 100644 index 0000000000..cdf682cd6d Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/shooter/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/shooter/before.png b/docs/benchmark/prd345/template-matrix/shooter/before.png new file mode 100644 index 0000000000..1cd39f7e2e Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/shooter/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/snow/after-result.json.gz b/docs/benchmark/prd345/template-matrix/snow/after-result.json.gz new file mode 100644 index 0000000000..655dc44e4f Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/snow/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/snow/after.png b/docs/benchmark/prd345/template-matrix/snow/after.png new file mode 100644 index 0000000000..6b4dc5edcc Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/snow/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/snow/before-result.json.gz b/docs/benchmark/prd345/template-matrix/snow/before-result.json.gz new file mode 100644 index 0000000000..632db6cbfd Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/snow/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/snow/before.png b/docs/benchmark/prd345/template-matrix/snow/before.png new file mode 100644 index 0000000000..66e9864dcd Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/snow/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/starter/after-result.json.gz b/docs/benchmark/prd345/template-matrix/starter/after-result.json.gz new file mode 100644 index 0000000000..930b2e0b57 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/starter/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/starter/after.png b/docs/benchmark/prd345/template-matrix/starter/after.png new file mode 100644 index 0000000000..3fa1d5fd73 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/starter/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/starter/before-result.json.gz b/docs/benchmark/prd345/template-matrix/starter/before-result.json.gz new file mode 100644 index 0000000000..b3b85b7077 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/starter/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/starter/before.png b/docs/benchmark/prd345/template-matrix/starter/before.png new file mode 100644 index 0000000000..898b7631ef Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/starter/before.png differ diff --git a/docs/benchmark/prd345/template-matrix/tower-defense/after-result.json.gz b/docs/benchmark/prd345/template-matrix/tower-defense/after-result.json.gz new file mode 100644 index 0000000000..37a134412b Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/tower-defense/after-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/tower-defense/after.png b/docs/benchmark/prd345/template-matrix/tower-defense/after.png new file mode 100644 index 0000000000..6b3c5497af Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/tower-defense/after.png differ diff --git a/docs/benchmark/prd345/template-matrix/tower-defense/before-result.json.gz b/docs/benchmark/prd345/template-matrix/tower-defense/before-result.json.gz new file mode 100644 index 0000000000..647999eacc Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/tower-defense/before-result.json.gz differ diff --git a/docs/benchmark/prd345/template-matrix/tower-defense/before.png b/docs/benchmark/prd345/template-matrix/tower-defense/before.png new file mode 100644 index 0000000000..ce3881d1a8 Binary files /dev/null and b/docs/benchmark/prd345/template-matrix/tower-defense/before.png differ diff --git a/docs/verification/prd345/actual-starter-backlit-after.webp b/docs/verification/prd345/actual-starter-backlit-after.webp new file mode 100644 index 0000000000..678a472a7c Binary files /dev/null and b/docs/verification/prd345/actual-starter-backlit-after.webp differ diff --git a/docs/verification/prd345/actual-starter-backlit-before.webp b/docs/verification/prd345/actual-starter-backlit-before.webp new file mode 100644 index 0000000000..1d612f04e0 Binary files /dev/null and b/docs/verification/prd345/actual-starter-backlit-before.webp differ diff --git a/docs/verification/prd345/actual-starter-proof.json.gz b/docs/verification/prd345/actual-starter-proof.json.gz new file mode 100644 index 0000000000..7dbca0454f Binary files /dev/null and b/docs/verification/prd345/actual-starter-proof.json.gz differ diff --git a/docs/verification/prd345/cached-restart.json.gz b/docs/verification/prd345/cached-restart.json.gz new file mode 100644 index 0000000000..eaf1726290 Binary files /dev/null and b/docs/verification/prd345/cached-restart.json.gz differ diff --git a/docs/verification/prd345/edge-enabled-acceptance1/report.json.gz b/docs/verification/prd345/edge-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..0782e84cfb Binary files /dev/null and b/docs/verification/prd345/edge-enabled-acceptance1/report.json.gz differ diff --git a/docs/verification/prd345/edge-rim-zero-acceptance1/report.json.gz b/docs/verification/prd345/edge-rim-zero-acceptance1/report.json.gz new file mode 100644 index 0000000000..a93f7d4771 Binary files /dev/null and b/docs/verification/prd345/edge-rim-zero-acceptance1/report.json.gz differ diff --git a/docs/verification/prd345/fill-black.png b/docs/verification/prd345/fill-black.png new file mode 100644 index 0000000000..6acd96e66c Binary files /dev/null and b/docs/verification/prd345/fill-black.png differ diff --git a/docs/verification/prd345/fill-enabled-acceptance1/report.json.gz b/docs/verification/prd345/fill-enabled-acceptance1/report.json.gz new file mode 100644 index 0000000000..eb3e110809 Binary files /dev/null and b/docs/verification/prd345/fill-enabled-acceptance1/report.json.gz differ diff --git a/docs/verification/prd345/fill-enabled.png b/docs/verification/prd345/fill-enabled.png new file mode 100644 index 0000000000..e66aabfb0c Binary files /dev/null and b/docs/verification/prd345/fill-enabled.png differ diff --git a/docs/verification/prd345/fill-fill-black-acceptance1/report.json.gz b/docs/verification/prd345/fill-fill-black-acceptance1/report.json.gz new file mode 100644 index 0000000000..ff02d66352 Binary files /dev/null and b/docs/verification/prd345/fill-fill-black-acceptance1/report.json.gz differ diff --git a/docs/verification/prd345/live-backlit-baseline/windows.json.gz b/docs/verification/prd345/live-backlit-baseline/windows.json.gz new file mode 100644 index 0000000000..366fd547c5 Binary files /dev/null and b/docs/verification/prd345/live-backlit-baseline/windows.json.gz differ diff --git a/docs/verification/prd345/live-backlit-enabled/windows.json.gz b/docs/verification/prd345/live-backlit-enabled/windows.json.gz new file mode 100644 index 0000000000..f195cee165 Binary files /dev/null and b/docs/verification/prd345/live-backlit-enabled/windows.json.gz differ diff --git a/docs/verification/prd345/marker-omitted-proof1/report.json.gz b/docs/verification/prd345/marker-omitted-proof1/report.json.gz new file mode 100644 index 0000000000..3e7892aed2 Binary files /dev/null and b/docs/verification/prd345/marker-omitted-proof1/report.json.gz differ diff --git a/docs/verification/prd345/marker-positive-proof1/report.json.gz b/docs/verification/prd345/marker-positive-proof1/report.json.gz new file mode 100644 index 0000000000..22bfd9498a Binary files /dev/null and b/docs/verification/prd345/marker-positive-proof1/report.json.gz differ diff --git a/docs/verification/prd345/qualification.json b/docs/verification/prd345/qualification.json new file mode 100644 index 0000000000..5e4f9e51cf --- /dev/null +++ b/docs/verification/prd345/qualification.json @@ -0,0 +1,463 @@ +{ + "pass": true, + "rimMeasurements": [ + { + "pose": "pose-end", + "measured": true, + "method": "paired actual decoded PNG p99 edge/body difference; fixed scene/camera/pose; only rimGain differs", + "availableEdgeP99Delta": 45, + "availableBodyP99Delta": 2, + "availableMarginDelta": 43, + "enabled": { + "edgeP99": 52, + "bodyP99": 2 + }, + "zeroOverride": { + "edgeP99": 7, + "bodyP99": 0 + } + }, + { + "pose": "pose-rest", + "measured": true, + "method": "paired actual decoded PNG p99 edge/body difference; fixed scene/camera/pose; only rimGain differs", + "availableEdgeP99Delta": 45, + "availableBodyP99Delta": 2, + "availableMarginDelta": 43, + "enabled": { + "edgeP99": 52, + "bodyP99": 2 + }, + "zeroOverride": { + "edgeP99": 7, + "bodyP99": 0 + } + } + ], + "scope": "isolated actual starter character/material fixture; no shipped defaults or per-template/native admission", + "crops": { + "coordinateSpace": "decoded PNG physical pixels, top-left origin; no HUD mask/DPR/rescale", + "selection": "Exploratory end-pose image inspection identified interior calf edge and chest. Bounds and thresholds frozen before fresh assertion-bearing positive/negative captures. Mid pose remains separate visual witness; these end-pose crops are not claimed for every pose.", + "edge": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "body": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "edgeMargin": 30, + "fillP1Max": 5, + "fillP99Min": 30, + "negativeControls": ["rimGain-zero", "black-fill", "omitted-report"] + }, + "cases": [ + { + "name": "edge-enabled", + "pass": true, + "expectedFailures": [], + "marker": { + "status": "measured", + "meanRadiance": 0, + "meanRGB": [0, 0, 0], + "reason": "Solid-angle-weighted linear source radiance, with active environment intensity.", + "scope": "scene environment source availability; not material-specific reflected contribution", + "environmentState": "dark", + "ibl": "non-contributing", + "analyticFill": { + "requestedGain": 1, + "admitted": false, + "effectiveGain": 0, + "color": [0.13286832154414627, 0.19806931954941637, 0.3371636150376657], + "blackColorOverride": false, + "reason": "not admitted by this qualification" + }, + "rimGain": 0.12, + "fillGain": 1, + "darkThreshold": 0.001 + }, + "rows": [ + { + "pose": "pose-end", + "metrics": { + "mean": 7.945578231292517, + "p1": 0, + "p50": 0, + "p99": 52, + "clipFraction": 0, + "blackFraction": 0.564625850340136 + }, + "reference": { + "mean": 0.08928571428571429, + "p1": 0, + "p50": 0, + "p99": 2, + "clipFraction": 0, + "blackFraction": 0.9258241758241759 + } + }, + { + "pose": "pose-rest", + "metrics": { + "mean": 7.945578231292517, + "p1": 0, + "p50": 0, + "p99": 52, + "clipFraction": 0, + "blackFraction": 0.564625850340136 + }, + "reference": { + "mean": 0.08928571428571429, + "p1": 0, + "p50": 0, + "p99": 2, + "clipFraction": 0, + "blackFraction": 0.9258241758241759 + } + } + ], + "provenance": { + "arm": { + "before": "enabled", + "after": "enabled" + }, + "shot": { + "before": "backlit-black-ibl", + "after": "backlit-black-ibl" + }, + "animationTicks": { + "before": 0, + "after": 90 + }, + "animationSeconds": { + "before": 0, + "after": 1.5 + }, + "convertedMaterials": { + "before": 5, + "after": 5 + }, + "excluded": { + "before": [], + "after": [] + }, + "environmentState": { + "before": "dark", + "after": "dark" + }, + "bufferWidth": { + "before": 1280, + "after": 1280 + }, + "bufferHeight": { + "before": 720, + "after": 720 + }, + "cameraPosition": { + "before": [0, 1.35, 6], + "after": [0, 1.35, 6] + } + } + }, + { + "name": "edge-rim-zero", + "pass": false, + "expectedFailures": ["tone.0.compare", "tone.1.compare"], + "marker": { + "status": "measured", + "meanRadiance": 0, + "meanRGB": [0, 0, 0], + "reason": "Solid-angle-weighted linear source radiance, with active environment intensity.", + "scope": "scene environment source availability; not material-specific reflected contribution", + "environmentState": "dark", + "ibl": "non-contributing", + "analyticFill": { + "requestedGain": 1, + "admitted": false, + "effectiveGain": 0, + "color": [0.13286832154414627, 0.19806931954941637, 0.3371636150376657], + "blackColorOverride": false, + "reason": "not admitted by this qualification" + }, + "rimGain": 0, + "fillGain": 1, + "darkThreshold": 0.001 + }, + "rows": [ + { + "pose": "pose-end", + "metrics": { + "mean": 0.22448979591836735, + "p1": 0, + "p50": 0, + "p99": 7, + "clipFraction": 0, + "blackFraction": 0.9863945578231292 + }, + "reference": { + "mean": 0, + "p1": 0, + "p50": 0, + "p99": 0, + "clipFraction": 0, + "blackFraction": 1 + } + }, + { + "pose": "pose-rest", + "metrics": { + "mean": 0.22448979591836735, + "p1": 0, + "p50": 0, + "p99": 7, + "clipFraction": 0, + "blackFraction": 0.9863945578231292 + }, + "reference": { + "mean": 0, + "p1": 0, + "p50": 0, + "p99": 0, + "clipFraction": 0, + "blackFraction": 1 + } + } + ], + "provenance": { + "arm": { + "before": "rim-zero", + "after": "rim-zero" + }, + "shot": { + "before": "backlit-black-ibl", + "after": "backlit-black-ibl" + }, + "animationTicks": { + "before": 0, + "after": 90 + }, + "animationSeconds": { + "before": 0, + "after": 1.5 + }, + "convertedMaterials": { + "before": 5, + "after": 5 + }, + "excluded": { + "before": [], + "after": [] + }, + "environmentState": { + "before": "dark", + "after": "dark" + }, + "bufferWidth": { + "before": 1280, + "after": 1280 + }, + "bufferHeight": { + "before": 720, + "after": 720 + }, + "cameraPosition": { + "before": [0, 1.35, 6], + "after": [0, 1.35, 6] + } + } + }, + { + "name": "fill-enabled", + "pass": true, + "expectedFailures": [], + "marker": { + "status": "measured", + "meanRadiance": 0, + "meanRGB": [0, 0, 0], + "reason": "Solid-angle-weighted linear source radiance, with active environment intensity.", + "scope": "scene environment source availability; not material-specific reflected contribution", + "environmentState": "dark", + "ibl": "non-contributing", + "analyticFill": { + "requestedGain": 1, + "admitted": true, + "effectiveGain": 1, + "color": [0.13286832154414627, 0.19806931954941637, 0.3371636150376657], + "blackColorOverride": false, + "reason": "analytic fill enabled by caller; contribution still needs pixel qualification" + }, + "rimGain": 0.12, + "fillGain": 1, + "darkThreshold": 0.001 + }, + "rows": [ + { + "pose": "pose-end", + "metrics": { + "mean": 27.324175824175825, + "p1": 0, + "p50": 32, + "p99": 43, + "clipFraction": 0, + "blackFraction": 0.12087912087912088 + } + }, + { + "pose": "pose-rest", + "metrics": { + "mean": 27.324175824175825, + "p1": 0, + "p50": 32, + "p99": 43, + "clipFraction": 0, + "blackFraction": 0.12087912087912088 + } + } + ], + "provenance": { + "arm": { + "before": "enabled", + "after": "enabled" + }, + "shot": { + "before": "dark", + "after": "dark" + }, + "animationTicks": { + "before": 0, + "after": 90 + }, + "animationSeconds": { + "before": 0, + "after": 1.5 + }, + "convertedMaterials": { + "before": 5, + "after": 5 + }, + "excluded": { + "before": [], + "after": [] + }, + "environmentState": { + "before": "dark", + "after": "dark" + }, + "bufferWidth": { + "before": 1280, + "after": 1280 + }, + "bufferHeight": { + "before": 720, + "after": 720 + }, + "cameraPosition": { + "before": [0, 1.35, 6], + "after": [0, 1.35, 6] + } + } + }, + { + "name": "fill-fill-black", + "pass": false, + "expectedFailures": ["tone.0.p99", "tone.1.p99"], + "marker": { + "status": "measured", + "meanRadiance": 0, + "meanRGB": [0, 0, 0], + "reason": "Solid-angle-weighted linear source radiance, with active environment intensity.", + "scope": "scene environment source availability; not material-specific reflected contribution", + "environmentState": "dark", + "ibl": "non-contributing", + "analyticFill": { + "requestedGain": 1, + "admitted": true, + "effectiveGain": 1, + "color": [0, 0, 0], + "blackColorOverride": true, + "reason": "disabled by black fillColor override" + }, + "rimGain": 0.12, + "fillGain": 1, + "darkThreshold": 0.001 + }, + "rows": [ + { + "pose": "pose-end", + "metrics": { + "mean": 0, + "p1": 0, + "p50": 0, + "p99": 0, + "clipFraction": 0, + "blackFraction": 1 + } + }, + { + "pose": "pose-rest", + "metrics": { + "mean": 0, + "p1": 0, + "p50": 0, + "p99": 0, + "clipFraction": 0, + "blackFraction": 1 + } + } + ], + "provenance": { + "arm": { + "before": "fill-black", + "after": "fill-black" + }, + "shot": { + "before": "dark", + "after": "dark" + }, + "animationTicks": { + "before": 0, + "after": 90 + }, + "animationSeconds": { + "before": 0, + "after": 1.5 + }, + "convertedMaterials": { + "before": 5, + "after": 5 + }, + "excluded": { + "before": [], + "after": [] + }, + "environmentState": { + "before": "dark", + "after": "dark" + }, + "bufferWidth": { + "before": 1280, + "after": 1280 + }, + "bufferHeight": { + "before": 720, + "after": 720 + }, + "cameraPosition": { + "before": [0, 1.35, 6], + "after": [0, 1.35, 6] + } + } + } + ], + "markerMutation": "TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE", + "limitations": [ + "photographed environment remains unknown on current CPU measurement path", + "native regional capture unimplemented and fails closed", + "all-template/default integration and clean generated starter gates pending", + "timing uses sparse real engine timestamps; private-display FPS and compilation completion unqualified" + ] +} diff --git a/docs/verification/prd345/rim-enabled.png b/docs/verification/prd345/rim-enabled.png new file mode 100644 index 0000000000..4a5487d61d Binary files /dev/null and b/docs/verification/prd345/rim-enabled.png differ diff --git a/docs/verification/prd345/rim-zero.png b/docs/verification/prd345/rim-zero.png new file mode 100644 index 0000000000..78a9f11460 Binary files /dev/null and b/docs/verification/prd345/rim-zero.png differ diff --git a/docs/verification/prd345/source-aliases.json b/docs/verification/prd345/source-aliases.json new file mode 100644 index 0000000000..e19729c58b --- /dev/null +++ b/docs/verification/prd345/source-aliases.json @@ -0,0 +1,58 @@ +{ + "edge-enabled": { + "pose-end": { + "file": "rim-enabled.png", + "decodedRGBA": "0bfaa5c2d1d243bade27b849c91691bed35147933698e91ef95ec2870b093d21", + "originalCapturePNG": "cb644b741f27de82bc1940224d955213f0426ee312202e549de8a36da48ef048", + "originalPath": "artifacts/backlight-defaults/edge-enabled-acceptance1/pose-end.png" + }, + "pose-rest": { + "file": "rim-enabled.png", + "decodedRGBA": "0bfaa5c2d1d243bade27b849c91691bed35147933698e91ef95ec2870b093d21", + "originalCapturePNG": "cb644b741f27de82bc1940224d955213f0426ee312202e549de8a36da48ef048", + "originalPath": "artifacts/backlight-defaults/edge-enabled-acceptance1/pose-rest.png" + } + }, + "edge-rim-zero": { + "pose-end": { + "file": "rim-zero.png", + "decodedRGBA": "266d617691ae4bb76a6e626b8085bf6ebfc42f3761433dfe1c1d14c5eae96990", + "originalCapturePNG": "b17f933a6213e55b6bc1dcb77d727117ed8cf8074f296062a2f11881c4b8fad5", + "originalPath": "artifacts/backlight-defaults/edge-rim-zero-acceptance1/pose-end.png" + }, + "pose-rest": { + "file": "rim-zero.png", + "decodedRGBA": "266d617691ae4bb76a6e626b8085bf6ebfc42f3761433dfe1c1d14c5eae96990", + "originalCapturePNG": "b17f933a6213e55b6bc1dcb77d727117ed8cf8074f296062a2f11881c4b8fad5", + "originalPath": "artifacts/backlight-defaults/edge-rim-zero-acceptance1/pose-rest.png" + } + }, + "fill-enabled": { + "pose-end": { + "file": "fill-enabled.png", + "decodedRGBA": "3551e3a2c46ee8e5654418bd1133108832ba96e6b0da145238d48e6c3de7fd69", + "originalCapturePNG": "5de30aa4f5ab319fa8f8c710bb03c6b42d1cb4480f2dc72a4e90048d75bdc081", + "originalPath": "artifacts/backlight-defaults/fill-enabled-acceptance1/pose-end.png" + }, + "pose-rest": { + "file": "fill-enabled.png", + "decodedRGBA": "3551e3a2c46ee8e5654418bd1133108832ba96e6b0da145238d48e6c3de7fd69", + "originalCapturePNG": "5de30aa4f5ab319fa8f8c710bb03c6b42d1cb4480f2dc72a4e90048d75bdc081", + "originalPath": "artifacts/backlight-defaults/fill-enabled-acceptance1/pose-rest.png" + } + }, + "fill-fill-black": { + "pose-end": { + "file": "fill-black.png", + "decodedRGBA": "887289ba0a91988fdc6f74353753113e6cf7cfd26005a03cd1486ec14dcb9241", + "originalCapturePNG": "9ee0ff3e1c9edd8d011bec966bd5d95fbf4973ffc242fd6e975ba6114eb7a289", + "originalPath": "artifacts/backlight-defaults/fill-fill-black-acceptance1/pose-end.png" + }, + "pose-rest": { + "file": "fill-black.png", + "decodedRGBA": "887289ba0a91988fdc6f74353753113e6cf7cfd26005a03cd1486ec14dcb9241", + "originalCapturePNG": "9ee0ff3e1c9edd8d011bec966bd5d95fbf4973ffc242fd6e975ba6114eb7a289", + "originalPath": "artifacts/backlight-defaults/fill-fill-black-acceptance1/pose-rest.png" + } + } +} diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index a6686b28b8..40c18f633b 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -4793,6 +4793,20 @@ "supersedes": [], "symbol": "CaptureGuardError" }, + { + "aliases": [], + "constraints": ["does not acquire another screenshot or alter frame timing"], + "example": "collectRegionalTone(png, assertions, \"character\", \"posed\");", + "importPath": "@threenative/playtest/capture", + "kind": "function", + "overrides": [], + "package": "@threenative/playtest", + "signature": "export function collectRegionalTone( png: Buffer, assertions: readonly IPlaytestToneAssertion[], label: string, atStep?: string, ): IPlaytestRegionalToneObservation[] { … }", + "situations": ["measure a specified pixel crop in a captured playtest frame"], + "summary": "Collect opt-in regional tone observations from the same acquired PNG.", + "supersedes": [], + "symbol": "collectRegionalTone" + }, { "aliases": [], "constraints": [], diff --git a/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts b/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts new file mode 100644 index 0000000000..0c59399093 --- /dev/null +++ b/packages/create-threenative/__tests__/backlight-material-lifecycle.spec.ts @@ -0,0 +1,509 @@ +import { + BoxGeometry, + Camera, + Color, + DirectionalLight, + EquirectangularReflectionMapping, + type Material, + Mesh, + MeshBasicMaterial, + MeshPhysicalMaterial, + MeshStandardMaterial, + Scene, + 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 }; +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +function world() { + const scene = new Scene(); + const camera = new Camera(); + const key = new DirectionalLight(0xfff1e0, 4.5); + scene.add(key); + return { scene, camera, key }; +} +test("actual mesh arrays preserve sharing, maps, emissive and unsupported material slots", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const map = new Texture(); + const source = new MeshStandardMaterial({ + map, + emissive: 0x123456, + emissiveIntensity: 0.7, + transparent: true, + opacity: 0.4, + }); + const physical = new MeshPhysicalMaterial(); + const custom = new MeshStandardMaterial(); + custom.onBeforeCompile = () => {}; + const basic = new MeshBasicMaterial(); + const original = [source, physical, custom, basic]; + const mesh = new Mesh(new BoxGeometry(), original); + const shared = new Mesh(new BoxGeometry(), source); + scene.add(mesh, shared); + const sourceDispose = vi.spyOn(source, "dispose"); + const textureDispose = vi.spyOn(map, "dispose"); + const controller = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + expect(mesh.material).not.toBe(original); + const converted = shared.material as MeshStandardMaterial; + expect((mesh.material as Material[])[0]).toBe(converted); + expect((mesh.material as Material[]).slice(1)).toEqual([physical, custom, basic]); + expect(converted.map).toBe(map); + expect(converted.emissive.equals(source.emissive)).toBe(true); + expect(converted.emissiveIntensity).toBe(0.7); + expect(converted.opacity).toBe(0.4); + expect(converted.transparent).toBe(true); + expect(controller.debug()).toMatchObject({ convertedMaterials: 1, excludedSlots: 3 }); + const convertedDispose = vi.spyOn(converted, "dispose"); + controller.setEnabled(false); + expect(mesh.material).toBe(original); + expect(shared.material).toBe(source); + expect(convertedDispose).not.toHaveBeenCalled(); + controller.setEnabled(true); + expect(shared.material).toBe(converted); + controller.dispose(); + controller.dispose(); + expect(mesh.material).toBe(original); + expect(shared.material).toBe(source); + expect(convertedDispose).toHaveBeenCalledTimes(1); + expect(sourceDispose).not.toHaveBeenCalled(); + expect(textureDispose).not.toHaveBeenCalled(); +}); +test.each([ + { ...desktop, web: false }, + { ...desktop, mobile: true }, + { ...desktop, software: true }, + { ...desktop, rendererKind: "webgl" }, +])("platform fallback keeps originals even when enabled requested %j", (environment) => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const source = new MeshStandardMaterial(); + const mesh = new Mesh(new BoxGeometry(), source); + scene.add(mesh); + const c = createMaterialLighting(scene, camera, key, { ...environment, enabled: true }); + expect(mesh.material).toBe(source); + c.setEnabled(true); + expect(mesh.material).toBe(source); + expect(c.debug()).toMatchObject({ enabled: false, convertedMaterials: 0 }); + c.dispose(); +}); +test("quality gate only admits high web hardware desktop, including pinned fallback", () => { + expect(materialLightingEnabled("high", desktop)).toBe(true); + for (const tier of ["low", "medium"] as const) + expect(materialLightingEnabled(tier, desktop)).toBe(false); + expect(materialLightingEnabled("high", { ...desktop, software: true })).toBe(false); +}); +test("borrowed authored material override is preserved on downshift and exit", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const original = new MeshStandardMaterial(); + const mesh = new Mesh(new BoxGeometry(), original); + scene.add(mesh); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + const authored = new MeshStandardMaterial(); + mesh.material = authored; + c.setEnabled(false); + c.setEnabled(true); + c.dispose(); + expect(mesh.material).toBe(authored); +}); +test("unknown environment suppresses fill, measured dark setter enables it, override still reports", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const image = new Texture({ width: 4, height: 2 } as never); + scene.environment = image; + scene.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial())); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + expect(c.controls.fillAdmitted).toBe(false); + expect(c.debug()).toMatchObject({ environmentState: "unknown", meanRadiance: null }); + c.setEnvironmentMeasurement({ + status: "measured", + meanRadiance: 0, + meanRGB: [0, 0, 0], + reason: "known GPU sample", + }); + expect(c.controls.fillAdmitted).toBe(true); + c.controls.rimGain = 0; + // JavaScript callers can replace the readonly TypeScript property at runtime. + Reflect.set(c.controls, "fillColor", new Color(0)); + const report = c.report(); + expect(report).toMatchObject({ rimGain: 0, analyticFill: { blackColorOverride: true } }); + expect(c.debug()).toMatchObject({ meanRadiance: 0 }); + scene.environment = new Texture(); + expect(c.controls.fillAdmitted).toBe(false); + c.dispose(); +}); +test("missing environment admits directional fill and live controls remain game-owned", () => { + const logs = vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + expect(c.controls.rimGain).toBe(0.12); + expect(c.controls.fillGain).toBe(1); + expect(c.controls.fillAngularSize).toBe(0.7); + expect(c.controls.fillColor.equals(new Color(0x667b9d))).toBe(true); + expect(c.controls.fillAdmitted).toBe(true); + c.controls.fillGain = 0; + c.report(); + expect(logs).toHaveBeenCalledWith(expect.stringContaining("TN_ENVIRONMENT_CONTRIBUTION")); + c.dispose(); +}); + +test("authored array edits survive disposal while owned slots restore", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const source = new MeshStandardMaterial(); + const originals = [source, source]; + const mesh = new Mesh(new BoxGeometry(), originals); + scene.add(mesh); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + const authored = new MeshBasicMaterial(); + const current = mesh.material as Material[]; + current[1] = authored; + c.dispose(); + expect(mesh.material).toEqual([source, authored]); +}); +test("all-unsupported arrays retain their original identity", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const originals = [new MeshPhysicalMaterial(), new MeshBasicMaterial()]; + const mesh = new Mesh(new BoxGeometry(), originals); + scene.add(mesh); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + expect(mesh.material).toBe(originals); + c.dispose(); + expect(mesh.material).toBe(originals); +}); + +function mockRenderer(read?: () => Promise) { + const oldTarget = { name: "borrowed target" }; + let target: unknown = oldTarget; + let face = 3; + let level = 2; + const renderer = { + isWebGPURenderer: true, + toneMapping: 4, + outputColorSpace: "srgb", + autoClear: false, + getRenderTarget: () => target, + getActiveCubeFace: () => face, + getActiveMipmapLevel: () => level, + setRenderTarget: vi.fn((next: unknown, nextFace = 0, nextLevel = 0) => { + target = next; + face = nextFace; + level = nextLevel; + }), + render: vi.fn((..._args: unknown[]) => {}), + readRenderTargetPixelsAsync: vi.fn((..._args: unknown[]) => + ( + read ?? + (async () => { + const pixels = new Float32Array(64 * 32 * 4); + for (let i = 0; i < pixels.length; i += 4) pixels.set([0.5, 0.5, 0.5, 1], i); + return pixels; + }) + )(), + ), + }; + return { renderer, oldTarget }; +} +test("GPU sample restores renderer state before readback settles and preserves borrowed resources", async () => { + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + scene.environmentIntensity = 2; + const sourceDispose = vi.spyOn(source, "dispose"); + let resolvePixels: (value: Float32Array) => void = () => {}; + const { renderer, oldTarget } = mockRenderer( + () => + new Promise((resolve) => { + resolvePixels = resolve; + }), + ); + const pending = sampleEnvironment(renderer, scene, desktop); + expect(renderer.getRenderTarget()).toBe(oldTarget); + expect(renderer.getActiveCubeFace()).toBe(3); + expect(renderer.getActiveMipmapLevel()).toBe(2); + expect(renderer.toneMapping).toBe(4); + expect(renderer.outputColorSpace).toBe("srgb"); + expect(renderer.autoClear).toBe(false); + const scratch = renderer.readRenderTargetPixelsAsync.mock.calls[0]?.[0] as unknown as { + dispose(): void; + }; + const disposeTarget = vi.spyOn(scratch, "dispose"); + const quad = renderer.render.mock.calls[0]?.[0] as unknown as { + material: { dispose(): void }; + geometry: { dispose(): void }; + }; + const disposeMaterial = vi.spyOn(quad.material, "dispose"); + const disposeGeometry = vi.spyOn(quad.geometry, "dispose"); + const pixels = new Float32Array(64 * 32 * 4).fill(0.5); + resolvePixels(pixels); + const sampled = await pending; + expect(sampled.measurement.status).toBe("measured"); + expect(sampled.measurement.meanRadiance).toBeCloseTo(1, 12); + expect(sampled.source).toBe(source); + expect(sampled.intensity).toBe(2); + expect(disposeTarget).toHaveBeenCalledTimes(1); + expect(disposeMaterial).toHaveBeenCalledTimes(1); + expect(disposeGeometry).not.toHaveBeenCalled(); + expect(sourceDispose).not.toHaveBeenCalled(); +}); +test("failed readback restores state and returns unknown", async () => { + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + const { renderer, oldTarget } = mockRenderer(async () => { + throw new Error("unsupported readback"); + }); + const result = await sampleEnvironment(renderer, scene, desktop); + expect(result.measurement).toMatchObject({ status: "unknown", meanRadiance: null }); + expect(renderer.getRenderTarget()).toBe(oldTarget); + expect(renderer.toneMapping).toBe(4); +}); +test("environment source changes invalidate asynchronous samples and controller snapshot", async () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + let resolvePixels: (value: Float32Array) => void = () => {}; + const { renderer } = mockRenderer( + () => + new Promise((resolve) => { + resolvePixels = resolve; + }), + ); + const pending = sampleEnvironment(renderer, scene, desktop); + scene.environment = new Texture(); + resolvePixels(new Float32Array(64 * 32 * 4)); + const sampled = await pending; + expect(sampled.measurement.status).toBe("unknown"); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + c.setEnvironmentMeasurement( + { status: "measured", meanRadiance: 0, meanRGB: [0, 0, 0], reason: "stale" }, + source, + 1, + ); + expect(c.controls.fillAdmitted).toBe(false); + c.dispose(); +}); +test("sampler skips unsupported platform without rendering or inventing a mean", async () => { + const { scene } = world(); + scene.environment = new Texture(); + const { renderer } = mockRenderer(); + const sampled = await sampleEnvironment(renderer, scene, { ...desktop, web: false }); + expect(sampled.measurement).toMatchObject({ status: "unknown", meanRadiance: null }); + expect(renderer.render).not.toHaveBeenCalled(); +}); + +test("render failure also restores state and skips readback", async () => { + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + const { renderer, oldTarget } = mockRenderer(); + renderer.render.mockImplementation(() => { + throw new Error("pipeline unavailable"); + }); + const sampled = await sampleEnvironment(renderer, scene, desktop); + expect(sampled.measurement.status).toBe("unknown"); + expect(renderer.getRenderTarget()).toBe(oldTarget); + expect(renderer.getActiveCubeFace()).toBe(3); + expect(renderer.getActiveMipmapLevel()).toBe(2); + expect(renderer.outputColorSpace).toBe("srgb"); + expect(renderer.autoClear).toBe(false); + expect(renderer.readRenderTargetPixelsAsync).not.toHaveBeenCalled(); +}); + +test("shortened authored arrays stay shortened when owned slots restore", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const source = new MeshStandardMaterial(); + const mesh = new Mesh(new BoxGeometry(), [source, source]); + scene.add(mesh); + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + (mesh.material as Material[]).pop(); + c.dispose(); + expect(mesh.material).toEqual([source]); +}); +const sourceChanges = [ + (source: Texture) => { + source.needsUpdate = true; + }, + (source: Texture) => { + source.colorSpace = "srgb"; + }, + (source: Texture) => { + source.mapping = 300; + }, +]; +test.each(sourceChanges)( + "same texture content/interpretation changes invalidate fill", + (change) => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + const c = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + c.setEnvironmentMeasurement({ + status: "measured", + meanRadiance: 0, + meanRGB: [0, 0, 0], + reason: "snapshot", + }); + expect(c.controls.fillAdmitted).toBe(true); + change(source); + expect(c.controls.fillAdmitted).toBe(false); + c.dispose(); + }, +); +test.each(sourceChanges)("same texture changes invalidate pending GPU readback", async (change) => { + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + let resolvePixels: (value: Float32Array) => void = () => {}; + const { renderer } = mockRenderer( + () => + new Promise((resolve) => { + resolvePixels = resolve; + }), + ); + const pending = sampleEnvironment(renderer, scene, desktop); + change(source); + resolvePixels(new Float32Array(64 * 32 * 4)); + expect((await pending).measurement.status).toBe("unknown"); +}); + +test("hung GPU readback times out without disposing pending resources; late settlement cleans once", async () => { + vi.useFakeTimers(); + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + let resolvePixels: (value: Float32Array) => void = () => {}; + const { renderer } = mockRenderer( + () => + new Promise((resolve) => { + resolvePixels = resolve; + }), + ); + const pending = sampleEnvironment(renderer, scene, desktop); + let outcome: Awaited | undefined; + void pending.then((value) => { + outcome = value; + }); + const scratch = renderer.readRenderTargetPixelsAsync.mock.calls[0]?.[0] as unknown as { + dispose(): void; + }; + const disposeTarget = vi.spyOn(scratch, "dispose"); + const quad = renderer.render.mock.calls[0]?.[0] as unknown as { material: { dispose(): void } }; + const disposeMaterial = vi.spyOn(quad.material, "dispose"); + try { + await vi.advanceTimersByTimeAsync(1001); + expect(outcome?.measurement).toMatchObject({ status: "unknown", meanRadiance: null }); + expect(disposeTarget).not.toHaveBeenCalled(); + expect(disposeMaterial).not.toHaveBeenCalled(); + } finally { + resolvePixels(new Float32Array(64 * 32 * 4)); + await pending; + await Promise.resolve(); + } + expect(disposeTarget).toHaveBeenCalledTimes(1); + expect(disposeMaterial).toHaveBeenCalledTimes(1); +}); + +test("in-place original array edits during fallback survive tier recovery", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + const first = new MeshStandardMaterial(); + const second = new MeshStandardMaterial(); + const original: Material[] = [first, second]; + const mesh = new Mesh(new BoxGeometry(), original); + scene.add(mesh); + const controller = createMaterialLighting(scene, camera, key, { ...desktop, enabled: true }); + const convertedFirst = (mesh.material as Material[])[0]; + controller.setEnabled(false); + expect(mesh.material).toBe(original); + const authored = new MeshBasicMaterial(); + original[1] = authored; + controller.setEnabled(true); + expect((mesh.material as Material[])[1]).toBe(authored); + expect((mesh.material as Material[])[0]).toBe(convertedFirst); + controller.dispose(); + expect(mesh.material).toBe(original); + expect(original).toEqual([first, authored]); +}); + +test("successful immutable source samples reuse cache but changed provenance resamples", async () => { + const { scene } = world(); + const source = new Texture(); + source.mapping = EquirectangularReflectionMapping; + scene.environment = source; + const { renderer } = mockRenderer(); + const first = await sampleEnvironment(renderer, scene, desktop); + const repeated = await sampleEnvironment(renderer, scene, desktop); + expect(repeated.measurement).toEqual(first.measurement); + expect(renderer.render).toHaveBeenCalledTimes(1); + expect(renderer.readRenderTargetPixelsAsync).toHaveBeenCalledTimes(1); + source.needsUpdate = true; + await sampleEnvironment(renderer, scene, desktop); + expect(renderer.render).toHaveBeenCalledTimes(2); + scene.environmentIntensity = 2; + const stronger = await sampleEnvironment(renderer, scene, desktop); + expect(stronger.measurement.meanRadiance).toBeCloseTo(1); + expect(renderer.render).toHaveBeenCalledTimes(3); + scene.environment = new Texture(); + scene.environment.mapping = EquirectangularReflectionMapping; + await sampleEnvironment(renderer, scene, desktop); + expect(renderer.render).toHaveBeenCalledTimes(4); + await sampleEnvironment(renderer, scene, { ...desktop, software: true }); + expect(renderer.render).toHaveBeenCalledTimes(4); + const other = mockRenderer(); + await sampleEnvironment(other.renderer, scene, desktop); + expect(other.renderer.render).toHaveBeenCalledTimes(1); +}); + +test("unknown sample failures are retried rather than cached", async () => { + const { scene } = world(); + scene.environment = new Texture(); + scene.environment.mapping = EquirectangularReflectionMapping; + const { renderer } = mockRenderer(async () => { + throw new Error("unavailable"); + }); + await sampleEnvironment(renderer, scene, desktop); + await sampleEnvironment(renderer, scene, desktop); + expect(renderer.render).toHaveBeenCalledTimes(2); +}); + +test("WebGPU wrapper with a WebGL backend preserves originals and skips sampling", async () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const { scene, camera, key } = world(); + scene.environment = new Texture(); + scene.environment.mapping = EquirectangularReflectionMapping; + const original = new MeshStandardMaterial(); + const mesh = new Mesh(new BoxGeometry(), original); + scene.add(mesh); + const { renderer } = mockRenderer(); + Reflect.set(renderer, "backend", { isWebGLBackend: true }); + const sample = await sampleEnvironment(renderer, scene, desktop); + expect(sample.measurement.status).toBe("unknown"); + expect(renderer.render).not.toHaveBeenCalled(); + expect(renderer.readRenderTargetPixelsAsync).not.toHaveBeenCalled(); + const environment = { ...desktop, webglFallback: true }; + expect(materialLightingEnabled("high", environment)).toBe(false); + const controller = createMaterialLighting(scene, camera, key, { ...environment, enabled: true }); + controller.setEnabled(true); + expect(mesh.material).toBe(original); + expect(controller.debug().convertedMaterials).toBe(0); + controller.dispose(); +}); diff --git a/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts b/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts new file mode 100644 index 0000000000..6d1f6bf319 --- /dev/null +++ b/packages/create-threenative/__tests__/backlight-render-matrices.spec.ts @@ -0,0 +1,107 @@ +import { + Camera, + Color, + DirectionalLight, + Group, + MeshStandardMaterial, + type Object3D, + Scene, + Vector3, +} from "three"; +import { expect, test, vi } from "vitest"; +import { backlightMaterial } from "../templates/starter/src/render/backlightMaterial.js"; +interface IUniform { + isUniformNode?: boolean; + updateType?: string; + value: unknown; + update(frame: { camera: Camera }): void; +} +test("render uniforms read engine-updated light matrices without mutating shared matrices or update flags", () => { + const scene = new Scene(); + const ancestor = new Group(); + const key = new DirectionalLight(0xffffff, 4.5); + const camera = new Camera(); + ancestor.position.set(3, 1, 0); + key.position.set(0, 2, -3); + key.target.position.set(-1, 0, 0); + ancestor.add(key); + scene.add(ancestor); // Target is deliberately not attached to the scene. + const controls = { + scene, + camera, + key, + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0.1, 0.2, 0.3), + fillDirection: new Vector3(-1, 1, 1), + fillAngularSize: 0.7, + fillAdmitted: false, + }; + const material = backlightMaterial(new MeshStandardMaterial(), controls); + const uniforms: IUniform[] = []; + material.emissiveNode?.traverse((node) => { + const uniform = node as unknown as IUniform; + if (uniform.isUniformNode && uniform.updateType === "render") uniforms.push(uniform); + }); + const direction = uniforms.find( + (node) => node.value instanceof Vector3 && node.value !== controls.fillDirection, + ); + expect(direction).toBeDefined(); + const gain = () => + uniforms.filter((node) => typeof node.value === "number").map((node) => node.value); + // Same contract as Three's light uniforms: renderer/game updates world matrices before callbacks. + const engineUpdate = () => { + scene.updateMatrixWorld(true); + key.target.updateMatrixWorld(true); + }; + const nodes: Object3D[] = [scene, ancestor, key, key.target]; + engineUpdate(); + const matrixSpies = nodes.flatMap((node) => [ + vi.spyOn(node, "updateWorldMatrix"), + vi.spyOn(node, "updateMatrixWorld"), + vi.spyOn(node, "updateMatrix"), + ]); + const render = () => { + // Pending changes belong to the engine's next update, never to this material callback. + for (const node of nodes) node.matrixWorldNeedsUpdate = true; + const snapshots = nodes.map((node) => ({ + local: node.matrix.clone(), + world: node.matrixWorld.clone(), + dirty: node.matrixWorldNeedsUpdate, + auto: node.matrixAutoUpdate, + worldAuto: node.matrixWorldAutoUpdate, + })); + for (const uniform of uniforms) uniform.update({ camera }); + nodes.forEach((node, index) => { + const snapshot = snapshots[index]; + if (snapshot === undefined) throw new Error("Missing matrix snapshot."); + expect(node.matrix.equals(snapshot.local)).toBe(true); + expect(node.matrixWorld.equals(snapshot.world)).toBe(true); + expect(node.matrixWorldNeedsUpdate).toBe(snapshot.dirty); + expect(node.matrixAutoUpdate).toBe(snapshot.auto); + expect(node.matrixWorldAutoUpdate).toBe(snapshot.worldAuto); + }); + }; + render(); + expect(gain()).toContain(4.5); + expect((direction?.value as Vector3).distanceTo(new Vector3(4, 3, -3).normalize())).toBeLessThan( + 1e-12, + ); + for (const spy of matrixSpies) expect(spy).not.toHaveBeenCalled(); + for (const spy of matrixSpies) spy.mockRestore(); + ancestor.position.set(-2, 3, 1); + key.target.position.set(1, 1, 0); + engineUpdate(); + render(); + expect((direction?.value as Vector3).distanceTo(new Vector3(-3, 4, -2).normalize())).toBeLessThan( + 1e-12, + ); + ancestor.visible = false; + render(); + expect(gain()).not.toContain(4.5); + ancestor.visible = true; + scene.remove(ancestor); + render(); + expect(gain()).not.toContain(4.5); + material.dispose(); +}); diff --git a/packages/create-threenative/__tests__/dynamic-material-lifecycle.spec.ts b/packages/create-threenative/__tests__/dynamic-material-lifecycle.spec.ts new file mode 100644 index 0000000000..a010570359 --- /dev/null +++ b/packages/create-threenative/__tests__/dynamic-material-lifecycle.spec.ts @@ -0,0 +1,114 @@ +import { + BoxGeometry, + Camera, + DirectionalLight, + Group, + Mesh, + MeshStandardMaterial, + Scene, + Texture, +} from "three"; +import { expect, test, vi } from "vitest"; +import { createMaterialLighting } from "../templates/starter/src/render/materialLighting.js"; +function setup() { + const scene = new Scene(); + const key = new DirectionalLight(); + scene.add(key); + return { + scene, + controller: createMaterialLighting(scene, new Camera(), key, { + enabled: true, + web: true, + rendererKind: "webgpu", + mobile: false, + software: false, + }), + }; +} +test("dynamic enrollment shares conversions and releases the final receiver only", () => { + const { scene, controller } = setup(); + const map = new Texture(); + const source = new MeshStandardMaterial({ map }); + const borrowed = vi.spyOn(source, "dispose"); + const texture = vi.spyOn(map, "dispose"); + const a = new Mesh(new BoxGeometry(), source); + const b = new Mesh(new BoxGeometry(), source); + scene.add(a, b); + controller.enroll(a); + controller.enroll(a); + controller.enroll(b); + expect(a.material).toBe(b.material); + expect(a.material).not.toBe(source); + const owned = vi.spyOn(a.material as MeshStandardMaterial, "dispose"); + expect(controller.debug().enrolledMeshes).toBe(2); + controller.release(a); + a.removeFromParent(); + expect(a.material).toBe(source); + expect(owned).not.toHaveBeenCalled(); + controller.release(b); + expect(owned).toHaveBeenCalledTimes(1); + expect(controller.debug()).toMatchObject({ enrolledMeshes: 0, convertedMaterials: 0 }); + controller.release(b); + controller.dispose(); + expect(owned).toHaveBeenCalledTimes(1); + expect(borrowed).not.toHaveBeenCalled(); + expect(texture).not.toHaveBeenCalled(); +}); +test("fallback spawns convert on recovery and mutable arrays preserve authored edits on release", () => { + const { scene, controller } = setup(); + controller.setEnabled(false); + const source = new MeshStandardMaterial(); + const edited = new MeshStandardMaterial(); + const array = [source, source]; + const mesh = new Mesh(new BoxGeometry(), array); + scene.add(mesh); + controller.enroll(mesh); + expect(mesh.material).toBe(array); + array[1] = edited; + controller.setEnabled(true); + const applied = mesh.material as MeshStandardMaterial[]; + expect(applied[0]).not.toBe(source); + expect(applied[1]).not.toBe(edited); + const replacement = new MeshStandardMaterial(); + applied[1] = replacement; + controller.release(mesh); + expect(mesh.material).toEqual([source, replacement]); + controller.enroll(mesh); + expect((mesh.material as MeshStandardMaterial[])[1]).not.toBe(replacement); + controller.dispose(); + expect(mesh.material).toEqual([source, replacement]); +}); +test("spawn/remove cycles leave no retained assignments or conversions and disposed enrollment is inert", () => { + const { scene, controller } = setup(); + for (let i = 0; i < 25; i++) { + const root = new Group(); + const mesh = new Mesh(new BoxGeometry(), new MeshStandardMaterial()); + root.add(mesh); + scene.add(root); + controller.enroll(root); + controller.release(root); + root.removeFromParent(); + expect(controller.debug()).toMatchObject({ enrolledMeshes: 0, convertedMaterials: 0 }); + } + controller.dispose(); + const mesh = new Mesh(new BoxGeometry(), new MeshStandardMaterial()); + const original = mesh.material; + controller.enroll(mesh); + controller.setEnabled(true); + expect(mesh.material).toBe(original); +}); +test("later material override survives release and reenrollment borrows the new source", () => { + const { controller } = setup(); + const mesh = new Mesh(new BoxGeometry(), new MeshStandardMaterial()); + controller.enroll(mesh); + const owned = vi.spyOn(mesh.material as MeshStandardMaterial, "dispose"); + const replacement = new MeshStandardMaterial(); + mesh.material = replacement; + controller.release(mesh); + expect(mesh.material).toBe(replacement); + expect(owned).toHaveBeenCalledTimes(1); + controller.enroll(mesh); + expect(mesh.material).not.toBe(replacement); + controller.dispose(); + expect(mesh.material).toBe(replacement); +}); diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts index 3f664fc11c..54bfe23ae8 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts @@ -7,7 +7,7 @@ import { } from "./proof.js"; type ColdBootReport = Parameters[0] & { - observations?: { tone?: readonly { code: string; atStep?: string; p99: number }[] }; + observations?: { tone?: readonly { code: string; atStep?: string; p99?: number }[] }; }; const timingFields = [ "updates", diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/assets.manifest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/assets.manifest.json new file mode 100644 index 0000000000..fe443bff4b --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/assets.manifest.json @@ -0,0 +1 @@ +{ "version": 1, "entries": {} } diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-baseline.playtest.json new file mode 100644 index 0000000000..97c8b93f73 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-baseline.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-baseline.playtest.json new file mode 100644 index 0000000000..7deec1c2bc --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-baseline.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-black-ibl-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-enabled.playtest.json new file mode 100644 index 0000000000..c81f1ba230 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-enabled.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-black-ibl-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-rim-zero.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-rim-zero.playtest.json new file mode 100644 index 0000000000..b5105dd5a0 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-black-ibl-rim-zero.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-black-ibl-rim-zero", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "rim-zero", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-enabled.playtest.json new file mode 100644 index 0000000000..5da8c3c6d0 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-enabled.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-rim-zero.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-rim-zero.playtest.json new file mode 100644 index 0000000000..7e272e7a16 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/backlit-rim-zero.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-backlit-rim-zero", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "rim-zero", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/clean-starter.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/clean-starter.mjs new file mode 100644 index 0000000000..7e191fac50 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/clean-starter.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +import { PerspectiveCamera } from "../../../node_modules/three/build/three.module.js"; +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; + +const arm = process.argv[2]; +assert.ok(["before", "after"].includes(arm)); +const port = arm === "before" ? 5194 : 5195; +const out = `artifacts/backlight-defaults/clean-starter-${arm}-capture${process.argv[3] ?? "2"}`; +const config = parseStandalonePlaytestArgs([ + "--scenario", + "packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json", + "--url", + `http://127.0.0.1:${port}/`, + "--timeout", + "60000", + "--artifacts", + out, + ...flags.flatMap((flag) => ["--browser-arg", flag]), +]); +await withBrowserCapture(config, async (session) => { + const snapshot = await session.bridge.sample({ + include: ["components", "renderChain", "runtimeObservations", "state", "scene"], + label: "clean-default-starter-ready", + }); + assert.equal(session.provenance.adapter.vendor, "nvidia"); + assert.equal(session.provenance.adapter.architecture, "turing"); + await mkdir(out, { recursive: true }); + await writeFile( + `${out}/snapshot.json`, + JSON.stringify({ arm, provenance: session.provenance, snapshot }, null, 2), + ); + await session.screenshot("default-starter-ready"); + const camera = new PerspectiveCamera(); + camera.position.set(-6, 1.45, -2.7); + camera.lookAt(-2, 1.25, 0); + const requested = { position: camera.position.toArray(), rotation: camera.quaternion.toArray() }; + await session.bridge.applySetup({ entities: [{ entity: "camera.main", transform: requested }] }); + await session.page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const backlit = await session.bridge.sample({ + include: ["components", "renderChain", "state", "scene"], + label: "clean-default-starter-backlit-camera", + }); + const observed = backlit.entities.find((row) => row.id === "camera.main").transform; + assert.deepEqual(observed.position, requested.position); + assert.deepEqual(observed.rotation, requested.rotation); + assert.deepEqual( + backlit.clock, + snapshot.clock, + "Camera placement must not advance character/content pose.", + ); + await writeFile( + `${out}/backlit-snapshot.json`, + JSON.stringify({ arm, requested, provenance: session.provenance, snapshot: backlit }, null, 2), + ); + await session.screenshot("backlit-starter-camera"); + if (arm === "after") { + const restart = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const raw = game.ctx.renderer.raw; + const source = game.ctx.scene.environment; + const original = raw.readRenderTargetPixelsAsync; + let readbacks = 0; + raw.readRenderTargetPixelsAsync = function (...args) { + readbacks++; + return original.apply(this, args); + }; + const start = performance.now(); + try { + await game.goto("play"); + return { + readbacks, + elapsedMs: performance.now() - start, + sameSource: game.ctx.scene.environment === source, + backendWebGL: raw.backend.isWebGLBackend === true, + }; + } finally { + raw.readRenderTargetPixelsAsync = original; + } + }); + assert.equal(restart.sameSource, true); + assert.equal(restart.readbacks, 0); + assert.equal(restart.backendWebGL, false); + const cached = await session.bridge.sample({ + include: ["components"], + label: "cached-restart", + }); + assert.equal(cached.components["material-lighting"].status, "measured"); + await writeFile( + `${out}/cached-restart.json`, + JSON.stringify( + { restart, materialLighting: cached.components["material-lighting"] }, + null, + 2, + ), + ); + } + console.log( + JSON.stringify({ + arm, + provenance: session.provenance, + clock: snapshot.clock, + performance: snapshot.performance, + }), + ); +}); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.mjs new file mode 100644 index 0000000000..f326fba883 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.mjs @@ -0,0 +1,95 @@ +// Read the engine's real frame/timestamp windows; own no renderer, meter or browser lifetime. +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const arm = process.argv[2]; +const view = process.argv[3] ?? "backlit"; +if (!["baseline", "enabled"].includes(arm) || !["backlit", "dark"].includes(view)) + throw new Error("Expected baseline|enabled and backlit|dark"); +// Optional explicit output preserves earlier measurements; defaults remain backward compatible. +const out = resolve(process.argv[4] ?? `artifacts/backlight-defaults/live-${view}-${arm}`); +const url = `http://127.0.0.1:5193/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html?arm=${arm}&shot=${view}&liveCost=1`; +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +const config = parseStandalonePlaytestArgs([ + "--scenario", + "packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json", + "--url", + url, + "--timeout", + "60000", + "--artifacts", + out, + ...flags.flatMap((flag) => ["--browser-arg", flag]), +]); +await withBrowserCapture(config, async (session) => { + const errors = []; + session.page.on("pageerror", (error) => errors.push(String(error))); + session.page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + // The readiness handshake pauses its initial game. Reload with liveCost's explicit non-held + // plugin and do not handshake again, so the engine's actual RAF loop generates these windows. + await session.page.reload({ waitUntil: "domcontentloaded" }); + await session.page.waitForFunction( + () => globalThis.__BACKLIGHT_BUDGET_WINDOWS__?.length >= 5, + {}, + { timeout: 55000 }, + ); + const windows = await session.page.evaluate(() => globalThis.__BACKLIGHT_BUDGET_WINDOWS__); + const adapter = await session.page.evaluate(async () => { + const a = await navigator.gpu.requestAdapter(); + return a?.info && { vendor: a.info.vendor, architecture: a.info.architecture }; + }); + if (adapter?.vendor !== "nvidia" || adapter.architecture !== "turing") + throw new Error(`Unqualified adapter ${JSON.stringify(adapter)}`); + // Report does not expose compilation completion; this is only a bounded startup discard. + const steady = windows.filter((w) => w.window > 2); + if ( + steady.length < 3 || + steady.some( + (w) => + w.frames !== 60 || + !Number.isInteger(w.gpu?.samples) || + w.gpu.samples <= 0 || + w.surface?.resolutionScale !== 1 || + w.surface?.sampleCount !== 4 || + !Number.isFinite(w.gpuMs) || + w.gpuMs <= 0 || + w.surface?.drawingBufferWidth !== 1280 || + w.surface?.drawingBufferHeight !== 720, + ) + ) + throw new Error(`Unqualified timing windows ${JSON.stringify(steady)}`); + if (errors.length) throw new Error(`Runtime errors ${JSON.stringify(errors)}`); + await mkdir(out, { recursive: true }); + await writeFile( + `${out}/windows.json`, + JSON.stringify( + { + arm, + view, + adapter, + provenance: session.provenance, + windows, + steady, + qualification: "actual engine timestamp windows; private-display fps is not claimed", + }, + null, + 2, + ), + ); + await session.screenshot("steady"); + console.log(JSON.stringify({ arm, view, steady })); +}); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json new file mode 100644 index 0000000000..671ba11942 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json @@ -0,0 +1,22 @@ +{ + "name": "backlight-live-engine-window-readiness", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "wait", + "label": "readiness-only", + "waitFrames": 1 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-baseline.playtest.json new file mode 100644 index 0000000000..8073429b5e --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-baseline.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-dark-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-enabled.playtest.json new file mode 100644 index 0000000000..3c42a7ad77 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-enabled.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-dark-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-fill-black.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-fill-black.playtest.json new file mode 100644 index 0000000000..585db31a0f --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/dark-fill-black.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-dark-fill-black", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "fill-black", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-baseline.playtest.json new file mode 100644 index 0000000000..0a594df371 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-baseline.playtest.json @@ -0,0 +1,156 @@ +{ + "name": "backlight-edge-proof-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-enabled.playtest.json new file mode 100644 index 0000000000..801a321852 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-enabled.playtest.json @@ -0,0 +1,156 @@ +{ + "name": "backlight-edge-proof-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-rim-zero.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-rim-zero.playtest.json new file mode 100644 index 0000000000..0cedbe8b41 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/edge-rim-zero.playtest.json @@ -0,0 +1,156 @@ +{ + "name": "backlight-edge-proof-rim-zero", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "rim-zero", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "backlit-black-ibl", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured explicitly black CPU-readable source." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "compare": { + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "metric": "p99", + "minDelta": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment-gpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment-gpu.mjs new file mode 100644 index 0000000000..9fed563166 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment-gpu.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; + +const out = "artifacts/backlight-defaults/actual-jpeg-gpu-sample1"; +const config = parseStandalonePlaytestArgs([ + "--scenario", + "packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json", + "--url", + "http://127.0.0.1:5193/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html?arm=enabled&shot=backlit&gpuSample=1", + "--timeout", + "60000", + "--artifacts", + out, + ...flags.flatMap((flag) => ["--browser-arg", flag]), +]); +await withBrowserCapture(config, async (session) => { + const sample = await session.page.evaluate(() => globalThis.__BACKLIGHT_SOURCE_SAMPLE__); + assert.equal(sample.measurement.status, "measured"); + assert.equal(sample.sourceWidth, 4096); + assert.equal(sample.sourceHeight, 2048); + assert.equal(sample.intensity, 2.5); + assert.ok( + Math.abs(sample.measurement.meanRadiance - 0.5471439738825153) < 0.04, + "Actual GPU estimate must agree with independent spherical-linear JPEG oracle within disclosed sample tolerance.", + ); + await mkdir(out, { recursive: true }); + await writeFile( + `${out}/sample.json`, + JSON.stringify( + { + sample, + provenance: session.provenance, + oracle: { + meanRadiance: 0.5471439738825153, + tolerance: 0.04, + method: + "independent full JPEG spherical-weighted sRGB-to-linear CPU decode; not used in shipped source", + }, + }, + null, + 2, + ), + ); + await session.screenshot("sampled-character"); + console.log(JSON.stringify(sample)); +}); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.cpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.cpu.mjs new file mode 100644 index 0000000000..41c091c2f9 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.cpu.mjs @@ -0,0 +1,97 @@ +// Lightweight source-measurement oracle. Run with tsx; this is not visual/native acceptance. +import assert from "node:assert/strict"; +import { + Color, + DataTexture, + EquirectangularReflectionMapping, + FloatType, + RGBAFormat, + SRGBColorSpace, + Scene, + Texture, + UnsignedByteType, +} from "three"; +import { measureEnvironment, reportEnvironmentContribution } from "./environment.ts"; +let checks = 0; +const check = (name, run) => { + run(); + checks += 1; + console.log(`PASS ${name}`); +}; +const scene = new Scene(); +const bind = (data, width, height, type) => { + const texture = new DataTexture(data, width, height, RGBAFormat, type); + texture.mapping = EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentIntensity = 1; + return texture; +}; +check("missing is measured zero, distinct from unreadable", () => { + assert.equal(measureEnvironment(scene, 16).meanRadiance, 0); + const image = new Texture({ width: 2, height: 2 }); + image.mapping = EquirectangularReflectionMapping; + scene.environment = image; + assert.equal(measureEnvironment(scene, 16).status, "unknown"); +}); +check("spherical row measure does not overweight the poles", () => { + bind(new Float32Array([1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1]), 1, 4, FloatType); + assert.ok(Math.abs(measureEnvironment(scene, 16).meanRadiance - (1 - Math.SQRT1_2)) < 1e-12); +}); +check("active environment intensity scales source radiance", () => { + scene.environmentIntensity = 2.5; + assert.ok( + Math.abs(measureEnvironment(scene, 16).meanRadiance - 2.5 * (1 - Math.SQRT1_2)) < 1e-12, + ); +}); +check("sRGB decoding happens before radiance averaging", () => { + const t = bind(new Uint8Array([128, 128, 128, 255]), 1, 1, UnsignedByteType); + t.colorSpace = SRGBColorSpace; + assert.ok(Math.abs(measureEnvironment(scene, 16).meanRadiance - 0.21586050011389926) < 1e-12); +}); +check("measurement refuses over-budget source", () => { + bind(new Uint8Array(8), 2, 1, UnsignedByteType); + assert.equal(measureEnvironment(scene, 1).status, "unknown"); +}); +check("invalid sample counts and negative radiance stay unknown", () => { + bind(new Float32Array([1, 1, 1]), 1, 1, FloatType); + assert.equal(measureEnvironment(scene, 16).status, "unknown"); + bind(new Float32Array([-1, 0, 0, 1]), 1, 1, FloatType); + assert.equal(measureEnvironment(scene, 16).status, "unknown"); +}); +check("invalid intensity is never presented as black IBL", () => { + bind(new Float32Array([1, 1, 1, 1]), 1, 1, FloatType); + scene.environmentIntensity = Number.NaN; + assert.equal(measureEnvironment(scene, 16).status, "unknown"); +}); +check("zero overrides preserve measured report and do not admit a fill", () => { + bind(new Float32Array([0, 0, 0, 1]), 1, 1, FloatType); + const original = console.info; + const calls = []; + console.info = (...values) => calls.push(values); + try { + const report = reportEnvironmentContribution(scene, { + darkThreshold: 0.01, + rimGain: 0, + fillGain: 0, + fillAdmitted: false, + fillColor: new Color(0), + maxSourceTexels: 16, + }); + assert.equal(report.meanRadiance, 0); + assert.equal(report.environmentState, "dark"); + assert.equal(report.ibl, "non-contributing"); + assert.equal(report.analyticFill.admitted, false); + assert.equal(report.analyticFill.effectiveGain, 0); + assert.ok(calls[0][0].startsWith("TN_ENVIRONMENT_CONTRIBUTION:")); + assert.equal( + JSON.parse(calls[0][0].slice("TN_ENVIRONMENT_CONTRIBUTION:".length)).ibl, + "non-contributing", + ); + } finally { + console.info = original; + } +}); +check("invalid work budget refuses before measurement", () => { + assert.throws(() => measureEnvironment(scene, 0), /budget/); +}); +console.log(`${checks} CPU measurement checks passed; no default/native/visual admission.`); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.ts b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.ts new file mode 100644 index 0000000000..8062b1c06f --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/environment.ts @@ -0,0 +1,184 @@ +// Isolated generated-source qualification. No shipped template/default uses this yet. +import { + type Color, + EquirectangularReflectionMapping, + FloatType, + LinearSRGBColorSpace, + NoColorSpace, + RGBAFormat, + SRGBColorSpace, + type Scene, + type Texture, + UnsignedByteType, +} from "three"; + +export interface EnvironmentMeasurement { + readonly status: "measured" | "unknown"; + readonly meanRadiance: number | null; + readonly meanRGB: readonly [number, number, number] | null; + readonly reason: string; +} +const unknown = (reason: string): EnvironmentMeasurement => ({ + 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 ReadableSource { + readonly status: "source"; + readonly data: Uint8Array | Float32Array; + readonly width: number; + readonly height: number; + readonly bytes: boolean; +} +function readSource( + texture: Texture, + maxSourceTexels: number, +): ReadableSource | EnvironmentMeasurement { + 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: ReadableSource, 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): EnvironmentMeasurement { + 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 ContributionControls { + readonly darkThreshold: number; + readonly rimGain: number; + readonly fillGain: number; + readonly fillAdmitted: boolean; + readonly fillColor: Color; + readonly maxSourceTexels: number; +} +function fillReport(options: ContributionControls) { + 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: ContributionControls) { + 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 = 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/__tests__/fixtures/backlight-defaults/fill-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-baseline.playtest.json new file mode 100644 index 0000000000..74d0809f7e --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-baseline.playtest.json @@ -0,0 +1,148 @@ +{ + "name": "backlight-fill-proof-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-enabled.playtest.json new file mode 100644 index 0000000000..631050b68e --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-enabled.playtest.json @@ -0,0 +1,148 @@ +{ + "name": "backlight-fill-proof-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-fill-black.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-fill-black.playtest.json new file mode 100644 index 0000000000..fc0643c6a1 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/fill-fill-black.playtest.json @@ -0,0 +1,148 @@ +{ + "name": "backlight-fill-proof-fill-black", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "fill-black", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "dark", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "dark", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + }, + "tone": [ + { + "atStep": "fixed-pose-end", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + }, + { + "atStep": "fixed-pose-rest", + "region": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "p1": { + "max": 5 + }, + "p99": { + "min": 30 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/final-fixture.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/final-fixture.mjs new file mode 100644 index 0000000000..61abb19986 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/final-fixture.mjs @@ -0,0 +1,60 @@ +// Final frozen-source original fixture proof; run only after the serial template matrix. +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + runStandalonePlaytest, +} from "../../../../playtest/dist/runner/index.js"; +const root = process.cwd(); +const base = resolve("artifacts/backlight-defaults/final-fixture2"); +const fixture = resolve("packages/create-threenative/__tests__/fixtures/backlight-defaults"); +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +const cases = [ + ["edge-enabled-acceptance1", "edge-enabled", "enabled", "backlit-black-ibl", false], + ["edge-rim-zero-acceptance1", "edge-rim-zero", "rim-zero", "backlit-black-ibl", false], + ["fill-enabled-acceptance1", "fill-enabled", "enabled", "dark", false], + ["fill-fill-black-acceptance1", "fill-fill-black", "fill-black", "dark", false], + ["marker-positive-proof1", "fill-enabled", "enabled", "dark", false], + ["marker-omitted-proof1", "fill-enabled", "enabled", "dark", true], +]; +const results = []; +for (const [name, scenario, arm, shot, omit] of cases) { + const output = resolve(base, name); + await mkdir(output, { recursive: true }); + const url = `http://127.0.0.1:5193/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html?arm=${arm}&shot=${shot}${omit ? "&omitReport=1" : ""}`; + const config = parseStandalonePlaytestArgs([ + "--headed", + "--project", + root, + "--scenario", + resolve(fixture, `${scenario}.playtest.json`), + "--url", + url, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((flag) => ["--browser-arg", flag]), + ]); + const result = { name, url, scenario }; + try { + const report = await runStandalonePlaytest(config); + await writeFile(resolve(output, "report.json"), JSON.stringify(report, null, 2)); + result.reportPass = report.pass; + result.failedIds = report.assertionResults.filter((row) => !row.pass).map((row) => row.id); + } catch (error) { + result.error = { message: error.message, stack: error.stack }; + } + results.push(result); + await writeFile(resolve(base, "runs.json"), JSON.stringify(results, null, 2)); + console.log(JSON.stringify(result)); +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-baseline.playtest.json new file mode 100644 index 0000000000..eb6da7aaa8 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-baseline.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-frontlit-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "frontlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-enabled.playtest.json new file mode 100644 index 0000000000..92d25a6d05 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-enabled.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-frontlit-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "frontlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-rim-zero.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-rim-zero.playtest.json new file mode 100644 index 0000000000..f449cf9c0b --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/frontlit-rim-zero.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-frontlit-rim-zero", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "rim-zero", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "frontlit", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "unknown", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html b/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html new file mode 100644 index 0000000000..220bcbea77 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/index.html @@ -0,0 +1 @@ +Backlight qualification diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts b/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts new file mode 100644 index 0000000000..22244593e1 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/main.ts @@ -0,0 +1,274 @@ +// Qualification only: no shipped default or starter source is changed. +import { + AnimationMixer, + BoxGeometry, + Color, + DataTexture, + DirectionalLight, + EquirectangularReflectionMapping, + FloatType, + Material, + Mesh, + MeshStandardMaterial, + type PerspectiveCamera, + RGBAFormat, + SphereGeometry, + TextureLoader, + type Scene as ThreeScene, + Vector3, +} from "three"; +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 { 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 { WorldEnvironment } from "../../../templates/starter/src/render/worldEnvironment.js"; +import { reportEnvironmentContribution } from "./environment.js"; +import { type BacklightControls, backlightMaterial } from "./material.js"; +const params = new URLSearchParams(location.search); +const arm = params.get("arm") ?? "baseline"; +const shot = params.get("shot") ?? "backlit"; +const liveCost = params.get("liveCost") === "1"; +const omitReport = params.get("omitReport") === "1"; +if (!["baseline", "enabled", "rim-zero", "fill-black"].includes(arm)) + throw new Error("Unknown backlight arm"); +if (!["backlit", "backlit-black-ibl", "frontlit", "dark", "missing"].includes(shot)) + throw new Error("Unknown backlight shot"); +function qualificationReport( + scene: ThreeScene, + controls: Parameters[1], +) { + const original = console.info; + try { + // Deliberate missing-marker mutation only; measurements and material controls remain intact. + if (omitReport) console.info = () => {}; + return reportEnvironmentContribution(scene, controls); + } finally { + console.info = original; + } +} +function convertSceneMaterials( + scene: ThreeScene, + controls: BacklightControls, + converted: Map, + excluded: string[], + enabled: boolean, +) { + if (!enabled) return; + const convert = (source: Material): Material => { + const cached = converted.get(source); + if (cached) return cached; + if ( + !(source instanceof MeshStandardMaterial) || + "isMeshPhysicalMaterial" in source || + source.onBeforeCompile !== Material.prototype.onBeforeCompile + ) { + excluded.push(source.type); + return source; + } + const material = backlightMaterial(source, controls); + converted.set(source, material); + return material; + }; + scene.traverse((object) => { + if (!(object instanceof Mesh)) return; + object.material = Array.isArray(object.material) + ? object.material.map(convert) + : convert(object.material); + }); +} +const captures: Array> = []; +class QualificationScene extends Scene { + #model: Awaited> | undefined; + #sky: Awaited> | undefined; + #dispose = () => {}; + override async load(ctx: ICtx) { + [this.#model, this.#sky] = await Promise.all([ + new GLTFLoader().loadAsync( + "/packages/create-threenative/template-assets/assets/mannequin.glb", + ), + new TextureLoader().loadAsync("/packages/create-threenative/template-assets/assets/sky.jpg"), + ]); + if (params.get("gpuSample") === "1") { + setupSky(ctx.scene, this.#sky); + const started = performance.now(); + const sample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: true, + rendererKind: ctx.renderer.kind, + mobile: false, + software: ctx.renderer.softwareAdapter !== undefined, + }); + const result = { + measurement: sample.measurement, + elapsedMs: performance.now() - started, + sourceWidth: this.#sky.image.width, + sourceHeight: this.#sky.image.height, + intensity: sample.intensity, + }; + ( + globalThis as unknown as { __BACKLIGHT_SOURCE_SAMPLE__: unknown } + ).__BACKLIGHT_SOURCE_SAMPLE__ = result; + console.info(`TN_ACTUAL_ENVIRONMENT_GPU_SAMPLE:${JSON.stringify(result)}`); + } + } + override enter(ctx: ICtx) { + if (!this.#model || !this.#sky) throw new Error("Backlight fixture assets not loaded"); + const camera = ctx.camera as PerspectiveCamera; + camera.position.set(0, 1.35, 6); + camera.lookAt(0, 1, 0); + setupSky(ctx.scene, this.#sky); + ctx.scene.fog = null; + const darkEnvironment = new DataTexture( + new Float32Array(4 * 2 * 4), + 4, + 2, + RGBAFormat, + FloatType, + ); + darkEnvironment.mapping = EquirectangularReflectionMapping; + if (shot === "dark" || shot === "backlit-black-ibl") ctx.scene.environment = darkEnvironment; + if (shot === "missing") ctx.scene.environment = null; + const key = new DirectionalLight(0xffeed0, 4.5); + key.position.set(0, 2, shot === "frontlit" ? 3 : -3); + if (shot === "backlit" || shot === "backlit-black-ibl" || shot === "frontlit") + ctx.scene.add(key); + const controls: BacklightControls = { + key, + scene: ctx.scene, + camera, + rimGain: arm === "rim-zero" ? 0 : 0.12, + fillGain: 1, + fillColor: new Color(arm === "fill-black" ? 0 : 0x667b9d), + fillDirection: new Vector3(-1, 1, 1), + fillAngularSize: 0.7, + fillAdmitted: arm !== "baseline" && (shot === "dark" || shot === "missing"), + }; + const report = qualificationReport(ctx.scene, { + darkThreshold: 0.001, + rimGain: arm === "baseline" ? 0 : controls.rimGain, + fillGain: arm === "baseline" ? 0 : controls.fillGain, + fillAdmitted: controls.fillAdmitted, + fillColor: controls.fillColor, + maxSourceTexels: 65536, + }); + const model = this.#model.scene; + ctx.add(model); + const mixer = new AnimationMixer(model); + const clip = this.#model.animations.find((clip) => clip.name === "Jog_Fwd_Loop"); + if (!clip) throw new Error("Actual starter mannequin jog clip missing"); + mixer.clipAction(clip).play(); + const primitives = [ + new Mesh( + new SphereGeometry(0.5, 32, 16), + new MeshStandardMaterial({ color: 0x8290a1, metalness: 1, roughness: 0.3 }), + ), + new Mesh( + new SphereGeometry(0.5, 32, 16), + new MeshStandardMaterial({ color: 0x7e8eb3, roughness: 0.7 }), + ), + ] as const; + primitives[0].position.set(-1.5, 0.5, 0); + primitives[1].position.set(1.5, 0.5, 0); + const floor = new Mesh( + new BoxGeometry(8, 0.1, 8), + new MeshStandardMaterial({ color: 0x424955, roughness: 0.8 }), + ); + floor.position.y = -0.05; + for (const mesh of [...primitives, floor]) ctx.add(mesh); + const ownedSources = [...primitives, floor].map((mesh) => mesh.material); + const converted = new Map(); + const excluded: string[] = []; + convertSceneMaterials(ctx.scene, controls, converted, excluded, arm !== "baseline"); + const applied = new WorldEnvironment(qualityPreset("high")).apply( + ctx.renderer, + ctx.scene, + camera, + ); + let animationTicks = 0; + let animationRunning = false; + ctx.entities.add("qualification", { + debug: () => ({ + arm, + shot, + animationTicks, + animationSeconds: Math.round(mixer.time * 1e9) / 1e9, + convertedMaterials: converted.size, + excluded, + environmentState: report.environmentState, + bufferWidth: ctx.renderer.domElement.width, + bufferHeight: ctx.renderer.domElement.height, + cameraPosition: camera.position.toArray(), + }), + }); + console.info("TN_BACKLIGHT_QUALIFICATION", { + arm, + shot, + controls: { + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor.toArray(), + fillDirection: controls.fillDirection.toArray(), + fillAngularSize: controls.fillAngularSize, + fillAdmitted: controls.fillAdmitted, + }, + report, + camera: camera.position.toArray(), + convertedMaterials: converted.size, + excluded, + stages: applied.stages, + }); + this.#dispose = () => { + mixer.stopAllAction(); + mixer.uncacheRoot(model); + applied.dispose?.(); + for (const material of converted.values()) material.dispose(); + for (const material of ownedSources) material.dispose(); + for (const mesh of [...primitives, floor]) mesh.geometry.dispose(); + darkEnvironment.dispose(); + this.#sky?.dispose(); + }; + return (frameCtx: ICtx, dt: number) => { + if (frameCtx.input.justPressed("pose")) { + animationTicks = 0; + animationRunning = true; + mixer.setTime(0); + } + if (!animationRunning || animationTicks >= 90) return; + animationTicks += 1; + mixer.update(dt); + }; + } + override exit() { + this.#dispose(); + } +} +if (liveCost) Object.assign(globalThis, { __BACKLIGHT_BUDGET_WINDOWS__: captures }); +const game = defineGame({ + camera: { projection: "perspective", fov: 40, near: 0.1, far: 100 }, + renderer: { preferWebGPU: true, resolutionScale: 1 }, + display: { maxFps: 60 }, + input: { pose: { keys: ["KeyP"] } }, + initialState: {}, + seed: 345, + assets: { + manifest: + "/packages/create-threenative/__tests__/fixtures/backlight-defaults/assets.manifest.json", + }, + frameBudget: { + reportEvery: 60, + onWindow: (window) => { + captures.push(window as unknown as Record); + }, + }, + scenes: { qualification: QualificationScene }, + start: "qualification", + plugins: [playtest({ holdUntilAttached: !liveCost })], +}); +void game + .start() + .then(() => { + if (!game.ctx) throw new Error("Backlight fixture ctx missing"); + document.body.append(game.ctx.renderer.domElement); + }) + .catch((error: unknown) => console.error(error)); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/material-live.cpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material-live.cpu.mjs new file mode 100644 index 0000000000..852bff03a8 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material-live.cpu.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { + Color, + DirectionalLight, + Group, + MeshStandardMaterial, + PerspectiveCamera, + Scene, + Vector3, +} from "three"; +import { backlightMaterial } from "./material.ts"; +const scene = new Scene(); +const camera = new PerspectiveCamera(); +const group = new Group(); +const key = new DirectionalLight(0xffeed0, 4.5); +key.position.set(0, 2, -3); +group.add(key); +scene.add(group); +const c = { + scene, + camera, + key, + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0.1, 0.2, 0.3), + fillDirection: new Vector3(-1, 1, 1), + fillAngularSize: 0.7, + fillAdmitted: false, +}; +const m = backlightMaterial(new MeshStandardMaterial({ roughness: 0 }), c); +const numeric = []; +const vectors = []; +const colors = []; +m.emissiveNode.traverse((n) => { + if (n.isUniformNode && n.updateType === "render") { + if (typeof n.value === "number") numeric.push(n); + if (n.value?.isVector3) vectors.push(n); + if (n.value?.isColor) colors.push(n); + } +}); +assert.ok(numeric.length >= 4); +const update = () => { + // Simulate the engine boundary, including an unattached target owned by the game. + scene.updateMatrixWorld(true); + key.target.updateMatrixWorld(true); + for (const n of numeric) n.update({ camera }); +}; +const gains = () => numeric.map((n) => n.value); +update(); +assert.ok(gains().includes(4.5)); +scene.remove(group); +update(); +assert.ok(!gains().includes(4.5)); +scene.add(group); +group.visible = false; +update(); +assert.ok(!gains().includes(4.5)); +group.visible = true; +key.layers.set(2); +update(); +assert.ok(!gains().includes(4.5)); +key.layers.set(0); +key.position.copy(key.target.position); +update(); +assert.ok(!gains().includes(4.5)); +assert.ok(vectors.every((n) => n.value.toArray().every(Number.isFinite))); +key.position.set(1e308, 0, 0); +assert.throws(update, /magnitude/); +key.position.set(0, 2, -3); +key.intensity = Number.NaN; +assert.throws(update, /controls/); +key.intensity = 4.5; +c.rimGain = Number.NaN; +assert.throws(update, /controls/); +c.rimGain = 0.12; +c.fillAngularSize = 0; +assert.throws(update, /controls/); +c.fillAngularSize = 0.7; +const old = key.color; +const keyColorNode = colors.find((n) => n.value === old); +assert.ok(keyColorNode); +key.color = new Color(0.2, 0.3, 0.4); +keyColorNode.update({ camera }); +assert.equal(keyColorNode.value, key.color); +key.color.r = -1; +assert.throws(update, /controls/); +console.log( + "PASS live detached/hidden/layer-excluded/coincident key, invalid mutable controls/key, replaced key colour, finite fallback vector. CPU update boundaries only; no shader/GPU admission.", +); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.cpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.cpu.mjs new file mode 100644 index 0000000000..c40761808d --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.cpu.mjs @@ -0,0 +1,68 @@ +// CPU conversion fidelity only; a constructed TSL graph is not GPU/visual/native proof. +import assert from "node:assert/strict"; +import { + Color, + DirectionalLight, + DoubleSide, + MeshPhysicalMaterial, + MeshStandardMaterial, + PerspectiveCamera, + Scene, + Texture, + Vector3, +} from "three"; +import { backlightMaterial } from "./material.ts"; +const source = new MeshStandardMaterial({ + color: 0x345678, + metalness: 0.8, + roughness: 0.65, + emissive: 0x101820, + emissiveIntensity: 2.5, + transparent: true, + opacity: 0.7, + side: DoubleSide, +}); +source.map = new Texture(); +source.normalMap = new Texture(); +source.emissiveMap = new Texture(); +const key = new DirectionalLight(0xffeed0, 2.5); +const scene = new Scene(); +scene.add(key); +const controls = { + key, + scene, + camera: new PerspectiveCamera(), + rimGain: 0.1, + fillGain: 0.2, + fillColor: new Color(0x405060), + fillDirection: new Vector3(0, 1, 0), + fillAngularSize: 0.3, + fillAdmitted: false, +}; +const converted = backlightMaterial(source, controls); +for (const name of [ + "metalness", + "roughness", + "emissiveIntensity", + "transparent", + "opacity", + "side", +]) + assert.equal(converted[name], source[name], name); +for (const name of ["color", "emissive"]) assert.ok(converted[name].equals(source[name]), name); +for (const name of ["map", "normalMap", "emissiveMap"]) + assert.equal(converted[name], source[name], name); +assert.ok(converted.emissiveNode); +assert.equal(converted.vertexNode, null); +assert.equal(converted.normalNode, null); +assert.throws(() => backlightMaterial(new MeshPhysicalMaterial(), controls), /Physical\/custom/); +const custom = new MeshStandardMaterial(); +custom.onBeforeCompile = () => {}; +assert.throws(() => backlightMaterial(custom, controls), /Physical\/custom/); +assert.throws( + () => backlightMaterial(source, { ...controls, fillDirection: new Vector3() }), + /controls/, +); +console.log( + "PASS standard property/map/emissive fidelity, default vertex/normal paths, unsupported material/direction refusal; no GPU or skinning execution claimed.", +); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.ts b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.ts new file mode 100644 index 0000000000..1eed712199 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/material.ts @@ -0,0 +1,178 @@ +// Isolated generated-source qualification. Appearance controls are authored by the fixture. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +export interface BacklightControls { + 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/missing/unknown case is qualified. */ + fillAdmitted: boolean; +} + +function validateControls(controls: BacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: BacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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: BacklightControls) { + 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-baseline.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-baseline.playtest.json new file mode 100644 index 0000000000..3cc449d99b --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-baseline.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-missing-baseline", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "baseline", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "missing", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "missing", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-enabled.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-enabled.playtest.json new file mode 100644 index 0000000000..aceb74245a --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-enabled.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-missing-enabled", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "enabled", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "missing", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "missing", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-fill-black.playtest.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-fill-black.playtest.json new file mode 100644 index 0000000000..489a88c1a3 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/missing-fill-black.playtest.json @@ -0,0 +1,116 @@ +{ + "name": "backlight-missing-fill-black", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "kind": "input", + "label": "start-fixed-pose", + "press": "KeyP", + "holdTicks": 1 + }, + { + "kind": "wait", + "label": "fixed-pose-mid", + "waitTicks": 43, + "screenshot": "pose-mid" + }, + { + "kind": "wait", + "label": "fixed-pose-end", + "waitTicks": 45, + "screenshot": "pose-end" + }, + { + "kind": "wait", + "label": "fixed-pose-rest", + "waitFrames": 60, + "screenshot": "pose-rest" + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true, + "noNetworkErrors": true + }, + "components": [ + { + "entity": "qualification", + "component": "arm", + "equals": "fill-black", + "allowTrivial": "Matched comparison arm identity." + }, + { + "entity": "qualification", + "component": "shot", + "equals": "missing", + "allowTrivial": "Matched scene and light identity." + }, + { + "entity": "qualification", + "component": "animationTicks", + "equals": 90, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 45 + }, + { + "label": "fixed-pose-end", + "equals": 90 + } + ] + }, + { + "entity": "qualification", + "component": "animationSeconds", + "equals": 1.5, + "atSteps": [ + { + "label": "fixed-pose-mid", + "equals": 0.75 + }, + { + "label": "fixed-pose-end", + "equals": 1.5 + } + ] + }, + { + "entity": "qualification", + "component": "bufferWidth", + "equals": 1280, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "bufferHeight", + "equals": 720, + "allowTrivial": "Read from actual drawing canvas." + }, + { + "entity": "qualification", + "component": "environmentState", + "equals": "missing", + "allowTrivial": "Measured source category; JPEG remains unknown." + } + ], + "renderChain": { + "tier": "high", + "stages": { + "includes": ["ambientOcclusion", "bloom", "vignette"], + "excludes": ["ssgi"] + } + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/proof-crops.json b/packages/create-threenative/__tests__/fixtures/backlight-defaults/proof-crops.json new file mode 100644 index 0000000000..25de5d2b6f --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/proof-crops.json @@ -0,0 +1,20 @@ +{ + "coordinateSpace": "decoded PNG physical pixels, top-left origin; no HUD mask/DPR/rescale", + "selection": "Exploratory end-pose image inspection identified interior calf edge and chest. Bounds and thresholds frozen before fresh assertion-bearing positive/negative captures. Mid pose remains separate visual witness; these end-pose crops are not claimed for every pose.", + "edge": { + "x": 624, + "y": 447, + "width": 7, + "height": 21 + }, + "body": { + "x": 618, + "y": 322, + "width": 28, + "height": 26 + }, + "edgeMargin": 30, + "fillP1Max": 5, + "fillP99Min": 30, + "negativeControls": ["rimGain-zero", "black-fill", "omitted-report"] +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/qualify.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/qualify.mjs new file mode 100644 index 0000000000..feb0bcdaef --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/qualify.mjs @@ -0,0 +1,194 @@ +// Inspect real retained reports AND decoded screenshot pixels; expected negatives are named gates. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { PNG } from "pngjs"; +import { inspectFrame } from "../../../../playtest/dist/capture.js"; +import { assertEnvironmentMarker } from "./report-proof.ts"; +const base = resolve(process.argv[2] ?? "artifacts/backlight-defaults"); +const crops = JSON.parse(await readFile(new URL("./proof-crops.json", import.meta.url), "utf8")); +async function json(path) { + let bytes; + try { + if ((await stat(path)).size > 1048576) throw new Error("Proof JSON exceeds 1 MiB."); + bytes = await readFile(path); + } catch (error) { + if (error.code !== "ENOENT") throw error; + bytes = gunzipSync(await readFile(`${path}.gz`), { maxOutputLength: 1048576 }); + } + if (bytes.length > 1048576) throw new Error("Expanded proof exceeds 1 MiB."); + return JSON.parse(bytes.toString("utf8")); +} +let aliases; +try { + aliases = await json(resolve(base, "source-aliases.json")); +} catch (error) { + if (error.code !== "ENOENT") throw error; +} +function cropMetrics(image, region) { + assert.ok(region.x >= 0 && region.y >= 0 && region.width > 0 && region.height > 0); + assert.ok(region.x + region.width <= image.width && region.y + region.height <= image.height); + const crop = new PNG({ width: region.width, height: region.height }); + PNG.bitblt(image, crop, region.x, region.y, region.width, region.height, 0, 0); + return inspectFrame(PNG.sync.write(crop)).tone; +} +const cases = []; +for (const name of ["edge-enabled", "edge-rim-zero", "fill-enabled", "fill-fill-black"]) { + const directory = resolve(base, `${name}-acceptance1`); + const report = await json(resolve(directory, "report.json")); + const edge = name.startsWith("edge"); + const negative = name !== "edge-enabled" && name !== "fill-enabled"; + const failures = negative + ? edge + ? ["tone.0.compare", "tone.1.compare"] + : ["tone.0.p99", "tone.1.p99"] + : []; + assert.equal(report.pass, !negative); + assert.deepEqual( + report.assertionResults + .filter((row) => !row.pass) + .map((row) => row.id) + .sort(), + failures, + ); + assert.equal(report.diagnostics.length, failures.length); + assert.ok(report.diagnostics.every((row) => row.code === "TN_PLAYTEST_TONE_ASSERTION_FAILED")); + assert.equal(report.capture.rendererKind, "webgpu"); + assert.equal(report.capture.target, "web"); + assert.equal(report.capture.adapter.vendor, "nvidia"); + assert.equal(report.capture.adapter.architecture, "turing"); + assert.deepEqual(report.capture.viewport, { width: 1280, height: 720 }); + const marker = assertEnvironmentMarker(report.observations.console, { + environmentState: "dark", + rimGain: name === "edge-rim-zero" ? 0 : 0.12, + fillEnabled: !edge, + blackFill: name === "fill-fill-black", + }); + const rows = []; + for (const [index, pose] of ["pose-end", "pose-rest"].entries()) { + const alias = aliases?.[name]?.[pose]; + const image = PNG.sync.read( + await readFile(alias ? resolve(base, alias.file) : resolve(directory, `${pose}.png`)), + ); + if (alias) + assert.equal(createHash("sha256").update(image.data).digest("hex"), alias.decodedRGBA); + + assert.equal(image.width, 1280); + assert.equal(image.height, 720); + const row = report.observations.tone.find( + (row) => + row.assertionIndex === index && + row.atStep === (index === 0 ? "fixed-pose-end" : "fixed-pose-rest"), + ); + assert.ok(row); + assert.deepEqual(row.region, edge ? crops.edge : crops.body); + const actual = cropMetrics(image, row.region); + for (const [key, value] of Object.entries(actual)) + assert.equal(row[key], value, `Report must match actual decoded PNG ${key}`); + if (edge) { + assert.deepEqual(row.reference.region, crops.body); + assert.deepEqual(row.reference.metrics, cropMetrics(image, crops.body)); + assert.equal(actual.p99 - row.reference.metrics.p99 >= crops.edgeMargin, !negative); + } else { + assert.ok(actual.p1 <= crops.fillP1Max); + assert.equal(actual.p99 >= crops.fillP99Min, !negative); + } + rows.push({ pose, metrics: actual, reference: row.reference?.metrics }); + } + cases.push({ + name, + pass: report.pass, + expectedFailures: failures, + marker, + rows, + provenance: report.observations.components.qualification, + }); +} +const positive = await json(resolve(base, "marker-positive-proof1/report.json")); +const omitted = await json(resolve(base, "marker-omitted-proof1/report.json")); +for (const report of [positive, omitted]) { + assert.equal(report.pass, true); + assert.deepEqual(report.diagnostics, []); + assert.equal(report.capture.adapter.vendor, "nvidia"); +} +const expected = { environmentState: "dark", rimGain: 0.12, fillEnabled: true, blackFill: false }; +assertEnvironmentMarker(positive.observations.console, expected); +assert.throws( + () => assertEnvironmentMarker(omitted.observations.console, expected), + /TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE/, +); +// A precise paired-pixel contribution remains measured even for the zero-override arm. +// This is a bounded fixture report, not a claimed runtime BRDF/IBL energy measurement. +const rimOn = cases.find((row) => row.name === "edge-enabled"); +const rimOff = cases.find((row) => row.name === "edge-rim-zero"); +for (const [candidate, arm] of [ + [rimOn, "enabled"], + [rimOff, "rim-zero"], +]) { + const evidence = candidate.provenance; + assert.ok(evidence, "Retained scene provenance is required."); + assert.deepEqual(evidence.arm, { before: arm, after: arm }); + assert.deepEqual(evidence.shot, { before: "backlit-black-ibl", after: "backlit-black-ibl" }); + assert.deepEqual(evidence.cameraPosition, { before: [0, 1.35, 6], after: [0, 1.35, 6] }); + assert.deepEqual(evidence.animationTicks, { before: 0, after: 90 }); + assert.deepEqual(evidence.animationSeconds, { before: 0, after: 1.5 }); + for (const key of [ + "convertedMaterials", + "excluded", + "environmentState", + "bufferWidth", + "bufferHeight", + ]) + assert.deepEqual( + evidence[key], + rimOn.provenance[key], + `Paired scene provenance must match: ${key}`, + ); +} +const rimMeasurements = rimOn.rows.map((row, index) => { + const off = rimOff.rows[index]; + const edgeDelta = row.metrics.p99 - off.metrics.p99; + const bodyDelta = row.reference.p99 - off.reference.p99; + assert.ok(edgeDelta > 0); + return { + pose: row.pose, + measured: true, + method: + "paired actual decoded PNG p99 edge/body difference; fixed scene/camera/pose; only rimGain differs", + availableEdgeP99Delta: edgeDelta, + availableBodyP99Delta: bodyDelta, + availableMarginDelta: edgeDelta - bodyDelta, + enabled: { edgeP99: row.metrics.p99, bodyP99: row.reference.p99 }, + zeroOverride: { edgeP99: off.metrics.p99, bodyP99: off.reference.p99 }, + }; +}); +for (const candidate of [rimOn, rimOff]) { + const measurement = { + arm: candidate.name, + applied: candidate.name === "edge-enabled", + rimGain: candidate.marker.rimGain, + measurements: rimMeasurements, + }; + console.log(`TN_RIM_CONTRIBUTION:${JSON.stringify(measurement)}`); +} +const result = { + pass: true, + rimMeasurements, + scope: + "isolated actual starter character/material fixture; no shipped defaults or per-template/native admission", + crops, + cases, + markerMutation: "TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE", + limitations: [ + "photographed environment remains unknown on current CPU measurement path", + "native regional capture unimplemented and fails closed", + "all-template/default integration and clean generated starter gates pending", + "timing uses sparse real engine timestamps; private-display FPS and compilation completion unqualified", + ], +}; +await writeFile(resolve(base, "qualification.json"), `${JSON.stringify(result, null, 2)}\n`); +console.log( + "PASS actual PNG crop/report agreement, edge margin and zero-rim failure, no-sun range and black-fill failure, measured report survives zero override and missing-marker fails.", +); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-controls.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-controls.mjs new file mode 100644 index 0000000000..f883488a9c --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-controls.mjs @@ -0,0 +1,144 @@ +// Actual generated racing controls; no source/gameplay/renderer hooks or fixed-tick advancement. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const base = resolve("artifacts/backlight-defaults/racing-controls1"); +await mkdir(base, { recursive: true }); +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +for (const arm of ["before", "after"]) { + const project = resolve(`artifacts/backlight-defaults/template-matrix2/racing-${arm}`); + const output = resolve(base, arm); + const stages = []; + const config = parseStandalonePlaytestArgs([ + "--project", + project, + "--scenario", + resolve("packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json"), + "--url", + "http://127.0.0.1:5196/", + "--port", + "5196", + "--server-command", + `node ${resolve(project, "node_modules/vite/bin/vite.js")} --host 127.0.0.1 --port 5196 --strictPort > ${resolve(base, `${arm}-server.log`)} 2>&1`, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((f) => ["--browser-arg", f]), + ]); + await withBrowserCapture(config, async (session) => { + assert.equal(session.provenance.adapter.vendor, "nvidia"); + assert.equal(session.provenance.adapter.architecture, "turing"); + assert.equal(session.provenance.rendererKind, "webgpu"); + let originalClock; + let presentations = 0; + const present = async (count) => { + await session.page.evaluate(async (count) => { + for (let i = 0; i < count; i++) + await new Promise((resolve) => requestAnimationFrame(resolve)); + }, count); + presentations += count; + }; + const capture = async (label) => { + const snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations", "renderChain"], + label, + }); + if (originalClock === undefined) originalClock = snapshot.clock; + else assert.deepEqual(snapshot.clock, originalClock, "Presentation wait changed fixed pose"); + const raw = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const scene = game.ctx.scene; + const raw = game.ctx.renderer.raw; + const lights = []; + const materials = []; + scene.traverse((object) => { + if (object.isLight) + lights.push({ + name: object.name, + type: object.type, + color: object.color.toArray(), + intensity: object.intensity, + castShadow: object.castShadow, + shadowReady: Boolean(object.shadow?.map), + shadowTexture: object.shadow?.map?.texture?.uuid, + }); + if (object.isMesh) + for (const m of Array.isArray(object.material) ? object.material : [object.material]) + materials.push({ + name: m.name, + type: m.type, + node: m.isNodeMaterial === true, + receiveShadow: object.receiveShadow, + castShadow: object.castShadow, + }); + }); + return { + environment: scene.environment + ? { + uuid: scene.environment.uuid, + version: scene.environment.version, + intensity: scene.environmentIntensity, + } + : null, + shadowMap: { enabled: raw.shadowMap.enabled, type: raw.shadowMap.type }, + width: raw.domElement.width, + height: raw.domElement.height, + samples: raw.samples, + lights, + materials, + }; + }); + await session.screenshot(label); + stages.push({ label, explicitPresentationWaits: presentations, snapshot, raw }); + await writeFile( + resolve(output, "controls.json"), + JSON.stringify( + { + arm, + provenance: session.provenance, + scope: + "explicit RAF waits, screenshots and bridge observation can also present; fixed gameplay clock unchanged; no compileComplete inference", + stages, + }, + null, + 2, + ), + ); + }; + await capture("initial"); + await present(20); + await capture("after-20-presentations"); + if (arm === "after") { + await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const controller = game.ctx.entities.get("material-lighting"); + if (!controller) throw Error("Missing material controller"); + controller.controls.rimGain = 0; + controller.report(); + }); + await present(2); + await capture("rim-zero"); + await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + game.ctx.entities.get("material-lighting").setEnabled(false); + }); + await present(2); + await capture("convention-disabled"); + } + }); + console.log(`${arm} racing controls PASS`); +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-expression-controls.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-expression-controls.mjs new file mode 100644 index 0000000000..891b7f8440 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-expression-controls.mjs @@ -0,0 +1,127 @@ +// Actual generated racing controls; no source/gameplay/renderer hooks or fixed-tick advancement. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const base = resolve("artifacts/backlight-defaults/racing-isolation3"); +await mkdir(base, { recursive: true }); +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +for (const arm of process.argv.slice(2)) { + const project = resolve(`artifacts/backlight-defaults/racing-isolation3/${arm}`); + const output = resolve(base, `${arm}-capture`); + const stages = []; + const config = parseStandalonePlaytestArgs([ + "--project", + project, + "--scenario", + resolve("packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json"), + "--url", + "http://127.0.0.1:5196/", + "--port", + "5196", + "--server-command", + `node ${resolve(project, "node_modules/vite/bin/vite.js")} --host 127.0.0.1 --port 5196 --strictPort > ${resolve(base, `${arm}-server.log`)} 2>&1`, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((f) => ["--browser-arg", f]), + ]); + await withBrowserCapture(config, async (session) => { + assert.equal(session.provenance.adapter.vendor, "nvidia"); + assert.equal(session.provenance.adapter.architecture, "turing"); + assert.equal(session.provenance.rendererKind, "webgpu"); + let originalClock; + let presentations = 0; + const present = async (count) => { + await session.page.evaluate(async (count) => { + for (let i = 0; i < count; i++) + await new Promise((resolve) => requestAnimationFrame(resolve)); + }, count); + presentations += count; + }; + const capture = async (label) => { + const snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations", "renderChain"], + label, + }); + if (originalClock === undefined) originalClock = snapshot.clock; + else assert.deepEqual(snapshot.clock, originalClock, "Presentation wait changed fixed pose"); + const raw = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const scene = game.ctx.scene; + const raw = game.ctx.renderer.raw; + const lights = []; + const materials = []; + scene.traverse((object) => { + if (object.isLight) + lights.push({ + name: object.name, + type: object.type, + color: object.color.toArray(), + intensity: object.intensity, + castShadow: object.castShadow, + shadowReady: Boolean(object.shadow?.map), + shadowTexture: object.shadow?.map?.texture?.uuid, + }); + if (object.isMesh) + for (const m of Array.isArray(object.material) ? object.material : [object.material]) + materials.push({ + name: m.name, + type: m.type, + node: m.isNodeMaterial === true, + receiveShadow: object.receiveShadow, + castShadow: object.castShadow, + }); + }); + return { + environment: scene.environment + ? { + uuid: scene.environment.uuid, + version: scene.environment.version, + intensity: scene.environmentIntensity, + } + : null, + shadowMap: { enabled: raw.shadowMap.enabled, type: raw.shadowMap.type }, + width: raw.domElement.width, + height: raw.domElement.height, + samples: raw.samples, + lights, + materials, + }; + }); + await session.screenshot(label); + stages.push({ label, explicitPresentationWaits: presentations, snapshot, raw }); + await writeFile( + resolve(output, "controls.json"), + JSON.stringify( + { + arm, + provenance: session.provenance, + scope: + "explicit RAF waits, screenshots and bridge observation can also present; fixed gameplay clock unchanged; no compileComplete inference", + stages, + }, + null, + 2, + ), + ); + }; + await capture("initial"); + await present(20); + await capture("after-20-presentations"); + }); + console.log(`${arm} racing controls PASS`); +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-isolation.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-isolation.mjs new file mode 100644 index 0000000000..e470503761 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-isolation.mjs @@ -0,0 +1,127 @@ +// Actual generated racing controls; no source/gameplay/renderer hooks or fixed-tick advancement. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const base = resolve("artifacts/backlight-defaults/racing-isolation1"); +await mkdir(base, { recursive: true }); +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +for (const arm of ["skip-sampler", "sampler-only"]) { + const project = resolve(`artifacts/backlight-defaults/racing-isolation1/${arm}`); + const output = resolve(base, `${arm}-capture`); + const stages = []; + const config = parseStandalonePlaytestArgs([ + "--project", + project, + "--scenario", + resolve("packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json"), + "--url", + "http://127.0.0.1:5196/", + "--port", + "5196", + "--server-command", + `node ${resolve(project, "node_modules/vite/bin/vite.js")} --host 127.0.0.1 --port 5196 --strictPort > ${resolve(base, `${arm}-server.log`)} 2>&1`, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((f) => ["--browser-arg", f]), + ]); + await withBrowserCapture(config, async (session) => { + assert.equal(session.provenance.adapter.vendor, "nvidia"); + assert.equal(session.provenance.adapter.architecture, "turing"); + assert.equal(session.provenance.rendererKind, "webgpu"); + let originalClock; + let presentations = 0; + const present = async (count) => { + await session.page.evaluate(async (count) => { + for (let i = 0; i < count; i++) + await new Promise((resolve) => requestAnimationFrame(resolve)); + }, count); + presentations += count; + }; + const capture = async (label) => { + const snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations", "renderChain"], + label, + }); + if (originalClock === undefined) originalClock = snapshot.clock; + else assert.deepEqual(snapshot.clock, originalClock, "Presentation wait changed fixed pose"); + const raw = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const scene = game.ctx.scene; + const raw = game.ctx.renderer.raw; + const lights = []; + const materials = []; + scene.traverse((object) => { + if (object.isLight) + lights.push({ + name: object.name, + type: object.type, + color: object.color.toArray(), + intensity: object.intensity, + castShadow: object.castShadow, + shadowReady: Boolean(object.shadow?.map), + shadowTexture: object.shadow?.map?.texture?.uuid, + }); + if (object.isMesh) + for (const m of Array.isArray(object.material) ? object.material : [object.material]) + materials.push({ + name: m.name, + type: m.type, + node: m.isNodeMaterial === true, + receiveShadow: object.receiveShadow, + castShadow: object.castShadow, + }); + }); + return { + environment: scene.environment + ? { + uuid: scene.environment.uuid, + version: scene.environment.version, + intensity: scene.environmentIntensity, + } + : null, + shadowMap: { enabled: raw.shadowMap.enabled, type: raw.shadowMap.type }, + width: raw.domElement.width, + height: raw.domElement.height, + samples: raw.samples, + lights, + materials, + }; + }); + await session.screenshot(label); + stages.push({ label, explicitPresentationWaits: presentations, snapshot, raw }); + await writeFile( + resolve(output, "controls.json"), + JSON.stringify( + { + arm, + provenance: session.provenance, + scope: + "explicit RAF waits, screenshots and bridge observation can also present; fixed gameplay clock unchanged; no compileComplete inference", + stages, + }, + null, + 2, + ), + ); + }; + await capture("initial"); + await present(20); + await capture("after-20-presentations"); + }); + console.log(`${arm} racing controls PASS`); +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-zero-startup.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-zero-startup.mjs new file mode 100644 index 0000000000..ab4847be11 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/racing-zero-startup.mjs @@ -0,0 +1,127 @@ +// Actual generated racing controls; no source/gameplay/renderer hooks or fixed-tick advancement. +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +const base = resolve("artifacts/backlight-defaults/racing-isolation2"); +await mkdir(base, { recursive: true }); +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +for (const arm of [process.argv[2]]) { + const project = resolve(`artifacts/backlight-defaults/racing-isolation2/${arm}`); + const output = resolve(base, `${arm}-capture`); + const stages = []; + const config = parseStandalonePlaytestArgs([ + "--project", + project, + "--scenario", + resolve("packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json"), + "--url", + "http://127.0.0.1:5196/", + "--port", + "5196", + "--server-command", + `node ${resolve(project, "node_modules/vite/bin/vite.js")} --host 127.0.0.1 --port 5196 --strictPort > ${resolve(base, `${arm}-server.log`)} 2>&1`, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((f) => ["--browser-arg", f]), + ]); + await withBrowserCapture(config, async (session) => { + assert.equal(session.provenance.adapter.vendor, "nvidia"); + assert.equal(session.provenance.adapter.architecture, "turing"); + assert.equal(session.provenance.rendererKind, "webgpu"); + let originalClock; + let presentations = 0; + const present = async (count) => { + await session.page.evaluate(async (count) => { + for (let i = 0; i < count; i++) + await new Promise((resolve) => requestAnimationFrame(resolve)); + }, count); + presentations += count; + }; + const capture = async (label) => { + const snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations", "renderChain"], + label, + }); + if (originalClock === undefined) originalClock = snapshot.clock; + else assert.deepEqual(snapshot.clock, originalClock, "Presentation wait changed fixed pose"); + const raw = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const scene = game.ctx.scene; + const raw = game.ctx.renderer.raw; + const lights = []; + const materials = []; + scene.traverse((object) => { + if (object.isLight) + lights.push({ + name: object.name, + type: object.type, + color: object.color.toArray(), + intensity: object.intensity, + castShadow: object.castShadow, + shadowReady: Boolean(object.shadow?.map), + shadowTexture: object.shadow?.map?.texture?.uuid, + }); + if (object.isMesh) + for (const m of Array.isArray(object.material) ? object.material : [object.material]) + materials.push({ + name: m.name, + type: m.type, + node: m.isNodeMaterial === true, + receiveShadow: object.receiveShadow, + castShadow: object.castShadow, + }); + }); + return { + environment: scene.environment + ? { + uuid: scene.environment.uuid, + version: scene.environment.version, + intensity: scene.environmentIntensity, + } + : null, + shadowMap: { enabled: raw.shadowMap.enabled, type: raw.shadowMap.type }, + width: raw.domElement.width, + height: raw.domElement.height, + samples: raw.samples, + lights, + materials, + }; + }); + await session.screenshot(label); + stages.push({ label, explicitPresentationWaits: presentations, snapshot, raw }); + await writeFile( + resolve(output, "controls.json"), + JSON.stringify( + { + arm, + provenance: session.provenance, + scope: + "explicit RAF waits, screenshots and bridge observation can also present; fixed gameplay clock unchanged; no compileComplete inference", + stages, + }, + null, + 2, + ), + ); + }; + await capture("initial"); + await present(20); + await capture("after-20-presentations"); + }); + console.log(`${arm} racing controls PASS`); +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.cpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.cpu.mjs new file mode 100644 index 0000000000..764fae400d --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.cpu.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { Color, Scene } from "three"; +import { reportEnvironmentContribution } from "./environment.ts"; +import { assertEnvironmentMarker } from "./report-proof.ts"; +const records = []; +const original = console.info; +try { + console.info = (...args) => records.push({ text: args.join(" ") }); + reportEnvironmentContribution(new Scene(), { + darkThreshold: 0.001, + rimGain: 0, + fillGain: 1, + fillAdmitted: true, + fillColor: new Color(0.1, 0.2, 0.3), + maxSourceTexels: 16, + }); +} finally { + console.info = original; +} +const expected = { environmentState: "missing", rimGain: 0, fillEnabled: true, blackFill: false }; +assert.throws( + () => assertEnvironmentMarker(records, { ...expected, environmentState: "dark" }), + /INVALID/, +); +assertEnvironmentMarker(records, expected); +assert.throws(() => assertEnvironmentMarker([], expected), /MISSING/); +assert.throws(() => assertEnvironmentMarker([...records, ...records], expected), /DUPLICATE/); +assert.throws( + () => assertEnvironmentMarker([{ text: "TN_ENVIRONMENT_CONTRIBUTION:{}" }], expected), + /INVALID/, +); +const corrupt = JSON.parse(records[0].text.split(":").slice(1).join(":")); +corrupt.meanRadiance = 1; +assert.throws( + () => + assertEnvironmentMarker( + [{ text: `TN_ENVIRONMENT_CONTRIBUTION:${JSON.stringify(corrupt)}` }], + expected, + ), + /INVALID/, +); +console.log( + "PASS structured measured marker, zero override, missing/duplicate/invalid and forged-dark controls; CPU only.", +); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.ts b/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.ts new file mode 100644 index 0000000000..4c7b98bb59 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/report-proof.ts @@ -0,0 +1,49 @@ +// Bounded proof over actual retained console records, matching the exposure verifier pattern. +export function assertEnvironmentMarker( + records: readonly { text: string }[], + expected: { + environmentState: "dark" | "missing"; + rimGain: number; + fillEnabled: boolean; + blackFill: boolean; + }, +) { + if (!Array.isArray(records) || records.length > 4096) + throw new Error("TN_ENVIRONMENT_RECORDS_INVALID"); + const prefix = "TN_ENVIRONMENT_CONTRIBUTION:"; + const markers = records.filter( + (entry) => typeof entry?.text === "string" && entry.text.startsWith(prefix), + ); + if (markers.length !== 1) throw new Error("TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE"); + const text = markers[0]?.text; + if (text.length > 65536) throw new Error("TN_ENVIRONMENT_MARKER_OVERSIZE"); + const value = JSON.parse(text.slice(prefix.length)); + if ( + value?.status !== "measured" || + value.meanRadiance !== 0 || + !Array.isArray(value.meanRGB) || + value.meanRGB.length !== 3 || + !value.meanRGB.every((sample: unknown) => sample === 0) || + value.environmentState !== expected.environmentState || + value.ibl !== "non-contributing" || + value.rimGain !== expected.rimGain || + value.analyticFill?.admitted !== expected.fillEnabled || + value.analyticFill?.blackColorOverride !== expected.blackFill || + !Number.isFinite(value.analyticFill?.effectiveGain) || + value.analyticFill.effectiveGain !== (expected.fillEnabled ? 1 : 0) || + !Array.isArray(value.analyticFill?.color) || + value.analyticFill.color.length !== 3 || + !value.analyticFill.color.every( + (sample: unknown) => typeof sample === "number" && Number.isFinite(sample) && sample >= 0, + ) + ) + throw new Error("TN_ENVIRONMENT_MARKER_INVALID"); + const black = value.analyticFill.color.every((sample: number) => sample === 0); + if ( + black !== expected.blackFill || + typeof value.analyticFill.reason !== "string" || + value.analyticFill.reason.length === 0 + ) + throw new Error("TN_ENVIRONMENT_FILL_INVALID"); + return value; +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series-proof.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series-proof.mjs new file mode 100644 index 0000000000..038c6d855f --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series-proof.mjs @@ -0,0 +1,73 @@ +// Task-owned frozen-source and readiness proof; no browser or renderer mutations. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, readdir, readlink } from "node:fs/promises"; +import { relative, resolve } from "node:path"; +export function launchOrder(count = 40) { + assert.ok(Number.isSafeInteger(count) && count > 0 && count % 2 === 0); + return Array.from({ length: count / 2 }, () => ["before", "after", "after", "before"]).flat(); +} +export function statistics(values) { + assert.ok(values.length > 0 && values.every((value) => Number.isFinite(value) && value >= 0)); + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return { + count: sorted.length, + median: sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2, + p95: sorted[Math.ceil(sorted.length * 0.95) - 1], + maximum: sorted.at(-1), + method: "nearest rank ceil(0.95*n), one-based", + }; +} +export async function treeFingerprint(root) { + const files = []; + async function walk(directory) { + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if (["node_modules", ".git", "dist", ".vite", "artifacts"].includes(entry.name)) continue; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) + files.push({ + path: relative(root, path), + sha256: createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + }); + else if (entry.isSymbolicLink()) + files.push({ + path: relative(root, path), + symlinkTarget: await readlink(path), + policy: "target text retained; never traversed", + }); + else throw new Error(`Unqualified source-tree entry: ${path}`); + } + } + await walk(root); + return { root, files, sha256: createHash("sha256").update(JSON.stringify(files)).digest("hex") }; +} +export function validateSample(provenance, readiness, observed) { + assert.equal(provenance.rendererKind, "webgpu"); + assert.equal(provenance.target, "web"); + assert.equal(provenance.adapter.vendor, "nvidia"); + assert.equal(provenance.adapter.architecture, "turing"); + assert.deepEqual(provenance.viewport, { width: 1280, height: 720 }); + assert.equal(readiness.rule, "sustained-frames"); + const startup = readiness.startup; + assert.equal(startup.phase, "ready"); + const { readyMs, loadStartedMs } = startup.timeline; + assert.ok( + Number.isFinite(readyMs) && + Number.isFinite(loadStartedMs) && + readyMs >= loadStartedMs && + loadStartedMs >= 0, + ); + assert.equal(observed.backendWebGL, false); + assert.equal(observed.width, 1280); + assert.equal(observed.height, 720); + assert.equal(observed.samples, 4); + assert.ok(!startup.holds?.some((hold) => hold.expired)); + assert.ok((startup.warmup?.observed?.failed ?? 0) === 0, "Reported startup compilation failed"); + return { navigationReadyMs: readyMs, sceneLoadToReadyMs: readyMs - loadStartedMs }; +} diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.cpu.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.cpu.mjs new file mode 100644 index 0000000000..7bf467e8e5 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.cpu.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { launchOrder, statistics, validateSample } from "./startup-series.mjs"; +const order = launchOrder(); +assert.equal(order.length, 80); +assert.equal(order.filter((a) => a === "before").length, 40); +assert.deepEqual(order.slice(0, 8), [ + "before", + "after", + "after", + "before", + "before", + "after", + "after", + "before", +]); +assert.equal(statistics(Array.from({ length: 40 }, (_, i) => 40 - i)).p95, 38); +assert.equal(statistics([1, 2, 3, 4]).median, 2.5); +assert.throws(() => statistics([Number.NaN])); +assert.throws(() => launchOrder(3)); +const provenance = { + rendererKind: "webgpu", + target: "web", + adapter: { vendor: "nvidia", architecture: "turing" }, + viewport: { width: 1280, height: 720 }, +}; +const ready = { + rule: "sustained-frames", + startup: { phase: "ready", timeline: { readyMs: 900, loadStartedMs: 600 } }, +}; +const observed = { backendWebGL: false, width: 1280, height: 720, samples: 4 }; +assert.deepEqual(validateSample(provenance, ready, observed), { + navigationReadyMs: 900, + sceneLoadToReadyMs: 300, +}); +assert.throws(() => validateSample(provenance, ready, { ...observed, backendWebGL: true })); +assert.throws(() => + validateSample( + provenance, + { ...ready, startup: { ...ready.startup, timeline: { readyMs: 500, loadStartedMs: 600 } } }, + observed, + ), +); +console.log( + "PASS balanced 40/arm launch order, nearest-rank p95, median, invalid statistics, readiness clock and backend controls; no GPU execution.", +); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.mjs new file mode 100644 index 0000000000..538b2a49eb --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/startup-series.mjs @@ -0,0 +1,186 @@ +// Task-owned serial startup proof. No screenshots, scene restart or camera mutations. +import assert from "node:assert/strict"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + launchOrder, + statistics, + treeFingerprint, + validateSample, +} from "./startup-series-proof.mjs"; +export { launchOrder, statistics, validateSample } from "./startup-series-proof.mjs"; +async function run() { + const { parseStandalonePlaytestArgs, withBrowserCapture } = await import( + "../../../../playtest/dist/runner/index.js" + ); + const output = resolve(process.argv[2] ?? "artifacts/backlight-defaults/startup-series1"); + await mkdir(output, { recursive: true }); + // Refuse accidentally replacing an existing series. + assert.equal((await readdir(output)).length, 0, "Output directory must be empty"); + const roots = { + before: resolve(process.argv[3] ?? "artifacts/backlight-defaults/clean-starter-before2"), + after: resolve(process.argv[4] ?? "artifacts/backlight-defaults/clean-starter-after3"), + }; + const urls = { + before: process.argv[5] ?? "http://127.0.0.1:5194/", + after: process.argv[6] ?? "http://127.0.0.1:5195/", + }; + const frozen = { + before: await treeFingerprint(roots.before), + after: await treeFingerprint(roots.after), + }; + await writeFile(resolve(output, "frozen-trees.json"), JSON.stringify(frozen, null, 2)); + const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", + ]; + const samples = []; + let matched; + for (const [index, arm] of launchOrder().entries()) { + const directory = resolve(output, `${String(index + 1).padStart(3, "0")}-${arm}`); + const row = { index: index + 1, arm, success: false, directory }; + try { + assert.equal( + (await treeFingerprint(roots[arm])).sha256, + frozen[arm].sha256, + "Frozen source changed", + ); + const config = parseStandalonePlaytestArgs([ + "--scenario", + "packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json", + "--url", + urls[arm], + "--timeout", + "60000", + "--artifacts", + directory, + ...flags.flatMap((flag) => ["--browser-arg", flag]), + ]); + await withBrowserCapture(config, async (session) => { + row.provenance = session.provenance; + row.captureSession = JSON.parse( + await readFile(resolve(directory, "capture-session.json"), "utf8"), + ); + row.snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations"], + label: "startup-series-ready", + }); + row.observed = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const raw = game.ctx.renderer.raw; + return { + backendWebGL: raw.backend.isWebGLBackend === true, + width: raw.domElement.width, + height: raw.domElement.height, + samples: raw.samples, + camera: { + position: game.ctx.camera.position.toArray(), + rotation: game.ctx.camera.quaternion.toArray(), + }, + }; + }); + assert.ok( + !row.snapshot.diagnostics?.some( + (value) => value && typeof value === "object" && value.severity === "error", + ), + "Runtime diagnostics reported an error", + ); + row.metrics = validateSample(row.provenance, row.captureSession.startup, row.observed); + const identity = { + browserArgs: row.provenance.browserArgs, + adapter: row.provenance.adapter, + clock: row.snapshot.clock, + camera: row.observed.camera, + state: row.snapshot.state, + }; + if (matched === undefined) matched = identity; + else assert.deepEqual(identity, matched, "Matched launch controls drifted"); + row.success = true; + }); + } catch (error) { + row.success = false; + row.error = { name: error.name, message: error.message, stack: error.stack }; + // Preserve any provenance/readiness artifact emitted before callback failure. + for (const [key, file] of [ + ["provenance", "capture.json"], + ["captureSession", "capture-session.json"], + ]) { + if (row[key] === undefined) + try { + row[key] = JSON.parse(await readFile(resolve(directory, file), "utf8")); + } catch {} + } + } + samples.push(row); + await writeFile(resolve(output, "samples.json"), JSON.stringify(samples, null, 2)); + console.log( + `${index + 1}/80 ${arm}: ${row.success ? `${row.metrics.navigationReadyMs} ms` : row.error.message}`, + ); + if (index < 3 && !row.success) { + await writeFile( + resolve(output, "early-stop.json"), + JSON.stringify( + { + pass: false, + reason: "Initial launch failed; stop before repeating invalid controls", + sample: row, + }, + null, + 2, + ), + ); + process.exitCode = 1; + return; + } + } + const finalTrees = { + before: await treeFingerprint(roots.before), + after: await treeFingerprint(roots.after), + }; + const unchanged = ["before", "after"].every( + (arm) => frozen[arm].sha256 === finalTrees[arm].sha256, + ); + const pass = unchanged && samples.length === 80 && samples.every((row) => row.success); + const arms = Object.fromEntries( + ["before", "after"].map((arm) => { + const successes = samples.filter((row) => row.arm === arm && row.success); + return [ + arm, + { + attempted: 40, + failures: 40 - successes.length, + navigationReadyMs: successes.length + ? statistics(successes.map((row) => row.metrics.navigationReadyMs)) + : null, + sceneLoadToReadyMs: successes.length + ? statistics(successes.map((row) => row.metrics.sceneLoadToReadyMs)) + : null, + }, + ]; + }), + ); + const result = { + pass, + unchanged, + arms, + p95DeltaMs: pass ? arms.after.navigationReadyMs.p95 - arms.before.navigationReadyMs.p95 : null, + scope: + "fresh Chromium process/context/page; engine ready timeline performance.now origin; load-to-ready also retained; no browser launch/lease/server timing", + cache: + "fresh page/HTTP cache; OS filesystem and GPU driver shader caches uncontrolled; NOT shader-cold", + limitations: + "40 samples/arm is empirical engineering evidence, not a precise population-tail estimate. Failures retained; successful-only statistics are unqualified if any failure occurs. No compileComplete or full restart readiness inferred.", + samples, + }; + await writeFile(resolve(output, "summary.json"), JSON.stringify(result, null, 2)); + if (!pass) process.exitCode = 1; +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await run(); diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix-files.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix-files.mjs new file mode 100644 index 0000000000..97131f04bf --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix-files.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { relative, resolve } from "node:path"; +export async function command(bin, args, directory, log) { + await mkdir(resolve(log, ".."), { recursive: true }); + const child = spawn(bin, args, { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (data) => { + output += data; + }); + child.stderr.on("data", (data) => { + output += data; + }); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + await writeFile(log, output); + assert.equal(code, 0, `${bin} failed; see ${log}`); +} +export async function fingerprint(root) { + const rows = []; + async function walk(dir) { + for (const e of (await readdir(dir, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if ( + [ + "node_modules", + ".git", + "dist", + ".vite", + ".agents", + ".claude", + ".codex", + "tools", + "scripts", + "playtests", + ].includes(e.name) + ) + continue; + const p = resolve(dir, e.name); + if (e.isDirectory()) await walk(p); + else if (e.isFile()) + rows.push([ + relative(root, p), + createHash("sha256") + .update(await readFile(p)) + .digest("hex"), + ]); + } + } + await walk(root); + return { + sha256: createHash("sha256").update(JSON.stringify(rows)).digest("hex"), + files: rows, + excluded: + "dependencies/build outputs and authoring tooling; runtime source/config/assets retained", + }; +} + +export const admitted = [ + "minimal", + "starter", + "platformer", + "runner", + "shooter", + "racing", + "action-rpg", + "rts", + "tower-defense", + "puzzle", + "sailing", + "snow", +]; diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix.mjs new file mode 100644 index 0000000000..b6145f31a2 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/template-matrix.mjs @@ -0,0 +1,197 @@ +// Task-owned matched generated-template proof. Invoke with local tsx; no network install. +import assert from "node:assert/strict"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { compileAssets } from "../../../../assets/dist/index.js"; +import { + parseStandalonePlaytestArgs, + withBrowserCapture, +} from "../../../../playtest/dist/runner/index.js"; +import { createProject } from "../../../src/index.ts"; +import { admitted, command, fingerprint } from "./template-matrix-files.mjs"; +const cwd = process.cwd(); +const base = resolve( + process.env.TN_BACKLIGHT_MATRIX_OUTPUT ?? "artifacts/backlight-defaults/template-matrix1", +); +const templates = process.argv.slice(2); +assert.ok(templates.length > 0, "Name templates; start with minimal"); +assert.ok(templates.every((t) => admitted.includes(t))); // Rain intentionally excluded. +await mkdir(base, { recursive: true }); +const archiveRoot = resolve("artifacts/backlight-defaults/template-before-source"); +try { + await readFile(resolve(archiveRoot, "source.json")); +} catch { + await mkdir(archiveRoot, { recursive: true }); + const archive = resolve(archiveRoot, "before.tar"); + await command( + "git", + ["archive", "--format=tar", `--output=${archive}`, "8cb5d3e8b", "packages/create-threenative"], + cwd, + resolve(base, "archive.log"), + ); + await command("tar", ["-xf", archive, "-C", archiveRoot], cwd, resolve(base, "extract.log")); + await writeFile( + resolve(archiveRoot, "source.json"), + JSON.stringify({ base: "8cb5d3e8b", method: "git archive own task checkpoint" }), + ); +} +const flags = [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--use-vulkan=native", + "--disable-vulkan-fallback-to-gl-for-testing", +]; +let results = []; +try { + results = JSON.parse(await readFile(resolve(base, "matrix.json"), "utf8")).results; +} catch (error) { + if (error.code !== "ENOENT") throw error; +} +assert.ok( + !templates.some((template) => results.some((row) => row.template === template)), + "Already recorded template; use a separate matrix directory for retry", +); +for (const template of templates) { + const pair = { template, pass: false, arms: {} }; + for (const arm of ["before", "after"]) { + const project = resolve(base, `${template}-${arm}`); + const output = resolve(base, `${template}-${arm}-capture`); + const row = { project, output, pass: false }; + pair.arms[arm] = row; + try { + const root = + arm === "before" + ? resolve(archiveRoot, "packages/create-threenative/templates") + : resolve("packages/create-threenative/templates"); + await createProject({ target: project, template, install: false }, cwd, root); + await symlink( + resolve("artifacts/backlight-defaults/clean-starter-before2/node_modules"), + resolve(project, "node_modules"), + "dir", + ); + row.source = await fingerprint(project); + const { default: config } = await import( + pathToFileURL(resolve(project, "threenative.config.ts")).href + ); + await compileAssets({ config: config.assets, cwd: project, platform: "web" }); + await command( + "node", + [resolve(project, "node_modules/vite/bin/vite.js"), "build"], + project, + resolve(base, `${template}-${arm}-build.log`), + ); + row.cooked = await fingerprint(resolve(project, "public")); + // Vite dev serves exact generated source; its existing asset watcher reuses cooked assets. + const capture = parseStandalonePlaytestArgs([ + "--project", + project, + "--scenario", + resolve( + "packages/create-threenative/__tests__/fixtures/backlight-defaults/cost.playtest.json", + ), + "--url", + "http://127.0.0.1:5196/", + "--port", + "5196", + "--server-command", + `node ${resolve(project, "node_modules/vite/bin/vite.js")} --host 127.0.0.1 --port 5196 --strictPort > ${resolve(base, `${template}-${arm}-server.log`)} 2>&1`, + "--timeout", + "60000", + "--artifacts", + output, + ...flags.flatMap((f) => ["--browser-arg", f]), + ]); + await withBrowserCapture(capture, async (session) => { + row.captureSession = JSON.parse( + await readFile(resolve(output, "capture-session.json"), "utf8"), + ); + row.provenance = session.provenance; + assert.equal(row.provenance.adapter.vendor, "nvidia"); + assert.equal(row.provenance.adapter.architecture, "turing"); + assert.equal(row.provenance.rendererKind, "webgpu"); + assert.deepEqual(row.provenance.viewport, { width: 1280, height: 720 }); + row.snapshot = await session.bridge.sample({ + include: ["components", "state", "scene", "runtimeObservations", "renderChain"], + label: "matched-template-ready", + }); + row.observed = await session.page.evaluate(async () => { + const { default: game } = await import("/src/game.ts"); + const raw = game.ctx.renderer.raw; + return { + width: raw.domElement.width, + height: raw.domElement.height, + backendWebGL: raw.backend.isWebGLBackend === true, + samples: raw.samples, + camera: { + position: game.ctx.camera.position.toArray(), + rotation: game.ctx.camera.quaternion.toArray(), + }, + }; + }); + assert.equal(row.observed.backendWebGL, false); + assert.equal(row.observed.width, 1280); + assert.equal(row.observed.height, 720); + assert.ok(!row.snapshot.diagnostics?.some((r) => r?.severity === "error")); + await session.screenshot("default-template-ready"); + await writeFile(resolve(output, "snapshot.json"), JSON.stringify(row, null, 2)); + }); + row.pass = true; + } catch (error) { + row.error = { message: error.message, stack: error.stack }; + } + await writeFile(resolve(base, `${template}-${arm}-result.json`), JSON.stringify(row, null, 2)); + console.log(`${template} ${arm}: ${row.pass ? "PASS" : row.error.message}`); + } + if (pair.arms.before.pass && pair.arms.after.pass) { + try { + assert.deepEqual( + pair.arms.before.observed, + pair.arms.after.observed, + "Camera/buffer/backend mismatch", + ); + assert.deepEqual( + pair.arms.before.snapshot.clock, + pair.arms.after.snapshot.clock, + "Pose clock mismatch", + ); + assert.deepEqual( + pair.arms.before.snapshot.state, + pair.arms.after.snapshot.state, + "Content state mismatch", + ); + assert.equal( + pair.arms.before.cooked.sha256, + pair.arms.after.cooked.sha256, + "Cooked assets mismatch", + ); + pair.pass = true; + } catch (error) { + pair.error = error.message; + } + } + results.push(pair); + await writeFile( + resolve(base, "matrix.json"), + JSON.stringify( + { + pass: results.every((r) => r.pass), + scope: + "matched default generated source, actual hardware WebGPU; visual admission requires pixel review", + excluded: ["rain untouched"], + results, + }, + null, + 2, + ), + ); + if (template === "minimal" && !pair.pass) { + process.exitCode = 1; + break; + } +} +if (results.some((r) => !r.pass)) process.exitCode = 1; diff --git a/packages/create-threenative/__tests__/fixtures/backlight-defaults/verify-marker.mjs b/packages/create-threenative/__tests__/fixtures/backlight-defaults/verify-marker.mjs new file mode 100644 index 0000000000..2e12d8a3b6 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/backlight-defaults/verify-marker.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; +import { assertEnvironmentMarker } from "./report-proof.ts"; +const [positive, omitted] = process.argv.slice(2); +if (!positive || !omitted) + throw new Error("Expected actual positive and omitted console.json paths."); +async function records(path) { + if ((await stat(path)).size > 1048576) throw new Error("Console proof exceeds 1 MiB."); + return JSON.parse(await readFile(path, "utf8")); +} +const expected = { environmentState: "dark", rimGain: 0.12, fillEnabled: true, blackFill: false }; +const report = assertEnvironmentMarker(await records(positive), expected); +const omittedRecords = await records(omitted); +assert.throws( + () => assertEnvironmentMarker(omittedRecords, expected), + /TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE/, +); +console.log( + JSON.stringify({ + positive, + omitted, + pass: true, + actualReport: report, + negativeFailure: "TN_ENVIRONMENT_MARKER_MISSING_OR_DUPLICATE", + }), +); diff --git a/packages/create-threenative/__tests__/minimal-material-lighting.spec.ts b/packages/create-threenative/__tests__/minimal-material-lighting.spec.ts new file mode 100644 index 0000000000..d8f06de774 --- /dev/null +++ b/packages/create-threenative/__tests__/minimal-material-lighting.spec.ts @@ -0,0 +1,69 @@ +import { + BoxGeometry, + Camera, + Color, + DirectionalLight, + Mesh, + MeshStandardMaterial, + Scene, +} from "three"; +import { afterEach, expect, test, vi } from "vitest"; +import { + createLightingConvention, + setupLighting, +} from "../templates/minimal/src/render/lighting.js"; +import { createMaterialLighting } from "../templates/minimal/src/render/materialLighting.js"; +import { materialLightingEnabled, qualityPreset } from "../templates/minimal/src/render/quality.js"; +const apply = vi.hoisted(() => vi.fn()); +vi.mock("../templates/minimal/src/render/worldEnvironment.js", () => ({ + WorldEnvironment: class { + apply = apply; + }, +})); +import { setupPost } from "../templates/minimal/src/render/postprocessing.js"; +afterEach(() => vi.restoreAllMocks()); +test("minimal preserves its authored sun and independent convention overrides", () => { + const scene = new Scene(); + const { key } = setupLighting(scene, { shadowMap: { enabled: false, type: 0 } }); + expect(key.intensity).toBe(4.5); + expect(key.color.equals(new Color(0xfff1e0))).toBe(true); + const convention = createLightingConvention({ rimGain: 0 }); + expect(convention.rimGain).toBe(0); + expect(createLightingConvention().rimGain).toBe(0.12); + expect(qualityPreset("high").ssgiEnabled).not.toBe(true); +}); +test("minimal converts attached materials only on admitted desktop and restores borrowed sources", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const scene = new Scene(); + const source = new MeshStandardMaterial(); + const mesh = new Mesh(new BoxGeometry(), source); + scene.add(mesh); + const environment = { web: true, rendererKind: "webgpu" }; + const controller = createMaterialLighting(scene, new Camera(), new DirectionalLight(), { + ...environment, + enabled: materialLightingEnabled("high", environment), + }); + expect(mesh.material).not.toBe(source); + controller.setEnabled(false); + expect(mesh.material).toBe(source); + controller.dispose(); + expect(materialLightingEnabled("high", { ...environment, web: false })).toBe(false); +}); +test("minimal post owns its graph and invokes the tier callback after apply", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const events: string[] = []; + const dispose = vi.fn(); + apply.mockImplementation(() => { + events.push("apply"); + return { dispose }; + }); + const post = setupPost({} as never, new Scene(), new Camera(), { + tier: "low", + onTierChanged: () => events.push("tier"), + }); + expect(events).toEqual(["apply", "tier"]); + expect(post.tier).toBe("low"); + post.dispose(); + post.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); +}); diff --git a/packages/create-threenative/__tests__/racing.spec.ts b/packages/create-threenative/__tests__/racing.spec.ts index 3ddf350348..ea3f863cbf 100644 --- a/packages/create-threenative/__tests__/racing.spec.ts +++ b/packages/create-threenative/__tests__/racing.spec.ts @@ -135,7 +135,11 @@ describe("the circuit", () => { // TN_PLAYTEST_CAPABILITY_MISSING for `runtime.components`. const load = race.slice(race.indexOf("override load"), race.indexOf("override enter")); expect(load).toContain('texture("sky.jpg")'); - expect(load).not.toMatch(/\bawait\b/u); + // The sampling continuation may await after decode; the load hook itself must still + // return immediately so the player registry is populated synchronously. + expect(load).toContain("override load(ctx: GameCtx): void"); + expect(load).toContain('void ctx.assets.texture("sky.jpg").then('); + expect(load.split(".then(")[0]).not.toMatch(/\bawait\b/u); }); }); diff --git a/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts b/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts index c67c7c7110..f7bebaaee6 100644 --- a/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/rts-runtime-cost.spec.ts @@ -783,7 +783,10 @@ describe("rts kit ordinary-frame runtime cost", () => { }, random: { range: (from: number, to: number) => from + 1 }, renderer: { - createRenderChain: () => ({ applied: { dropped: [], stages: ["bloom"] } }), + createRenderChain: () => ({ + applied: { dropped: [], stages: ["bloom"] }, + dispose: () => undefined, + }), kind: "webgl", raw: { shadowMap: { enabled: false, type: 0 } }, setOutputNode: () => undefined, diff --git a/packages/create-threenative/__tests__/sailing-readiness.spec.ts b/packages/create-threenative/__tests__/sailing-readiness.spec.ts index 4b7d99d277..9a8e639d86 100644 --- a/packages/create-threenative/__tests__/sailing-readiness.spec.ts +++ b/packages/create-threenative/__tests__/sailing-readiness.spec.ts @@ -64,7 +64,17 @@ vi.mock("../templates/sailing/src/render/ocean.js", () => ({ surfaceHeight: vi.fn(() => 0), })); vi.mock("../templates/sailing/src/render/postprocessing.js", () => ({ - setupPost: vi.fn(), + setupPost: vi.fn(() => ({ tier: "high", dispose: vi.fn() })), +})); +vi.mock("../templates/sailing/src/render/materialLighting.js", () => ({ + createMaterialLighting: vi.fn(() => ({ + setEnabled: vi.fn(), + setEnvironmentMeasurement: vi.fn(), + dispose: vi.fn(), + })), +})); +vi.mock("../templates/sailing/src/render/environmentSetup.js", () => ({ + loadedEnvironmentSample: vi.fn(() => undefined), })); vi.mock("../templates/sailing/src/render/props.js", () => ({ createBuoy: vi.fn(() => ({ position: { set: vi.fn() } })), diff --git a/packages/create-threenative/__tests__/sailing-snow-material-lighting.spec.ts b/packages/create-threenative/__tests__/sailing-snow-material-lighting.spec.ts new file mode 100644 index 0000000000..a1a0e3eafb --- /dev/null +++ b/packages/create-threenative/__tests__/sailing-snow-material-lighting.spec.ts @@ -0,0 +1,45 @@ +import { Color, Scene } from "three"; +import { expect, test, vi } from "vitest"; +import { + createLightingConvention as sailingConvention, + setupLighting as sailingLights, +} from "../templates/sailing/src/render/lighting.js"; +import { palette } from "../templates/sailing/src/render/palette.js"; +import { + createLightingConvention as snowConvention, + setupLighting as snowLights, +} from "../templates/snow/src/render/lighting.js"; +import { materialLightingEnabled } from "../templates/snow/src/render/quality.js"; +test("sailing preserves authored sun palette while exposing independent material controls", () => { + const scene = new Scene(); + const key = sailingLights(scene, { shadowMap: { enabled: false, type: 0 } }); + expect(key.intensity).toBe(3.4); + expect(key.color.equals(new Color(palette.player))).toBe(true); + expect(sailingConvention().rimGain).toBe(0.12); +}); +test("snow preserves storm lights and defaults analytic fill to zero", () => { + const scene = new Scene(); + const lights = snowLights(scene, { shadowMap: { enabled: false, type: 0 } }); + expect(scene.children).toHaveLength(4); + lights.update({ x: 0, z: 0 }, 1); + expect(lights.sun.intensity).toBeCloseTo(0.4); + expect(snowConvention().fillGain).toBe(0); + expect(snowConvention().rimGain).toBe(0.12); + expect(materialLightingEnabled("high", { web: false, rendererKind: "webgpu" })).toBe(false); +}); + +test("sailing setup samples the loaded source without disposing borrowed sky textures", async () => { + const { Texture } = await import("three"); + const { loadSky } = await import("../templates/sailing/src/render/sky.js"); + const { prepareEnvironmentSample, loadedEnvironmentSample } = await import( + "../templates/sailing/src/render/environmentSetup.js" + ); + const texture = new Texture(); + const dispose = vi.spyOn(texture, "dispose"); + await loadSky({ texture: async () => texture }); + await prepareEnvironmentSample({}, { web: false, rendererKind: "webgpu" }); + const sample = loadedEnvironmentSample(); + expect(sample?.source).toBe(texture); + expect(sample?.measurement.status).toBe("unknown"); + expect(dispose).not.toHaveBeenCalled(); +}); diff --git a/packages/create-threenative/__tests__/sailing.spec.ts b/packages/create-threenative/__tests__/sailing.spec.ts index 9819ba0484..555164e674 100644 --- a/packages/create-threenative/__tests__/sailing.spec.ts +++ b/packages/create-threenative/__tests__/sailing.spec.ts @@ -62,7 +62,18 @@ vi.mock("../templates/sailing/src/render/ocean.js", () => ({ surfaceHeight: vi.fn(() => 0), })); vi.mock("../templates/sailing/src/render/postprocessing.js", () => ({ - setupPost: vi.fn(), + setupPost: vi.fn(() => ({ tier: "high", dispose: vi.fn() })), +})); +// These cases exercise course scoring, not appearance; material lifecycle has dedicated tests. +vi.mock("../templates/sailing/src/render/materialLighting.js", () => ({ + createMaterialLighting: vi.fn(() => ({ + setEnabled: vi.fn(), + setEnvironmentMeasurement: vi.fn(), + dispose: vi.fn(), + })), +})); +vi.mock("../templates/sailing/src/render/environmentSetup.js", () => ({ + loadedEnvironmentSample: vi.fn(() => undefined), })); vi.mock("../templates/sailing/src/render/props.js", () => ({ createBuoy: vi.fn(() => ({ position: { set: vi.fn() } })), diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index b240e322e4..df62e230e9 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,24 +173,24 @@ const BUG_REPORT_SKILL_PATHS = [ // Current develop c18a42b integration: all13 actual generated trees were byte-compared // against reviewed 8bf trees; only the copied Three compute-only Storage3DTexture patch changed. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { - // 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` is the only byte that moved on top of the exposure/fog tree, so - // all thirteen trees move again. Values below are the merged-tree measurement, not either side's. - "action-rpg": "6aa05e1a9e39719f30242ddee588a91236fbfdbf6b75ec252e91d56effccb49a", - minimal: "ced30816bf592b829d655941da8cfb8555295fe45fd915d73ed1605a15a2a8f9", - platformer: "de6323b7fd05a66ce5e89415df962ed59eae765c65346a71488c609381bf14c6", - puzzle: "5c7eea6460b6f6718018cf3a5ffd9f9ccaeffa219fc3337cb7216008af2ef03f", - racing: "0e9c54935e58bacf835aa10e6d633f1360da394e759714903d7d36f6e04bf38b", - rain: "9b406dc1d87df403e4bff9f825fa4ad824e71cfb77dfb482d0a4da5b54f4e91b", - rts: "54b9ee0cdfe8d4cae460efacc348fba30ac6bf04680b282c789eeec619ed2481", - runner: "30695f2421a37adbeace02a2d30523da187c208b37252d93970f38f1618f064b", - // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "a4e1a5c72086968e3137eb6b4a8168d0a74f28d292a21f4f82bcd355d9e9138e", - shooter: "668cb308dfaaa2cc86431bb71db895f8e5c8d9e6562bb4e333ac5a0b6618e046", - snow: "550ef7eff2c6df576716145d3b75c2956ac6413a24a1d5463c4d6c6c6ca9575b", - starter: "77d9c77d83cfc2d28e857303f07c1cca498e1135ee3474ac27fac43dfb6337a9", - "tower-defense": "ebed6e13a9be25ba48f6dcb41e2a23fc4798b074d46af688333c326771e919dc", + // Recomputed 2026-10-03 on the merge of develop (PRD-339 exposure/fog, PRD-423 frame + // consolidation) into the PRD-345 backlight/defaults branch: the merged tree carries both + // sides' engine, manifest and template bytes, so all thirteen generated trees were + // re-measured through createProject on that merged tree. Values below are the merged-tree + // measurement, not either side's. + "action-rpg": "85dede7f62295e7f286944727cc64de40dc759d9324cb38d02c6b03bfcf9eeea", + minimal: "168ed30d0c543c5973c4f642968ed1672cc85e10b94fdb687f851f70a0cdce08", + platformer: "6de157b3c2cfa0034d48f8326b9f1a90fe90ebce3bf265ac91bae379482d017b", + puzzle: "54c2a109b3c8d0575a06718b3e8f2c1c3bb75a8d968d7afb3215ac998e11b79c", + racing: "7b31cdc89f985f7431e1ef294dba9c6010fdebef6f250d767f86f6f6fcdb4d21", + rain: "9ba4b5d3bd72d2803db42568d1126c7dd96173b51acaa8c484d6d2139859f5fd", + rts: "a2d9006abe0b33c04b272c8ba2120d780e88782d1bdcee0c4d40635886a7adc3", + runner: "2d7dd25c0d763b84dd08f591d8bf124fb169ccba17e4b0b0bf967ac37d9a2258", + sailing: "7b87e05b98e3136cae944eab416442108dc1edf1f4ebe759e8ce3e24b58aece9", + shooter: "adecce922b383deb3c44f66cd518a2b433acee0ef23f3e2b11900eb05c674902", + snow: "791580f3b7314d4983a2cbf57b87b83be2e4740fa63e204327826120f1a0cf12", + starter: "5ab4d1828be78d82e7b83fbb2e07afa998372314ffb46accbaffd3dc4ad8cfdd", + "tower-defense": "6e93920048bd67a0b9cfeec9d8d6ec8c71dd17d0af9e5ff40cb67e7c60aa9714", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts index dc9922414f..3aaa3b8558 100644 --- a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts @@ -239,7 +239,10 @@ function sceneContext( raw: { shadowMap: { enabled: false, type: 0 } }, // The real chain returns what it installed; a template reads that report back to print // TN_WORLD_ENVIRONMENT, so the double has to honour the contract rather than return void. - createRenderChain: () => ({ applied: { dropped: [], stages: ["bloom"] } }), + createRenderChain: () => ({ + applied: { dropped: [], stages: ["bloom"] }, + dispose: () => undefined, + }), setOutputNode: () => undefined, }, scene, @@ -694,6 +697,55 @@ 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 { Race } = await import("../templates/racing/src/scenes/Race.js"); + const physics = await physicsFixture(RACING_GRAVITY); + const context = sceneContext(physics.physics, Race.initialState); + let complete: (() => void) | undefined; + const sampleSpy = vi + .spyOn(sampling, "sampleEnvironment") + .mockImplementation((_renderer, scene) => { + const snapshot = captureEnvironmentSnapshot(scene); + return new Promise((resolve) => { + complete = () => + resolve({ + ...snapshot, + measurement: { + status: "measured", + meanRadiance: 0.25, + meanRGB: [0.25, 0.25, 0.25], + reason: "CPU timing fixture", + }, + }); + }); + }); + const race = new Race(); + try { + // A cached texture resolves before enter(), while the bounded sample is still pending. + expect(race.load(context as never)).toBeUndefined(); + await Promise.resolve(); + expect(sampleSpy).toHaveBeenCalledTimes(1); + expect(complete).toBeTypeOf("function"); + race.enter(context as never); + const controller = context.entities.get("material-lighting") as { + debug(): { status: string; meanRadiance: number | null }; + }; + expect(controller.debug().status).toBe("unknown"); + complete?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.debug()).toMatchObject({ status: "measured", meanRadiance: 0.25 }); + } finally { + race.exit(context as never); + sampleSpy.mockRestore(); + physics.dispose(); + } + }); + it("executes the racing scene player scan for 600 measured frames", async () => { const { Race } = await import("../templates/racing/src/scenes/Race.js"); const racingPhysics = await physicsFixture(RACING_GRAVITY); diff --git a/packages/create-threenative/__tests__/tower-material-lighting-lifecycle.spec.ts b/packages/create-threenative/__tests__/tower-material-lighting-lifecycle.spec.ts new file mode 100644 index 0000000000..5ba4e17cd2 --- /dev/null +++ b/packages/create-threenative/__tests__/tower-material-lighting-lifecycle.spec.ts @@ -0,0 +1,87 @@ +import { Camera, DirectionalLight, type Material, Mesh, Scene, Vector3 } from "three"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRandom } from "../../core/src/random.js"; +import { TOWER_KINDS } from "../templates/tower-defense/src/balance.js"; +import { createMaterialLighting } from "../templates/tower-defense/src/render/materialLighting.js"; +import { type ITowerWorld, Tower } from "../templates/tower-defense/src/towers/Tower.js"; + +// Physics queries are unused: exercise the real tower models and replacement lifecycle on CPU. +vi.mock("@threenative/physics", () => ({ + CollisionShape3D: { sphere: (radius: number) => ({ radius }) }, +})); +afterEach(() => vi.restoreAllMocks()); +test("actual tower upgrades and recycling release old receivers without disposing authored shared materials", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const scene = new Scene(); + const key = new DirectionalLight(); + scene.add(key); + const controller = createMaterialLighting(scene, new Camera(), key, { + enabled: true, + web: true, + rendererKind: "webgpu", + mobile: false, + software: false, + }); + const baseline = controller.debug(); + const world = { + random: createRandom(345), + enemies: new Map(), + effects: {}, + query: {}, + onShot() {}, + } as unknown as ITowerWorld; + const borrowed = new Map>(); + const owned = new Map>(); + const assignments = (root: Tower["group"]) => { + const result = new Map(); + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + result.set(object, object.material); + for (const material of Array.isArray(object.material) ? object.material : [object.material]) + if (!borrowed.has(material)) borrowed.set(material, vi.spyOn(material, "dispose")); + }); + return result; + }; + const enroll = (tower: Tower) => { + const originals = assignments(tower.group); + controller.enroll(tower.group); + expect(controller.debug().enrolledMeshes).toBeGreaterThan(baseline.enrolledMeshes); + expect(controller.debug().convertedMaterials).toBeGreaterThan(0); + tower.group.traverse((object) => { + if (!(object instanceof Mesh)) return; + for (const material of Array.isArray(object.material) ? object.material : [object.material]) + if (!borrowed.has(material) && !owned.has(material)) + owned.set(material, vi.spyOn(material, "dispose")); + }); + return originals; + }; + for (const kind of TOWER_KINDS) { + const tower = new Tower({ id: kind, kind, padIndex: 0, position: new Vector3(), world }); + scene.add(tower.group); + let originals = enroll(tower); + for (let level = 2; level <= 3; level++) { + const old = tower.group; + controller.release(old); + for (const [mesh, material] of originals) expect(mesh.material).toBe(material); + expect(controller.debug()).toMatchObject({ + enrolledMeshes: baseline.enrolledMeshes, + convertedMaterials: baseline.convertedMaterials, + }); + expect(tower.upgrade()).toBe(true); + expect(tower.group).not.toBe(old); + expect(old.parent).toBeNull(); + originals = enroll(tower); + } + controller.release(tower.group); + for (const [mesh, material] of originals) expect(mesh.material).toBe(material); + tower.dispose(); + expect(controller.debug()).toMatchObject({ + enrolledMeshes: baseline.enrolledMeshes, + convertedMaterials: baseline.convertedMaterials, + }); + } + controller.dispose(); + expect(owned.size).toBeGreaterThan(0); + for (const spy of owned.values()) expect(spy).toHaveBeenCalledTimes(1); + for (const spy of borrowed.values()) expect(spy).not.toHaveBeenCalled(); +}); diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 6789a58f6e..7d76c116d5 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -3806,6 +3806,21 @@ export class CaptureGuardError extends Error { … } throw new CaptureGuardError("menu", "no bright pixels"); ``` +### `collectRegionalTone` + +`function` — Collect opt-in regional tone observations from the same acquired PNG. + +```ts +export function collectRegionalTone( png: Buffer, assertions: readonly IPlaytestToneAssertion[], label: string, atStep?: string, ): IPlaytestRegionalToneObservation[] { … } +``` + +- **Use when:** measure a specified pixel crop in a captured playtest frame +- **Constraints:** does not acquire another screenshot or alter frame timing + +```ts +collectRegionalTone(png, assertions, "character", "posed"); +``` + ### `inspectFrame` `function` — Inspect a PNG frame for visible pixels and luminance variation. diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index a6686b28b8..40c18f633b 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -4793,6 +4793,20 @@ "supersedes": [], "symbol": "CaptureGuardError" }, + { + "aliases": [], + "constraints": ["does not acquire another screenshot or alter frame timing"], + "example": "collectRegionalTone(png, assertions, \"character\", \"posed\");", + "importPath": "@threenative/playtest/capture", + "kind": "function", + "overrides": [], + "package": "@threenative/playtest", + "signature": "export function collectRegionalTone( png: Buffer, assertions: readonly IPlaytestToneAssertion[], label: string, atStep?: string, ): IPlaytestRegionalToneObservation[] { … }", + "situations": ["measure a specified pixel crop in a captured playtest frame"], + "summary": "Collect opt-in regional tone observations from the same acquired PNG.", + "supersedes": [], + "symbol": "collectRegionalTone" + }, { "aliases": [], "constraints": [], diff --git a/packages/create-threenative/templates/action-rpg/AGENTS.md b/packages/create-threenative/templates/action-rpg/AGENTS.md index 7b3d749740..3889df176a 100644 --- a/packages/create-threenative/templates/action-rpg/AGENTS.md +++ b/packages/create-threenative/templates/action-rpg/AGENTS.md @@ -91,5 +91,5 @@ A scenario with no assertions or missing observations fails; keep the durable sm Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/action-rpg/CLAUDE.md b/packages/create-threenative/templates/action-rpg/CLAUDE.md index 8da588fd84..53674b7945 100644 --- a/packages/create-threenative/templates/action-rpg/CLAUDE.md +++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md @@ -93,5 +93,5 @@ A scenario with no assertions or missing observations fails; keep the durable sm Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts b/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/action-rpg/src/render/environmentContribution.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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/action-rpg/src/render/environmentRadiance.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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/environmentSampling.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentSampling.ts new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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/action-rpg/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/action-rpg/src/render/lighting.ts index ccb174d6bd..c9ab64c21a 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/lighting.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/lighting.ts @@ -4,7 +4,7 @@ // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. // The torch point lights in `dungeon.ts` are the only other sources, and they carry no shadows. -import { DirectionalLight, Object3D, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, Object3D, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { DUNGEON_CENTRE } from "./dungeon.js"; import { SUN_DIRECTION } from "./sky.js"; @@ -52,3 +52,28 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts b/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts b/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/action-rpg/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/action-rpg/src/render/postprocessing.ts b/packages/create-threenative/templates/action-rpg/src/render/postprocessing.ts index 86e3ab96cb..132d53fda1 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/postprocessing.ts @@ -26,8 +26,10 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + /** Scene-owned material assignments follow the resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,5 +42,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } 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 f3ac71c780..a40e96f1fe 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/quality.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/quality.ts @@ -127,3 +127,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} 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 f5f0abcfe0..2233a31500 100644 --- a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts +++ b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts @@ -3,6 +3,7 @@ import { Scene, type SceneFrame, afterPhysics, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -18,10 +19,17 @@ 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"; import { setupPost } from "../render/postprocessing.js"; import { createLootVisual } from "../render/props.js"; +import { + type QualityTier, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "../render/quality.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import { createArcaneSurge, createAttackArc, createHitBurst } from "../render/vfx.js"; @@ -84,6 +92,10 @@ export class Play extends Scene { #mannequin: IMannequin | undefined; #sky: Texture | undefined; + #post: ReturnType | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + override async load(ctx: GameCtx): Promise { // One rig, five instances. Quaternius' Universal Animation Library combat mannequin (CC0) is // the shared figure: the hero, the raiders and the boss are the same asset, so "add an enemy" @@ -92,6 +104,14 @@ export class Play extends Scene { ctx.assets.model("mannequin-combat.glb"), ctx.assets.texture("sky.jpg"), ]); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override enter(ctx: GameCtx): SceneFrame { @@ -111,7 +131,19 @@ export class Play extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code, exactly like createRandom. - setupPost(ctx.renderer, ctx.scene, camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + let materialTier: QualityTier = "low"; + this.#post = setupPost(ctx.renderer, ctx.scene, camera, { + onTierChanged: (tier) => { + materialTier = tier; + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)); + }, godraysLight: lighting.key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, @@ -233,6 +265,7 @@ export class Play extends Scene { .copy(player.mesh.position) .add(new Vector3(pendingVisuals.length * 0.8, 0, 1.2)); ctx.add(visual); + this.#materialLighting?.enroll(visual); pendingStacks.push(stack); pendingVisuals.push(visual); syncPendingLoot(); @@ -266,7 +299,9 @@ export class Play extends Scene { continue; } pendingStacks.splice(index, 1); - pendingVisuals[index]?.removeFromParent(); + const visual = pendingVisuals[index]; + if (visual !== undefined) this.#materialLighting?.release(visual); + visual?.removeFromParent(); pendingVisuals.splice(index, 1); changed = true; } @@ -443,6 +478,24 @@ export class Play extends Scene { afterPhysics(ctx, (dt) => cameraRig.follow(player.mesh.position, dt)); let lastSavedRoom = currentRoom; const frameState: Partial = {}; + // Collect only after the loaded character and scene receivers are attached. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, lighting.key, { + ...materialEnvironment, + enabled: materialLightingEnabled(materialTier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const sample = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); + } + return (frameCtx, dt) => { loading.update(); elapsed += dt; @@ -535,4 +588,13 @@ export class Play extends Scene { if (player.dead) frameCtx.state.set({ gameOver: 1, phase: "lost" }); }; } + + override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + super.exit(ctx); + } } diff --git a/packages/create-threenative/templates/minimal/AGENTS.md b/packages/create-threenative/templates/minimal/AGENTS.md index 86a7ae3ae5..3ecb4d225f 100644 --- a/packages/create-threenative/templates/minimal/AGENTS.md +++ b/packages/create-threenative/templates/minimal/AGENTS.md @@ -1,5 +1,4 @@ # AGENTS.md — __PROJECT_NAME__ minimal - Instructions for the AI agent in this game. This template has no React or Tailwind. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. @@ -92,5 +91,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/minimal/CLAUDE.md b/packages/create-threenative/templates/minimal/CLAUDE.md index ed3fe5cb90..a9a1cdc88e 100644 --- a/packages/create-threenative/templates/minimal/CLAUDE.md +++ b/packages/create-threenative/templates/minimal/CLAUDE.md @@ -1,7 +1,6 @@ # AGENTS.md — __PROJECT_NAME__ minimal - Instructions for the AI agent in this game. This template has no React or Tailwind. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. @@ -94,5 +93,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts b/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts b/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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/environmentRadiance.ts b/packages/create-threenative/templates/minimal/src/render/environmentRadiance.ts new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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/minimal/src/render/environmentSampleCache.ts b/packages/create-threenative/templates/minimal/src/render/environmentSampleCache.ts new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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/minimal/src/render/environmentSampling.ts b/packages/create-threenative/templates/minimal/src/render/environmentSampling.ts new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/minimal/src/render/lighting.ts index 9d35f26233..a7585a511c 100644 --- a/packages/create-threenative/templates/minimal/src/render/lighting.ts +++ b/packages/create-threenative/templates/minimal/src/render/lighting.ts @@ -3,7 +3,7 @@ // // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. -import { DirectionalLight, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -41,3 +41,27 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts b/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts new file mode 100644 index 0000000000..255e3ca196 --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/materialAssignments.ts @@ -0,0 +1,102 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments: IMaterialAssignment[] = []; + const converted = new Map>(); + const originalOf = new Map(); + const exclusions: string[] = []; + scene.traverse((object) => { + if (!(object instanceof Mesh)) return; + assignments.push({ mesh: object, original: object.material }); + for (const material of Array.isArray(object.material) ? object.material : [object.material]) { + const reason = unsupported(material); + if (reason !== undefined) + exclusions.push( + `${object.name || object.type}:${material.name || material.type}:${reason}`, + ); + } + }); + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map(convert); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original); + } + assignment.mesh.material = assignment.applied; + } + return { + setEnabled(enabled: boolean): void { + for (const assignment of assignments) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + excludedSlots: exclusions.length, + exclusions, + }), + dispose(): void { + for (const assignment of assignments) restore(assignment); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/minimal/src/render/materialLighting.ts b/packages/create-threenative/templates/minimal/src/render/materialLighting.ts new file mode 100644 index 0000000000..38e3a5248e --- /dev/null +++ b/packages/create-threenative/templates/minimal/src/render/materialLighting.ts @@ -0,0 +1,146 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/minimal/src/render/postprocessing.ts b/packages/create-threenative/templates/minimal/src/render/postprocessing.ts index 029ef102c4..2daf21bbbf 100644 --- a/packages/create-threenative/templates/minimal/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/minimal/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,7 +41,17 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/minimal/src/render/quality.ts b/packages/create-threenative/templates/minimal/src/render/quality.ts index f3ac71c780..c9691cfa00 100644 --- a/packages/create-threenative/templates/minimal/src/render/quality.ts +++ b/packages/create-threenative/templates/minimal/src/render/quality.ts @@ -127,3 +127,35 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +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 + ); +} diff --git a/packages/create-threenative/templates/minimal/src/scenes/Play.ts b/packages/create-threenative/templates/minimal/src/scenes/Play.ts index c84cbe4003..36ebdfea83 100644 --- a/packages/create-threenative/templates/minimal/src/scenes/Play.ts +++ b/packages/create-threenative/templates/minimal/src/scenes/Play.ts @@ -3,6 +3,7 @@ import { Scene, type SceneFrame, afterPhysics, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -24,8 +25,10 @@ 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"; import { floorMaterial, propMaterial, @@ -33,6 +36,7 @@ import { worldGridUVs, } from "../render/materials.js"; import { setupPost } from "../render/postprocessing.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -47,12 +51,23 @@ export class Play extends Scene { #model: IPlayerModel | undefined; #sky: Texture | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; override async load(ctx: GameCtx): Promise { [this.#model, this.#sky] = await Promise.all([ ctx.assets.model("mannequin.glb"), ctx.assets.texture("sky.jpg"), ]); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override enter(ctx: GameCtx): SceneFrame { @@ -67,8 +82,17 @@ export class Play extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code, exactly like createRandom. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { godraysLight: lighting.key, + onTierChanged: (tier) => + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)), mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, }); @@ -174,6 +198,23 @@ export class Play extends Scene { if (body === player.body) ctx.state.set((state) => ({ score: state.score + 1 })); }); + // Loaded character and all arena meshes are attached before the single material traversal. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, lighting.key, { + ...materialEnvironment, + enabled: materialLightingEnabled(this.#post.tier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const { measurement, source, intensity } = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + measurement, + source, + intensity, + this.#environmentSample, + ); + } const statePatch: Partial = {}; return (frameCtx, dt) => { loading.update(); @@ -186,4 +227,11 @@ export class Play extends Scene { frameCtx.state.set(statePatch); }; } + override exit(): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + } } diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index 6b478fe082..e9b3fb5d8b 100644 --- a/packages/create-threenative/templates/platformer/AGENTS.md +++ b/packages/create-threenative/templates/platformer/AGENTS.md @@ -54,5 +54,5 @@ Recipes in the installed create-threenative: `node_modules/create-threenative/ag ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md index f61ef2453f..50df2a7d2b 100644 --- a/packages/create-threenative/templates/platformer/CLAUDE.md +++ b/packages/create-threenative/templates/platformer/CLAUDE.md @@ -56,5 +56,5 @@ Recipes in the installed create-threenative: `node_modules/create-threenative/ag ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts b/packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts b/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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/environmentSnapshot.ts b/packages/create-threenative/templates/platformer/src/render/environmentSnapshot.ts new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/platformer/src/render/lighting.ts b/packages/create-threenative/templates/platformer/src/render/lighting.ts index 336bc0680b..dfcc2e2124 100644 --- a/packages/create-threenative/templates/platformer/src/render/lighting.ts +++ b/packages/create-threenative/templates/platformer/src/render/lighting.ts @@ -6,7 +6,7 @@ // flattens exactly the shadow side the sun was there to make. The low-poly geometry keeps its own // separation through `flatShading` and baked vertex mottle, which is why this level reads as // facetted without a second light. -import { DirectionalLight, PCFSoftShadowMap, type Scene, type Vector3 } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -53,3 +53,28 @@ export function setupLighting(scene: Scene, renderer: ShadowRenderer, mobile = f key, }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts b/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/platformer/src/render/materialLighting.ts b/packages/create-threenative/templates/platformer/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/platformer/src/render/postprocessing.ts b/packages/create-threenative/templates/platformer/src/render/postprocessing.ts index e43e30e069..d1868d82c4 100644 --- a/packages/create-threenative/templates/platformer/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/platformer/src/render/postprocessing.ts @@ -30,8 +30,10 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + /** Scene-owned material assignments follow the resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { // The native production collector explicitly marks a software-only hosted smoke run. This is // a profile input, not a host fact: the normal desktop game remains high, while this run uses // the existing low look so screenshot and lifecycle evidence can settle on a CPU adapter. @@ -52,5 +54,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/platformer/src/render/quality.ts b/packages/create-threenative/templates/platformer/src/render/quality.ts index c287fae8ff..920664afef 100644 --- a/packages/create-threenative/templates/platformer/src/render/quality.ts +++ b/packages/create-threenative/templates/platformer/src/render/quality.ts @@ -117,3 +117,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/platformer/src/scenes/Play.ts b/packages/create-threenative/templates/platformer/src/scenes/Play.ts index fec946eeb2..e852b28371 100644 --- a/packages/create-threenative/templates/platformer/src/scenes/Play.ts +++ b/packages/create-threenative/templates/platformer/src/scenes/Play.ts @@ -4,6 +4,7 @@ import { Scene, type SceneFrame, afterPhysics, + getPlatform, isMobile, isTouchscreenAvailable, mergeByMaterial, @@ -25,12 +26,19 @@ import { Checkpoints } from "../level/Checkpoints.js"; import { buildStage, makeRng } from "../level/Stage.js"; import { emitPlaytestEvent } from "../playtest-events.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"; import { flat } from "../render/materials.js"; import { C } from "../render/palette.js"; import { burst, coinArc } from "../render/pickups.js"; import { setupPost } from "../render/postprocessing.js"; +import { + type QualityTier, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "../render/quality.js"; import { cloudLobes, setupSky, skyFloor } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -103,6 +111,10 @@ export class Play extends Scene { * only appears in `enter()` cannot be placed. That is what makes a stomp reproducible: the * walker is where the scenario put it, not where boot time left it. */ + #post: ReturnType | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + override async load(ctx: GameCtx): Promise { // Registered before the first `await`, because that is the whole window a scenario's // `setup.place` has to find them in: the bridge applies setup between `load()` starting and @@ -114,6 +126,14 @@ export class Play extends Scene { this.#walkers.push(walker); } this.#sky = await ctx.assets.texture("sky.jpg"); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override enter(ctx: GameCtx): SceneFrame { @@ -136,7 +156,19 @@ export class Play extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + let materialTier: QualityTier = "low"; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { + onTierChanged: (tier) => { + materialTier = tier; + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)); + }, godraysLight: lighting.key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, @@ -226,6 +258,24 @@ export class Play extends Scene { lighting.follow(fox.mesh.position); }); + // Collect only after the loaded character and scene receivers are attached. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, lighting.key, { + ...materialEnvironment, + enabled: materialLightingEnabled(materialTier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const sample = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); + } + return (frameCtx, dt) => { loading.update(); fox.update( @@ -324,6 +374,15 @@ export class Play extends Scene { frameCtx.state.set(statePatch); }; } + + override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + super.exit(ctx); + } } /** diff --git a/packages/create-threenative/templates/puzzle/AGENTS.md b/packages/create-threenative/templates/puzzle/AGENTS.md index 7daf39ace6..4f2618716a 100644 --- a/packages/create-threenative/templates/puzzle/AGENTS.md +++ b/packages/create-threenative/templates/puzzle/AGENTS.md @@ -92,5 +92,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/puzzle/CLAUDE.md b/packages/create-threenative/templates/puzzle/CLAUDE.md index e4a0193d63..c970e7c497 100644 --- a/packages/create-threenative/templates/puzzle/CLAUDE.md +++ b/packages/create-threenative/templates/puzzle/CLAUDE.md @@ -94,5 +94,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts b/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts b/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/puzzle/src/render/lighting.ts index 2ff74ebb04..f6f46ea0f7 100644 --- a/packages/create-threenative/templates/puzzle/src/render/lighting.ts +++ b/packages/create-threenative/templates/puzzle/src/render/lighting.ts @@ -6,7 +6,14 @@ // bright because a named source is pointing at it, so the temptation to raise the fill until the // crates are comfortably visible has to be resisted: the moment the floor stops being near-black // the picture stops being a vault. -import { DirectionalLight, HemisphereLight, PCFSoftShadowMap, type Scene } from "three"; +import { + Color, + DirectionalLight, + HemisphereLight, + PCFSoftShadowMap, + type Scene, + Vector3, +} from "three"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -53,3 +60,28 @@ export function setupLighting( return key; } + +/** Existing authored rim/fill is preserved; opt in to extra terms with the named gains. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0, + fillGain: 0, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts b/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts b/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/puzzle/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/puzzle/src/render/postprocessing.ts b/packages/create-threenative/templates/puzzle/src/render/postprocessing.ts index 029ef102c4..9ea4fae3ca 100644 --- a/packages/create-threenative/templates/puzzle/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/puzzle/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +) { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,7 +41,9 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, }); + environment.onTierChanged?.(tier); + return { tier, dispose: () => applied.dispose?.() }; } diff --git a/packages/create-threenative/templates/puzzle/src/render/quality.ts b/packages/create-threenative/templates/puzzle/src/render/quality.ts index badaf2c937..2a319782af 100644 --- a/packages/create-threenative/templates/puzzle/src/render/quality.ts +++ b/packages/create-threenative/templates/puzzle/src/render/quality.ts @@ -160,3 +160,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts index 2baa4072f2..31cc026349 100644 --- a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts +++ b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts @@ -3,6 +3,7 @@ import { type IRandom, Scene, type SceneFrame, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -22,8 +23,10 @@ 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"; import { crateBraceMaterial, crateMaterials, @@ -32,6 +35,7 @@ import { phaseMaterial, } from "../render/materials.js"; import { setupPost } from "../render/postprocessing.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import { SEAL, VAULT, createVaultRoom } from "../render/vault.js"; @@ -109,6 +113,18 @@ export class Play extends Scene { #banner: Mesh | undefined; #statics: RigidBody3D[] = []; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; + #materialEnvironment(ctx: GameCtx) { + return { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + } static override readonly initialState: GameState = { blockedTicks: 0, crates: 0, @@ -184,6 +200,12 @@ export class Play extends Scene { if (banner === undefined) throw new Error("Proof glTF did not contain a mesh."); this.#banner = banner; console.info("TN_NATIVE_STARTER_ASSETS_LOADED:texture,glb"); + setupSky(ctx.scene); + this.#environmentSample = await sampleEnvironment( + ctx.renderer.raw, + ctx.scene, + this.#materialEnvironment(ctx), + ); } override enter(ctx: GameCtx): SceneFrame { @@ -199,10 +221,14 @@ export class Play extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { godraysLight: sun, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, + onTierChanged: (tier) => + this.#materialLighting?.setEnabled( + materialLightingEnabled(tier, this.#materialEnvironment(ctx)), + ), }); const loading = createLoadingScreen(ctx); const room = createVaultRoom(); @@ -400,6 +426,23 @@ export class Play extends Scene { const frameState: Partial = {}; + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, sun, { + ...this.#materialEnvironment(ctx), + enabled: materialLightingEnabled(this.#post.tier, this.#materialEnvironment(ctx)), + }), + ); + if (this.#environmentSample !== undefined) { + const { measurement, source, intensity } = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + measurement, + source, + intensity, + this.#environmentSample, + ); + } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame is the vault's whole // per-tick bookkeeping — the two replay passes, the settle test, the push tally and the state // flush — and splitting it would move the wiring between three closures that all share it. @@ -550,6 +593,11 @@ export class Play extends Scene { } override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + for (const body of this.#statics) body.dispose(); this.#statics = []; super.exit(ctx); diff --git a/packages/create-threenative/templates/racing/AGENTS.md b/packages/create-threenative/templates/racing/AGENTS.md index 0d9cd00f29..63a7cd59aa 100644 --- a/packages/create-threenative/templates/racing/AGENTS.md +++ b/packages/create-threenative/templates/racing/AGENTS.md @@ -92,5 +92,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/racing/CLAUDE.md b/packages/create-threenative/templates/racing/CLAUDE.md index 840c25d552..88d385cbcb 100644 --- a/packages/create-threenative/templates/racing/CLAUDE.md +++ b/packages/create-threenative/templates/racing/CLAUDE.md @@ -94,5 +94,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts b/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/racing/src/render/environmentContribution.ts b/packages/create-threenative/templates/racing/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/racing/src/render/lighting.ts index f7ea969cbd..010b650129 100644 --- a/packages/create-threenative/templates/racing/src/render/lighting.ts +++ b/packages/create-threenative/templates/racing/src/render/lighting.ts @@ -3,7 +3,7 @@ // // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. -import { DirectionalLight, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -43,3 +43,28 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/racing/src/render/materialAssignments.ts b/packages/create-threenative/templates/racing/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/racing/src/render/materialLighting.ts b/packages/create-threenative/templates/racing/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/racing/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/racing/src/render/postprocessing.ts b/packages/create-threenative/templates/racing/src/render/postprocessing.ts index 86e3ab96cb..132d53fda1 100644 --- a/packages/create-threenative/templates/racing/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/racing/src/render/postprocessing.ts @@ -26,8 +26,10 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + /** Scene-owned material assignments follow the resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,5 +42,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/racing/src/render/quality.ts b/packages/create-threenative/templates/racing/src/render/quality.ts index 80b4078fdf..1ae19ad0af 100644 --- a/packages/create-threenative/templates/racing/src/render/quality.ts +++ b/packages/create-threenative/templates/racing/src/render/quality.ts @@ -130,3 +130,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/racing/src/scenes/Race.ts b/packages/create-threenative/templates/racing/src/scenes/Race.ts index 8a0d2d1aa9..662cdbb07f 100644 --- a/packages/create-threenative/templates/racing/src/scenes/Race.ts +++ b/packages/create-threenative/templates/racing/src/scenes/Race.ts @@ -3,6 +3,7 @@ import { Scene, type SceneFrame, afterPhysics, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -13,9 +14,16 @@ 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"; import { setupPost } from "../render/postprocessing.js"; +import { + type QualityTier, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "../render/quality.js"; import { SUN_DIRECTION, setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import { type GameState, type RaceStatus, resolveRaceStatus } from "../state.js"; @@ -98,11 +106,42 @@ export class Race extends Scene { * the moment it decodes, which is also what a player sees — a sky that arrives rather than a * black screen that waits. */ + #post: ReturnType | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + #lightingGeneration = 0; + override load(ctx: GameCtx): void { - void ctx.assets.texture("sky.jpg").then((texture) => { + const generation = ++this.#lightingGeneration; + void ctx.assets.texture("sky.jpg").then(async (texture) => { + if (generation !== this.#lightingGeneration) return; this.#sky = texture; const scene = ctx.scene; - if (scene !== undefined) setupSky(scene, texture); + if (scene === undefined) return; + setupSky(scene, texture); + const controller = this.#materialLighting; + const sample = await sampleEnvironment(ctx.renderer.raw, scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); + if (generation !== this.#lightingGeneration || ctx.scene !== scene) return; + this.#environmentSample = sample; + // A cached sky may start sampling before enter creates the receiver controller. + // A controller that already existed when sampling began must still be the same one. + const currentController = this.#materialLighting; + if ( + currentController !== undefined && + (controller === undefined || controller === currentController) + ) + currentController.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); }); } @@ -115,7 +154,19 @@ export class Race extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code, exactly like createRandom. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + let materialTier: QualityTier = "low"; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { + onTierChanged: (tier) => { + materialTier = tier; + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)); + }, godraysLight: lighting.key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, @@ -229,6 +280,24 @@ export class Race extends Scene { camera.rotateZ(cameraBank(car.lateralLoad, FEEL.lateral)); }); + // Collect only after the loaded character and scene receivers are attached. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, lighting.key, { + ...materialEnvironment, + enabled: materialLightingEnabled(materialTier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const sample = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); + } + return (frameCtx, dt) => { loading.update(); if (frameCtx.input.justPressed("restart")) { @@ -277,4 +346,14 @@ export class Race extends Scene { frameCtx.state.set(statePatch); }; } + + override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + this.#lightingGeneration += 1; + super.exit(ctx); + } } diff --git a/packages/create-threenative/templates/rain/AGENTS.md b/packages/create-threenative/templates/rain/AGENTS.md index 66c8f06e5f..502dfc131d 100644 --- a/packages/create-threenative/templates/rain/AGENTS.md +++ b/packages/create-threenative/templates/rain/AGENTS.md @@ -1,5 +1,4 @@ # AGENTS.md — __PROJECT_NAME__ rain - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. ## Ownership @@ -92,5 +91,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Backlight exception: this storm raymarches custom shader lighting in `src/render/lighting.ts`; standard-material `rimGain`/`fillGain` conversion is unqualified. Preserve its authored flash rim and sky fill. The cloud render-target radiance is unknown until sampled; do not claim an environment contribution from `scene.environment`. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/rain/CLAUDE.md b/packages/create-threenative/templates/rain/CLAUDE.md index 3bc56fe630..e12541eb22 100644 --- a/packages/create-threenative/templates/rain/CLAUDE.md +++ b/packages/create-threenative/templates/rain/CLAUDE.md @@ -1,7 +1,6 @@ # AGENTS.md — __PROJECT_NAME__ rain - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. ## Ownership @@ -94,5 +93,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Backlight exception: this storm raymarches custom shader lighting in `src/render/lighting.ts`; standard-material `rimGain`/`fillGain` conversion is unqualified. Preserve its authored flash rim and sky fill. The cloud render-target radiance is unknown until sampled; do not claim an environment contribution from `scene.environment`. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/rts/AGENTS.md b/packages/create-threenative/templates/rts/AGENTS.md index aed04e065a..1db748dc97 100644 --- a/packages/create-threenative/templates/rts/AGENTS.md +++ b/packages/create-threenative/templates/rts/AGENTS.md @@ -1,5 +1,4 @@ # AGENTS.md — __PROJECT_NAME__ rts - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. ## Ownership @@ -92,5 +91,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/rts/CLAUDE.md b/packages/create-threenative/templates/rts/CLAUDE.md index 8ce95b4887..f37ea2756b 100644 --- a/packages/create-threenative/templates/rts/CLAUDE.md +++ b/packages/create-threenative/templates/rts/CLAUDE.md @@ -1,7 +1,6 @@ # AGENTS.md — __PROJECT_NAME__ rts - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. ## Ownership @@ -94,5 +93,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/rts/src/render/army.ts b/packages/create-threenative/templates/rts/src/render/army.ts index 2b5171e5f5..309407e0a3 100644 --- a/packages/create-threenative/templates/rts/src/render/army.ts +++ b/packages/create-threenative/templates/rts/src/render/army.ts @@ -68,7 +68,10 @@ const _work = new Color(0x77eddf); const _queue = new Color(0x77cddd); const _backing = new Color(palette.gridLine); -export function createArmy(models: IUnitModels): IArmy { +export function createArmy( + models: IUnitModels, + receivers: { added?: (mesh: Object3D) => void; removed?: (mesh: Object3D) => void } = {}, +): IArmy { const root = new Object3D(); const batches = new Map(); const markers: IMarker[] = []; @@ -137,6 +140,7 @@ export function createArmy(models: IUnitModels): IArmy { mesh.receiveShadow = true; mesh.frustumCulled = false; root.add(mesh); + receivers.added?.(mesh); return mesh; }); const made: IBatch = { list: [], parts }; @@ -290,7 +294,11 @@ export function createArmy(models: IUnitModels): IArmy { dispose: () => { // The model geometries belong to `models`, which frees them; the materials are shared by // every model and by a later rebuild, so nothing here disposes one. - for (const entry of batches.values()) for (const part of entry.parts) root.remove(part); + for (const entry of batches.values()) + for (const part of entry.parts) { + receivers.removed?.(part); + root.remove(part); + } batches.clear(); for (const ring of rings) { ring.geometry.dispose(); diff --git a/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts b/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/rts/src/render/environmentContribution.ts b/packages/create-threenative/templates/rts/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/rts/src/render/lighting.ts index eafd4a1d61..19e288b214 100644 --- a/packages/create-threenative/templates/rts/src/render/lighting.ts +++ b/packages/create-threenative/templates/rts/src/render/lighting.ts @@ -3,7 +3,7 @@ // // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. -import { DirectionalLight, Object3D, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, Object3D, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -59,3 +59,28 @@ export function setupLighting(scene: Scene, renderer: ShadowRenderer, mobile = f }, }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/rts/src/render/materialAssignments.ts b/packages/create-threenative/templates/rts/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/rts/src/render/materialLighting.ts b/packages/create-threenative/templates/rts/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/rts/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/rts/src/render/postprocessing.ts b/packages/create-threenative/templates/rts/src/render/postprocessing.ts index 029ef102c4..9ea4fae3ca 100644 --- a/packages/create-threenative/templates/rts/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/rts/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +) { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,7 +41,9 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, }); + environment.onTierChanged?.(tier); + return { tier, dispose: () => applied.dispose?.() }; } diff --git a/packages/create-threenative/templates/rts/src/render/quality.ts b/packages/create-threenative/templates/rts/src/render/quality.ts index f3ac71c780..a40e96f1fe 100644 --- a/packages/create-threenative/templates/rts/src/render/quality.ts +++ b/packages/create-threenative/templates/rts/src/render/quality.ts @@ -127,3 +127,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/rts/src/scenes/Play.ts b/packages/create-threenative/templates/rts/src/scenes/Play.ts index bda03a2977..4e599f79f1 100644 --- a/packages/create-threenative/templates/rts/src/scenes/Play.ts +++ b/packages/create-threenative/templates/rts/src/scenes/Play.ts @@ -1,4 +1,4 @@ -import { type ICtx, Scene, type SceneFrame, isMobile } from "@threenative/core"; +import { type ICtx, Scene, type SceneFrame, getPlatform, isMobile } from "@threenative/core"; import { Object3D, type OrthographicCamera, type Texture, type Vector2 } from "three"; import { onSceneIntent } from "../orders.js"; import { createArmy } from "../render/army.js"; @@ -13,11 +13,14 @@ import { panSpeed, zoomRts, } from "../render/camera.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"; import { createUnitModels } from "../render/models.js"; import { type PickTarget, pickEntity, unitsInBox } from "../render/picking.js"; import { setupPost } from "../render/postprocessing.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { createResources } from "../render/resources.js"; import { setupSky } from "../render/sky.js"; import { createTerrain } from "../render/terrain.js"; @@ -99,6 +102,18 @@ class SelectionAnchor { } export class Play extends Scene { + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; + #materialEnvironment(ctx: GameCtx) { + return { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + } static override readonly initialState: GameState = INITIAL_STATE; #sky: Texture | undefined; @@ -128,6 +143,12 @@ export class Play extends Scene { override async load(ctx: GameCtx): Promise { this.#sky = await ctx.assets.texture("sky.jpg"); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment( + ctx.renderer.raw, + ctx.scene, + this.#materialEnvironment(ctx), + ); } override enter(ctx: GameCtx): SceneFrame { @@ -150,15 +171,22 @@ export class Play extends Scene { this.#sun = sun; // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code, exactly like createRandom. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { godraysLight: sun.key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, + onTierChanged: (tier) => + this.#materialLighting?.setEnabled( + materialLightingEnabled(tier, this.#materialEnvironment(ctx)), + ), }); const loading = createLoadingScreen(ctx); this.#terrain = createTerrain(); this.#models = createUnitModels(); - this.#army = createArmy(this.#models); + this.#army = createArmy(this.#models, { + added: (mesh) => this.#materialLighting?.enroll(mesh), + removed: (mesh) => this.#materialLighting?.release(mesh), + }); this.#resources = createResources(); const world = new Object3D(); world.add(this.#terrain.root, this.#resources.root, this.#army.root, this.#anchor.object); @@ -170,6 +198,23 @@ export class Play extends Scene { this.#selectIds(sim.army().map((entity) => entity.id)); onSceneIntent((intent, payload) => this.#intent(intent, payload)); + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, sun.key, { + ...this.#materialEnvironment(ctx), + enabled: materialLightingEnabled(this.#post.tier, this.#materialEnvironment(ctx)), + }), + ); + if (this.#environmentSample !== undefined) { + const { measurement, source, intensity } = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + measurement, + source, + intensity, + this.#environmentSample, + ); + } + return (frameCtx, dt) => { loading.update(); this.#readInput(frameCtx, dt); @@ -194,6 +239,11 @@ export class Play extends Scene { } override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + onSceneIntent(() => undefined); ctx.entities.remove("selection"); this.#army?.dispose(); diff --git a/packages/create-threenative/templates/runner/AGENTS.md b/packages/create-threenative/templates/runner/AGENTS.md index b9eb982bce..bb01e1fd72 100644 --- a/packages/create-threenative/templates/runner/AGENTS.md +++ b/packages/create-threenative/templates/runner/AGENTS.md @@ -90,5 +90,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/runner/CLAUDE.md b/packages/create-threenative/templates/runner/CLAUDE.md index f95aa10ff2..c020259d72 100644 --- a/packages/create-threenative/templates/runner/CLAUDE.md +++ b/packages/create-threenative/templates/runner/CLAUDE.md @@ -92,5 +92,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts b/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/runner/src/render/environmentContribution.ts b/packages/create-threenative/templates/runner/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/runner/src/render/lighting.ts index feeaebf7e8..3999471b26 100644 --- a/packages/create-threenative/templates/runner/src/render/lighting.ts +++ b/packages/create-threenative/templates/runner/src/render/lighting.ts @@ -5,7 +5,7 @@ // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. // The rig is returned because `WorldEnvironment`'s godrays stage raymarches against a shadow map, // so `setupPost` needs the key light itself; a shadowless one is refused by name. -import { DirectionalLight, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -51,3 +51,28 @@ export function setupLighting( return { key, fill }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/runner/src/render/materialAssignments.ts b/packages/create-threenative/templates/runner/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/runner/src/render/materialLighting.ts b/packages/create-threenative/templates/runner/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/runner/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/runner/src/render/postprocessing.ts b/packages/create-threenative/templates/runner/src/render/postprocessing.ts index 86e3ab96cb..132d53fda1 100644 --- a/packages/create-threenative/templates/runner/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/runner/src/render/postprocessing.ts @@ -26,8 +26,10 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + /** Scene-owned material assignments follow the resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,5 +42,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/runner/src/render/quality.ts b/packages/create-threenative/templates/runner/src/render/quality.ts index dd61a5e6a4..db24404e98 100644 --- a/packages/create-threenative/templates/runner/src/render/quality.ts +++ b/packages/create-threenative/templates/runner/src/render/quality.ts @@ -115,3 +115,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/runner/src/scenes/Run.ts b/packages/create-threenative/templates/runner/src/scenes/Run.ts index 7385fdacba..c1f22306da 100644 --- a/packages/create-threenative/templates/runner/src/scenes/Run.ts +++ b/packages/create-threenative/templates/runner/src/scenes/Run.ts @@ -5,6 +5,7 @@ import { Scene, type SceneFrame, afterPhysics, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -13,9 +14,16 @@ import { 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"; import { setupPost } from "../render/postprocessing.js"; +import { + type QualityTier, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "../render/quality.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import { type GameState, INITIAL_STATE } from "../state.js"; @@ -34,11 +42,23 @@ export class Run extends Scene { #model: IRunnerModel | undefined; #sky: Texture | undefined; + #post: ReturnType | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + override async load(ctx: GameCtx): Promise { [this.#model, this.#sky] = await Promise.all([ ctx.assets.model("mannequin.glb"), ctx.assets.texture("sky.jpg"), ]); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override enter(ctx: GameCtx): SceneFrame { @@ -52,7 +72,19 @@ export class Run extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + let materialTier: QualityTier = "low"; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { + onTierChanged: (tier) => { + materialTier = tier; + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)); + }, godraysLight: key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, @@ -70,7 +102,11 @@ export class Run extends Scene { // `ctx.random` is the seeded source `defineGame({ seed })` installed, so the same seed builds // the same track — which is what makes a runner's scenario reproducible at all. const chunkContext = { - add: (object: Parameters[0]) => ctx.add(object), + add: (object: Parameters[0]) => { + const added = ctx.add(object); + this.#materialLighting?.enroll(object); + return added; + }, physics: ctx.physics, random: () => ctx.random(), }; @@ -101,6 +137,25 @@ export class Run extends Scene { dust.position.set(runner.mesh.position.x, 0.15, runner.mesh.position.z + 0.6); }); + // Collect only after the loaded character and scene receivers are attached. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, key, { + ...materialEnvironment, + enabled: materialLightingEnabled(materialTier, materialEnvironment), + overrides: { fillGain: 0 }, + }), + ); + if (this.#environmentSample !== undefined) { + const sample = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); + } + return (frameCtx, dt) => { loading.update(); if (frameCtx.input.justPressed("restart")) { @@ -159,4 +214,13 @@ export class Run extends Scene { frameCtx.state.set(next); }; } + + override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + super.exit(ctx); + } } diff --git a/packages/create-threenative/templates/sailing/AGENTS.md b/packages/create-threenative/templates/sailing/AGENTS.md index 12ddc82e41..f6e07bf162 100644 --- a/packages/create-threenative/templates/sailing/AGENTS.md +++ b/packages/create-threenative/templates/sailing/AGENTS.md @@ -92,5 +92,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/sailing/CLAUDE.md b/packages/create-threenative/templates/sailing/CLAUDE.md index 15091bb31b..2f04554864 100644 --- a/packages/create-threenative/templates/sailing/CLAUDE.md +++ b/packages/create-threenative/templates/sailing/CLAUDE.md @@ -94,5 +94,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts b/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts b/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..97c905fdea --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentSetup.ts @@ -0,0 +1,24 @@ +// 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; +export const loadedEnvironmentSample = (): IEnvironmentSample | undefined => sample; +export async function prepareEnvironmentSample( + renderer: unknown, + environment: IMaterialLightingEnvironment, +): Promise { + const scratch = new Scene(); + setupSky(scratch, { software: environment.software }); + try { + sample = await sampleEnvironment(renderer, scratch, environment); + } finally { + scratch.traverse((object) => { + if (!(object instanceof Mesh)) return; + object.geometry.dispose(); + for (const material of Array.isArray(object.material) ? object.material : [object.material]) + material.dispose(); + }); + } +} diff --git a/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts b/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/sailing/src/render/lighting.ts index feb83ae1f7..6ae68dd175 100644 --- a/packages/create-threenative/templates/sailing/src/render/lighting.ts +++ b/packages/create-threenative/templates/sailing/src/render/lighting.ts @@ -1,5 +1,5 @@ // Generated for you. This is ordinary Three.js; tune the key for your sea. -import { DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { palette } from "./palette.js"; import { SUN_DIRECTION } from "./sky.js"; @@ -61,3 +61,28 @@ export function followSun( ); key.target.updateMatrixWorld(); } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts b/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/sailing/src/render/materialLighting.ts b/packages/create-threenative/templates/sailing/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/sailing/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/sailing/src/render/postprocessing.ts b/packages/create-threenative/templates/sailing/src/render/postprocessing.ts index 86e3ab96cb..c578acf223 100644 --- a/packages/create-threenative/templates/sailing/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/sailing/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,5 +41,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/sailing/src/render/quality.ts b/packages/create-threenative/templates/sailing/src/render/quality.ts index 3947cf4c52..f3f6472e12 100644 --- a/packages/create-threenative/templates/sailing/src/render/quality.ts +++ b/packages/create-threenative/templates/sailing/src/render/quality.ts @@ -149,3 +149,35 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +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 + ); +} diff --git a/packages/create-threenative/templates/sailing/src/scenes/Boot.ts b/packages/create-threenative/templates/sailing/src/scenes/Boot.ts index 665023f175..a5945af50a 100644 --- a/packages/create-threenative/templates/sailing/src/scenes/Boot.ts +++ b/packages/create-threenative/templates/sailing/src/scenes/Boot.ts @@ -1,7 +1,9 @@ -import { Scene } from "@threenative/core"; +import { Scene, getPlatform, isMobile } from "@threenative/core"; import type { IPhysicsContext } from "@threenative/physics"; import type { Texture } from "three"; +import { prepareEnvironmentSample } from "../render/environmentSetup.js"; import { loadShipModel } from "../render/props.js"; +import { isWebGLFallbackRenderer } from "../render/quality.js"; import { loadSky } from "../render/sky.js"; import type { GameState } from "../state.js"; import { Sailing } from "./Sailing.js"; @@ -19,12 +21,21 @@ export class Boot extends Scene { // is read the instant `Boot.enter()` returns — not once `Sailing.enter()` eventually does. See // `loadSky` and `loadShipModel`. override async load(ctx: { + readonly renderer?: Parameters[0]["renderer"]; readonly assets: { model(path: string): Promise; texture(path: string): Promise; }; }): Promise { await Promise.all([loadSky(ctx.assets), loadShipModel(ctx.assets)]); + if (ctx.renderer !== undefined) + await prepareEnvironmentSample(ctx.renderer.raw, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override enter(ctx: Parameters[0]): void { diff --git a/packages/create-threenative/templates/sailing/src/scenes/Sailing.ts b/packages/create-threenative/templates/sailing/src/scenes/Sailing.ts index 2762b79220..876cfbef81 100644 --- a/packages/create-threenative/templates/sailing/src/scenes/Sailing.ts +++ b/packages/create-threenative/templates/sailing/src/scenes/Sailing.ts @@ -3,6 +3,7 @@ import { Scene, type SceneFrame, WaterSurface3D, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -10,8 +11,10 @@ import type { IPhysicsContext } from "@threenative/physics"; import type { Group, PerspectiveCamera, Texture } from "three"; import { Ship } from "../entities/Ship.js"; import { followShip, setupCamera } from "../render/camera.js"; +import { loadedEnvironmentSample } from "../render/environmentSetup.js"; import { followSun, setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; +import { createMaterialLighting } from "../render/materialLighting.js"; import { createMaterials } from "../render/materials.js"; import { SEA_MIRROR, @@ -22,6 +25,7 @@ import { } from "../render/ocean.js"; import { setupPost } from "../render/postprocessing.js"; import { createBuoy, createIsland, getShipModel } from "../render/props.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -64,6 +68,8 @@ const MAX_WAKE_SPEED = 4.6; const WIND_DURATION = 120; export class Sailing extends Scene { + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; static override readonly initialState: GameState = { buoysRounded: 0, elapsed: 0, @@ -91,7 +97,16 @@ export class Sailing extends Scene { const software = ctx.renderer.softwareAdapter !== undefined; setupSky(ctx.scene, { software }); const sun = setupLighting(ctx.scene, ctx.renderer.raw as Parameters[1]); - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { + onTierChanged: (tier) => + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)), godraysLight: sun, mobile: isMobile(), software, @@ -135,6 +150,21 @@ export class Sailing extends Scene { // The ship is deliberately not in the sea's mirror: it is a second draw of the hull *and its // keel*, which the mirror sees from below as a black blot smeared under the stern. ctx.entities.add("player", ship); + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, sun, { + ...materialEnvironment, + enabled: materialLightingEnabled(this.#post.tier, materialEnvironment), + }), + ); + const sample = loadedEnvironmentSample(); + if (sample !== undefined) + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); let elapsed = 0; let buoysRounded = 0; let markBearing = 0; @@ -261,6 +291,10 @@ export class Sailing extends Scene { } override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; this.#stopOceanReadiness?.(); this.#stopOceanReadiness = undefined; // The mirror owns a render target and a pass. Nothing in the frame releases it, so a restart diff --git a/packages/create-threenative/templates/shooter/AGENTS.md b/packages/create-threenative/templates/shooter/AGENTS.md index 5c04237d76..8b43f8df45 100644 --- a/packages/create-threenative/templates/shooter/AGENTS.md +++ b/packages/create-threenative/templates/shooter/AGENTS.md @@ -79,5 +79,5 @@ Open a capture after visual changes; a scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. On a touch-primary device, `src/entities/TouchControls.ts` adds a movement stick, a look stick, and fire, aim, reload and crouch pads; the stick pushed to its rim sprints. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/shooter/CLAUDE.md b/packages/create-threenative/templates/shooter/CLAUDE.md index 16f0914293..071d707037 100644 --- a/packages/create-threenative/templates/shooter/CLAUDE.md +++ b/packages/create-threenative/templates/shooter/CLAUDE.md @@ -81,5 +81,5 @@ Open a capture after visual changes; a scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. On a touch-primary device, `src/entities/TouchControls.ts` adds a movement stick, a look stick, and fire, aim, reload and crouch pads; the stick pushed to its rim sprints. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts b/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts b/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/shooter/src/render/lighting.ts index 1ff6d86a30..1afda5c0d4 100644 --- a/packages/create-threenative/templates/shooter/src/render/lighting.ts +++ b/packages/create-threenative/templates/shooter/src/render/lighting.ts @@ -3,7 +3,7 @@ // // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. -import { DirectionalLight, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -59,3 +59,28 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts b/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/shooter/src/render/materialLighting.ts b/packages/create-threenative/templates/shooter/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/shooter/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/shooter/src/render/postprocessing.ts b/packages/create-threenative/templates/shooter/src/render/postprocessing.ts index 029ef102c4..20655ffffd 100644 --- a/packages/create-threenative/templates/shooter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/shooter/src/render/postprocessing.ts @@ -26,8 +26,10 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + /** Scene-owned material assignments follow the resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,7 +42,17 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/shooter/src/render/quality.ts b/packages/create-threenative/templates/shooter/src/render/quality.ts index f3ac71c780..a40e96f1fe 100644 --- a/packages/create-threenative/templates/shooter/src/render/quality.ts +++ b/packages/create-threenative/templates/shooter/src/render/quality.ts @@ -127,3 +127,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/shooter/src/scenes/Play.ts b/packages/create-threenative/templates/shooter/src/scenes/Play.ts index 52d23df1bf..0c08958f08 100644 --- a/packages/create-threenative/templates/shooter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/shooter/src/scenes/Play.ts @@ -4,6 +4,7 @@ import { Scene, type SceneFrame, TracerPool3D, + getPlatform, isMobile, } from "@threenative/core"; import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics"; @@ -27,12 +28,19 @@ 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"; +import { createMaterialLighting } from "../render/materialLighting.js"; import { BoxOccluders } from "../render/occlusion.js"; import { PooledBillboards } from "../render/pooled-billboards.js"; import { setupPost } from "../render/postprocessing.js"; +import { + type QualityTier, + isWebGLFallbackRenderer, + materialLightingEnabled, +} from "../render/quality.js"; import { scale } from "../render/scale.js"; import { setupSky } from "../render/sky.js"; import { TOWN_HALF, type Town, buildTown } from "../render/town.js"; @@ -101,6 +109,10 @@ export class Play extends Scene { } | undefined; + #post: ReturnType | undefined; + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + override async load(ctx: GameCtx): Promise { // Three files, and one of them is the whole town: the geometry is procedural // (`render/town.ts`), so the only downloads are two rigs and one photograph. @@ -119,9 +131,23 @@ export class Play extends Scene { ctx.assets.texture("sky.jpg"), ]); this.#assets = { enemy, viewmodel, sky }; + setupSky(ctx.scene, this.#assets.sky); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); } override exit(): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + this.#environmentSample = undefined; + // The hook closes over this scene's player. Leaving it registered means a restart keeps // syncing the camera to the torn-down body until `enter` happens to overwrite it. onAfterPhysics(undefined); @@ -145,7 +171,19 @@ export class Play extends Scene { TOWN_HALF, mobile, ); - setupPost(ctx.renderer, ctx.scene, camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + let materialTier: QualityTier = "low"; + this.#post = setupPost(ctx.renderer, ctx.scene, camera, { + onTierChanged: (tier) => { + materialTier = tier; + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)); + }, godraysLight: key, mobile, software: ctx.renderer.softwareAdapter !== undefined, @@ -685,6 +723,24 @@ export class Play extends Scene { }; ctx.entities.remove("render"); ctx.entities.add("render", { debug: renderInfo }); + // Collect only after the loaded character and scene receivers are attached. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, key, { + ...materialEnvironment, + enabled: materialLightingEnabled(materialTier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const sample = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + sample.measurement, + sample.source, + sample.intensity, + sample, + ); + } + return (frameCtx, dt) => { loading.update(); // The totals of the frame that just finished, read before this frame's diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md index 7bd840f44d..e04a6de3ca 100644 --- a/packages/create-threenative/templates/snow/AGENTS.md +++ b/packages/create-threenative/templates/snow/AGENTS.md @@ -86,5 +86,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md index 42d1980207..a9928e2e59 100644 --- a/packages/create-threenative/templates/snow/CLAUDE.md +++ b/packages/create-threenative/templates/snow/CLAUDE.md @@ -88,5 +88,5 @@ Open a capture after visual changes. A scenario with no assertions or missing ob Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts b/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/snow/src/render/environmentContribution.ts b/packages/create-threenative/templates/snow/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/snow/src/render/lighting.ts index 06e90c178d..3228c902cc 100644 --- a/packages/create-threenative/templates/snow/src/render/lighting.ts +++ b/packages/create-threenative/templates/snow/src/render/lighting.ts @@ -1,5 +1,12 @@ // Generated for you. This is ordinary Three.js; tune the sun and fill for your snow. -import { DirectionalLight, HemisphereLight, PCFSoftShadowMap, type Scene } from "three"; +import { + Color, + DirectionalLight, + HemisphereLight, + PCFSoftShadowMap, + type Scene, + Vector3, +} from "three"; import { palette } from "./palette.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -48,3 +55,28 @@ export function setupLighting(scene: Scene, renderer: ShadowRenderer): ISnowLigh }, }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 0, // Existing hemisphere and camera fill own the storm response. + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/materialAssignments.ts b/packages/create-threenative/templates/snow/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/materialLighting.ts b/packages/create-threenative/templates/snow/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/snow/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/snow/src/render/postprocessing.ts b/packages/create-threenative/templates/snow/src/render/postprocessing.ts index 86e3ab96cb..c578acf223 100644 --- a/packages/create-threenative/templates/snow/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/snow/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +): { readonly tier: QualityTier; dispose(): void } { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,5 +41,15 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight }); + environment.onTierChanged?.(tier); + let disposed = false; + return { + tier, + dispose(): void { + if (disposed) return; + disposed = true; + applied.dispose?.(); + }, + }; } diff --git a/packages/create-threenative/templates/snow/src/render/quality.ts b/packages/create-threenative/templates/snow/src/render/quality.ts index 4c380e5741..f822cacaac 100644 --- a/packages/create-threenative/templates/snow/src/render/quality.ts +++ b/packages/create-threenative/templates/snow/src/render/quality.ts @@ -134,3 +134,35 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +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 + ); +} diff --git a/packages/create-threenative/templates/snow/src/scenes/Snow.ts b/packages/create-threenative/templates/snow/src/scenes/Snow.ts index 0f9a10291a..9519788a64 100644 --- a/packages/create-threenative/templates/snow/src/scenes/Snow.ts +++ b/packages/create-threenative/templates/snow/src/scenes/Snow.ts @@ -3,6 +3,7 @@ import { type ICtx, Scene, type SceneFrame, + getPlatform, isMobile, isTouchscreenAvailable, } from "@threenative/core"; @@ -21,9 +22,11 @@ import { BOOT_AREA, bootPrint } from "../render/bootPrint.js"; import { OrbitFollow, setupCamera } from "../render/camera.js"; import { setupLighting } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; +import { createMaterialLighting } from "../render/materialLighting.js"; import { createMaterials } from "../render/materials.js"; import { setupPost } from "../render/postprocessing.js"; import { createBall, createCrate, createLog } from "../render/props.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { createScenery } from "../render/scenery.js"; import { setupSky } from "../render/sky.js"; import { createSnowSurface } from "../render/snowSurface.js"; @@ -78,6 +81,8 @@ function placeBody( } export class Snow extends Scene { + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; static override readonly initialState: GameState = { autoExplore: true, ballGap: 0, @@ -113,7 +118,16 @@ export class Snow extends Scene { ctx.scene, ctx.renderer.raw as Parameters[1], ); - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { + onTierChanged: (tier) => + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)), mobile, software: ctx.renderer.softwareAdapter !== undefined, }); @@ -513,6 +527,13 @@ export class Snow extends Scene { let waypoint = 0; let pointer: { x: number; y: number } | undefined; + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, lights.sun, { + ...materialEnvironment, + enabled: materialLightingEnabled(this.#post.tier, materialEnvironment), + }), + ); return (frameCtx, dt) => { loading.update(); const input = frameCtx.input; @@ -611,6 +632,12 @@ export class Snow extends Scene { }); }; } + override exit(): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + } } function clampToField(value: number): number { diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index d7b1d630f6..f553c4d5b5 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -1,7 +1,5 @@ # AGENTS.md — __PROJECT_NAME__ starter - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. - ## Ownership ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, physics bindings, and the state bridge. This repository owns gameplay and every visible choice in `src/render/`, `src/entities/`, `src/scenes/`, and `src/ui/`; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. The look ships as a photographed sky and a rigged figure: `src/render/sky.ts` makes `assets/sky.jpg` (Poly Haven, CC0) the background, the environment light and the fog colour, `lighting.ts` adds the one sun it names in `SUN_DIRECTION`, and `Player.ts` drives `assets/mannequin.glb` (Quaternius, CC0) through `SkeletalMesh3D`. Swap the sky for any equirectangular image and re-aim `SUN_DIRECTION` at its sun; swap the rig and edit `CLIPS`. @@ -93,4 +91,5 @@ Recipes in the installed create-threenative: `node_modules/create-threenative/ag ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. +High-tier desktop browser WebGPU adds game-owned grazing backlight (`lighting.ts`: `rimGain: 0`) and measured-dark directional fill (`fillGain: 0` or black `fillColor`). `materialLighting.ts` preserves borrowed material maps and restores originals on lower quality/exit; mobile, software, WebGL and native retain originals. `TN_ENVIRONMENT_CONTRIBUTION` reports even with overrides zero; `environmentSampling.ts` estimates actual IBL once during load; unavailable/stale samples stay unknown and disable analytic fill. Native material terms remain unqualified. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index 2d5151a470..3bc76989d4 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -1,9 +1,7 @@ # AGENTS.md — __PROJECT_NAME__ starter - Instructions for the AI agent in this game. `CLAUDE.md` mirrors this file; edit `AGENTS.md`. - ## Ownership ThreeNative owns bootstrap, renderer, fixed-step loop, input, loading, physics bindings, and the state bridge. This repository owns gameplay and every visible choice in `src/render/`, `src/entities/`, `src/scenes/`, and `src/ui/`; `src/game.ts` is portable and React mounts from `src/main.ts`. The render camera also skips an object that projects under **0.5 px** in it; `renderer.minimumProjectedPixels` raises that threshold (`false` disables the cut, not the count) and `alwaysRender(object)` exempts an object, while camera-attached objects and shadow casters are kept. The engine also owns the per-frame world-matrix walk, and by default it does not descend into a hidden subtree — so a game that reads a hidden object's `matrixWorld` directly must use `getWorldPosition` (or call `object.updateWorldMatrix(true, false)`) first; `renderer.matrixWorld: "all"` restores three's every-node walk, and `TN_PROJECTION` reports the visited-node count either way. The look ships as a photographed sky and a rigged figure: `src/render/sky.ts` makes `assets/sky.jpg` (Poly Haven, CC0) the background, the environment light and the fog colour, `lighting.ts` adds the one sun it names in `SUN_DIRECTION`, and `Player.ts` drives `assets/mannequin.glb` (Quaternius, CC0) through `SkeletalMesh3D`. Swap the sky for any equirectangular image and re-aim `SUN_DIRECTION` at its sun; swap the rig and edit `CLIPS`. @@ -95,4 +93,5 @@ Recipes in the installed create-threenative: `node_modules/create-threenative/ag ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. +High-tier desktop browser WebGPU adds game-owned grazing backlight (`lighting.ts`: `rimGain: 0`) and measured-dark directional fill (`fillGain: 0` or black `fillColor`). `materialLighting.ts` preserves borrowed material maps and restores originals on lower quality/exit; mobile, software, WebGL and native retain originals. `TN_ENVIRONMENT_CONTRIBUTION` reports even with overrides zero; `environmentSampling.ts` estimates actual IBL once during load; unavailable/stale samples stay unknown and disable analytic fill. Native material terms remain unqualified. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts b/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/starter/src/render/environmentContribution.ts b/packages/create-threenative/templates/starter/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/starter/src/render/lighting.ts index 0ba8b44d46..05bdcf10e8 100644 --- a/packages/create-threenative/templates/starter/src/render/lighting.ts +++ b/packages/create-threenative/templates/starter/src/render/lighting.ts @@ -3,7 +3,7 @@ // // One sun. The sky image in `sky.ts` is the fill light — its environment reaches every face the // sun misses — so there is no hemisphere or ambient light stacked on top to flatten the frame. -import { DirectionalLight, PCFSoftShadowMap, type Scene } from "three"; +import { Color, DirectionalLight, PCFSoftShadowMap, type Scene, Vector3 } from "three"; import { SUN_DIRECTION } from "./sky.js"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -41,3 +41,28 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } + +/** Material conventions are game-owned; each scene gets independent mutable controls. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0.12, + fillGain: 1, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/materialAssignments.ts b/packages/create-threenative/templates/starter/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/materialLighting.ts b/packages/create-threenative/templates/starter/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/postprocessing.ts b/packages/create-threenative/templates/starter/src/render/postprocessing.ts index e7547055ab..6d4a140a9c 100644 --- a/packages/create-threenative/templates/starter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/starter/src/render/postprocessing.ts @@ -22,6 +22,7 @@ import type { FogMedium } from "./volumetricFog.js"; import { type OutputRenderer, WorldEnvironment } from "./worldEnvironment.js"; interface IPostController { + readonly tier: QualityTier; debug(): Record; observe(window: IQualityWindow): void; dispose(): void; @@ -45,6 +46,8 @@ export function setupPost( software?: boolean; /** Forces a tier while keeping its costs observed. Unknown names throw. */ tier?: QualityTier; + /** Scene-owned material assignments follow the same resolved tier. */ + onTierChanged?: (tier: QualityTier) => void; /** * The bounded participating medium from `volumetricFog.ts`, built once per graph: a tier change * replaces the graph, and a fog controller owns one graph, so it is released and rebuilt here @@ -77,6 +80,7 @@ export function setupPost( }); disposeGraph = applied.dispose; observation = { ...observation, stages: applied.stages, dropped: applied.dropped }; + environment.onTierChanged?.(policy.tier); } apply(); const source = environment.tier === undefined ? "platform" : "override"; @@ -86,6 +90,9 @@ export function setupPost( } source=${source}`, ); const controller = { + get tier(): QualityTier { + return policy.tier; + }, debug: () => observation, observe(window: IQualityWindow): void { if (disposed) return; diff --git a/packages/create-threenative/templates/starter/src/render/quality.ts b/packages/create-threenative/templates/starter/src/render/quality.ts index be5327fc1f..9d6bb47c73 100644 --- a/packages/create-threenative/templates/starter/src/render/quality.ts +++ b/packages/create-threenative/templates/starter/src/render/quality.ts @@ -126,3 +126,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index 52129f90be..0de666f8f4 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -25,10 +25,13 @@ 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 } from "../render/lighting.js"; import { createLoadingScreen } from "../render/loading.js"; +import { createMaterialLighting } from "../render/materialLighting.js"; import { createPennantMaterial, propMaterial } from "../render/materials.js"; import { setupPost } from "../render/postprocessing.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { ball, block, spike, tube } from "../render/shapes.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; @@ -48,6 +51,7 @@ export class Play extends Scene { #assetProof: Mesh | undefined; #playerModel: IPlayerModel | undefined; #sky: Texture | undefined; + #environmentSample: IEnvironmentSample | undefined; static override readonly initialState: GameState = { coyoteJumps: 0, @@ -78,6 +82,14 @@ export class Play extends Scene { ]); this.#playerModel = playerModel; this.#sky = sky; + setupSky(ctx.scene, sky, ctx.renderer.softwareAdapter !== undefined); + this.#environmentSample = await sampleEnvironment(ctx.renderer.raw, ctx.scene, { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }); // A 16-pixel check filtered smoothly is a grey smear at flag size; nearest keeps the // squares square, which is the whole reason the finish flag is legible from the ledge. texture.magFilter = NearestFilter; @@ -147,10 +159,19 @@ export class Play extends Scene { ctx.renderer.raw as Parameters[1], isMobile(), ); + const materialEnvironment = { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; this.#post = ctx.entities.add( "quality", setupPost(ctx.renderer, ctx.scene, ctx.camera, { godraysLight: key, + onTierChanged: (tier) => + this.#materialLighting?.setEnabled(materialLightingEnabled(tier, materialEnvironment)), mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, // One rule for the frame budget, from the engine: the display refresh capped at 120, @@ -229,6 +250,23 @@ export class Play extends Scene { // not a debug object parked over the level. The texture and the glTF still load in // `load()` above, which is what the native asset gate greps for. const goal = ctx.entities.add("goal", new Goal(ctx, this.#assetProof)); + // All borrowed materials, including the cloned skinned mannequin and arena, now exist. + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, key, { + ...materialEnvironment, + enabled: materialLightingEnabled(this.#post.tier, materialEnvironment), + }), + ); + if (this.#environmentSample !== undefined) { + const { measurement, source, intensity } = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + measurement, + source, + intensity, + this.#environmentSample, + ); + } const supportSurfaceY = (position: { readonly x: number; readonly z: number }): | number | undefined => { @@ -359,8 +397,11 @@ export class Play extends Scene { } #post: ReturnType | undefined; + #materialLighting: ReturnType | undefined; override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; // Also releases the medium `setupPost` built, with the graph it composed into. this.#post?.dispose(); this.#post = undefined; diff --git a/packages/create-threenative/templates/tower-defense/AGENTS.md b/packages/create-threenative/templates/tower-defense/AGENTS.md index 289c63cb2e..499a67f841 100644 --- a/packages/create-threenative/templates/tower-defense/AGENTS.md +++ b/packages/create-threenative/templates/tower-defense/AGENTS.md @@ -92,5 +92,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/tower-defense/CLAUDE.md b/packages/create-threenative/templates/tower-defense/CLAUDE.md index 9bdc9aff35..b9b6367071 100644 --- a/packages/create-threenative/templates/tower-defense/CLAUDE.md +++ b/packages/create-threenative/templates/tower-defense/CLAUDE.md @@ -94,5 +94,5 @@ reports the source. A scenario with no assertions or missing observations fails; Recipes in the installed create-threenative: `node_modules/create-threenative/agent-docs/references/assertion-reference.md`, `node_modules/create-threenative/agent-docs/references/build-profiles.md`, `node_modules/create-threenative/agent-docs/references/capability-reference.md`, `node_modules/create-threenative/agent-docs/references/capture-the-frame.md`, `node_modules/create-threenative/agent-docs/references/creating-creatures.md`, `node_modules/create-threenative/agent-docs/references/ctx-cookbook.md`, `node_modules/create-threenative/agent-docs/references/debug-surface.md`, `node_modules/create-threenative/agent-docs/references/finding-assets.md`, `node_modules/create-threenative/agent-docs/references/gameplay-recipes.md`, `node_modules/create-threenative/agent-docs/references/menu-screens.md`, `node_modules/create-threenative/agent-docs/references/mobile-memory-budget.md`, `node_modules/create-threenative/agent-docs/references/performance-basics.md`, `node_modules/create-threenative/agent-docs/references/rigging-characters.md`, `node_modules/create-threenative/agent-docs/references/sculpt-from-a-reference.md`, `node_modules/create-threenative/agent-docs/references/trace-a-slow-frame.md`, `node_modules/create-threenative/agent-docs/references/visual-baseline.md`, and `node_modules/create-threenative/agent-docs/references/webview-ui.md`. ## Optional multiplayer transport For online play only, import `connect` from `@threenative/core/net` with an HTTPS URL and nonempty identity credential; configure `connectTimeoutMs`, `maxReliableMessageBytes`, `maxQueuedReliableBytes`, and `maxQueuedDatagrams` (10s/65,536/1 MiB/256), use `reliable-ordered` for ordered reliable messages and bounded `unreliable` datagrams that may drop, and keep serialization, replication, prediction, interpolation, snapshots and rejoin in this game's `src/` and server. There is no fallback: unsupported WebTransport/native rejects with `TN_NET_UNAVAILABLE`; reference Go server: `packages/runtime-native/examples/webtransport/server`. - +Material backlighting lives in `src/render/lighting.ts`: named `rimGain` and `fillGain` controls preserve the authored palette; `materialLighting.ts` reports `TN_ENVIRONMENT_CONTRIBUTION`. Admission is limited to high hardware desktop WebGPU; mobile/software/WebGL/native retain original materials. Existing authored fill stays authoritative. `src/render/exposure.ts` owns optional adaptation (`enabled: false`); `autoExposure.ts` meters even when disabled. Recipe: `node_modules/create-threenative/agent-docs/references/auto-exposure.md`. diff --git a/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts b/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts new file mode 100644 index 0000000000..4c8584f7b6 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/backlightMaterial.ts @@ -0,0 +1,178 @@ +// Generated for you. Ordinary Three.js; controls belong to your game. +import { + type Camera, + type Color, + type DirectionalLight, + Material, + type MeshStandardMaterial, + type Object3D, + 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"; + +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; +} + +function validateControls(controls: IBacklightControls) { + if ( + !Number.isFinite(controls.rimGain) || + controls.rimGain < 0 || + !Number.isFinite(controls.key.intensity) || + controls.key.intensity < 0 || + !controls.key.color.toArray().every((value) => Number.isFinite(value) && value >= 0) || + !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 || + !controls.fillColor.toArray().every((value) => Number.isFinite(value) && value >= 0) + ) + throw new Error( + "Backlight controls require finite nonnegative gains, a direction and angular size in (0, 1.5].", + ); +} +function resolveKey(controls: IBacklightControls, direction: Vector3, target: Vector3) { + 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 (!direction.toArray().every(Number.isFinite)) 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(controls.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; +} + +/** + * 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); + const rimGain = uniform(controls.rimGain).onRenderUpdate(() => { + validateControls(controls); + return controls.rimGain; + }); + const fillGain = uniform(0).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAdmitted ? controls.fillGain : 0; + }); + const fillColor = uniform(controls.fillColor).onRenderUpdate(() => { + validateControls(controls); + return controls.fillColor; + }); + const fillDirection = uniform(controls.fillDirection).onRenderUpdate(() => { + validateControls(controls); + return controls.fillDirection; + }); + const fillAngularSize = uniform(controls.fillAngularSize).onRenderUpdate(() => { + validateControls(controls); + return controls.fillAngularSize; + }); + const keyColor = uniform(controls.key.color).onRenderUpdate(() => { + validateControls(controls); + return controls.key.color; + }); + const keyDirectionValue = new Vector3(0, 1, 0); + const keyTargetValue = new Vector3(); + const keyDirection = uniform(keyDirectionValue).onRenderUpdate(() => { + resolveKey(controls, keyDirectionValue, keyTargetValue); + return keyDirectionValue; + }); + // Removal/visibility/intensity are live. A removed sun cannot leave an invented rim behind. + const keyGain = uniform(0).onRenderUpdate(() => + resolveKey(controls, keyDirectionValue, keyTargetValue), + ); + 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); + material.emissiveNode = materialEmissive.add(rim).add(fill); + return material; +} diff --git a/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts b/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts new file mode 100644 index 0000000000..a922353cc2 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/environmentContribution.ts @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000000..7c489a5206 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/environmentRadiance.ts @@ -0,0 +1,36 @@ +// 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 new file mode 100644 index 0000000000..403c2b1318 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/environmentSampleCache.ts @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000000..5726a53411 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/environmentSampling.ts @@ -0,0 +1,193 @@ +// 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 new file mode 100644 index 0000000000..dd5cc8e16c --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/environmentSnapshot.ts @@ -0,0 +1,53 @@ +// 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/lighting.ts b/packages/create-threenative/templates/tower-defense/src/render/lighting.ts index 8fd78fdce7..e69d34a16b 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/lighting.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/lighting.ts @@ -5,7 +5,14 @@ // cool sky-to-moss hemisphere that keeps the shaded sides from going black, and a faint cold rim from // behind that separates the towers from the slab. The photographed sky in `sky.ts` is the fourth // source: it is what metal reflects. -import { DirectionalLight, HemisphereLight, PCFSoftShadowMap, type Scene } from "three"; +import { + Color, + DirectionalLight, + HemisphereLight, + PCFSoftShadowMap, + type Scene, + Vector3, +} from "three"; type ShadowRenderer = { shadowMap: { enabled: boolean; type: number } }; @@ -40,3 +47,28 @@ export function setupLighting( // shadow map, so `setupPost` needs the light itself. return { key }; } + +/** Existing authored rim/fill is preserved; opt in to extra terms with the named gains. */ +export interface ILightingConvention { + rimGain: number; + fillGain: number; + fillColor: Color; + fillDirection: Vector3; + fillAngularSize: number; + darkThreshold: number; + maxSourceTexels: number; +} +export function createLightingConvention( + overrides: Partial = {}, +): ILightingConvention { + return { + rimGain: 0, + fillGain: 0, + fillColor: new Color(0x667b9d), + fillDirection: new Vector3(0, 1, 1).normalize(), + fillAngularSize: 0.7, + darkThreshold: 0.001, + maxSourceTexels: 65536, + ...overrides, + }; +} diff --git a/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts b/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts new file mode 100644 index 0000000000..3d12ed589b --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/materialAssignments.ts @@ -0,0 +1,160 @@ +// 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"]; + applied?: Mesh["material"]; + slots?: readonly Material[]; + readonly owned: Set; + readonly exclusions: string[]; +} +export function createMaterialAssignments(scene: Scene, controls: IBacklightControls) { + const assignments = new Map(); + const references = new Map(); + let enabled = false; + let disposed = false; + const converted = new Map>(); + const originalOf = new Map(); + function enroll(root: Object3D): void { + if (disposed) return; + root.traverse((object) => { + if (!(object instanceof Mesh) || assignments.has(object)) return; + const assignment: IMaterialAssignment = { + mesh: object, + original: object.material, + owned: new Set(), + exclusions: [], + }; + assignments.set(object, assignment); + assignment.exclusions.push(...excludedSlots(object)); + if (enabled) applyAssignment(assignment); + }); + } + function release(root: Object3D): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const assignment = assignments.get(object); + if (assignment === undefined) return; + restore(assignment); + assignments.delete(object); + for (const source of assignment.owned) releaseConversion(source); + }); + } + function excludedSlots(mesh: Mesh): string[] { + const slots = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + return slots.flatMap((material) => { + const reason = unsupported(material); + return reason === undefined + ? [] + : [`${mesh.name || mesh.type}:${material.name || material.type}:${reason}`]; + }); + } + function releaseConversion(source: MeshStandardMaterial): void { + const remaining = (references.get(source) ?? 1) - 1; + if (remaining > 0) { + references.set(source, remaining); + return; + } + const material = converted.get(source); + if (material !== undefined) { + material.dispose(); + originalOf.delete(material); + } + references.delete(source); + converted.delete(source); + } + function unsupported(material: Material): string | undefined { + if (!(material instanceof MeshStandardMaterial) || material.type !== "MeshStandardMaterial") + return "unsupported material type"; + if ( + material.onBeforeCompile !== Material.prototype.onBeforeCompile || + material.customProgramCacheKey !== Material.prototype.customProgramCacheKey + ) + return "custom shader requires separate qualification"; + return undefined; + } + function convert(material: Material, assignment: IMaterialAssignment): Material { + if (unsupported(material) !== undefined) return material; + const standard = material as MeshStandardMaterial; + let owned = converted.get(standard); + if (owned === undefined) { + owned = backlightMaterial(standard, controls); + converted.set(standard, owned); + originalOf.set(owned, standard); + } + if (!assignment.owned.has(standard)) { + assignment.owned.add(standard); + references.set(standard, (references.get(standard) ?? 0) + 1); + } + return owned; + } + function restore(assignment: IMaterialAssignment): void { + const current = assignment.mesh.material; + if (current === assignment.applied) { + const unchanged = + !Array.isArray(current) || + (current.length === assignment.slots?.length && + current.every((material, index) => material === assignment.slots?.[index])); + if (unchanged) { + assignment.mesh.material = assignment.original; + return; + } + } + // Preserve authored array edits/replacements, but never leave an owned conversion attached. + if (Array.isArray(current)) { + const restored = current.map((material) => originalOf.get(material) ?? material); + if (restored.some((material, index) => material !== current[index])) + assignment.mesh.material = restored; + } else assignment.mesh.material = originalOf.get(current) ?? current; + } + function applyAssignment(assignment: IMaterialAssignment): void { + // The game's later material override wins; never overwrite it on a tier recovery. + if (assignment.mesh.material !== assignment.original) return; + if (Array.isArray(assignment.original)) { + // Borrowed arrays may be edited in place during a fallback. Read their current slots. + const slots = assignment.original.map((material) => convert(material, assignment)); + assignment.applied = slots.some( + (material, index) => material !== (assignment.original as Material[])[index], + ) + ? slots + : assignment.original; + assignment.slots = [...slots]; + } else if (assignment.applied === undefined) { + assignment.applied = convert(assignment.original, assignment); + } + assignment.mesh.material = assignment.applied; + } + enroll(scene); + return { + enroll, + release, + setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested; + for (const assignment of assignments.values()) { + if (enabled) applyAssignment(assignment); + else restore(assignment); + } + }, + debug: () => ({ + convertedMaterials: converted.size, + enrolledMeshes: assignments.size, + excludedSlots: [...assignments.values()].reduce( + (sum, value) => sum + value.exclusions.length, + 0, + ), + exclusions: [...assignments.values()].flatMap((value) => value.exclusions), + }), + dispose(): void { + if (disposed) return; + disposed = true; + for (const assignment of assignments.values()) restore(assignment); + assignments.clear(); + references.clear(); + for (const material of converted.values()) material.dispose(); + converted.clear(); + originalOf.clear(); + }, + }; +} diff --git a/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts b/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts new file mode 100644 index 0000000000..cb622230b1 --- /dev/null +++ b/packages/create-threenative/templates/tower-defense/src/render/materialLighting.ts @@ -0,0 +1,155 @@ +// Generated for you. Scene-owned material assignments; no engine appearance hooks. +import { + type Camera, + type DirectionalLight, + Material, + Mesh, + MeshStandardMaterial, + type Object3D, + type Scene, + type Texture, +} from "three"; +import { type IBacklightControls, backlightMaterial } from "./backlightMaterial.js"; +import { + type IEnvironmentMeasurement, + measureEnvironment, + reportEnvironmentContribution, +} from "./environmentContribution.js"; +import { + type IEnvironmentSourceSnapshot, + captureEnvironmentSnapshot, + environmentSnapshotCurrent, +} from "./environmentSnapshot.js"; +import { type ILightingConvention, createLightingConvention } from "./lighting.js"; +import { createMaterialAssignments } from "./materialAssignments.js"; +import { type IMaterialLightingEnvironment, materialLightingEnabled } from "./quality.js"; +interface IMaterialLightingOptions extends IMaterialLightingEnvironment { + readonly enabled: boolean; + readonly overrides?: Partial; +} +/** Call once AFTER the scene's loaded character, arena and props are attached. */ +export function createMaterialLighting( + scene: Scene, + camera: Camera, + key: DirectionalLight, + options: IMaterialLightingOptions, +) { + const settings = createLightingConvention(options.overrides); + let enabled = false; + let disposed = false; + let fillApproved = false; + let measured = measureEnvironment(scene, settings.maxSourceTexels); + let measurementSnapshot = captureEnvironmentSnapshot(scene); + const controls: IBacklightControls = { + key, + scene, + camera, + get rimGain() { + return settings.rimGain; + }, + set rimGain(value) { + settings.rimGain = value; + }, + get fillGain() { + return settings.fillGain; + }, + set fillGain(value) { + settings.fillGain = value; + }, + fillColor: settings.fillColor, + fillDirection: settings.fillDirection, + get fillAngularSize() { + return settings.fillAngularSize; + }, + set fillAngularSize(value) { + settings.fillAngularSize = value; + }, + get fillAdmitted() { + return enabled && fillApproved && environmentSnapshotCurrent(scene, measurementSnapshot); + }, + }; + const assignments = createMaterialAssignments(scene, controls); + function report() { + if (!environmentSnapshotCurrent(scene, measurementSnapshot)) { + measured = measureEnvironment(scene, settings.maxSourceTexels); + measurementSnapshot = captureEnvironmentSnapshot(scene); + } + fillApproved = + measured.status === "measured" && + measured.meanRadiance !== null && + measured.meanRadiance <= settings.darkThreshold; + return reportEnvironmentContribution( + scene, + { + ...settings, + rimGain: controls.rimGain, + fillGain: controls.fillGain, + fillColor: controls.fillColor, + fillAdmitted: controls.fillAdmitted, + }, + measured, + ); + } + let observation: ReturnType; + function setEnabled(requested: boolean): void { + if (disposed) return; + enabled = requested && materialLightingEnabled("high", options); + assignments.setEnabled(enabled); + observation = report(); + } + setEnabled(options.enabled); + return { + controls, + setEnabled, + /** Enroll a newly attached game-owned subtree. */ + enroll(root: Object3D): void { + if (!disposed) assignments.enroll(root); + }, + /** Restore assignments before removing a subtree or disposing borrowed materials. */ + release(root: Object3D): void { + if (!disposed) assignments.release(root); + }, + report() { + observation = report(); + return observation; + }, + /** Root's later GPU sampler can provide a measured snapshot of the current environment. */ + setEnvironmentMeasurement( + measurement: IEnvironmentMeasurement, + source: Texture | null = scene.environment, + intensity = scene.environmentIntensity, + snapshot: IEnvironmentSourceSnapshot = captureEnvironmentSnapshot(scene), + ): void { + if (disposed) return; + if ( + measurement.status === "measured" && + (measurement.meanRadiance === null || + !Number.isFinite(measurement.meanRadiance) || + measurement.meanRadiance < 0 || + measurement.meanRGB === null || + !measurement.meanRGB.every((value) => Number.isFinite(value) && value >= 0)) + ) + throw new Error("Measured environment radiance must be finite and nonnegative."); + if ( + source !== snapshot.source || + intensity !== snapshot.intensity || + !environmentSnapshotCurrent(scene, snapshot) + ) + return; + measured = measurement; + measurementSnapshot = snapshot; + observation = report(); + }, + debug: () => ({ + ...observation, + enabled, + ...assignments.debug(), + }), + dispose(): void { + if (disposed) return; + enabled = false; + disposed = true; + assignments.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/tower-defense/src/render/postprocessing.ts b/packages/create-threenative/templates/tower-defense/src/render/postprocessing.ts index 029ef102c4..9ea4fae3ca 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/postprocessing.ts @@ -26,8 +26,9 @@ export function setupPost( software?: boolean; /** Forces a tier, ignoring `mobile`. An unknown name throws rather than falling back. */ tier?: QualityTier; + onTierChanged?: (tier: QualityTier) => void; } = {}, -): void { +) { const tier = resolveQualityTier({ mobile: environment.mobile, software: environment.software, @@ -40,7 +41,9 @@ export function setupPost( } source=${source}`, ); const world = new WorldEnvironment(qualityPreset(tier)); - world.apply(renderer, scene, camera, { + const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, }); + environment.onTierChanged?.(tier); + return { tier, dispose: () => applied.dispose?.() }; } 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 1c7da07a47..81cfadceb1 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/quality.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/quality.ts @@ -127,3 +127,36 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** 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 + ); +} 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 24e7c5a475..24cff07544 100644 --- a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts +++ b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts @@ -1,4 +1,11 @@ -import { CameraShake, type ICtx, Scene, type SceneFrame, isMobile } from "@threenative/core"; +import { + CameraShake, + type ICtx, + Scene, + type SceneFrame, + getPlatform, + isMobile, +} from "@threenative/core"; import type { IPhysicsContext } from "@threenative/physics"; import { type Group, type Mesh, type PerspectiveCamera, type Texture, Vector3 } from "three"; import { @@ -27,10 +34,13 @@ 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"; import { palette } from "../render/palette.js"; import { setupPost } from "../render/postprocessing.js"; +import { isWebGLFallbackRenderer, materialLightingEnabled } from "../render/quality.js"; import { ghostTower, rangeGeometry, rangeRing } from "../render/shapes.js"; import { setupSky } from "../render/sky.js"; import { type GameState, INITIAL_STATE } from "../state.js"; @@ -64,12 +74,30 @@ function sameState(a: GameState, b: GameState): boolean { } export class Battle extends Scene { + #environmentSample: IEnvironmentSample | undefined; + #materialLighting: ReturnType | undefined; + #post: ReturnType | undefined; + #materialEnvironment(ctx: GameCtx) { + return { + web: getPlatform().runtime === "web", + rendererKind: ctx.renderer.kind, + webglFallback: isWebGLFallbackRenderer(ctx.renderer.raw), + mobile: isMobile(), + software: ctx.renderer.softwareAdapter !== undefined, + }; + } static override readonly initialState = INITIAL_STATE; #sky: Texture | undefined; override async load(ctx: GameCtx): Promise { this.#sky = await ctx.assets.texture("sky.jpg"); + setupSky(ctx.scene, this.#sky); + this.#environmentSample = await sampleEnvironment( + ctx.renderer.raw, + ctx.scene, + this.#materialEnvironment(ctx), + ); } override enter(ctx: GameCtx): SceneFrame { @@ -82,10 +110,14 @@ export class Battle extends Scene { ); // isMobile() arrives as an argument because src/render/ imports no framework package: the // platform decision is made here, in portable game code. - setupPost(ctx.renderer, ctx.scene, ctx.camera, { + this.#post = setupPost(ctx.renderer, ctx.scene, ctx.camera, { godraysLight: key, mobile: isMobile(), software: ctx.renderer.softwareAdapter !== undefined, + onTierChanged: (tier) => + this.#materialLighting?.setEnabled( + materialLightingEnabled(tier, this.#materialEnvironment(ctx)), + ), }); const camera = ctx.camera as PerspectiveCamera; setupCamera(camera); @@ -155,6 +187,7 @@ export class Battle extends Scene { pool.push(enemy); enemies.set(entityId, enemy); ctx.add(enemy.mesh); + this.#materialLighting?.enroll(enemy.mesh); ctx.entities.add(entityId, enemy); } enemy.reset(`${enemy.entityId}.w${wave}`, spawn.hp); @@ -194,6 +227,7 @@ export class Battle extends Scene { towerAt.set(pad, tower); board.occupy(pad); ctx.add(tower.group); + this.#materialLighting?.enroll(tower.group); ctx.entities.add(`tower.${id}`, tower); return tower; }; @@ -328,7 +362,10 @@ export class Battle extends Scene { return; } tower.invested += cost; + const oldGroup = tower.group; + this.#materialLighting?.release(oldGroup); tower.upgrade(); + this.#materialLighting?.enroll(tower.group); select(tower); setRing(selectRing, tower.stats.range); selectRadius = tower.stats.range; @@ -343,6 +380,7 @@ export class Battle extends Scene { towerAt.delete(tower.padIndex); towers.delete(tower.id); select(undefined); + this.#materialLighting?.release(tower.group); ctx.entities.remove(`tower.${tower.id}`); toast(`Recycled +${refund}`); }; @@ -516,6 +554,23 @@ export class Battle extends Scene { if (!sameState(next, previous)) frameCtx.state.set(next); }; + this.#materialLighting = ctx.entities.add( + "material-lighting", + createMaterialLighting(ctx.scene, ctx.camera, key, { + ...this.#materialEnvironment(ctx), + enabled: materialLightingEnabled(this.#post.tier, this.#materialEnvironment(ctx)), + }), + ); + if (this.#environmentSample !== undefined) { + const { measurement, source, intensity } = this.#environmentSample; + this.#materialLighting.setEnvironmentMeasurement( + measurement, + source, + intensity, + this.#environmentSample, + ); + } + return (frameCtx, rawDt) => { loading.update(); const input = frameCtx.input; @@ -536,4 +591,11 @@ export class Battle extends Scene { publish(frameCtx, speed); }; } + override exit(ctx: GameCtx): void { + this.#materialLighting?.dispose(); + this.#materialLighting = undefined; + this.#post?.dispose(); + this.#post = undefined; + super.exit(ctx); + } } diff --git a/packages/metahuman/__tests__/consumer-package.spec.ts b/packages/metahuman/__tests__/consumer-package.spec.ts index b62eb9e37a..cc09ae317f 100644 --- a/packages/metahuman/__tests__/consumer-package.spec.ts +++ b/packages/metahuman/__tests__/consumer-package.spec.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; -import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -54,7 +54,8 @@ beforeAll(async () => { .split("\n") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0), - root: path.join(archiveDirectory, "package"), + // Node resolves import.meta URLs through a symlinked TMPDIR to physical paths. + root: await realpath(path.join(archiveDirectory, "package")), }; // Created in beforeAll, outside any test, so makeTempDir can only register process-exit cleanup, // which a vitest worker does not reliably reach: remove the one sandbox directory explicitly. diff --git a/packages/playtest/__tests__/orphan-cleanup.sh b/packages/playtest/__tests__/orphan-cleanup.sh index 42b6277922..b106f8c9ad 100755 --- a/packages/playtest/__tests__/orphan-cleanup.sh +++ b/packages/playtest/__tests__/orphan-cleanup.sh @@ -118,6 +118,19 @@ if [[ ! -x "$browser_path" ]]; then exit 2 fi +# The capture mutex intentionally retains its coordination root and empty queue after release. +# Initialize only this run's private infrastructure before counting; still require no lease or +# queue entry after SIGTERM, so a real lock/process/profile leak cannot become a green count. +capture_coordination_root="$suite_temp_root/threenative-playtest-capture" +mkdir -p -- "$capture_coordination_root/queue" +capture_coordination_empty() { + [[ ! -e "$capture_coordination_root/lock" && ! -L "$capture_coordination_root/lock" ]] || return 1 + if [[ -d "$capture_coordination_root/queue" && ! -L "$capture_coordination_root/queue" ]]; then + [[ -z "$(find "$capture_coordination_root/queue" -mindepth 1 -maxdepth 1 -print -quit)" ]] + else + [[ ! -e "$capture_coordination_root/queue" && ! -L "$capture_coordination_root/queue" ]] + fi +} before_temp_directories="$(count_temp_directories)" set +e @@ -167,7 +180,7 @@ while true; do sleep 1 orphans="$(list_orphan_processes)" after_temp_directories="$(count_temp_directories)" - if [[ -z "$orphans" && "$after_temp_directories" -le "$before_temp_directories" ]]; then + if [[ -z "$orphans" && "$after_temp_directories" -le "$before_temp_directories" ]] && capture_coordination_empty; then break fi if (( SECONDS - settle_started >= settle_deadline_seconds )); then @@ -181,6 +194,12 @@ if [[ -n "$orphans" ]]; then exit 1 fi +if ! capture_coordination_empty; then + echo "owned capture lease or queue entries remain after ${settle_deadline_seconds}s in '$capture_coordination_root'" >&2 + find "$capture_coordination_root" -mindepth 1 -maxdepth 2 -print >&2 2>/dev/null || true + exit 1 +fi + if [[ "$after_temp_directories" -gt "$before_temp_directories" ]]; then echo "temporary directory count grew in suite namespace '$suite_temp_root' and did not settle within ${settle_deadline_seconds}s: before $before_temp_directories, after $after_temp_directories" >&2 find "$suite_temp_root" -mindepth 1 -maxdepth 1 -type d -print >&2 2>/dev/null || true diff --git a/packages/playtest/__tests__/owned-run-resources.spec.ts b/packages/playtest/__tests__/owned-run-resources.spec.ts new file mode 100644 index 0000000000..6838bb4370 --- /dev/null +++ b/packages/playtest/__tests__/owned-run-resources.spec.ts @@ -0,0 +1,55 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, vi } from "vitest"; +import { acquireCaptureLock } from "../src/runner/captureLock.js"; +import { createOwnedRunResourceRelease } from "../src/runner/ownedRunResources.js"; +import { handlePlaytestSignal } from "../src/runner/runner.js"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; + +test("signal waits for pending owned acquisition and releases once before exiting", async () => { + const events: string[] = []; + let resolveLease!: (value: { release(): Promise }) => void; + const pending = new Promise<{ release(): Promise }>((resolve) => { resolveLease = resolve; }); + const release = vi.fn(async () => { events.push("lease-release"); }); + const cleanup = createOwnedRunResourceRelease(undefinedResource, () => pending); + const signal = handlePlaytestSignal(async () => cleanup(), (code) => events.push(`code:${code}`), + (code) => events.push(`exit:${code}`), "browser", () => undefined); + await Promise.resolve(); + expect(events).toEqual([]); + resolveLease({ release }); + await signal; + await cleanup(); + expect(events).toEqual(["lease-release", "code:2", "exit:2"]); + expect(release).toHaveBeenCalledTimes(1); +}); + +function undefinedResource() { return undefined; } + +test("display acquisition/release errors do not skip lease release or duplicate cleanup", async () => { + for (const failedAcquisition of [false, true]) { + const events: string[] = []; + const display = failedAcquisition ? Promise.reject(new Error("display acquisition failed")) + : Promise.resolve({ release: async () => { events.push("display"); throw new Error("display release failed"); } }); + const lease = Promise.resolve({ release: async () => { events.push("lease"); } }); + const cleanup = createOwnedRunResourceRelease(() => display, () => lease); + await Promise.all([cleanup(), cleanup()]); + await cleanup(); + expect(events).toEqual(failedAcquisition ? ["lease"] : ["display", "lease"]); + } +}); + +test("real acquired lease cleanup preserves a replacement owner's holder", async () => { + const root = await makeTempDir("tn-owned-release-"); + try { + const holder = path.join(root, "lock", "holder.json"); + const lease = await acquireCaptureLock({ lockRoot: root, timeoutMs: 1000 }); + const replacement = { pid: process.pid + 100000, startedAt: "replacement-owner" }; + await writeFile(holder, JSON.stringify(replacement)); + const cleanup = createOwnedRunResourceRelease(undefinedResource, () => Promise.resolve(lease)); + await cleanup(); + await cleanup(); + expect(JSON.parse(await readFile(holder, "utf8"))).toEqual(replacement); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/playtest/__tests__/tone-regions.spec.ts b/packages/playtest/__tests__/tone-regions.spec.ts new file mode 100644 index 0000000000..a696886086 --- /dev/null +++ b/packages/playtest/__tests__/tone-regions.spec.ts @@ -0,0 +1,259 @@ +import { PNG } from "pngjs"; +import { expect, test } from "vitest"; +import { inspectFrame, collectRegionalTone } from "../src/capture.js"; +import { validatePlaytestScenario } from "../src/scenario.js"; +import { evaluateRichPlaytestAssertions } from "../src/assertion-evaluators.js"; +import type { IPlaytestReport } from "../src/report.js"; +const left = { x: 0, y: 0, width: 2, height: 2 }; +const right = { x: 2, y: 0, width: 2, height: 2 }; +function png() { + const image = new PNG({ width: 4, height: 2 }); + for (let y = 0; y < 2; y++) + for (let x = 0; x < 4; x++) { + const v = x < 2 ? 40 : 200; + image.data.set([v, v, v, x === 0 && y === 0 ? 0 : 255], (y * 4 + x) * 4); + } + return PNG.sync.write(image); +} +function scenario(tone: unknown) { + return validatePlaytestScenario( + { + name: "regions", + schemaVersion: 1, + steps: [{ label: "landed", waitTicks: 1 }], + assert: { tone }, + }, + "regions.json", + ); +} +function evaluate(tone: unknown, observed: unknown) { + return evaluateRichPlaytestAssertions({ + scenario: scenario(tone), + report: { + observations: { console: [], hud: {}, network: [], resources: {}, tone: observed }, + } as unknown as IPlaytestReport, + }); +} +test("physical regions use exact pixels and exclude transparent pixels", () => { + const assertions = scenario([ + { region: left, mean: { min: 40, max: 40 } }, + { region: right, p99: { min: 200 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + expect(rows.map((r) => r.mean)).toEqual([40, 200]); + expect(rows[0]).toMatchObject({ assertionIndex: 0, region: left, p99: 40 }); + expect(evaluate(assertions, rows).assertions.every((r) => r.pass)).toBe(true); +}); +test.each([ + { ...left, x: -1 }, + { ...left, x: 0.5 }, + { ...left, width: 0 }, + { ...left, height: Number.MAX_SAFE_INTEGER + 1 }, + { ...left, y: "0" }, + { ...left, extra: 1 }, +])("rejects malformed region %j", (region) => { + expect(() => scenario([{ region, mean: { min: 1 } }])).toThrow(); +}); +test.each([ + { region: right, metric: "typo", minDelta: 1 }, + { region: right, metric: "p99", minDelta: Number.POSITIVE_INFINITY }, + { region: right, metric: "p99", minDelta: 256 }, +])("rejects malformed compare %j", (compare) => { + expect(() => scenario([{ region: left, compare }])).toThrow(); +}); +test("out-of-bounds and transparent regions fail without substituting whole-frame metrics", () => { + const assertions = scenario([ + { region: { ...left, x: 4 }, mean: { min: 0 } }, + { region: { x: 0, y: 0, width: 1, height: 1 }, mean: { min: 0 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + expect(rows.every((r) => r.error)).toBe(true); + expect( + evaluate(assertions, rows) + .assertions.filter((r) => r.id.startsWith("tone.")) + .every((r) => !r.pass), + ).toBe(true); +}); +test("same capture comparison uses primary minus reference and mutation fails", () => { + const assertions = scenario([ + { region: right, compare: { region: left, metric: "p99", minDelta: 160 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + expect(rows[0]!.reference).toMatchObject({ region: left, metrics: { p99: 40 } }); + expect(evaluate(assertions, rows).assertions.every((r) => r.pass)).toBe(true); + expect( + evaluate( + [{ region: right, compare: { region: left, metric: "p99", minDelta: 161 } }], + rows, + ).assertions.some((r) => !r.pass), + ).toBe(true); +}); +test("step selection, assertion identity, and absent native regions fail closed", () => { + const assertions = scenario([{ atStep: "landed", region: right, p99: { min: 200 } }]).assert! + .tone!; + expect(collectRegionalTone(png(), assertions, "after.png")).toEqual([]); + const rows = collectRegionalTone(png(), assertions, "step.png", "landed"); + expect(evaluate(assertions, rows).assertions.every((r) => r.pass)).toBe(true); + expect( + evaluate( + assertions, + rows.map((r) => ({ ...r, assertionIndex: 1 })), + ).assertions.some((r) => !r.pass), + ).toBe(true); + expect( + evaluate(assertions, [ + { code: "TN_TONE", label: "step.png", atStep: "landed", ...inspectFrame(png()).tone }, + ]).assertions.some((r) => !r.pass), + ).toBe(true); +}); +test("legacy whole-frame evaluation ignores later regional rows", () => { + const whole = { code: "TN_TONE", label: "after.png", ...inspectFrame(png()).tone }; + const rows = collectRegionalTone( + png(), + scenario([{ region: left, mean: { min: 0 } }]).assert!.tone!, + "after.png", + ); + expect( + evaluate([{ mean: { min: whole.mean!, max: whole.mean! } }], [whole, ...rows]).assertions.every( + (r) => r.pass, + ), + ).toBe(true); + expect( + collectRegionalTone(png(), scenario([{ mean: { min: 0 } }]).assert!.tone!, "after.png"), + ).toEqual([]); +}); + +test("comparison requires a primary region and a complete reference", () => { + expect(() => scenario([{ compare: { region: left, metric: "mean", minDelta: 1 } }])).toThrow( + "requires region", + ); + expect(() => scenario([{ region: right, compare: { region: left, metric: "mean" } }])).toThrow( + "minDelta", + ); + const assertions = scenario([ + { region: right, compare: { region: left, metric: "mean", minDelta: 1 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + expect( + evaluate( + assertions, + rows.map((r) => ({ ...r, reference: undefined })), + ).assertions.some((r) => !r.pass), + ).toBe(true); + expect( + evaluate( + assertions, + rows.map((r) => ({ ...r, reference: { region: right, metrics: r.reference!.metrics } })), + ).assertions.some((r) => !r.pass), + ).toBe(true); +}); +test("a translucent pixel counts once, with no alpha weighting or implicit mask", () => { + const image = new PNG({ width: 2, height: 1 }); + image.data.set([0, 0, 0, 1, 255, 255, 255, 255]); + const assertions = scenario([ + { + region: { x: 0, y: 0, width: 2, height: 1 }, + mean: { min: 127.5, max: 127.5 }, + blackFraction: { min: 0.5 }, + clipFraction: { min: 0.5 }, + }, + ]).assert!.tone!; + const rows = collectRegionalTone(PNG.sync.write(image), assertions, "after.png"); + expect(evaluate(assertions, rows).assertions.every((r) => r.pass)).toBe(true); +}); + +test("known quadrants preserve top-left physical coordinates", () => { + const image = new PNG({ width: 4, height: 4 }); + for (let y = 0; y < 4; y++) + for (let x = 0; x < 4; x++) { + const v = (y < 2 ? 0 : 100) + (x < 2 ? 20 : 60); + image.data.set([v, v, v, 255], (y * 4 + x) * 4); + } + const regions = [ + { x: 0, y: 0, width: 2, height: 2 }, + { x: 2, y: 0, width: 2, height: 2 }, + { x: 0, y: 2, width: 2, height: 2 }, + { x: 2, y: 2, width: 2, height: 2 }, + ]; + const assertions = scenario(regions.map((region) => ({ region, mean: { min: 0 } }))).assert! + .tone!; + expect( + collectRegionalTone(PNG.sync.write(image), assertions, "after.png").map((row) => row.mean), + ).toEqual([20, 60, 120, 160]); +}); +test("an out-of-bounds reference fails rather than comparing against zero", () => { + const assertions = scenario([ + { region: right, compare: { region: { ...left, y: 2 }, metric: "p99", minDelta: 1 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + expect(rows[0]!.reference?.error).toBeDefined(); + expect( + evaluate(assertions, rows).assertions.find((row) => row.id === "tone.0.observed")?.pass, + ).toBe(false); +}); + +test.each(["primary", "reference"])( + "null %s region observation fails closed without throwing", + (part) => { + const assertions = scenario([ + { region: right, compare: { region: left, metric: "p99", minDelta: 1 } }, + ]).assert!.tone!; + const rows = collectRegionalTone(png(), assertions, "after.png"); + const malformed = rows.map((row) => + part === "primary" + ? { ...row, region: null } + : { ...row, reference: { ...row.reference, region: null } }, + ); + expect(() => evaluate(assertions, malformed)).not.toThrow(); + expect( + evaluate(assertions, malformed).assertions.find((row) => row.id === "tone.0.observed")?.pass, + ).toBe(false); + }, +); +test("regional named step after cannot collide with terminal after.png", () => { + const loaded = validatePlaytestScenario( + { + name: "collision", + schemaVersion: 1, + steps: [{ label: "after", waitTicks: 1 }], + assert: { + tone: [ + { atStep: "after", region: right, p99: { min: 200 } }, + { region: left, p99: { max: 40 } }, + ], + }, + }, + "collision.json", + ); + const rows = [ + ...collectRegionalTone(png(), loaded.assert!.tone!, "after.png", "after"), + ...collectRegionalTone(png(), loaded.assert!.tone!, "after.png"), + ]; + const result = evaluateRichPlaytestAssertions({ + scenario: loaded, + report: { + observations: { console: [], hud: {}, network: [], resources: {}, tone: rows }, + } as unknown as IPlaytestReport, + }); + expect(result.assertions.every((row) => row.pass)).toBe(true); +}); + +test("legacy exported tone observation keeps required numeric metrics", () => { + const legacy: import("../src/tone.js").IPlaytestToneObservation = { + code: "TN_TONE", + label: "after.png", + mean: 1, + p1: 1, + p50: 1, + p99: 1, + clipFraction: 0, + blackFraction: 0, + }; + expect(legacy.mean.toFixed(2)).toBe("1.00"); + // @ts-expect-error A whole-frame observation must still supply every metric. + const missing: import("../src/tone.js").IPlaytestToneObservation = { + code: "TN_TONE", + label: "after.png", + }; + void missing; +}); diff --git a/packages/playtest/src/assertion-report.ts b/packages/playtest/src/assertion-report.ts index d042b09d18..0682f74ee9 100644 --- a/packages/playtest/src/assertion-report.ts +++ b/packages/playtest/src/assertion-report.ts @@ -1,4 +1,4 @@ -import type { IPlaytestToneObservation } from "./tone.js"; +import type { IPlaytestToneCaptureObservation } from "./tone.js"; import type { IPlaytestComponentAssertion, IPlaytestDiagnosticsAssertion, @@ -51,7 +51,7 @@ export interface IPlaytestVisualElementRegionObservation { } export interface IPlaytestObservations { - tone?: IPlaytestToneObservation[]; + tone?: IPlaytestToneCaptureObservation[]; animation?: unknown; components?: Record>; componentSeries?: Array<{ label: string; snapshots: Record>; tick: number }>; diff --git a/packages/playtest/src/assertion-schema.ts b/packages/playtest/src/assertion-schema.ts index 1e209169e7..932e052429 100644 --- a/packages/playtest/src/assertion-schema.ts +++ b/packages/playtest/src/assertion-schema.ts @@ -30,6 +30,8 @@ export const PLAYTEST_ASSERTION_REGISTRY: readonly IPlaytestAssertionSchemaEntry example: { tone: [{ atStep: "landed", mean: { min: 60, max: 140 }, p99: { min: 150 } }] }, fields: [ { description: "Capture after this named step; omit for the final frame.", name: "atStep", type: "non-empty string" }, + { description: "Exact decoded PNG physical-pixel rectangle; non-negative safe-integer x/y, positive safe-integer width/height. No clipping or scaling.", name: "region", type: "{ x: number, y: number, width: number, height: number }" }, + { description: "Same-capture primary minus reference region metric must reach minDelta; requires primary region.", name: "compare", type: "{ region: object, metric: string, minDelta: number }" }, ...TONE_METRICS.map((name) => ({ description: "Inclusive finite bounds; luminance is 0..255 and fractions are 0..1. At least one bound is required.", name, type: "{ min?: number, max?: number }" })), ], cardinality: "array", diff --git a/packages/playtest/src/capture.ts b/packages/playtest/src/capture.ts index b44587d24c..f56626fdfe 100644 --- a/packages/playtest/src/capture.ts +++ b/packages/playtest/src/capture.ts @@ -1,5 +1,6 @@ import { PNG } from "pngjs"; -import type { IToneMetrics } from "./tone.js"; +import type { IPlaytestToneAssertion } from "./scenario/schema-base.js"; +import type { IToneMetrics, IToneRegion, IPlaytestRegionalToneObservation } from "./tone.js"; export const CAPTURE_GUARD_LIMITS = { brightLuminance: 0.05, @@ -47,7 +48,10 @@ export class CaptureGuardError extends Error { * @example const stats = inspectFrame(png); */ export function inspectFrame(png: Buffer): ICaptureFrameStats { - const image = PNG.sync.read(png); + return inspectDecodedFrame(PNG.sync.read(png)); +} + +function inspectDecodedFrame(image: PNG): ICaptureFrameStats { const colors = new Set(); const histogram = new Uint32Array(256); let toneTotal = 0; @@ -149,3 +153,62 @@ export function assertCaptureNotBlank(png: Buffer, label: string): ICaptureFrame * @example assertFrameShowsSomething(png, "first frame"); */ export const assertFrameShowsSomething = assertCaptureNotBlank; + +/** + * Collect opt-in regional tone observations from the same acquired PNG. + * @situation measure a specified pixel crop in a captured playtest frame + * @constraint does not acquire another screenshot or alter frame timing + * @example collectRegionalTone(png, assertions, "character", "posed"); + */ +export function collectRegionalTone( + png: Buffer, + assertions: readonly IPlaytestToneAssertion[], + label: string, + atStep?: string, +): IPlaytestRegionalToneObservation[] { + const selected: { assertion: IPlaytestToneAssertion; assertionIndex: number }[] = []; + for (const [assertionIndex, assertion] of assertions.entries()) + if (assertion.region !== undefined && assertion.atStep === atStep) + selected.push({ assertion, assertionIndex }); + if (selected.length === 0) return []; + const image = PNG.sync.read(png); + const measure = (region: IToneRegion): { metrics?: IToneMetrics; error?: string } => { + if ( + ![region.x, region.y, region.width, region.height].every(Number.isSafeInteger) || + region.x < 0 || + region.y < 0 || + region.width <= 0 || + region.height <= 0 || + region.width > image.width - region.x || + region.height > image.height - region.y + ) + return { error: "Tone region is outside decoded PNG bounds or invalid." }; + const cropped = new PNG({ width: region.width, height: region.height }); + for (let y = 0; y < region.height; y++) { + const offset = ((region.y + y) * image.width + region.x) * 4; + image.data.copy(cropped.data, y * region.width * 4, offset, offset + region.width * 4); + } + const metrics = inspectDecodedFrame(cropped).tone; + return metrics === undefined + ? { error: "Tone region contains no visible pixels." } + : { metrics }; + }; + return selected.map(({ assertion, assertionIndex }) => { + const region = assertion.region!; + const measured = measure(region); + const reference = + assertion.compare === undefined + ? undefined + : { region: assertion.compare.region, ...measure(assertion.compare.region) }; + return { + code: "TN_TONE", + label, + ...(atStep === undefined ? {} : { atStep }), + assertionIndex, + region, + ...measured.metrics, + ...(measured.error === undefined ? {} : { error: measured.error }), + ...(reference === undefined ? {} : { reference }), + }; + }); +} diff --git a/packages/playtest/src/evaluators/tone.ts b/packages/playtest/src/evaluators/tone.ts index b256711e6a..25194150a9 100644 --- a/packages/playtest/src/evaluators/tone.ts +++ b/packages/playtest/src/evaluators/tone.ts @@ -1,27 +1,117 @@ import { TONE_METRICS } from "../tone.js"; +import type { IToneMetrics, IToneRegion, IPlaytestRegionalToneObservation } from "../tone.js"; import type { IEvaluationContext } from "./context.js"; -export function emitTone({ assertions, diagnostics, input, scenarioAssertions }: IEvaluationContext): void { +export function emitTone({ + assertions, + diagnostics, + input, + scenarioAssertions, +}: IEvaluationContext): void { for (const [index, expected] of (scenarioAssertions.tone ?? []).entries()) { const label = expected.atStep ?? "after.png"; const frames = input.report.observations?.tone; // A step may use the reserved-looking name "after". The terminal capture is appended last. - const frame = Array.isArray(frames) ? frames.slice().reverse().find((entry) => entry !== null && typeof entry === "object" && (expected.atStep === undefined - ? entry.atStep === undefined && entry.label === "after.png" - : entry.atStep === expected.atStep)) : undefined; - if (frame?.code !== "TN_TONE" || !TONE_METRICS.every((key) => typeof frame[key] === "number" - && Number.isFinite(frame[key]) && frame[key] >= 0 && frame[key] <= (key.endsWith("Fraction") ? 1 : 255))) { - assertions.push({ id: `tone.${index}.observed`, pass: false, details: { expected, observed: frame } }); - diagnostics.push({ code: "TN_PLAYTEST_TONE_UNOBSERVED", message: `Tone capture '${label}' is missing or has invalid luminance metrics.`, severity: "error", observedRuntimePath: "observations.json/tone" }); + const frame = Array.isArray(frames) + ? frames + .slice() + .reverse() + .find((entry) => { + if (entry === null || typeof entry !== "object") return false; + const regional = entry as Partial; + const identity = + expected.region === undefined + ? regional.assertionIndex === undefined && regional.region === undefined + : regional.assertionIndex === index && sameRegion(regional.region, expected.region); + return ( + identity && + (expected.atStep === undefined + ? entry.atStep === undefined && entry.label === "after.png" + : entry.atStep === expected.atStep) + ); + }) + : undefined; + const regional = frame as IPlaytestRegionalToneObservation | undefined; + if ( + frame?.code !== "TN_TONE" || + regional?.error !== undefined || + !validMetrics(frame) || + (expected.compare !== undefined && + (regional?.reference?.error !== undefined || + !sameRegion(regional?.reference?.region, expected.compare.region) || + !validMetrics(regional?.reference?.metrics))) + ) { + assertions.push({ + id: `tone.${index}.observed`, + pass: false, + details: { expected, observed: frame }, + }); + diagnostics.push({ + code: "TN_PLAYTEST_TONE_UNOBSERVED", + message: `Tone capture '${label}' is missing or has invalid luminance metrics.`, + severity: "error", + observedRuntimePath: "observations.json/tone", + }); continue; } + if (expected.compare !== undefined) { + const { metric, minDelta } = expected.compare; + const observed = (frame as IToneMetrics)[metric] - regional!.reference!.metrics![metric]; + const pass = observed >= minDelta; + assertions.push({ + id: `tone.${index}.compare`, + pass, + details: { observed, expected: expected.compare, frame }, + }); + if (!pass) + diagnostics.push({ + code: "TN_PLAYTEST_TONE_ASSERTION_FAILED", + message: `Tone '${label}' ${metric} primary minus reference measured ${observed}; required >= ${minDelta}.`, + severity: "error", + observedRuntimePath: "observations.json/tone", + }); + } for (const key of TONE_METRICS) { const bound = expected[key]; if (bound === undefined) continue; - const observed = frame[key]; - const pass = (bound.min === undefined || observed >= bound.min) && (bound.max === undefined || observed <= bound.max); - assertions.push({ id: `tone.${index}.${key}`, pass, details: { observed, expected: bound, frame } }); - if (!pass) diagnostics.push({ code: "TN_PLAYTEST_TONE_ASSERTION_FAILED", message: `Tone '${label}' ${key} measured ${observed}; required ${JSON.stringify(bound)}.`, severity: "error", observedRuntimePath: "observations.json/tone" }); + const observed = (frame as IToneMetrics)[key]; + const pass = + (bound.min === undefined || observed >= bound.min) && + (bound.max === undefined || observed <= bound.max); + assertions.push({ + id: `tone.${index}.${key}`, + pass, + details: { observed, expected: bound, frame }, + }); + if (!pass) + diagnostics.push({ + code: "TN_PLAYTEST_TONE_ASSERTION_FAILED", + message: `Tone '${label}' ${key} measured ${observed}; required ${JSON.stringify(bound)}.`, + severity: "error", + observedRuntimePath: "observations.json/tone", + }); } } } + +function sameRegion(value: unknown, expected: IToneRegion): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const observed = value as Record; + return ( + observed.x === expected.x && + observed.y === expected.y && + observed.width === expected.width && + observed.height === expected.height + ); +} +function validMetrics(value: unknown): value is IToneMetrics { + if (typeof value !== "object" || value === null) return false; + const metrics = value as IToneMetrics; + return TONE_METRICS.every( + (key) => + typeof metrics[key] === "number" && + Number.isFinite(metrics[key]) && + metrics[key] >= 0 && + metrics[key] <= (key.endsWith("Fraction") ? 1 : 255), + ); +} diff --git a/packages/playtest/src/runner/ownedRunResources.ts b/packages/playtest/src/runner/ownedRunResources.ts new file mode 100644 index 0000000000..47aa6b168a --- /dev/null +++ b/packages/playtest/src/runner/ownedRunResources.ts @@ -0,0 +1,18 @@ +interface IOwnedRunResource { + release(): Promise; +} +/** Release only the handles acquired by this run, after any in-flight acquisition settles. */ +export function createOwnedRunResourceRelease( + display: () => Promise | undefined, + lease: () => Promise | undefined, +): () => Promise { + let releasePromise: Promise | undefined; + return () => { + releasePromise ??= (async () => { + // Match normal teardown order; a display failure must not strand the capture lease. + await display()?.then((resource) => resource.release()).catch(() => undefined); + await lease()?.then((resource) => resource.release()).catch(() => undefined); + })(); + return releasePromise; + }; +} diff --git a/packages/playtest/src/runner/runner.ts b/packages/playtest/src/runner/runner.ts index 257d6587c5..dab583ae13 100644 --- a/packages/playtest/src/runner/runner.ts +++ b/packages/playtest/src/runner/runner.ts @@ -1,4 +1,5 @@ -import type { IPlaytestToneObservation } from "../tone.js"; +import { createOwnedRunResourceRelease } from "./ownedRunResources.js"; +import type { IPlaytestToneCaptureObservation } from "../tone.js"; import { waitFrames, captureVisualSurface, runStep, sampleVisualElementBounds, screenshotObservations, sampleAfterTransition } from "./steps.js"; import type { StepInputState } from "./steps.js"; import { withPerformanceBudget } from "./buildReport.js"; @@ -46,7 +47,7 @@ import { type PlaytestVec3, } from "../index.js"; import type { IPlaytestObservationSnapshot } from "../protocol.js"; -import { assertCaptureNotBlank, CaptureGuardError } from "../capture.js"; +import { assertCaptureNotBlank, CaptureGuardError, collectRegionalTone } from "../capture.js"; import { chromium, type Browser, type BrowserContext, type CDPSession, type Page } from "playwright"; import { connectPlaytestBridge, PlaytestBridgeError, type IPlaytestBridgeClient } from "./bridgeClient.js"; @@ -224,6 +225,12 @@ async function runStandalonePlaytestInternal( let browserLaunch: Promise | undefined; let context: BrowserContext | undefined; let page: Page | undefined; + let displayAcquisition: Promise | undefined; + let leaseAcquisition: Promise | undefined; + const releaseOwnedResources = createOwnedRunResourceRelease( + () => displayAcquisition, + () => leaseAcquisition, + ); let teardownPromise: Promise | undefined; let serverTeardownPromise: Promise | undefined; const pageLifecycle: IPageLifecycle = { @@ -283,8 +290,13 @@ async function runStandalonePlaytestInternal( })(); await teardownPromise.catch(() => undefined); if (stopManagedServerOnTeardown) await stopServer(); + // Signal exits do not reach finally. Await this run's pending handles before exiting too. + await releaseOwnedResources(); }; let tearingDown = false; + const ensureRunning = (): void => { + if (tearingDown) throw interruptedPlaytestError(activeConfig.target ?? "browser"); + }; const handleSignal = (): void => { // Read by the startup wait so it yields to teardown instead of polling to its own deadline. tearingDown = true; @@ -306,23 +318,28 @@ async function runStandalonePlaytestInternal( process.stderr.write(`${JSON.stringify({ diagnostics: [preflight] })}\n`); } let providedDisplay: IProvidedDisplay | undefined; - let captureLease: ICaptureLease | undefined; let observationArtifactsWritten = false; const consoleEntries: IRunnerConsoleEntry[] = []; const networkEntries: Array<{ method: string; url: string }> = []; try { if (needsPixels) { - captureLease = await acquireRunnerCaptureLock(); - providedDisplay = await provideRunDisplay(); + leaseAcquisition = acquireRunnerCaptureLock(); + await leaseAcquisition; + ensureRunning(); + displayAcquisition = provideRunDisplay(); + providedDisplay = await displayAcquisition; + ensureRunning(); } if (activeConfig.server !== undefined && server === undefined) { if (!usesFreePort) await assertManagedUrlAvailable(activeConfig.url); + ensureRunning(); server = startManagedServer(activeConfig, usesFreePort ? activeConfig.port : undefined); } if (options.remoteBrowser !== undefined && server !== undefined && options.managedServer === undefined) { await waitForUrl(activeConfig.url, activeConfig.server?.timeoutMs ?? activeConfig.timeoutMs, server); } await options.remoteBrowser?.prepare(activeConfig); + ensureRunning(); // Snapshot the profiles that already exist, so teardown can tell this run's from a sibling's. profilesBeforeLaunch = playwrightProfileDirectories(); browserLaunch = options.remoteBrowser === undefined @@ -333,9 +350,11 @@ async function runStandalonePlaytestInternal( }) : options.remoteBrowser.connect(activeConfig); browser = await browserLaunch; + ensureRunning(); if (options.remoteBrowser === undefined && server !== undefined && options.managedServer === undefined) { await waitForUrl(activeConfig.url, activeConfig.server?.timeoutMs ?? activeConfig.timeoutMs, server); } + ensureRunning(); const touchBrowser = isTouchPrimaryBrowserScenario(scenario); // Explicit touch-control scenarios expose the same mobile signal that core's portable // predicate reads; keyboard and other pointer-only scenarios stay desktop. @@ -347,6 +366,7 @@ async function runStandalonePlaytestInternal( : {}), }) : await options.remoteBrowser.context(browser); + ensureRunning(); if (touchBrowser && options.remoteBrowser === undefined) { // Chromium keeps desktop `userAgentData.mobile` unless full mobile emulation is enabled. // That emulation breaks the minimal template's WebGPU atmosphere path, so expose the one @@ -358,10 +378,13 @@ async function runStandalonePlaytestInternal( }); }); } + ensureRunning(); if (activeConfig.trace) { await context.tracing.start({ screenshots: true, snapshots: true }); } + ensureRunning(); page = await context.newPage(); + ensureRunning(); await page.addInitScript((liveClock) => { // Announce the runner before any game code evaluates, so an adapter can hold its loop // instead of racing this run's first observation. @@ -486,7 +509,7 @@ async function runStandalonePlaytestInternal( // measured. Collected whenever a movement assertion exists, which is what pays for them. const capturesMovementSamples = capturesAnonymousMovement || scenario.assert?.movement !== undefined; const movementSamples: IMovementSampleInterval[] = []; - const tone: IPlaytestToneObservation[] = []; + const tone: IPlaytestToneCaptureObservation[] = []; const wantsFinalTone = scenario.assert?.tone?.some(({ atStep }) => atStep === undefined) === true; const wantsVisual = (scenario.assert?.visual?.length ?? 0) > 0; const visualAssertions = scenario.assert?.visual ?? []; @@ -558,6 +581,7 @@ async function runStandalonePlaytestInternal( try { const stats = assertCaptureNotBlank(png, label); if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); + tone.push(...collectRegionalTone(png, scenario.assert?.tone ?? [], label, atStep)); return { ...(elementRegions === undefined ? {} : { elementRegions }), image: png }; } catch (error) { if (!(error instanceof CaptureGuardError)) throw error; @@ -571,6 +595,7 @@ async function runStandalonePlaytestInternal( try { const stats = assertCaptureNotBlank(png, label); if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); + tone.push(...collectRegionalTone(png, scenario.assert?.tone ?? [], label, atStep)); return { ...(elementRegions === undefined ? {} : { elementRegions }), image: png }; } catch (error2) { if (!(error2 instanceof CaptureGuardError)) throw error2; @@ -604,6 +629,7 @@ async function runStandalonePlaytestInternal( } const stats = assertCaptureNotBlank(png, label); if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); + tone.push(...collectRegionalTone(png, scenario.assert?.tone ?? [], label, atStep)); return { image: png }; } catch (error) { if (!(error instanceof CaptureGuardError)) throw error; @@ -613,6 +639,7 @@ async function runStandalonePlaytestInternal( if (retry !== undefined) { const stats = assertCaptureNotBlank(retry, label); if (stats.tone !== undefined) tone.push({ code: "TN_TONE", label, ...(atStep === undefined ? {} : { atStep }), ...stats.tone }); + tone.push(...collectRegionalTone(retry, scenario.assert?.tone ?? [], label, atStep)); return { image: retry }; } } catch (error2) { @@ -832,10 +859,8 @@ async function runStandalonePlaytestInternal( process.off("SIGTERM", handleSignal); await stopCpuProfile(false); await teardown(); - // Released last-in-first-out: the browser dies before the display it rendered on, and the - // display before the lock that serialises displays. Both releases swallow their own errors. - await providedDisplay?.release().catch(() => undefined); - await captureLease?.release().catch(() => undefined); + // Signal and normal/error teardown share the same idempotent owner-handle release. + await releaseOwnedResources(); } } diff --git a/packages/playtest/src/scenario/generated-assertion-validators.ts b/packages/playtest/src/scenario/generated-assertion-validators.ts index 74eb370f04..d059cf8d71 100644 --- a/packages/playtest/src/scenario/generated-assertion-validators.ts +++ b/packages/playtest/src/scenario/generated-assertion-validators.ts @@ -13,6 +13,8 @@ function isRecord(value: unknown): value is Record { export const GENERATED_ASSERTION_FIELD_VALIDATORS: Readonly>>> = Object.freeze({ "tone": Object.freeze({ "atStep": (value: unknown) => typeof value === "string" && value.trim() !== "", + "region": (value: unknown) => isRecord(value), + "compare": (value: unknown) => isRecord(value), "mean": (value: unknown) => isRecord(value), "p1": (value: unknown) => isRecord(value), "p50": (value: unknown) => isRecord(value), diff --git a/packages/playtest/src/scenario/schema-base.ts b/packages/playtest/src/scenario/schema-base.ts index 99a21d91e5..c757296ca6 100644 --- a/packages/playtest/src/scenario/schema-base.ts +++ b/packages/playtest/src/scenario/schema-base.ts @@ -1,4 +1,4 @@ -import type { IToneMetrics } from "../tone.js"; +import type { IToneMetrics, IToneRegion, IToneComparison } from "../tone.js"; import type { IPlaytestGeometryCaptureRequest, PlaytestFramePassKind, PlaytestFramePhase } from "../protocol.js"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -577,6 +577,8 @@ export interface IPlaytestAudioAssertion { } export interface IPlaytestToneAssertion extends Partial> { + region?: IToneRegion; + compare?: IToneComparison; /** Omit to bound the final capture, otherwise capture after this named step. */ atStep?: string; } diff --git a/packages/playtest/src/scenario/schema-validate.ts b/packages/playtest/src/scenario/schema-validate.ts index f7d42ddd0c..93dcfdbf1e 100644 --- a/packages/playtest/src/scenario/schema-validate.ts +++ b/packages/playtest/src/scenario/schema-validate.ts @@ -1,4 +1,5 @@ import { TONE_METRICS } from "../tone.js"; +import type { IToneRegion } from "../tone.js"; import type { IPlaytestToneAssertion } from "./schema-base.js"; import { PLAYTEST_ASSERTION_REGISTRY } from "../assertions.js"; import { invalidScenario, invalidStep, rejectUnknownKeys } from "./errors.js"; @@ -1353,8 +1354,19 @@ export function validateGeometryCaptureRequest( function validateToneAssertion(value: unknown, scenarioPath: string, objectPath: string): IPlaytestToneAssertion { const record = requireRecord(value, scenarioPath, objectPath); - rejectUnknownKeys(record, ["atStep", ...TONE_METRICS], scenarioPath, objectPath); + rejectUnknownKeys(record, ["atStep", "region", "compare", ...TONE_METRICS], scenarioPath, objectPath); const result: IPlaytestToneAssertion = {}; + if (record.region !== undefined) result.region = validateToneRegion(record.region, scenarioPath, `${objectPath}.region`); + if (record.compare !== undefined) { + if (result.region === undefined) throw invalidScenario(scenarioPath, `${objectPath}.compare requires region.`); + const compare = requireRecord(record.compare, scenarioPath, `${objectPath}.compare`); + rejectUnknownKeys(compare, ["region", "metric", "minDelta"], scenarioPath, `${objectPath}.compare`); + if (!TONE_METRICS.includes(compare.metric as keyof import("../tone.js").IToneMetrics)) throw invalidScenario(scenarioPath, `${objectPath}.compare.metric is unsupported.`); + const metric = compare.metric as keyof import("../tone.js").IToneMetrics; + const maximum = metric.endsWith("Fraction") ? 1 : 255; + if (typeof compare.minDelta !== "number" || !Number.isFinite(compare.minDelta) || Math.abs(compare.minDelta) > maximum) throw invalidScenario(scenarioPath, `${objectPath}.compare.minDelta must be finite in [-${maximum}, ${maximum}].`); + result.compare = { region: validateToneRegion(compare.region, scenarioPath, `${objectPath}.compare.region`), metric, minDelta: compare.minDelta }; + } if (record.atStep !== undefined) result.atStep = requireString(record, "atStep", scenarioPath, objectPath); for (const metric of TONE_METRICS) { if (record[metric] === undefined) continue; @@ -1373,6 +1385,29 @@ function validateToneAssertion(value: unknown, scenarioPath: string, objectPath: } result[metric] = { ...present("min", bound.min as number | undefined), ...present("max", bound.max as number | undefined) }; } - if (!TONE_METRICS.some((key) => result[key] !== undefined)) throw invalidScenario(scenarioPath, `${objectPath} requires at least one metric bound.`); + if (result.compare === undefined && !TONE_METRICS.some((key) => result[key] !== undefined)) throw invalidScenario(scenarioPath, `${objectPath} requires at least one metric bound.`); return result; } + +function validateToneRegion(value: unknown, scenarioPath: string, objectPath: string): IToneRegion { + const record = requireRecord(value, scenarioPath, objectPath); + rejectUnknownKeys(record, ["x", "y", "width", "height"], scenarioPath, objectPath); + for (const key of ["x", "y", "width", "height"] as const) { + const minimum = key === "x" || key === "y" ? 0 : 1; + if ( + typeof record[key] !== "number" || + !Number.isSafeInteger(record[key]) || + record[key] < minimum + ) + throw invalidScenario( + scenarioPath, + `${objectPath}.${key} must be a safe integer >= ${minimum}.`, + ); + } + return { + x: record.x as number, + y: record.y as number, + width: record.width as number, + height: record.height as number, + }; +} diff --git a/packages/playtest/src/tone.ts b/packages/playtest/src/tone.ts index 2c55570964..e07ad6feb9 100644 --- a/packages/playtest/src/tone.ts +++ b/packages/playtest/src/tone.ts @@ -12,9 +12,40 @@ export interface IToneMetrics { export const TONE_METRICS = ["mean", "p1", "p50", "p99", "clipFraction", "blackFraction"] as const; +/** Top-left coordinates in decoded PNG physical pixels; never CSS pixels. */ +export interface IToneRegion { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} +export interface IToneComparison { + readonly region: IToneRegion; + readonly metric: keyof IToneMetrics; + readonly minDelta: number; +} + export interface IPlaytestToneObservation extends IToneMetrics { readonly code: "TN_TONE"; readonly label: string; /** Absent for the convenience before/after captures. */ readonly atStep?: string; } + +/** Regional failures carry an error and omit unmeasured metrics. */ +export interface IPlaytestRegionalToneObservation extends Partial { + readonly code: "TN_TONE"; + readonly label: string; + readonly atStep?: string; + readonly assertionIndex: number; + readonly region: IToneRegion; + readonly reference?: { + readonly region: IToneRegion; + readonly metrics?: IToneMetrics; + readonly error?: string; + }; + readonly error?: string; +} +export type IPlaytestToneCaptureObservation = + | IPlaytestToneObservation + | IPlaytestRegionalToneObservation; diff --git a/scripts/__tests__/orphan-infrastructure.spec.ts b/scripts/__tests__/orphan-infrastructure.spec.ts new file mode 100644 index 0000000000..1166ae9339 --- /dev/null +++ b/scripts/__tests__/orphan-infrastructure.spec.ts @@ -0,0 +1,51 @@ +import { spawnSync } from "node:child_process"; +import { readFile, rm } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { makeTempDir } from "../../test-support/temp-dir.js"; + +it.each(["0", "1"])( + "retains lease, queue and directory leak checks with CAPTURE_LOCK=%s", + async (mode) => { + const source = await readFile("packages/playtest/__tests__/orphan-cleanup.sh", "utf8"); + const start = source.indexOf('capture_coordination_root="$suite_temp_root/'); + const end = source.indexOf("\nbefore_temp_directories=", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const root = await makeTempDir("tn-orphan-infrastructure-"); + try { + const result = spawnSync( + "bash", + [ + "-c", + [ + 'set -euo pipefail; suite_temp_root="$TN_TEST_ROOT"', + source.slice(start, end), + "capture_coordination_empty", + 'test "$(find "$suite_temp_root" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1', + 'mkdir "$capture_coordination_root/lock"', + 'printf "owned lease\\n" > "$capture_coordination_root/lock/holder.json"', + "if capture_coordination_empty; then exit 21; fi", + 'test -f "$capture_coordination_root/lock/holder.json"', + 'rm "$capture_coordination_root/lock/holder.json"; rmdir "$capture_coordination_root/lock"', + 'printf "owned waiter\\n" > "$capture_coordination_root/queue/123.json"', + "if capture_coordination_empty; then exit 22; fi", + 'test -f "$capture_coordination_root/queue/123.json"', + 'rm "$capture_coordination_root/queue/123.json"; capture_coordination_empty', + 'mkdir "$suite_temp_root/foreign"; printf preserve > "$suite_temp_root/foreign/sentinel"', + 'ln -s "$suite_temp_root/foreign" "$capture_coordination_root/lock"', + "if capture_coordination_empty; then exit 23; fi", + 'test "$(cat "$suite_temp_root/foreign/sentinel")" = preserve', + 'rm "$capture_coordination_root/lock"; capture_coordination_empty', + 'rmdir "$capture_coordination_root/queue"; ln -s "$suite_temp_root/foreign" "$capture_coordination_root/queue"', + "if capture_coordination_empty; then exit 24; fi", + 'test "$(cat "$suite_temp_root/foreign/sentinel")" = preserve', + ].join("\n"), + ], + { encoding: "utf8", env: { ...process.env, CAPTURE_LOCK: mode, TN_TEST_ROOT: root } }, + ); + expect(result.status, result.stderr).toBe(0); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/__tests__/primary-docs.spec.ts b/scripts/__tests__/primary-docs.spec.ts index a6f17cff58..508b9f71ca 100644 --- a/scripts/__tests__/primary-docs.spec.ts +++ b/scripts/__tests__/primary-docs.spec.ts @@ -274,4 +274,20 @@ describe("primary documentation agrees with the shipped surfaces", () => { expect(quality).toContain("No visual inspection was performed"); expect(quality).not.toMatch(/\binspected\b/iu); }); + it("names material controls or the explicit custom-storm exception in generated instructions", async () => { + const root = path.join(repoRoot, "packages/create-threenative/templates"); + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const instructions = await readFile(path.join(root, entry.name, "AGENTS.md"), "utf8"); + expect(instructions, entry.name).toContain("rimGain"); + expect(instructions, entry.name).toContain("fillGain"); + if (entry.name === "rain") { + expect(instructions).toContain("conversion is unqualified"); + expect(instructions).toContain("radiance is unknown until sampled"); + } else { + expect(instructions, entry.name).toContain("TN_ENVIRONMENT_CONTRIBUTION"); + expect(instructions, entry.name).toMatch(/original materials|retain originals/u); + } + } + }); }); diff --git a/scripts/__tests__/run-test-suite-temp.spec.ts b/scripts/__tests__/run-test-suite-temp.spec.ts new file mode 100644 index 0000000000..c3b5a9cd7e --- /dev/null +++ b/scripts/__tests__/run-test-suite-temp.spec.ts @@ -0,0 +1,93 @@ +import { spawnSync } from "node:child_process"; +import { readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeTempDir } from "../../test-support/temp-dir.js"; +const scriptPath = path.resolve("scripts/run-test-suite.sh"); + +describe("run-test-suite temporary ownership", () => { + it("uses the requested temporary parent and cleans only its own suite directory", async () => { + const source = await readFile(scriptPath, "utf8"); + const start = source.indexOf('suite_tmp_root="$(mktemp'); + const end = source.indexOf("\nset +e", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const parent = await makeTempDir("tn suite parent "); + const retained = path.join(parent, "retained-owner-evidence"); + await writeFile(retained, "preserve"); + try { + const result = spawnSync( + "bash", + [ + "-c", + [ + "heartbeat_pid=0; lease_registered=0; run_status=0", + source.slice(start, end), + 'printf "%s\\n" "$suite_tmp_root" "$TMPDIR"', + ].join("\n"), + ], + { encoding: "utf8", env: { ...process.env, TMPDIR: parent } }, + ); + expect(result.status, result.stderr).toBe(0); + const [directory, inherited] = result.stdout.trim().split("\n"); + if (directory === undefined || inherited === undefined) + throw new Error(`run-test-suite printed ${JSON.stringify(result.stdout)}`); + expect(path.dirname(directory)).toBe(parent); + expect(inherited).toBe(directory); + await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(retained, "utf8")).toBe("preserve"); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + + it("refuses allocation failures before continuing and removes only an owned empty directory", async () => { + const source = await readFile(scriptPath, "utf8"); + const start = source.indexOf('suite_tmp_root="$(mktemp'); + const end = source.indexOf("\nset +e", start); + const parent = await makeTempDir("tn allocation parent "); + const receipt = path.join(parent, "receipt"); + const retained = path.join(parent, "retained"); + await writeFile(retained, "preserve"); + const snippet = source.slice(start, end); + try { + const directoryFailure = spawnSync( + "bash", + [ + "-c", + [ + "heartbeat_pid=0; lease_registered=0; run_status=0", + 'mktemp() { printf "attempt\\n" >> "$TN_ALLOCATION_RECEIPT"; return 23; }', + snippet, + ].join("\n"), + ], + { + encoding: "utf8", + env: { ...process.env, TMPDIR: parent, TN_ALLOCATION_RECEIPT: receipt }, + }, + ); + expect(directoryFailure.status).toBe(23); + expect(await readFile(receipt, "utf8")).toBe("attempt\n"); + const markerFailure = spawnSync( + "bash", + [ + "-c", + [ + "heartbeat_pid=0; lease_registered=0; run_status=0", + 'mktemp() { if [[ "$1" == "-d" ]]; then local allocated; allocated="$(command mktemp "$@")" || return $?; printf "%s" "$allocated" > "$TN_ALLOCATION_RECEIPT"; printf "%s\\n" "$allocated"; else return 24; fi; }', + snippet, + ].join("\n"), + ], + { + encoding: "utf8", + env: { ...process.env, TMPDIR: parent, TN_ALLOCATION_RECEIPT: receipt }, + }, + ); + expect(markerFailure.status).toBe(24); + await expect(stat(await readFile(receipt, "utf8"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(retained, "utf8")).toBe("preserve"); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/search-path.spec.ts b/scripts/__tests__/search-path.spec.ts index 3b4b9b7089..1114ea64b4 100644 --- a/scripts/__tests__/search-path.spec.ts +++ b/scripts/__tests__/search-path.spec.ts @@ -166,7 +166,7 @@ describe("the tracked .ignore (PRD-357 F2/F5)", () => { .map((line) => line.split(":").slice(0, 2).join(":")); expect(sites).toEqual([ // Takes a log file path, not a directory. - "packages/playtest/__tests__/orphan-cleanup.sh:150", + "packages/playtest/__tests__/orphan-cleanup.sh:163", // Inert: an expected-substring literal inside a spec, never executed. "scripts/__tests__/sweep-delta.spec.ts:193", ]); diff --git a/scripts/api-surface.json b/scripts/api-surface.json index a885dc63ab..c849fc5ac2 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -254,6 +254,7 @@ "@threenative/playtest/capture#assertCaptureNotBlank": "export function assertCaptureNotBlank(png: Buffer, label: string): ICaptureFrameStats { … }", "@threenative/playtest/capture#assertFrameShowsSomething": "assertFrameShowsSomething = assertCaptureNotBlank", "@threenative/playtest/capture#CaptureGuardError": "export class CaptureGuardError extends Error { … }", + "@threenative/playtest/capture#collectRegionalTone": "export function collectRegionalTone( png: Buffer, assertions: readonly IPlaytestToneAssertion[], label: string, atStep?: string, ): IPlaytestRegionalToneObservation[] { … }", "@threenative/playtest/capture#inspectFrame": "export function inspectFrame(png: Buffer): ICaptureFrameStats { … }", "@threenative/playtest/protocol#assertJsonSafe": "export function assertJsonSafe(value: unknown, path = \"$\"): asserts value is JsonValue { … }", "@threenative/playtest/protocol#jsonByteLength": "export function jsonByteLength(value: JsonValue): number { … }", diff --git a/scripts/run-test-suite.sh b/scripts/run-test-suite.sh index f92d637e20..06ddaf5ddc 100755 --- a/scripts/run-test-suite.sh +++ b/scripts/run-test-suite.sh @@ -59,10 +59,14 @@ case "$suite_prebuilt" in ;; esac -suite_tmp_root="$(mktemp -d /tmp/threenative-suite.XXXXXX)" +suite_tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/threenative-suite.XXXXXX")" || exit $? export TN_SUITE_TMPDIR="$suite_tmp_root" export TMPDIR="$suite_tmp_root" -suite_marker="$(mktemp "$suite_tmp_root/tn-suite-count.XXXXXX")" +suite_marker="$(mktemp "$suite_tmp_root/tn-suite-count.XXXXXX")" || { + suite_allocation_status=$? + rmdir -- "$suite_tmp_root" + exit "$suite_allocation_status" +} # Names this run so any legacy tag-aware temp guard also stays isolated from concurrent lanes. export TN_TEST_TEMP_TAG="run${$}" lease_branch="$(git symbolic-ref -q HEAD || true)"