diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 277e0b5e6b..3266c3b303 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -41,6 +41,7 @@ jobs: outputs: csg: ${{ steps.filter.outputs.csg }} decals: ${{ steps.filter.outputs.decals }} + fog: ${{ steps.filter.outputs.fog }} fluid-particles: ${{ steps.filter.outputs.fluid-particles }} ik: ${{ steps.filter.outputs.ik }} tone: ${{ steps.filter.outputs.tone }} @@ -61,6 +62,7 @@ jobs: csg ^examples/integrations/csg/.*$ decals ^packages/playtest/.*$|^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^packages/runtime-native/.*$|^scripts/xvfb\.sh$|^\.github/actions/workspace-dist/.*$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^pnpm-lock\.yaml$ fluid-particles ^packages/core/src/fluid-particles\.ts$|^packages/core/src/gpu-readback\.ts$|^packages/core/__tests__/fluid-particles\.spec\.ts$|^packages/core/patches/.*$|^packages/playtest/.*$|^examples/prd476-fluid-particles/.*$|^scripts/verify-fluid-collision\.ts$|^scripts/verify-fluid-consumers\.ts$|^scripts/verify-fluid-collision-native\.ts$|^scripts/__tests__/fluid-collision-native-proof\.spec\.ts$|^packages/runtime-native/.*$|^\.github/actions/workspace-dist/.*$|^scripts/fluid-collision-proof\.ts$|^scripts/__tests__/fluid-collision-proof\.spec\.ts$|^scripts/__tests__/fluid-collision-view\.spec\.ts$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^scripts/xvfb\.sh$|^scripts/workspace-packages\.ts$ + fog ^packages/create-threenative/templates/starter/src/render/volumetricFog[A-Za-z]*\.ts$|^packages/create-threenative/templates/starter/src/render/postprocessing\.ts$|^packages/create-threenative/templates/starter/src/scenes/Play\.ts$|^packages/create-threenative/template-assets/worldEnvironment\.ts$|^packages/create-threenative/__tests__/vq-(volumetric-fog|fog-fixture)\.spec\.ts$|^packages/core/src/renderer\.ts$|^packages/core/src/render/chain\.ts$|^examples/abyss-framework/vq-fog/.*$|^examples/abyss-framework/playtests/vq-volumetric-fog\.playtest\.json$|^scripts/verify-volumetric-fog\.ts$|^scripts/__tests__/(verify-volumetric-fog|vq-fog-native-proof)\.spec\.ts$|^packages/runtime-native/include/mystral/webgpu_compat\.h$|^packages/runtime-native/src/webgpu/bindings_presentation\.cpp$|^packages/runtime-native/tests/surface_texture_status_test\.cpp$|^packages/runtime-native/tests/canvas_presentation_test\.cpp$ ik ^examples/integrations/ik/.*$ tone ^scripts/xvfb\.sh$|^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposureGraph|exposureReadback|exposureSettings|worldEnvironment)\.ts$ vegetation ^examples/integrations/vegetation/.*$ @@ -252,6 +254,116 @@ jobs: path: artifacts/tone-exposure if-no-files-found: error + fog: + name: fog capture + if: ${{ needs.paths.outputs.fog == 'true' && !github.event.pull_request.draft }} + needs: paths + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/playwright-chromium + - run: pnpm --filter @threenative/playtest build + - run: pnpm --filter abyss-framework exec vite build --config vq-fog/vite.config.ts + - run: node --import tsx scripts/verify-volumetric-fog.ts + - uses: actions/upload-artifact@v7 + if: always() + with: + name: volumetric-fog-${{ github.event.pull_request.head.sha || github.sha }} + path: artifacts/volumetric-fog/ + retention-days: 30 + if-no-files-found: error + + fog-native: + name: Linux native fog correctness + needs: [fog] + # Reuse the proven native-platforms / native-assets ARM QuickJS route without changing defaults. + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + env: + CMAKE_BUILD_PARALLEL_LEVEL: '2' + TN_PLAYTEST_ALLOW_SOFTWARE: '1' + TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: pnpm install --frozen-lockfile + - name: Build this source's public JavaScript packages + uses: ./.github/actions/workspace-dist + with: + save-bundles: 'false' + pack-archives: 'false' + - name: Verify native preflight and capture rejection controls + run: pnpm exec vitest run scripts/__tests__/vq-fog-native-proof.spec.ts --maxWorkers=1 + - name: Restore checksum-provisioned native dependencies + uses: actions/cache/restore@v4 + with: + path: packages/runtime-native/third_party + key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }} + - name: Install the existing native lane's system prerequisites + run: >- + sudo apt-get update && sudo apt-get install -y + libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev + libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev + libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev + libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr + libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - name: Build the ordinary ARM desktop host + run: pnpm --filter @threenative/runtime-native native:build + - name: Verify the compiled surface acquisition status contract + run: | + cmake --build packages/runtime-native/build/tn-linux --target threenative-surface-texture-status-test --parallel 2 + packages/runtime-native/build/tn-linux/threenative-surface-texture-status-test + - name: Verify rendered presentation destination pixels + run: | + cmake --build packages/runtime-native/build/tn-linux --target threenative-canvas-presentation-test --parallel 2 + packages/runtime-native/build/tn-linux/threenative-canvas-presentation-test + - name: Isolate native resize with no fog allocation + id: resize-control + # Preserve the original fog sequence even when this diagnostic reproduces the abort. + # The final step below still fails the job if this control fails. + continue-on-error: true + env: + VQ_FOG_RESIZE_CONTROL: '1' + THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral + run: node --import tsx scripts/verify-volumetric-fog.ts + - name: Run the same authored fog and pixel gates on the native host + env: + THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral + run: node --import tsx scripts/verify-volumetric-fog.ts + - name: Require the no-fog resize control + if: always() && steps.resize-control.outcome != 'success' + run: exit 1 + - name: Retain native screenshots, host console and executable identities + if: always() + uses: actions/upload-artifact@v7 + with: + name: volumetric-fog-native-${{ github.event.pull_request.head.sha || github.sha }} + path: | + artifacts/volumetric-fog-native/ + artifacts/volumetric-fog-native-resize-control/ + retention-days: 30 + if-no-files-found: warn + capture: name: capture if: ${{ !github.event.pull_request.draft && needs.paths.outputs.world-capture == 'true' }} diff --git a/docs/PRDs/done/PRD-VQ-07-volumetric-fog.md b/docs/PRDs/done/PRD-VQ-07-volumetric-fog.md new file mode 100644 index 0000000000..f8cff927a9 --- /dev/null +++ b/docs/PRDs/done/PRD-VQ-07-volumetric-fog.md @@ -0,0 +1,254 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-07 — Local volumetric fog composes with depth, lights and existing atmosphere + +**Status:** COMPLETE — 2026-10-03 at `b93944e60afc42d12cb0d89c5fd65f1a07fe2b7d`. All six phase items and both acceptance criteria are verified at that exact head by the hosted integration run [37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365) — 29 browser WebGPU arms, 29 Linux ARM64 QuickJS/wgpu-native arms, both no-fog resize controls, the compiled acquisition status contract and actual GPU destination pixels — together with the full repository CI run [37147171521](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171521). Linux ARM64 software correctness only: no phone, hardware-tier or OS-window claim. Archived individually while sibling PRDs stay active; `develop` advanced after this run, so promotion is the merging parent's task and is recorded as such below. +**Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 2 / atmosphere. +**Dependencies:** Independent of clouds. Reuse VQ-06 only for any local-light feature it has actually qualified. + +## Grounding and intended outcome + +ThreeNative already has atmosphere LUTs and god-ray composition. Current upstream [VolumeNodeMaterial](https://threejs.org/docs/pages/VolumeNodeMaterial.html) and volumetric lighting machinery are reuse candidates, not proof of a finished ThreeNative fog product. Recheck the installed pin and the merged rain kit before introducing a new algorithm. + +**Outcome:** A bounded mist volume in an interior/exterior fixture responds to the main light and declared local lights, is occluded by solid geometry and blends into the existing atmosphere without becoming a uniformly bright screen overlay. + +## Design and ownership + +Start with the supported upstream volume/material or existing kit implementation. Density functions, phase function, color and artistic controls remain generated TSL/game source. Share only depth access, lifetime and ordered dispatch that the current render loop cannot already supply. Composite radiance with transmittance in the scene-linear graph before its one output transform; specify the relationship with aerial perspective and god rays so the same medium is not added twice. Keep history optional until its disocclusion behavior is qualified. + +A bounded volume and height-dependent density, one directional source and an explicitly supported local-light subset. No mandatory froxel engine, general weather manager, or promise that all point/spot shadow types work. + +## Required behavior + +- Zero density produces the no-fog image; an opaque foreground wall hides fog behind it. +- The fixture tests the camera both outside and inside the volume, a light toggle and overlapping density bounds. +- Step count and resolution are quality controls, with an explicit off/unsupported route and no dormant GPU allocations. +- Resizes, camera cuts and streamed occluder changes do not reuse invalid depth/history. + +## Execution phases + +All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — Depth-correct participating volume + +- [x] Implement game-owned volumetric composition using the admitted upstream/kit mechanism and shared scene depth. Verified: generated source builds on the pinned shader compiler and hosted WebGPU run 36990624761 renders it; proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. +- [x] Cover zero density, inside/outside camera positions and opaque occlusion with analytic or reference fixtures. Verified: run 36990624761 captured all positions, exact off/zero image identity and the unchanged foreground-wall reference patch recorded below; proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. + +### Phase 2 — Compose with the existing environment + +- [x] Integrate directional and the declared local-light subset without double-applying atmosphere or god rays. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. +- [x] Add bounded quality settings, history invalidation where used and deterministic resource disposal. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [x] The fog fixture verifies occlusion, light response and zero-density identity on browser WebGPU with fixed-camera captures. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu` — [run 37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365), source `b93944e60`, `fog capture` job: 29 arms, 392 in-arm assertions and 13 cross-arm assertions, 0 failures and 0 console/network/runtime diagnostics on Chromium WebGPU / Google SwiftShader, 640×400, private Xvfb 1600×900, `page.screenshot`. +- [x] The same authored volume runs on Linux native and remains valid through resize and scene teardown. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}` — [run 37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365), `Linux native fog correctness` job: 29 arms, 419 in-arm assertions, 29 authored 5% whole-frame nonblank gates and 13 cross-arm assertions, 0 failures, plus both no-fog resize controls at 320×240 then 640×400 with zero fog target allocations. Named adapter `native:vulkan/llvmpipe/llvmpipe (LLVM 20.1.2, 128 bits)/Mesa 25.2.8-0ubuntu0.24.04.4 (LLVM 20.1.2)`, QuickJS engine, `device.screenshot`; runtime SHA256 `5a164cc2…`, bundle SHA256 `b67511f2…`. + +## Acceptance criteria + +- [x] Fog is depth-correct and light-responsive, and turning it off restores the original graph and allocation baseline. proof: [run 37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365) at `b93944e60`, both targets and both no-fog controls; zero-density `zero` is byte-identical to `off` on browser and native, `off` is exactly RGB 0 across the pinned 500×400 evaluation area, the foreground wall ROI max is exactly 0 in all four scattering arms, the calibration card minimum is 146 with a cross-arm difference of exactly 0, and directional/point light response measures 15.9585 and 48.7755 mean RGB bytes at changed ratios 0.99333 and 1.0. Overlapping bounds clear their pinned floor at 0.09386 changed channels (browser) and 0.09385 (native) against 0.05, with a maximum channel delta of 43 against 4. Renderer textures are 5 off, 10 active and back to 5 at final off after three observed stable render intervals. +- [x] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts` (24) plus the Phase 3 lifecycle scenario at [run 37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365): the repeated sequence observes 4 created targets, 3 target and material releases and 1 live target, then 4 releases, 0 live targets and the texture baseline 5 at final off, with final-off pixels exactly equal to the independent off baseline on both targets. Repeated scene exit/re-entry observes 3 entries, 2 exits, one exit release per target and material, 0 live targets and textures back to 5. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts packages/create-threenative/__tests__/vq-fog-fixture.spec.ts --maxWorkers=1` passes 3 files / 99 tests. + +## Performance and promotion + +Record pixels, ray steps, light count and cost on the same fixture. Choose tier admission from measurements, not an arbitrary AAA label. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Phone performance and a wider local-shadow set are separate qualification work. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. + +### Current checkpoint — 2026-10-02 + +The fresh checkout implements ownership-aware `clearOutputNode(expectedNode?)` without changing legacy no-argument calls. Generated `WorldEnvironment` now returns an idempotent disposer for a base-colour-only graph, passes its explicit scene pass to installation, releases its own scene pass and materialized base texture, and refuses an unsupported direct path before invoking the allocation factory. This prerequisite does not enable fog or change the default picture. + +Red-green proof: three new behavioral failures reproduced first; `pnpm exec vitest run packages/core/__tests__/renderer.spec.ts packages/create-threenative/__tests__/world-environment-lifetime.spec.ts packages/create-threenative/__tests__/shared-render-sources.spec.ts --maxWorkers=1` then passed 39/39. `pnpm exec tsc --noEmit -p packages/core/tsconfig.json`, focused strict renderer/generated-source compilation, API-surface and capability-manifest checks passed. Focused Biome passed with four complexity warnings, no errors. No runtime screenshot, native qualification, aggregate CI or fog acceptance is claimed. + +Reuse review at pinned `three@0.185.1`: `VolumetricLightingModel.direct()` skips a directional light because it has no `distance`; extinction is derived from accumulated light rather than independent density. Its material cannot satisfy the bounded directional radiance/transmittance contract unchanged. Existing `GodraysNode` provides the depth reconstruction and ordinary directional shadow coordinates to reuse in game-owned source. Rain's screen-quad coast and its god-ray composition do not provide bounded participating volumes. No new shared visual algorithm or renderer is admitted. + +Review correction: pinned `convertToTexture(PassNode)` returns a borrowed pass texture, not an owned RTT. A new PassNode-return regression reproduced the disposal TypeError; the ownership test now additionally requires `isRTTNode === true`. PassNode disposal and caller-owned texture/RTT preservation are covered. The same focused suite passes 42/42 and strict touched-source/test typechecking passes; this supersedes the earlier 39-test checkpoint for the lifecycle code. + +Mixed ownership correction: actual-renderer regressions reproduced an obsolete chain clearing a newer direct graph and automatic budget observation rebuilding the obsolete chain. `RenderChain` now retains the installed input identity, clears only owned/attempted output, and skips automatic observation when the renderer says it was superseded. Replacement does not implicitly dispose caller dependencies; explicit disposal remains available. Borrowed RTT target and material preservation are both asserted directly. Focused renderer/render-chain/environment/shared-source suite passes 88/88; core typecheck passes. This proves the exercised lifecycle transitions, not GPU behavior. + +Fog implementation checkpoint: opt-in generated `templates/starter/src/render/volumetricFog.ts` now builds bounded overlapping height-density transport before exposure, with independent extinction, directional shadow lookup and explicitly unshadowed finite-range point lights. It owns a half-float nearest-filtered RTT, supports full/half resolution with per-pixel depth-discontinuity fallback, retains no history, and returns before graph allocation when off/unsupported/zero-density. No default tier enables it. The standalone fixture at `examples/abyss-framework/vq-fog/` exercises that exact source with outside/inside camera, light, wall, overlap, quality and rebuild controls. + +Fresh proof: the fog and verifier contract suites pass 23/23, including generation of the actual depth/shadow transport WGSL with/without lights; focused strict source/test/fixture/verifier typechecks and the production Vite fixture build pass. These are not rendered-pixel tests: analytic/wall-depth image proof and all Phase 3 qualification remain open. The hosted `fog` lane in `integration.yml` (folded there from the former `integration-volumetric-fog.yml`, one workflow per root AGENTS.md) uses the built public playtest runner for actual canvas captures and adapter/source-SHA provenance, rejects software device loss, and retains diagnostic captures on failure. The local executor has no display tooling and its Unix-socket restriction was verified, so local screenshot execution was not attempted through an alternate route. + +Scaffold validation initially failed only its whole-tree byte snapshot because the deliberate shared lifetime changes affect all thirteen kits and the new optional fog file affects starter. After inspecting that source-only delta, the expected generated-tree hashes were refreshed with an inline rationale; the unchanged starter-copy assertions now include the new file. The full scaffold suite passes 66/66. Fog/verifier plus CI structure/needs suites pass 164/164; relative documentation-link checks pass. No runtime evidence is inferred from these checks. + +Actual diagnostic progress: [hosted run 36989192897](https://github.com/ThreeNativeHQ/threenative/actions/runs/36989192897) rendered the off baseline at source `1bb878e033d29e084b912bc9266a15b72d6cbf16`, Chromium WebGPU / Google SwiftShader / 640×400. Its [provenance](../../verification/vq07-progress/1bb878e03/provenance.json) is retained with every capture's SHA256 in it; the images themselves live in the hosted artifact. The run failed closed on one HTTP404 console error; all baseline image/component assertions passed, but fog-on variants did not run. This is diagnostic baseline progress, not fog qualification or a hardware/native claim. The first report omitted the failing resource URL; fixture-only preview diagnostics now record HTTP error status and URL without relaxing the error gate. + +The unchanged-HTML diagnostic rerun [36990205268](https://github.com/ThreeNativeHQ/threenative/actions/runs/36990205268) identifies the exact failed resource in `off/http-errors.jsonl`: `/favicon.ico`, HTTP404. The fixture now uses the repository's existing inline-favicon convention. Its new source regression failed first; fog/verifier tests then pass 24/24 and the production bundle builds. No error assertion was weakened. The next hosted run must verify that this removes the console failure and reaches fog-on capture. + +First successful actual fog captures: [run 36990624761](https://github.com/ThreeNativeHQ/threenative/actions/runs/36990624761), source `4f8fe47b1801282827c9b5d0f0cba40b99c7c2a7`, Chromium WebGPU / Google SwiftShader / 640×400. All nine named variants passed with empty diagnostic lists, and off/zero images were pixel-identical. The unchanged [fog-on capture](../../verification/vq07-progress/640c103-browser/fog.png) and [all-variant provenance](../../verification/vq07-progress/4f8fe47b1/provenance.json) are retained, the latter carrying each arm's SHA256; this run's inside-volume and off-baseline images are in artifact 11220323995. Visual inspection confirms bounded mist and light response. The fixed wall-center patch (x184, y216, 18×18) has exactly zero changed pixels against off; full/half fog mean absolute difference is 0.0695/255. This is browser software-rendered correctness progress, not native or hardware-performance qualification. Same-node replacement ownership still needs a unique installation receipt; a green screenshot run does not close that review finding. + + +Installation ownership correction: `setOutputNode` now returns a unique optional receipt with `isCurrent()` / `dispose()`, capturing the actual `RenderPipeline` installation. Same-node reinstall regressions failed first; old observers and disposers now respect the receipt, as does the generated direct base-colour path. Legacy void-style adapters (including historically ignored return values) retain compatibility behavior but do not gain installation-level guarantees. Receipts own only the installed pipeline; graph dependencies still belong to their explicit caller. The superseded node-equality query was removed. The focused renderer/chain/environment/shared-source/scaffold suite passes 157/157, core and touched-source/test typechecks pass, and API/capability checks pass. Generated-source hashes are refreshed only for this intentional shared lifecycle edit. Fresh hosted capture is selected for subsequent renderer/chain/canonical-environment changes. + +Independent receipt review of staged tree `2062ba7783c53bc27a5ba6537a8b60874b9c06fa` passed for pipeline-installation ownership: the reviewer reran renderer/render-chain/WorldEnvironment tests, 89/89 passed, with no blocking defect in that delta. This explicitly does not transfer or guarantee arbitrary shared graph-dependency lifetimes. Hosted pixel proof will be rerun at the published receipt head. + + +Receipt-head runtime revalidation: [run 36994760252](https://github.com/ThreeNativeHQ/threenative/actions/runs/36994760252) passed all nine variants at exact source `9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a`, Google SwiftShader WebGPU, 640×400, no diagnostics. Downloaded artifact `11220323995` matched SHA256 `658c454f4f1888f2b98f9b78054b7f3ae6cebedbe6327372ec844f21a058e71d`; all nine PNGs are byte-identical to the retained successful captures. [Existing provenance](../../verification/vq07-progress/4f8fe47b1/provenance.json) now includes that source/run revalidation. This closes receipt-head visual regression evidence, not the remaining fog acceptance. + +The next fixture-only qualification slice removes surface illumination from light-response evidence: black unlit override surfaces plus a black background preserve geometry/depth/shadow casters. A black no-fog arm must produce exactly zero RGB across the whole image. Both fog light toggles must add at least 1 mean RGB byte and change more than 2 RGB bytes on at least 10% of the pinned room ROI (x260, y155, 150×90), excluding the foreground plate; its interior patch (x184, y216, 18×18) must remain exactly black in all four arms. These gates were pinned before hosted execution. Normal positive-arm nonblank checks and all runtime/error/device-loss checks remain unchanged. The verifier now actually runs the committed repeated-lifecycle scenario and its return-to-off extension, checks actual target/material disposal events, and requires the restored pixels to equal the off baseline. Fixture/verifier/source tests pass 29/29 after the new behavioral assertions failed first; strict types and the production build pass. New hosted light/lifecycle evidence remains pending, as do resize/native/performance qualification. + + +Aggregate verification for this slice: root `pnpm typecheck` passed; root `pnpm lint` passed with 1,017 warnings and no errors. Root `pnpm test` exited 2 before suite phases because the installed `tsx` CLI cannot create `/tmp/threenative-suite…/tsx-1000/482.pipe` (`listen EPERM`), the confirmed environment Unix-socket restriction. Two positive `evidence-budget.spec.ts` subprocess fixtures fail for the same `tsx` restriction; no gate was weakened or represented as passed. Full aggregate tests and native execution remain unrun here. Expanded public scenario round-trip validation also caught and corrected the lifecycle loader's metadata (`sourcePath`) being serialized back as an invalid authored key, before hosted execution. + +Moving-base reconciliation: `beedf1e8703b8d7318f11dea0f183764075cc153` is merged with exact develop `6c8858d74ae0c76887c4387ffbd90cf6b32fb6a1`. The sole conflict was the generated scaffold digest table: current 0.2.8 package pins and rain's corrected `.ts` verifier import are retained alongside VQ07's receipt/fog source. All thirteen hashes were remeasured from the combined isolated tree after the old table failed. The combined scaffold/doctor/renderer/chain/environment/fog/fixture/verifier suites pass 282/282, create-threenative types and the fixture production build pass. No shared rendering source was changed by the reconciliation. + + +Pre-run calibration correction: source inspection shows the public runner rejects uniform blank captures before evaluating black-region assertions. Rather than accept a failed capture or weaken any normal guard, the four scattering-control arms now share a fixture-owned gradient calibration card at x510–630, y20–140, ahead of the volume. The full fog evaluation area (x0, y0, 500×400) must still be exactly black without fog; the room/light and foreground-wall ROIs are unchanged. An interior calibration patch (x515, y25, 110×110) must have RGB channels at least 32 and be byte-identical across all four controls. The normal full-frame nonblank gate remains 5% in every arm, and every arm must pass all runtime/error/device-loss guards. This revised control design was pinned before any hosted scattering-control execution; no failed pixel threshold was relaxed. The card is visible only in the four fixed-outside-camera controls, so the original normal variants retain their authored picture. New fixture/verifier regressions failed first and pass 9/9; types pass. + +Isolated-scattering progress: [run 36997361343](https://github.com/ThreeNativeHQ/threenative/actions/runs/36997361343) at source `001b934008cbc6e3ab196678506ae9b14f751f1e` passes all thirteen independent captures with empty diagnostics, including the four calibration controls. Downloaded artifact `11221669862` matches SHA256 `2c88ec8b13a525986867301a735ede33f5b76ab3b6d9db3c46459554b5b7a4ca`. The [black control](../../verification/vq07-progress/001b93400/blackOff.png) and [four-arm provenance/pixel metrics](../../verification/vq07-progress/001b93400/provenance.json) are retained, the latter carrying the scattering arm's SHA256; that image is in artifact 11221669862. The pinned room ROI passes: directional mean RGB delta 15.9585/255, changed ratio 0.99333; point mean delta 48.7755/255, changed ratio 1.0. The 500×400 black area and foreground plate are exactly zero; calibration minimum RGB is 146 and cross-arm difference is exactly zero. These isolates show scattering response rather than changed surface illumination. + +The overall run fails closed on lifecycle's old logical `disposedGraphs >= 4` assertion: the sampled initial state had `builds=0`, so four installs yielded three logical releases. Actual targets/materials were created=3, released=2, live=1 as asserted; there was no device loss or other runtime error. This is preserved as a setup mismatch, not a complete lifecycle pass. The corrected scenario explicitly executes **fog → inside → off → fog → rebuild**, then a separate final-off extension. It now requires exactly four real fog targets created, three target/material releases and one live target at the final fog frame; final off requires all four target/material releases and zero live targets. No dummy resources, manually advanced counters or lowered thresholds are used. New sequence assertions failed first; source/fixture/verifier tests pass 29/29. The stronger sequence and return-to-off pixels await the next hosted run. + + +Successful lifecycle/control rerun: [run 36998309685](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998309685) at `1c9534fe9fdd29458d880afe70c45dc4aa5f22ce` passed all 15 captures with empty diagnostics on Google SwiftShader WebGPU, 640×400. Artifact `11222836638` matches SHA256 `9f0c207b9ba0cbc024b78104cc025a09acb8c925e13cba6fc22e17d78195c551`. Observed counts are exactly 4 created / 3 target and material releases / 1 live target after reconstruction, then 4 releases / 0 live targets at final off. Final-off pixels equal the independent off baseline; zero-density identity and isolated-light metrics also pass. All four scattering PNGs remain byte-identical to the durable images; [provenance](../../verification/vq07-progress/001b93400/provenance.json) includes this successful revalidation. Phase 2 and the browser Phase 3 box are now verified. Acceptance remains open for portable target/depth resizing, independent renderer texture-baseline observation, native execution and repository promotion gates; OS-window lifecycle and hardware performance are not inferred. + + +Next bounded proof slice (runtime pending): existing portable `renderer.setSize` drives 320×240 then 640×400 on the same fog controller, updating the camera projection; actual fog target dimensions, one creation/zero teardown and exact restored fog pixels are required. Independent upstream `renderer.info.memory.textures` samples must agree with the off baseline, after at least three distinct completed render frames with stable counts since each graph/size transition. Repeated simulation ticks without a new renderer frame cannot advance this boundary. These assertions qualify target/depth resize and observed resource accounting, not OS-window lifecycle. Fixture and verifier guards were red-green before the change; no engine API or fog shader change is involved. + + +Render-boundary correction: [run 36999492881](https://github.com/ThreeNativeHQ/threenative/actions/runs/36999492881) at `0d9c1c537b5883081a6c746ec4313ab7236a6207` failed closed on the first off-arm stability assertions. Artifact `11223002573` (SHA256 `a6c57e6bb2b4cd54026bb0ce94ba093902c4af69dfd10c8a0f78937871c22c6b`) shows a valid texture count of 5 but zero counted render boundaries after setup. Pinned Three updates `info.frame` in its own Animation loop, which the framework does not use. The fixture now samples cumulative `info.render.calls` at the next game update, counting one completed render interval even if it contained multiple passes; simulation-only updates still cannot advance it. A regression that leaves `info.frame` fixed at zero and advances only render calls failed first. All three-frame stability and image/count thresholds remain unchanged; new runtime proof is pending. + +Measured-settle correction: [run 37000014054](https://github.com/ThreeNativeHQ/threenative/actions/runs/37000014054) at `47464cca247eeb2391de7057276cef9cb8aa36fc` observed one completed render interval before the fixed-tick sequence ended, so it correctly failed the three-frame predicate. Artifact `11222909308` matches SHA256 `54d4a610d60770663f3bfefc790c07ffc22cf76b69ed63a318751c2d6a441eba`. `FixedStepLoop.advance()` simulates only; the bridge interprets these authored waitFrames as ticks. Each capture and each resize transition now uses the existing `waitForResource` observer for `state.stableTextureFrames >= 3`, bounded to 30 seconds. That mechanism advances one fixed tick between bounded polls, allowing the real render loop to progress and the game to sample its actual counters; elapsed time or poll count alone cannot satisfy it. Public scenario round-trip tests validate every arm. All resource/image thresholds remain unchanged. The existing desktop project bundler also compiled this same portable game source successfully (entry `src/game.ts`); that compile is not native execution proof. + +Target/depth resize and renderer-accounting proof: [run 37000862324](https://github.com/ThreeNativeHQ/threenative/actions/runs/37000862324) at `1505e018aa38630b91893ab8265c447833e99a51` passed all 17 cases with empty diagnostics. Artifact `11224155982` matches SHA256 `a21b102966a09371896a4d796fd278525925a5c4b6ce70b2a81cb1eb79ac45d4`. Actual target sizes were 320×240 then 640×400 with one controller/target creation and zero teardown; restored fog pixels exactly equal the original fixed-size fog frame. Independent renderer texture count returns to 5 at final off, equal to the off baseline, after three observed stable render intervals in both arms. The [restored capture](../../verification/vq07-progress/640c103-browser/fog.png) and [provenance/observations](../../verification/vq07-progress/1505e018a/provenance.json) are retained unchanged, the latter carrying the 320x240 target's SHA256; that image is in artifact 11224155982. Browser implementation qualification now includes all these controls; native execution, OS-window lifecycle, hardware performance and repository promotion gates remain separately unproven. + + +Measured workload/cost record for source `1505e018aa38630b91893ab8265c447833e99a51`, run `37000862324`, Google SwiftShader (`vendor=google`, `architecture=swiftshader`), browser WebGPU / 640×400: full fog renders 256,000 transport pixels at 48 steps, half fog 64,000 transport pixels at 48 steps, with one ordinary shadowed directional light and one unshadowed finite-range point light. The overlap arm uses two bounds; the normal arm uses one. Half-resolution edge fallback can perform additional full-resolution work, so the pixel ratio is not a claimed cost reduction. Renderer texture counts are 5 off and 10 active, returning to 5 after teardown. Pipeline-census creation service totals over each entire boot/capture window were 1.4 ms off (5 events), 2.5 ms full fog (8 events), and 1.9 ms half fog (8 events). These coarse host-side pipeline-creation timings are **not per-frame fog time, GPU duration or hardware performance**. Timestamp tracking was disabled; per-frame GPU cost and hardware tier admission remain unmeasured. No default tier is enabled from these numbers. + +Native qualification slice (not yet run): the existing built public `runDesktopPlaytest` and desktop project bundler now reuse the same 17 authored controls and external pixel metrics. Native scenarios map identical state predicates to supported resources/startup assertions, retaining strict native console/readiness/device-loss and actual `device.screenshot` / named-WebGPU-adapter provenance checks. Real-handshake preflight uses the QuickJS descriptor captured by PR396 plus the actual mailbox transport capabilities, including host key injection. The native source reconfigures its backing surface from canvas dimensions; only the small-target arm therefore declares a 320×240 native PNG, while the restored and other native captures remain 640×400 (browser CSS captures stay 640×400 throughout). This distinction is pinned before native execution; no image or error threshold is lowered. The dependent ARM Linux job reuses the proven native lane's checksummed provisioning/build and records actual host/bundle/source hashes. Unknown adapter feature lists cannot substitute for a named identity. Native acceptance stays unticked until that host executes and its real screenshots pass. + +Native-slice local validation: source/fixture/capture/native-handshake/CI-structure/CI-needs/document suites pass 191/191; strict verifier/test types, the exact desktop `--native-backend` bundle command and evidence budget pass. This validates the route and fail-closed guards, not native runtime rendering. The public runner must now execute on the hosted ARM host and preserve its actual PNGs before native acceptance can advance. + +Independent review passed for native-route source staged at tree `15da770ebf0915f8f53abf3276e29a413e00f558`: the reviewer reran verifier/native-proof tests, 14/14 passed, and found no blocker in the supported predicate mapping or native provenance/console/pixel guards. A final scenario strengthening also waits for three observed render intervals after **every** fog/inside/off/rebuild transition, with labeled snapshots, rather than only after the last transition. The prior disposal events prove real controller/target-object release; these additional boundaries establish that each intermediate graph actually rendered before replacement. Exact create/release/live and restored-pixel thresholds are unchanged. Native execution remains pending. + + +Native progress and exact failure: [run 37005493900](https://github.com/ThreeNativeHQ/threenative/actions/runs/37005493900), source `1f73e559c7a8fa01740cc2b5b24ebec8dfc2f4e7`, built the ordinary Linux ARM64 host and accepted all fifteen pre-resize arms with actual `device.screenshot`, named Mesa llvmpipe WebGPU adapter and empty runtime diagnostics: off, zero, fog, inside, sunOff, pointOff, overlap, half, wallOff, blackOff, scatter, scatterSunOff, scatterPointOff, lifecycle and lifecycleOff. Actual [native fog](../../verification/vq07-progress/640c103-native/resizeRestore.png) and [full provenance with unchanged controls](../../verification/vq07-progress/1f73e559c-native/provenance.json) are retained, the latter carrying the inside and isolated-scattering arms' SHA256; those two images are in artifact 11226555526. Native artifact `11226555526` matches SHA256 `cc5cc8f973b8edc9f16a188f3ae68f1e0afb6b2f508ee4a392df222191587af5`. After download, the exact source's pinned pixel metric function passes these native controls: zero/off and final-off/off are identical, evaluation/wall RGB is zero, calibration difference is zero, directional mean delta is 15.9577/255 (changed ratio 0.99333), and point mean delta is 48.7721/255 (ratio 1.0). These cross-arm metrics were recomputed locally from real PNGs because the hosted script stopped before its final metric phase. Native lifecycle observed 4 created / 3 target and material releases / 1 live target, then 4 releases / 0 live targets, textures back to 5 after at least three actual stable render intervals. + +The native gate is **failed**, not complete: `resizeSmall/report.json` records `TN_PLAYTEST_HOST_EXITED`, `wgpuSurfaceGetCurrentTexture` validation **“Surface image is already acquired”**, then SIGABRT. No resize screenshot or console.json was produced. The verifier's subsequent console-file read raised ENOENT and obscured this primary report in the job log; neither is waived. Native resize/restore remains unverified and the native phase/acceptance boxes remain open. The same exact source's browser job passed all 17 cases and all identity/scattering/resource/resize metrics, artifact `11225682361` SHA256 `5cc58198f77714817c43c486cf8b83d7b7e3ef23730e4010c1026b2918740c35`. This includes labeled render settling after every intermediate lifecycle graph. Available native console logs contain no `TN_FRAME_BUDGET`/`TN_HOST_GAP` windows; no per-frame fog cost is claimed from pipeline-creation timings. + + +Whole-feature review identified three corrections and remaining qualification: sunlight outside the ordinary directional shadow map must follow pinned Three's unshadowed convention; native captures must retain the authored 5% nonblank predicate; and primary failed-host reports must be surfaced before optional console artifacts are read. It also requires camera/occluder changes on the retained controller, actual scene exit/re-entry, and same-fixture per-frame cost from the maintained frame-budget meter. These remain actionable work, without widening phone/local-shadow scope or claiming full acceptance. + +The next diagnostic slice changes only proof tooling: native PNGs now run the existing playtest `regionMetrics` against the frozen 5% whole-frame minimum, and a failed report surfaces its primary diagnostic before console-file access. All fifteen original native PNGs were independently rechecked and pass (minimum 0.05625); per-image values are added to the existing native provenance. A separate no-fog control keeps the same geometry, renderer, authored key-driven resize and observed-render waits, asserting zero fog controller/target allocations. The unchanged original17-case sequence still runs, and the workflow requires both invocations. This isolates native surface lifetime from fog integration before any C++ runtime change. New verifier/control tests were red-green and strict types pass; actual control execution is pending. The outside-shadow-map shader correction is not included in this diagnostic slice. + + +Native resize isolation: [run 37011447056](https://github.com/ThreeNativeHQ/threenative/actions/runs/37011447056), exact checkout/build `7f710b7d3af5e2da18017e4a0f707d4217ddf85d`, failed both native resize invocations while browser17 cases passed. Artifact `11227694772` SHA256 `8ae66c5a60cba94b1d2b96c771ee2f3f7dbb98472b95ea980a067c83976da2f2` is verified. The original15 native captures again passed, including the restored5% guard; original fog resize again aborted with “Surface image is already acquired”. The no-fog control produced zero PNGs: its retained `resizeOff/console.json` first error is `TN_SURFACE_ACQUIRE_FAILED` status2, then “Failed to get current texture” and cascading unfinished-object errors. Its normalized continue-on-error step conclusion initially appeared successful, but its retained summary is false and the mandatory final workflow guard correctly failed. No control success is claimed. Both invocations built package0.3.4 from the named checkout and used runtime SHA256 `da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042`, bundle SHA256 `d2be9b3d0efa3247ff63c772421f75b77e8ea1fd9b271c4791ec7934f88c3e86`, identical to the earlier native run. + +The compatibility header, presentation/resources/canvas bindings and dependency provisioner were compared byte-for-byte against current develop `924b92f825d602485ef4e682f434b4757a825cd7`; all five are identical before this repair. This is a current native-host defect, not an older prebuilt. Pinned [wgpu-native25 acquisition](https://github.com/gfx-rs/wgpu-native/blob/v25.0.2.2/src/lib.rs#L3681) returns a real texture for SuccessSuboptimal (status2), but the existing helper accepts only SuccessOptimal and drops that result without adopting its lifetime. The proposed narrow repair admits both modern success statuses; timeout/outdated/lost/error remain rejected and the legacy single-success branch remains unchanged. A native C++ test using the production helper and the exact upstream header pin `bac520839ff5ed2e2b648ed540bd9ec45edbccbc` failed on Suboptimal first and now passes. All successful acquisitions retain the existing presentation-owned release path; no frame error is suppressed or extra present added. A repaired-host rerun of both original and no-fog sequences is still required before attributing resolution of the fog SIGABRT or claiming native resize qualification. + + +The acquisition ownership audit also makes the ordinary get-current-swapchain path explicitly require a non-null returned texture. A non-null rejected result is released before the existing failure marker; null remains failure. Successful images continue through the existing presentation owner and are released there. The status test additionally rejects OutOfMemory and DeviceLost. Its CMake target is registered in the normal and coverage build matrices and is required before both hosted resize invocations. Local C++ status-contract red/green, focused native/verifier/build-matrix tests33/33 and native source-list validation pass. Full repaired-host execution and independent native review are pending; no runtime pass is inferred from these checks. + +Native review corrections: the C++ regression now guards wgpu-only OutOfMemory/DeviceLost enums separately from pinned Dawn, and the new target is registered with its required pass marker in the actual native execution-contract table (46 declared targets). Both official backend-header compilations and executions pass; both binaries also pass the real execution lane marker check. The actual complete registration validator passes, build-matrix/coverage tests pass 15/15, and native-contract tests pass 8/9 with the unrelated CMake-dependent webtransport test blocked by the missing local CMake executable. The ordinary acquisition status/ownership change is otherwise unchanged. Independent review and the actual rebuilt-host original/no-fog sequences remain pending. + + +Resumed qualification checkpoint: [run 37018968247](https://github.com/ThreeNativeHQ/threenative/actions/runs/37018968247) at `ff59e2489367aa9808e2c288348f213299df397e` passed the browser gate and rebuilt the ordinary native host, but both mandatory native sequences failed. The verified native archive has SHA256 `3493cdd6c13c74861ee253788f53e36294903fe9ad89dd3618b34ae908172daf`. Fifteen original native arms remain passing. The no-fog control reaches the real key-driven resize, presents once, then reports `TN_SURFACE_ACQUIRE_FAILED` status 4 (Outdated) and fails screenshot capture. Accepting Suboptimal fixed the rejected-success contract; it did not resolve the separate mismatch between a 320×240 canvas backing extent and the unchanged 640×400 native drawable. No native resize success is claimed. + +The generated fog now initializes sunlight visibility outside the finite directional shadow map to one, following the installed Three shadow convention. Its actual generated-WGSL regression failed on the previous zero initialization and passes after the narrow correction. Only starter's expected generated-tree digest changes. A fresh isolated scaffold run also exposed the existing asset fixture's accidental dependence on a Three install above its temporary project; that test now supplies its own pinned Three dependency, preserving all codec assertions. + +The original seventeen runtime arms remain in order and unchanged in meaning. Twelve appended arms qualify sunlight outside shadow coverage, camera cuts/restoration and actual foreground-wall removal/reinsertion on the same controller, repeated `ctx.goto` scene exit/re-entry with observed target/material releases, and measured off/full/half frame cost. Every retained transition requires one creation and zero releases; its pixels must exactly equal the corresponding freshly constructed reference. Re-entered off scenes must restore both exact off pixels and the independent renderer texture count after three observed stable render intervals. The scattering ROI, exact-black foreground and calibration controls retain their existing thresholds. + +Cost arms use the existing thirty-frame `FrameBudget` meter and wait for ninety observed render intervals after the authored graph. The verifier uses the existing performance-marker parser, rejects missing windows or a compilation-contaminated final window, and records host frame/render distributions beside transport pixels, steps and light count. Missing GPU timestamps remain absent; these software-adapter measurements cannot admit a hardware tier. This slice has not yet produced new runtime screenshots or cost numbers; all remaining acceptance boxes stay open until the exact published source executes. + +Integrated native repair checkpoint: the concurrent `b0727a407f9a57fd2736dadf8a356f12bdae8d3d` Outdated-only single retry is retained. Canvas backing pixels and physical drawable pixels are now separate; a canvas-only change uses the existing offscreen presentation bridge without reconfiguring the swapchain. The bridge maps the entire source to the destination and preserves encoded color on a linear surface, with inverse transfer only for an sRGB destination. Physical resize drops the previous acquired image before reconfiguration. Outdated recovery retains offscreen source ownership and uses the physical extent, refusing to reconfigure a held direct output. Compiled production-body regressions cover these ownership, retry-limit, failure and extent transitions. The new registered `threenative-canvas-presentation-test` renders and reads back every destination pixel at larger, equal and smaller sizes in linear and sRGB formats; its actual GPU execution is still pending in the ordinary hosted lane. + +Fresh local validation of the integrated slice: root typecheck passes; root lint passes with 1,020 warnings and no errors; renderer/chain/environment/scaffold/fog/verifier suites pass 205/205, CI structure/needs pass 141/141, API/capability and evidence-budget checks pass. A formatting-only repair to earlier native provenance preserves its parsed JSON content exactly. Independent review reran 48 fog and 69 focused native checks and found no blocker in the integrated source; pinned-Dawn syntax checks pass. Root `pnpm test` remains blocked before suite execution by the local `tsx` Unix-IPC restriction. A full native unit attempt reports 1,482 passed, 43 failed and 70 skipped; all 43 failure names reproduce on an isolated exact `b0727a4` baseline, with missing built hosts, display/compiler/tooling and pre-existing fixture/environment failures. These are disclosed failures, not a full-suite pass. The actual built-host destination-pixel test and all 29 fog arms plus both no-fog controls must pass before native acceptance advances. + +Current-base reconciliation: merged exact develop `569fdb267538c5eae9472760fd71be59745b9a24` before requesting expensive hosted proof. The sole conflict was this PRD's original planning copy; all implemented evidence, current acceptance criteria and unfinished boxes are preserved. Merged tone/Fluid additions remain intact. Combined-source playtest/core JS and declarations build, root typecheck, current API/capability checks and all 346 renderer/scaffold/fog/verifier/CI-contract tests pass. Root lint passes with 1,024 warnings and no errors. Scaffold digests were measured on the combined tree and already match, so no further refresh was made. Native presentation source remains byte-identical to the independently reviewed repair. New runtime/cost evidence is still pending. + + +Latest actual proof: [run 37046042533](https://github.com/ThreeNativeHQ/threenative/actions/runs/37046042533) passes every original and added arm at source `640c103017d56e56abb56745eb875885c32aec06`: 29 browser WebGPU/SwiftShader cases, 29 Linux ARM64 QuickJS/wgpu-native/Mesa llvmpipe cases, both no-fog resize controls, compiled acquisition status and actual GPU destination pixels. The native resize now yields 320×240 and restores 640×400 on the retained controller; camera and streamed-wall transitions exactly match fresh reference pixels; repeated scene exit/re-entry restores exact off pixels and renderer texture count 5. Verified archive SHA256 values are `fc56bf38d6f3dfababbf424471887abd3992ba1ee0614596f046a20a2d2780b4` (browser 11244876573) and `003e4ee6e591d136d6c61abbad74b75605b6b11dc3e14afbf61831d1bc297710` (native 11244629357). [Browser proof](../../verification/vq07-progress/640c103-browser/provenance.json), [native proof](../../verification/vq07-progress/640c103-native/provenance.json), [restored native target](../../verification/vq07-progress/640c103-native/resizeRestore.png) retain the fog images and all 60 frame identities with the SHA256 behind each one; the 320x240 native target and the remaining controls are inside the two verified artifacts. + +The cost record separates thirty-frame host/render and presented-interval distributions from sparse GPU-query samples: browser off/full/half use 4/4/4 queries with 26/26/26 stale frames; native uses 4/4/3 with 26/26/27 stale. Software adapters and virtual capture displays do not establish hardware, phone or default-tier admission. The real native host console, ready/compile-settled state, resource predicates and pixels are mandatory; the standard core plugin's absent application-diagnostics channel is not represented as a clean observed channel. All untested OS-window-resize/background/foreground paths remain excluded; tested resizing is the canvas backing/render-target transition. + +An independent line-by-line audit recomputed identity/scattering/lifecycle evidence from actual artifacts and reran 137 ownership/fog/verifier checks; the original six phase claims and two ACs are supported at 640c103. Before archive, exact develop `42fa306c0c46417afb4cafda23f55d09a557f860` was then reconciled as required. Its unrelated world/shadow code is preserved. Native presentation, fog, fixture and verifier blob identities are unchanged, but component equivalence is not being substituted for runtime qualification of that code-base merge. This latest source remains draft/75% pending its fresh hosted run. + +Combined-source local qualification after the 42fa306c merge: core JS/declarations build, root typecheck, current API surface/capability manifest and scaffold import checks pass. Root lint passes with 1,032 warnings and no errors. The complete core plus fog/scaffold/verifier selection passes 2,377 tests with two skipped across 181 files. An initial subprocess selected an unintended pnpm 11 executable and failed the hot import-meta fixture; the supported pinned pnpm 10.25.0 environment passes that fixture 3/3 and the entire selection without changing source or assertions. Hosted browser/native qualification of this reconciled source is pending; the earlier 640c103 proof remains explicitly source-scoped. + +Documentation links and the real staged evidence-byte budget pass. Documentation/citation/budget unit selection reports 29 passed and two failed: both CLI-fixture failures are the existing local `tsx` listen-EPERM restriction before the evidence command starts, while the real-tree budget unit passes. No CLI assertion or permission boundary is changed; ordinary hosted CI must execute the full lane. + +Evidence-budget repair: hosted `budgets` and the real-tree budget unit failed closed because this branch's retained progress tree pushed `docs/verification` to 72.1 MB against a 72 MB cap, with the message "stop the growth at the commit that causes it, do not raise the cap". The cap is untouched. Three retained PNGs were byte-identical duplicates of images the authoritative `640c103-native` proof already carries — `fog.png` = `resizeRestore.png` (`95e2d1a1…`), `inside.png` = `cameraCut.png` (`1bec85d6…`) and `off.png` = `sceneRepeatedOff.png` (`340699e0…`) — so those three files are deleted and the two links above now name the surviving copies; both `provenance.json` files, every recorded SHA256 and the remaining five native controls are retained, and byte-identity stays checkable from the recorded digests. No threshold, cap or assertion moved. + + +Correction to that record, measured rather than assumed: the tree did **not** return to 71.9 MB. `pnpm budgets` and the real-tree budget unit both fail closed at 72.7 MB against the same 72 MB cap, which the previous commit's prose claimed otherwise. This branch's own retained progress tree is the whole difference — develop measures 71.7 MB, so 1.08 MB came in with these captures. + +Budget repair, no cap raised and no host artifact touched: the retained images are now the two authoritative fog frames a reviewer can actually look at — [browser fog](../../verification/vq07-progress/640c103-browser/fog.png) and [native fog](../../verification/vq07-progress/640c103-native/resizeRestore.png) — plus the 2.5 KB [black calibration control](../../verification/vq07-progress/001b93400/blackOff.png). Every `provenance.json` stays, and each one already carries the SHA256, pass flag, dimensions and resource counts of all of its arms, so identity stays checkable from the recorded digests and the two verified artifacts rather than from a second copy of the bytes. The eighteen superseded intermediate captures are deleted and the six checkpoint paragraphs that linked them now name the run, artifact id and digest instead. `docs/verification` returns to 71.9 MB measured, not asserted. Every claim those paragraphs make — the pixel metrics, the frame identities, the resize and re-entry results — is unchanged and still backed by the artifacts named there. + + +Reviewer repairs (2026-10-03, source `21222ef50` + this pass). Four findings, all fixed in the generated fog source rather than around it. **(1)** The authored mist had been added to `src/scenes/Play.ts` as a `MIST` block, which puts a look decision in a scene; it is now `STARTER_MIST` in `src/render/volumetricFog.ts`, and `Play.ts` only passes it the live backend. **(2)** That block also hardcoded `renderer: "webgpu"` while declaring `environment.sceneFog: false` — but `sky.ts` installs the scene's `FogExp2`, so the medium's own guard (`scene.fog` must be absent at compose) would have thrown the moment the flag was turned on: the advertised single flag could not work. `createVolumetricFog` now takes the scene, takes its fog over for its own lifetime and restores it on disposal, so one flag is genuinely enough and the air is never counted twice; `STARTER_MIST` is typed `Omit` so no scene can claim a backend the renderer does not have, and `Play.ts` supplies `ctx.renderer.kind`, which makes the WebGL fallback of `preferWebGPU` allocate nothing instead of silently dropping a baseColour. `environment.sceneFog` is deleted rather than left as a field that can only be false. There is no automatic sun: the generated source and the template instructions now say the mist is lit by `ambient` until `sun` is passed, that a `sun` needs `lighting.ts`'s real light with an initialized shadow map, and that a tier change rebuilds the medium. **(3)** `scripts/__tests__/verify-volumetric-fog.spec.ts` uses `test-support/temp-dir.ts` again, so its `temp-dir-guard` allowlist entry is gone rather than justified; the standalone native `.mjs` exception stays. **(4)** The `fog` lane in `integration.yml` gained the paths this repair touches — `postprocessing.ts`, starter `Play.ts`, both fog specs and `scripts/__tests__/verify-volumetric-fog.spec.ts` — so the qualification actually reruns when the shipped wiring changes. Develop's lanes are untouched and no second job or stage was added; the two jobs and the single lane filter are the ones already reviewed. + +Red-green proof for the wiring, before the aggregate lane: the new scene-fog take-over test failed on the medium that only cleared `scene.fog` inside `dispose` (`expected FogExp2{…} to be null`), and the two new source regressions failed against the pre-repair files (no `renderer: renderer.kind` in the fog source; `Play.ts` still carrying `IVolumetricFogOptions`, `volumes:` and `albedo:`). `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts packages/create-threenative/__tests__/vq-fog-fixture.spec.ts --maxWorkers=1` passes 30/30, `scripts/__tests__/temp-dir-guard.spec.ts` with `scripts/__tests__/verify-volumetric-fog.spec.ts` passes 15/15, `packages/create-threenative` typecheck and root `pnpm typecheck` pass, and the documentation-link, evidence-budget, CI-structure, CI-needs and mirror-sync selection passes 230/230 with `pnpm budgets` reporting `evidence budget: ok` at the measured 71.9 MB. Only starter's expected generated-tree digest moved (`a0abaf94…`), remeasured through `createProject`; the other twelve templates still match develop's measurements. + +Still open, unchanged by this pass: Phase 3's native box, both acceptance criteria, and any promotion claim. The `640c103` browser/native qualification remains source-scoped — this repair changes the shipped wiring, so a fresh hosted browser and native run plus the full repository CI are required before the PRD can be marked done, and no box is ticked on the strength of the earlier run. + +Local runtime qualification at `c36455830`, and the ownership repair that moved it. A local browser WebGPU sweep over the standalone fixture ran all 29 arms — off, zero, fog, inside, sunOff, pointOff, overlap, half, wallOff, blackOff, scatter, scatterSunOff, scatterPointOff, lifecycle, lifecycleOff, resizeSmall, resizeRestore, scatterOutside, scatterOutsideSunOff, scatterOutsidePointOff, cameraCut, cameraRestore, streamWallOut, streamWallIn, sceneReentry, sceneRepeatedOff, costOff, costFull, costHalf — with every arm passing and zero diagnostics on an `nvidia`/`turing` adapter, private Xvfb at 1600x900, `page.screenshot` captures, and each arm carrying its own source SHA. Its cost arms separate the numbers honestly: `costOff` measured 0 transport pixels and 0 ray steps against `costFull`'s 256000 pixels at 48 steps and `costHalf`'s 64000 pixels at 48 steps, and each records only same-fixture host frame/render distributions (30 frames) with a sparse GPU-query sample, explicitly stating that a software adapter establishes neither hardware cost nor tier admission. This is a local browser result only: it says nothing about Linux native, which needs the hosted lane. + +Two genuine defects were found while collecting it. The `fog` lane in `integration.yml` used `(?:…)` non-capturing groups, which `grep -E` does not support, so the lane never matched and the hosted fog job would have stayed false; plain ERE groups restore it, proven by reproducing the old `grep -E` exit 1 and by 201 passing CI structure/needs tests. Separately, the five local full-suite failures in `packaging.spec.ts` and `three-storage3d-usage.spec.ts` were a stale `node_modules`, not a code regression: develop's `three@0.185.1` patch gained the compute-only 3D storage usage fix at `c18a42bea` (08:51) while this worktree's install predated it (00:17), so `computeOnlyStorage3D` was absent from the installed tree; `pnpm install --frozen-lockfile` re-applied the patch and both files pass 8/8 and 3/3 unchanged, with the patch byte-identical to develop's throughout. + +Ownership repair, red-green. `createVolumetricFog` restored the captured `scene.fog` unconditionally on disposal, so a fog the game authored while the medium lived was silently clobbered on scene teardown. The medium owns the slot only while it holds it, so disposal now restores the captured fog when `scene.fog` is still null and leaves a later authored non-null fog as the live one. The new case failed first against the unconditional restore (`expected FogExp2{…} to be FogExp2{…}`) and passes after it; `vq-volumetric-fog.spec.ts` passes 24/24 and the full scaffold suite 66/66, with only starter's expected generated-tree digest moving to `9c0d409a…` and the other twelve templates still matching develop's measurements. Root typecheck and lint pass. Because the standalone fixture imports this exact source, this repair moves the code the 29 arms exercise, so the sweep above is evidence for `c36455830` and not for the current head; a fresh hosted browser and native run is still required, and Phase 3's native box, both acceptance criteria and every promotion claim remain unticked. + +Threshold and coverage repair. The overlap gate stays exactly as first pinned — a five-percent changed-channel ratio and a four-level maximum channel delta, both absolute differences between the one-bound and two-bound frames — and the signed-mean "added extinction can only brighten" clause is gone: added extinction adds in-scattering along the ray, and nothing in a generally lit scene says the composite must get brighter. What was wrong was the fixture's control, not the bar. The second bound had been a 5 m × 2.5 m × 8 m box that the foreground wall, the pillar and the ceiling beams hid, and its faster height falloff made it locally weaker than the primary bound; the measured effect was 28666 of 768000 changed channels, 0.03733, below the 0.05 it had to clear. The control is now pinned to the room's whole lit interior — `Box3(-4.2, 0, -9.2)..(4.2, 3, 2.6)`, density 0.28, baseHeight 0.5, heightFalloff 0.32 — nested inside the unchanged primary bound on every axis, and the two parameters were written down before the capture rather than fitted to it. Two real browser WebGPU arms of the same fixture through `runStandalonePlaytest` on a private Xvfb, both passing with zero diagnostics, measure 72060 of 768000 changed channels (0.09383) and a maximum channel delta of 43 — nearly twice the ratio floor and ten times the delta floor. The synthetic guard is unchanged: identical frames and a nine-level delta on one of two pixels still fail. + +Shadow-coverage repair. `fogInsideShadowMap` sampled the eight corners of the volume for membership in the shadow camera's NDC box, which is false `false`: a bounding volume can enclose the whole shadow box with no corner of itself inside it, so a small map swallowed by a large volume was reported as unshadowed. It now transforms the volume by the shadow camera's own view-projection and intersects the resulting conservative clip-space AABB against the clip box, which can only over-report. The depth range is read from `camera.coordinateSystem` rather than assumed, because Three syncs the shadow camera to the frame camera's convention and rebuilds its projection with it: 0..1 on WebGPU, -1..1 on WebGL. The new case aims a 40 cm map down the length of the room — wholly inside `ROOM_BOUNDS`, with none of the room's eight corners inside the map — and it fails first against the corner version (`expected false to be true`) and passes after. The same map translated clear of the room stays outside. Runtime `shadowOutside` remains read from those transforms in both directions and never from the mode name: `scatter` observes `false`, `scatterOutside` observes `true`. + +Cost-arm reporting. `fogFrameCost` reported `gpuMs` beside a thirty-frame window, which reads as a thirty-sample mean it never was. The retained console for `costFull` shows `gpu.samples: 4` and `gpuStale: 26` in its last window: the adapter answered four of thirty frames, and 174.78 ms is the mean of those four. The summary now carries `gpuSamples` and `gpuStaleFrames` beside it, and says in its own qualification string how many of how many frames the mean covers. This is a reporting correction only — no threshold moved, and no performance or tier-admission claim follows from four samples on a software adapter. + +Still open after this repair, unchanged: Phase 3's native box, both acceptance criteria and every promotion claim. The native audit cited above covers source `a6a155e8e`, not this head, so both correction commits must requalify through the ordinary hosted lane. + +Full local browser requalification of `432dd7043`, the committed source carrying both repairs: `pnpm exec tsx scripts/verify-volumetric-fog.ts` accepted all 29 arms with zero failing arms and zero runtime diagnostics on an `nvidia`/`turing` WebGPU adapter advertising `timestamp-query`, private Xvfb at 1600x900, `page.screenshot` captures, and `summary.json` recording that SHA. The overlap gate clears its pinned bar at 0.09383 changed channels and a maximum delta of 43; zero-density identity, the repeated lifecycle's baseline pixels, the resize restore, the six camera/occluder/scene-owner arms and both per-mode light-scattering pixel gates all pass. The cost arms still separate themselves: 0 transport pixels and 0 steps for `costOff` against 256000 pixels at 48 steps for `costFull` and 64000 for `costHalf`, with host frame and render distributions over the full 30 frames each. The GPU column stayed sparse on hardware too — 4 samples and 26 stale in every arm — and the reported `gpuMs` of 0.16, 0.62 and 0.97 is a mean over those four frames, which is no basis for a hardware cost or tier-admission claim and is not made as one. + +Cost-arm sample-count validation, closing the hole the reporting fix opened. `fogFrameCost` read `gpu.samples` and `gpuStale` through `Number(...)`, so a malformed or negative count reached the summary as `NaN` or a negative frame count, and a `gpuMs` with no `gpu` block was reported as a mean over an invented zero frames. `parsePerformanceMarkers` does not close it either — it validates hitch and projection payloads only, and pushes budget windows unvalidated (`packages/playtest/src/runner/perf.ts`) — so the check belongs in the verifier. Both counts must now be nonnegative integers within the measured window, and a window reporting `gpuMs` must carry a positive sample count; a window with no GPU query stays an explicit zero and unmeasured. Proof: five malformed-metadata cases through the existing valid-marker fixture (non-numeric, negative and over-window `samples`, negative `gpuStale`, and `gpuMs` with the `gpu` block absent) fail first against the previous code and pass now, with the retained 4-samples/26-stale window and the CPU-only path unchanged. `pnpm exec vitest run scripts/__tests__/verify-volumetric-fog.spec.ts` 16/16, `pnpm typecheck` and `biome check` on both files clean (only the two pre-existing complexity warnings in untouched functions). No threshold, rendering path or fixture moved; this is input validation only. + +### Final qualification — 2026-10-03, source `b93944e60afc42d12cb0d89c5fd65f1a07fe2b7d` + +Two commits close the branch. `630927667` is the GPU-sample-count validation recorded above. `b93944e60` is a type-only correction: the generated starter medium's return type is `FogMedium`, not `IFogMedium`, so starter's `render/postprocessing.ts` and `render/volumetricFog.ts` follow the repository's own type-alias naming rule. No shader, threshold, fixture or scenario changed, and only starter's expected generated-tree digest moved, to `d51d898f4f15403bf2e20a734ddeba972aca595df6c12d854ce5b0d4b8fb94d6`; the other twelve templates still match develop's measurements. + +[Run 37147171365](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171365) is the first run of this exact source. Every claim below was recomputed from that run's downloaded artifacts rather than carried over from `640c103` or any earlier head. + +**Cohort and provenance.** `sourceSha` and the retained `attempt.json` both read `b93944e60afc42d12cb0d89c5fd65f1a07fe2b7d` with `runId` 37147171365, on the browser and desktop artifacts alike. Browser artifact `11282561657`, upload SHA256 `4a465500564eb360e4a62e3e91bb387278b31e72c17ffcb4751c13170eb0cd27`. Native artifact `11283043309`, upload SHA256 `85ce7e3eaa1a036abdbbb9e54ec764b14621e0e21cd1073dfc1570d667744b2f`, carrying both `volumetric-fog-native/` and `volumetric-fog-native-resize-control/`. Browser adapter `google`/`swiftshader` advertising `timestamp-query`, WebGPU, 640×400 captures on a private Xvfb at 1600×900 through `page.screenshot`. Native adapter `native:vulkan/llvmpipe/llvmpipe (LLVM 20.1.2, 128 bits)/Mesa 25.2.8-0ubuntu0.24.04.4 (LLVM 20.1.2)` on Ubuntu ARM64 with the QuickJS engine and `device.screenshot`, runtime SHA256 `5a164cc2ec42e30b8597fb40abb125540aa73b37119dc2158f34f40a8589665b` and bundle SHA256 `b67511f2a6c1bf1f624e5aa0e40438f727d3d5debcd8d89712c979d1d29245eb`, both identical between the fog sequence and its no-fog control. The host reports `mystral-native@0.3.4`; the workspace cohort is `three@0.185.1`, `@threenative/core` 0.3.4 and `create-threenative` 0.2.8. Before the fog runs the job compiles and executes `threenative-surface-texture-status-test` ("native surface acquisition status contract passed") and `threenative-canvas-presentation-test` ("native canvas presentation pixels passed"), so the acquisition status contract and the destination-pixel readback are executed, not assumed. + +**Assertion census.** Browser: 29 arms, 392 in-arm assertions, 13 boolean cross-arm assertions and three per-frame cost records — 0 failures, 0 diagnostics in every arm. Native: 29 arms, 419 in-arm assertions, 13 cross-arm assertions, 29 authored 5% whole-frame nonblank results (minimum 0.05625, on the black control) and three cost records — 0 failures, 0 diagnostics. Both no-fog resize controls pass at 320×240 and 640×400 with `createdTargets`/`liveTargets`/`targetWidth`/`targetHeight`/`pixels`/`steps` all zero and textures at 5, so the observed resize is surface lifetime rather than fog integration. + +**Recomputed from the actual PNGs.** `zero` is byte-identical to `off` on both targets; `lifecycleOff` equals `off`; `resizeRestore` equals `fog` (native `resizeSmall` is the only 320×240 capture, every other capture 640×400); `cameraCut` equals `inside`; `cameraRestore`, `streamWallIn` equal `fog`; `streamWallOut` equals `wallOff`; `sceneReentry` and `sceneRepeatedOff` equal `off`. The overlap gate clears its pinned floor at 0.09385546875 changed channels and a maximum channel delta of 43 in the browser and 0.09384505208333334 / 43 natively, against the unchanged 0.05 and 4. The four-arm scattering check gives a black-control maximum of exactly 0 over the 500×400 evaluation area, a foreground-wall ROI maximum of exactly 0, a calibration minimum of 146 with a cross-arm difference of exactly 0, and directional/point deltas of 15.9585/0.99333 and 48.7755/1.0 (natively 15.9577/0.99333 and 48.7721/1.0). + +**Observed from state, not from the authored mode name.** Transport targets are 640×400 / 256000 pixels / 48 steps at full quality and 320×200 / 64000 / 48 at half, with `costOff` at 0 pixels and 0 steps, on both targets. `shadowOutside` is `false` in `scatter`, `scatterSunOff` and `scatterPointOff` and `true` in `scatterOutside`, `scatterOutsideSunOff` and `scatterOutsidePointOff`. Resize moves the retained controller to 320×240 / 76800 pixels and back to 640×400 / 256000 with one target created and none torn down. The repeated lifecycle observes 4 created targets, 3 target and material releases and 1 live target, then 4 releases, 0 live targets and textures 10 → 5; repeated scene exit/re-entry observes 3 entries, 2 exits, one exit release of a target and of a material, 0 live targets and textures back to 5. The cost arms report 30-frame host distributions separately from the GPU query, and the GPU mean is labelled with its own coverage: browser 4 samples / 26 stale in all three arms (45.71, 230.72 and 141.02 ms), native 4/26, 4/26 and 3/27 (10.68, 59.43 and 32.3 ms). No timestamp is reported as a 30-sample mean and none is used for tier admission. + +**Repository gates at the same head.** CI run [37147171521](https://github.com/ThreeNativeHQ/threenative/actions/runs/37147171521) is SUCCESS with 72 selected checks passing, none failed and none pending; its `ci-required` aggregator passed. Locally on this head: `pnpm typecheck` exit 0, `pnpm lint` exit 0 with 1,042 warnings and no errors, and `pnpm check:docs` exit 0 over 2,542 links with the doc-links/evidence-budget/evidence-citations/sync-agent-docs/CI-structure/CI-needs selection at 6 files / 245 tests. + +**Exclusions, carried forward unchanged.** Linux ARM64 software rendering (llvmpipe) and Chromium SwiftShader qualify browser and native correctness only. Phone performance, a wider local-shadow set, OS-window resize/background/foreground behaviour and any hardware or default-tier admission stay out of scope and are not claimed by any run above. The GPU means come from a software adapter on a virtual capture display and from as few as three answered frames. + +**Promotion.** This PRD's runtime was qualified at `b93944e60`, which is the exact head the run above qualified; the archive commit that moved this file into `done/` is `b655f4351`. `origin/develop` has since advanced past that head and GitHub reported the PR as `CONFLICTING`/`DIRTY`; reconciling that merge is the merging parent's task. Nothing here is a waiver of it, and no gate in the record above was weakened to close a box. + +### Reconciliation with develop `4d5e07c98` — merged at `8431684bc` + +The advancing-base merge GitHub reported as `CONFLICTING`/`DIRTY` is reconciled here by a normal merge (no rebase, no force push). Exact develop `4d5e07c98a946b572f6c2724f1ef2225f7b3f71a` is merged into this branch as `8431684bc6ed7483c91e72e7f26201eec172751a`. The historical proof above stays scoped to its own source; nothing in it is restated as this head's evidence. + +**The single conflict was `.github/workflows/integration.yml`**, where this branch's `fog`/`fog-native` jobs and develop's new `capture` (world-capture) job were inserted at the same point. Both sides are kept verbatim: develop's streaming, virtual-shadow, resolution-scaler and world-capture source is untouched, and both fog jobs keep their exact steps, path filter and artifact names. No fog source, shader, threshold, fixture, scenario or CI-contract assertion moved, `pnpm-lock.yaml` is byte-identical between this branch and develop (so no frozen reinstall was needed), and no expected scaffold digest needed refreshing — the combined tree already matched. + +**Local gates on the combined tree `8431684bc`.** `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts packages/create-threenative/__tests__/vq-fog-fixture.spec.ts packages/create-threenative/__tests__/scaffold.spec.ts scripts/__tests__/verify-volumetric-fog.spec.ts scripts/__tests__/vq-fog-native-proof.spec.ts` passes 5 files / 122 tests; `scripts/__tests__/ci-structure.spec.ts`, `ci-needs.spec.ts`, `temp-dir-guard.spec.ts` and `primary-docs.spec.ts` pass 4 files / 214 tests. Root `pnpm typecheck` exits 0, `pnpm lint` exits 0 with 1,047 warnings and no errors, `pnpm budgets` exits 0 reporting `evidence budget: ok`, and `pnpm check:docs` exits 0 over 2,544 links in 1,232 files. + +**Local browser requalification at `8431684bc`.** `pnpm exec tsx scripts/verify-volumetric-fog.ts` accepts all 29 arms with zero failing arms and zero runtime diagnostics on an `nvidia`/`turing` WebGPU adapter, a private Xvfb at `:1` 1600×900, `page.screenshot` captures, and every arm carrying `sourceSha 8431684bc6ed7483c91e72e7f26201eec172751a`. `summary.json` is SHA256 `ac608e32b4020fc79697587bc4f3eb94336cceb9af50729a72dc18f5dfe320c1`. Recomputed from this run's own PNGs: `zero` is byte-identical to `off` (`9603cbf7a8225e951811336b93110bfcf58ed677cb6b5f6bbb9596d4750efb2e` for both); the overlap gate clears its pinned floor at 0.093828125 changed pixels and a maximum channel delta of 43 against the unchanged 0.05 and 4; all 13 cross-arm assertions pass, including zero-density identity, baseline-pixel restore after the repeated lifecycle, the texture baseline of 5 after three settled/stable render intervals, exact resize restore, both camera arms, both streamed-wall arms and both scene re-entry arms. The cost arms stay separated and make no tier claim: `costOff` 0 transport pixels / 0 steps, `costFull` 256000 pixels at 48 steps, `costHalf` 64000 at 48, each reporting a 30-frame host distribution beside a GPU mean labelled 4 samples / 26 stale frames. + +**Still not proven at this head: Linux native.** The browser sweep above is a local software-adapter correctness result on a local GPU. The `b93944e60` native proof stays source-scoped and is **not** carried forward: native correctness at the reconciled head requires the hosted `fog-native` lane's ARM QuickJS run, and that run's ids, artifact digests and adapter identity are the merging parent's to record once it executes. Phone performance, a wider local-shadow set, OS-window resize/background/foreground behaviour and any hardware or default-tier admission remain excluded and unclaimed. + +#### Develop advanced again mid-reconciliation — `2c005f914` merged at `6701a80b2` + +`develop` moved one commit while the reconciliation above was running: `2c005f914` ("ci: trim template setup and select tests by actual impact", #418) replaced the paths job's per-lane `gh api` + `grep` selection with `scripts/ci-integration-scope.mjs`. Because GitHub creates no `pull_request` run for a conflicting pull request, the first reconciliation produced zero hosted runs, so this second merge is what lets the fog lane execute again. + +The conflict is the same file's lane-selection block, and the resolution is a selection-mechanism port rather than a gate change: upstream's selector is kept verbatim and this branch's fog lane is registered in the new `TN_LANE_FILTERS` inventory, which the script refuses to start without — a paths output with no filter, or a filter with no root job, throws. The fog pattern is the old lane filter with only its trailing `^\.github/workflows/integration\.yml$` clause removed, because upstream now selects a lane for workflow edits by comparing each job and filter separately. The `fog` and `fog-native` jobs, their steps, artifact names and digests are unchanged. + +Measured, not assumed: `scripts/__tests__/ci-integration-scope.spec.ts`, `ci-structure.spec.ts`, `ci-needs.spec.ts`, `ci-template-selection.spec.ts` and `ci-local.spec.ts` pass 5 files / 116 tests together with `primary-docs.spec.ts` in a 10-file / 312-test selection; running the real `node scripts/ci-integration-scope.mjs` with this merge's exact base and head prints `shared scheduling or workflow shape changed` and selects all eight lanes, which is the fail-closed answer for the commit that introduces the new mechanism, while a fog-source-only file list selects `fog=true` and nothing else. Root `pnpm typecheck` and `pnpm lint` exit 0 (1,051 warnings, no errors), `pnpm budgets` exits 0 reporting `evidence budget: ok`, `pnpm check:docs` exits 0 over 2,544 links in 1,233 files, and the fog source/fixture/scaffold/verifier/native-proof selection passes 5 files / 122 tests. No scaffold digest moved. + +**Fresh local browser requalification at `6701a80b2`.** `pnpm exec tsx scripts/verify-volumetric-fog.ts` accepts all 29 arms with zero failing arms and zero runtime diagnostics on an `nvidia`/`turing` WebGPU adapter advertising `timestamp-query`, a private Xvfb at 1600×900 and `page.screenshot` captures, every arm carrying `sourceSha 6701a80b2b3bba481d9f9c8fd88e3489857d6564`. `summary.json` is SHA256 `9cd50fb3abe08ccc2707ad201bc6039eebe752978ee0cdec31221da8dfb0a70c`; `zero` is again byte-identical to `off` (`9603cbf7a8225e951811336b93110bfcf58ed677cb6b5f6bbb9596d4750efb2e`), the overlap gate clears at 0.093828125 changed pixels and a maximum channel delta of 43 against the unchanged 0.05 and 4, and all 13 cross-arm assertions pass. The cost arms again report 0 transport pixels / 0 steps for `costOff`, 256000 at 48 steps for `costFull` and 64000 at 48 for `costHalf`, each with a 30-frame host distribution beside a GPU mean labelled 4 samples / 26 stale frames — no hardware or tier-admission claim follows. This is a local browser result: the hosted `fog` and `fog-native` lanes remain the authority for both targets at this head, and the Linux ARM64 native box is still unticked until that lane runs. + + diff --git a/docs/PRDs/rendering/PRD-VQ-07-volumetric-fog.md b/docs/PRDs/rendering/PRD-VQ-07-volumetric-fog.md deleted file mode 100644 index 8e6603fb7d..0000000000 --- a/docs/PRDs/rendering/PRD-VQ-07-volumetric-fog.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -prd_contract: v1 ---- - -# PRD-VQ-07 — Local volumetric fog composes with depth, lights and existing atmosphere - -**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed. -**Batch:** Visual quality execution batch. **Wave:** 2 / atmosphere. -**Dependencies:** Independent of clouds. Reuse VQ-06 only for any local-light feature it has actually qualified. - -## Grounding and intended outcome - -ThreeNative already has atmosphere LUTs and god-ray composition. Current upstream [VolumeNodeMaterial](https://threejs.org/docs/pages/VolumeNodeMaterial.html) and volumetric lighting machinery are reuse candidates, not proof of a finished ThreeNative fog product. Recheck the installed pin and the merged rain kit before introducing a new algorithm. - -**Outcome:** A bounded mist volume in an interior/exterior fixture responds to the main light and declared local lights, is occluded by solid geometry and blends into the existing atmosphere without becoming a uniformly bright screen overlay. - -## Design and ownership - -Start with the supported upstream volume/material or existing kit implementation. Density functions, phase function, color and artistic controls remain generated TSL/game source. Share only depth access, lifetime and ordered dispatch that the current render loop cannot already supply. Composite radiance with transmittance in the scene-linear graph before its one output transform; specify the relationship with aerial perspective and god rays so the same medium is not added twice. Keep history optional until its disocclusion behavior is qualified. - -A bounded volume and height-dependent density, one directional source and an explicitly supported local-light subset. No mandatory froxel engine, general weather manager, or promise that all point/spot shadow types work. - -## Required behavior - -- Zero density produces the no-fog image; an opaque foreground wall hides fog behind it. -- The fixture tests the camera both outside and inside the volume, a light toggle and overlapping density bounds. -- Step count and resolution are quality controls, with an explicit off/unsupported route and no dormant GPU allocations. -- Resizes, camera cuts and streamed occluder changes do not reuse invalid depth/history. - -## Execution phases - -All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow EXECUTE.md for fixture setup, variables, review and repository gates. - -### Phase 1 — Depth-correct participating volume - -- [ ] Implement game-owned volumetric composition using the admitted upstream/kit mechanism and shared scene depth. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. -- [ ] Cover zero density, inside/outside camera positions and opaque occlusion with analytic or reference fixtures. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. - -### Phase 2 — Compose with the existing environment - -- [ ] Integrate directional and the declared local-light subset without double-applying atmosphere or god rays. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. -- [ ] Add bounded quality settings, history invalidation where used and deterministic resource disposal. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`. - -### Phase 3 — Qualify the visible result - -- [ ] The fog fixture verifies occlusion, light response and zero-density identity on browser WebGPU with fixed-camera captures. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. -- [ ] The same authored volume runs on Linux native and remains valid through resize and scene teardown. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`. - -## Acceptance criteria - -- [ ] Fog is depth-correct and light-responsive, and turning it off restores the original graph and allocation baseline. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. -- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts` plus the Phase 3 lifecycle scenario. - -## Performance and promotion - -Record pixels, ray steps, light count and cost on the same fixture. Choose tier admission from measurements, not an arbitrary AAA label. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. - -## Blocked on - -Phone performance and a wider local-shadow set are separate qualification work. - -## Completion record - -Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index 369ddacfd0..60f8e4649c 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -2,7 +2,7 @@ # Native coverage — 2026-08-28 Configuration: `tn-linux-coverage` with clang source-based coverage. Executed -42 native contract targets; 3 configured +44 native contract targets; 3 configured targets could not be built and are named below. | Subsystem | Instrumented lines | Covered | Line coverage | @@ -14,19 +14,19 @@ targets could not be built and are named below. | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | | `src/js/` | 2785 | 2212 | 79.43% | -| `src/platform/` | 1284 | 928 | 72.27% | +| `src/platform/` | 1293 | 936 | 72.39% | | `src/raytracing/` | 461 | 399 | 86.55% | | `src/runtime.cpp` | 2361 | 1870 | 79.20% | | `src/screenshot_gate.cpp` | 27 | 24 | 88.89% | | `src/storage/` | 327 | 286 | 87.46% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 9661 | 7380 | 76.39% | +| `src/webgpu/` | 9724 | 7441 | 76.52% | | `src/webtransport/` | 1391 | 1078 | 77.50% | | `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **24356** | **19174** | **78.72%** | +| **TOTAL** | **24428** | **19243** | **78.77%** | -Source digest: `sha256:b309d234707d2ecd8ad21f4c86933adb2b1b9d43ed966e2e276f89c33fa4dbad` +Source digest: `sha256:43460aee6c5928c8ae0e1e283851d528a609baed495751161ffa2422b66f595d` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/docs/verification/vq07-progress/001b93400/blackOff.png b/docs/verification/vq07-progress/001b93400/blackOff.png new file mode 100644 index 0000000000..54a83bcc63 Binary files /dev/null and b/docs/verification/vq07-progress/001b93400/blackOff.png differ diff --git a/docs/verification/vq07-progress/001b93400/provenance.json b/docs/verification/vq07-progress/001b93400/provenance.json new file mode 100644 index 0000000000..c675ccfe9f --- /dev/null +++ b/docs/verification/vq07-progress/001b93400/provenance.json @@ -0,0 +1,587 @@ +{ + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "runId": 36997361343, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36997361343", + "artifactId": 11221669862, + "artifactSha256": "2c88ec8b13a525986867301a735ede33f5b76ab3b6d9db3c46459554b5b7a4ca", + "overallRunPass": false, + "qualification": "Actual browser WebGPU software correctness progress. All isolated-scattering arms pass normal runtime/capture guards and the pinned pixel metrics. Overall run stops at lifecycle setup-count mismatch; no complete lifecycle, resize, native or performance claim.", + "images": { + "blackOff": { + "file": "blackOff.png", + "sha256": "8949e59556dd3e14b91756277782d8a98c2fd4af47bc27b232266540c2e1d3c0", + "mode": "blackOff", + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "lights": { + "directional": { + "name": "fog-directional", + "type": "DirectionalLight", + "shadow": "ordinary-map" + }, + "point": { + "name": "fog-point", + "type": "PointLight", + "shadow": "none", + "range": 8 + } + } + }, + "scatter": { + "file": "scatter.png", + "sha256": "89df2275163ca948f5ef8b5e18b3f94e57b9e01e5a6f5137dcf2fbe32aece809", + "mode": "scatter", + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "lights": { + "directional": { + "name": "fog-directional", + "type": "DirectionalLight", + "shadow": "ordinary-map" + }, + "point": { + "name": "fog-point", + "type": "PointLight", + "shadow": "none", + "range": 8 + } + } + }, + "scatterSunOff": { + "file": "scatterSunOff.png", + "sha256": "b10e9a6bea07b1f01b9b2b239882653d1538c115bc6911c20de95c0d517fe429", + "mode": "scatterSunOff", + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "lights": { + "directional": { + "name": "fog-directional", + "type": "DirectionalLight", + "shadow": "ordinary-map" + }, + "point": { + "name": "fog-point", + "type": "PointLight", + "shadow": "none", + "range": 8 + } + } + }, + "scatterPointOff": { + "file": "scatterPointOff.png", + "sha256": "d05cd825f2b4779e2abadd111517195bfb2b0fdca593b371992a23b534cd761f", + "mode": "scatterPointOff", + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "lights": { + "directional": { + "name": "fog-directional", + "type": "DirectionalLight", + "shadow": "ordinary-map" + }, + "point": { + "name": "fog-point", + "type": "PointLight", + "shadow": "none", + "range": 8 + } + } + } + }, + "pixelMetrics": { + "computedWithVerifierSourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.958518518518519, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.77553086419753, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + }, + "lifecycleFailure": { + "mode": "lifecycle", + "sourceSha": "001b934008cbc6e3ab196678506ae9b14f751f1e", + "pass": false, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [ + { + "code": "TN_PLAYTEST_COMPONENT_ASSERTION_FAILED", + "message": "Component 'disposedGraphs' on entity 'fog' did not satisfy the assertion.", + "observedRuntimePath": "observations.json/components/fog/disposedGraphs", + "severity": "error", + "suggestion": "Expected {\"changed\":true,\"component\":\"disposedGraphs\",\"entity\":\"fog\",\"gte\":4}, observed before=0 after=3. Check the owning script and runtime component synchronization." + } + ], + "lights": { + "directional": { + "name": "fog-directional", + "type": "DirectionalLight", + "shadow": "ordinary-map" + }, + "point": { + "name": "fog-point", + "type": "PointLight", + "shadow": "none", + "range": 8 + } + } + }, + "successfulRevalidation": { + "sourceSha": "1c9534fe9fdd29458d880afe70c45dc4aa5f22ce", + "runId": 36998309685, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36998309685", + "artifactId": 11222836638, + "artifactSha256": "9f0c207b9ba0cbc024b78104cc025a09acb8c925e13cba6fc22e17d78195c551", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "allFourControlPngBytesIdentical": true, + "results": [ + { + "mode": "off", + "pass": true, + "diagnostics": [] + }, + { + "mode": "zero", + "pass": true, + "diagnostics": [] + }, + { + "mode": "fog", + "pass": true, + "diagnostics": [] + }, + { + "mode": "inside", + "pass": true, + "diagnostics": [] + }, + { + "mode": "sunOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "pointOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "overlap", + "pass": true, + "diagnostics": [] + }, + { + "mode": "half", + "pass": true, + "diagnostics": [] + }, + { + "mode": "wallOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "blackOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "scatter", + "pass": true, + "diagnostics": [] + }, + { + "mode": "scatterSunOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "scatterPointOff", + "pass": true, + "diagnostics": [] + }, + { + "mode": "lifecycle", + "pass": true, + "diagnostics": [] + }, + { + "mode": "lifecycleOff", + "pass": true, + "diagnostics": [] + }, + { + "assertion": "zero-density pixel identity", + "pass": true + }, + { + "assertion": "repeated lifecycle restores baseline pixels", + "pass": true + }, + { + "assertion": "isolated light-scattering pixels", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.958518518518519, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.77553086419753, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + } + ], + "lifecycleObservations": { + "lifecycle": { + "mode": { + "before": "off", + "after": "fog" + }, + "ready": { + "before": false, + "after": true + }, + "builds": { + "before": 0, + "after": 5 + }, + "disposedGraphs": { + "before": 0, + "after": 4 + }, + "createdTargets": { + "before": 0, + "after": 4 + }, + "releasedTargets": { + "before": 0, + "after": 3 + }, + "releasedMaterials": { + "before": 0, + "after": 3 + }, + "liveTargets": { + "before": 0, + "after": 1 + }, + "scatteringOnly": { + "before": false, + "after": false + }, + "targets": { + "before": 0, + "after": 1 + }, + "pixels": { + "before": 0, + "after": 256000 + }, + "steps": { + "before": 0, + "after": 48 + }, + "inside": { + "before": false, + "after": false + }, + "sun": { + "before": true, + "after": true + }, + "point": { + "before": true, + "after": true + }, + "overlaps": { + "before": false, + "after": false + } + }, + "lifecycleOff": { + "mode": { + "before": "off", + "after": "off" + }, + "ready": { + "before": false, + "after": true + }, + "builds": { + "before": 0, + "after": 6 + }, + "disposedGraphs": { + "before": 0, + "after": 5 + }, + "createdTargets": { + "before": 0, + "after": 4 + }, + "releasedTargets": { + "before": 0, + "after": 4 + }, + "releasedMaterials": { + "before": 0, + "after": 4 + }, + "liveTargets": { + "before": 0, + "after": 0 + }, + "scatteringOnly": { + "before": false, + "after": false + }, + "targets": { + "before": 0, + "after": 0 + }, + "pixels": { + "before": 0, + "after": 0 + }, + "steps": { + "before": 0, + "after": 0 + }, + "inside": { + "before": false, + "after": false + }, + "sun": { + "before": true, + "after": true + }, + "point": { + "before": true, + "after": true + }, + "overlaps": { + "before": false, + "after": false + } + } + } + } +} diff --git a/docs/verification/vq07-progress/1505e018a/provenance.json b/docs/verification/vq07-progress/1505e018a/provenance.json new file mode 100644 index 0000000000..aa7daed582 --- /dev/null +++ b/docs/verification/vq07-progress/1505e018a/provenance.json @@ -0,0 +1,598 @@ +{ + "sourceSha": "1505e018aa38630b91893ab8265c447833e99a51", + "runId": 37000862324, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37000862324", + "artifactId": 11224155982, + "artifactSha256": "a21b102966a09371896a4d796fd278525925a5c4b6ce70b2a81cb1eb79ac45d4", + "qualification": "Browser WebGPU software-rendered target/depth resize and texture-accounting proof. No operating-system window resize, native runtime or hardware-performance claim.", + "images": { + "resizeSmall": { + "file": "resizeSmall.png", + "sha256": "d7e3e7adbe608ea141c07cdf5cfc86b8869728e7e830f9dd1513ef47c2a154b2", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "components": { + "mode": { + "before": "off", + "after": "fog" + }, + "ready": { + "before": false, + "after": true + }, + "builds": { + "before": 0, + "after": 1 + }, + "disposedGraphs": { + "before": 0, + "after": 0 + }, + "createdTargets": { + "before": 0, + "after": 1 + }, + "releasedTargets": { + "before": 0, + "after": 0 + }, + "releasedMaterials": { + "before": 0, + "after": 0 + }, + "liveTargets": { + "before": 0, + "after": 1 + }, + "scatteringOnly": { + "before": false, + "after": false + }, + "textures": { + "before": 2, + "after": 10 + }, + "settledRenderFrames": { + "before": 1, + "after": 3 + }, + "stableTextureFrames": { + "before": 1, + "after": 3 + }, + "targetWidth": { + "before": 0, + "after": 320 + }, + "targetHeight": { + "before": 0, + "after": 240 + }, + "targets": { + "before": 0, + "after": 1 + }, + "pixels": { + "before": 0, + "after": 76800 + }, + "steps": { + "before": 0, + "after": 48 + }, + "inside": { + "before": false, + "after": false + }, + "sun": { + "before": true, + "after": true + }, + "point": { + "before": true, + "after": true + }, + "overlaps": { + "before": false, + "after": false + } + } + }, + "resizeRestore": { + "file": "resizeRestore.png", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "components": { + "mode": { + "before": "off", + "after": "fog" + }, + "ready": { + "before": false, + "after": true + }, + "builds": { + "before": 0, + "after": 1 + }, + "disposedGraphs": { + "before": 0, + "after": 0 + }, + "createdTargets": { + "before": 0, + "after": 1 + }, + "releasedTargets": { + "before": 0, + "after": 0 + }, + "releasedMaterials": { + "before": 0, + "after": 0 + }, + "liveTargets": { + "before": 0, + "after": 1 + }, + "scatteringOnly": { + "before": false, + "after": false + }, + "textures": { + "before": 2, + "after": 10 + }, + "settledRenderFrames": { + "before": 1, + "after": 3 + }, + "stableTextureFrames": { + "before": 1, + "after": 3 + }, + "targetWidth": { + "before": 0, + "after": 640 + }, + "targetHeight": { + "before": 0, + "after": 400 + }, + "targets": { + "before": 0, + "after": 1 + }, + "pixels": { + "before": 0, + "after": 256000 + }, + "steps": { + "before": 0, + "after": 48 + }, + "inside": { + "before": false, + "after": false + }, + "sun": { + "before": true, + "after": true + }, + "point": { + "before": true, + "after": true + }, + "overlaps": { + "before": false, + "after": false + } + } + } + }, + "results": [ + { + "mode": "off", + "pass": true, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "zero", + "pass": true, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "fog", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "inside", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "sunOff", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "pointOff", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "overlap", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "half", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "wallOff", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "blackOff", + "pass": true, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "scatter", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "scatterSunOff", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "scatterPointOff", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "lifecycle", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "lifecycleOff", + "pass": true, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "resizeSmall", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "mode": "resizeRestore", + "pass": true, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "diagnostics": [] + }, + { + "assertion": "zero-density pixel identity", + "pass": true + }, + { + "assertion": "repeated lifecycle restores baseline pixels", + "pass": true + }, + { + "assertion": "renderer texture allocation baseline", + "baselineResources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "restoredResources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "pass": true + }, + { + "assertion": "target/depth resize restores exact pixels", + "pass": true + }, + { + "assertion": "isolated light-scattering pixels", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.958518518518519, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.77553086419753, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + } + ], + "workloadAndCost": { + "sourceSha": "1505e018aa38630b91893ab8265c447833e99a51", + "runId": 37000862324, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "scope": "Coarse host-side pipeline-creation service over each entire boot/capture window. Not per-frame fog time, GPU duration or a performance comparison. Timestamp tracking was disabled. No hardware tier admission.", + "cases": { + "off": { + "transportPixels": 0, + "raySteps": 0, + "rendererTextures": 5, + "bounds": 0, + "declaredDirectionalLights": 1, + "declaredUnshadowedPoints": 1, + "pipelineCounts": { + "lookups": 5, + "creations": 5, + "failures": 0, + "pending": 0, + "uniquePrograms": 4, + "uniquePipelines": 5, + "recordedEvents": 5, + "droppedEvents": 0, + "deviceCreations": 5, + "directCreations": 0 + }, + "pipelineCreationServiceMs": 1.3999999999359716 + }, + "fog": { + "transportPixels": 256000, + "raySteps": 48, + "rendererTextures": 10, + "bounds": 1, + "declaredDirectionalLights": 1, + "declaredUnshadowedPoints": 1, + "pipelineCounts": { + "lookups": 8, + "creations": 8, + "failures": 0, + "pending": 0, + "uniquePrograms": 7, + "uniquePipelines": 8, + "recordedEvents": 8, + "droppedEvents": 0, + "deviceCreations": 8, + "directCreations": 0 + }, + "pipelineCreationServiceMs": 2.500000000029104 + }, + "half": { + "transportPixels": 64000, + "raySteps": 48, + "rendererTextures": 10, + "bounds": 1, + "declaredDirectionalLights": 1, + "declaredUnshadowedPoints": 1, + "pipelineCounts": { + "lookups": 8, + "creations": 8, + "failures": 0, + "pending": 0, + "uniquePrograms": 7, + "uniquePipelines": 8, + "recordedEvents": 8, + "droppedEvents": 0, + "deviceCreations": 8, + "directCreations": 0 + }, + "pipelineCreationServiceMs": 1.900000000023283 + }, + "overlap": { + "transportPixels": 256000, + "raySteps": 48, + "rendererTextures": 10, + "bounds": 2, + "declaredDirectionalLights": 1, + "declaredUnshadowedPoints": 1, + "pipelineCounts": { + "lookups": 8, + "creations": 8, + "failures": 0, + "pending": 0, + "uniquePrograms": 7, + "uniquePipelines": 8, + "recordedEvents": 8, + "droppedEvents": 0, + "deviceCreations": 8, + "directCreations": 0 + }, + "pipelineCreationServiceMs": 2.1000000000058208 + } + } + } +} diff --git a/docs/verification/vq07-progress/1bb878e03/provenance.json b/docs/verification/vq07-progress/1bb878e03/provenance.json new file mode 100644 index 0000000000..ded6bd86b8 --- /dev/null +++ b/docs/verification/vq07-progress/1bb878e03/provenance.json @@ -0,0 +1,43 @@ +{ + "sourceSha": "1bb878e033d29e084b912bc9266a15b72d6cbf16", + "runId": 36989192897, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36989192897", + "artifactId": 11218269895, + "artifactSha256": "8f6a56c6934ccc83fae3e007fbba8510c61e1daacbb796345553a74820866c21", + "imageSha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "case": "off", + "qualified": false, + "status": "Baseline diagnostic progress only. Run failed on one HTTP404 console error; fog-on variants did not execute.", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [ + { + "code": "TN_PLAYTEST_CONSOLE_ERROR", + "message": "1 browser console error(s) were captured during playtest.", + "severity": "error", + "suggestion": "Open console.json in the playtest artifact directory and fix the first runtime error." + } + ] +} diff --git a/docs/verification/vq07-progress/1f73e559c-native/blackOff.png b/docs/verification/vq07-progress/1f73e559c-native/blackOff.png new file mode 100644 index 0000000000..117b78c9f8 Binary files /dev/null and b/docs/verification/vq07-progress/1f73e559c-native/blackOff.png differ diff --git a/docs/verification/vq07-progress/1f73e559c-native/provenance.json b/docs/verification/vq07-progress/1f73e559c-native/provenance.json new file mode 100644 index 0000000000..2ea3ac0b4a --- /dev/null +++ b/docs/verification/vq07-progress/1f73e559c-native/provenance.json @@ -0,0 +1,249 @@ +{ + "repository": "ThreeNativeHQ/threenative", + "sourceSha": "1f73e559c7a8fa01740cc2b5b24ebec8dfc2f4e7", + "pass": false, + "qualification": "Software WebGPU correctness only. Fifteen native arms passed; resize failed. No hardware performance or complete native acceptance claim.", + "runId": 37005493900, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37005493900", + "artifactId": 11226555526, + "artifactSha256": "cc5cc8f973b8edc9f16a188f3ae68f1e0afb6b2f508ee4a392df222191587af5", + "nativeBuild": { + "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "bundleSha256": "d2be9b3d0efa3247ff63c772421f75b77e8ea1fd9b271c4791ec7934f88c3e86", + "buildSourceSha": "1f73e559c7a8fa01740cc2b5b24ebec8dfc2f4e7", + "packageVersion": "0.3.4", + "wgpuNativeVersion": "25.0.2.2" + }, + "capture": { + "adapter": "llvmpipe (LLVM 20.1.2, 128 bits); Mesa 25.2.8-0ubuntu0.24.04.3", + "rendererKind": "webgpu", + "captureMethod": "device.screenshot", + "target": "desktop", + "viewport": { + "width": 640, + "height": 400 + } + }, + "acceptedArms": [ + "off", + "zero", + "fog", + "inside", + "sunOff", + "pointOff", + "overlap", + "half", + "wallOff", + "blackOff", + "scatter", + "scatterSunOff", + "scatterPointOff", + "lifecycle", + "lifecycleOff" + ], + "images": { + "off": { + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "nonblankPixelRatio": 0.99937109375, + "passesMinimum005": true, + "file": "off.png" + }, + "zero": { + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "nonblankPixelRatio": 0.99937109375, + "passesMinimum005": true + }, + "fog": { + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "nonblankPixelRatio": 0.99998828125, + "passesMinimum005": true, + "file": "fog.png" + }, + "inside": { + "sha256": "1bec85d68b3bed2d79b8eeb7e3fd4f3aa4f64140f1e9e44165971c49b123c9bf", + "nonblankPixelRatio": 1, + "passesMinimum005": true, + "file": "inside.png" + }, + "sunOff": { + "sha256": "65050dc6819eb17bef27df1215351c485324c1e0c2fd49014b777292a0fa11c3", + "nonblankPixelRatio": 0.999984375, + "passesMinimum005": true + }, + "pointOff": { + "sha256": "badc6fd442e064320d244cdc24b09487d487af31316215cfb532c2b71c29a6d4", + "nonblankPixelRatio": 0.99893359375, + "passesMinimum005": true + }, + "overlap": { + "sha256": "2ed15c18e59c9936c5d1df7b1816278450b81a2d28cc5b329ef6d4b59b7c1ff6", + "nonblankPixelRatio": 0.99998828125, + "passesMinimum005": true + }, + "half": { + "sha256": "05238af75c2a820556e6360bfa9b5dad467c145b6f8e0408c33cca4c4c3efbd3", + "nonblankPixelRatio": 0.9999921875, + "passesMinimum005": true + }, + "wallOff": { + "sha256": "85487cc1dd56d7baf5e18e4b8ef33befda91ca2c1004cf6877b067a10959531a", + "nonblankPixelRatio": 0.99998828125, + "passesMinimum005": true + }, + "blackOff": { + "sha256": "b3d4f1282739ab51813d412fc278fe6ec8555ab44baaf68e145e4007ca4bc0ea", + "nonblankPixelRatio": 0.05625, + "passesMinimum005": true, + "file": "blackOff.png" + }, + "scatter": { + "sha256": "eca0f87d4e38a72c378753f62cdeaadbdc14901193b33716ebe64daef3e0be26", + "nonblankPixelRatio": 0.23834375, + "passesMinimum005": true, + "file": "scatter.png" + }, + "scatterSunOff": { + "sha256": "d1412a75a5612ff4653692c1c9314102124e135335f17ef0d4daff9857e4be49", + "nonblankPixelRatio": 0.236296875, + "passesMinimum005": true, + "file": "scatterSunOff.png" + }, + "scatterPointOff": { + "sha256": "5b272a565aa3eca8c45b4dea7671f3183d6f91c0f2cde2bf95ced43151b144dc", + "nonblankPixelRatio": 0.23907421875, + "passesMinimum005": true, + "file": "scatterPointOff.png" + }, + "lifecycle": { + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "nonblankPixelRatio": 0.99998828125, + "passesMinimum005": true + }, + "lifecycleOff": { + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "nonblankPixelRatio": 0.99937109375, + "passesMinimum005": true + } + }, + "nonblankMethod": "Existing playtest regionMetrics on decoded PNGs, full frame, luminance >0.01, alpha >0; frozen minimum 0.05. All fifteen pass; minimum 0.05625.", + "postCaptureMetrics": { + "zeroIdentity": true, + "offIdentity": true, + "lights": { + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.957703703703704, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.772123456790126, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + } + }, + "postCaptureMetricMethod": "After downloading and digest-checking the failed-run artifact, decoded actual PNGs were evaluated with fogLightPixelMetrics exported from the exact source verifier; exact pixel identities were compared from decoded bytes. These cross-arm checks did not execute in the hosted script because resize aborted first.", + "resourceSummary": { + "createdTargets": 4, + "releasedTargetsBeforeOff": 3, + "liveTargetsBeforeOff": 1, + "releasedTargetsAfterOff": 4, + "liveTargetsAfterOff": 0, + "matchingMaterialReleaseCounts": true, + "rendererTexturesOffActiveReturnedOff": [5, 10, 5], + "minimumObservedStableRenderIntervals": 3 + }, + "resizeFailure": { + "case": "resizeSmall", + "code": "TN_PLAYTEST_HOST_EXITED", + "surfaceError": "Surface image is already acquired", + "termination": "SIGABRT", + "screenshotPresent": false, + "consoleFilePresent": false + }, + "sameSourceBrowser": { + "artifactId": 11225682361, + "artifactSha256": "5cc58198f77714817c43c486cf8b83d7b7e3ef23730e4010c1026b2918740c35", + "pass": true, + "cases": 17, + "qualification": "Google SwiftShader browser WebGPU software correctness; includes each intermediate graph rendering before replacement, full teardown and portable target/depth resize." + }, + "resizeDiagnosticRevalidation": { + "sourceSha": "7f710b7d3af5e2da18017e4a0f707d4217ddf85d", + "runId": 37011447056, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37011447056", + "artifactId": 11227694772, + "artifactSha256": "8ae66c5a60cba94b1d2b96c771ee2f3f7dbb98472b95ea980a067c83976da2f2", + "nativeBuild": { + "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "bundleSha256": "d2be9b3d0efa3247ff63c772421f75b77e8ea1fd9b271c4791ec7934f88c3e86", + "buildSourceSha": "7f710b7d3af5e2da18017e4a0f707d4217ddf85d", + "packageVersion": "0.3.4", + "wgpuNativeVersion": "25.0.2.2" + }, + "pass": false, + "acceptedNativeArms": 15, + "allAcceptedPngBytesIdenticalToOriginal": true, + "browserCasesPassed": 17, + "resizeControl": { + "pass": false, + "capturedPngs": 0, + "firstErrorCode": "TN_SURFACE_ACQUIRE_FAILED", + "surfaceStatus": 2, + "nextError": "Failed to get current texture", + "correction": "The diagnostic step normalized conclusion was success, but its retained summary.pass was false and the mandatory final guard failed. No control capture passed." + }, + "originalResizeFailure": { + "case": "resizeSmall", + "code": "TN_PLAYTEST_HOST_EXITED", + "surfaceError": "Surface image is already acquired", + "termination": "SIGABRT", + "screenshotPresent": false + }, + "currentDevelopComparison": { + "sha": "924b92f825d602485ef4e682f434b4757a825cd7", + "method": "Connector-read file content compared byte-for-byte against7f710b7d3 checkout before the fix. All five equal.", + "baselineGitBlobs": { + "packages/runtime-native/include/mystral/webgpu_compat.h": "d826bdaeb9850bb3f3632bf50c4542e146d2c5e5", + "packages/runtime-native/src/webgpu/bindings_presentation.cpp": "cbedaa40abf9d7f26b2bad60318dc915b33c8365", + "packages/runtime-native/src/webgpu/bindings_resources.cpp": "d7c15f425ca65d2f4ee2f9bf6b7fa78d8c1092ef", + "packages/runtime-native/src/webgpu/bindings.cpp": "28789db6974dc0fd250e63c4f70ef07091412ad5", + "packages/runtime-native/scripts/download-deps.mjs": "a6cc308a8377e4c7255fe51d4e83e01abf22e31d" + } + } + } +} diff --git a/docs/verification/vq07-progress/4f8fe47b1/provenance.json b/docs/verification/vq07-progress/4f8fe47b1/provenance.json new file mode 100644 index 0000000000..5b212febfb --- /dev/null +++ b/docs/verification/vq07-progress/4f8fe47b1/provenance.json @@ -0,0 +1,577 @@ +{ + "sourceSha": "4f8fe47b1801282827c9b5d0f0cba40b99c7c2a7", + "runId": 36990624761, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36990624761", + "artifactId": 11219149473, + "artifactSha256": "cd497a2b3353c270bca238cc3101f66064eb12c0eb6bf7c88e2d4d1618244c84", + "qualification": "Actual browser WebGPU software-rendered correctness progress. No native or hardware-performance claim. Ownership receipt and remaining lifecycle work still open.", + "images": { + "off": { + "file": "off.png", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "zero": { + "file": "zero.png", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "fog": { + "file": "fog.png", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "inside": { + "file": "inside.png", + "sha256": "433893ec04051f081f42b98911aa744d8e3dd01313bb668bc44294c4dc8cb88f", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "sunOff": { + "file": "sunOff.png", + "sha256": "89cc1d8c5a80146b387b62ac4a12a727cc85b6b4383f5ebe7ad31b7d8f222ee2", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "pointOff": { + "file": "pointOff.png", + "sha256": "b9773b188bae64544605bf0ce3a7f669abd244045d16b6ef4a0b31c3065710d0", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "overlap": { + "file": "overlap.png", + "sha256": "96c723eb30bce2932eda3632b07a02df390759f71b57da1cfe1292ca468e8cd7", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "half": { + "file": "half.png", + "sha256": "c37f6762da0bf849312b17a755da754473a478712fa27f67b945fdef47678123", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + }, + "wallOff": { + "file": "wallOff.png", + "sha256": "764c5c6cf0f217f7e81538b1929a6e9cae55ce2d3ba10656b4f1f6bb3c4151c4", + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [], + "pass": true + } + }, + "zeroDensityPixelsIdentical": true, + "foregroundWallReference": { + "region": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "basis": "Fixed fixture camera projection of foreground wall center; region is interior to its solid face.", + "fogVsOffMeanAbsoluteByteDifference": 0, + "changedPixels": 0 + }, + "halfVsFullMeanAbsoluteByteDifference": 0.06948697916666667, + "receiptHeadRevalidation": { + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "runId": 36994760252, + "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36994760252", + "artifactId": 11220323995, + "artifactSha256": "658c454f4f1888f2b98f9b78054b7f3ae6cebedbe6327372ec844f21a058e71d", + "allNinePngBytesIdentical": true, + "results": [ + { + "mode": "off", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "zero", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "fog", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "inside", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "sunOff", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "pointOff", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "overlap", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "half", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "mode": "wallOff", + "sourceSha": "9b2bcd7a1e9db2aa181a34c2c7fd5a060724138a", + "pass": true, + "capture": { + "adapter": { + "architecture": "swiftshader", + "vendor": "google", + "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query", + "limit.maxBindGroups": "4", + "limit.maxTextureDimension2D": "8192", + "limit.maxStorageBufferBindingSize": "1073741824" + }, + "browserArgs": [ + "--ozone-platform=x11", + "--enable-unsafe-webgpu", + "--disable-gpu-sandbox", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan" + ], + "captureMethod": "page.screenshot", + "rendererKind": "webgpu", + "target": "web", + "viewport": { + "height": 400, + "width": 640 + } + }, + "diagnostics": [] + }, + { + "assertion": "zero-density pixel identity", + "pass": true + } + ] + } +} diff --git a/docs/verification/vq07-progress/640c103-browser/fog.png b/docs/verification/vq07-progress/640c103-browser/fog.png new file mode 100644 index 0000000000..93faf81b38 Binary files /dev/null and b/docs/verification/vq07-progress/640c103-browser/fog.png differ diff --git a/docs/verification/vq07-progress/640c103-browser/provenance.json b/docs/verification/vq07-progress/640c103-browser/provenance.json new file mode 100644 index 0000000000..6ad7f730ac --- /dev/null +++ b/docs/verification/vq07-progress/640c103-browser/provenance.json @@ -0,0 +1,491 @@ +{ + "source": "640c103017d56e56abb56745eb875885c32aec06", + "run": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37046042533", + "artifact": 11244876573, + "artifactSha256": "fc56bf38d6f3dfababbf424471887abd3992ba1ee0614596f046a20a2d2780b4", + "target": "browser WebGPU", + "adapter": "Google SwiftShader", + "qualification": "Software-rendered correctness only. Host frame distributions cover 30 frames; GPU-query distributions contain only 4 resolved samples and 26 stale frames. No hardware performance or tier admission.", + "frames": [ + { + "case": "off", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "zero", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "fog", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "inside", + "sha256": "433893ec04051f081f42b98911aa744d8e3dd01313bb668bc44294c4dc8cb88f", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "sunOff", + "sha256": "89cc1d8c5a80146b387b62ac4a12a727cc85b6b4383f5ebe7ad31b7d8f222ee2", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "pointOff", + "sha256": "b9773b188bae64544605bf0ce3a7f669abd244045d16b6ef4a0b31c3065710d0", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "overlap", + "sha256": "96c723eb30bce2932eda3632b07a02df390759f71b57da1cfe1292ca468e8cd7", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "half", + "sha256": "c37f6762da0bf849312b17a755da754473a478712fa27f67b945fdef47678123", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "wallOff", + "sha256": "764c5c6cf0f217f7e81538b1929a6e9cae55ce2d3ba10656b4f1f6bb3c4151c4", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "blackOff", + "sha256": "8949e59556dd3e14b91756277782d8a98c2fd4af47bc27b232266540c2e1d3c0", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatter", + "sha256": "89df2275163ca948f5ef8b5e18b3f94e57b9e01e5a6f5137dcf2fbe32aece809", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatterSunOff", + "sha256": "b10e9a6bea07b1f01b9b2b239882653d1538c115bc6911c20de95c0d517fe429", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatterPointOff", + "sha256": "d05cd825f2b4779e2abadd111517195bfb2b0fdca593b371992a23b534cd761f", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "lifecycle", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "lifecycleOff", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "resizeSmall", + "sha256": "d7e3e7adbe608ea141c07cdf5cfc86b8869728e7e830f9dd1513ef47c2a154b2", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "resizeRestore", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatterOutside", + "sha256": "89df2275163ca948f5ef8b5e18b3f94e57b9e01e5a6f5137dcf2fbe32aece809", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatterOutsideSunOff", + "sha256": "b10e9a6bea07b1f01b9b2b239882653d1538c115bc6911c20de95c0d517fe429", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "scatterOutsidePointOff", + "sha256": "d05cd825f2b4779e2abadd111517195bfb2b0fdca593b371992a23b534cd761f", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "cameraCut", + "sha256": "433893ec04051f081f42b98911aa744d8e3dd01313bb668bc44294c4dc8cb88f", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "cameraRestore", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "streamWallOut", + "sha256": "764c5c6cf0f217f7e81538b1929a6e9cae55ce2d3ba10656b4f1f6bb3c4151c4", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "streamWallIn", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "sceneReentry", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "sceneRepeatedOff", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "costOff", + "sha256": "fa3486dd2033281d2ec1db552cf46fba7971f4fbe0ab1cea731a5092e55a5935", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "costFull", + "sha256": "0ac40bc66226495a430c379c039abdadff54a867d19c97e81895083b2c64a190", + "pass": true, + "width": 640, + "height": 400 + }, + { + "case": "costHalf", + "sha256": "c37f6762da0bf849312b17a755da754473a478712fa27f67b945fdef47678123", + "pass": true, + "width": 640, + "height": 400 + } + ], + "pixelAndResourceMetrics": [ + { + "assertion": "zero-density pixel identity", + "pass": true + }, + { + "assertion": "repeated lifecycle restores baseline pixels", + "pass": true + }, + { + "assertion": "renderer texture allocation baseline", + "baselineResources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "restoredResources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "pass": true + }, + { + "assertion": "target/depth resize restores exact pixels", + "pass": true + }, + { + "assertion": "cameraCut matches fresh inside pixels", + "pass": true + }, + { + "assertion": "cameraRestore matches fresh fog pixels", + "pass": true + }, + { + "assertion": "streamWallOut matches fresh wallOff pixels", + "pass": true + }, + { + "assertion": "streamWallIn matches fresh fog pixels", + "pass": true + }, + { + "assertion": "sceneReentry matches fresh off pixels", + "pass": true + }, + { + "assertion": "sceneRepeatedOff matches fresh off pixels", + "pass": true + }, + { + "assertion": "isolated light-scattering pixels", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.958518518518519, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.77553086419753, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + }, + { + "assertion": "unshadowed light outside directional map", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.958518518518519, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.77553086419753, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + } + ], + "cost": [ + { + "case": "costOff", + "window": 5, + "hostFrames": 30, + "frameMs": { + "max": 2.1, + "mean": 0.99, + "p50": 0.9, + "p95": 1.8, + "p99": 2.1, + "samples": 30 + }, + "renderMs": { + "max": 2, + "mean": 0.94, + "p50": 0.9, + "p95": 1.8, + "p99": 2, + "samples": 30 + }, + "gpuQueryMs": { + "max": 64.92, + "mean": 64.49, + "p50": 64.83, + "p95": 64.92, + "p99": 64.92, + "samples": 4 + }, + "gpuStaleFrames": 26, + "transportPixels": 0, + "raySteps": 0, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 83.4, + "mean": 67.78, + "p50": 66.7, + "p95": 83.3, + "p99": 83.4, + "samples": 30 + }, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + }, + { + "case": "costFull", + "window": 5, + "hostFrames": 30, + "frameMs": { + "max": 2.6, + "mean": 1.49, + "p50": 1.3, + "p95": 2.6, + "p99": 2.6, + "samples": 30 + }, + "renderMs": { + "max": 2.6, + "mean": 1.42, + "p50": 1.3, + "p95": 2.5, + "p99": 2.6, + "samples": 30 + }, + "gpuQueryMs": { + "max": 355.65, + "mean": 347.17, + "p50": 342.59, + "p95": 355.65, + "p99": 355.65, + "samples": 4 + }, + "gpuStaleFrames": 26, + "transportPixels": 256000, + "raySteps": 48, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 366.7, + "mean": 349.98, + "p50": 350, + "p95": 366.7, + "p99": 366.7, + "samples": 30 + }, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + }, + { + "case": "costHalf", + "window": 5, + "hostFrames": 30, + "frameMs": { + "max": 3.6, + "mean": 1.55, + "p50": 1.4, + "p95": 2.5, + "p99": 3.6, + "samples": 30 + }, + "renderMs": { + "max": 3.6, + "mean": 1.47, + "p50": 1.3, + "p95": 2.5, + "p99": 3.6, + "samples": 30 + }, + "gpuQueryMs": { + "max": 211.8, + "mean": 208.02, + "p50": 205.92, + "p95": 211.8, + "p99": 211.8, + "samples": 4 + }, + "gpuStaleFrames": 26, + "transportPixels": 64000, + "raySteps": 48, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 233.4, + "mean": 213.33, + "p50": 216.6, + "p95": 233.4, + "p99": 233.4, + "samples": 30 + }, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + } + ] +} diff --git a/docs/verification/vq07-progress/640c103-native/provenance.json b/docs/verification/vq07-progress/640c103-native/provenance.json new file mode 100644 index 0000000000..3d65df597d --- /dev/null +++ b/docs/verification/vq07-progress/640c103-native/provenance.json @@ -0,0 +1,880 @@ +{ + "source": "640c103017d56e56abb56745eb875885c32aec06", + "run": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37046042533", + "artifact": 11244629357, + "artifactSha256": "003e4ee6e591d136d6c61abbad74b75605b6b11dc3e14afbf61831d1bc297710", + "target": "Linux ARM64 QuickJS / wgpu-native", + "adapter": "Mesa llvmpipe (LLVM 20.1.2, 128 bits), Mesa 25.2.8", + "captureMethod": "device.screenshot", + "runtimeAndBundle": { + "runtimeSha256": "5a164cc2ec42e30b8597fb40abb125540aa73b37119dc2158f34f40a8589665b", + "bundleSha256": "826fb0bc5dad56523e2768c4a24a12e7f78d9a3f303a573bca7be66bb61fa08d" + }, + "qualification": "Software-rendered correctness only. No phone, hardware-performance or tier-admission claim. Thirty-frame host windows contain only 3-4 resolved GPU samples; remaining stale frames are explicit below.", + "diagnosticsEvidence": { + "required": "Nonempty actual host console with first-frame marker and no runtime/GPU errors, plus asserted startup and authored resource predicates.", + "applicationSnapshotDiagnostics": "Not provided by the standard core plugin; a clean application diagnostic channel is not claimed." + }, + "nativeHostContracts": { + "surfaceStatus": "passed compiled ordinary-host status contract", + "destinationPixels": "passed actual GPU readback for larger/equal/smaller linear and sRGB destinations", + "noFogResizeControl": true + }, + "frames": [ + { + "case": "off", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 4, + "stableTextureFrames": 4 + } + }, + { + "case": "zero", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "fog", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "inside", + "sha256": "1bec85d68b3bed2d79b8eeb7e3fd4f3aa4f64140f1e9e44165971c49b123c9bf", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "sunOff", + "sha256": "65050dc6819eb17bef27df1215351c485324c1e0c2fd49014b777292a0fa11c3", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "pointOff", + "sha256": "badc6fd442e064320d244cdc24b09487d487af31316215cfb532c2b71c29a6d4", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "overlap", + "sha256": "2ed15c18e59c9936c5d1df7b1816278450b81a2d28cc5b329ef6d4b59b7c1ff6", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "half", + "sha256": "05238af75c2a820556e6360bfa9b5dad467c145b6f8e0408c33cca4c4c3efbd3", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "wallOff", + "sha256": "85487cc1dd56d7baf5e18e4b8ef33befda91ca2c1004cf6877b067a10959531a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "blackOff", + "sha256": "b3d4f1282739ab51813d412fc278fe6ec8555ab44baaf68e145e4007ca4bc0ea", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatter", + "sha256": "eca0f87d4e38a72c378753f62cdeaadbdc14901193b33716ebe64daef3e0be26", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatterSunOff", + "sha256": "d1412a75a5612ff4653692c1c9314102124e135335f17ef0d4daff9857e4be49", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatterPointOff", + "sha256": "5b272a565aa3eca8c45b4dea7671f3183d6f91c0f2cde2bf95ced43151b144dc", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "lifecycle", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "lifecycleOff", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "resizeSmall", + "sha256": "4637e18bd82a08c8763cbd78f2589e5e429597612af661cb51100cbafe63bc61", + "pass": true, + "width": 320, + "height": 240, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "resizeRestore", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatterOutside", + "sha256": "eca0f87d4e38a72c378753f62cdeaadbdc14901193b33716ebe64daef3e0be26", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatterOutsideSunOff", + "sha256": "d1412a75a5612ff4653692c1c9314102124e135335f17ef0d4daff9857e4be49", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "scatterOutsidePointOff", + "sha256": "5b272a565aa3eca8c45b4dea7671f3183d6f91c0f2cde2bf95ced43151b144dc", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "cameraCut", + "sha256": "1bec85d68b3bed2d79b8eeb7e3fd4f3aa4f64140f1e9e44165971c49b123c9bf", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "cameraRestore", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "streamWallOut", + "sha256": "85487cc1dd56d7baf5e18e4b8ef33befda91ca2c1004cf6877b067a10959531a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "streamWallIn", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "sceneReentry", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "sceneRepeatedOff", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "costOff", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 90, + "stableTextureFrames": 90 + } + }, + { + "case": "costFull", + "sha256": "95e2d1a14559df4a6778916e3192d15127d59e4e83b9a76f433e7c829add464a", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 90, + "stableTextureFrames": 90 + } + }, + { + "case": "costHalf", + "sha256": "05238af75c2a820556e6360bfa9b5dad467c145b6f8e0408c33cca4c4c3efbd3", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 10, + "settledRenderFrames": 90, + "stableTextureFrames": 90 + } + }, + { + "case": "resizeOff", + "sha256": "f5a4c3cd3b08b6acd50f966317688a0017fd66ad6fd655aa6970135e16f1b6fe", + "pass": true, + "width": 320, + "height": 240, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + }, + { + "case": "resizeOffRestore", + "sha256": "340699e0cb4c8740fd083df7b0ed1484e3364efae2b6ce17f0e117f1e73e9d15", + "pass": true, + "width": 640, + "height": 400, + "resources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + } + } + ], + "pixelAndResourceMetrics": [ + { + "assertion": "off authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "zero authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "fog authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "inside authored whole-frame nonblank ratio", + "darkPixelRatio": 0.17884765625, + "nonblankPixelRatio": 1, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "sunOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.92972265625, + "nonblankPixelRatio": 0.999984375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "pointOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.65212890625, + "nonblankPixelRatio": 0.99893359375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "overlap authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62975, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "half authored whole-frame nonblank ratio", + "darkPixelRatio": 0.6297734375, + "nonblankPixelRatio": 0.9999921875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "wallOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62996484375, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "blackOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.94375, + "nonblankPixelRatio": 0.05625, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatter authored whole-frame nonblank ratio", + "darkPixelRatio": 0.86383203125, + "nonblankPixelRatio": 0.23834375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatterSunOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.88630078125, + "nonblankPixelRatio": 0.236296875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatterPointOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.9053125, + "nonblankPixelRatio": 0.23907421875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "lifecycle authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "lifecycleOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "resizeSmall authored whole-frame nonblank ratio", + "darkPixelRatio": 0.5757421875, + "nonblankPixelRatio": 0.9999869791666667, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "resizeRestore authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatterOutside authored whole-frame nonblank ratio", + "darkPixelRatio": 0.86383203125, + "nonblankPixelRatio": 0.23834375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatterOutsideSunOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.88630078125, + "nonblankPixelRatio": 0.236296875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "scatterOutsidePointOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.9053125, + "nonblankPixelRatio": 0.23907421875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "cameraCut authored whole-frame nonblank ratio", + "darkPixelRatio": 0.17884765625, + "nonblankPixelRatio": 1, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "cameraRestore authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "streamWallOut authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62996484375, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "streamWallIn authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "sceneReentry authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "sceneRepeatedOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "costOff authored whole-frame nonblank ratio", + "darkPixelRatio": 0.67553515625, + "nonblankPixelRatio": 0.99937109375, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "costFull authored whole-frame nonblank ratio", + "darkPixelRatio": 0.62978515625, + "nonblankPixelRatio": 0.99998828125, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "costHalf authored whole-frame nonblank ratio", + "darkPixelRatio": 0.6297734375, + "nonblankPixelRatio": 0.9999921875, + "minimum": 0.05, + "pass": true + }, + { + "assertion": "zero-density pixel identity", + "pass": true + }, + { + "assertion": "repeated lifecycle restores baseline pixels", + "pass": true + }, + { + "assertion": "renderer texture allocation baseline", + "baselineResources": { + "textures": 5, + "settledRenderFrames": 4, + "stableTextureFrames": 4 + }, + "restoredResources": { + "textures": 5, + "settledRenderFrames": 3, + "stableTextureFrames": 3 + }, + "pass": true + }, + { + "assertion": "target/depth resize restores exact pixels", + "pass": true + }, + { + "assertion": "cameraCut matches fresh inside pixels", + "pass": true + }, + { + "assertion": "cameraRestore matches fresh fog pixels", + "pass": true + }, + { + "assertion": "streamWallOut matches fresh wallOff pixels", + "pass": true + }, + { + "assertion": "streamWallIn matches fresh fog pixels", + "pass": true + }, + { + "assertion": "sceneReentry matches fresh off pixels", + "pass": true + }, + { + "assertion": "sceneRepeatedOff matches fresh off pixels", + "pass": true + }, + { + "assertion": "isolated light-scattering pixels", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.957703703703704, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.772123456790126, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + }, + { + "assertion": "unshadowed light outside directional map", + "baselineMaxRgb": 0, + "wallMaxRgb": 0, + "roi": { + "x": 260, + "y": 155, + "width": 150, + "height": 90 + }, + "wallRoi": { + "x": 184, + "y": 216, + "width": 18, + "height": 18 + }, + "evaluationRoi": { + "x": 0, + "y": 0, + "width": 500, + "height": 400 + }, + "calibrationRoi": { + "x": 515, + "y": 25, + "width": 110, + "height": 110 + }, + "calibrationMinimum": 146, + "calibrationMaxDifference": 0, + "sun": { + "meanRgbDelta": 15.957703703703704, + "changedPixelRatio": 0.9933333333333333 + }, + "point": { + "meanRgbDelta": 48.772123456790126, + "changedPixelRatio": 1 + }, + "thresholds": { + "minMeanRgbDelta": 1, + "minChangedPixelRatio": 0.1, + "maxBlackRgb": 0, + "minCalibrationRgb": 32, + "maxCalibrationDifference": 0 + }, + "pass": true + } + ], + "cost": [ + { + "case": "costOff", + "window": 19, + "hostFrames": 30, + "frameMs": { + "max": 14.75, + "mean": 4.56, + "p50": 3.09, + "p95": 14.47, + "p99": 14.75, + "samples": 30 + }, + "renderMs": { + "max": 3.1, + "mean": 2.96, + "p50": 2.97, + "p95": 3.08, + "p99": 3.1, + "samples": 30 + }, + "gpuQueryMs": { + "max": 10.93, + "mean": 10.61, + "p50": 10.31, + "p95": 10.93, + "p99": 10.93, + "samples": 4 + }, + "gpuStaleFrames": 26, + "transportPixels": 0, + "raySteps": 0, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 22.05, + "mean": 16.67, + "p50": 16.65, + "p95": 21.82, + "p99": 22.05, + "samples": 30 + }, + "nativePresents": 30, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + }, + { + "case": "costFull", + "window": 6, + "hostFrames": 30, + "frameMs": { + "max": 65.04, + "mean": 11.84, + "p50": 3.88, + "p95": 63.37, + "p99": 65.04, + "samples": 30 + }, + "renderMs": { + "max": 3.93, + "mean": 3.77, + "p50": 3.77, + "p95": 3.91, + "p99": 3.93, + "samples": 30 + }, + "gpuQueryMs": { + "max": 60.45, + "mean": 59.12, + "p50": 58.76, + "p95": 60.45, + "p99": 60.45, + "samples": 4 + }, + "gpuStaleFrames": 26, + "transportPixels": 256000, + "raySteps": 48, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 72.98, + "mean": 67.16, + "p50": 64.75, + "p95": 72.81, + "p99": 72.98, + "samples": 30 + }, + "nativePresents": 30, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + }, + { + "case": "costHalf", + "window": 9, + "hostFrames": 30, + "frameMs": { + "max": 37.46, + "mean": 7.17, + "p50": 3.86, + "p95": 36.88, + "p99": 37.46, + "samples": 30 + }, + "renderMs": { + "max": 3.93, + "mean": 3.76, + "p50": 3.76, + "p95": 3.91, + "p99": 3.93, + "samples": 30 + }, + "gpuQueryMs": { + "max": 32.93, + "mean": 32.52, + "p50": 32.5, + "p95": 32.93, + "p99": 32.93, + "samples": 3 + }, + "gpuStaleFrames": 27, + "transportPixels": 64000, + "raySteps": 48, + "directionalLights": 1, + "unshadowedPointLights": 1, + "presentedMs": { + "max": 47.11, + "mean": 39.87, + "p50": 37.46, + "p95": 46.99, + "p99": 47.11, + "samples": 30 + }, + "nativePresents": 30, + "cadenceQualification": "Engine-measured frame interval on a virtual capture display; not a hardware display-rate or tier-admission result." + } + ] +} diff --git a/docs/verification/vq07-progress/640c103-native/resizeRestore.png b/docs/verification/vq07-progress/640c103-native/resizeRestore.png new file mode 100644 index 0000000000..c24d7d8ce1 Binary files /dev/null and b/docs/verification/vq07-progress/640c103-native/resizeRestore.png differ diff --git a/examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json b/examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json new file mode 100644 index 0000000000..dd6d2fdd64 --- /dev/null +++ b/examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json @@ -0,0 +1,193 @@ +{ + "schemaVersion": 1, + "name": "vq-volumetric-fog-lifecycle", + "target": "web", + "viewport": { + "width": 640, + "height": 400 + }, + "warmupFrames": 60, + "steps": [ + { + "press": ["KeyF"], + "holdTicks": 1, + "release": true, + "label": "install-fog-baseline" + }, + { + "kind": "wait", + "waitFrames": 60 + }, + { + "waitForResource": { + "id": "state", + "path": "stableTextureFrames", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true, + "label": "initial-fog-rendered" + }, + { + "press": ["KeyI"], + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "waitFrames": 20 + }, + { + "waitForResource": { + "id": "state", + "path": "stableTextureFrames", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true, + "label": "inside-rendered" + }, + { + "press": ["KeyO"], + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "waitFrames": 20 + }, + { + "waitForResource": { + "id": "state", + "path": "stableTextureFrames", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true, + "label": "off-rendered" + }, + { + "press": ["KeyF"], + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "waitFrames": 20 + }, + { + "waitForResource": { + "id": "state", + "path": "stableTextureFrames", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true, + "label": "fog-return-rendered" + }, + { + "press": ["KeyC"], + "holdTicks": 1, + "release": true + }, + { + "kind": "wait", + "waitFrames": 60 + }, + { + "waitForResource": { + "id": "state", + "path": "stableTextureFrames", + "gte": 3 + }, + "timeoutMs": 30000, + "release": true, + "label": "rebuild-rendered" + } + ], + "assert": { + "components": [ + { + "entity": "fog", + "component": "ready", + "equals": true, + "allowTrivial": "The graph must be ready after repeated reconstruction." + }, + { + "entity": "fog", + "component": "mode", + "equals": "fog", + "allowTrivial": "The fixture returns to the initial authored medium." + }, + { + "entity": "fog", + "component": "targets", + "equals": 1, + "allowTrivial": "Exactly the current fog RTT remains after replacement." + }, + { + "entity": "fog", + "component": "disposedGraphs", + "gte": 4, + "changed": true + }, + { + "entity": "fog", + "component": "createdTargets", + "equals": 4, + "allowTrivial": "Observe actual owned teardown events and the live target balance after reconstruction." + }, + { + "entity": "fog", + "component": "releasedTargets", + "equals": 3, + "allowTrivial": "Observe actual owned teardown events and the live target balance after reconstruction." + }, + { + "entity": "fog", + "component": "releasedMaterials", + "equals": 3, + "allowTrivial": "Observe actual owned teardown events and the live target balance after reconstruction." + }, + { + "entity": "fog", + "component": "liveTargets", + "equals": 1, + "allowTrivial": "Observe actual owned teardown events and the live target balance after reconstruction." + }, + { + "entity": "fog", + "component": "settledRenderFrames", + "gte": 3, + "allowTrivial": "Resource counts must be observed stable over completed rendered frames." + }, + { + "entity": "fog", + "component": "stableTextureFrames", + "gte": 3, + "allowTrivial": "Resource counts must be observed stable over completed rendered frames." + } + ], + "diagnostics": { + "noConsoleErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 640, + "height": 400, + "minNonblankPixelRatio": 0.05 + } + } + ] + }, + "artifacts": { + "screenshots": "after", + "console": true, + "runtimeTrace": true + } +} diff --git a/examples/abyss-framework/vq-fog/README.md b/examples/abyss-framework/vq-fog/README.md new file mode 100644 index 0000000000..6e262db1d3 --- /dev/null +++ b/examples/abyss-framework/vq-fog/README.md @@ -0,0 +1,61 @@ +# Bounded volumetric-fog fixture + +This opt-in fixture uses the starter's generated `volumetricFog.ts` and `WorldEnvironment` directly. The default game and template picture are unchanged. The fixture starts with fog off, warms the ordinary directional shadow map, and uses the named keyboard variants below. + +- F: bounded height fog, 48 steps, full resolution +- O / Z: off / zero density (both own zero fog targets) +- I: camera inside the bounds +- S / P: directional / point-light intensity off +- B: overlapping density bounds +- H: half-resolution fog with depth-discontinuity fallback +- W: remove the foreground wall so current depth changes +- C: dispose and rebuild the current graph +- R / T: resize the existing render target to 320×240 / restore 640×400 +- L / K / J: scattering-only black-surface control, then directional / point light off +- N: black no-fog control (exact zero RGB in the 500×400 fog evaluation area) +- 1 / 2 / 3: scattering with the shadow map entirely away from the volume, then sun / point off +- 4 / 5: cut the camera inside / restore outside without replacing the fog controller +- 6 / 7: remove / reinsert the foreground wall without replacing the fog controller +- 8: exit and re-enter through the real scene owner, returning to off + +The four scattering controls share a visible gradient calibration card outside the measured fog area, with an identical-pixel check, so a working black control still satisfies the runner's normal nonblank guard. + +The medium replaces scene fog, aerial haze and god rays. The only local light admitted here is an unshadowed finite-range point source. No temporal history is retained. The source rejects orthographic, logarithmic and reversed-depth configurations rather than pretending they are qualified. + +## Browser capture + +Run from the repository root: + +```sh +pnpm --filter @threenative/playtest build +pnpm --filter abyss-framework exec vite build --config vq-fog/vite.config.ts +node --import tsx scripts/verify-volumetric-fog.ts +``` + +The existing public runner provides headed Chromium, a private Xvfb, canvas captures and adapter provenance. The feature-specific hosted integration workflow invokes these commands on Ubuntu 24.04 and preserves PNGs, reports, observations and the tested source SHA under `artifacts/volumetric-fog/`. Software adapters qualify correctness only. A device-loss warning invalidates the result even if the runner reports a pass. The verifier checks zero-density identity, isolated directional/point scattering in a pinned room region with an exact-black foreground plate, actual teardown events over repeated graph replacement, and return-to-off pixel identity. It retains named light identities and source/adapter provenance. Foreground-wall/shaft appearance also requires inspecting the actual captures. Do not treat the source build or WGSL generation test as runtime proof. + +## Native entry and lifecycle + +`src/game.ts` is the same portable default-exported game for both runtimes; `package.json` names it for existing project-mode conformance: + +```sh +pnpm parity --project examples/abyss-framework/vq-fog --target desktop +``` + +The reusable repeated-lifecycle scenario is `../playtests/vq-volumetric-fog.playtest.json`. Portable target/depth resize scenarios retain the same fog controller and require exact restored pixels. Allocation comparisons require unchanged texture counts over at least three observed completed render frames, not just elapsed time. These do not establish operating-system window resize behavior. + +Run 37046042533 at source `640c103017d56e56abb56745eb875885c32aec06` passes all 29 browser and 29 Linux ARM64 native arms, both no-fog resize controls, and the actual GPU destination-pixel contract. Native resize follows 320×240 and restores 640×400 without replacing the controller, with exact restored pixels and settled allocation baseline. The PRD retains both earlier failure history and these real native PNGs, source/runtime/bundle identities and scoped measurements. Browser SwiftShader and native Mesa llvmpipe qualify software-rendered correctness; no hardware-performance or default-tier claim is made. + + +The same verifier can use an already-built Linux desktop host: + +```sh +THREENATIVE_RUNTIME_BINARY=/absolute/path/to/mystral node --import tsx scripts/verify-volumetric-fog.ts +``` + +It bundles the same `src/game.ts`, uses the public desktop mailbox runner, and retains host/game hashes plus native screenshots under `artifacts/volumetric-fog-native/`. Native scenarios use resources/startup; unsupported browser diagnostics/visual assertion families are replaced by mandatory native host-console/readiness/provenance checks and the same external pixel gates. Native `renderer.setSize` changes the backing surface, so the small-target PNG is 320×240; the restored PNG remains 640×400. The integration workflow's native job reuses the normal ARM Linux host setup after browser proof passes. A separate `VQ_FOG_RESIZE_CONTROL=1` invocation isolates the same renderer/scene resize path with fog off and requires zero fog allocations. It writes `artifacts/volumetric-fog-native-resize-control/`. Both the diagnostic control and the unchanged original sequence are required by the native workflow. The external native guard now applies the same authored 5% whole-frame nonblank threshold using the existing playtest pixel metric; the native runner’s generic dark-frame exemption does not replace that assertion. + + +The verifier preserves the original seventeen arms and appends twelve qualification arms. Retained camera/occluder transitions must match freshly constructed reference pixels exactly, and repeated scene re-entry must restore the off image and renderer allocation baseline. Dedicated off/full/half cost arms wait for ninety actual observed render intervals and record a clean thirty-frame window from the existing `TN_FRAME_BUDGET` meter. Host frame/render durations are measured separately from GPU timing; an absent timestamp observation is not zero and software-adapter cost does not qualify a hardware tier. These added arms passed on both targets in run 37046042533. CPU frame/render, GPU-query and presented-interval distributions are separate; GPU samples are sparse and stale counts are explicit in provenance. + +The subsequent latest-develop reconciliation retains these qualified component bytes but is awaiting exact-head hosted requalification; source equivalence alone is not a new runtime pass. diff --git a/examples/abyss-framework/vq-fog/index.html b/examples/abyss-framework/vq-fog/index.html new file mode 100644 index 0000000000..9932a858a4 --- /dev/null +++ b/examples/abyss-framework/vq-fog/index.html @@ -0,0 +1,2 @@ + +VQ07 bounded fog fixture diff --git a/examples/abyss-framework/vq-fog/package.json b/examples/abyss-framework/vq-fog/package.json new file mode 100644 index 0000000000..be5ced5381 --- /dev/null +++ b/examples/abyss-framework/vq-fog/package.json @@ -0,0 +1,6 @@ +{ + "name": "vq-volumetric-fog-fixture", + "private": true, + "type": "module", + "threenative": { "nativeEntry": "src/game.ts" } +} diff --git a/examples/abyss-framework/vq-fog/src/game.ts b/examples/abyss-framework/vq-fog/src/game.ts new file mode 100644 index 0000000000..0f91cb1792 --- /dev/null +++ b/examples/abyss-framework/vq-fog/src/game.ts @@ -0,0 +1,430 @@ +import { + Box3, + BoxGeometry, + Color, + DirectionalLight, + Float32BufferAttribute, + Group, + HemisphereLight, + Matrix4, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + type PerspectiveCamera, + PlaneGeometry, + PointLight, + Vector3, + WebGPUCoordinateSystem, +} from "three"; +import { Scene as GameScene, type ICtx, defineGame } from "../../../../packages/core/src/index.js"; +import { playtest } from "../../../../packages/core/src/playtest.js"; +import { createVolumetricFog } from "../../../../packages/create-threenative/templates/starter/src/render/volumetricFog.js"; +import { WorldEnvironment } from "../../../../packages/create-threenative/templates/starter/src/render/worldEnvironment.js"; + +export const ROOM_BOUNDS = new Box3(new Vector3(-4.5, 0, -9.5), new Vector3(4.5, 4, 3)); + +// Coverage, not intent: does the fog volume reach this light's shadow-map bounds? A bounding volume +// can enclose the whole shadow box with no corner of itself inside it, so sampling corners answers +// `false` for an overlap that is really there. Project the volume by the shadow camera's own +// view-projection and intersect the conservative clip-space AABB — never false "outside". The depth +// range is the camera's, not a constant: Three maps near/far to 0..1 on WebGPU and -1..1 on WebGL. +export function fogInsideShadowMap(light: DirectionalLight, bounds: Box3): boolean { + const camera = light.shadow.camera; + camera.updateMatrixWorld(true); + const viewProjection = new Matrix4().multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse, + ); + const clip = new Box3( + new Vector3(-1, -1, camera.coordinateSystem === WebGPUCoordinateSystem ? 0 : -1), + new Vector3(1, 1, 1), + ); + return bounds.clone().applyMatrix4(viewProjection).intersectsBox(clip); +} + +const initialState = { + mode: "off", + ready: false, + builds: 0, + disposedGraphs: 0, + createdTargets: 0, + releasedTargets: 0, + releasedMaterials: 0, + liveTargets: 0, + scatteringOnly: false, + textures: -1, + settledRenderFrames: 0, + stableTextureFrames: 0, + targetWidth: 0, + targetHeight: 0, + targets: 0, + pixels: 0, + steps: 0, + inside: false, + sun: true, + point: true, + overlaps: false, + shadowOutside: false, + streamedWall: false, + sceneEntries: 0, + sceneExits: 0, + exitReleasedTargets: 0, + exitReleasedMaterials: 0, +}; +type FogState = typeof initialState; +type FogCtx = ICtx; +interface IProbeInfo { + render: { calls: number }; + memory: { textures: number }; +} +const modes = [ + "fog", + "off", + "zero", + "inside", + "sunOff", + "pointOff", + "overlap", + "half", + "wallOff", + "scatter", + "scatterSunOff", + "scatterPointOff", + "blackOff", + "scatterOutside", + "scatterOutsideSunOff", + "scatterOutsidePointOff", +] as const; +type Mode = (typeof modes)[number]; + +export class FogProbe extends GameScene { + static override readonly initialState = initialState; + #geometry = new BoxGeometry(); + #material = new MeshStandardMaterial({ color: 0x7c8a91, roughness: 0.85 }); + #black = new MeshBasicMaterial({ color: 0x000000 }); + #background = new Color(0x131e2a); + #blackBackground = new Color(0x000000); + #calibration = new Mesh(new PlaneGeometry(1, 1), new MeshBasicMaterial({ vertexColors: true })); + #sun = new DirectionalLight(0xffedce, 3); + #point = new PointLight(0xffad60, 30, 8); + #wall: Mesh | undefined; + #fog: ReturnType; + #release: (() => void) | undefined; + #mode: Mode = "off"; + #cameraCut = false; + #streamedWall = false; + #applied: Mode | undefined; + #builds = 0; + #disposedGraphs = 0; + #createdTargets = 0; + #releasedTargets = 0; + #releasedMaterials = 0; + #lastRenderCalls = -1; + #lastTextures = -1; + #settledRenderFrames = 0; + #stableTextureFrames = 0; + + override enter(ctx: FogCtx): void { + ctx.state.set({ sceneEntries: ctx.state.getState().sceneEntries + 1 }); + ctx.scene.background = this.#background; + ctx.scene.fog = null; + // Same calibration card in every scattering-control arm, outside the measured room ROI. + // It satisfies the ordinary nonblank capture guard without altering any proof threshold. + this.#calibration.name = "fog-calibration"; + this.#calibration.material.allowOverride = false; + this.#calibration.geometry.setAttribute( + "color", + new Float32BufferAttribute([0.3, 0.3, 0.3, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.9, 0.9, 0.9], 3), + ); + // This card must cost zero draws outside the scattering controls, whose arms assert renderer draw + // and texture counts; a zero-opacity visible card would add a draw to every measured arm. + // engine-override: a hidden calibration reference, never a rendered surface to prewarm + this.#calibration.visible = false; + ctx.camera.add(this.#calibration); + ctx.add(ctx.camera); + const box = (position: Vector3, scale: Vector3): Mesh => { + const mesh = new Mesh(this.#geometry, this.#material); + mesh.position.copy(position); + mesh.scale.copy(scale); + mesh.castShadow = true; + mesh.receiveShadow = true; + ctx.add(mesh); + return mesh; + }; + box(new Vector3(0, -0.2, -3), new Vector3(14, 0.4, 18)); + box(new Vector3(0, 2, -10), new Vector3(10, 4, 0.3)); + box(new Vector3(-5, 2, -5), new Vector3(0.3, 4, 10)); + box(new Vector3(-2.9, 4, -5), new Vector3(4, 0.3, 10)); + box(new Vector3(3.6, 4, -5), new Vector3(2.8, 0.3, 10)); + box(new Vector3(-1.4, 1, -5), new Vector3(1, 2, 1)); + this.#wall = box(new Vector3(0.4, 1.6, 3.6), new Vector3(2, 3.2, 0.4)); + this.#wall.name = "fog-wall"; + this.#sun.name = "fog-directional"; + this.#point.name = "fog-point"; + this.#sun.position.set(-3, 7, 1); + this.#sun.target.position.set(0, 0, -5); + this.#sun.castShadow = true; + Object.assign(this.#sun.shadow.camera, { + left: -9, + right: 9, + top: 9, + bottom: -9, + near: 0.1, + far: 30, + }); + this.#sun.shadow.mapSize.set(512, 512); + this.#sun.shadow.bias = -0.0002; + this.#point.position.set(2, 1.3, -3); + ctx.add(this.#sun); + ctx.add(this.#sun.target); + ctx.add(this.#point); + ctx.add(new HemisphereLight(0xb3cced, 0x201d1a, 0.35)); + const raw = ctx.renderer.raw as { shadowMap: { enabled: boolean } }; + raw.shadowMap.enabled = true; + ctx.entities.add("fog", { object: new Group(), debug: () => ({ ...ctx.state.getState() }) }); + this.#positionCamera(ctx); + } + + override update(ctx: FogCtx): void { + if (ctx.input.justPressed("reenter")) { + ctx.goto("fog"); + return; + } + for (const action of ["cameraCut", "cameraRestore", "streamWallOut", "streamWallIn"]) { + if (!ctx.input.justPressed(action)) continue; + if (action === "cameraCut" || action === "cameraRestore") + this.#cameraCut = action === "cameraCut"; + else { + this.#streamedWall = action === "streamWallOut"; + if (this.#streamedWall) this.#wall?.removeFromParent(); + else if (this.#wall !== undefined) ctx.add(this.#wall); + } + this.#resetObservation(ctx); + } + for (const mode of modes) if (ctx.input.justPressed(mode)) this.#mode = mode; + if (ctx.input.justPressed("rebuild")) this.#applied = undefined; + if (ctx.input.justPressed("resizeSmall")) this.#resize(ctx, 320, 240); + if (ctx.input.justPressed("resizeRestore")) this.#resize(ctx, 640, 400); + this.#positionCamera(ctx); + const scatteringOnly = this.#mode.startsWith("scatter") || this.#mode === "blackOff"; + ctx.scene.overrideMaterial = scatteringOnly ? this.#black : null; + this.#calibration.visible = scatteringOnly; + ctx.scene.background = scatteringOnly ? this.#blackBackground : this.#background; + const outsideShadowMap = this.#mode.startsWith("scatterOutside"); + Object.assign( + this.#sun.shadow.camera, + outsideShadowMap + ? { left: 30, right: 31, top: 31, bottom: 30 } + : { left: -9, right: 9, top: 9, bottom: -9 }, + ); + this.#sun.shadow.camera.updateProjectionMatrix(); + this.#sun.intensity = this.#mode === "sunOff" || this.#mode.endsWith("SunOff") ? 0 : 3; + this.#point.intensity = this.#mode === "pointOff" || this.#mode.endsWith("PointOff") ? 0 : 30; + if (this.#wall !== undefined) this.#wall.visible = this.#mode !== "wallOff"; + // Warm the ordinary directional shadow map with the first real scene frame before composing. + if (this.#applied !== this.#mode && this.#sun.shadow.map !== null) this.#compose(ctx); + const observation = this.#fog?.diagnostics(); + const info = ctx.renderer.info as IProbeInfo; + // Three's info.frame is driven by its Animation loop; this game owns the loop instead. + // A new cumulative render.calls value at update means the previous render interval returned. + // Count one observation boundary regardless of the number of internal passes in that interval. + if (info.render.calls !== this.#lastRenderCalls) { + this.#lastRenderCalls = info.render.calls; + this.#settledRenderFrames += 1; + this.#stableTextureFrames = + info.memory.textures === this.#lastTextures ? this.#stableTextureFrames + 1 : 1; + this.#lastTextures = info.memory.textures; + } + ctx.state.set({ + mode: this.#mode, + ready: this.#applied === this.#mode, + builds: this.#builds, + disposedGraphs: this.#disposedGraphs, + createdTargets: this.#createdTargets, + releasedTargets: this.#releasedTargets, + releasedMaterials: this.#releasedMaterials, + liveTargets: this.#createdTargets - this.#releasedTargets, + scatteringOnly, + textures: info.memory.textures, + settledRenderFrames: this.#settledRenderFrames, + stableTextureFrames: this.#stableTextureFrames, + targetWidth: this.#fog?.target?.width ?? 0, + targetHeight: this.#fog?.target?.height ?? 0, + targets: observation?.renderTargets ?? 0, + pixels: observation?.pixels ?? 0, + steps: observation?.steps ?? 0, + inside: this.#mode === "inside" || this.#cameraCut, + shadowOutside: !fogInsideShadowMap(this.#sun, ROOM_BOUNDS), + streamedWall: this.#streamedWall, + sun: this.#sun.intensity > 0, + point: this.#point.intensity > 0, + overlaps: this.#mode === "overlap", + }); + } + + #positionCamera(ctx: FogCtx): void { + if (this.#mode === "inside" || this.#cameraCut) ctx.camera.position.set(0, 1.5, -1); + else ctx.camera.position.set(6.5, 3, 11); + ctx.camera.lookAt(0, 1.4, -5); + const camera = ctx.camera as PerspectiveCamera; + const halfHeight = Math.tan((camera.fov * Math.PI) / 360); + const halfWidth = halfHeight * camera.aspect; + this.#calibration.position.set( + ((570 / 640) * 2 - 1) * halfWidth, + (1 - (80 / 400) * 2) * halfHeight, + -1, + ); + this.#calibration.scale.set((120 / 640) * 2 * halfWidth, (120 / 400) * 2 * halfHeight, 1); + } + + #resetObservation(ctx: FogCtx): void { + this.#lastRenderCalls = (ctx.renderer.info as IProbeInfo).render.calls; + this.#lastTextures = -1; + this.#settledRenderFrames = 0; + this.#stableTextureFrames = 0; + } + + #resize(ctx: FogCtx, width: number, height: number): void { + ctx.renderer.setSize(width, height); + const camera = ctx.camera as PerspectiveCamera; + camera.aspect = width / height; + camera.updateProjectionMatrix(); + this.#resetObservation(ctx); + } + + #compose(ctx: FogCtx): void { + this.#resetObservation(ctx); + this.#disposeGraph(); + const volumes = [ + { + bounds: ROOM_BOUNDS, + density: this.#mode === "zero" ? 0 : 0.18, + baseHeight: 0.5, + heightFalloff: 0.35, + }, + ]; + // The overlap control's second bound is pinned to the room's whole lit interior, not a sliver: + // a nested volume that hides behind one prop measures nothing about composition. Nested inside + // ROOM_BOUNDS on every axis, denser and slower-falling than the primary bound above. + if (this.#mode === "overlap") + volumes.push({ + bounds: new Box3(new Vector3(-4.2, 0, -9.2), new Vector3(4.2, 3, 2.6)), + density: 0.28, + baseHeight: 0.5, + heightFalloff: 0.32, + }); + const raw = ctx.renderer.raw as { + logarithmicDepthBuffer?: boolean; + reversedDepthBuffer?: boolean; + }; + const fog = createVolumetricFog(ctx.scene, ctx.camera as PerspectiveCamera, { + enabled: this.#mode !== "off" && this.#mode !== "blackOff", + renderer: ctx.renderer.kind, + logarithmicDepth: raw.logarithmicDepthBuffer, + reversedDepth: raw.reversedDepthBuffer, + steps: 48, + resolutionScale: this.#mode === "half" ? 0.5 : 1, + volumes, + albedo: new Color(0.86, 0.9, 0.95), + ambient: new Color(0.055, 0.075, 0.11), + anisotropy: 0.35, + sun: this.#sun, + points: [this.#point], + environment: { aerialPerspective: false, godRays: false }, + }); + const world = new WorldEnvironment({ + bloomEnabled: false, + screenSpaceAA: "disabled", + tonemapMode: "neutral", + exposure: 1, + }); + const applied = world.apply( + ctx.renderer, + ctx.scene, + ctx.camera, + fog === undefined ? {} : { baseColour: (scenePass) => fog.compose(scenePass) }, + ); + this.#fog = fog; + const target = fog?.target; + const material = fog?.material; + if (target !== undefined) this.#createdTargets += 1; + this.#release = () => { + applied.dispose?.(); + // Count actual teardown events, not resize-driven RenderTarget invalidations. + const onTarget = () => { + this.#releasedTargets += 1; + }; + const onMaterial = () => { + this.#releasedMaterials += 1; + }; + target?.addEventListener("dispose", onTarget); + material?.addEventListener("dispose", onMaterial); + fog?.dispose(); + target?.removeEventListener("dispose", onTarget); + material?.removeEventListener("dispose", onMaterial); + }; + this.#builds += 1; + this.#applied = this.#mode; + } + + #disposeGraph(): void { + if (this.#release === undefined) return; + this.#release(); + this.#release = undefined; + this.#fog = undefined; + this.#disposedGraphs += 1; + } + override exit(ctx: FogCtx): void { + this.#disposeGraph(); + ctx.state.set({ + sceneExits: ctx.state.getState().sceneExits + 1, + exitReleasedTargets: this.#releasedTargets, + exitReleasedMaterials: this.#releasedMaterials, + }); + this.#geometry.dispose(); + this.#material.dispose(); + this.#black.dispose(); + this.#calibration.removeFromParent(); + this.#calibration.geometry.dispose(); + this.#calibration.material.dispose(); + this.#sun.dispose(); + this.#point.dispose(); + } +} + +export default defineGame({ + camera: { far: 80, near: 0.1, fov: 52, projection: "perspective" }, + initialState, + frameBudget: { reportEvery: 30 }, + input: { + fog: { keys: ["KeyF"] }, + off: { keys: ["KeyO"] }, + zero: { keys: ["KeyZ"] }, + inside: { keys: ["KeyI"] }, + sunOff: { keys: ["KeyS"] }, + pointOff: { keys: ["KeyP"] }, + overlap: { keys: ["KeyB"] }, + half: { keys: ["KeyH"] }, + wallOff: { keys: ["KeyW"] }, + scatter: { keys: ["KeyL"] }, + scatterSunOff: { keys: ["KeyK"] }, + scatterPointOff: { keys: ["KeyJ"] }, + blackOff: { keys: ["KeyN"] }, + resizeSmall: { keys: ["KeyR"] }, + resizeRestore: { keys: ["KeyT"] }, + rebuild: { keys: ["KeyC"] }, + scatterOutside: { keys: ["Digit1"] }, + scatterOutsideSunOff: { keys: ["Digit2"] }, + scatterOutsidePointOff: { keys: ["Digit3"] }, + cameraCut: { keys: ["Digit4"] }, + cameraRestore: { keys: ["Digit5"] }, + streamWallOut: { keys: ["Digit6"] }, + streamWallIn: { keys: ["Digit7"] }, + reenter: { keys: ["Digit8"] }, + }, + plugins: [playtest()], + renderer: { preferWebGPU: true, resolutionScale: 1, pixelRatio: 1 }, + scenes: { fog: FogProbe }, + seed: 20261002, + start: "fog", +}); diff --git a/examples/abyss-framework/vq-fog/src/main.ts b/examples/abyss-framework/vq-fog/src/main.ts new file mode 100644 index 0000000000..d1ff30dfc2 --- /dev/null +++ b/examples/abyss-framework/vq-fog/src/main.ts @@ -0,0 +1,6 @@ +import game from "./game.js"; +void game.start().then(() => { + const canvas = game.ctx?.renderer.domElement; + if (canvas === undefined) throw new Error("Fog fixture did not produce a canvas."); + document.body.appendChild(canvas); +}); diff --git a/examples/abyss-framework/vq-fog/vite.config.ts b/examples/abyss-framework/vq-fog/vite.config.ts new file mode 100644 index 0000000000..7f020ababb --- /dev/null +++ b/examples/abyss-framework/vq-fog/vite.config.ts @@ -0,0 +1,29 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + build: { outDir: "dist", emptyOutDir: true }, + plugins: [ + { + name: "fog-fixture-http-diagnostics", + configurePreviewServer(server) { + const log = process.env.VQ_FOG_HTTP_LOG; + if (log === undefined) return; + mkdirSync(path.dirname(log), { recursive: true }); + server.middlewares.use((request, response, next) => { + response.on("finish", () => { + if (response.statusCode >= 400) + appendFileSync( + log, + `${JSON.stringify({ url: request.url, status: response.statusCode })}\n`, + ); + }); + next(); + }); + }, + }, + ], +}); diff --git a/packages/core/__tests__/renderer.spec.ts b/packages/core/__tests__/renderer.spec.ts index be278c8ea3..17db1b63e0 100644 --- a/packages/core/__tests__/renderer.spec.ts +++ b/packages/core/__tests__/renderer.spec.ts @@ -890,6 +890,209 @@ describe("createRenderer", () => { } }); + it("does not let an obsolete output owner clear its replacement", async () => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const renderPipeline = vi + .spyOn(RenderPipeline.prototype, "render") + .mockImplementation(() => {}); + const rawRender = vi.fn(); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: rawRender, + setSize: () => undefined, + }), + }); + try { + const first = {}; + const replacement = {}; + renderer.setOutputNode(first); + renderer.setOutputNode(replacement); + renderer.clearOutputNode?.(first); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderPipeline).toHaveBeenCalledTimes(1); + expect(rawRender).not.toHaveBeenCalled(); + renderer.clearOutputNode?.(replacement); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(rawRender).toHaveBeenCalledTimes(1); + // Legacy no-argument calls keep their unconditional behavior. + renderer.setOutputNode(first); + renderer.clearOutputNode?.(); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(rawRender).toHaveBeenCalledTimes(2); + } finally { + renderer.dispose(); + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }); + + it("keeps a direct replacement when an older render chain is disposed", async () => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const renderPipeline = vi + .spyOn(RenderPipeline.prototype, "render") + .mockImplementation(() => {}); + const rawRender = vi.fn(); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: rawRender, + setSize: () => undefined, + }), + }); + try { + const previous = renderer.createRenderChain?.({ + input: {}, + request: { stages: ["bloom"], tier: "high" }, + stages: [{ name: "bloom", build: (input) => input }], + }); + expect(previous?.applied.stages).toEqual(["bloom"]); + const direct = {}; + renderer.setOutputNode(direct); + previous?.dispose(); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderPipeline).toHaveBeenCalledTimes(1); + expect(rawRender).not.toHaveBeenCalled(); + renderer.clearOutputNode?.(direct); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(rawRender).toHaveBeenCalledTimes(1); + } finally { + renderer.dispose(); + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }); + + it("does not resurrect a superseded automatic chain during budget observation", async () => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const renderPipeline = vi + .spyOn(RenderPipeline.prototype, "render") + .mockImplementation(() => {}); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: () => {}, + setSize: () => undefined, + }), + }); + try { + const build = vi.fn((input) => input); + const previous = renderer.createRenderChain?.({ + input: {}, + request: { stages: ["bloom"], tier: "auto" }, + stages: [{ name: "bloom", build }], + }); + renderer.setOutputNode({}); + for (let index = 0; index < 4; index += 1) { + renderer.observeRenderChainBudget?.({ phases: { render: { p95: 40 } } }); + renderer.observeRenderChainFrame?.(); + } + expect(build).toHaveBeenCalledTimes(1); + expect(previous?.disposed).toBe(false); + previous?.dispose(); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderPipeline).toHaveBeenCalledTimes(1); + } finally { + renderer.dispose(); + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }); + + it("gives same-node output installations distinct ownership receipts", async () => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const renderPipeline = vi + .spyOn(RenderPipeline.prototype, "render") + .mockImplementation(() => {}); + const rawRender = vi.fn(); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: rawRender, + setSize: () => undefined, + }), + }); + try { + const shared = {}; + const first = renderer.setOutputNode(shared); + const second = renderer.setOutputNode(shared); + if (!first || !second) throw new Error("Missing installation receipt"); + expect(first.isCurrent()).toBe(false); + expect(second.isCurrent()).toBe(true); + first.dispose(); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderPipeline).toHaveBeenCalledTimes(1); + second.dispose(); + second.dispose(); + expect(second.isCurrent()).toBe(false); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(rawRender).toHaveBeenCalledTimes(1); + } finally { + renderer.dispose(); + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }); + + it("does not observe or clear an obsolete chain after a same-node direct reinstall", async () => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const renderPipeline = vi + .spyOn(RenderPipeline.prototype, "render") + .mockImplementation(() => {}); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: () => {}, + setSize: () => undefined, + }), + }); + try { + const shared = {}; + const build = vi.fn((input) => input); + const chain = renderer.createRenderChain?.({ + input: shared, + request: { stages: ["bloom"], tier: "auto" }, + stages: [{ name: "bloom", build }], + }); + renderer.setOutputNode(shared); + for (let index = 0; index < 2; index += 1) + renderer.observeRenderChainBudget?.({ phases: { render: { p95: 40 } } }); + expect(build).toHaveBeenCalledTimes(1); + chain?.dispose(); + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderPipeline).toHaveBeenCalledTimes(1); + } finally { + renderer.dispose(); + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }); + it("replaces and disposes only the framework-owned output pipeline", async () => { const canvas = testCanvas(); const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); diff --git a/packages/core/src/render/chain.ts b/packages/core/src/render/chain.ts index 1e8d74bec6..c2393b6fc8 100644 --- a/packages/core/src/render/chain.ts +++ b/packages/core/src/render/chain.ts @@ -1,7 +1,7 @@ import type { MRTNode, Node } from "three/webgpu"; import type { IFrameBudgetWindow } from "../frame-budget.js"; -import type { RendererKind } from "../renderer.js"; +import type { IRenderOutputInstallation, RenderOutputSetter, RendererKind } from "../renderer.js"; import { velocityTexture, withVelocityContext } from "./velocity.js"; import type { IVelocityRenderPass } from "./velocity.js"; @@ -53,11 +53,11 @@ export interface IRenderChainVelocityResult { export interface IRenderChainRenderer { readonly kind: RendererKind; readonly raw: unknown; - clearOutputNode?(): void; + clearOutputNode?(expectedNode?: unknown): void; /** Internal renderer seam used to turn on core-owned previous-frame bookkeeping for active temporal stages. */ setRenderChainVelocityEnabled?(enabled: boolean): void; /** Install a graph and identify the authored world pass that follows the rendered scene root. */ - setOutputNode(node: unknown, worldPass?: unknown): void; + setOutputNode: RenderOutputSetter; } const TIER_LEVEL: Record = { @@ -275,6 +275,8 @@ export class RenderChain { #ownedVelocityPass: IVelocityRenderPass | undefined = undefined; #ownedVelocityMrt: MRTNode | null | undefined = undefined; #disposed = false; + #installedNode: unknown; + #installation: IRenderOutputInstallation | undefined; #applied: IRenderChainApplied; constructor(renderer: IRenderChainRenderer, options?: Omit); @@ -421,7 +423,19 @@ export class RenderChain { if (stages.length > 0) { try { - this.#renderer.setOutputNode(node, this.#worldPass ?? this.#requestVelocity.pass); + const installation = this.#renderer.setOutputNode( + node, + this.#worldPass ?? this.#requestVelocity.pass, + ); + // Historical void-style callbacks can return an ignored value (for example Array.push). + // Only an explicit receipt upgrades that adapter to installation ownership. + this.#installation = + installation && + typeof installation.isCurrent === "function" && + typeof installation.dispose === "function" + ? installation + : undefined; + this.#installedNode = node; } catch (error) { const reason = `install:${errorMessage(error)}`; dropped.push(...stages.map((name) => ({ name, reason }))); @@ -429,10 +443,13 @@ export class RenderChain { contributions.length = 0; for (const definition of builtStageDefinitions) definition.dispose?.(); builtStageDefinitions.length = 0; - this.#renderer.clearOutputNode?.(); + // A renderer may throw after partially installing this attempt. Clear that identity, + // then the prior owned graph, without touching an unrelated replacement. + this.#renderer.clearOutputNode?.(node); + this.#clearOwnedOutput(); } } else { - this.#renderer.clearOutputNode?.(); + this.#clearOwnedOutput(); } const activeVelocity = stages.some((name) => @@ -467,6 +484,7 @@ export class RenderChain { /** Samples the active temporal stage's completed velocity result after rendering. */ observeFrame(): IRenderChainApplied { if (this.#disposed) throw new Error("RenderChain.observeFrame called after dispose()."); + if (this.#installation?.isCurrent() === false) return this.#applied; const requiresMeasurement = this.#applied.stages.some((name) => requiresVelocityFor(this.#stageDefinitions.get(name), name), ); @@ -492,7 +510,7 @@ export class RenderChain { /** Feed the chain the same render-window evidence used by the frame budget. */ observeFrameBudget(window: IRenderChainBudgetWindow): RenderChainTier { if (this.#disposed) throw new Error("RenderChain.observeFrameBudget called after dispose()."); - if (!this.#automatic) return this.#tier; + if (!this.#automatic || this.#installation?.isCurrent() === false) return this.#tier; const renderP95 = window.phases.render?.p95; if (typeof renderP95 !== "number" || !Number.isFinite(renderP95) || renderP95 < 0) { throw new Error("RenderChain frame budget render.p95 must be a finite non-negative number."); @@ -530,10 +548,19 @@ export class RenderChain { this.#disposeActiveStages(); this.#restoreOwnedVelocityOutput(); this.#renderer.setRenderChainVelocityEnabled?.(false); - this.#renderer.clearOutputNode?.(); + this.#clearOwnedOutput(); forgetReport(this.#renderer); } + #clearOwnedOutput(): void { + if (this.#installation !== undefined) this.#installation.dispose(); + // Compatibility only: a void-returning adapter cannot distinguish same-node reinstalls. + else if (this.#installedNode !== undefined) + this.#renderer.clearOutputNode?.(this.#installedNode); + this.#installation = undefined; + this.#installedNode = undefined; + } + #setMeasurement(measurement: IRenderChainVelocityMeasurement | undefined, emit: boolean): void { const { provisioned, required, source } = this.#applied.velocity; this.#applied = { diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 824e9ba9d0..3f72e809cc 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -15,6 +15,17 @@ import { export type RendererKind = "webgpu" | "webgl2"; +/** Owns one output-pipeline installation, not the lifetime of its input graph dependencies. */ +export interface IRenderOutputInstallation { + isCurrent(): boolean; + dispose(): void; +} + +/** Union of callbacks preserves TypeScript's legacy void-callback return-value compatibility. */ +export type RenderOutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => IRenderOutputInstallation); + type WarmableSurface = { clone: () => WarmableSurface; opacity?: number; @@ -175,8 +186,8 @@ export interface IRendererLike { render(scene: Object3D, camera: Camera): void; /** Draws after the world without clearing or passing through the world's output pipeline. */ renderOverlay(scene: Object3D, camera: Camera): void; - /** Removes the output pipeline installed by a render-chain. */ - clearOutputNode?(): void; + /** Legacy input-filtered clear; use an installation receipt for same-node replacement safety. */ + clearOutputNode?(expectedNode?: unknown): void; /** Creates the core-owned chain seam without making generated render source import the package. */ createRenderChain?: (options: Omit) => RenderChain; /** Feeds automatic render-chain tiers the completed frame-budget window. */ @@ -188,7 +199,9 @@ export interface IRendererLike { /** Internal callback used by RenderChain; games should request velocity through the chain. */ setRenderChainVelocityEnabled?: (enabled: boolean) => void; /** Installs a graph; pass the authored world pass when the graph contains auxiliary passes. */ - setOutputNode(node: unknown, worldPass?: unknown): void; + // The owned renderer returns a unique receipt. Legacy adapters may return void, without + // installation-level replacement safety; callers still own graph dependency disposal. + setOutputNode: RenderOutputSetter; setSize(width: number, height: number, updateStyle?: boolean): void; /** * The GPU time the last resolved frame actually cost, in milliseconds, or `undefined` when the @@ -409,6 +422,8 @@ function wrapRenderer( softwareAdapter?: string, ): IRendererLike { let outputPipeline: RenderPipeline | undefined; + // Keep caller identity separately: RenderPipeline may wrap its public output node. + let outputInput: unknown; let outputPass: PassNode | undefined; const renderChains = new Set(); let renderChainUsesPerObjectVelocity = false; @@ -743,6 +758,7 @@ function wrapRenderer( renderChainUsesPerObjectVelocity = false; outputPipeline?.dispose(); outputPipeline = undefined; + outputInput = undefined; outputPass = undefined; pipelineCensus?.dispose(); raw.dispose?.(); @@ -776,10 +792,23 @@ function wrapRenderer( outputPipeline?.dispose(); outputPass = nextOutputPass; outputPipeline = nextPipeline; + outputInput = node; + return { + isCurrent: () => outputPipeline === nextPipeline, + dispose: () => { + if (outputPipeline !== nextPipeline) return; + outputPipeline = undefined; + outputInput = undefined; + outputPass = undefined; + nextPipeline.dispose(); + }, + }; }, - clearOutputNode: () => { + clearOutputNode: (expectedNode) => { + if (expectedNode !== undefined && expectedNode !== outputInput) return; outputPipeline?.dispose(); outputPipeline = undefined; + outputInput = undefined; outputPass = undefined; }, renderChainUsesPerObjectVelocity: () => renderChainUsesPerObjectVelocity, diff --git a/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx new file mode 100644 index 0000000000..0ca6b366fe --- /dev/null +++ b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GameUi } from "../templates/rain/src/ui/GameUi.js"; + +const hooks = vi.hoisted(() => ({ + effects: [] as Array<() => unknown>, + refs: [] as Array<{ current: unknown }>, + index: 0, + state: { + quality: "performance", + softwareRendering: true, + qualityExplicit: false, + loading: { ready: false, progress: 0 }, + }, + send: vi.fn(), +})); +vi.mock("@threenative/ui", () => ({ + UiLayer: "div", + useUiState: () => hooks.state, + useUiIntent: () => hooks.send, +})); +vi.mock("react/jsx-runtime", () => ({ + jsx: (type: unknown, props: unknown) => ({ type, props }), + jsxs: (type: unknown, props: unknown) => ({ type, props }), +})); +vi.mock("react/jsx-dev-runtime", () => ({ + jsxDEV: (type: unknown, props: unknown) => ({ type, props }), +})); +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => unknown) => hooks.effects.push(effect), + useRef: (value: unknown) => { + const index = hooks.index++; + hooks.refs[index] ??= { current: value }; + return hooks.refs[index]; + }, + useState: (value: unknown) => [value, vi.fn()], +})); + +function publishSnapshot(): void { + hooks.index = 0; + hooks.effects = []; + const element = GameUi(); + element.props.children.type(); + for (const effect of hooks.effects) effect(); +} + +describe("Rain initial quality ownership", () => { + beforeEach(() => { + hooks.refs = []; + hooks.send.mockClear(); + hooks.state = { + quality: "performance", + softwareRendering: true, + qualityExplicit: false, + loading: { ready: false, progress: 0 }, + }; + vi.stubGlobal("window", { + innerWidth: 1440, + location: { search: "" }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("keeps the scene's software study tier instead of raising it for a wide UI", () => { + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + }); + + it("honours an explicit URL tier even on the software default", () => { + window.location.search = "?quality=ultra"; + publishSnapshot(); + expect(hooks.send).toHaveBeenCalledWith("setQuality", "ultra"); + }); + + it("keeps a later player choice when another snapshot arrives", () => { + publishSnapshot(); + hooks.send.mockClear(); + hooks.state.quality = "balanced"; + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + }); + + it.each([ + [699, "performance"], + [700, "high"], + [1440, "high"], + ])("keeps the hardware width default at %s pixels", (width, quality) => { + hooks.state.softwareRendering = false; + window.innerWidth = Number(width); + publishSnapshot(); + expect(hooks.send).toHaveBeenCalledWith("setQuality", quality); + hooks.send.mockClear(); + window.innerWidth = 400; + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + }); + + it("keeps an explicit hardware choice made before the first UI snapshot", () => { + hooks.state.softwareRendering = false; + hooks.state.qualityExplicit = true; + hooks.state.quality = "balanced"; + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + }); + it("keeps a runner's raw quality setup on software after scene loading", () => { + hooks.state.quality = "balanced"; + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + }); + it("waits for adapter classification without consuming its one-time hardware choice", () => { + Reflect.deleteProperty(hooks.state, "softwareRendering"); + publishSnapshot(); + expect(hooks.send.mock.calls.filter(([name]) => name === "setQuality")).toEqual([]); + hooks.state.softwareRendering = false; + publishSnapshot(); + expect(hooks.send).toHaveBeenCalledWith("setQuality", "high"); + }); +}); diff --git a/packages/create-threenative/__tests__/rain-software-default.spec.ts b/packages/create-threenative/__tests__/rain-software-default.spec.ts new file mode 100644 index 0000000000..7b46cf3a82 --- /dev/null +++ b/packages/create-threenative/__tests__/rain-software-default.spec.ts @@ -0,0 +1,115 @@ +import { fileURLToPath } from "node:url"; +import { PerspectiveCamera, Scene as ThreeScene } from "three"; +import { describe, expect, it, vi } from "vitest"; +import { composeScenarioSetupRequest } from "../../playtest/src/runner/setup-request.js"; +import { loadPlaytestScenario } from "../../playtest/src/scenario.js"; +import { installThreePlaytestBridge } from "../../playtest/src/three/bridge.js"; +import { studyTier } from "../templates/rain/src/render/quality.js"; +import { Coast, type WeatherCtx } from "../templates/rain/src/scenes/Boot.js"; +import { intentPatch, preset } from "../templates/rain/src/state.js"; + +vi.mock("@threenative/core", () => ({ + Scene: class { + load() {} + }, + alwaysRender: vi.fn(), + isMobile: () => false, +})); +vi.mock("../templates/rain/src/audio/storm.js", () => ({ + STORM_AUDIO_ENTITY: "audio", + createStormAudio: vi.fn(), +})); +vi.mock("../templates/rain/src/render/camera.js", () => ({ + createCameraRig: vi.fn(), + setupCamera: vi.fn(), +})); +vi.mock("../templates/rain/src/render/lightning.js", () => ({ createStormLightning: vi.fn() })); +vi.mock("../templates/rain/src/render/loading.js", () => ({ createLoadingScreen: vi.fn() })); +vi.mock("../templates/rain/src/render/postprocessing.js", () => ({ setupPost: vi.fn() })); +vi.mock("../templates/rain/src/render/rain.js", () => ({ createStormRain: vi.fn() })); +vi.mock("../templates/rain/src/render/world.js", () => ({ createWeatherWorld: vi.fn() })); + +function load(software: string | undefined, width: number, choice?: string) { + let state = { ...Coast.initialState }; + if (choice !== undefined) + state = { ...state, ...intentPatch("setQuality", choice, preset("storm")) }; + const ctx = { + renderer: { softwareAdapter: software }, + viewport: { size: { width, height: 720 } }, + state: { + getState: () => state, + set: (patch: object) => { + state = { ...state, ...patch }; + }, + flush: vi.fn(), + }, + } as unknown as WeatherCtx; + new Coast().load(ctx); + return { state, ctx }; +} + +describe("Rain study quality before scene entry", () => { + it("can apply the tiers scenario setup through the engine's read-only state contract", async () => { + const scenario = await loadPlaytestScenario( + fileURLToPath(new URL("../templates/rain/", import.meta.url)), + "playtests/tiers.playtest.json", + ); + const installation = installThreePlaytestBridge({ + camera: new PerspectiveCamera(), + scene: new ThreeScene(), + renderer: { getDrawingBufferSize: (target) => target.set(1280, 720) }, + resources: { read: () => ({ state: { quality: "performance" } }) }, + }); + try { + await expect( + installation.bridge.applySetup?.( + await composeScenarioSetupRequest( + { sample: async (request) => installation.bridge.sample(request) }, + scenario, + ), + ), + ).resolves.toEqual({ + entities: [], + resources: [], + }); + } finally { + installation.dispose(); + } + }); + it.each(["swiftshader", "llvmpipe"])( + "selects performance on %s before the first storm frame", + (adapter) => { + expect(load(adapter, 1440).state.quality).toBe("performance"); + }, + ); + it("leaves the authored hardware window-width policy to the UI", () => { + expect(load(undefined, 1440).state.quality).toBe("balanced"); + expect(load(undefined, 699).state.quality).toBe("balanced"); + expect(load(undefined, 700).state.softwareRendering).toBe(false); + }); + it("feeds the existing cheap cloud and reflection policy without changing its budgets", () => { + expect(studyTier(load("swiftshader", 1440).state.quality)).toEqual({ + cloudScale: 0.4, + cloudSteps: 32, + rainBudget: 6_500, + reflections: false, + }); + expect(studyTier(load("swiftshader", 1440, "high").state.quality)).toEqual({ + cloudScale: 0.6, + cloudSteps: 64, + rainBudget: 12_000, + reflections: true, + }); + }); + it.each(["performance", "balanced", "high", "ultra"])( + "preserves an early explicit %s choice", + (quality) => { + expect(load("swiftshader", 1440, quality).state.quality).toBe(quality); + }, + ); + it("does not revise a choice on a viewport resize", () => { + const { state, ctx } = load(undefined, 1440); + Object.assign(ctx.viewport.size, { width: 400 }); + expect(state.quality).toBe("balanced"); + }); +}); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 8401ff1201..248a76225d 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -165,44 +165,27 @@ const BUG_REPORT_SKILL_PATHS = [ // arrive through the templating step rather than a verbatim copy, which is why a content-hash // matcher does not list them and this ablation is the evidence instead. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { - // Recomputed 2026-10-01 on the merge of develop a602467db (PRD-458/473): every template's frame - // budget now comes from resolveTargetFps, so ten trees move and `rts` does not; the capability reference (365 -> 368 entries) then moved all eleven, because it ships in every scaffold. - // Recomputed 2026-10-01, three times, each by a real run that found the previous tree wrong: - // the first gave each quality.ts a software adapter policy; the second found ten of eleven - // setupPost callers never forwarded the adapter fact to it; the third found no template but - // `starter` set `renderChainTier`, so a low preset still ran the high render chain. The same ten - // trees move on each of the last two. `starter` is unchanged throughout — its setupPost hands the - // whole environment to createAdaptiveQuality and its quality.ts already carried the chain tier. - // Values measured through createProject by the spec that asserts them, not by hand. - // Recomputed again 2026-10-01 for `sailing` alone: `sailMotion` is a range across landed cloth - // readbacks, and one landed copy makes that range zero by arithmetic, so the sails scenario now - // holds long enough for two copies to arrive on a CPU rasteriser and asserts the landed count - // beside it. Only the two sailing template files changed, so only this one tree moves. - // Recomputed again 2026-10-01 after merging the quality/post chain into this branch and landing - // the rts sim's order, queue and event-record fixes: `rts` alone moves, and it is the only one of - // the eleven that carries `src/sim/`. Measured through createProject on the merged tree. - // Recomputed 2026-10-01 on the merge of develop (PRD-470/471/472/474) into the rain + snow - // branch (PRD-469, PRD-473): the merged tree carries both sides' engine and manifest bytes, so - // all thirteen trees, rain and snow included, were re-measured through createProject. - // Recomputed 2026-10-02 rebasing the FabCLI manual-login fallback onto develop: the shared - // threenative-assets skill ships in every scaffold, so all thirteen trees move. - // PRD-479: each scaffold embeds the reviewed compute-only Storage3DTexture Three patch. - // A clean-tree audit matched all13 prior hashes, then changed only the copied patch and - // reproduced all13 CI received hashes. No template, version or other scaffold bytes changed. - "action-rpg": "115e31cd5a319cf1407d94486a839304ca26cc6c532384129f6a3e8e7f1a3e36", - minimal: "4149749167f26a50c4fe67b801cc71e3d9e26d737f7e43766813f69ac5804900", - platformer: "87dc88a3a6d50e88c476cf80c5be501bca62ce27474aed5d8155e02d8e729c90", - puzzle: "fc5d88b24b78db3830510360fe2a4e53a7d0805737ddc07047fb6530b23009bb", - racing: "502373fe0d07d97dca2bab70929ead5f7bafac123d3b97c2ae49b4a919e5124e", - rain: "731042034a9c73824998c919137aa7002d75cac031713e6d309bc8301f8b4088", - rts: "2b875e1041e0e533d17f5bd5104094e11f8fbbdd06aef1e0156cba80fc21585b", - runner: "b66c2056b1256773911c0f6e46bea6c2f705cc3acbb2b8e2aa3ee8c461938cff", - sailing: "2d1ec2373e3697afa0d8f6341260911c24f988315dc2942d1328c0ddbd848011", - // VQ11 after merge of develop 416ffd7: measured through createProject, only shooter changed. - shooter: "2a4a5cb0b4c91b2cc7578df8bee854d3fa66ffd58f85a8c840964c5549cecc54", - snow: "7b634782b3bc6eff9cc9b15141bbe5bba9d1014e034d2a2e8859e785911a6424", - starter: "212b3f23c97b60ad8475a9418a6f4eeb87202ebad570b424cab83ff6c3c4ef17", - "tower-defense": "b72a0736abbcd3b2007852684b9298dc6d2069c8130337f140f15fb48f341511", + // Recomputed 2026-10-03 on the merge of develop c18a42bea into VQ07: the merged tree carries + // develop's copied Storage3DTexture patch and shared-skill bytes beside VQ07's receipt source and + // four-module starter fog, so all thirteen trees moved. Measured through createProject by the spec + // below; the previous table matched none of them, which is what proves the merge is in the tree. + "action-rpg": "444f6123d2f6055f1c4a46b0d35003d1f45b8a2bbc94b90355d6655a37a356ba", + minimal: "a37c5190035c8fba71f31d6510a9df8f17fb40e2c032509a1a83f9b4ffb80e45", + platformer: "5e73de346450aa81f0b7531d5349f15b29dcd25bc25e880933aa0d688291a22f", + puzzle: "c362aff80fad0bfb06d4425922762dfda9cb2b4169acce6525be790029716f89", + racing: "02fe5731ddfbbcfef2ff9d07db68199682d5dc8f2a46dba21fca8e9e2e297b9e", + rain: "0bfcbb52b862bb1833290ed45d6803692d09b536338cbe31c011230c35de40cf", + rts: "5200d85876eb561337007f59ae27967191d4896ee4ac3987f30a5186bee2c210", + runner: "9e5798c74c3ff7bb59011928a42014a21f8bf7bf665419f12a5cfd85162ebdd3", + sailing: "0a70a652dd128e56be83863ac45dab52c5aa05d7944f584108d0f7b5900c829e", + shooter: "b13ae63da5e591296d68b4e419780f5bd6f640d79702910cdbb71a4e25bf88cd", + snow: "7a0f7d84d2c0591fe87ad23756ec3fc6e1566247a12337899a6dde2071b75454", + // VQ07 again, for the fog ownership rule alone: `volumetricFog.ts` restores the captured + // `scene.fog` only while the medium still holds the slot, so a fog the game authors while the + // medium lives survives disposal. One file, one line, starter only; the other twelve trees are + // untouched and still match develop's measurements, which is the ablation for this change. + starter: "d51d898f4f15403bf2e20a734ddeba972aca595df6c12d854ce5b0d4b8fb94d6", + "tower-defense": "5f56275cfff3b81607c6aca3662d776df0052fed462ff00e93568ab50ecbc789", }; const GENERATED_SCAFFOLD_METADATA = @@ -300,6 +283,10 @@ const STARTER_PATHS = [ "src/render/lighting.ts", "src/render/postprocessing.ts", "src/render/worldEnvironment.ts", + "src/render/volumetricFog.ts", + "src/render/volumetricFogOptions.ts", + "src/render/volumetricFogVolume.ts", + "src/render/volumetricFogTransport.ts", "src/render/palette.ts", "src/render/materials.ts", "src/render/arena.ts", diff --git a/packages/create-threenative/__tests__/vq-fog-fixture.spec.ts b/packages/create-threenative/__tests__/vq-fog-fixture.spec.ts new file mode 100644 index 0000000000..b470c6c9d6 --- /dev/null +++ b/packages/create-threenative/__tests__/vq-fog-fixture.spec.ts @@ -0,0 +1,278 @@ +import { + type Color, + DepthTexture, + DirectionalLight, + Mesh, + MeshBasicMaterial, + type Object3D, + PerspectiveCamera, + PointLight, + RenderTarget, + Scene, +} from "three"; +import { describe, expect, it } from "vitest"; +import { + FogProbe, + ROOM_BOUNDS, + fogInsideShadowMap, +} from "../../../examples/abyss-framework/vq-fog/src/game.js"; +import { Registry } from "../../core/src/entities.js"; +import { createGameStore } from "../../core/src/state.js"; + +type Ctx = Parameters[0]; +function fixture() { + const probe = new FogProbe(); + const scene = new Scene(); + const state = createGameStore({ ...FogProbe.initialState }); + let action = ""; + const info = { frame: 0, render: { calls: 0 }, memory: { textures: 2 } }; + const sizes: number[][] = []; + const transitions: string[] = []; + // No GPU is available in a unit test. Use real scene/state/TSL ownership objects; + // only the input edge and renderer installation boundary are replaced. + const ctx = { + scene, + state, + goto: (name: string) => transitions.push(name), + camera: new PerspectiveCamera(52, 1.6, 0.1, 80), + entities: new Registry(), + add: (object: T): T => { + scene.add(object); + return object; + }, + input: { justPressed: (name: string) => name === action }, + renderer: { + kind: "webgpu", + info, + setSize: (width: number, height: number) => sizes.push([width, height]), + raw: { shadowMap: { enabled: false } }, + setOutputNode: () => {}, + clearOutputNode: () => {}, + }, + } as unknown as Ctx; + probe.enter(ctx); + const sun = scene.children.find( + (object): object is DirectionalLight => object instanceof DirectionalLight, + ); + if (sun === undefined) throw new Error("Missing authored directional light"); + sun.shadow.map = new RenderTarget(16, 16); + sun.shadow.map.depthTexture = new DepthTexture(16, 16); + return { + probe, + ctx, + transitions, + info, + sizes, + camera: ctx.camera as PerspectiveCamera, + scene, + state, + sun, + select(mode: string) { + action = mode; + probe.update(ctx); + action = ""; + }, + }; +} + +describe("fog qualification fixture", () => { + it("isolates scattering with black unlit surfaces while retaining geometry and shadow casters", () => { + const f = fixture(); + const meshes = f.scene.children.filter((object) => object instanceof Mesh); + f.select("scatter"); + const calibration = f.scene.getObjectByName("fog-calibration"); + expect(calibration).toBeInstanceOf(Mesh); + expect((calibration as Mesh).material).toBeInstanceOf(MeshBasicMaterial); + expect(((calibration as Mesh).material as MeshBasicMaterial).allowOverride).toBe(false); + expect(calibration?.castShadow).toBe(false); + expect(calibration?.visible).toBe(true); + expect(f.scene.overrideMaterial).toBeInstanceOf(MeshBasicMaterial); + expect((f.scene.overrideMaterial as MeshBasicMaterial).color.getHex()).toBe(0); + expect((f.scene.background as Color).getHex()).toBe(0); + expect(meshes.every((mesh) => mesh.castShadow && mesh.visible)).toBe(true); + f.select("scatterSunOff"); + expect(f.sun.intensity).toBe(0); + expect(f.scene.children.find((object) => object instanceof PointLight)?.intensity).toBe(30); + f.select("scatterPointOff"); + expect(f.sun.intensity).toBe(3); + expect(f.scene.children.find((object) => object instanceof PointLight)?.intensity).toBe(0); + f.select("blackOff"); + expect(f.state.getState().targets).toBe(0); + expect((f.scene.background as Color).getHex()).toBe(0); + f.select("fog"); + expect(f.scene.overrideMaterial).toBeNull(); + expect((f.scene.background as Color).getHex()).toBe(0x131e2a); + f.probe.exit(f.ctx); + }); + it("observes actual target/material release events across rebuild and off", () => { + const f = fixture(); + for (const mode of ["fog", "inside", "off", "fog", "rebuild", "off"]) f.select(mode); + expect(f.state.getState()).toMatchObject({ + createdTargets: 4, + releasedTargets: 4, + releasedMaterials: 4, + liveTargets: 0, + targets: 0, + }); + f.probe.exit(f.ctx); + }); +}); + +it("resizes the live graph without rebuilding and restores camera projection", () => { + const f = fixture(); + f.select("fog"); + f.select("resizeSmall"); + expect(f.sizes).toEqual([[320, 240]]); + expect(f.camera.aspect).toBe(4 / 3); + expect(f.state.getState()).toMatchObject({ + createdTargets: 1, + releasedTargets: 0, + liveTargets: 1, + }); + f.select("resizeRestore"); + expect(f.sizes).toEqual([ + [320, 240], + [640, 400], + ]); + expect(f.camera.aspect).toBe(1.6); + expect(f.state.getState().builds).toBe(1); + f.probe.exit(f.ctx); +}); +it("counts texture stability only across observed completed render frames", () => { + const f = fixture(); + f.select("fog"); + for (let i = 0; i < 5; i += 1) f.select(""); + expect(f.state.getState()).toMatchObject({ settledRenderFrames: 0, stableTextureFrames: 0 }); + for (let i = 1; i <= 3; i += 1) { + f.info.render.calls = i * 4; + f.select(""); + } + expect(f.state.getState()).toMatchObject({ + settledRenderFrames: 3, + stableTextureFrames: 3, + textures: 2, + }); + f.info.memory.textures = 3; + f.info.render.calls = 16; + f.select(""); + expect(f.state.getState().stableTextureFrames).toBe(1); + f.select("off"); + expect(f.state.getState()).toMatchObject({ settledRenderFrames: 0, stableTextureFrames: 0 }); + f.probe.exit(f.ctx); +}); + +it("moves shadow coverage away while preserving the isolated light controls", () => { + const f = fixture(); + f.select("scatterOutside"); + expect(f.sun.shadow.camera.left).toBe(30); + expect(f.sun.shadow.camera.right).toBe(31); + expect(f.state.getState()).toMatchObject({ + shadowOutside: true, + scatteringOnly: true, + sun: true, + }); + f.select("scatterOutsideSunOff"); + expect(f.sun.intensity).toBe(0); + expect(f.scene.children.find((object) => object instanceof PointLight)?.intensity).toBe(30); + f.select("scatterOutsidePointOff"); + expect(f.sun.intensity).toBe(3); + expect(f.scene.children.find((object) => object instanceof PointLight)?.intensity).toBe(0); + f.select("fog"); + expect(f.sun.shadow.camera.left).toBe(-9); + f.probe.exit(f.ctx); +}); +it("observes real shadow-map coverage instead of the authored mode name", () => { + const sun = new DirectionalLight(0xffffff, 3); + sun.position.set(-3, 7, 1); + sun.target.position.set(0, 0, -5); + sun.target.updateMatrixWorld(); + sun.shadow.camera.updateProjectionMatrix(); + Object.assign(sun.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 0.1, far: 30 }); + sun.shadow.camera.updateProjectionMatrix(); + expect(fogInsideShadowMap(sun, ROOM_BOUNDS)).toBe(true); + Object.assign(sun.shadow.camera, { left: 30, right: 31, top: 31, bottom: 30 }); + sun.shadow.camera.updateProjectionMatrix(); + expect(fogInsideShadowMap(sun, ROOM_BOUNDS)).toBe(false); + const f = fixture(); + f.select("scatter"); + f.select("scatterOutside"); + expect(f.state.getState()).toMatchObject({ shadowOutside: true }); + f.select("fog"); + expect(f.state.getState()).toMatchObject({ shadowOutside: false }); + f.probe.exit(f.ctx); +}); + +it("sees a shadow map the volume wholly swallows, which no corner of the volume enters", () => { + // The same transforms the renderer hands the shadow camera, so this is coverage and not intent. + const sun = new DirectionalLight(0xffffff, 3); + const aim = (z: number) => { + sun.position.set(-4, 1.5, z); + sun.target.position.set(-4, 1.5, z + 3); + sun.updateMatrixWorld(true); + sun.target.updateMatrixWorld(true); + sun.shadow.updateMatrices(sun); + }; + Object.assign(sun.shadow.camera, { + left: -0.2, + right: 0.2, + top: 0.2, + bottom: -0.2, + near: 0.1, + far: 3, + }); + sun.shadow.camera.updateProjectionMatrix(); + // A 40 cm map inside a 9 m room: the map is entirely within the volume, and not one of the + // volume's eight corners is within the map. A corner test calls this fog unshadowed. + aim(-3); + expect(fogInsideShadowMap(sun, ROOM_BOUNDS)).toBe(true); + // The same map translated clear of the room. A conservative test may over-report, never miss. + aim(-60); + expect(fogInsideShadowMap(sun, ROOM_BOUNDS)).toBe(false); +}); + +it("cuts the camera and streams the wall on the same controller", () => { + const f = fixture(); + f.select("fog"); + const outside = f.camera.position.clone(); + const wall = f.scene.getObjectByName("fog-wall"); + expect(wall).toBeInstanceOf(Mesh); + f.select("cameraCut"); + expect(f.camera.position.toArray()).toEqual([0, 1.5, -1]); + expect(f.state.getState()).toMatchObject({ + inside: true, + builds: 1, + createdTargets: 1, + releasedTargets: 0, + }); + f.select("cameraRestore"); + expect(f.camera.position.equals(outside)).toBe(true); + f.select("streamWallOut"); + expect(wall?.parent).toBeNull(); + expect(f.state.getState()).toMatchObject({ + streamedWall: true, + builds: 1, + stableTextureFrames: 0, + }); + f.select("streamWallIn"); + expect(wall?.parent).toBe(f.scene); + expect(f.state.getState()).toMatchObject({ + streamedWall: false, + builds: 1, + createdTargets: 1, + releasedTargets: 0, + }); + f.probe.exit(f.ctx); +}); +it("routes reentry through the scene owner and records actual exit releases", () => { + const f = fixture(); + f.select("fog"); + f.select("reenter"); + expect(f.transitions).toEqual(["fog"]); + f.probe.exit(f.ctx); + expect(f.state.getState()).toMatchObject({ + sceneEntries: 1, + sceneExits: 1, + exitReleasedTargets: 1, + exitReleasedMaterials: 1, + }); +}); diff --git a/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts new file mode 100644 index 0000000000..4baa40cb17 --- /dev/null +++ b/packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts @@ -0,0 +1,271 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + Box3, + BoxGeometry, + Color, + DepthTexture, + DirectionalLight, + FogExp2, + Mesh, + MeshBasicMaterial, + OrthographicCamera, + PerspectiveCamera, + PointLight, + RenderTarget, + Scene, + Vector3, +} from "three"; +import { pass } from "three/tsl"; +import { type Node, WGSLNodeBuilder } from "three/webgpu"; +import { describe, expect, it, vi } from "vitest"; +import { + type IVolumetricFogOptions, + createVolumetricFog, +} from "../templates/starter/src/render/volumetricFog.js"; + +function required(value: T | undefined | null): T { + if (value == null) throw new Error("Missing fixture value"); + return value; +} +function settings(): IVolumetricFogOptions { + return { + enabled: true, + renderer: "webgpu", + steps: 32, + resolutionScale: 1, + volumes: [ + { + bounds: new Box3(new Vector3(-2, -1, -8), new Vector3(2, 3, -2)), + density: 0.2, + baseHeight: 0, + heightFalloff: 0, + }, + ], + albedo: new Color(0.8, 0.8, 0.8), + ambient: new Color(0.1, 0.1, 0.1), + anisotropy: 0, + environment: { aerialPerspective: false, godRays: false }, + }; +} + +describe("opt-in generated volumetric fog", () => { + it.each(["disabled", "unsupported", "zero", "empty"])( + "%s returns no controller before graph allocation", + (reason) => { + const options = settings(); + if (reason === "disabled") options.enabled = false; + if (reason === "unsupported") options.renderer = "webgl2"; + if (reason === "zero") required(options.volumes[0]).density = 0; + if (reason === "empty") options.volumes = []; + expect(createVolumetricFog(new Scene(), new PerspectiveCamera(), options)).toBeUndefined(); + }, + ); + it.each([0, -1, 8.5, 129, Number.NaN])("rejects invalid steps %s", (steps) => { + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), steps }), + ).toThrow(/steps/); + }); + it.each([0, 0.25, 2, Number.NaN])("rejects unsupported resolution %s", (resolutionScale) => { + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), resolutionScale }), + ).toThrow(/resolutionScale/); + }); + it("refuses unqualified camera and depth encodings", () => { + expect(() => + createVolumetricFog( + new Scene(), + new OrthographicCamera() as unknown as PerspectiveCamera, + settings(), + ), + ).toThrow(/perspective/); + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { + ...settings(), + logarithmicDepth: true, + }), + ).toThrow(/depth/); + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { + ...settings(), + reversedDepth: true, + }), + ).toThrow(/depth/); + }); + it("rejects double-counted atmosphere or shafts before allocation", () => { + for (const name of ["aerialPerspective", "godRays"] as const) { + const options = settings(); + options.environment[name] = true; + expect(() => createVolumetricFog(new Scene(), new PerspectiveCamera(), options)).toThrow( + /same medium/, + ); + } + }); + it("rejects invalid density, bounds, albedo and phase", () => { + const options = settings(); + required(options.volumes[0]).density = -1; + expect(() => createVolumetricFog(new Scene(), new PerspectiveCamera(), options)).toThrow( + /density/, + ); + required(options.volumes[0]).density = 0.2; + required(options.volumes[0]).bounds.makeEmpty(); + expect(() => createVolumetricFog(new Scene(), new PerspectiveCamera(), options)).toThrow( + /bounds/, + ); + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), anisotropy: 1 }), + ).toThrow(/anisotropy/); + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { + ...settings(), + albedo: new Color(2, 0, 0), + }), + ).toThrow(/albedo/); + }); + it("refuses missing directional maps and unsupported local-light shadows", () => { + const sun = new DirectionalLight(); + sun.castShadow = true; + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), sun }), + ).toThrow(/shadow map/); + const point = new PointLight(); + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), points: [point] }), + ).toThrow(/finite-range/); + point.distance = 5; + point.castShadow = true; + expect(() => + createVolumetricFog(new Scene(), new PerspectiveCamera(), { ...settings(), points: [point] }), + ).toThrow(/unshadowed/); + }); + it("owns the scene's fog while it lives and puts the authored fog back", () => { + // The starter's `sky.ts` installs a FogExp2 on `scene.fog`; two air densities would double it. + const scene = new Scene(); + const authored = new FogExp2(0xcfd8e3, 0.003); + scene.fog = authored; + const camera = new PerspectiveCamera(); + const fog = required(createVolumetricFog(scene, camera, settings())); + expect(scene.fog).toBeNull(); + expect(() => fog.compose(pass(scene, camera))).not.toThrow(); + fog.dispose(); + expect(scene.fog).toBe(authored); + }); + it("leaves a fog the game authored while the medium lived", () => { + const scene = new Scene(); + scene.fog = new FogExp2(0xcfd8e3, 0.003); + const camera = new PerspectiveCamera(); + const fog = required(createVolumetricFog(scene, camera, settings())); + expect(() => fog.compose(pass(scene, camera))).not.toThrow(); + const later = new FogExp2(0x101418, 0.02); + scene.fog = later; + fog.dispose(); + expect(scene.fog).toBe(later); + }); + it("owns no history and disposes its own target and material exactly once", () => { + const camera = new PerspectiveCamera(); + const scene = new Scene(); + const fog = required(createVolumetricFog(scene, camera, settings())); + const scenePass = pass(scene, camera); + expect(fog.compose(scenePass)).toBeDefined(); + expect(fog.diagnostics()).toMatchObject({ + steps: 32, + resolutionScale: 1, + history: false, + volumes: 1, + localLights: 0, + }); + const targetDispose = vi.spyOn(required(fog.target), "dispose"); + const materialDispose = vi.spyOn(required(fog.material), "dispose"); + const depthDispose = vi.spyOn(required(scenePass.renderTarget.depthTexture), "dispose"); + fog.dispose(); + fog.dispose(); + expect(targetDispose).toHaveBeenCalledTimes(1); + expect(materialDispose).toHaveBeenCalledTimes(1); + expect(depthDispose).not.toHaveBeenCalled(); + expect(() => fog.compose(scenePass)).toThrow(/disposed/); + scenePass.dispose(); + }); +}); + +it.each([false, true])("builds the actual depth-clipped transport WGSL (lights=%s)", (lights) => { + const camera = new PerspectiveCamera(); + const options = settings(); + if (lights) { + const sun = new DirectionalLight(); + sun.castShadow = true; + sun.shadow.map = new RenderTarget(32, 32); + sun.shadow.map.depthTexture = new DepthTexture(32, 32); + options.sun = sun; + options.points = [new PointLight(0xffffff, 1, 4)]; + } + const fog = required(createVolumetricFog(new Scene(), camera, options)); + const scenePass = pass(new Scene(), camera); + fog.compose(scenePass); + const object = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); + const renderer = { + backend: { + isWebGPUBackend: true, + capabilities: { getUniformBufferLimit: () => 65_536 }, + utils: { getTextureSampleData: () => ({ primarySamples: 1 }) }, + }, + coordinateSystem: 2001, + hasCompatibility: () => true, + hasFeature: () => false, + getRenderTarget: () => null, + getOutputBufferType: () => 1016, + samples: 0, + getMRT: () => null, + library: { fromMaterial: () => null }, + shadowMap: { enabled: true, type: 1 }, + }; + const builder = new WGSLNodeBuilder(object, renderer as never) as unknown as { + camera: PerspectiveCamera; + setShaderStage(stage: string): void; + flowStagesNode(node: Node, output: string): { code: string }; + }; + builder.camera = camera; + builder.setShaderStage("fragment"); + const flow = builder.flowStagesNode(required(fog.transport), "vec4"); + expect(flow.code).toContain("exp("); + expect(flow.code).toContain("for ("); + expect(flow.code).toContain("32"); + if (lights) { + expect(flow.code).toContain("textureSampleCompare"); + const sampled = /([A-Za-z_][A-Za-z_0-9]*) = textureSampleCompare/.exec(flow.code)?.[1]; + const visibility = new RegExp(`([A-Za-z_][A-Za-z_0-9]*) = ${sampled};`).exec(flow.code)?.[1]; + expect(visibility).toBeDefined(); + // Match the actual generated shader default, not a second JavaScript implementation. + // Outside ordinary shadow coverage the directional source stays unshadowed, like Three. + expect(flow.code).toContain(`${visibility} = 1.0;`); + } + fog.dispose(); + scenePass.dispose(); + options.sun?.shadow.map?.dispose(); +}); + +/** + * The shipped wiring, as source. Two ways this regressed are both silent at runtime: a look that + * lives in a scene file breaks `src/render/` ownership, and a hardcoded backend builds a medium on + * a WebGL fallback that then silently allocates nothing. + */ +describe("the starter's shipped mist", () => { + const source = (relative: string) => + readFile(path.join("packages/create-threenative/templates/starter/src", relative), "utf8").then( + (text) => text, + ); + + it("keeps its flag in the fog source and names no backend of its own", async () => { + const fog = await source("render/volumetricFog.ts"); + expect(fog).toMatch(/export const STARTER_MIST: Omit/u); + expect(fog).toMatch(/enabled: false/u); + expect(fog).not.toMatch(/renderer: "webgpu"/u); + }); + + it("keeps the authored look out of the scene and wires the live backend once", async () => { + const play = await source("scenes/Play.ts"); + expect(play).not.toMatch(/IVolumetricFogOptions|volumes:|albedo:/u); + expect(play).toMatch( + /createVolumetricFog\(ctx\.scene, ctx\.camera as PerspectiveCamera, \{\s*\.\.\.STARTER_MIST,\s*renderer: ctx\.renderer\.kind,?\s*\}\)/u, + ); + }); +}); diff --git a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts index 1a3affec45..f44c0cd516 100644 --- a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts @@ -1,5 +1,6 @@ -import { PerspectiveCamera, Scene } from "three"; +import { PerspectiveCamera, Scene, Texture } from "three"; import BloomNode from "three/addons/tsl/display/BloomNode.js"; +import { rtt, texture, vec4 } from "three/tsl"; import type { Node } from "three/webgpu"; import { expect, it, vi } from "vitest"; import { type OutputRenderer, WorldEnvironment } from "../template-assets/worldEnvironment.js"; @@ -38,3 +39,129 @@ it("releases the bloom effect and its materialized input when its chain is repla applied.dispose?.(); for (const release of releases) expect(release).toHaveBeenCalledTimes(1); }); + +it("owns the direct base-colour graph without requiring an unrelated post stage", () => { + const releases: ReturnType[] = []; + const worldPasses: unknown[] = []; + const outputs: unknown[] = []; + const clearOutputNode = vi.fn(); + const renderer = { + kind: "webgpu", + raw: {}, + clearOutputNode, + setOutputNode(node: unknown, worldPass?: unknown) { + outputs.push(node); + worldPasses.push(worldPass); + (node as Node).traverse((candidate) => { + if (Reflect.get(candidate, "isRTTNode") !== true) return; + const scratch = candidate as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + releases.push(vi.spyOn(scratch.renderTarget, "dispose")); + releases.push(vi.spyOn(scratch._quadMesh.material, "dispose")); + }); + }, + }; + let suppliedPass: unknown; + const applied = new WorldEnvironment({ bloomEnabled: false, screenSpaceAA: "disabled" }).apply( + renderer, + new Scene(), + new PerspectiveCamera(), + { + baseColour(scenePass) { + suppliedPass = scenePass; + releases.push(vi.spyOn(scenePass, "dispose")); + return scenePass.getTextureNode("output").mul(0.5); + }, + }, + ); + expect(applied.stages).toEqual([]); + expect(applied.dispose).toBeTypeOf("function"); + expect(worldPasses).toEqual([suppliedPass]); + expect(releases).toHaveLength(3); + applied.dispose?.(); + applied.dispose?.(); + expect(clearOutputNode).toHaveBeenCalledExactlyOnceWith(outputs[0]); + for (const release of releases) expect(release).toHaveBeenCalledTimes(1); +}); + +it("refuses a direct graph on an unsupported renderer without invoking its allocation factory", () => { + const baseColour = vi.fn(); + const applied = new WorldEnvironment({ bloomEnabled: false, screenSpaceAA: "disabled" }).apply( + { kind: "webgl2", raw: {} }, + new Scene(), + new PerspectiveCamera(), + { baseColour }, + ); + expect(baseColour).not.toHaveBeenCalled(); + expect(applied.dropped).toEqual([{ name: "baseColour", reason: "renderer:webgl2" }]); +}); + +it("disposes a base-colour PassNode without mistaking its borrowed texture for an owned RTT", () => { + let releasePass: ReturnType | undefined; + const applied = new WorldEnvironment({ bloomEnabled: false, screenSpaceAA: "disabled" }).apply( + { kind: "webgpu", raw: {}, setOutputNode: () => {}, clearOutputNode: () => {} }, + new Scene(), + new PerspectiveCamera(), + { + baseColour(scenePass) { + releasePass = vi.spyOn(scenePass, "dispose"); + return scenePass; + }, + }, + ); + expect(() => applied.dispose?.()).not.toThrow(); + applied.dispose?.(); + expect(releasePass).toHaveBeenCalledTimes(1); +}); + +it.each(["texture", "rtt"])("leaves caller-owned %s resources alive", (kind) => { + const supplied = kind === "rtt" ? rtt(vec4(0.2, 0.3, 0.4, 1)) : texture(new Texture()); + const release = vi.spyOn(supplied.value, "dispose"); + const borrowedRtt = + Reflect.get(supplied, "isRTTNode") === true + ? (supplied as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }) + : undefined; + const targetRelease = + borrowedRtt === undefined ? undefined : vi.spyOn(borrowedRtt.renderTarget, "dispose"); + const materialRelease = + borrowedRtt === undefined ? undefined : vi.spyOn(borrowedRtt._quadMesh.material, "dispose"); + const applied = new WorldEnvironment({ bloomEnabled: false, screenSpaceAA: "disabled" }).apply( + { kind: "webgpu", raw: {}, setOutputNode: () => {}, clearOutputNode: () => {} }, + new Scene(), + new PerspectiveCamera(), + { baseColour: () => supplied }, + ); + applied.dispose?.(); + applied.dispose?.(); + expect(release).not.toHaveBeenCalled(); + if (targetRelease !== undefined) expect(targetRelease).not.toHaveBeenCalled(); + if (materialRelease !== undefined) expect(materialRelease).not.toHaveBeenCalled(); + borrowedRtt?.renderTarget.dispose(); + borrowedRtt?._quadMesh.material.dispose(); + supplied.value.dispose(); +}); + +it("releases the unique direct installation receipt rather than the legacy node identity", () => { + const dispose = vi.fn(); + const clearOutputNode = vi.fn(); + const applied = new WorldEnvironment({ bloomEnabled: false, screenSpaceAA: "disabled" }).apply( + { + kind: "webgpu", + raw: {}, + clearOutputNode, + setOutputNode: () => ({ isCurrent: () => true, dispose }), + }, + new Scene(), + new PerspectiveCamera(), + { baseColour: (scenePass) => scenePass }, + ); + applied.dispose?.(); + applied.dispose?.(); + expect(dispose).toHaveBeenCalledTimes(1); + expect(clearOutputNode).not.toHaveBeenCalled(); +}); diff --git a/packages/create-threenative/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/rain/playtests/tiers.playtest.json b/packages/create-threenative/templates/rain/playtests/tiers.playtest.json index 4936e5c97e..172d1e4291 100644 --- a/packages/create-threenative/templates/rain/playtests/tiers.playtest.json +++ b/packages/create-threenative/templates/rain/playtests/tiers.playtest.json @@ -11,6 +11,21 @@ "screenshots": "before-after" }, "steps": [ + { + "kind": "click", + "at": { "element": { "id": "quality" } }, + "waitTicks": 2, + "release": true + }, + { "kind": "input", "press": "Escape", "waitTicks": 2, "release": true }, + { "kind": "input", "press": "End", "waitTicks": 2, "release": true }, + { + "kind": "input", + "press": "ArrowUp", + "label": "requested-high", + "waitTicks": 2, + "release": true + }, { "kind": "wait", "label": "high", diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/rain/src/scenes/Boot.ts b/packages/create-threenative/templates/rain/src/scenes/Boot.ts index 706638505b..59b1de79fc 100644 --- a/packages/create-threenative/templates/rain/src/scenes/Boot.ts +++ b/packages/create-threenative/templates/rain/src/scenes/Boot.ts @@ -86,6 +86,19 @@ export class Coast extends Scene { weather: { ...STORM }, }; + override load(ctx: WeatherCtx): void { + // Pick the study's actual cloud/reflection tier before its first render and before runner + // setup. The post chain's low tier alone does not change these authored passes. + const softwareRendering = ctx.renderer.softwareAdapter !== undefined; + ctx.state.set({ + softwareRendering, + ...(softwareRendering && ctx.state.getState().qualityExplicit !== true + ? { quality: "performance" as const } + : {}), + }); + ctx.state.flush(); + } + override enter(ctx: WeatherCtx): SceneFrame { const camera = ctx.camera as PerspectiveCamera; setupCamera(camera); diff --git a/packages/create-threenative/templates/rain/src/state.ts b/packages/create-threenative/templates/rain/src/state.ts index de2aafb78c..ca2aca2861 100644 --- a/packages/create-threenative/templates/rain/src/state.ts +++ b/packages/create-threenative/templates/rain/src/state.ts @@ -155,7 +155,7 @@ export function intentPatch( if (!QUALITIES.includes(payload as QualityName)) { throw new Error("expected performance, balanced, high or ultra"); } - return { quality: payload as QualityName }; + return { quality: payload as QualityName, qualityExplicit: true }; case "setWeather": return { target: weatherTarget(payload, currentTarget) }; case "pause": @@ -212,6 +212,10 @@ export type GameState = { target: Weather; preset: PresetName; quality: QualityName; + /** A URL or player choice outranks the scene's first-frame default. */ + qualityExplicit?: boolean; + /** Observed before scene entry; the UI must not infer an adapter from window width. */ + softwareRendering?: boolean; elapsed: number; frame: number; flash: number; diff --git a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx index 65549b69da..ac6393883d 100644 --- a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx +++ b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx @@ -7,8 +7,7 @@ import { Menu } from "./Menu.js"; import { automationRequest } from "./automation.js"; const TOAST_MS = 3600; - -/** Below this width the study asks for the cheap tier, exactly as the source study did. */ +/** Preserve the source study's one-time window-width default on hardware. */ const NARROW_PX = 700; export function GameUi() { @@ -138,18 +137,19 @@ function RainInterface() { send("setCinematic", false); }, [send, state]); - /** The first snapshot picks a tier from the width; a later choice is the player's and is kept. */ + /** Scene loading owns the default; the query and later player choices override it. */ const qualityChosen = useRef(false); useEffect(() => { - if (state === undefined || qualityChosen.current) return; + if (state === undefined || state.softwareRendering === undefined || qualityChosen.current) + return; qualityChosen.current = true; - // `?quality=` outranks the width default: an operator asking for `high` on a phone-sized - // window is asking for the tier, not for the guess this would otherwise make. + // An operator asking for high on a phone or software adapter keeps that explicit choice. const asked = automationRequest(window.location.search).quality; if (asked !== undefined) { send("setQuality", asked); return; } + if (state.qualityExplicit === true || state.softwareRendering) return; send("setQuality", window.innerWidth < NARROW_PX ? "performance" : "high"); }, [send, state]); diff --git a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index a3b6db683e..2eb3d90d2a 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -81,7 +81,7 @@ therefore keep the previous clip's pose, and `boneContact` reports in metres whe ## Quality and proof `src/render/quality.ts` owns `low`, `medium`, `high`; platform selects the boot tier, and `adaptiveQuality.ts` reads `game.ts` frame windows. Fresh GPU time wins, with a named presentation fallback. After startup, two overloaded windows lower quality; five with 20% headroom raise it; cooldown is five seconds. Presentation fallback allows 5% timing jitter (`presentationTolerance`); `overloadBudgetMs` reports its threshold. Vsync-bound presentation alone cannot prove recovery headroom. -`setupPost` exposes policy options and `targetFps`; `Play` supplies `display.maxFps` and readiness. Pin `{ tier: "low" }`; invalid tiers throw and pinned costs report. The `quality` entity and `TN_QUALITY_TIER` expose decisions; dispose on scene exit. +`setupPost` exposes policy options and `targetFps`; `Play` supplies `display.maxFps` and readiness. Pin `{ tier: "low" }`; invalid tiers throw and pinned costs report. The `quality` entity and `TN_QUALITY_TIER` expose decisions; dispose on scene exit. A bounded participating medium is generated source you own: `src/render/volumetricFog.ts` and its three companions build depth-correct mist, and `STARTER_MIST` there owns this game's density, colour, bounds and its single `enabled` flag — off costs no target and no pixel. `Play` passes it the live `ctx.renderer.kind`, so the WebGL fallback allocates nothing, and it owns `scene.fog` while it lives, so aerial perspective and god rays must stay off: they render the same air. `setupPost`'s `fog` factory rebuilds it whenever the tier replaces the chain. It is lit by `ambient` until you pass `points` (unshadowed, finite-range `PointLight`s) or a `sun` whose shadow map already exists — nothing wires one for you. The shipped chain is ambient occlusion on `high` and `medium`, bloom, vignette, the tone curve and the shared SMAA. There are no authored paint stages: a real sun and a real sky carry the shading, and a Kuwahara smear or an ink outline over a photograph only cost frames. `quality.ts` owns tier and strength, and every `xxxEnabled: true` carries the measurement that justified it; `TN_RENDER_CHAIN` names each stage independently, and a missing stage observation is a failure. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. A scenario with no assertions or missing observations fails; open a real capture after visual changes. When a frame costs more than it should, find the object first: backtick opens the overlay, whose **Geometry** tab captures one frame on **Capture** and ranks the objects that submitted its triangles beside their projected size on screen; an agent gets the same report from `"assert": { "geometry": { "limit": 25 } }` in a scenario, which needs `runtime.geometry`. Those numbers are measured submissions reconciled against the frame's own pass totals, so the remainder the rows do not account for is reported, and what cannot be measured — a packed batch's per-member draws, unknown bounds — says so with a reason instead of reading as zero. diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index 4b5133c358..531fb9629a 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -83,7 +83,7 @@ therefore keep the previous clip's pose, and `boneContact` reports in metres whe ## Quality and proof `src/render/quality.ts` owns `low`, `medium`, `high`; platform selects the boot tier, and `adaptiveQuality.ts` reads `game.ts` frame windows. Fresh GPU time wins, with a named presentation fallback. After startup, two overloaded windows lower quality; five with 20% headroom raise it; cooldown is five seconds. Presentation fallback allows 5% timing jitter (`presentationTolerance`); `overloadBudgetMs` reports its threshold. Vsync-bound presentation alone cannot prove recovery headroom. -`setupPost` exposes policy options and `targetFps`; `Play` supplies `display.maxFps` and readiness. Pin `{ tier: "low" }`; invalid tiers throw and pinned costs report. The `quality` entity and `TN_QUALITY_TIER` expose decisions; dispose on scene exit. +`setupPost` exposes policy options and `targetFps`; `Play` supplies `display.maxFps` and readiness. Pin `{ tier: "low" }`; invalid tiers throw and pinned costs report. The `quality` entity and `TN_QUALITY_TIER` expose decisions; dispose on scene exit. A bounded participating medium is generated source you own: `src/render/volumetricFog.ts` and its three companions build depth-correct mist, and `STARTER_MIST` there owns this game's density, colour, bounds and its single `enabled` flag — off costs no target and no pixel. `Play` passes it the live `ctx.renderer.kind`, so the WebGL fallback allocates nothing, and it owns `scene.fog` while it lives, so aerial perspective and god rays must stay off: they render the same air. `setupPost`'s `fog` factory rebuilds it whenever the tier replaces the chain. It is lit by `ambient` until you pass `points` (unshadowed, finite-range `PointLight`s) or a `sun` whose shadow map already exists — nothing wires one for you. The shipped chain is ambient occlusion on `high` and `medium`, bloom, vignette, the tone curve and the shared SMAA. There are no authored paint stages: a real sun and a real sky carry the shading, and a Kuwahara smear or an ink outline over a photograph only cost frames. `quality.ts` owns tier and strength, and every `xxxEnabled: true` carries the measurement that justified it; `TN_RENDER_CHAIN` names each stage independently, and a missing stage observation is a failure. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. A scenario with no assertions or missing observations fails; open a real capture after visual changes. When a frame costs more than it should, find the object first: backtick opens the overlay, whose **Geometry** tab captures one frame on **Capture** and ranks the objects that submitted its triangles beside their projected size on screen; an agent gets the same report from `"assert": { "geometry": { "limit": 25 } }` in a scenario, which needs `runtime.geometry`. Those numbers are measured submissions reconciled against the frame's own pass totals, so the remainder the rows do not account for is reported, and what cannot be measured — a packed batch's per-member draws, unknown bounds — says so with a reason instead of reading as zero. diff --git a/packages/create-threenative/templates/starter/src/render/postprocessing.ts b/packages/create-threenative/templates/starter/src/render/postprocessing.ts index d6629a7fb9..e7547055ab 100644 --- a/packages/create-threenative/templates/starter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/starter/src/render/postprocessing.ts @@ -18,6 +18,7 @@ import { formatQualityAdaptation, } from "./adaptiveQuality.js"; import { type QualityTier, qualityPreset } from "./quality.js"; +import type { FogMedium } from "./volumetricFog.js"; import { type OutputRenderer, WorldEnvironment } from "./worldEnvironment.js"; interface IPostController { @@ -44,12 +45,19 @@ export function setupPost( software?: boolean; /** Forces a tier while keeping its costs observed. Unknown names throw. */ tier?: QualityTier; + /** + * 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 + * rather than composed twice. Omit it and the chain starts from the beauty pass unchanged. + */ + fog?: () => FogMedium | undefined; } = {}, ): IPostController { const policy = createAdaptiveQuality(environment, environment); active?.dispose(); let disposed = false; let disposeGraph: (() => void) | undefined; + let medium: FogMedium | undefined; let observation: Record = { tier: policy.tier, source: policy.pinned ? "pinned" : "auto", @@ -57,10 +65,15 @@ export function setupPost( function apply(): void { // Replacement is serialized: no old graph or subscription remains alive beside the new one. disposeGraph?.(); + medium?.dispose(); + medium = environment.fog?.(); + const composed = medium; const settings = qualityPreset(policy.tier); const world = new WorldEnvironment(settings); const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, + // Ahead of exposure and every stage, which is the only place a participating medium can go. + baseColour: composed === undefined ? undefined : (scenePass) => composed.compose(scenePass), }); disposeGraph = applied.dispose; observation = { ...observation, stages: applied.stages, dropped: applied.dropped }; @@ -91,6 +104,8 @@ export function setupPost( disposed = true; disposeGraph?.(); disposeGraph = undefined; + medium?.dispose(); + medium = undefined; if (active === controller) active = undefined; }, }; diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFog.ts b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts new file mode 100644 index 0000000000..23a58e7fa1 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/volumetricFog.ts @@ -0,0 +1,116 @@ +// Generated for you: opt-in Three.js, with no default tier or picture change. +// Compose this medium before exposure/bloom/output. It takes the scene's own fog over while it +// lives and restores it on disposal; aerial haze and god rays must stay off, and a separate +// clear-air sky remains valid. Density/phase/colour belong to this game — see `STARTER_MIST` +// below for the starter's own, which is the object to edit when the mist should be on. +// Pinned Three 0.185.1 VolumeNodeMaterial skips directional lights and couples extinction to +// illumination. This bounded integrator reuses GodraysNode's depth/shadow coordinates instead. +// `volumetricFogOptions.ts` is the contract it accepts, `volumetricFogVolume.ts` the graph it +// builds and owns, and `volumetricFogTransport.ts` the per-step integration inside it. +import { Box3, Color, type PerspectiveCamera, type Scene, Vector3 } from "three"; +import type { Node, NodeMaterial, RenderTarget } from "three/webgpu"; +import type { IFogVolume, IVolumetricFogOptions } from "./volumetricFogOptions.js"; +import { validateFogOptions } from "./volumetricFogOptions.js"; +import type { ScenePass } from "./volumetricFogTransport.js"; +import { type IFogVolumeGraph, composeFogVolume } from "./volumetricFogVolume.js"; + +export type { IFogVolume, IVolumetricFogOptions }; + +/** The owned medium a composed graph exposes: one controller, one graph, one owned target. */ +export type FogMedium = Exclude, undefined>; + +/** + * This game's mist — the one flag, density, colour, bounds and step count, all in this folder + * because they are look decisions. `Play.ts` passes this object and the live renderer's `kind` to + * `createVolumetricFog`; the backend is deliberately not in here, so no scene can claim a backend + * the renderer does not have. + * + * `enabled: false` costs nothing: the medium returns before this module allocates a target, a + * material or a graph, so the shipped picture and the off baseline are exactly what they were + * without it. Set it true and the mist is bounded by `volumes`, thins with height and is lit by + * `ambient` alone. + * + * There is no automatic sun. Pass `sun: key` from `lighting.ts` once `key.shadow.map` exists — the + * graph is built before the first render, so the first medium has no shadow map to sample and none + * is invented; a tier change rebuilds it and picks one up. Up to four unshadowed finite-range + * `PointLight`s need no preparation at all. + */ +export const STARTER_MIST: Omit = { + enabled: false, + steps: 32, + resolutionScale: 0.5, + // A slab over the arena floor: thickest at the boards, thinning with height. + volumes: [ + { + bounds: new Box3(new Vector3(-9, 0, -6), new Vector3(9, 3, 6)), + density: 0.06, + baseHeight: 0, + heightFalloff: 0.5, + }, + ], + albedo: new Color("#cfd8e3"), + ambient: new Color("#0b1016"), + anisotropy: 0.3, + environment: { aerialPerspective: false, godRays: false }, +}; + +/** + * Off, unsupported and zero-density return before allocating any graph/target/material. + * + * While a medium lives it owns the scene's fog: `sky.ts` renders the same air through + * `scene.fog`, so two of them would double it. Disposal puts the authored fog back, unless + * the game authored a new one while the medium lived — that later fog is then the live one. + */ +export function createVolumetricFog( + scene: Scene, + camera: PerspectiveCamera, + supplied: IVolumetricFogOptions, +) { + if (!supplied.enabled || supplied.renderer !== "webgpu") return undefined; + const options = { ...supplied }; + validateFogOptions(camera, options); + const volumes = options.volumes.filter((volume) => volume.density > 0); + if (volumes.length === 0) return undefined; + const authoredFog = scene.fog; + scene.fog = null; + let graph: IFogVolumeGraph | undefined; + let disposed = false; + return { + get target(): RenderTarget | undefined { + return graph?.target; + }, + get material(): NodeMaterial | undefined { + return graph?.material; + }, + get transport(): Node<"vec4"> | undefined { + return graph?.transport; + }, + diagnostics: () => ({ + steps: options.steps, + resolutionScale: options.resolutionScale, + volumes: volumes.length, + localLights: (options.points ?? []).length, + history: false, + renderTargets: graph === undefined ? 0 : 1, + pixels: graph === undefined ? 0 : graph.target.width * graph.target.height, + }), + compose(scenePass: ScenePass): Node<"vec4"> { + if (disposed) throw new Error("volumetricFog: disposed controller."); + if (graph !== undefined) throw new Error("volumetricFog: compose once per owned graph."); + if (scenePass.camera !== camera) + throw new Error("volumetricFog: scene depth must come from this camera."); + if (Reflect.get(scenePass.scene, "fog") != null) + throw new Error("volumetricFog: scene fog duplicates the same medium."); + graph = composeFogVolume(camera, options, volumes, scenePass); + return graph.compose; + }, + dispose(): void { + if (disposed) return; + disposed = true; + graph?.target.dispose(); + graph?.material.dispose(); + graph = undefined; + if (scene.fog == null) scene.fog = authoredFog; + }, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFogOptions.ts b/packages/create-threenative/templates/starter/src/render/volumetricFogOptions.ts new file mode 100644 index 0000000000..0b76d69a12 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/volumetricFogOptions.ts @@ -0,0 +1,96 @@ +// The contract this medium accepts. Everything here is checked before a graph, a target or a +// material exists, so a rejected request costs nothing and names itself in the error. +import type { Box3, Color, DirectionalLight, PerspectiveCamera, PointLight } from "three"; + +export interface IFogVolume { + bounds: Box3; + /** Extinction per metre at/below baseHeight, independent of light intensity. */ + density: number; + baseHeight: number; + heightFalloff: number; +} +export interface IVolumetricFogOptions { + enabled: boolean; + /** The live renderer's backend, `renderer.kind`. Anything but `webgpu` allocates nothing. */ + renderer: string; + /** Explicit unqualified budget, 8–128. No automatic tier admission before measurements. */ + steps: number; + resolutionScale: number; + volumes: IFogVolume[]; + albedo: Color; + ambient: Color; + anisotropy: number; + /** + * A shadow-casting `DirectionalLight` whose `shadow.map` is already initialized — there is no + * other shadow source this graph can read, and none is created for it. + */ + sun?: DirectionalLight; + /** At most four unshadowed finite-range PointLights; spot/cube shadows are excluded. */ + points?: PointLight[]; + logarithmicDepth?: boolean; + reversedDepth?: boolean; + /** + * What else is already drawing the same air. Both false is the only qualified answer: the + * medium takes `scene.fog` over while it lives and puts it back on disposal, so only aerial + * perspective and god rays have to stay off — either would add its contribution a second time. + */ + environment: { aerialPerspective: boolean; godRays: boolean }; +} +function finite(name: string, value: number, low: number, high = Number.POSITIVE_INFINITY): void { + if (!Number.isFinite(value) || value < low || value > high) + throw new Error(`volumetricFog: ${name} must be finite in [${low}, ${high}].`); +} +function validateVolumes(volumes: IFogVolume[]): void { + if (volumes.length > 8) throw new Error("volumetricFog: at most eight bounds."); + for (const volume of volumes) { + finite("density", volume.density, 0); + finite("heightFalloff", volume.heightFalloff, 0); + finite("baseHeight", volume.baseHeight, Number.NEGATIVE_INFINITY); + const { min: low, max: high } = volume.bounds; + if ( + ![...low, ...high].every(Number.isFinite) || + low.x >= high.x || + low.y >= high.y || + low.z >= high.z + ) + throw new Error("volumetricFog: bounds need finite positive extent."); + } +} +export function validateFogOptions( + camera: PerspectiveCamera, + options: IVolumetricFogOptions, +): void { + if (!camera.isPerspectiveCamera) throw new Error("volumetricFog: perspective camera required."); + if (options.logarithmicDepth || options.reversedDepth) + throw new Error("volumetricFog: only ordinary perspective depth is qualified for this graph."); + finite("steps", options.steps, 8, 128); + if (!Number.isInteger(options.steps)) throw new Error("volumetricFog: steps must be integer."); + if (options.resolutionScale !== 1 && options.resolutionScale !== 0.5) + throw new Error("volumetricFog: resolutionScale must be 1 or 0.5."); + finite("anisotropy", options.anisotropy, -0.9, 0.9); + validateVolumes(options.volumes); + for (const channel of options.albedo.toArray()) finite("albedo", channel, 0, 1); + for (const channel of options.ambient.toArray()) finite("ambient", channel, 0); + if (options.environment.aerialPerspective || options.environment.godRays) + throw new Error( + "volumetricFog: aerial perspective and god rays must be off — this graph renders the same medium.", + ); + const sun = options.sun; + if ( + sun !== undefined && + (!sun.castShadow || sun.shadow.map?.depthTexture == null || sun.shadow.shadowNode != null) + ) + throw new Error("volumetricFog: sun needs an initialized ordinary directional shadow map."); + const points = options.points ?? []; + if (points.length > 4) throw new Error("volumetricFog: at most four local lights."); + for (const point of points) { + if ( + !point.isPointLight || + point.castShadow || + !Number.isFinite(point.distance) || + point.distance <= 0 + ) + throw new Error("volumetricFog: local lights must be unshadowed finite-range PointLights."); + finite("point decay", point.decay, 0, 4); + } +} diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFogTransport.ts b/packages/create-threenative/templates/starter/src/render/volumetricFogTransport.ts new file mode 100644 index 0000000000..fc88569b9e --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/volumetricFogTransport.ts @@ -0,0 +1,176 @@ +// The integrator: what one bounded volume does to one pixel, marching `steps` equal slices +// between the depth-correct entry and exit of the camera ray. Everything it reads arrives on +// `inputs`, so the uniforms that describe the camera, the bounds and the lights stay with the +// graph that owns them. +import type { DepthTexture, DirectionalLight, PointLight } from "three"; +import { + Fn, + If, + Loop, + float, + getViewPosition, + lightShadowMatrix, + max, + min, + type pass, + reference, + screenUV, + texture, + vec2, + vec3, + vec4, +} from "three/tsl"; +import type { Node } from "three/webgpu"; + +export type ScenePass = ReturnType; +type FogDepth = ReturnType; +export interface IFogBounds { + low: Node<"vec3">; + high: Node<"vec3">; + /** Extinction per metre at/below baseHeight, independent of light intensity. */ + density: number; + baseHeight: number; + heightFalloff: number; +} +export interface IFogLocalLight { + point: PointLight; + radiance: Node<"vec3">; + position: Node<"vec3">; +} +export interface IFogTransportInputs { + albedo: Node<"vec3">; + ambient: Node<"vec3">; + anisotropy: number; + bounds: IFogBounds[]; + cameraMatrix: Node<"mat4">; + depth: FogDepth; + inverseProjection: Node<"mat4">; + localLights: IFogLocalLight[]; + origin: Node<"vec3">; + shadowDepth: DepthTexture | null | undefined; + steps: number; + sun: DirectionalLight | undefined; + sunDirection: Node<"vec3"> | undefined; + sunRadiance: Node<"vec3"> | undefined; +} + +export function fogTransport(inputs: IFogTransportInputs): Node<"vec4"> { + const { + albedo, + ambient, + anisotropy, + bounds, + cameraMatrix, + depth, + inverseProjection, + localLights, + origin, + shadowDepth, + steps, + sun, + sunDirection, + sunRadiance, + } = inputs; + const phase = (cosine: Node<"float">) => + float(1 - anisotropy ** 2).div( + float(1 + anisotropy ** 2) + .sub(cosine.mul(2 * anisotropy)) + .pow(1.5) + .mul(4 * Math.PI), + ); + return Fn(() => { + const endpoint = cameraMatrix.mul( + vec4(getViewPosition(screenUV, depth.r, inverseProjection), 1), + ).xyz; + const toSurface = endpoint.sub(origin).toVar(); + const distance = toSurface.length().max(0.000001).toVar(); + const direction = toSurface.div(distance).toVar(); + const safeDirection = direction + .greaterThanEqual(0) + .select(direction.abs().max(0.000001), direction.abs().max(0.000001).negate()); + const near = distance.toVar(); + const far = float(0).toVar(); + for (const volume of bounds) { + const a = volume.low.sub(origin).div(safeDirection); + const b = volume.high.sub(origin).div(safeDirection); + const entry = min(a, b); + const exit = max(a, b); + const start = max(max(entry.x, entry.y), max(entry.z, 0)); + const end = min(min(exit.x, exit.y), min(exit.z, distance)); + If(end.greaterThan(start), () => { + near.assign(min(near, start)); + far.assign(max(far, end)); + }); + } + const transmission = float(1).toVar(); + const scattering = vec3(0).toVar(); + If(far.greaterThan(near), () => { + const ds = far.sub(near).div(steps).toVar(); + Loop(steps, ({ i }) => { + const position = origin.add(direction.mul(near.add(float(i).add(0.5).mul(ds)))).toVar(); + const extinction = float(0).toVar(); + for (const volume of bounds) { + If( + position + .greaterThanEqual(volume.low) + .all() + .and(position.lessThanEqual(volume.high).all()), + () => { + extinction.addAssign( + position.y + .sub(volume.baseHeight) + .max(0) + .mul(-volume.heightFalloff) + .exp() + .mul(volume.density), + ); + }, + ); + } + const illumination = vec3(ambient).toVar(); + if ( + sun !== undefined && + shadowDepth != null && + sunDirection !== undefined && + sunRadiance !== undefined + ) { + const projected = lightShadowMatrix(sun).mul(vec4(position, 1)); + const coordinate = projected.xyz.div(projected.w).toVar(); + // Outside the finite shadow map, Three treats the directional source as unshadowed. + const visibility = float(1).toVar(); + If(coordinate.greaterThanEqual(0).all().and(coordinate.lessThanEqual(1).all()), () => { + visibility.assign( + texture(shadowDepth, vec2(coordinate.x, coordinate.y.oneMinus())).compare( + coordinate.z.add(reference("bias", "float", sun.shadow)), + ), + ); + }); + illumination.addAssign( + sunRadiance.mul(phase(direction.dot(sunDirection))).mul(visibility), + ); + } + for (const local of localLights) { + const toLight = local.position.sub(position); + const radius = toLight.length().max(0.01); + const attenuation = radius + .div(local.point.distance) + .pow(4) + .oneMinus() + .max(0) + .pow(2) + .div(radius.pow(local.point.decay)); + illumination.addAssign( + local.radiance.mul(attenuation).mul(phase(direction.dot(toLight.div(radius)))), + ); + } + // Exact homogeneous-step solution. Extinction persists when every light is off. + const stepTransmission = extinction.mul(ds).negate().exp(); + scattering.addAssign( + transmission.mul(stepTransmission.oneMinus()).mul(albedo).mul(illumination), + ); + transmission.mulAssign(stepTransmission); + }); + }); + return vec4(scattering, transmission); + })(); +} diff --git a/packages/create-threenative/templates/starter/src/render/volumetricFogVolume.ts b/packages/create-threenative/templates/starter/src/render/volumetricFogVolume.ts new file mode 100644 index 0000000000..37e824e242 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/volumetricFogVolume.ts @@ -0,0 +1,139 @@ +// The volume graph: what the camera, the bounds and the lights are each frame, plus the target +// the transport is rendered into and the one screen-space sample that reads it back. It owns that +// target and its material; the controller that owns this graph disposes both. +import { + type DirectionalLight, + HalfFloatType, + NearestFilter, + type PerspectiveCamera, + type PointLight, + Vector3, +} from "three"; +import { Fn, If, float, getViewPosition, max, rtt, screenUV, uniform, vec2, vec4 } from "three/tsl"; +import type { Node, NodeMaterial, RenderTarget } from "three/webgpu"; +import type { IFogVolume, IVolumetricFogOptions } from "./volumetricFogOptions.js"; +import { + type IFogBounds, + type IFogLocalLight, + type ScenePass, + fogTransport, +} from "./volumetricFogTransport.js"; + +type FogTexture = ReturnType & { + renderTarget: RenderTarget; + _quadMesh: { material: NodeMaterial }; +}; +export interface IFogVolumeGraph { + /** The bounded integrator itself, before the target it is rendered into. */ + transport: Node<"vec4">; + target: RenderTarget; + material: NodeMaterial; + /** Scene radiance attenuated by the medium, composited before exposure. */ + compose: Node<"vec4">; +} + +function lightRadiance(light: DirectionalLight | PointLight) { + const value = new Vector3(); + return uniform(value).onRenderUpdate(() => + value + .set(light.color.r, light.color.g, light.color.b) + .multiplyScalar(light.visible ? light.intensity : 0), + ); +} + +export function composeFogVolume( + camera: PerspectiveCamera, + options: IVolumetricFogOptions, + volumes: IFogVolume[], + scenePass: ScenePass, +): IFogVolumeGraph { + const bounds: IFogBounds[] = volumes.map((volume) => ({ + ...volume, + low: uniform(volume.bounds.min.clone()), + high: uniform(volume.bounds.max.clone()), + })); + const cameraMatrix = uniform(camera.matrixWorld); + const inverseProjection = uniform(camera.projectionMatrixInverse); + const cameraPosition = new Vector3(); + const origin = uniform(cameraPosition).onRenderUpdate(() => + camera.getWorldPosition(cameraPosition), + ); + const ambient = uniform(new Vector3(options.ambient.r, options.ambient.g, options.ambient.b)); + const albedo = uniform(new Vector3(options.albedo.r, options.albedo.g, options.albedo.b)); + const sun = options.sun; + const shadowDepth = sun?.shadow.map?.depthTexture; + const directionValue = new Vector3(); + const targetValue = new Vector3(); + const sunDirection = + sun === undefined + ? undefined + : uniform(directionValue).onRenderUpdate(() => + sun + .getWorldPosition(directionValue) + .sub(sun.target.getWorldPosition(targetValue)) + .normalize(), + ); + const sunRadiance = sun === undefined ? undefined : lightRadiance(sun); + const localLights: IFogLocalLight[] = (options.points ?? []).map((point) => { + const position = new Vector3(); + return { + point, + radiance: lightRadiance(point), + position: uniform(position).onRenderUpdate(() => point.getWorldPosition(position)), + }; + }); + const depth = scenePass.getTextureNode("depth"); + const scene = scenePass.getTextureNode("output"); + const inputs = { + albedo, + ambient, + anisotropy: options.anisotropy, + bounds, + cameraMatrix, + depth, + inverseProjection, + localLights, + origin, + shadowDepth, + steps: options.steps, + sun, + sunDirection, + sunRadiance, + }; + const transport = fogTransport(inputs); + const rendered = rtt(transport, null, null, { + depthBuffer: false, + type: HalfFloatType, + minFilter: NearestFilter, + magFilter: NearestFilter, + }) as FogTexture; + rendered.setResolutionScale(options.resolutionScale); + const sizeValue = new Vector3(); + const size = uniform(sizeValue).onRenderUpdate(() => + sizeValue.set(rendered.renderTarget.width, rendered.renderTarget.height, 0), + ); + const compose = Fn(() => { + const result = rendered.sample(screenUV).toVar(); + // Half-resolution nearest sampling cannot blend fog across a wall. At a depth mismatch, + // integrate the full-resolution pixel instead; do not borrow stale/foreground history. + if (options.resolutionScale < 1) { + const lowUV = screenUV.mul(size.xy).floor().add(0.5).div(size.xy); + const fullZ = getViewPosition(screenUV, depth.r, inverseProjection).z; + const lowZ = getViewPosition(lowUV, depth.sample(lowUV).r, inverseProjection).z; + If( + fullZ + .sub(lowZ) + .abs() + .greaterThan(max(fullZ.abs().mul(0.01), 0.01)), + () => result.assign(fogTransport(inputs)), + ); + } + return vec4(scene.rgb.mul(result.a).add(result.rgb), scene.a); + })(); + return { + transport, + target: rendered.renderTarget, + material: rendered._quadMesh.material, + compose, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index 5f024c1a60..52129f90be 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -32,6 +32,7 @@ import { setupPost } from "../render/postprocessing.js"; import { ball, block, spike, tube } from "../render/shapes.js"; import { setupSky } from "../render/sky.js"; import { TouchControls } from "../render/touch-controls.js"; +import { STARTER_MIST, createVolumetricFog } from "../render/volumetricFog.js"; import type { GameState } from "../state.js"; export type GameCtx = ICtx; @@ -156,6 +157,14 @@ export class Play extends Scene { // 60 on mobile. A game that names `display.maxFps` overrides it here too. targetFps: resolveTargetFps(config, getPlatform()).targetFps, ready: () => ctx.startup.phase === "ready", + // Rebuilt per graph, so a tier change that replaces the chain cannot compose one medium + // twice. The look and the flag are `STARTER_MIST`'s, in `volumetricFog.ts`; only the + // backend is wired here, and with `enabled` false nothing is allocated at all. + fog: () => + createVolumetricFog(ctx.scene, ctx.camera as PerspectiveCamera, { + ...STARTER_MIST, + renderer: ctx.renderer.kind, + }), }), ); const loading = createLoadingScreen(ctx); @@ -352,6 +361,7 @@ export class Play extends Scene { #post: ReturnType | undefined; override exit(ctx: GameCtx): void { + // Also releases the medium `setupPost` built, with the graph it composed into. this.#post?.dispose(); this.#post = undefined; super.exit(ctx); diff --git a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts index f61ec7a8e9..db8c58ad10 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts @@ -327,6 +327,11 @@ function stage(definition: { function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } +type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type OutputSetter = + | ((node: unknown, worldPass?: unknown) => void) + | ((node: unknown, worldPass?: unknown) => OutputInstallation); + export type OutputRenderer = { kind: string; raw: unknown; @@ -335,7 +340,9 @@ export type OutputRenderer = { * stage is off: the chain installs nothing for an empty stage list, and dropping the game's * own composition on the floor would be the silent no-op this class exists to prevent. */ - setOutputNode?: (node: unknown) => void; + setOutputNode?: OutputSetter; + /** Legacy node-only clear. The returned receipt distinguishes same-node reinstalls. */ + clearOutputNode?: (expectedNode?: unknown) => void; createRenderChain?: (options: { input?: unknown; worldPass?: unknown; @@ -466,6 +473,11 @@ export class WorldEnvironment { this.#reportApplied([], []); return { dropped: [], stages: [] }; } + if (requested.length === 0 && renderer.kind !== "webgpu") { + const dropped = [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]; + this.#reportApplied([], dropped); + return { dropped, stages: [] }; + } raw.toneMappingExposure = 1; const scenePass = pass(scene, camera); @@ -500,7 +512,24 @@ export class WorldEnvironment { const view = camera as PerspectiveCamera; const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); - const exposed = convertToTexture(base).mul(options.exposure); + const baseTexture = convertToTexture(base); + const exposed = baseTexture.mul(options.exposure); + let released = false; + const releaseGraph = (): void => { + if (released) return; + released = true; + // Only a newly created RTT is ours. A PassNode converts to a borrowed PassTextureNode; + // supplied TextureNodes/RTTs belong to the caller even when their resources are shared. + if (baseTexture !== base && Reflect.get(baseTexture, "isRTTNode") === true) { + const owned = baseTexture as unknown as { + renderTarget: { dispose(): void }; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + scenePass.dispose(); + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -727,15 +756,35 @@ export class WorldEnvironment { // A composed base colour with every stage off still has to reach the frame. The chain // installs nothing for an empty stage list, so this is the one path that goes direct. if (requested.length === 0) { - if (renderer.kind !== "webgpu") { - this.#reportApplied([], [{ name: "baseColour", reason: `renderer:${renderer.kind}` }]); - return { dropped: [], stages: [] }; + if (renderer.setOutputNode === undefined || renderer.clearOutputNode === undefined) { + releaseGraph(); + throw new Error("Output node installation and ownership-aware disposal are required."); + } + let installation: OutputInstallation | undefined; + try { + const returned = renderer.setOutputNode(exposed, scenePass); + installation = + returned && + typeof returned.isCurrent === "function" && + typeof returned.dispose === "function" + ? returned + : undefined; + } catch (error) { + releaseGraph(); + throw error; } - if (renderer.setOutputNode === undefined) - throw new Error("setOutputNode is unavailable, so the composed base colour cannot run."); - renderer.setOutputNode(exposed); this.#reportApplied([], []); - return { dropped: [], stages: [] }; + return { + dropped: [], + stages: [], + dispose: () => { + if (released) return; + if (installation) installation.dispose(); + // Legacy adapters returning void retain node-only, not installation-level safety. + else renderer.clearOutputNode?.(exposed); + releaseGraph(); + }, + }; } if (renderer.createRenderChain === undefined) throw new Error("RenderChain is unavailable."); @@ -750,8 +799,9 @@ export class WorldEnvironment { dropped: chain.applied.dropped, stages: chain.applied.stages, dispose: () => { + if (released) return; chain.dispose(); - scenePass.dispose(); + releaseGraph(); }, }; } diff --git a/packages/runtime-native/CMakeLists.txt b/packages/runtime-native/CMakeLists.txt index be45c01523..c8cc1443da 100644 --- a/packages/runtime-native/CMakeLists.txt +++ b/packages/runtime-native/CMakeLists.txt @@ -2510,11 +2510,23 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi add_executable(threenative-webgpu-comprehensive-test EXCLUDE_FROM_ALL tests/webgpu_comprehensive_test.cpp) target_link_libraries(threenative-webgpu-comprehensive-test PRIVATE mystral-runtime) + add_executable(threenative-canvas-presentation-test EXCLUDE_FROM_ALL + tests/canvas_presentation_test.cpp) + target_link_libraries(threenative-canvas-presentation-test PRIVATE mystral-runtime) + add_executable(threenative-surface-texture-status-test EXCLUDE_FROM_ALL + tests/surface_texture_status_test.cpp) + target_link_libraries(threenative-surface-texture-status-test PRIVATE mystral-runtime) if(MYSTRAL_USE_DAWN) target_include_directories(threenative-webgpu-comprehensive-test PRIVATE ${DAWN_INCLUDE_DIR}) + target_include_directories(threenative-surface-texture-status-test PRIVATE ${DAWN_INCLUDE_DIR}) + target_include_directories(threenative-canvas-presentation-test PRIVATE ${DAWN_INCLUDE_DIR}) elseif(MYSTRAL_USE_WGPU AND TARGET wgpu::wgpu) target_include_directories(threenative-webgpu-comprehensive-test PRIVATE ${WGPU_TEST_INCLUDES}) + target_include_directories(threenative-surface-texture-status-test PRIVATE ${WGPU_TEST_INCLUDES}) + target_include_directories(threenative-canvas-presentation-test PRIVATE ${WGPU_TEST_INCLUDES}) endif() + tn_register_contract_test(threenative-surface-texture-status-test) + tn_register_contract_test(threenative-canvas-presentation-test) add_executable(threenative-js-module-comprehensive-test EXCLUDE_FROM_ALL tests/js_module_comprehensive_test.cpp) diff --git a/packages/runtime-native/build-matrix.json b/packages/runtime-native/build-matrix.json index de2e9fb36c..3917bcbcd3 100644 --- a/packages/runtime-native/build-matrix.json +++ b/packages/runtime-native/build-matrix.json @@ -14,6 +14,7 @@ "threenative-bindings-creation-test", "threenative-canvas-audio-comprehensive-test", "threenative-canvas2d-dirty-test", + "threenative-canvas-presentation-test", "threenative-cli-network-fs-test", "threenative-command-encoder-class-table-test", "threenative-crash-handler-policy-test", @@ -40,6 +41,7 @@ "threenative-shutdown-lifetime-test", "threenative-stall-budget-hitch-test", "threenative-surface-format-selection-test", + "threenative-surface-texture-status-test", "threenative-timer-delivery-test", "threenative-timer-engine-first-test", "threenative-timestamp-query-test", @@ -66,6 +68,7 @@ "threenative-bindings-creation-test", "threenative-canvas-audio-comprehensive-test", "threenative-canvas2d-dirty-test", + "threenative-canvas-presentation-test", "threenative-cli-network-fs-test", "threenative-command-encoder-class-table-test", "threenative-crash-handler-policy-test", @@ -92,6 +95,7 @@ "threenative-shutdown-lifetime-test", "threenative-stall-budget-hitch-test", "threenative-surface-format-selection-test", + "threenative-surface-texture-status-test", "threenative-timer-delivery-test", "threenative-timer-engine-first-test", "threenative-timestamp-query-test", diff --git a/packages/runtime-native/docs/G1-desktop-host.md b/packages/runtime-native/docs/G1-desktop-host.md index e6fe26377e..69296ea555 100644 --- a/packages/runtime-native/docs/G1-desktop-host.md +++ b/packages/runtime-native/docs/G1-desktop-host.md @@ -418,3 +418,18 @@ Refreshed executable: `wrap-desktop-build.log` records successful packaging with UI overlay compositing follow-up: the overlay's refusal was correct and unreachable. Nothing blends on a bare Xvfb — it has COMPOSITE off and no compositing manager, and a child redirected with `CompositeRedirectAutomatic` still reads back unblended on this server — so the host refused to attach, and every private display the repository provisions was exactly that display. Both helpers that provision one now enable COMPOSITE and SHAPE and start an installed compositing manager for their own display (`xcompmgr -n`, else picom or compton), wait a fixed 250 ms and drop one that had already exited (a settle plus a liveness check, not a poll for `_NET_WM_CM_S0`, so a compositor slower than the settle is still not accounted for): the playtest runner's `captureEnvironment`, which reports the name it started (and now records it in the `captureDisplay` diagnostic), and `packages/runtime-native/scripts/xvfb.sh`, which the repo-root `scripts/xvfb.sh` now execs rather than reimplementing — the first version of this fix landed in the packaged copy alone and no gate in the repository ran it. `scripts/__tests__/xvfb.spec.ts` runs the display assertions against both copies for that reason, and the four Linux CI apt blocks install xcompmgr. A host with none installed still gets its display and still gets the refusal, naming the dependency. Evidence: the same fixture refused with `TN_UI_OVERLAY:{"attached":false,"reason":"no compositing manager is running, so nothing would blend the overlay"}` on a bare display and reported `TN_UI_OVERLAY:{"attached":true}` under the root wrapper with a compositor, from `build/tn-linux/mystral` sha256 `a500aca3ae6e0e6694c68891cab9e2150ce7f7a739f53af07e428fbd6cd6a6a9`. This is X11 only; it proves attachment on the executed Linux lane, not a blended game frame, and not macOS or Windows. Canvas and audio follow-up on the same binary: `ctx.setLineDash(5)` used to read a `length` off a number, cast the NaN to a size_t near 2^63 and push floats until the host died — a hang from a plain game typo where a browser throws. The binding now refuses a non-sequence and stops at the first missing, negative or non-finite entry, so an object that merely claims a huge length allocates nothing; under an identical 1500 MB memory cap the pre-fix runtime was killed at the cap (exit 137) and the fixed one passed. The spec's odd-length doubling moved into `Canvas2DContext::setLineDash`, the entry every caller shares, so `setLineDash([5])` reads back `[5, 5]` and the stored list is the list that strokes. `threenative-canvas2d-dirty-test` now reads back pixels for a radial gradient's centre and rim, a dashed stroke's dash/gap/dash, and a clip that paints inside its region and nothing outside; `threenative-audio-graph-test` covers both legs of scheduled playback, a cue at an absolute start time sounding and a cue one second ahead staying silent and pending. All green locally; no performance claim is attached to either. + + +## Bounded-fog resize isolation — 2026-10-02 + +[Run37011447056](https://github.com/ThreeNativeHQ/threenative/actions/runs/37011447056) built package0.3.4 from checkout `7f710b7d3af5e2da18017e4a0f707d4217ddf85d` on Linux ARM64, QuickJS/wgpu-native25.0.2.2, Mesa llvmpipe. Fifteen authored fog/lifecycle captures passed; renderer resize failed. The identical no-fog resize control first reports `TN_SURFACE_ACQUIRE_FAILED` status2, then failed-current-texture and unfinished-object errors, with no PNG. The original fog resize separately reproduces an acquire-already-held SIGABRT. Runtime SHA256 is `da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042`; artifact11227694772 SHA256 is `8ae66c5a60cba94b1d2b96c771ee2f3f7dbb98472b95ea980a067c83976da2f2`. + +The native compatibility helper incorrectly rejected modern SuccessSuboptimal even though it carries an acquired texture. The narrow status correction has a red-green compiled helper test; timeout/outdated/lost/error remain failures. The integration gate builds and runs `threenative-surface-texture-status-test`, then both unmodified resize sequences. Repaired-host execution remains pending, so this is not a runtime pass or a hardware-performance claim. Full source/adapter/error lineage remains in the [VQ07 PRD](../../../docs/PRDs/done/PRD-VQ-07-volumetric-fog.md). + + +Native status review correction: the new target now has an execution contract and required pass marker in `verify-native-contracts.mjs`; all 46 discovered targets pass its actual registration validator. The C++ contract compiles and executes against both the official Dawn `d14ae3d97a` generated headers and wgpu header pin `bac520839ff5ed2e2b648ed540bd9ec45edbccbc`. Only the wgpu branch refers to its distinct OutOfMemory/DeviceLost enums; pinned Dawn represents these errors with Error. Both real compiled binaries pass the registered execution lane and marker check. Local native-contract tests pass 8/9; the unrelated webtransport configuration test cannot run because CMake is absent. The build-matrix/coverage tests pass 15/15. This is status-contract proof, not a rebuilt native host or a comprehensive surface-owner audit. Repaired-host qualification remains pending. + +VQ07 physical/render-extent correction checkpoint: the rebuilt `ff59e248` host still failed both resize sequences in run 37018968247 with Outdated acquisition after the canvas changed to 320×240 while the native drawable remained 640×400. The repair now keeps these extents separate and reuses the offscreen presentation draw for size mismatch, with normalized whole-image sampling and format-correct transfer. The concurrent `b0727a4` Outdated-only, single-retry recovery is preserved, using physical extent and retaining the bridge's source image/view ownership. Compiled production-body resize/retry tests and 69 focused native checks pass; independent review and pinned-Dawn syntax checks pass. The existing integration lane additionally requires the registered `threenative-canvas-presentation-test` to render/read every pixel of larger/equal/smaller linear and sRGB destinations before both no-fog controls and 29 authored fog arms run. Actual rebuilt-host and destination-pixel GPU execution remain pending; no new native or hardware-performance result is claimed. + + +Actual VQ07 correctness proof now passes at source `640c103017d56e56abb56745eb875885c32aec06`, [run 37046042533](https://github.com/ThreeNativeHQ/threenative/actions/runs/37046042533): the ordinary rebuilt Linux ARM64 host passes its acquisition-status contract, real larger/equal/smaller linear/sRGB destination-pixel GPU test, both no-fog resize controls and all 29 authored fog/light/transition/cost arms. Native artifact 11244629357 SHA256 is `003e4ee6e591d136d6c61abbad74b75605b6b11dc3e14afbf61831d1bc297710`. Native runtime/bundle hashes and original PNG identities are recorded in [minimal provenance](../../../docs/verification/vq07-progress/640c103-native/provenance.json). Canvas resize/restore, retained camera and streamed-wall changes, scene exits/re-entry, exact off pixels and settled texture baseline pass. This is Mesa llvmpipe software correctness, not hardware performance or untested OS-window/background/foreground behavior. A later latest-develop reconciliation is awaiting its own runtime requalification; no new pass is inferred from unchanged component hashes. diff --git a/packages/runtime-native/include/mystral/platform/window.h b/packages/runtime-native/include/mystral/platform/window.h index 665d30e7a6..684d807fd7 100644 --- a/packages/runtime-native/include/mystral/platform/window.h +++ b/packages/runtime-native/include/mystral/platform/window.h @@ -87,6 +87,11 @@ void setMetalLayerDrawableSize(void* metalLayer, int width, int height); */ void getWindowSize(int* width, int* height); +// Actual drawable pixels, independent of the canvas/input-coordinate cache. Android uses +// the native handle owned by the live WebGPU surface, not a replacement SDL window. +// Called on the SDL/main thread. False means no valid drawable is currently available. +bool getWindowDrawableSize(void* surfaceNativeHandle, int* width, int* height); + /** * Set fullscreen mode */ diff --git a/packages/runtime-native/include/mystral/webgpu/bindings.h b/packages/runtime-native/include/mystral/webgpu/bindings.h index 9856012dc5..dd6ada1ff6 100644 --- a/packages/runtime-native/include/mystral/webgpu/bindings.h +++ b/packages/runtime-native/include/mystral/webgpu/bindings.h @@ -41,7 +41,8 @@ bool initBindings( uint32_t width, uint32_t height, bool debug, - void* wgpuAdapter = nullptr); + void* wgpuAdapter = nullptr, + void* surfaceNativeHandle = nullptr); // Drops every reference to the live presentation surface, ahead of a rebuild. Android replaces // the `ANativeWindow` behind a backgrounded app rather than reconfiguring it, and wgpu-native @@ -50,7 +51,8 @@ void detachSurfaceForRebuild(BindingsState* state); // Publishes a rebuilt surface to the bindings, which is where every present reads it from. void republishSurface(BindingsState* state, void* wgpuSurface, uint32_t surfaceFormat, - uint32_t presentMode, uint32_t width, uint32_t height); + uint32_t presentMode, uint32_t width, uint32_t height, + void* surfaceNativeHandle = nullptr); void setOffscreenTexture(BindingsState* state, void* texture, void* textureView); void beginDawnFrame(BindingsState* state); diff --git a/packages/runtime-native/include/mystral/webgpu_compat.h b/packages/runtime-native/include/mystral/webgpu_compat.h index d826bdaeb9..b9624feec0 100644 --- a/packages/runtime-native/include/mystral/webgpu_compat.h +++ b/packages/runtime-native/include/mystral/webgpu_compat.h @@ -69,6 +69,7 @@ typedef WGPUBufferMapAsyncStatus WGPUBufferMapAsyncStatus_Compat; #define WGPU_SURFACE_TEXTURE_STATUS_TYPE WGPUSurfaceGetCurrentTextureStatus #define WGPUSurfaceGetCurrentTextureStatus_Success_Compat WGPUSurfaceGetCurrentTextureStatus_Success #define WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal_Compat WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal +#define WGPUSurfaceGetCurrentTextureStatus_Outdated_Compat WGPUSurfaceGetCurrentTextureStatus_Outdated #define WGPUSurfaceGetCurrentTextureStatus_Error_Compat WGPUSurfaceGetCurrentTextureStatus_Error // Error types - wgpu-native has DeviceLost @@ -173,6 +174,7 @@ typedef WGPUMapAsyncStatus WGPUBufferMapAsyncStatus_Compat; #define WGPU_SURFACE_TEXTURE_STATUS_TYPE WGPUSurfaceGetCurrentTextureStatus #define WGPUSurfaceGetCurrentTextureStatus_Success_Compat WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal #define WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal_Compat WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal +#define WGPUSurfaceGetCurrentTextureStatus_Outdated_Compat WGPUSurfaceGetCurrentTextureStatus_Outdated #define WGPUSurfaceGetCurrentTextureStatus_Error_Compat WGPUSurfaceGetCurrentTextureStatus_Error // Error types - Dawn removed DeviceLost as an error type (maps to Unknown) @@ -254,8 +256,26 @@ inline void setupShaderModuleWGSL(WGPUShaderModuleDescriptor* desc, // ============================================================================ inline bool wgpuSurfaceTextureStatusIsSuccess(int status) { +#if defined(MYSTRAL_WEBGPU_DAWN) || defined(MYSTRAL_WEBGPU_WGPU_MODERN) + // Both modern backends return a usable acquired texture for either success status. + // Rejecting Suboptimal strands that image instead of presenting/releasing it. + return status == WGPUSurfaceGetCurrentTextureStatus_Success_Compat || + status == WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal_Compat; +#else + // Older headers report suboptimal separately from their single Success status. return status == WGPUSurfaceGetCurrentTextureStatus_Success_Compat; - // Note: Suboptimal is also considered success on wgpu, but Dawn maps both to Success +#endif +} + +/** + * The swapchain has to be rebuilt before another image can be acquired. + * + * Unlike the other failures, Outdated is not a dead end: the platform's presentation system has + * declared the swapchain stale, and a fresh `SurfaceConfigure` is the documented way back. Callers + * that can reconfigure treat it as recoverable; the ones that cannot still see it as a failure. + */ +inline bool wgpuSurfaceTextureStatusNeedsReconfigure(int status) { + return status == WGPUSurfaceGetCurrentTextureStatus_Outdated_Compat; } #endif // MYSTRAL_WEBGPU_COMPAT_H diff --git a/packages/runtime-native/scripts/verify-native-contracts.mjs b/packages/runtime-native/scripts/verify-native-contracts.mjs index 02761bfa94..2e6b2e4ae7 100644 --- a/packages/runtime-native/scripts/verify-native-contracts.mjs +++ b/packages/runtime-native/scripts/verify-native-contracts.mjs @@ -103,6 +103,12 @@ export const executionContracts = { "threenative-presentation-pacing-test": { invocations: [{ args: [], passLine: "PRESENTATION_PACING_OK" }], }, + "threenative-canvas-presentation-test": { + invocations: [{ args: [], passLine: "native canvas presentation pixels passed" }], + }, + "threenative-surface-texture-status-test": { + invocations: [{ args: [], passLine: "native surface acquisition status contract passed" }], + }, "threenative-local-storage-test": { invocations: [{ args: [], passLine: "local_storage bindings: all assertions passed" }], }, diff --git a/packages/runtime-native/src/platform/window.cpp b/packages/runtime-native/src/platform/window.cpp index 78ec04ecdc..4a2d760ffd 100644 --- a/packages/runtime-native/src/platform/window.cpp +++ b/packages/runtime-native/src/platform/window.cpp @@ -19,6 +19,9 @@ #include #include #include +#if defined(__ANDROID__) +#include +#endif #include "stb_image.h" namespace mystral { @@ -569,6 +572,22 @@ void getWindowSize(int* width, int* height) { *height = g_window.height; } +bool getWindowDrawableSize(void* surfaceNativeHandle, int* width, int* height) { + *width = 0; + *height = 0; +#if defined(__ANDROID__) + if (!surfaceNativeHandle) return false; + auto* window = static_cast(surfaceNativeHandle); + *width = ANativeWindow_getWidth(window); + *height = ANativeWindow_getHeight(window); +#else + (void)surfaceNativeHandle; + if (!g_window.sdlWindow || !SDL_GetWindowSizeInPixels(g_window.sdlWindow, width, height)) + return false; +#endif + return *width > 0 && *height > 0; +} + /** * Set fullscreen mode */ diff --git a/packages/runtime-native/src/runtime.cpp b/packages/runtime-native/src/runtime.cpp index 14e9f04948..c132b8607e 100644 --- a/packages/runtime-native/src/runtime.cpp +++ b/packages/runtime-native/src/runtime.cpp @@ -799,7 +799,7 @@ class RuntimeImpl : public Runtime { bindingsState_ = webgpu::createBindingsState(); bindingsState_->pipelineCache.identity.app = config_.pipelineCacheAppIdentity; bindingsState_->pipelineCache.identity.shaders = config_.pipelineCacheSourceIdentity; - if (!webgpu::initBindings(bindingsState_, jsEngine_.get(), webgpu_->getInstance(), webgpu_->getDevice(), webgpu_->getQueue(), surface, webgpu_->getPreferredFormat(), webgpu_->getPresentMode(), width_, height_, config_.debug, webgpu_->getAdapter())) { + if (!webgpu::initBindings(bindingsState_, jsEngine_.get(), webgpu_->getInstance(), webgpu_->getDevice(), webgpu_->getQueue(), surface, webgpu_->getPreferredFormat(), webgpu_->getPresentMode(), width_, height_, config_.debug, webgpu_->getAdapter(), webgpu_->getSurfaceNativeHandle())) { std::cerr << "[Mystral] Failed to initialize WebGPU bindings" << std::endl; webgpu::destroyBindingsState(bindingsState_); bindingsState_ = nullptr; @@ -1300,7 +1300,8 @@ class RuntimeImpl : public Runtime { webgpu::republishSurface(bindingsState_, webgpu_->getSurface(), webgpu_->getPreferredFormat(), webgpu_->getPresentMode(), - static_cast(width_), static_cast(height_)); + static_cast(width_), static_cast(height_), + webgpu_->getSurfaceNativeHandle()); std::ostringstream marker; marker << "TN_LIFECYCLE_SURFACE:{\"event\":\"revalidated\",\"previousWindow\":\"" diff --git a/packages/runtime-native/src/webgpu/bindings.cpp b/packages/runtime-native/src/webgpu/bindings.cpp index 28789db697..2c7e344c91 100644 --- a/packages/runtime-native/src/webgpu/bindings.cpp +++ b/packages/runtime-native/src/webgpu/bindings.cpp @@ -954,7 +954,8 @@ static js::JSValueHandle getCurrentCanvasTexture( // acquisition/view bookkeeping as the main canvas or endFrame never presents them. const auto canvas = state->engine->getProperty(canvasContext, "canvas"); if (!syncSurfaceSizeToCanvas(state, canvas)) { - state->engine->throwException("Canvas dimensions must be positive integer pixels"); + if (!state->engine->hasException()) + state->engine->throwException("Canvas dimensions must be positive integer pixels"); return state->engine->newUndefined(); } @@ -2778,7 +2779,7 @@ static bool installWebGPUBindingTables(BindingsState* state, js::Engine* engine) #endif /** Initialize WebGPU bindings in the JS engine. */ -bool initBindings(BindingsState* state, js::Engine* engine, void* wgpuInstance, void* wgpuDevice, void* wgpuQueue, void* wgpuSurface, uint32_t surfaceFormat, uint32_t presentMode, uint32_t width, uint32_t height, bool debug, void* wgpuAdapter) { +bool initBindings(BindingsState* state, js::Engine* engine, void* wgpuInstance, void* wgpuDevice, void* wgpuQueue, void* wgpuSurface, uint32_t surfaceFormat, uint32_t presentMode, uint32_t width, uint32_t height, bool debug, void* wgpuAdapter, void* surfaceNativeHandle) { if (!state || !engine) { std::cerr << "[WebGPU] No JS engine provided for bindings" << std::endl; return false; @@ -2802,6 +2803,9 @@ bool initBindings(BindingsState* state, js::Engine* engine, void* wgpuInstance, // Set canvas dimensions from window size state->presentation.canvasWidth = width; state->presentation.canvasHeight = height; + state->presentation.surfaceWidth = width; + state->presentation.surfaceHeight = height; + state->presentation.surfaceNativeHandle = surfaceNativeHandle; state->presentation.nativeSurfaceFormat = (WGPUTextureFormat)surfaceFormat; state->presentation.requiresSrgbPresentationBridge = state->surface != nullptr && isSrgbSurfaceFormat(state->presentation.nativeSurfaceFormat); diff --git a/packages/runtime-native/src/webgpu/bindings_presentation.cpp b/packages/runtime-native/src/webgpu/bindings_presentation.cpp index cbedaa40ab..64fcec2e8a 100644 --- a/packages/runtime-native/src/webgpu/bindings_presentation.cpp +++ b/packages/runtime-native/src/webgpu/bindings_presentation.cpp @@ -302,12 +302,7 @@ static bool readCanvasDimension( return true; } -/** - * Keep the native presentation surface in lockstep with renderer.setSize(). - * Three.js changes the canvas backing dimensions directly; unlike a browser - * GPUCanvasContext, the native surface is not reconfigured by that property - * write, so acquire must apply the pending size before creating the color view. - */ +/** Synchronize render resolution separately from the physical presentation extent. */ bool syncSurfaceSizeToCanvas(BindingsState* state, js::JSValueHandle canvas) { if (!state->surface) return true; @@ -317,32 +312,24 @@ bool syncSurfaceSizeToCanvas(BindingsState* state, js::JSValueHandle canvas) { !readCanvasDimension(state, canvas, "height", height)) { return false; } - - if (width == state->presentation.canvasWidth && height == state->presentation.canvasHeight) - return true; - - // On a direct-presentation surface, a swapchain image acquired earlier in this scene's - // life may still be held in state->presentation.currentTexture — the frame boundary that presents it has - // not run. wgpu-native refuses to reconfigure a surface with an outstanding - // SurfaceOutput: its panic reads "`SurfaceOutput` must be dropped before a new `Surface` - // is made", and the panic aborts the process (PRD-183: the Android emulator died with a - // silent SIGABRT exactly here, 67 ms into the first render after renderer.setSize). - // - // Discard that image rather than presenting it: the caller is already replacing the - // frame's contents at the new size, an extra present would break the one-present-per- - // frame invariant the device gates enforce, and releasing the texture back unwinds the - // SurfaceOutput so the reconfigure below is accepted. Deferring the reconfigure instead - // is worse on both platforms — the current frame would draw new-size depth onto - // old-size colour, "attachments have differing sizes", the failure tier-1 day recorded - // on desktop. - // - // The sRGB presentation bridge never holds a raw surface output across frames - // (state->presentation.currentTexture is an offscreen linear texture there), so it keeps the immediate - // reconfigure it always had. - if (state->presentation.currentTexture != nullptr && !state->presentation.requiresSrgbPresentationBridge) { - // setSize can be called between two submits in one JavaScript turn. Replay the safe - // prefix before dropping view ids that the deferred stream still names, or its later - // frame-boundary replay fails closed with an opaque "unknown texture view id". + int drawableWidth = 0; + int drawableHeight = 0; + if (!platform::getWindowDrawableSize(state->presentation.surfaceNativeHandle, + &drawableWidth, &drawableHeight)) { + state->engine->throwException("Native drawable dimensions are unavailable"); + return false; + } + const uint32_t surfaceWidth = static_cast(drawableWidth); + const uint32_t surfaceHeight = static_cast(drawableHeight); + const bool surfaceChanged = surfaceWidth != state->presentation.surfaceWidth || + surfaceHeight != state->presentation.surfaceHeight; + if (!surfaceChanged && width == state->presentation.canvasWidth && + height == state->presentation.canvasHeight) return true; + + // A canvas change invalidates an offscreen frame just as it invalidates a direct frame. + // Replay first: deferred commands still name these view IDs. Drop the old SurfaceOutput + // before configuring a real window resize, without adding an extra present. + if (state->presentation.currentTexture != nullptr) { if (!flushRecordedFrameOps(state)) return false; state->presentation.framePresentPending = false; releaseCurrentSurfaceTextureViews(state); @@ -356,29 +343,68 @@ bool syncSurfaceSizeToCanvas(BindingsState* state, js::JSValueHandle canvas) { state->presentation.surfaceRenderPassEnded = false; } + if (surfaceChanged) { + WGPUSurfaceConfiguration config = {}; + config.device = state->device; + config.format = state->presentation.nativeSurfaceFormat; + config.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc; + config.alphaMode = WGPUCompositeAlphaMode_Auto; + config.width = surfaceWidth; + config.height = surfaceHeight; + config.presentMode = state->presentation.presentMode; + wgpuSurfaceConfigure(state->surface, &config); + state->presentation.surfaceWidth = surfaceWidth; + state->presentation.surfaceHeight = surfaceHeight; + } + state->presentation.canvasWidth = width; + state->presentation.canvasHeight = height; + if (state->verboseLogging) { + std::cout << "[WebGPU] Canvas " << width << "x" << height << ", drawable " + << surfaceWidth << "x" << surfaceHeight << std::endl; + } + return true; +} + +bool requiresPresentationBridge(const BindingsState* state) { + return state->presentation.requiresSrgbPresentationBridge || + state->presentation.canvasWidth != state->presentation.surfaceWidth || + state->presentation.canvasHeight != state->presentation.surfaceHeight; +} + +/** Recover a genuinely stale swapchain without touching the canvas being presented. */ +static bool reconfigureSurfaceForAcquire(BindingsState* state) { + if (!state || !state->surface || state->presentation.surfaceWidth == 0 || + state->presentation.surfaceHeight == 0) return false; + // Direct acquisition normally has no held output; refuse to reconfigure if it does. + // Bridge presentation instead holds an offscreen canvas. Its source view has already + // been passed to the blit, so releasing its aliases here would use a freed view. + if (state->presentation.currentTexture && !requiresPresentationBridge(state)) return false; + WGPUSurfaceConfiguration config = {}; config.device = state->device; config.format = state->presentation.nativeSurfaceFormat; config.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc; config.alphaMode = WGPUCompositeAlphaMode_Auto; - config.width = width; - config.height = height; + config.width = state->presentation.surfaceWidth; + config.height = state->presentation.surfaceHeight; config.presentMode = state->presentation.presentMode; wgpuSurfaceConfigure(state->surface, &config); - - reportSurfaceFormatMarker(state->presentation.nativeSurfaceFormat, - state->presentation.surfaceFormat, - state->presentation.requiresSrgbPresentationBridge, - state->presentation.presentMode); - - state->presentation.canvasWidth = width; - state->presentation.canvasHeight = height; - if (state->verboseLogging) { - std::cout << "[WebGPU] Surface resized from canvas: " << width << "x" << height << std::endl; - } return true; } +/** + * Acquires the current surface image, rebuilding the swapchain once when it reports itself stale. + * Ownership of the returned texture is the caller's, exactly as the raw call's is. + */ +static void acquireSurfaceImage(BindingsState* state, WGPUSurfaceTexture* surfaceTexture) { + wgpuSurfaceGetCurrentTexture(state->surface, surfaceTexture); + if (!wgpuSurfaceTextureStatusNeedsReconfigure(surfaceTexture->status)) return; + if (surfaceTexture->texture) wgpuTextureRelease(surfaceTexture->texture); + surfaceTexture->texture = nullptr; + if (!reconfigureSurfaceForAcquire(state)) return; + wgpuSurfaceGetCurrentTexture(state->surface, surfaceTexture); +} + void trackCurrentSurfaceTextureView(BindingsState* state, uint64_t viewId, WGPUTextureView view) { if (!state || !view) return; state->presentation.currentSurfaceTextureViews[viewId] = view; @@ -436,17 +462,26 @@ static bool ensureSrgbPresentationPipeline(BindingsState* state) { if (state->presentation.srgbPresentationPipeline) return true; - const char* shaderCode = R"( + const std::string shaderCode = std::string("const decodeSrgb = ") + + (state->presentation.requiresSrgbPresentationBridge ? "true;\n" : "false;\n") + R"( @group(0) @binding(0) var sourceTexture: texture_2d; + struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + }; @vertex - fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4f { + fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { var positions = array( vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0) ); - return vec4f(positions[vertexIndex], 0.0, 1.0); + let position = positions[vertexIndex]; + var output: VertexOutput; + output.position = vec4f(position, 0.0, 1.0); + output.uv = position * vec2f(0.5, -0.5) + vec2f(0.5); + return output; } fn srgbToLinear(value: vec3f) -> vec3f { @@ -456,15 +491,18 @@ static bool ensureSrgbPresentationPipeline(BindingsState* state) { } @fragment - fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { - let encoded = textureLoad(sourceTexture, vec2i(position.xy), 0); - return vec4f(srgbToLinear(encoded.rgb), encoded.a); + fn fs_main(input: VertexOutput) -> @location(0) vec4f { + let size = textureDimensions(sourceTexture); + let pixel = clamp(vec2i(input.uv * vec2f(size)), vec2i(0), vec2i(size) - vec2i(1)); + let encoded = textureLoad(sourceTexture, pixel, 0); + if (decodeSrgb) { return vec4f(srgbToLinear(encoded.rgb), encoded.a); } + return encoded; } )"; WGPUShaderModuleWGSLDescriptor_Compat wgslDescriptor = {}; WGPUShaderModuleDescriptor shaderDescriptor = {}; - setupShaderModuleWGSL(&shaderDescriptor, &wgslDescriptor, shaderCode); + setupShaderModuleWGSL(&shaderDescriptor, &wgslDescriptor, shaderCode.c_str()); WGPUShaderModule shaderModule = wgpuDeviceCreateShaderModule(state->device, &shaderDescriptor); if (!shaderModule) return false; @@ -527,28 +565,9 @@ static bool ensureSrgbPresentationPipeline(BindingsState* state) { return true; } -static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureView sourceView) { - if (!sourceView || !ensureSrgbPresentationPipeline(state)) return false; - - WGPUSurfaceTexture surfaceTexture = {}; - wgpuSurfaceGetCurrentTexture(state->surface, &surfaceTexture); - if (!wgpuSurfaceTextureStatusIsSuccess(surfaceTexture.status)) return false; - - WGPUTextureViewDescriptor surfaceViewDescriptor = {}; - surfaceViewDescriptor.format = state->presentation.nativeSurfaceFormat; - surfaceViewDescriptor.dimension = WGPUTextureViewDimension_2D; - surfaceViewDescriptor.baseMipLevel = 0; - surfaceViewDescriptor.mipLevelCount = 1; - surfaceViewDescriptor.baseArrayLayer = 0; - surfaceViewDescriptor.arrayLayerCount = 1; - surfaceViewDescriptor.aspect = WGPUTextureAspect_All; - WGPUTextureView surfaceView = - wgpuTextureCreateView(surfaceTexture.texture, &surfaceViewDescriptor); - if (!surfaceView) { - wgpuTextureRelease(surfaceTexture.texture); - return false; - } - +bool blitPresentationTexture(BindingsState* state, WGPUTextureView sourceView, + WGPUTextureView surfaceView) { + if (!sourceView || !surfaceView || !ensureSrgbPresentationPipeline(state)) return false; WGPUBindGroupEntry bindGroupEntry = {}; bindGroupEntry.binding = 0; bindGroupEntry.textureView = sourceView; @@ -558,8 +577,6 @@ static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureV bindGroupDescriptor.entries = &bindGroupEntry; WGPUBindGroup bindGroup = wgpuDeviceCreateBindGroup(state->device, &bindGroupDescriptor); if (!bindGroup) { - wgpuTextureViewRelease(surfaceView); - wgpuTextureRelease(surfaceTexture.texture); return false; } @@ -567,8 +584,6 @@ static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureV WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(state->device, &encoderDescriptor); if (!encoder) { wgpuBindGroupRelease(bindGroup); - wgpuTextureViewRelease(surfaceView); - wgpuTextureRelease(surfaceTexture.texture); return false; } WGPURenderPassColorAttachment colorAttachment = {}; @@ -587,8 +602,6 @@ static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureV if (!renderPass) { wgpuCommandEncoderRelease(encoder); wgpuBindGroupRelease(bindGroup); - wgpuTextureViewRelease(surfaceView); - wgpuTextureRelease(surfaceTexture.texture); return false; } wgpuRenderPassEncoderSetPipeline(renderPass, state->presentation.srgbPresentationPipeline); @@ -604,13 +617,35 @@ static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureV if (encoded) { flushUploadStaging(state); wgpuQueueSubmit(state->queue, 1, &commandBuffer); - wgpuSurfacePresent(state->surface); } if (commandBuffer) wgpuCommandBufferRelease(commandBuffer); wgpuCommandEncoderRelease(encoder); wgpuBindGroupRelease(bindGroup); - wgpuTextureViewRelease(surfaceView); + return encoded; +} + +static void reportSurfaceAcquireFailure(uint32_t status); + +static bool presentLinearTextureToSrgbSurface(BindingsState* state, WGPUTextureView sourceView) { + if (!sourceView) return false; + WGPUSurfaceTexture surfaceTexture = {}; + acquireSurfaceImage(state, &surfaceTexture); + if (!wgpuSurfaceTextureStatusIsSuccess(surfaceTexture.status) || !surfaceTexture.texture) { + if (surfaceTexture.texture) wgpuTextureRelease(surfaceTexture.texture); + reportSurfaceAcquireFailure(static_cast(surfaceTexture.status)); + return false; + } + WGPUTextureViewDescriptor surfaceViewDescriptor = {}; + surfaceViewDescriptor.format = state->presentation.nativeSurfaceFormat; + surfaceViewDescriptor.dimension = WGPUTextureViewDimension_2D; + surfaceViewDescriptor.mipLevelCount = 1; + surfaceViewDescriptor.arrayLayerCount = 1; + surfaceViewDescriptor.aspect = WGPUTextureAspect_All; + WGPUTextureView surfaceView = wgpuTextureCreateView(surfaceTexture.texture, &surfaceViewDescriptor); + const bool encoded = blitPresentationTexture(state, sourceView, surfaceView); + if (encoded) wgpuSurfacePresent(state->surface); + if (surfaceView) wgpuTextureViewRelease(surfaceView); wgpuTextureRelease(surfaceTexture.texture); return encoded; } @@ -713,14 +748,17 @@ WGPUTexture getCurrentSwapchainTexture(BindingsState* state) { return nullptr; } - if (state->presentation.requiresSrgbPresentationBridge) { + if (requiresPresentationBridge(state)) { return createLinearPresentationTexture(state); } - WGPUSurfaceTexture surfaceTexture; - wgpuSurfaceGetCurrentTexture(state->surface, &surfaceTexture); + WGPUSurfaceTexture surfaceTexture = {}; + acquireSurfaceImage(state, &surfaceTexture); - if (!wgpuSurfaceTextureStatusIsSuccess(surfaceTexture.status)) { + if (!wgpuSurfaceTextureStatusIsSuccess(surfaceTexture.status) || !surfaceTexture.texture) { + // The API returns texture ownership even when the caller cannot use the result. + // Never strand an acquired image on a rejected status; null remains a named failure. + if (surfaceTexture.texture) wgpuTextureRelease(surfaceTexture.texture); // Fail by name. A surface that stops handing out images presents nothing, and the loop is // no longer paced by presenting, so this fires hundreds of times a second, which is // precisely how the resume defect looked from the outside: frames running away, presents @@ -765,6 +803,7 @@ void detachSurfaceForRebuild(BindingsState* state) { state->presentation.surfaceRenderPassEnded = false; state->screenshot.screenshotCapturedThisFrame = false; state->surface = nullptr; + state->presentation.surfaceNativeHandle = nullptr; #else (void)state; #endif @@ -777,10 +816,20 @@ void detachSurfaceForRebuild(BindingsState* state) { * dead one: the resume defect again, one indirection later. */ void republishSurface(BindingsState* state, void* wgpuSurface, uint32_t surfaceFormat, - uint32_t presentMode, uint32_t width, uint32_t height) { + uint32_t presentMode, uint32_t width, uint32_t height, + void* surfaceNativeHandle) { #if defined(MYSTRAL_WEBGPU_WGPU) || defined(MYSTRAL_WEBGPU_DAWN) if (!state) return; + if (state->presentation.nativeSurfaceFormat != static_cast(surfaceFormat)) { + if (state->presentation.srgbPresentationPipeline) + wgpuRenderPipelineRelease(state->presentation.srgbPresentationPipeline); + if (state->presentation.srgbPresentationBindGroupLayout) + wgpuBindGroupLayoutRelease(state->presentation.srgbPresentationBindGroupLayout); + state->presentation.srgbPresentationPipeline = nullptr; + state->presentation.srgbPresentationBindGroupLayout = nullptr; + } state->surface = (WGPUSurface)wgpuSurface; + state->presentation.surfaceNativeHandle = surfaceNativeHandle; state->presentation.presentMode = static_cast(presentMode); state->presentation.nativeSurfaceFormat = (WGPUTextureFormat)surfaceFormat; state->presentation.requiresSrgbPresentationBridge = @@ -788,11 +837,9 @@ void republishSurface(BindingsState* state, void* wgpuSurface, uint32_t surfaceF state->presentation.surfaceFormat = state->presentation.requiresSrgbPresentationBridge ? linearSurfaceFormat(state->presentation.nativeSurfaceFormat) : state->presentation.nativeSurfaceFormat; - // The same pairing startup makes: the canvas dimensions name the size the surface is - // configured at, so `syncSurfaceSizeToCanvas` reconfigures on the next frame if the game is - // rendering at a different one. - state->presentation.canvasWidth = width; - state->presentation.canvasHeight = height; + // A rebuilt drawable does not change the renderer's canvas backing resolution. + state->presentation.surfaceWidth = width; + state->presentation.surfaceHeight = height; reportSurfaceFormatMarker(state->presentation.nativeSurfaceFormat, state->presentation.surfaceFormat, state->presentation.requiresSrgbPresentationBridge, @@ -801,6 +848,7 @@ void republishSurface(BindingsState* state, void* wgpuSurface, uint32_t surfaceF << " (format=" << state->presentation.surfaceFormat << ")" << std::endl; #else (void)state; (void)wgpuSurface; (void)surfaceFormat; (void)presentMode; (void)width; (void)height; + (void)surfaceNativeHandle; #endif } @@ -838,7 +886,7 @@ void presentPendingSurface(BindingsState* state) { std::cout << "[WebGPU] Presenting surface" << std::endl; const auto presentStart = std::chrono::steady_clock::now(); const uint64_t presentThreadCpuStart = readRenderThreadCpuNs(); - const bool presented = state->presentation.requiresSrgbPresentationBridge + const bool presented = requiresPresentationBridge(state) ? presentLinearTextureToSrgbSurface(state, state->presentation.currentTextureView) : (wgpuSurfacePresent(state->surface), true); const uint64_t presentThreadCpuEnd = readRenderThreadCpuNs(); @@ -887,7 +935,7 @@ void presentPendingSurface(BindingsState* state) { reportPipelineCheckpoint(state, state->profiling.presentCount); pollPipelineCachePersistence(state); } else { - std::cerr << "[WebGPU] sRGB presentation bridge failed" << std::endl; + std::cerr << "[WebGPU] Canvas presentation bridge failed" << std::endl; } } diff --git a/packages/runtime-native/src/webgpu/bindings_presentation.h b/packages/runtime-native/src/webgpu/bindings_presentation.h index 372d861fef..031e2e544d 100644 --- a/packages/runtime-native/src/webgpu/bindings_presentation.h +++ b/packages/runtime-native/src/webgpu/bindings_presentation.h @@ -29,6 +29,10 @@ void reportSurfaceFormatMarker( WGPUTextureFormat renderFormat, bool usesSrgbBridge, WGPUPresentMode presentMode); +// The same full-canvas transfer used to present to the native surface. +bool blitPresentationTexture(BindingsState* state, WGPUTextureView sourceView, + WGPUTextureView surfaceView); +bool requiresPresentationBridge(const BindingsState* state); bool syncSurfaceSizeToCanvas(BindingsState* state, js::JSValueHandle canvas); WGPUTexture getCurrentSwapchainTexture(BindingsState* state); void trackCurrentSurfaceTextureView(BindingsState* state, uint64_t viewId, WGPUTextureView view); diff --git a/packages/runtime-native/src/webgpu/bindings_state.h b/packages/runtime-native/src/webgpu/bindings_state.h index a19ef5af77..f17d29feed 100644 --- a/packages/runtime-native/src/webgpu/bindings_state.h +++ b/packages/runtime-native/src/webgpu/bindings_state.h @@ -272,6 +272,10 @@ struct PresentationState { WGPUBindGroupLayout srgbPresentationBindGroupLayout = nullptr; uint32_t canvasWidth = 800; uint32_t canvasHeight = 600; + // Canvas backing pixels can differ from the window pixels the swapchain presents. + uint32_t surfaceWidth = 0; + uint32_t surfaceHeight = 0; + void* surfaceNativeHandle = nullptr; bool contextConfigured = false; WGPUTexture currentTexture = nullptr; WGPUTextureView currentTextureView = nullptr; diff --git a/packages/runtime-native/tests/canvas_presentation_test.cpp b/packages/runtime-native/tests/canvas_presentation_test.cpp new file mode 100644 index 0000000000..f72936aba1 --- /dev/null +++ b/packages/runtime-native/tests/canvas_presentation_test.cpp @@ -0,0 +1,128 @@ +// Exercise the production presentation draw against a real GPU destination, including its +// bottom/right edges. Canvas readback alone cannot catch a cropped or black native presentation. +#include "mystral/runtime.h" +#include "mystral/webgpu/bindings.h" +#include "../src/webgpu/bindings_presentation.h" +#include "../src/webgpu/bindings_state.h" + +#include +#include +#include +#include +#include + +namespace { +using namespace mystral::webgpu; + +struct Texture { + WGPUTexture texture = nullptr; + WGPUTextureView view = nullptr; + Texture(WGPUDevice device, uint32_t width, uint32_t height, WGPUTextureFormat format) { + WGPUTextureDescriptor descriptor = {}; + descriptor.dimension = WGPUTextureDimension_2D; + descriptor.size = {width, height, 1}; + descriptor.format = format; + descriptor.mipLevelCount = 1; + descriptor.sampleCount = 1; + descriptor.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding | + WGPUTextureUsage_CopySrc | WGPUTextureUsage_CopyDst; + texture = wgpuDeviceCreateTexture(device, &descriptor); + if (texture) view = wgpuTextureCreateView(texture, nullptr); + } + ~Texture() { + if (view) wgpuTextureViewRelease(view); + if (texture) wgpuTextureRelease(texture); + } +}; + +bool verifyDestination(mystral::Runtime& runtime, WGPUTextureFormat format, + uint32_t width, uint32_t height) { + auto* state = static_cast(runtime.getWebGPUBindingsState()); + // Source and destination differ in size and contain asymmetric, non-primary colors. + // This catches wrong UV orientation, out-of-bounds loads and an unwanted sRGB conversion. + constexpr uint32_t sourceWidth = 4, sourceHeight = 2; + constexpr std::array source = { + 32, 64, 128, 255, 64, 96, 160, 255, 96, 128, 192, 255, 128, 160, 224, 255, + 224, 160, 128, 255, 192, 128, 96, 255, 160, 96, 64, 255, 128, 64, 32, 255, + }; + // Re-publication invalidates a cached pipeline when the destination format changes. + republishSurface(state, nullptr, static_cast(format), + static_cast(WGPUPresentMode_Fifo), width, height); + state->presentation.requiresSrgbPresentationBridge = isSrgbSurfaceFormat(format); + state->presentation.canvasWidth = sourceWidth; + state->presentation.canvasHeight = sourceHeight; + Texture sourceTexture(state->device, sourceWidth, sourceHeight, WGPUTextureFormat_RGBA8Unorm); + Texture destination(state->device, width, height, format); + if (!sourceTexture.view || !destination.view) return false; + WGPUImageCopyTexture_Compat copy = {}; + copy.texture = sourceTexture.texture; + copy.aspect = WGPUTextureAspect_All; + WGPUTextureDataLayout_Compat layout = {}; + layout.bytesPerRow = sourceWidth * 4; + layout.rowsPerImage = sourceHeight; + WGPUExtent3D extent = {sourceWidth, sourceHeight, 1}; + wgpuQueueWriteTexture(state->queue, ©, source.data(), source.size(), &layout, &extent); + if (!blitPresentationTexture(state, sourceTexture.view, destination.view)) return false; + + // Reuse the runtime's actual GPU readback, pointed at the bridge's destination texture. + // No frame is presented here; real swapchain acquisition is covered by the hosted fog run. + state->presentation.canvasWidth = width; + state->presentation.canvasHeight = height; + state->presentation.surfaceFormat = format; + state->presentation.currentTexture = destination.texture; + state->presentation.currentViewSourceTexture = nullptr; + state->presentation.surfaceRenderPassEnded = true; + state->screenshot.screenshotCapturedThisFrame = false; + requestFrameScreenshot(state); + captureFrameScreenshot(state); + std::vector pixels; + uint32_t capturedWidth = 0, capturedHeight = 0; + const bool captured = runtime.captureFrame(pixels, capturedWidth, capturedHeight); + state->presentation.currentTexture = nullptr; + state->presentation.surfaceRenderPassEnded = false; + if (!captured || capturedWidth != width || capturedHeight != height || + pixels.size() != width * height * 4) return false; + for (uint32_t y = 0; y < height; ++y) { + for (uint32_t x = 0; x < width; ++x) { + const uint32_t sx = ((2 * x + 1) * sourceWidth) / (2 * width); + const uint32_t sy = ((2 * y + 1) * sourceHeight) / (2 * height); + for (uint32_t channel = 0; channel < 4; ++channel) { + const int actual = pixels[(y * width + x) * 4 + channel]; + const int expected = source[(sy * sourceWidth + sx) * 4 + channel]; + // sRGB encode/decode may round one UNORM byte; linear copies are exact. + const int tolerance = isSrgbSurfaceFormat(format) && channel != 3 ? 1 : 0; + if (std::abs(actual - expected) > tolerance) { + std::cerr << "presentation pixel mismatch at " << x << "," << y + << " channel=" << channel << " actual=" << actual + << " expected=" << expected << " format=" << format << '\n'; + return false; + } + } + } + } + return true; +} +} // namespace + +int main() { + mystral::RuntimeConfig config; + config.noSdl = true; + config.width = 4; + config.height = 2; + auto runtime = mystral::Runtime::create(config); + if (!runtime) return 1; + auto* state = static_cast(runtime->getWebGPUBindingsState()); + state->presentation.requiresSrgbPresentationBridge = false; + state->presentation.surfaceWidth = state->presentation.canvasWidth; + state->presentation.surfaceHeight = state->presentation.canvasHeight; + if (requiresPresentationBridge(state)) return 2; + state->presentation.surfaceWidth *= 2; + if (!requiresPresentationBridge(state)) return 3; + for (const auto format : {WGPUTextureFormat_RGBA8Unorm, WGPUTextureFormat_RGBA8UnormSrgb}) { + for (const auto size : {std::array{12, 8}, {4, 2}, {2, 1}}) { + if (!verifyDestination(*runtime, format, size[0], size[1])) return 4; + } + } + std::cout << "native canvas presentation pixels passed\n"; + return 0; +} diff --git a/packages/runtime-native/tests/lifecycle_policy_test.cpp b/packages/runtime-native/tests/lifecycle_policy_test.cpp index 7dff65eab0..8b9679d50c 100644 --- a/packages/runtime-native/tests/lifecycle_policy_test.cpp +++ b/packages/runtime-native/tests/lifecycle_policy_test.cpp @@ -98,7 +98,8 @@ bool surfaceBindingStateFollowsRebuild() { const bool published = state->surface == reinterpret_cast(replacement) && state->presentation.surfaceFormat == WGPUTextureFormat_BGRA8Unorm && state->presentation.presentMode == WGPUPresentMode_Fifo && - state->presentation.canvasWidth == 720 && state->presentation.canvasHeight == 1280; + state->presentation.surfaceWidth == 720 && state->presentation.surfaceHeight == 1280 && + state->presentation.canvasWidth == 800 && state->presentation.canvasHeight == 600; state->presentation.framePresentPending = true; mystral::webgpu::detachSurfaceForRebuild(state); const bool detached = state->surface == nullptr && !state->presentation.framePresentPending; diff --git a/packages/runtime-native/tests/native-contract-lane.test.mjs b/packages/runtime-native/tests/native-contract-lane.test.mjs index a863a70bf8..38b81cd2af 100644 --- a/packages/runtime-native/tests/native-contract-lane.test.mjs +++ b/packages/runtime-native/tests/native-contract-lane.test.mjs @@ -88,11 +88,11 @@ test("should fail when a declared test target is not executed", () => { // screenshot capture-gate ordering contract; +1 for PRD-329's surface-format selection // diagnostic contract; +5 for the comprehensive coverage contracts; +1 for PRD-368's pipeline // cache contract; +1 for PRD-399's presentation-pacing contract; +1 for PRD-465 Phase 2's - // MetaHuman facial rig bindings contract. + // MetaHuman facial rig bindings contract; +1 for VQ07's surface acquisition status contract. // Bump alongside any new add_executable contract target. - // 45 since PRD-465 registered threenative-metahuman-bindings-test. The next assertion pins this against CMakeLists + // 47 since VQ07 also registered threenative-canvas-presentation-test. The next assertion pins this against CMakeLists // itself, so the literal is a tripwire for an unreviewed target, not the source of truth. - assert.equal(discovered.length, 45); + assert.equal(discovered.length, 47); assert.deepEqual(discovered, declaredTargets(cmake)); assert.doesNotThrow(() => validateExecutionContracts(discovered, executionContracts)); diff --git a/packages/runtime-native/tests/resize-attachment-invariant.test.mjs b/packages/runtime-native/tests/resize-attachment-invariant.test.mjs index ab0a868e27..105463eb6b 100644 --- a/packages/runtime-native/tests/resize-attachment-invariant.test.mjs +++ b/packages/runtime-native/tests/resize-attachment-invariant.test.mjs @@ -20,8 +20,8 @@ function assertResizeAttachmentContract(definitions) { ); assert.match( definitions.syncSize, - /config\.width = width;[\s\S]*?config\.height = height;[\s\S]*?wgpuSurfaceConfigure\(state->surface, &config\)/u, - "canvas backing dimensions must reconfigure the native surface before acquisition", + /config\.width = surfaceWidth;[\s\S]*?config\.height = surfaceHeight;[\s\S]*?wgpuSurfaceConfigure\(state->surface, &config\)/u, + "only physical drawable dimensions may configure the native surface", ); assert.match( definitions.currentTextureHandler, diff --git a/packages/runtime-native/tests/resize-presentation-behavior.test.mjs b/packages/runtime-native/tests/resize-presentation-behavior.test.mjs new file mode 100644 index 0000000000..61908b1a02 --- /dev/null +++ b/packages/runtime-native/tests/resize-presentation-behavior.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "vitest"; +import { nativeDefinition } from "../../../test-support/native-definition.js"; + +const compiler = process.env.CXX || "c++"; +const probe = spawnSync(compiler, ["--version"], { encoding: "utf8" }); +const nativeTest = probe.error?.code === "ENOENT" ? test.skip : test; + +// Compile the production synchronization body against controlled platform/GPU boundaries. +// This proves its state transitions, not driver behavior; the source-built hosted test proves +// actual canvas/depth attachments and rendered destination pixels with the real WebGPU backend. +nativeTest("canvas resolution changes never configure a differently sized native drawable", () => { + const directory = mkdtempSync(join(tmpdir(), "tn-presentation-resize-")); + try { + const source = join(directory, "contract.cpp"); + const executable = join(directory, process.platform === "win32" ? "contract.exe" : "contract"); + writeFileSync(source, ` +#include +#include +#include +#include +#include +using WGPUTexture = void*; +namespace js { struct JSValueHandle { uint32_t width, height; }; } +struct PresentationState { + uint32_t canvasWidth = 640, canvasHeight = 400; + uint32_t surfaceWidth = 640, surfaceHeight = 400; + void* surfaceNativeHandle = nullptr; + bool requiresSrgbPresentationBridge = false; + WGPUTexture currentTexture = nullptr; + uint64_t currentSurfaceTextureId = 0; + bool framePresentPending = false, surfaceRenderPassEnded = false; + void* surfaceRenderEncoder = nullptr; + int nativeSurfaceFormat = 1, surfaceFormat = 1, presentMode = 7; +}; +struct Engine { std::string error; void throwException(const char* text) { error = text; } }; +struct BindingsState { + Engine engineValue; + Engine* engine = &engineValue; + void* surface = reinterpret_cast(1); + void* device = reinterpret_cast(2); + PresentationState presentation; + struct { std::map textureRegistry; } registries; + bool verboseLogging = false; +}; +struct WGPUSurfaceConfiguration { + void* device; int format, usage, alphaMode; uint32_t width, height; int presentMode; +}; +constexpr int WGPUTextureUsage_RenderAttachment = 1, WGPUTextureUsage_CopySrc = 2; +constexpr int WGPUCompositeAlphaMode_Auto = 1; +int drawableWidth = 640, drawableHeight = 400; +bool canFlush = true; +std::vector calls; +WGPUSurfaceConfiguration lastConfig{}; +struct WGPUSurfaceTexture { int status = 0; WGPUTexture texture = nullptr; }; +constexpr int success = 1, outdated = 2, timeout = 3; +int firstStatus = outdated, secondStatus = success, acquireCalls = 0; +std::vector releasedTextures; +bool wgpuSurfaceTextureStatusNeedsReconfigure(int status) { return status == outdated; } +void wgpuSurfaceGetCurrentTexture(void*, WGPUSurfaceTexture* texture) { + calls.push_back("acquire"); + texture->status = acquireCalls == 0 ? firstStatus : secondStatus; + texture->texture = reinterpret_cast(static_cast(100 + ++acquireCalls)); +} +namespace mystral::platform { + bool getWindowDrawableSize(void*, int* w, int* h) { + *w = drawableWidth; *h = drawableHeight; return *w > 0 && *h > 0; + } +} +namespace platform = mystral::platform; +bool readCanvasDimension(BindingsState*, js::JSValueHandle canvas, const char* name, uint32_t& value) { + value = std::string(name) == "width" ? canvas.width : canvas.height; return value > 0; +} +bool flushRecordedFrameOps(BindingsState*) { calls.push_back("flush"); return canFlush; } +void releaseCurrentSurfaceTextureViews(BindingsState*) { calls.push_back("views"); } +void wgpuTextureRelease(WGPUTexture texture) { calls.push_back("texture"); releasedTextures.push_back(texture); } +void wgpuSurfaceConfigure(void*, const WGPUSurfaceConfiguration* config) { + calls.push_back("configure"); lastConfig = *config; +} +void reportSurfaceFormatMarker(int, int, bool, int) {} +${nativeDefinition("syncSurfaceSizeToCanvas").text} +${nativeDefinition("requiresPresentationBridge").text} +${nativeDefinition("reconfigureSurfaceForAcquire").text} +${nativeDefinition("acquireSurfaceImage").text} +int main() { + BindingsState state; + if (requiresPresentationBridge(&state)) return 6; + if (!syncSurfaceSizeToCanvas(&state, {320, 240}) || !calls.empty() || + state.presentation.canvasWidth != 320 || state.presentation.canvasHeight != 240 || + state.presentation.surfaceWidth != 640 || state.presentation.surfaceHeight != 400) { + std::cerr << "canvas-only resize reconfigured physical surface or lost canvas extent\\n"; return 1; + } + if (!requiresPresentationBridge(&state)) return 7; + state.presentation.requiresSrgbPresentationBridge = true; + state.presentation.currentTexture = reinterpret_cast(3); + state.presentation.currentSurfaceTextureId = 9; + state.presentation.framePresentPending = true; + state.registries.textureRegistry[9] = 1; + if (!syncSurfaceSizeToCanvas(&state, {640, 400}) || + calls != std::vector{"flush", "views", "texture"} || + state.presentation.currentTexture || state.presentation.currentSurfaceTextureId || + state.presentation.framePresentPending || !state.registries.textureRegistry.empty()) { + std::cerr << "offscreen resize retained stale texture/view aliases\\n"; return 2; + } + if (!requiresPresentationBridge(&state)) return 8; + state.presentation.requiresSrgbPresentationBridge = false; + if (requiresPresentationBridge(&state)) return 9; + calls.clear(); + drawableWidth = 800; drawableHeight = 600; + if (!syncSurfaceSizeToCanvas(&state, {640, 400}) || calls != std::vector{"configure"} || + lastConfig.width != 800 || lastConfig.height != 600 || lastConfig.presentMode != 7 || + state.presentation.canvasWidth != 640 || state.presentation.surfaceWidth != 800) { + std::cerr << "physical resize was missed with unchanged canvas dimensions\\n"; return 3; + } + calls.clear(); canFlush = false; + state.presentation.currentTexture = reinterpret_cast(4); + if (syncSurfaceSizeToCanvas(&state, {320, 240}) || calls != std::vector{"flush"} || + !state.presentation.currentTexture || state.presentation.canvasWidth != 640) { + std::cerr << "failed flush mutated acquired frame ownership\\n"; return 4; + } + calls.clear(); + if (syncSurfaceSizeToCanvas(&state, {0, 240}) || !calls.empty()) return 5; + drawableWidth = 0; + if (syncSurfaceSizeToCanvas(&state, {320, 240}) || !calls.empty() || + state.engineValue.error != "Native drawable dimensions are unavailable" || + !state.presentation.currentTexture || state.presentation.canvasWidth != 640) return 10; + calls.clear(); releasedTextures.clear(); canFlush = true; + state.presentation.canvasWidth = 320; state.presentation.canvasHeight = 240; + state.presentation.surfaceWidth = 640; state.presentation.surfaceHeight = 400; + state.presentation.currentSurfaceTextureId = 12; + state.presentation.framePresentPending = true; + state.registries.textureRegistry[12] = 1; + WGPUSurfaceTexture acquired; + acquireSurfaceImage(&state, &acquired); + if (calls != std::vector{"acquire", "texture", "configure", "acquire"} || + acquired.status != success || !acquired.texture || acquireCalls != 2 || + releasedTextures != std::vector{reinterpret_cast(101)} || + state.presentation.currentTexture != reinterpret_cast(4) || + state.presentation.currentSurfaceTextureId != 12 || !state.presentation.framePresentPending || + state.registries.textureRegistry.count(12) != 1 || + lastConfig.width != 640 || lastConfig.height != 400) { + std::cerr << "Outdated recovery invalidated bridge source or configured canvas extent\\n"; return 11; + } + // Persistent Outdated is returned after exactly one retry, never accepted as success. + calls.clear(); releasedTextures.clear(); acquireCalls = 0; secondStatus = outdated; + acquireSurfaceImage(&state, &acquired); + if (acquireCalls != 2 || acquired.status != outdated || !acquired.texture || + releasedTextures.size() != 1) return 12; + // Timeout is not a reconfigure signal; ownership stays with the caller. + calls.clear(); releasedTextures.clear(); acquireCalls = 0; firstStatus = timeout; + acquireSurfaceImage(&state, &acquired); + if (calls != std::vector{"acquire"} || acquired.status != timeout || + !acquired.texture || !releasedTextures.empty()) return 13; + // A held direct SurfaceOutput may not be discarded from the recovery helper. + calls.clear(); releasedTextures.clear(); acquireCalls = 0; firstStatus = outdated; + state.presentation.canvasWidth = 640; state.presentation.canvasHeight = 400; + acquireSurfaceImage(&state, &acquired); + if (calls != std::vector{"acquire", "texture"} || acquired.status != outdated || + acquired.texture || state.presentation.currentTexture != reinterpret_cast(4)) return 14; + std::cout << "native canvas and drawable extent contract passed\\n"; +} +`); + const built = spawnSync(compiler, ["-std=c++17", "-Wall", "-Wextra", "-Werror", source, "-o", executable], { + encoding: "utf8", timeout: 30_000, + }); + assert.equal(built.status, 0, built.error?.message ?? built.stderr); + const result = spawnSync(executable, [], { encoding: "utf8", timeout: 10_000 }); + assert.equal(result.status, 0, result.error?.message ?? `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /native canvas and drawable extent contract passed/u); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-native/tests/srgb-presentation.test.mjs b/packages/runtime-native/tests/srgb-presentation.test.mjs index 9d307043a2..9b7d445e9e 100644 --- a/packages/runtime-native/tests/srgb-presentation.test.mjs +++ b/packages/runtime-native/tests/srgb-presentation.test.mjs @@ -104,3 +104,12 @@ test("presentation contract rejects removal of the inverse transfer", () => { ); assert.throws(() => assertPresentationBridge(definitions)); }); + +test("the shared presentation bridge maps normalized canvas coordinates to the whole drawable", () => { + const pipeline = presentationDefinitions().pipeline; + assert.match(pipeline, /@location\(0\) uv: vec2f/u); + assert.match(pipeline, /textureDimensions\(sourceTexture\)/u); + assert.doesNotMatch(pipeline, /textureLoad\(sourceTexture, vec2i\(position\.xy\)/u); + assert.match(pipeline, /requiresSrgbPresentationBridge/u, + "size-only linear presentation must not apply the inverse sRGB transfer"); +}); diff --git a/packages/runtime-native/tests/surface_texture_status_test.cpp b/packages/runtime-native/tests/surface_texture_status_test.cpp new file mode 100644 index 0000000000..5b08019ee3 --- /dev/null +++ b/packages/runtime-native/tests/surface_texture_status_test.cpp @@ -0,0 +1,51 @@ +#include +#include "mystral/webgpu_compat.h" + +#include + +// The same production helper used by all surface acquisition paths. No device or display +// is needed: a suboptimal image is usable, while timeout/lost/outdated remain failures. +int main() { + if (!wgpuSurfaceTextureStatusIsSuccess(WGPUSurfaceGetCurrentTextureStatus_Success_Compat)) { + std::cerr << "optimal surface acquisition was rejected\n"; + return 1; + } +#if defined(MYSTRAL_WEBGPU_DAWN) || defined(MYSTRAL_WEBGPU_WGPU_MODERN) + if (!wgpuSurfaceTextureStatusIsSuccess(WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal)) { + std::cerr << "suboptimal surface acquisition was rejected\n"; + return 1; + } + if (wgpuSurfaceTextureStatusIsSuccess(WGPUSurfaceGetCurrentTextureStatus_Error)) return 2; +#endif +#if defined(MYSTRAL_WEBGPU_WGPU_MODERN) + // Pinned Dawn folds these failures into Error; only wgpu declares distinct statuses. + if (wgpuSurfaceTextureStatusIsSuccess(WGPUSurfaceGetCurrentTextureStatus_OutOfMemory)) return 2; + if (wgpuSurfaceTextureStatusIsSuccess(WGPUSurfaceGetCurrentTextureStatus_DeviceLost)) return 2; +#endif + for (const auto status : {WGPUSurfaceGetCurrentTextureStatus_Timeout, + WGPUSurfaceGetCurrentTextureStatus_Outdated, + WGPUSurfaceGetCurrentTextureStatus_Lost, + WGPUSurfaceGetCurrentTextureStatus_Force32}) { + if (wgpuSurfaceTextureStatusIsSuccess(status)) { + std::cerr << "failed surface acquisition was accepted\n"; + return 2; + } + } + // Exactly one failure is recoverable: Outdated names a stale swapchain, and the acquire path + // rebuilds it. Treating it like the dead ends would restore the perpetual post-resize failure. + if (!wgpuSurfaceTextureStatusNeedsReconfigure(WGPUSurfaceGetCurrentTextureStatus_Outdated)) { + std::cerr << "outdated surface acquisition was not recognised as recoverable\n"; + return 3; + } + for (const auto status : {WGPUSurfaceGetCurrentTextureStatus_Success_Compat, + WGPUSurfaceGetCurrentTextureStatus_Timeout, + WGPUSurfaceGetCurrentTextureStatus_Lost, + WGPUSurfaceGetCurrentTextureStatus_Force32}) { + if (wgpuSurfaceTextureStatusNeedsReconfigure(status)) { + std::cerr << "only an outdated surface acquisition is recoverable\n"; + return 3; + } + } + std::cout << "native surface acquisition status contract passed\n"; + return 0; +} diff --git a/scripts/__tests__/temp-dir-guard.spec.ts b/scripts/__tests__/temp-dir-guard.spec.ts index dabe80f025..c142633502 100644 --- a/scripts/__tests__/temp-dir-guard.spec.ts +++ b/scripts/__tests__/temp-dir-guard.spec.ts @@ -91,6 +91,10 @@ const allowedProductionCreators = new Map([ "packages/runtime-native/tests/async-image-decode.test.mjs", "The decode contract compiles a standalone probe with the system compiler into a scratch root and removes it in afterAll; the test runs under `node --test` as well as vitest, so it cannot import the workspace temp-dir helper.", ], + [ + "packages/runtime-native/tests/resize-presentation-behavior.test.mjs", + "The presentation contract compiles a standalone probe with the system compiler into a scratch root and removes it in a finally, for the same reason as the decode contract above.", + ], [ "scripts/capture-blender-mcp-tools.ts", "The tool-snapshot gate packs and installs into scratch roots removed in finally.", diff --git a/scripts/__tests__/verify-volumetric-fog.spec.ts b/scripts/__tests__/verify-volumetric-fog.spec.ts new file mode 100644 index 0000000000..e6d0c73cb3 --- /dev/null +++ b/scripts/__tests__/verify-volumetric-fog.spec.ts @@ -0,0 +1,349 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadPlaytestScenario } from "../../packages/playtest/dist/index.js"; +import { makeTempDir } from "../../test-support/temp-dir.js"; +import { + fogCaptureIsValid, + fogCaptureScenario, + fogCaptureScenarios, + fogFrameCost, + fogLightPixelMetrics, + fogOverlapPixelMetrics, + fogTextureBaselineMatches, +} from "../verify-volumetric-fog.js"; + +describe("volumetric fog runtime evidence", () => { + it("rejects software device loss even when the runner reports a pass", () => { + const good = { + pass: true, + capture: { + rendererKind: "webgpu", + adapter: { description: "SwiftShader" }, + viewport: { width: 640, height: 400 }, + }, + diagnostics: [], + }; + expect(fogCaptureIsValid(good)).toBe(true); + expect( + fogCaptureIsValid({ + ...good, + diagnostics: [{ code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST", severity: "warning" }], + }), + ).toBe(false); + expect( + fogCaptureIsValid({ ...good, diagnostics: [{ code: "UNRELATED_ERROR", severity: "error" }] }), + ).toBe(false); + expect(fogCaptureIsValid({ ...good, capture: undefined })).toBe(false); + expect(fogCaptureIsValid({ ...good, capture: { rendererKind: "webgl2" } })).toBe(false); + expect(fogCaptureIsValid({ ...good, capture: { ...good.capture, adapter: {} } })).toBe(false); + expect( + fogCaptureIsValid({ + ...good, + capture: { + ...good.capture, + adapter: { features: "timestamp-query", vendor: "unknown", architecture: "unavailable" }, + }, + }), + ).toBe(false); + expect( + fogCaptureIsValid({ + ...good, + capture: { ...good.capture, viewport: { width: 1, height: 1 } }, + }), + ).toBe(false); + expect(fogCaptureIsValid({ ...good, pass: false })).toBe(false); + }); + it("loads the generated pixel-bearing scenario with the public validator", async () => { + const root = await makeTempDir("vq07-scenario-"); + for (const { mode, scenario: authored } of await fogCaptureScenarios()) { + const file = path.join(root, `${mode}.playtest.json`); + await writeFile(file, JSON.stringify(authored)); + const scenario = await loadPlaytestScenario(root, file); + expect(scenario.assert?.visual).toHaveLength(mode === "blackOff" ? 2 : 1); + expect(scenario.artifacts?.screenshots).toBe("after"); + } + }); + it("loads the committed repeated-lifecycle scenario", async () => { + const scenario = await loadPlaytestScenario( + process.cwd(), + "examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json", + ); + expect(scenario.steps).toHaveLength(15); + }); +}); + +it("uses an inline favicon instead of the confirmed missing /favicon.ico", async () => { + const html = await readFile("examples/abyss-framework/vq-fog/index.html", "utf8"); + expect(html).toMatch(//); +}); + +it("requires a black no-fog control without weakening the positive-arm visual guard", () => { + expect(fogCaptureScenario("blackOff", "KeyN").assert?.visual?.[1]?.region).toMatchObject({ + maxLuminance: 0, + minDarkPixelRatio: 1, + }); + expect( + fogCaptureScenario("scatter", "KeyL").assert?.visual?.[0]?.region?.minNonblankPixelRatio, + ).toBe(0.05); +}); + +it("qualifies both lights in a fixed scattering ROI and rejects stray-pixel evidence", () => { + // Synthetic buffers exercise the verifier only; they are never runtime screenshot evidence. + const frame = () => ({ width: 640, height: 400, data: Buffer.alloc(640 * 400 * 4) }); + const black = frame(); + const both = frame(); + const sunOff = frame(); + const pointOff = frame(); + for (const image of [black, both, sunOff, pointOff]) + for (let y = 25; y < 135; y += 1) + for (let x = 515; x < 625; x += 1) + for (let c = 0; c < 3; c += 1) image.data[(y * 640 + x) * 4 + c] = 128; + for (let y = 155; y < 245; y += 1) + for (let x = 260; x < 410; x += 1) { + const i = (y * 640 + x) * 4; + for (let c = 0; c < 3; c += 1) { + both.data[i + c] = 30; + sunOff.data[i + c] = 20; + pointOff.data[i + c] = 10; + } + } + expect(fogLightPixelMetrics(black, both, sunOff, pointOff).pass).toBe(true); + expect(fogLightPixelMetrics(black, both, both, pointOff).pass).toBe(false); + const stray = { ...black, data: Buffer.from(black.data) }; + stray.data[(180 * 640 + 300) * 4] = 255; + expect(fogLightPixelMetrics(black, stray, black, black).pass).toBe(false); + const outside = { ...black, data: Buffer.from(black.data) }; + outside.data[(10 * 640 + 10) * 4] = 255; + expect(fogLightPixelMetrics(black, outside, black, black).pass).toBe(false); + both.data[(224 * 640 + 193) * 4] = 1; + expect(fogLightPixelMetrics(black, both, sunOff, pointOff).pass).toBe(false); + both.data[(224 * 640 + 193) * 4] = 0; + both.data[(30 * 640 + 520) * 4] = 127; + expect(fogLightPixelMetrics(black, both, sunOff, pointOff).pass).toBe(false); + both.data[(30 * 640 + 520) * 4] = 128; + black.data[0] = 1; + expect(fogLightPixelMetrics(black, both, sunOff, pointOff).pass).toBe(false); + expect(() => fogLightPixelMetrics({ ...black, width: 1 }, both, sunOff, pointOff)).toThrow(/640/); +}); + +it("runs the committed lifecycle and return-to-off scenarios, with actual release counts", async () => { + const scenarios = await fogCaptureScenarios(); + const lifecycle = scenarios.find(({ mode }) => mode === "lifecycle")?.scenario; + const off = scenarios.find(({ mode }) => mode === "lifecycleOff")?.scenario; + expect(lifecycle?.steps).toHaveLength(15); + expect(lifecycle?.steps[0]?.press).toEqual(["KeyF"]); + expect(off?.steps).toHaveLength(18); + expect(lifecycle?.assert?.components).toEqual( + expect.arrayContaining([expect.objectContaining({ component: "releasedTargets", equals: 3 })]), + ); + expect(off?.assert?.components).toEqual( + expect.arrayContaining([ + expect.objectContaining({ component: "releasedTargets", equals: 4 }), + expect.objectContaining({ component: "liveTargets", equals: 0 }), + ]), + ); +}); + +it("requires observed rendered-frame stability before claiming the texture baseline restored", () => { + const baseline = { textures: 2, settledRenderFrames: 3, stableTextureFrames: 3 }; + expect(fogTextureBaselineMatches(baseline, baseline)).toBe(true); + expect(fogTextureBaselineMatches(baseline, { ...baseline, textures: 3 })).toBe(false); + expect(fogTextureBaselineMatches(baseline, { ...baseline, settledRenderFrames: 0 })).toBe(false); + expect(fogTextureBaselineMatches(baseline, { ...baseline, stableTextureFrames: 2 })).toBe(false); + expect(fogTextureBaselineMatches({}, baseline)).toBe(false); +}); +it("captures the live target at both sizes without replacing the fog controller", async () => { + const cases = await fogCaptureScenarios(); + for (const [mode, width, height] of [ + ["resizeSmall", 320, 240], + ["resizeRestore", 640, 400], + ] as const) { + const scenario = cases.find((entry) => entry.mode === mode)?.scenario; + expect(scenario?.assert?.components).toEqual( + expect.arrayContaining([ + expect.objectContaining({ component: "targetWidth", equals: width }), + expect.objectContaining({ component: "targetHeight", equals: height }), + expect.objectContaining({ component: "createdTargets", equals: 1 }), + expect.objectContaining({ component: "releasedTargets", equals: 0 }), + ]), + ); + } +}); + +it("waits for measured render stability instead of treating fixed ticks as rendered frames", async () => { + for (const { scenario } of await fogCaptureScenarios()) { + expect(scenario.steps.at(-1)).toMatchObject({ + waitForResource: { id: "state", path: "stableTextureFrames", gte: 3 }, + timeoutMs: 30000, + }); + } + const restore = (await fogCaptureScenarios()).find( + ({ mode }) => mode === "resizeRestore", + )?.scenario; + const steps = restore?.steps ?? []; + const restoreAt = steps.findIndex( + (step) => Array.isArray(step.press) && step.press.includes("KeyT"), + ); + expect(steps[restoreAt - 1]?.waitForResource).toMatchObject({ + id: "state", + path: "stableTextureFrames", + gte: 3, + }); +}); + +it("presents each intermediate lifecycle graph before the following transition", async () => { + const scenario = (await fogCaptureScenarios()).find(({ mode }) => mode === "lifecycle")?.scenario; + const steps = scenario?.steps ?? []; + const inputs = steps.flatMap((step, index) => (step.press === undefined ? [] : [index])); + expect(inputs).toHaveLength(5); + for (const [at, index] of inputs.entries()) { + const boundary = steps[(inputs[at + 1] ?? steps.length) - 1]; + expect(boundary?.waitForResource).toEqual({ id: "state", path: "stableTextureFrames", gte: 3 }); + expect(boundary?.label).toMatch(/rendered$/); + expect((inputs[at + 1] ?? steps.length) - 1).toBeGreaterThan(index); + } +}); + +it("keeps original arms and adds retained camera, streamed-depth and scene transitions", async () => { + const scenarios = await fogCaptureScenarios(); + for (const mode of [ + "scatterOutside", + "scatterOutsideSunOff", + "scatterOutsidePointOff", + "cameraCut", + "cameraRestore", + "streamWallOut", + "streamWallIn", + "sceneReentry", + "sceneRepeatedOff", + ]) + expect( + scenarios.find((entry) => entry.mode === mode), + mode, + ).toBeDefined(); + for (const mode of ["cameraCut", "cameraRestore", "streamWallOut", "streamWallIn"]) { + const scenario = scenarios.find((entry) => entry.mode === mode)?.scenario; + expect(scenario?.assert?.components).toEqual( + expect.arrayContaining([ + expect.objectContaining({ component: "createdTargets", equals: 1 }), + expect.objectContaining({ component: "releasedTargets", equals: 0 }), + ]), + ); + } +}); + +it("records a clean measured frame-cost window and refuses missing or mixed-window evidence", () => { + const metric = { mean: 3, p50: 2.8, p95: 4 }; + const window = { + window: 3, + frames: 30, + frame: metric, + phases: { render: metric }, + surface: { drawingBufferWidth: 640, drawingBufferHeight: 400 }, + }; + const logs = [1, 2, 3].map((n) => ({ + text: `TN_FRAME_BUDGET:${JSON.stringify({ ...window, window: n })}`, + })); + expect(fogFrameCost(logs)).toMatchObject({ frames: 30, renderMs: metric, gpuMs: undefined }); + // gpuMs is a mean over the frames the device answered for, so the sparse ones travel with it. + const sparse = fogFrameCost([ + ...logs.slice(0, 2), + { + text: `TN_FRAME_BUDGET:${JSON.stringify({ + ...window, + gpuMs: 174.78, + gpu: { mean: 174.78, p50: 174.69, p95: 179.36, samples: 4 }, + gpuStale: 26, + })}`, + }, + ]); + expect(sparse).toMatchObject({ gpuMs: 174.78, gpuSamples: 4, gpuStaleFrames: 26 }); + expect(sparse.qualification).toMatch(/4 of 30 frames/); + // A gpu mean over a count the meter never measured is the same silent lie in its own field. + const measuredGpu = { mean: 174.78, p50: 174.69, p95: 179.36, samples: 4 }; + for (const gpu of [ + { gpu: { ...measuredGpu, samples: "many" }, gpuMs: 174.78 }, + { gpu: { ...measuredGpu, samples: -4 }, gpuMs: 174.78 }, + { gpu: { ...measuredGpu, samples: 31 }, gpuMs: 174.78 }, + { gpuStale: -26 }, + { gpu: undefined, gpuStale: 30 }, + ]) + expect(() => + fogFrameCost([ + ...logs.slice(0, 2), + { + text: `TN_FRAME_BUDGET:${JSON.stringify({ ...window, gpuMs: 174.78, gpuStale: 26, ...gpu })}`, + }, + ]), + ).toThrow(/gpu sample count/); + expect(() => fogFrameCost([])).toThrow(/windows/); + expect(() => fogFrameCost(logs.slice(0, 2))).toThrow(/windows/); + for (const change of [ + { frames: 0 }, + { frames: undefined }, + { window: 1 }, + { gpuMs: -1 }, + { phases: {} }, + { surface: { ...window.surface, compiling: true } }, + ]) + expect(() => + fogFrameCost([ + ...logs.slice(0, 2), + { text: `TN_FRAME_BUDGET:${JSON.stringify({ ...window, ...change })}` }, + ]), + ).toThrow(/frame-cost|windows/); + expect(() => fogFrameCost([{ text: "TN_FRAME_BUDGET:{broken" }])).toThrow(/MALFORMED/); +}); +it("waits for actual render observations before the three dedicated cost captures", async () => { + const scenarios = await fogCaptureScenarios(); + for (const mode of ["costOff", "costFull", "costHalf"]) { + const scenario = scenarios.find((entry) => entry.mode === mode)?.scenario; + expect(scenario?.steps).toContainEqual( + expect.objectContaining({ + waitForResource: { id: "state", path: "settledRenderFrames", gte: 90 }, + }), + ); + } +}); + +it("observes the realized transport target instead of trusting the request", async () => { + const scenarios = await fogCaptureScenarios(); + for (const [mode, width, height, steps] of [ + ["half", 320, 200, 48], + ["costFull", 640, 400, 48], + ["costHalf", 320, 200, 48], + ["costOff", 0, 0, 0], + ] as const) { + const components = scenarios.find((entry) => entry.mode === mode)?.scenario.assert?.components; + expect(components, mode).toEqual( + expect.arrayContaining([ + expect.objectContaining({ component: "targetWidth", equals: width }), + expect.objectContaining({ component: "targetHeight", equals: height }), + expect.objectContaining({ component: "pixels", equals: width * height }), + expect.objectContaining({ component: "steps", equals: steps }), + ]), + ); + } +}); + +it("declares the second density bound and measures that it changes the image", () => { + for (const mode of ["overlap", "fog"]) { + expect(fogCaptureScenario(mode, "KeyF").assert?.components).toContainEqual( + expect.objectContaining({ component: "overlaps", equals: mode === "overlap" }), + ); + } + const frame = { width: 2, height: 1, data: new Uint8Array(8) }; + const second = { width: 2, height: 1, data: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]) }; + expect(fogOverlapPixelMetrics(frame, second)).toMatchObject({ + changedPixelRatio: 0, + maxChannelDelta: 0, + pass: false, + }); + expect( + fogOverlapPixelMetrics(frame, { + ...second, + data: new Uint8Array([0, 0, 0, 255, 0, 9, 0, 255]), + }), + ).toMatchObject({ pass: true }); +}); diff --git a/scripts/__tests__/vq-fog-native-proof.spec.ts b/scripts/__tests__/vq-fog-native-proof.spec.ts new file mode 100644 index 0000000000..313a300bfc --- /dev/null +++ b/scripts/__tests__/vq-fog-native-proof.spec.ts @@ -0,0 +1,199 @@ +import { PNG } from "pngjs"; +import { expect, it } from "vitest"; +import { assertCaptureNotBlank } from "../../packages/playtest/dist/capture.js"; +import { + ANDROID_TRANSPORT_CAPABILITIES, + type IBridgeTransport, + connectPlaytestBridgeTransport, +} from "../../packages/playtest/dist/runner/index.js"; +import { requiredPlaytestCapabilities } from "../../packages/playtest/src/assertion-schema.js"; +import { PLAYTEST_PROTOCOL_LIMITS } from "../../packages/playtest/src/protocol.js"; +import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js"; +import { + fogCaptureScenarios, + fogNativeCaptureIsValid, + fogNativePixelMetrics, + fogResizeControlScenarios, + nativeFogScenario, + readFogNativeConsole, +} from "../verify-volumetric-fog.js"; + +const consoleEvidence = [{ type: "log", text: "TN_NATIVE_SMOKE_FIRST_FRAME" }]; +it("rejects a native frame below the authored five-percent nonblank threshold", () => { + // Verifier-only synthetic input, never screenshot evidence. The native generic dark-frame + // exemption accepts this image, but the authored fog scenario requires five percent. + const png = new PNG({ width: 640, height: 400 }); + for (let pixel = 0; pixel < 640 * 400; pixel += 1) png.data[pixel * 4 + 3] = 255; + for (let pixel = 0; pixel < 10000; pixel += 1) + for (let channel = 0; channel < 3; channel += 1) + png.data[pixel * 4 + channel] = 32 + (pixel % 96); + expect(() => assertCaptureNotBlank(PNG.sync.write(png), "synthetic unit input")).not.toThrow(); + expect(fogNativePixelMetrics(png).nonblankPixelRatio).toBe(0.0390625); + expect(fogNativePixelMetrics(png).pass).toBe(false); + for (let pixel = 10000; pixel < 12800; pixel += 1) + for (let channel = 0; channel < 3; channel += 1) png.data[pixel * 4 + channel] = 128; + expect(fogNativePixelMetrics(png).pass).toBe(true); +}); +it("surfaces the primary host failure before trying a missing console file", async () => { + await expect( + readFogNativeConsole( + { + ...report(), + pass: false, + diagnostics: [ + { + code: "TN_PLAYTEST_HOST_EXITED", + severity: "error", + message: "Surface image is already acquired; SIGABRT", + }, + ], + }, + "/does-not-exist/vq07/console.json", + ), + ).rejects.toThrow(/TN_PLAYTEST_HOST_EXITED.*Surface image is already acquired/u); +}); +it("isolates native resize with fog off while preserving the original fog sequence", async () => { + const original = await fogCaptureScenarios(); + const controls = fogResizeControlScenarios(); + expect(controls.map(({ mode }) => mode)).toEqual(["resizeOff", "resizeOffRestore"]); + for (const { scenario, mode } of controls) { + expect(validatePlaytestScenario(scenario, mode)).toBeDefined(); + expect(scenario.steps[0]).toMatchObject({ press: ["KeyO"] }); + expect(scenario.steps.some((step) => "press" in step && step.press?.includes("KeyF"))).toBe( + false, + ); + expect(scenario.steps.some((step) => "press" in step && step.press?.includes("KeyR"))).toBe( + true, + ); + for (const component of ["createdTargets", "releasedTargets", "liveTargets"]) + expect(scenario.assert?.components).toContainEqual( + expect.objectContaining({ component, equals: 0 }), + ); + } + expect( + controls[1]?.scenario.steps.some((step) => "press" in step && step.press?.includes("KeyT")), + ).toBe(true); + expect(original.slice(0, 17).map(({ mode }) => mode)).toEqual([ + "off", + "zero", + "fog", + "inside", + "sunOff", + "pointOff", + "overlap", + "half", + "wallOff", + "blackOff", + "scatter", + "scatterSunOff", + "scatterPointOff", + "lifecycle", + "lifecycleOff", + "resizeSmall", + "resizeRestore", + ]); + expect(original).toHaveLength(29); + expect(original.find(({ mode }) => mode === "resizeSmall")?.scenario.steps[0]).toMatchObject({ + press: ["KeyF"], + }); +}); +function report(): Parameters[0] { + return { + pass: true, + runtime: "native", + target: "desktop", + diagnostics: [], + startup: { phase: "ready", progress: 1, compileSettled: true, rule: "compile-settled" }, + assertionResults: [{ id: "resources.0", pass: true }], + capture: { + target: "desktop", + rendererKind: "webgpu", + captureMethod: "device.screenshot", + browserArgs: [], + adapter: { description: "llvmpipe" }, + viewport: { width: 640, height: 400 }, + }, + }; +} +it("requires actual native provenance, assertions, readiness and console evidence", () => { + expect(fogNativeCaptureIsValid(report(), consoleEvidence)).toBe(true); + for (const mutation of [ + { pass: false }, + { runtime: "browser" }, + { target: "web" }, + { capture: undefined }, + { assertionResults: [] }, + { startup: undefined }, + { diagnostics: [{ severity: "warning", code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" }] }, + ]) + expect( + fogNativeCaptureIsValid( + { ...report(), ...mutation } as Parameters[0], + consoleEvidence, + ), + ).toBe(false); + for (const badConsole of [ + [], + undefined, + [{ type: "error", text: "other error" }], + [{ type: "log", text: "[WebGPU] Device error (Validation): invalid texture" }], + [{ type: "log", text: "TypeError: unsupported graph" }], + [{ type: "log", text: "no first-frame marker" }], + ]) + expect(fogNativeCaptureIsValid(report(), badConsole)).toBe(false); +}); +it("preserves every fog state predicate through supported native resources", async () => { + for (const { mode, scenario } of await fogCaptureScenarios()) { + const native = nativeFogScenario(scenario); + expect(native.assert?.resources).toEqual( + scenario.assert?.components?.map(({ entity: _entity, component, ...predicate }) => ({ + id: "state", + path: component, + ...predicate, + })), + ); + expect(native.assert?.diagnostics).toBeUndefined(); + expect(native.assert?.visual).toBeUndefined(); + expect(native.assert?.startup?.maxReadyMs).toBe(120000); + expect(requiredPlaytestCapabilities(native, "desktop")).not.toContain("runtime.diagnostics"); + expect(validatePlaytestScenario(native, mode).target).toBe("desktop"); + } +}); +it("passes the real native handshake preflight for every generated arm", async () => { + // Minimal real QuickJS handshake captured by PR396 run36996645116, plus the actual + // shared mailbox transport capabilities (including host key injection). Not a native render claim. + const transport: IBridgeTransport = { + capabilities: ANDROID_TRANSPORT_CAPABILITIES, + waitForBridge: async () => true, + close: async () => {}, + async call(method: string): Promise { + if (method === "describe") + return { + capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + limits: PLAYTEST_PROTOCOL_LIMITS, + name: "@threenative/playtest/three", + protocolVersion: 1, + } as T; + if (method === "ready") return { ready: true } as T; + throw new Error(`Unexpected preflight call ${method}`); + }, + }; + for (const { scenario } of await fogCaptureScenarios()) + await expect( + connectPlaytestBridgeTransport(transport, nativeFogScenario(scenario), 1000, "desktop"), + ).resolves.toBeDefined(); +}); + +it("declares the native backing-surface size for the small-target capture without weakening defaults", async () => { + const authored = (await fogCaptureScenarios()).find( + ({ mode }) => mode === "resizeSmall", + )?.scenario; + if (authored === undefined) throw new Error("Missing small-target scenario"); + const viewport = { width: 320, height: 240 }; + expect(nativeFogScenario(authored, viewport).viewport).toEqual(viewport); + const capture = report().capture; + if (!capture) throw new Error("Missing native capture fixture"); + const small = { ...report(), capture: { ...capture, viewport } }; + expect(fogNativeCaptureIsValid(small, consoleEvidence)).toBe(false); + expect(fogNativeCaptureIsValid(small, consoleEvidence, viewport)).toBe(true); +}); diff --git a/scripts/verify-volumetric-fog.ts b/scripts/verify-volumetric-fog.ts new file mode 100644 index 0000000000..b243962238 --- /dev/null +++ b/scripts/verify-volumetric-fog.ts @@ -0,0 +1,947 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; +import { type IPlaytestScenario, loadPlaytestScenario } from "../packages/playtest/dist/index.js"; +// Use the built public runner: source-runner browser callbacks under tsx may capture __name. +import { + type IStandalonePlaytestReport, + WEBGPU_BROWSER_ARGS, + runDesktopPlaytest, + runStandalonePlaytest, +} from "../packages/playtest/dist/runner/index.js"; +import { parsePerformanceMarkers } from "../packages/playtest/src/runner/perf.js"; +import { regionMetrics } from "../packages/playtest/src/runner/steps.js"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const fixture = path.join(root, "examples/abyss-framework/vq-fog"); +const cases = [ + ["off", "KeyO"], + ["zero", "KeyZ"], + ["fog", "KeyF"], + ["inside", "KeyI"], + ["sunOff", "KeyS"], + ["pointOff", "KeyP"], + ["overlap", "KeyB"], + ["half", "KeyH"], + ["wallOff", "KeyW"], + ["blackOff", "KeyN"], + ["scatter", "KeyL"], + ["scatterSunOff", "KeyK"], + ["scatterPointOff", "KeyJ"], +] as const; + +const textureBoundary = { + waitForResource: { id: "state", path: "stableTextureFrames", gte: 3 }, + timeoutMs: 30_000, + release: true, +}; + +export function fogCaptureScenario(mode: string, key: string): IPlaytestScenario { + return { + schemaVersion: 1, + name: `vq-volumetric-fog-${mode}`, + target: "web", + viewport: { width: 640, height: 400 }, + warmupFrames: 60, + steps: [ + { press: [key], holdTicks: 1, release: true }, + { kind: "wait", waitFrames: 60, release: true }, + textureBoundary, + ], + assert: { + components: [ + { + entity: "fog", + component: "ready", + equals: true, + allowTrivial: "Capture only the ready graph.", + }, + { + entity: "fog", + component: "mode", + equals: mode, + allowTrivial: "A fixed named variant is the screenshot subject.", + }, + { + entity: "fog", + component: "targets", + equals: ["off", "zero", "blackOff"].includes(mode) ? 0 : 1, + allowTrivial: "Observe the owned allocation baseline for this variant.", + }, + ...["settledRenderFrames", "stableTextureFrames"].map((component) => ({ + entity: "fog", + component, + gte: 3, + allowTrivial: "Resource counts must be observed stable over completed rendered frames.", + })), + { + entity: "fog", + component: "overlaps", + equals: mode === "overlap", + allowTrivial: "Only the overlap arm installs a second, nested density bound.", + }, + ...[ + ["sun", mode !== "sunOff" && !mode.endsWith("SunOff")], + ["point", mode !== "pointOff" && !mode.endsWith("PointOff")], + ["scatteringOnly", mode.startsWith("scatter") || mode === "blackOff"], + ].map(([component, equals]) => ({ + entity: "fog", + component: String(component), + equals, + allowTrivial: "Observe the declared lighting and surface isolation for this arm.", + })), + ], + diagnostics: { noConsoleErrors: true, noRuntimeDiagnostics: true, runtimeReady: true }, + visual: [ + { + region: { + x: 0, + y: 0, + width: 640, + height: 400, + minNonblankPixelRatio: 0.05, + }, + }, + ...(mode === "blackOff" + ? [ + { + region: { + x: 0, + y: 0, + width: 500, + height: 400, + minNonblankPixelRatio: 0, + minDarkPixelRatio: 1, + maxLuminance: 0, + }, + }, + ] + : []), + ], + }, + artifacts: { screenshots: "after", console: true, runtimeTrace: true }, + }; +} + +export async function fogCaptureScenarios() { + // A requested resolution is not a realized one: these arms must observe their authored transport + // target, so a half-resolution request that silently rendered full size fails instead of passing. + const transportTargets: Record = { + half: [320, 200, 48], + costFull: [640, 400, 48], + costHalf: [320, 200, 48], + costOff: [0, 0, 0], + }; + const observeTransport = (scenario: IPlaytestScenario, mode: string): IPlaytestScenario => { + const target = transportTargets[mode]; + if (target === undefined) return scenario; + scenario.assert?.components?.push( + ...[ + ["targetWidth", target[0]], + ["targetHeight", target[1]], + ["pixels", target[0] * target[1]], + ["steps", target[2]], + ].map(([component, equals]) => ({ + entity: "fog", + component: String(component), + equals, + allowTrivial: "The transport target must realize this arm's authored resolution and steps.", + })), + ); + return scenario; + }; + const result = cases.map(([mode, key]) => ({ + mode: String(mode), + scenario: observeTransport(fogCaptureScenario(mode, key), String(mode)), + })); + const { sourcePath: _sourcePath, ...lifecycle } = await loadPlaytestScenario( + root, + "examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json", + ); + result.push({ mode: "lifecycle", scenario: lifecycle }); + const off = fogCaptureScenario("off", "KeyO"); + off.name = "vq-volumetric-fog-lifecycle-off"; + off.steps = [...lifecycle.steps, ...off.steps]; + off.assert?.components?.push( + ...["createdTargets", "releasedTargets", "releasedMaterials", "liveTargets"].map( + (component) => ({ + entity: "fog", + component, + equals: component === "liveTargets" ? 0 : 4, + allowTrivial: "After four actual fog graphs, off returns the owned allocation baseline.", + }), + ), + ); + result.push({ mode: "lifecycleOff", scenario: off }); + for (const [mode, width, height] of [ + ["resizeSmall", 320, 240], + ["resizeRestore", 640, 400], + ] as const) { + const scenario = fogCaptureScenario("fog", "KeyF"); + scenario.name = `vq-volumetric-fog-${mode}`; + scenario.steps.push( + { press: ["KeyR"], holdTicks: 1, release: true }, + { kind: "wait", waitFrames: 60, release: true }, + textureBoundary, + ); + if (mode === "resizeRestore") + scenario.steps.push( + { press: ["KeyT"], holdTicks: 1, release: true }, + { kind: "wait", waitFrames: 60, release: true }, + textureBoundary, + ); + scenario.assert?.components?.push( + ...[ + ["targetWidth", width], + ["targetHeight", height], + ["pixels", width * height], + ["createdTargets", 1], + ["releasedTargets", 0], + ["liveTargets", 1], + ].map(([component, equals]) => ({ + entity: "fog", + component: String(component), + equals, + allowTrivial: + "The existing fog target follows real renderer dimensions without graph replacement.", + })), + ); + result.push({ mode, scenario }); + } + // Extend, rather than replace, the original seventeen frozen controls. + for (const [mode, key] of [ + ["scatterOutside", "Digit1"], + ["scatterOutsideSunOff", "Digit2"], + ["scatterOutsidePointOff", "Digit3"], + ] as const) { + const scenario = fogCaptureScenario(mode, key); + scenario.assert?.components?.push({ + entity: "fog", + component: "shadowOutside", + equals: true, + allowTrivial: "The whole volume lies outside the ordinary directional shadow coverage.", + }); + result.push({ mode, scenario }); + } + for (const [mode, keys] of [ + ["cameraCut", ["Digit4"]], + ["cameraRestore", ["Digit4", "Digit5"]], + ["streamWallOut", ["Digit6"]], + ["streamWallIn", ["Digit6", "Digit7"]], + ["sceneReentry", ["Digit8"]], + ["sceneRepeatedOff", ["Digit8", "KeyF", "Digit8"]], + ] as const) { + const scenario = fogCaptureScenario(mode.startsWith("scene") ? "off" : "fog", "KeyF"); + scenario.name = `vq-volumetric-fog-${mode}`; + for (const key of keys) + scenario.steps.push( + { press: [key], holdTicks: 1, release: true }, + { kind: "wait", waitFrames: 60, release: true }, + textureBoundary, + ); + const predicates = mode.startsWith("scene") + ? [ + ["sceneEntries", mode === "sceneRepeatedOff" ? 3 : 2], + ["sceneExits", mode === "sceneRepeatedOff" ? 2 : 1], + ["exitReleasedTargets", 1], + ["exitReleasedMaterials", 1], + ["createdTargets", 0], + ["liveTargets", 0], + ] + : [ + ["createdTargets", 1], + ["releasedTargets", 0], + ["liveTargets", 1], + ["inside", mode === "cameraCut"], + ["streamedWall", mode === "streamWallOut"], + ]; + scenario.assert?.components?.push( + ...predicates.map(([component, equals]) => ({ + entity: "fog", + component: String(component), + equals, + allowTrivial: + "Observe retained-controller transitions or actual scene-owner teardown and re-entry.", + })), + ); + result.push({ mode, scenario }); + } + for (const [mode, variant, key] of [ + ["costOff", "off", "KeyO"], + ["costFull", "fog", "KeyF"], + ["costHalf", "half", "KeyH"], + ] as const) { + const scenario = observeTransport(fogCaptureScenario(variant, key), mode); + scenario.name = `vq-volumetric-fog-${mode}`; + scenario.steps.splice(scenario.steps.length - 1, 0, { + waitForResource: { id: "state", path: "settledRenderFrames", gte: 90 }, + timeoutMs: 120_000, + release: true, + }); + scenario.assert?.components?.push({ + entity: "fog", + component: "settledRenderFrames", + gte: 90, + allowTrivial: + "Measure at least three thirty-frame meter windows after this fixed graph rendered.", + }); + result.push({ mode, scenario }); + } + return result; +} + +export function fogFrameCost(consoleEntries: unknown) { + if (!Array.isArray(consoleEntries)) throw new Error("VQ07 measured frame windows are missing."); + const text = consoleEntries.map((entry) => entry.text).join("\n"); + const { budgets } = parsePerformanceMarkers(text); + const measured = budgets.at(-1); + if (budgets.length < 3 || measured === undefined) + throw new Error("VQ07 requires three measured frame windows."); + const render = measured.phases?.render; + const frame = measured.frame; + if ( + !Number.isInteger(measured.window) || + measured.window < 3 || + !Number.isInteger(measured.frames) || + measured.frames < 30 || + (measured.gpuMs !== undefined && (!Number.isFinite(measured.gpuMs) || measured.gpuMs < 0)) || + measured.surface === undefined || + measured.surface.compiling === true || + measured.surface.drawingBufferWidth !== 640 || + measured.surface.drawingBufferHeight !== 400 || + [render, frame].some( + (metric) => + metric === undefined || + [metric.mean, metric.p50, metric.p95].some((value) => !Number.isFinite(value) || value < 0), + ) + ) + throw new Error("VQ07 invalid or compilation-contaminated frame-cost window."); + // `gpuMs` is the meter's own mean over the frames the device answered for, not over `frames`. + // A software adapter answers sparsely, so the summary carries how many and how many it skipped. + const marker = text + .split("\n") + .filter((line) => line.includes("TN_FRAME_BUDGET:")) + .at(-1); + const raw = marker === undefined ? {} : JSON.parse(marker.split("TN_FRAME_BUDGET:")[1] ?? "{}"); + const gpuSamples = raw.gpu?.samples ?? 0; + const gpuStaleFrames = raw.gpuStale ?? 0; + // A count the meter cannot have measured is a malformed meter, not an absent one: `NaN`, a negative + // or an over-window count would print as a mean over frames nobody answered for. A window with no GPU + // query stays an explicit zero and is unmeasured; a `gpuMs` without a positive count is rejected + // rather than reported as a mean over zero frames. + if ( + [gpuSamples, gpuStaleFrames].some( + (count) => !Number.isInteger(count) || count < 0 || count > measured.frames, + ) || + (measured.gpuMs !== undefined && gpuSamples === 0) + ) + throw new Error( + `VQ07 gpu sample counts must be nonnegative integers within the ${measured.frames}-frame window.`, + ); + return { + window: measured.window, + frames: measured.frames, + frameMs: frame, + renderMs: render, + gpuMs: measured.gpuMs, + gpuSamples, + gpuStaleFrames, + qualification: `Same-fixture host frame/render duration; gpuMs is a mean over ${gpuSamples} of ${measured.frames} frames the device answered for and ${gpuStaleFrames} were stale; software adapters do not establish hardware cost or tier admission.`, + }; +} + +// A one-variable diagnostic: retain this fixture's geometry, renderer and resize path, +// while never creating a fog controller. The ordinary 17-case acceptance sequence is separate. +export function fogResizeControlScenarios() { + return ["resizeOff", "resizeOffRestore"].map((mode) => { + const scenario = fogCaptureScenario("off", "KeyO"); + scenario.name = `vq-volumetric-fog-${mode}`; + for (const key of mode === "resizeOff" ? ["KeyR"] : ["KeyR", "KeyT"]) + scenario.steps.push( + { press: [key], holdTicks: 1, release: true }, + { kind: "wait", waitFrames: 60, release: true }, + textureBoundary, + ); + scenario.assert?.components?.push( + ...["createdTargets", "releasedTargets", "liveTargets"].map((component) => ({ + entity: "fog", + component, + equals: 0, + allowTrivial: "The resize control must never allocate a fog controller or target.", + })), + ); + return { mode, scenario }; + }); +} + +export function fogNativePixelMetrics(png: PNG) { + const metrics = regionMetrics(png, { x: 0, y: 0, width: png.width, height: png.height }); + return { ...metrics, minimum: 0.05, pass: metrics.nonblankPixelRatio >= 0.05 }; +} + +export async function readFogNativeConsole( + report: Pick, + consolePath: string, + viewport = { width: 640, height: 400 }, +): Promise { + // Failed hosts can exit before writing console.json. Preserve their actual diagnostic. + if (!fogCaptureIsValid(report, viewport)) + throw new Error( + `VQ07 native capture failed: ${report.diagnostics.map(({ code, message }) => `${code}: ${message}`).join("; ") || "invalid capture report"}`, + ); + return JSON.parse(await readFile(consolePath, "utf8")); +} + +export function fogTextureBaselineMatches( + baseline: Record, + restored: Record, +): boolean { + return ( + [baseline, restored].every((observation) => + ["textures", "settledRenderFrames", "stableTextureFrames"].every( + (key) => + typeof observation[key] === "number" && + Number.isInteger(observation[key]) && + observation[key] >= (key === "textures" ? 0 : 3), + ), + ) && baseline.textures === restored.textures + ); +} + +interface IPixelFrame { + width: number; + height: number; + data: Uint8Array; +} +// Pinned before capture: room interior excludes the foreground plate (x < 238), +// while the second patch lies wholly inside that plate. No single bright pixel can pass. +const SCATTER_ROI = { x: 260, y: 155, width: 150, height: 90 }; +const FOG_EVALUATION_ROI = { x: 0, y: 0, width: 500, height: 400 }; +const CALIBRATION_ROI = { x: 515, y: 25, width: 110, height: 110 }; +const WALL_ROI = { x: 184, y: 216, width: 18, height: 18 }; +export function fogLightPixelMetrics( + black: IPixelFrame, + both: IPixelFrame, + sunOff: IPixelFrame, + pointOff: IPixelFrame, +) { + const images = [black, both, sunOff, pointOff]; + if ( + images.some( + (image) => image.width !== 640 || image.height !== 400 || image.data.length !== 640 * 400 * 4, + ) + ) + throw new Error("VQ07 scattering evidence requires complete 640x400 RGBA pixels."); + const maxRgb = (image: IPixelFrame, roi: typeof SCATTER_ROI) => { + let maximum = 0; + for (let y = roi.y; y < roi.y + roi.height; y += 1) + for (let x = roi.x; x < roi.x + roi.width; x += 1) + for (let c = 0; c < 3; c += 1) + maximum = Math.max(maximum, image.data[(y * image.width + x) * 4 + c] ?? Number.NaN); + return maximum; + }; + const delta = (without: IPixelFrame) => { + let total = 0; + let changed = 0; + for (let y = SCATTER_ROI.y; y < SCATTER_ROI.y + SCATTER_ROI.height; y += 1) + for (let x = SCATTER_ROI.x; x < SCATTER_ROI.x + SCATTER_ROI.width; x += 1) { + let strongest = 0; + for (let c = 0; c < 3; c += 1) { + const i = (y * both.width + x) * 4 + c; + const difference = (both.data[i] ?? Number.NaN) - (without.data[i] ?? Number.NaN); + total += difference; + strongest = Math.max(strongest, difference); + } + if (strongest > 2) changed += 1; + } + const pixels = SCATTER_ROI.width * SCATTER_ROI.height; + return { meanRgbDelta: total / (pixels * 3), changedPixelRatio: changed / pixels }; + }; + const baselineMaxRgb = maxRgb(black, FOG_EVALUATION_ROI); + let calibrationMinimum = 255; + let calibrationMaxDifference = 0; + for (let y = CALIBRATION_ROI.y; y < CALIBRATION_ROI.y + CALIBRATION_ROI.height; y += 1) + for (let x = CALIBRATION_ROI.x; x < CALIBRATION_ROI.x + CALIBRATION_ROI.width; x += 1) + for (let c = 0; c < 3; c += 1) { + const i = (y * 640 + x) * 4 + c; + const reference = black.data[i] ?? Number.NaN; + calibrationMinimum = Math.min(calibrationMinimum, reference); + for (const image of images) + calibrationMaxDifference = Math.max( + calibrationMaxDifference, + Math.abs((image.data[i] ?? Number.NaN) - reference), + ); + } + const wallMaxRgb = Math.max(...images.map((image) => maxRgb(image, WALL_ROI))); + const sun = delta(sunOff); + const point = delta(pointOff); + return { + baselineMaxRgb, + wallMaxRgb, + roi: SCATTER_ROI, + wallRoi: WALL_ROI, + evaluationRoi: FOG_EVALUATION_ROI, + calibrationRoi: CALIBRATION_ROI, + calibrationMinimum, + calibrationMaxDifference, + sun, + point, + thresholds: { + minMeanRgbDelta: 1, + minChangedPixelRatio: 0.1, + maxBlackRgb: 0, + minCalibrationRgb: 32, + maxCalibrationDifference: 0, + }, + pass: + baselineMaxRgb === 0 && + calibrationMinimum >= 32 && + calibrationMaxDifference === 0 && + wallMaxRgb === 0 && + [sun, point].every((metric) => metric.meanRgbDelta >= 1 && metric.changedPixelRatio >= 0.1), + }; +} + +// Overlapping bounds are only qualified if the second one actually changes the image: the same +// camera and lights, one extra nested density bound, a difference too large to be noise. +export function fogOverlapPixelMetrics(single: IPixelFrame, overlap: IPixelFrame) { + if ( + single.width !== overlap.width || + single.height !== overlap.height || + single.data.length !== overlap.data.length + ) + throw new Error("VQ07 overlap evidence requires equally sized frames."); + const pixels = single.width * single.height; + let changed = 0; + let maxChannelDelta = 0; + for (let i = 0; i < pixels; i += 1) + for (let c = 0; c < 3; c += 1) { + const at = i * 4 + c; + const difference = Math.abs( + (overlap.data[at] ?? Number.NaN) - (single.data[at] ?? Number.NaN), + ); + maxChannelDelta = Math.max(maxChannelDelta, difference); + if (difference > 2) changed += 1; + } + const changedPixelRatio = changed / (pixels * 3); + return { + changedPixelRatio, + maxChannelDelta, + thresholds: { minChangedPixelRatio: 0.05, minMaxChannelDelta: 4 }, + pass: changedPixelRatio >= 0.05 && maxChannelDelta >= 4, + }; +} + +export function fogCaptureIsValid( + report: { + pass: boolean; + capture?: { + rendererKind?: string; + adapter?: Record; + viewport?: { width: number; height: number }; + }; + diagnostics: readonly { code: string; severity: string }[]; + }, + viewport = { width: 640, height: 400 }, +): boolean { + return ( + report.pass && + report.capture?.rendererKind === "webgpu" && + report.capture.viewport?.width === viewport.width && + report.capture.viewport.height === viewport.height && + ["vendor", "architecture", "device", "description"].some((key) => { + const value = report.capture?.adapter?.[key]; + return ( + typeof value === "string" && value.trim() !== "" && !/^(unknown|unavailable)$/i.test(value) + ); + }) && + !report.diagnostics.some( + (diagnostic) => + diagnostic.severity === "error" || diagnostic.code === "TN_PLAYTEST_SOFTWARE_DEVICE_LOST", + ) + ); +} + +export function nativeFogScenario( + scenario: IPlaytestScenario, + viewport = scenario.viewport, +): IPlaytestScenario { + if (!scenario.assert?.components?.length) + throw new Error("Native fog proof requires state predicates."); + return { + ...scenario, + target: "desktop", + viewport, + artifacts: { screenshots: "after", console: true }, + assert: { + resources: scenario.assert.components.map(({ entity: _entity, component, ...predicate }) => ({ + id: "state", + path: component, + ...predicate, + })), + startup: { maxEnteredMs: 120_000, maxReadyMs: 120_000 }, + }, + }; +} + +export function fogNativeCaptureIsValid( + report: Pick< + IStandalonePlaytestReport, + "pass" | "capture" | "diagnostics" | "runtime" | "target" | "startup" | "assertionResults" + >, + nativeConsole: unknown, + viewport = { width: 640, height: 400 }, +): boolean { + return ( + fogCaptureIsValid(report, viewport) && + report.runtime === "native" && + report.target === "desktop" && + report.capture?.target === "desktop" && + report.capture.captureMethod === "device.screenshot" && + report.startup?.phase === "ready" && + report.startup.compileSettled === true && + (report.assertionResults?.length ?? 0) > 0 && + report.assertionResults?.every((result) => result.pass) === true && + !report.diagnostics.some((diagnostic) => /DEVICE_LOST|BRIDGE_MISSING/u.test(diagnostic.code)) && + Array.isArray(nativeConsole) && + nativeConsole.length > 0 && + nativeConsole.every( + (entry) => + entry && + typeof entry.text === "string" && + typeof entry.type === "string" && + entry.type !== "error" && + !/\[FATAL\]|\[WebGPU\].*(?:Device error|Device lost|Failed)|validation error|device(?:[ _-]| was )?lost|(?:Type|Reference|Range|Syntax)Error|TN_(?:NATIVE_START_FAILED|ASSETS_UNRESOLVED)/iu.test( + entry.text, + ), + ) && + nativeConsole.some((entry) => entry.text.includes("TN_NATIVE_SMOKE_FIRST_FRAME")) + ); +} + +async function main(): Promise { + const nativeRuntime = process.env.THREENATIVE_RUNTIME_BINARY; + const resizeControl = process.env.VQ_FOG_RESIZE_CONTROL === "1"; + if (resizeControl && nativeRuntime === undefined) + throw new Error("The native resize control requires the actual desktop host."); + const artifacts = path.join( + root, + "artifacts", + resizeControl + ? "volumetric-fog-native-resize-control" + : nativeRuntime === undefined + ? "volumetric-fog" + : "volumetric-fog-native", + ); + const nativeBundle = path.join(artifacts, "fog-native.js"); + const hash = async (file: string) => + createHash("sha256") + .update(await readFile(file)) + .digest("hex"); + await mkdir(artifacts, { recursive: true }); + const dirty = execFileSync("git", ["status", "--porcelain"], { + cwd: root, + encoding: "utf8", + }).trim(); + if (dirty !== "") + throw new Error("VQ07 captures require a clean committed source tree for SHA provenance."); + const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + const results: Record[] = []; + const summary = { + sourceSha, + pass: false, + resizeControl, + executionHost: { platform: process.platform, architecture: process.arch }, + nativeArtifacts: undefined as { runtimeSha256: string; bundleSha256: string } | undefined, + qualification: + nativeRuntime === undefined + ? "Browser WebGPU software correctness; no native or hardware-performance claim." + : "Linux native host software-rendered correctness; no Android, iOS, OS-window lifecycle or hardware-performance claim.", + results, + }; + await writeFile( + path.join(artifacts, "attempt.json"), + JSON.stringify({ + sourceSha, + runId: process.env.GITHUB_RUN_ID, + target: nativeRuntime === undefined ? "browser" : "desktop", + }), + ); + try { + if (nativeRuntime !== undefined) { + execFileSync( + process.execPath, + [ + "packages/runtime-native/scripts/bundle.mjs", + "--project", + fixture, + "--entry", + "src/game.ts", + "--target", + "desktop", + "--native-backend", + "--output", + nativeBundle, + ], + { cwd: root, stdio: "inherit" }, + ); + summary.nativeArtifacts = { + runtimeSha256: await hash(nativeRuntime), + bundleSha256: await hash(nativeBundle), + }; + } + for (const { mode, scenario: authored } of resizeControl + ? fogResizeControlScenarios() + : await fogCaptureScenarios()) { + const scenario = + nativeRuntime === undefined + ? authored + : nativeFogScenario( + authored, + mode === "resizeSmall" || mode === "resizeOff" + ? { width: 320, height: 240 } + : authored.viewport, + ); + const directory = path.join(artifacts, mode); + await mkdir(directory, { recursive: true }); + const scenarioPath = path.join(directory, "scenario.playtest.json"); + await writeFile(scenarioPath, `${JSON.stringify(scenario, null, 2)}\n`); + await loadPlaytestScenario(root, scenarioPath); + const report = + nativeRuntime === undefined + ? await runStandalonePlaytest({ + artifactDirectory: directory, + projectPath: fixture, + scenarioPath, + target: "browser", + headless: false, + allowSoftwareAdapter: true, + browserArgs: WEBGPU_BROWSER_ARGS, + port: 0, + url: "http://127.0.0.1:5173/", + timeoutMs: 120_000, + trace: false, + server: { + cwd: root, + command: `VQ_FOG_HTTP_LOG=${JSON.stringify(path.join(directory, "http-errors.jsonl"))} node examples/abyss-framework/node_modules/vite/bin/vite.js preview --config examples/abyss-framework/vq-fog/vite.config.ts --host 127.0.0.1 --port \${PORT}`, + timeoutMs: 60_000, + }, + }) + : await runDesktopPlaytest({ + artifactDirectory: directory, + projectPath: fixture, + scenarioPath, + target: "desktop", + desktop: { + executable: nativeRuntime, + hostArgs: ["run", nativeBundle, "--windowed", "--width", "640", "--height", "400"], + }, + allowSoftwareAdapter: true, + headless: false, + timeoutMs: 120_000, + trace: false, + url: "", + }); + await writeFile(path.join(directory, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + await writeFile( + path.join(directory, "observations.json"), + `${JSON.stringify(report.observations, null, 2)}\n`, + ); + const nativeConsole = + nativeRuntime === undefined + ? undefined + : await readFogNativeConsole( + report, + path.join(directory, "console.json"), + scenario.viewport, + ); + const valid = + nativeRuntime === undefined + ? fogCaptureIsValid(report) + : fogNativeCaptureIsValid(report, nativeConsole, scenario.viewport); + const observedState = report.observations?.resources?.state?.after as + | Record + | undefined; + results.push({ + mode, + sourceSha, + pass: valid, + capture: report.capture, + resources: Object.fromEntries( + ["textures", "settledRenderFrames", "stableTextureFrames"].map((key) => [ + key, + observedState?.[key], + ]), + ), + diagnostics: report.diagnostics, + lights: { + directional: { + name: "fog-directional", + type: "DirectionalLight", + shadow: "ordinary-map", + }, + point: { name: "fog-point", type: "PointLight", shadow: "none", range: 8 }, + }, + }); + await writeFile( + path.join(artifacts, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + if (!valid) + throw new Error( + `VQ07 ${mode}: rejected runtime/capture diagnostics; preserve artifacts as diagnostic only.`, + ); + const png = PNG.sync.read(await readFile(path.join(directory, "after.png"))); + if (png.width !== scenario.viewport.width || png.height !== scenario.viewport.height) + throw new Error( + `VQ07 ${mode}: actual screenshot dimensions do not match the declared capture viewport.`, + ); + if (mode.startsWith("cost")) { + const measured = fogFrameCost( + JSON.parse(await readFile(path.join(directory, "console.json"), "utf8")), + ); + results.push({ + assertion: `${mode} measured per-frame cost`, + ...measured, + transportPixels: observedState?.pixels, + transportWidth: observedState?.targetWidth, + transportHeight: observedState?.targetHeight, + raySteps: observedState?.steps, + directionalLights: 1, + localLights: 1, + }); + } + if (nativeRuntime !== undefined) { + const metrics = fogNativePixelMetrics(png); + results.push({ assertion: `${mode} authored whole-frame nonblank ratio`, ...metrics }); + if (!metrics.pass) + throw new Error( + `VQ07 ${mode}: native pixels fail the authored five-percent nonblank gate.`, + ); + } + } + if (resizeControl) { + if (nativeRuntime === undefined) throw new Error("Missing native resize-control executable."); + if ( + (await hash(nativeRuntime)) !== summary.nativeArtifacts?.runtimeSha256 || + (await hash(nativeBundle)) !== summary.nativeArtifacts?.bundleSha256 + ) + throw new Error("VQ07 native executable or game bundle changed during resize control."); + summary.pass = true; + return; + } + const off = PNG.sync.read(await readFile(path.join(artifacts, "off/after.png"))); + const zero = PNG.sync.read(await readFile(path.join(artifacts, "zero/after.png"))); + if (off.width !== zero.width || off.height !== zero.height || !off.data.equals(zero.data)) + throw new Error("VQ07 zero-density identity failed: rendered off/zero pixels differ."); + results.push({ assertion: "zero-density pixel identity", pass: true }); + const restored = PNG.sync.read(await readFile(path.join(artifacts, "lifecycleOff/after.png"))); + if (!off.data.equals(restored.data)) + throw new Error("VQ07 lifecycle off did not restore the baseline pixels."); + results.push({ assertion: "repeated lifecycle restores baseline pixels", pass: true }); + const baselineResources = results.find((result) => result.mode === "off")?.resources as + | Record + | undefined; + const restoredResources = results.find((result) => result.mode === "lifecycleOff")?.resources as + | Record + | undefined; + const texturesRestored = + baselineResources !== undefined && + restoredResources !== undefined && + fogTextureBaselineMatches(baselineResources, restoredResources); + results.push({ + assertion: "renderer texture allocation baseline", + baselineResources, + restoredResources, + pass: texturesRestored, + }); + if (!texturesRestored) + throw new Error("VQ07 renderer texture allocation baseline did not settle and restore."); + const fog = PNG.sync.read(await readFile(path.join(artifacts, "fog/after.png"))); + const resized = PNG.sync.read(await readFile(path.join(artifacts, "resizeRestore/after.png"))); + if ( + fog.width !== resized.width || + fog.height !== resized.height || + !fog.data.equals(resized.data) + ) + throw new Error("VQ07 target resize restore did not recover the exact fog pixels."); + results.push({ assertion: "target/depth resize restores exact pixels", pass: true }); + const overlapMetrics = fogOverlapPixelMetrics( + fog, + PNG.sync.read(await readFile(path.join(artifacts, "overlap/after.png"))), + ); + results.push({ assertion: "overlapping density bounds change the image", ...overlapMetrics }); + if (!overlapMetrics.pass) + throw new Error("VQ07 the overlapping density bound did not measurably change the image."); + for (const [mode, reference] of [ + ["cameraCut", "inside"], + ["cameraRestore", "fog"], + ["streamWallOut", "wallOff"], + ["streamWallIn", "fog"], + ["sceneReentry", "off"], + ["sceneRepeatedOff", "off"], + ] as const) { + const frame = PNG.sync.read(await readFile(path.join(artifacts, mode, "after.png"))); + const expected = PNG.sync.read(await readFile(path.join(artifacts, reference, "after.png"))); + if (!frame.data.equals(expected.data)) + throw new Error(`VQ07 ${mode} differs from fresh ${reference} pixels.`); + results.push({ assertion: `${mode} matches fresh ${reference} pixels`, pass: true }); + if (mode.startsWith("scene")) { + const resources = results.find((result) => result.mode === mode)?.resources as + | Record + | undefined; + if ( + !resources || + !baselineResources || + !fogTextureBaselineMatches(baselineResources, resources) + ) + throw new Error(`VQ07 ${mode} did not restore renderer texture allocations.`); + } + } + const lightFrames = await Promise.all( + ["blackOff", "scatter", "scatterSunOff", "scatterPointOff"].map(async (mode) => + PNG.sync.read(await readFile(path.join(artifacts, mode, "after.png"))), + ), + ); + const [black, both, sunOff, pointOff] = lightFrames; + if (!black || !both || !sunOff || !pointOff) + throw new Error("Missing scattering-control frame."); + const metrics = fogLightPixelMetrics(black, both, sunOff, pointOff); + results.push({ assertion: "isolated light-scattering pixels", ...metrics }); + if (!metrics.pass) + throw new Error("VQ07 isolated light-scattering/black-control pixel gates failed."); + const outsideFrames = await Promise.all( + ["scatterOutside", "scatterOutsideSunOff", "scatterOutsidePointOff"].map(async (mode) => + PNG.sync.read(await readFile(path.join(artifacts, mode, "after.png"))), + ), + ); + const [outside, outsideSunOff, outsidePointOff] = outsideFrames; + if (!outside || !outsideSunOff || !outsidePointOff) + throw new Error("Missing outside-shadow-map control."); + const outsideMetrics = fogLightPixelMetrics(black, outside, outsideSunOff, outsidePointOff); + results.push({ assertion: "unshadowed light outside directional map", ...outsideMetrics }); + if (!outsideMetrics.pass) + throw new Error("VQ07 outside-shadow-map light-response gates failed."); + if ( + nativeRuntime !== undefined && + ((await hash(nativeRuntime)) !== summary.nativeArtifacts?.runtimeSha256 || + (await hash(nativeBundle)) !== summary.nativeArtifacts?.bundleSha256) + ) + throw new Error("VQ07 native executable or game bundle changed during proof."); + summary.pass = true; + } finally { + await writeFile(path.join(artifacts, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + } +} +if ( + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +}