diff --git a/biome.json b/biome.json index 3986db935..2dc6baa87 100644 --- a/biome.json +++ b/biome.json @@ -210,6 +210,37 @@ "formatter": { "enabled": false } + }, + { + "include": ["examples/procedural-animals/src/render/donor/core/motion/quadruped.js"], + "linter": { + "rules": { + "style": { + "useNamingConvention": { + "level": "error", + "options": { + "conventions": [ + { + "selector": { + "kind": "interface" + }, + "match": "I(.*)" + }, + { + "selector": { + "kind": "objectLiteralProperty" + }, + "match": "(?:D|DL|T|F|H|W|U|Lf|(.+))", + "formats": ["camelCase"] + } + ], + "strictCase": false, + "requireAscii": false + } + } + } + } + } } ] } diff --git a/docs/PRDs/Unreal/performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/Unreal/performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 8c8e1e98f..5bb5e36d7 100644 --- a/docs/PRDs/Unreal/performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/Unreal/performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -1,8 +1,9 @@ # PRD-564 — The resolution scale changes without reallocating a render target -**Status:** NOT STARTED -**Priority:** P2 — All boxes are open: each scale step resizes the drawing buffer and every pass target, so the controller keeps coarse rungs; Phase 1 has not yet measured that hitch. -**Complexity:** 6 (MEDIUM) — 6–10 files (2), temporal GPU state in the chain stages (+2), native host must draw the same sub-rectangle (+2); risk override: none +**Status:** PARTIAL +**Blocker:** Latest original baseline `scheduler-qualified-baseline-host24-isolated-v1-1664a981` remains UNQUALIFIED despite all 279 observations and successful collection. Selected CPUs 10/11/22/23 have zero continuity gaps and LOST reports, but three distinct duplicate native scheduler records on CPUs 2/12/16 reject all-CPU continuity; no deduplication was applied. Independent GPU diagnostics also find Brave process 3172818 executing inside all three measured contexts. Per-context clock bounds are valid. The original gate was not evaluated; AC-1 and Phase 1 remain open and the draft stays at 75%. +**Priority:** P2 — Phase 1 continuation is reopened after asymmetric probe overhead was identified; corrected baseline and candidate measurements are required. +**Complexity:** 9 (HIGH) — 11+ authored implementation files including per-kit stage declarations (3), retained-target lifecycle module (+2), temporal GPU state and graph replacement (+2), native build boundary (+2); risk override: none **Owner:** João (decline decision after Phase 1), agent (implementation) **Depends on:** [PRD-384](../../done/PRD-384-adaptive-resolution-gpu-headroom.md) (the GPU-headroom policy this keeps unchanged). Related: [PRD-549](../../performance/PRD-549-the-engine-holds-60-fps-on-a-weak-gpu.md) (it fixes the scaler's gates and adds an MSAA rung below the floor; this PRD changes what a scale step costs, and duplicates none of its boxes), [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (a reconstructor needs a scale that can change often and cheaply). @@ -88,8 +89,8 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Acceptance Criteria -- [ ] AC-1 [local]: On the starter in browser WebGPU (RTX 2080), a forced scale step allocates no texture and builds no pipeline, and the step frame stays within the window's p95. proof: `node packages/playtest/dist/runner/cli.js perf` with a forced-step scenario, `TN_FRAME_BUDGET` and the pipeline census. — Evidence: pending. -- [ ] AC-2 [local]: A frame at scale 0.72 through the sub-rectangle matches the same frame through today's reallocation path within the visual gate's threshold. proof: `pnpm visuals:ab`. — Evidence: pending. +- [ ] AC-1 [local]: On the starter in browser WebGPU (RTX 2080), a forced scale step allocates no texture and builds no pipeline, and the step frame stays within the window's p95. proof: `node packages/playtest/dist/runner/cli.js perf` with a forced-step scenario, `TN_FRAME_BUDGET` and the pipeline census. — Evidence: latest original baseline `artifacts/prd564/scheduler-qualified-baseline-host24-isolated-v1-1664a981` records all 279 frames with collection statuses 0 and zero selected-CPU gaps or LOST reports. Duplicate native records reject all-CPU continuity; independent GPU diagnostics find foreign Brave activity in every measured context. No original gate, candidate timing or withdrawal decision is earned; baseline decision and candidate timing remain pending. +- [x] AC-2 [local]: A frame at scale 0.72 through the sub-rectangle matches the same frame through today's reallocation path within the owner’s visual-preservation gate (independent NEUTRAL or IMPROVEMENT). proof: fresh read-only visual judgment of two scale-0.72 pairs and one scale-1 control, with live-tier/source/protocol receipts below. — All three pairs and overall NEUTRAL satisfy the current owner rule. Pixel metrics are nonzero; shipped `pnpm visuals:ab` scoring is INDETERMINATE and measures quality MDE, not a starter pixel threshold. No pixel-identity or numeric-threshold pass is claimed. ## Integration Ledger @@ -101,24 +102,105 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** NOT STARTED +**Status:** REOPENED / UNQUALIFIED — `artifacts/prd564/scheduler-qualified-baseline-host24-isolated-v1-1664a981` records all 279 observations with the original control and RTX 2080, atomic renderer CPU reservation on 11/23 and workers 10/22, reversible KDE hiding and DPMS output control, and native-acknowledged perf recording on all 24 CPUs. Baseline, run, ACK, ownership collector, recorder status, decoder and GPU profile/stop/export are 0; recorder client exit 143 is retained. Selected CPUs 10/11/22/23 have zero continuity gaps and global LOST reports are zero, but three distinct duplicate native scheduler records on CPUs 2/12/16 fail all-CPU continuity. No records were deduplicated; `.afk/pr489-host24-isolated-v1-qualification.json` remains UNQUALIFIED. A separate GPU-only diagnostic uses all 279 frame bounds without granting CPU qualification: per-context clock intersections are valid, the host-clock envelope is 1.600 µs, and foreign Brave process 3172818 overlaps all three measured contexts with summed conservative overlaps of 13.460/34.068/36.257 ms. Alacritty also overlaps all three contexts; KWin, Plasma and an unmapped GPU interval overlap context 3. Resource restoration is complete. A proposed VT route was rejected on safety grounds before any live action. No original gate, candidate timing or withdrawal decision is earned; AC-1 and Phase 1 remain open and draft progress stays at 75%. **Files:** a forced-step playtest scenario under `packages/playtest`; no engine change -- [ ] Three forced steps (1.0 → 0.85 → 0.61 → 1.0) on the starter record the step frame, the next 30 frames, pipelines built and bytes reallocated, over 3 runs. proof: `node packages/playtest/dist/runner/cli.js perf` on the scenario, results under `## Decisions` with the continue or decline call. +- [ ] Three forced steps (1.0 → 0.85 → 0.61 → 1.0) on the starter record the step frame, the next 30 frames, pipelines built and bytes reallocated, over 3 runs. proof: `pnpm exec tsx packages/playtest/src/runner/measure-resolution-scale.ts artifacts/prd564/measurement-nvidia-render-core` — exit 0; 3 fresh contexts, 9 steps ×31 frames, zero new pipelines, descriptor bytes recorded; independently qualified timing and `resolutionScaleGate(runs, true)` return `continue`. Historical result retained for traceability; acceptance reopened pending corrected baseline. See Decisions for the per-run controls and narrow crossing. #### Phase 2: Draw into the sub-rectangle -**Status:** NOT STARTED -**Files:** `packages/core/src/renderer.ts`, `packages/core/src/render/chain.ts`, `packages/core/src/render/velocity.ts`, `packages/core/src/resolution-scaler.ts`; core specs -- [ ] A scale change sets the scene pass viewport and scissor and allocates nothing; a canvas resize still reallocates once. proof: red-green `pnpm exec vitest run packages/core/__tests__/renderer*.spec.ts` with an allocation-counting renderer stub. -- [ ] Each built-in chain stage samples inside the sub-rectangle, and a stage without support reports `refused` and keeps the reallocation path. proof: red-green chain spec, one case per stage in `RENDER_CHAIN_STAGE_ORDER`. +**Status:** COMPLETE for lifecycle, actual supported-stage shader sampling and named refusal/resize criteria below. The owner’s appearance-preservation gate passes under AC-2; pixel identity is not claimed. +**Files:** `packages/core/src/renderer.ts`, `packages/core/src/render/chain.ts`, `packages/core/src/render/resolution-subrect.ts` (new); canonical `packages/create-threenative/template-assets/worldEnvironment.ts` and its stamped kit copies, per-kit `src/render/grade.ts`; core/scaffolder specs. + +Implementation boundary: core retains target capacity, sets viewport/scissor, publishes numeric view fractions and restores wrapped instance methods on graph disposal. Generated render source owns each texture UV transform and half-texel clamp; no Three prototype is patched. Stock GTAO, denoise, bloom and spatial AA receive explicit adapters; pointwise grade/grain declare compatibility. Other stages, custom base colour and automatic exposure refuse the sub-rectangle until their sampling/history is supported, keeping the original resize path and naming the reason in `TN_RESOLUTION_SUBRECT`. One active graph owns the controller; replacement/disposal cannot let an old receipt change the new graph. The original source path remains the scale-0.72 visual control. Canvas growth, canvas shrink, stage refusal, scale changes during asynchronous compile and same-node graph replacement are the distinct lifecycle risks to verify. +- [x] A scale change sets the scene pass viewport and scissor and allocates nothing; a canvas resize still reallocates once. proof: `pnpm exec vitest run packages/core/__tests__/renderer-resolution-subrect.spec.ts` with an allocation-counting renderer stub. — 20 tests pass (exit 0), including `updates scene viewport/scissor without allocations and reallocates once on renderer canvas growth`: real installed PassNode retained through `createRenderer`/`setOutputNode`; scales 0.72 → 0.61 → 1 update viewport/scissor with zero target disposal events or drawing-buffer resize calls; an observed canvas growth invokes the renderer resize path once and invalidates the target allocation once; canvas shrink retains capacity without another invalidation. This tests Three's CPU target allocation lifecycle, not actual GPU allocations; no historical red run is claimed. PR489 spatial-AA lifecycle repair (2026-10-14): canonical antialias now invokes and resets its existing release closure, matching AO/bloom; all 13 mirrors regenerated with `pnpm exec tsx scripts/stamp-template-render.ts`. `ResolutionSubrect.release` removes bindings/restores methods only; stock SMAA owns/disposes its three targets, while this stage owns/disposes its generated input RTT and material. Existing lifecycle spec repeats build/dispose/build three times for SMAA/FXAA with and without subrect support, checking exactly-once disposal, removed texture views and no retired-target resize. Red: `pnpm exec vitest run packages/create-threenative/__tests__/world-environment-lifetime.spec.ts -t "releases retired.*rebuilds"` fails both cases (dispose called 0, expected 1); green: lifecycle, subrect shader, shared-render, renderer-subrect and render-chain specs pass 119 tests across 5 files. Workspace `pnpm typecheck` and direct changed-source Biome pass (3 warnings, no errors). Scaffold initially passed 65/66 with stale byte hashes. Bounded fixture follow-up: verified the canonical source and 13 mirrors differ only by the four-line dispose closure; removing only that closure from each actual `createProject` tree recovers its exact prior fingerprint. Updated only the 13 expected hashes in `packages/create-threenative/__tests__/scaffold.spec.ts`; hash algorithm, exclusions and assertions unchanged. `pnpm exec vitest run packages/create-threenative/__tests__/{scaffold,world-environment-lifetime,world-environment-subrect-sampling,template-conventions}.spec.ts` passes 101/101, including all 66 scaffold tests (exit 0). `pnpm check:docs`, the six prescribed doc-contract specs (246 tests), fixture Biome and `git diff --check` pass. No new full-workspace typecheck/lint or full-suite claim. No rendered-pixel or GPU rerun claim; canonical capture-source SHA-256 changes from `d3142bbad1223fb9ccef8563facc74a4bac8255476311d1d27844dd4f54302ed` to `9d87d65b64c6d634bfaf17e3b096c20f5630d7fa80963473e86e9747ce207a63`. Phase 1, AC-1 and AC-2 stay open. +- [x] Each built-in chain stage samples inside the sub-rectangle, and a stage without support reports `refused` and keeps the reallocation path. proof: red-green chain spec, one case per stage in `RENDER_CHAIN_STAGE_ORDER`. — Current passing `render-chain.spec.ts` case `requires explicit sampling support for %s` covers every canonical stage name, both support declarations, named applied/refused reporting and omission of the controller on refusal. Its identity builders do not exercise actual stage sampling. `renderer-resolution-subrect.spec.ts` case `keeps the stock resize path until the graph declares sampling support` separately proves inactive-controller resizing. The integrated refusal/resize assertion now passes: `keeps the ordinary resize and allocation path when an installed stage refuses the sub-rectangle` installs a real RenderChain on createRenderer, keeps the named unsupported stage, reports its refusal, leaves the controller inactive, and exercises scaled drawing-buffer/stock PassNode allocation without scissoring. Both focused specs pass 83 tests (20 renderer, 63 chain); GPU allocation is not claimed. Actual canonical AO shader sampling now has three passing compiler tests in `world-environment-subrect-sampling.spec.ts`: real WGSL GTAO depth/normal reads and denoised occlusion reads carry the controller’s live retained-view clamp; the same uniforms follow 0.72 → 0.61 without target disposal and reset on deactivation. This is shader/lifecycle proof, not GPU or pixel proof. Actual bloom and spatial-AA shader proof now passes in the same spec (8 total cases): bloom high-pass, five blur materials, composite and output; SMAA edges/weights/blend/output with full-texture LUT reads; FXAA input clamps and viewport texel size. Every sampled retained texture uses its own live view/clamp pair. Bloom source rebinding updates the already-compiled shader’s clamp uniforms through real NodeFrame updates; GPU binding refresh is outside this compiler fixture. Mip viewport/half-texel sizing is checked. Package typecheck and changed-file Biome pass. Combined with the 83 renderer/chain cases above, this closes stage sampling/refusal proof; visual acceptance remains open. #### Phase 3: Finer steps and native -**Status:** NOT STARTED +**Status:** COMPLETE for the bounded criteria below, including current-source full Vitest and the selected browser/desktop native parity case after the oscillation repair. Phase 1 timing and AC-1 remain open; full hosted CI is unrun. AC-2 visual preservation is verified. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` -- [ ] The controller takes continuous fractions (quantized to 8 px, 2% threshold, at least 8 frames apart, amortized up-steps), and PRD-384's existing scaler suites still pass. proof: red-green `pnpm exec vitest run packages/core/__tests__/resolution-scaler*.spec.ts`. -- [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect`. +- [x] The controller takes continuous fractions (quantized to 8 px, 2% threshold, at least 8 frames apart, amortized up-steps), and PRD-384's existing scaler suites still pass. proof: red-green `pnpm exec vitest run packages/core/__tests__/resolution-scaler*.spec.ts`. — 86 scaler cases pass (Node 20, exit 0), including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle and fresh usable GPU timing. Six new timer-less regressions failed before the routing guard: missing/stale/undersampled GPU windows skipped the one-rung probe (undefined instead of 0.85), the floor probe and transition probe; a clean window climbed to 0.975 instead of 0.85. Green coverage retains probe/refund, failed-probe hold, floor reporting/refund, tail deferral, four-window one-rung climbing, and missing-GPU fallback after a continuous fraction. Final review repair `1664a981d`: timer-less oscillation starting at 0.85 and ending on an up-step reproduced an emitted 1 instead of the guard’s lower 0.85. Moving rung-scale synchronization after the guard fixes the root cause without rounding genuine continuous changes. The new regression failed red (1 versus 0.85); `pnpm exec vitest run packages/core/__tests__/resolution-scaler*.spec.ts` passes all 87 cases across eight files (exit 0). Changed-file Biome and whitespace checks pass with two existing complexity warnings. Current-source full Vitest and selected browser/desktop native parity pass as recorded below; no additional acceptance box is earned. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. — Current-source refresh after scaler repair `1664a981`: `pnpm native:build` exits 0; rebuilt host SHA-256 `ee1e6b73e8c4ae3c0d91921012c5930559e9e3dddfda34679c86a7ede24aa67e`. `pnpm parity --target web --only-tests resolution-subrect --out "$PWD/artifacts/prd564/native-parity-current-1664a981-web"` and `pnpm parity --target desktop --only-tests resolution-subrect --reference "$PWD/artifacts/prd564/native-parity-current-1664a981-web" --out "$PWD/artifacts/prd564/native-parity-current-1664a981-desktop"` each report the selected row PASS, 1 pass, 0 fail, 107 unselected blocked; both CLIs exit 2 because those rows remain unselected, not a full-board pass. Desktop exits 0 after 300 frames; both screenshots are nonuniform 1280×720, pixelMismatchRatio 0, perceptualDeltaE 0, GPU validation errors empty. Browser adapter NVIDIA/Turing; native adapter unreported. The scene exercises repaired scaler SHA-256 `22e465378cce164eea9dc1b2c9779b552dd77ceabd2dd4ee23e84aa3ffc0b116`, unchanged controller `fefa24c1a4a7e803f1166bfc4f8f9c4a744e81e392590578c7cf01951e25d9a8` and scene `57e092fa0d89309c387cdb7ea8d0a7ef9613439d1a11335d8f140052ae08a45e`; all three source hashes stayed unchanged during proof, and the canonical worldEnvironment source is unchanged by the scaler repair. Timing acceptance AC-1 remains open. Earlier proof follows. Refreshed after develop integration at `caeca6a43`: `pnpm native:build` exits 0 (no compilation needed; host SHA-256 unchanged, `3b47aa034a1d7d3a0d91a76ea76586c0bfa48c75e8891815ce384f48f7388f14`). Fresh `pnpm parity --target web --only-tests resolution-subrect --out "$PWD/artifacts/prd564/integrated-parity-web"` and desktop comparison with `--reference "$PWD/artifacts/prd564/integrated-parity-web" --out "$PWD/artifacts/prd564/integrated-parity-desktop"` both have selected status PASS, 1 pass, 0 fail, 104 unselected blocked. Bounded CLI exit 2 reflects those unselected rows. Desktop metrics: 1280×720, pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Native adapter identity remains unreported; this is conformance equivalence, not the required visual improvement verdict. Earlier proof follows. proof: `pnpm parity --target web --only-tests resolution-subrect --out "$PWD/artifacts/prd564/timerless-parity-web"`, then `THREENATIVE_RUNTIME_BINARY="$PWD/packages/runtime-native/build/tn-linux/mystral" pnpm parity --target desktop --only-tests resolution-subrect --reference "$PWD/artifacts/prd564/timerless-parity-web" --out "$PWD/artifacts/prd564/timerless-parity-desktop"`. — Both selected rows PASS on the repaired policy; reports `artifacts/prd564/timerless-parity-{web,desktop}/report.json`, each summary 1 pass, 0 fail, 103 blocked. Both CLI exits 2 reflect only unselected rows. Browser adapter NVIDIA/turing, nonuniform 1280×720; native completed true, process exit 0, 300 frames, nonuniform 1280×720, pixelMismatchRatio 0, perceptualDeltaE 0, GPU validation errors empty. Native adapter identity is not reported. Source hashes and native host identity are recorded in Decisions; dirty HEAD evidence qualifies exact source, not committed-candidate CI. Historical proof: Scene commit `caba3775a` imports the shipped controller and asserts retained capacity, viewport/scissor and sampling through 0.72 → 1 → 0.72, including the renderer’s `setScale` → `setSize` sequence. Both selected rows PASS; desktop pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Controller source SHA-256 `fefa24c1a4a7e803f1166bfc4f8f9c4a744e81e392590578c7cf01951e25d9a8` was identical before/after and is present in optimization commit `7d886e7bf`. Reports: `artifacts/prd564/controller-parity-once-{web,desktop}/report.json`; wrapper exits 0 after checking selected results and stable source. Individual bounded commands exit 2 because all 103 unselected rows remain blocked; no full-registry claim. + +## Blocked on + +- The owner-authorized bounded CPU 11/23 + RTX 2080 window completed. Latest baseline attribution remains unqualified; the bounded retry cap is reached and no further automatic timing capture is scheduled. +- Current product `1664a981d` passes full Vitest on clean synced source `21c5`: 780 files passed / 4 skipped, 10,669 tests passed / 49 skipped, exit 0 in 591.21 seconds. Focused scaler tests, full workspace typecheck/lint, core build and current native parity also pass. Hosted CI and timing qualification remain open; draft/75% is unchanged. AC-2 and screenshot publication are complete. ## Decisions +- 2026-10-14 (final bounded timing attempt; no acceptance earned): `scheduler-qualified-baseline-tail-v3-1664a981` repairs all 279 measured clock/control/ownership/affinity/nice and NVML brackets; scheduler continuity is complete within measurement. Global decoder exit 7 is retained honestly: 13,519 lost events at 22450.657 seconds precede frames by about 99 seconds. Foreign work on CPU 11/23 includes ananicy 10.341 ms, node 10.135/8.556 ms, nginx 7.318 ms and perl 6.872 ms. The sole raw 23.7/5.3 ms crossing (run 3 scale 0.61) has renderer wait upper bound 13.3921 ms versus crossing margin 13.1 ms; this cannot establish a resize hitch without subtracting wait, which remains forbidden. All nine steps have 26 allocations and zero pipelines. CPU restoration reports 4,627 restored, 447 inherited, no unknown tasks or failures. The bounded retry cap is reached: stop automatic captures, keep Phase 1/AC-1 open and deliver the PR as draft/75%, without a qualified continue, decline, 100% or merge claim. Product repair `1664a981d` has 87 focused tests/typecheck/core build green; Prior job `pr489-scaler-final1664` ended 75 at the thermal launch guard before tests ran; the later successful log read displayed that historical refusal, not a new retrieval refusal. Fresh `pr489-scalersuite-cool`, launched at 07:04:36 UTC on clean synced source `21c5` with product repair `1664a981` after a 67°C / 0.07-load preflight, completes full Vitest with exit 0: 780 files passed / 4 skipped, 10,669 tests passed / 49 skipped, 591.21 seconds (test start 00:04:37 local). This supersedes the old launch refusal and proves full unit-suite coverage of the repaired product source, not the full native/CI board or timing acceptance. Current product `1664a981` also passes full workspace `pnpm typecheck`, core build and `pnpm lint` (exit 0, 3,805 files checked, 1,482 warnings, zero errors, no fixes). Later commits change documentation only; product tests were not rerun for those prose edits. Phase 1/AC-1 remain open and progress remains 75%; full hosted CI is unrun. Earlier full-suite and current native evidence retain their recorded revisions. + +- 2026-10-14 (candidate and evidence refresh): `347b8bf28` integrates develop; `ae0405096` fixes AA disposal; `085595ed2` refreshes scaffolds. Integration of develop `1d056cec3` (PR486) recomputes all 13 combined-tree hashes. After Three patch application: 273 focused tests/four files, workspace typecheck/lint, 3,097 doc links pass; earlier document contracts pass 246 tests. Current `97c920f65` full laptop Vitest (`pr489-integrated-full3`) exits 0: 780 files passed/4 skipped, 10,668 tests passed/49 skipped, 778.46 seconds. Core build, DTS and publint pass. This supersedes the old-candidate Node20 module-loader worker crash; that failure is historical. Timing rejects v1 (missing owned-thread mapping), v2 (foreign native process), v3 (unresolved userspace ownership: node TID 678988 CPU 11, 36 frames/175.105 ms; rg TID 693395, 2 frames/65.622 ms). Fresh `scheduler-qualified-baseline-post-jobs-a9e691c56` is also UNQUALIFIED: all 279 clock/affinity/nice/scheduler observations complete, zero LOST, but unowned tsc TID 1462226 overlaps run 1/frame 19 on CPU 11 for guaranteed 0.996892 ms. The owner approved the reserved window and AFK execution. First reserved baseline `scheduler-qualified-baseline-reserved-c4907f917` remains UNQUALIFIED: all 279 clocks complete, zero LOST, but root dockerd TID 1354 executes inside forced-step windows on CPU 11 for guaranteed 2.212718 ms total, failing CPU isolation. Per-process reservations were restored: 4,615 live / 599 inherited, unknown `[]`, failures `[]`. A separate 64 MiB recorder diagnostic measured stdout streaming at 409 dockerd ticks / 2.259 seconds versus direct-file recording at 122 ticks / 0.797 seconds; this diagnoses observer overhead, not the timing gate. Fresh `scheduler-qualified-baseline-direct-file-c4907f917` is executing with a single bind-mounted output file and the same CPU masks, without host/root configuration changes or added capabilities. Raw ratios ≤2 and zero pipelines earn no qualified DECLINE; retain optimization and timing boxes open. Fresh NVIDIA BEFORE source `502ba75647b59067fe6aac1174faf2cad0b82c763d0bd988f313b30b1cd78099` versus AFTER `9d87d65b64c6d634bfaf17e3b096c20f5630d7fa80963473e86e9747ce207a63` matches live high tier, 240 ticks, static grain 0.0039 and run-1 live/run-2 frozen protocols; sources restored. Independent NEUTRAL on both scale-0.72 pairs and scale-1 control passes the owner gate, closing AC-2. PixelMismatchRatio/ΔE: `0.6473828125/0.497017760`, `0.6477582465/0.497420701`, control `0.135687934/0.089608370`; nonidentical, `visuals:ab` quality-MDE INDETERMINATE, no pixel-pass claim. [Published screenshots](https://github.com/ThreeNativeHQ/threenative-engine/pull/489#issuecomment-6183175841): orphan `prd-564-screenshots`, immutable `2549f6aba14a344da4d35dd6a4f015998f5d7954`. + +- 2026-10-14 (bounded local develop integration): merged `origin/develop` at `2a6be3ed1710de1351e6cd6c23ed4b410bbb0d8a` into PR head `a8ffb3cbd0dcd54e1a6c306d5d9f4a04ec647852`, from merge base `5f7143895d03b8528116ebed7f161c4dd974505d`. Two asset receipt conflicts resolve to develop’s equivalent new-path/mtime guard with the PR’s ordinary/backdated-file and pre-existing-overwrite regressions retained; no retained-target, scaler or baseline-measurement source changes. Focused renderer/chain/scaler/probe/sampling/receipt checks pass 204 tests; scaffold passes 66 after rebuilding stale assets output; the native package’s Vitest conformance-runner suite passes 83 tests (runner contracts, not GPU/native parity). Initial direct Node invocations used the wrong test runner; the package Vitest command passes. Assets build/publint, assets typecheck, changed-file Biome (13 existing warnings), 3,097 documentation links, six document/CI contract suites (246 tests), and diff whitespace checks pass. Current owner instructions permit NEUTRAL for appearance-preserving performance; this removes the rule-decision blocker, not AC-2 evidence review. Timing qualification, AC-1/AC-2 and fresh full candidate qualification remain open; no gates or acceptance thresholds changed. No push or hosted merge. + +- 2026-10-13 (fresh clock-marker baseline rejected as a whole): `artifacts/prd564/clock-marker-baseline/run-{1,2,3}.json` contains 279 frames and 279 matching executing PID/TID markers on NVIDIA/turing, with the original control SHA unchanged. All frames have scheduler brackets, affinity `{11,23}` and nice −10 throughout their conservative intervals. Maximum bracketed runqueue wait is 0.15025/1.30010/1.18973 ms; step-frame bounds are 0.01450 ms then eight zeros. Accounting for Date.now's millisecond quantization and [Chromium's documented 100 µs coarse clock bound](https://raw.githubusercontent.com/chromium/chromium/main/third_party/blink/renderer/core/timing/time_clamper.h), all 279 marker clocks are compatible with the observer's monotonic clock; the common offset interval is −0.01587 to +0.03564 ms. [Chromium emits user marks at their supplied start time](https://chromium.googlesource.com/chromium/src/%2B/c701061e45c73c8b3669811f8ae8087301ae34a3/third_party/blink/renderer/core/timing/performance_user_timing.cc). This accounts for clock resolution, not an added timing-gate tolerance. No foreign nonzero NVML sample timestamp falls inside a context span, but complete GPU interval qualification remains open. Competing-CPU upper bounds reach 29.231/47.826/21.538%; ownership of that work is unresolved, so the predeclared unknown-load rule rejects the entire run. Raw step/p50 ratios are 1.804878/1.428571/1.800000, 1.897436/1.500000/1.097561, and 1.285714/2.000000/1.163265; every step builds zero pipelines. Neither continue nor decline is earned; no frame, step or context was dropped, and no wait was subtracted. Two bounded reading-arm attempts timed out without a qualification result; these numbers came from bounded local diagnostics, not an independent accepted verdict. +- 2026-10-13 (repository checks and temporary-directory repair): full `pnpm typecheck` and `pnpm lint` pass, with lint warnings. Both native engines' unit/physics/package lane passes after building its missing test executables and correcting the QuickJS build to disable V8. Full `pnpm test` remains red: root Vitest reports 10,306 passed, three failed and three worker-start errors. Golden-path and the three worker-start suites subsequently pass focused reruns (33 tests). The temporary-directory guard exposed the probe test and measurement starter creator: use the existing registered test-temp helper for disposable fixtures and a retained `starter-project` artifact directory for measurements; creating an existing project fails instead of overwriting it. Guard/probe/entrypoint checks pass 45 tests, with playtest typecheck and scoped Biome passing. The scaffold-byte guard is an updateable expected-output snapshot, as confirmed by its comments and prior PRD-563/566/569 updates. After reviewing the canonical render change, thirteen mirrors and starter grade/grain declarations, all thirteen hashes were measured through createProject and refreshed without changing the hashing algorithm, exclusions or assertion; all 66 scaffold tests pass. The earlier decision to hold those snapshots for performance acceptance was too strict: byte expectations can follow a reviewed draft while timing acceptance stays open. The full repository board and hosted CI are not green. + +- 2026-10-13 (agent, exact-source timer-less parity completed): supported `pnpm native:build` completed with exit 0 before this bookkeeping lane. The two commands in the Phase 3 native box produced selected `resolution-subrect` PASS in both authoritative reports; CLI exits 2 are solely the 103 unselected blocked rows, not full-registry success. Native process exit 0, completed true, 300 frames; both frames nonuniform 1280×720, pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Only the browser reports NVIDIA/turing; no native GPU adapter claim. Reports record HEAD `4637c8510a19b26260f94da052700499fd74f7b7`, dirty true intentionally before commit. Before/after-proof stable SHA-256 values match current source: scaler `904c81f9dc3dff5b6639487ee906cf84efddf2f3e8597b5bfde382261af4d4e0`, retained controller `fefa24c1a4a7e803f1166bfc4f8f9c4a744e81e392590578c7cf01951e25d9a8`, scene `57e092fa0d89309c387cdb7ea8d0a7ef9613439d1a11335d8f140052ae08a45e`; C++ host `packages/runtime-native/build/tn-linux/mystral` SHA-256 `3b47aa034a1d7d3a0d91a76ea76586c0bfa48c75e8891815ce384f48f7388f14` matches desktop provenance. Runner-owned output `packages/runtime-native/artifacts/conformance/desktop-bundles/resolution-subrect.js` contains all four new timer-less policy failure strings. This supersedes the missing-host prerequisite below while preserving its history. Bookkeeping checks: `pnpm check:docs` passes (3,089 links), the six prescribed document/CI-contract suites pass (246 tests), and `git diff --check` passes. Only Phase 3 native proof/status closes; Phase 1 timing decision, Phase 2 actual stage sampling/refusal proof, visual acceptance and fresh full CI remain open. No time-win or committed-candidate qualification claim. + +- 2026-10-13 (bounded timer-less exact-source proof prerequisite): inspected the shipped parity parser and the owned `controller-parity-once-{web,desktop}/report.json` metadata. The prior desktop report names only the already-known missing `packages/runtime-native/build/tn-linux/mystral`, SHA-256 `be7040efd40d5f875a7349fb13caa66f280e5b6556c0445e3c4df81811f602f0`; the existing `.afk/2026-10-12-pr489.md` record supplies no retained alternate host. No configured `THREENATIVE_RUNTIME_BINARY`, `TN_RUNTIME` or `MYSTRAL_BIN` exists, and this checkout has no native `build/` or `.runtime/` cache. No other worktree or mutable foreign binary was inspected. Desktop prerequisite: restore that exact host with matching SHA-256 and set `THREENATIVE_RUNTIME_BINARY` to its absolute path; otherwise the shipped provisioning path is `pnpm native:build` (download-deps then native-build), outside this 600-second lane. The runner automatically provisions when no explicit host is configured, so desktop was deliberately not launched. Browser was also not launched in this prerequisite-only lane; both selected `resolution-subrect` rows remain **BLOCKED / unrun**, command exit codes and executed bundle identities unavailable, all unselected rows unproven. Supported commands for the next lane are `pnpm parity --target web --only-tests resolution-subrect --out "$PWD/artifacts/prd564/timerless-parity-web"`, then `THREENATIVE_RUNTIME_BINARY= pnpm parity --target desktop --only-tests resolution-subrect --reference "$PWD/artifacts/prd564/timerless-parity-web" --out "$PWD/artifacts/prd564/timerless-parity-desktop"`, with Node 20 and the existing capture lease. Current source SHA-256: controller `fefa24c1a4a7e803f1166bfc4f8f9c4a744e81e392590578c7cf01951e25d9a8`; scaler `904c81f9dc3dff5b6639487ee906cf84efddf2f3e8597b5bfde382261af4d4e0`; scene `57e092fa0d89309c387cdb7ea8d0a7ef9613439d1a11335d8f140052ae08a45e`. Before/after digests unchanged. No processes launched, artifacts created, source edits, builds/downloads, commits/pushes, or acceptance ticks; historical parity is retained, Stage 2 sampling and timing/visual acceptance stay open. + +- 2026-10-13 (PR489 timer-less regression repair): engine-owned automatic resolution policy, not a game workaround. The sole continuous-route caller dispatched active sub-rectangle windows without a usable GPU reading and bypassed the existing timer-less probe/refund; six real regression cases failed before the fix. Added only `gpuMs !== undefined` to that shared dispatch guard. `PATH=/home/joao/.nvm/versions/node/v20.19.6/bin:$PATH pnpm exec vitest run packages/core/__tests__/resolution-scaler` passes all 86 cases in 8 files. Direct `pnpm exec biome check --vcs-enabled=false` on the three changed TS/JS files passes (exit 0, two complexity warnings); `git diff --check` passes. After reopening native proof, `pnpm prd:progress` reports 0/3 complete phases, 2/5 phase boxes, 0/2 acceptance boxes, `prd:25%`. The existing `resolution-subrect.js` scene now checks timer-less one-rung probing to the platform floor, refund without pinning, and one-rung climbing after four clean windows; those exact policy statements pass when evaluated with the shipped scaler under Node (no GPU/native claim). No appearance or rendering parameters changed. A continuous fraction losing GPU evidence resumes the existing nearest-rung policy (0.708333 → 0.61 probe → 0.72 refund), not an invented fractional fallback. Forced-resolution measurements remain under normal automatic-policy ownership: usable timing retains the continuous policy, missing timing retains the old probe and bounds; no forced probe was run or changed. Modified policy SHA-256 `904c81f9dc3dff5b6639487ee906cf84efddf2f3e8597b5bfde382261af4d4e0`; unchanged sub-rectangle mechanism SHA-256 `fefa24c1a4a7e803f1166bfc4f8f9c4a744e81e392590578c7cf01951e25d9a8`. Coordinator must rerun under its capture lease with Node 20: `pnpm parity --target web --only-tests resolution-subrect --out "$PWD/artifacts/prd564/timerless-parity-web"`, then `pnpm parity --target desktop --only-tests resolution-subrect --reference "$PWD/artifacts/prd564/timerless-parity-web" --out "$PWD/artifacts/prd564/timerless-parity-desktop"`; verify selected rows and stable exact source hashes across both runs. Unselected registry rows remain blocked, so bounded runner exit 2 is not a selected-row failure or a full-registry pass. Phase 3 native status/box reopened; unit box current after actual suite success. Phase 1 timing baseline, visual acceptance, and Phase 2 stage sampling/refusal proof stay open. No builds, GPU/device jobs, commits, pushes, new files, dependencies or playtest edits. + +- 2026-10-13 (bounded repair result and PR489 follow-up): existing clock-repair edits are preserved: per-frame user-timing markers remain after the unchanged end clock, with same-tick performance/wall calibration; raw CDP PID/TID/timestamps use only `blink.user_timing`. The existing sampler records schedstat clock brackets, actual affinity, nice/policy and GPU-process ancestry. The standalone measurement entrypoint has no outer runner deadline; shipped `traceRun.ts` also awaits completion without a flush deadline and exposes no reusable timeout helper. A cleared watchdog now bounds only `Tracing.end` plus `Tracing.tracingComplete` using the existing 20-second bridge wait budget, then detaches and follows existing resource cleanup. Red: missing-completion and stalled-stop tests each timed out at 2 seconds; green: both reject with `TN_RESOLUTION_TRACE_FLUSH_TIMEOUT`, while completed tracing reaches the original observed failure, releases resources and leaves no timer. No measured-frame timeout, marker placement, census, threshold or seam-restoration policy changed. Node 20 nearest checks: **37 probe + 7 entrypoint tests pass (44 total)**; playtest typecheck **passes**; direct `pnpm exec biome check --vcs-enabled=false` on all four changed TypeScript files **passes**, exit 0, 42 warnings, no errors. The preceding three-file direct check also passed (41 warnings, `/tmp/pr489-clock-biome-direct.log`); the earlier stdin `contents aren't fixed` refusal was not an actual direct lint failure. `git diff --check` **passes**. No runtime/game/render change, new file/helper, hardware job, dependency/build job, commit, push or checkbox tick. +- 2026-10-13 (NVIDIA observation contract): [NVIDIA Process Monitoring](https://docs.nvidia.com/deploy/nvidia-smi/index.html#process-monitoring) documents per-process SM/memory utilization averaged since the previous monitoring cycle, `-` for unsupported metrics/fetch errors (also for no process), a default one-second cycle configurable from 1–10 seconds, and rounded `HH:MM:SS` timestamps. Conservative whole-cycle enclosure is an inference from that contract: uninterrupted contiguous complete cycles, captured with full pre/post padding, can enclose an entire context window within guaranteed overall cycle bounds; rounded timestamps do not establish exact frame boundaries, and a two-second timestamp jump alone does not prove a lost cycle. Start the collector early enough to retain a complete preceding cycle and continue until a complete following cycle is retained; stop gracefully with SIGINT, drain stdout through EOF and wait for collector exit so the tail is not truncated. Unknown ownership, unsupported metrics, collector errors/truncation, unproven cycle continuity or any window outside guaranteed bounds remain **UNQUALIFIED**. Actual runtime marker/thread/kernel-clock correspondence is still unproven. No arbitrary tolerance, gate change or wait subtraction is permitted. The coordinator verified the owned nice-10 setting and prior unprivileged schedstat collection; no `sudo -n` observation or system-policy change is needed. The user's goal already authorizes normal diagnostic collection; the coordinator will collect only after review against predeclared conditions. Qualification requirements constrain evidence claims, not user authorization. Baseline and candidate must use the same probe, masks and nice settings. + +- 2026-10-13 (bounded measurement-validity repair; declared before any fresh run): corrected original baseline `artifacts/prd564/epoch-qualified-baseline-complete/run-{1,2,3}.json` completed 9 steps, zero pipelines; run 2 crossings include 11.12–11.41 ms and 3.91–4.26 ms runqueue wait and competing CPU up to 93.8%. All 279 frames bracket both owned threads with nice −10, but frame/kernel timebase, executing PID/TID, actual affinity masks and full GPU coverage are not proven (missing 1 Hz samples and final 0.09 s). Verdict **unqualified**; no run may be dropped or wait subtracted. Qualification is predeclared for the entire three-context run: all frame endpoints must map into observed kernel-clock bounds, marker-only user timing outside measured endpoints must identify the owned executing PID/TID per frame, actual masks must equal renderer {11,23}, sampler {0–5,12–17}, launcher {8–10,20–22}, and nice −10 must be observed on each executing thread. Every window needs bracketed scheduler observations, stable ownership and conservative GPU coverage through its end; unknown clocks/ownership/load, missing samples, foreign GPU work, or foreign CPU work on the renderer CPUs reject the entire run before interpreting timings. Baseline and candidate must use identical probes/priorities. No arbitrary tolerance, profiling categories, changed 2×/p95 gate, scheduling policy changes, or inferred quietness. GPU interval semantics must be established rather than assuming each 1 Hz sample covers its preceding second. No fresh run is authorized by this repair; all Phase 1/acceptance boxes remain open. + +- 2026-10-13 (bounded Phase 2 integration proof): the new renderer canvas-resize case closes only Phase 2's first unit criterion. Node 20 focused spec passes 19 tests; changed-test Biome and diff checks pass. Allocation counting observes the real retained PassNode target's disposal events through the renderer's installed resize callback, with a stubbed GPU renderer. Canvas shrink keeps the retained capacity, consistent with the existing lifecycle case. No runtime source, build, GPU job, commit or push changed; actual stage UV/clamp sampling, refused-stage integration, timing and visual acceptance remain open. Existing shipped behaviour needed no fix, so no red run was manufactured. +- 2026-10-13 (independent focused verification): coordinator logs confirm `pnpm exec vitest run packages/playtest/__tests__/resolution-scale-probe.spec.ts` passed 36 tests (`/tmp/pr489-probe-test.log`), `pnpm exec vitest run packages/core/__tests__/resolution-scaler packages/core/__tests__/renderer packages/core/__tests__/render-chain` passed 233 tests across 17 files (`/tmp/pr489-core-test.log`), and playtest build/typecheck passed (`/tmp/pr489-playtest-{build,typecheck}.log`). Existing cases inspected above leave both Phase 2 boxes open for the precise missing integration/sampling proof; rerunning those same cases cannot supply it, so no additional suite or hardware run was launched. Timing, visual acceptance and other phase/acceptance boxes are unchanged. +- 2026-10-10 (agent, measurement scope): existing `perf` and scenario actions do not supply forced resolution steps plus exact step-and-30-frame timings or texture allocation bytes. The candidate browser-only probe reuses `FrameBudget` and the pipeline census; `frameMs` is synchronous CPU work, `rafIntervalMs` is RAF callback spacing, and texture bytes are descriptor payloads excluding driver padding/metadata. No engine renderer or render stage changed. No valid measurement frames exist; all boxes remain open and neither continue nor decline is justified. +- 2026-10-10 (agent, bootstrap diagnosis): after dependency bootstrap, serialized `__name`, and Three subpath-alias failures, the doubtful assumption was that the custom Vite fixture matched the shipped starter. Read-only Opus 5.5 HIGH diagnosis rejected that equivalence: manual aliases and disabled dependency discovery also bypassed CommonJS prebundling, while raw asset copying bypassed the authored cook. The harness now loads the starter's authored Vite config/plugins/cook, links nine exact dependencies inside a real scaffold-local `node_modules`, keeps its `.vite` cache local, prebundles the late texture-utils import, and rejects a missing `resolutionScale: "auto"` pin source. This correction has not run in a browser; failed artifacts remain under `artifacts/prd564/`. +- 2026-10-10 (agent, correction checks): the two probe tests, playtest package typecheck, Biome formatting, and `git diff --check` passed. Fresh laptop status refused work at 98°C. The allowed `pnpm --filter create-threenative build` failed during declarations with `src/config.ts` TS7016 because `@threenative/core/dist/index.d.ts` is absent; stopped at this first unexpected failure without a build retry or browser launch. Build inventory found no additional tracked rewrites. The harness/probe/tests and PRD remain uncommitted for root review; dependency-link/cache isolation, missing-pin refusal, authored asset cook, and hardware measurement remain unverified at runtime. +- 2026-10-10 (agent, bounded prerequisite attempt): `pnpm --filter @threenative/core build` exited 1 after 13.90 seconds: JS and declarations were emitted, including `dist/index.d.ts`, but `bundle-engine-mcp.mjs` refused missing `packages/engine-mcp/dist/index.js` (`TN_ENGINE_MCP_BUNDLE`). The sequential scaffolder build was not run because the first command failed. Stopped without retry, broader build, or browser launch. Before/after snapshots found no tracked or untracked source changes and no tracked generated rewrites; logs and snapshots are in `artifacts/prd564/bootstrap-lsuzm78v/`. Exact next prerequisite is the `threenative-engine-mcp` package build; scaffold declarations remain absent. All measurement boxes stay open. +- 2026-10-10 (agent, inspected dependency closure): the authorized sequential builds all exited 0: `pnpm --filter threenative-engine-mcp build` (2.32 s), `pnpm --filter @threenative/core build` (16.35 s), and `pnpm --filter create-threenative build` (5.02 s), 24.13 s total including inventory. Required JS and declarations now exist for all three packages; normal core bundling and publint completed. Before/after hashes found zero tracked or untracked source changes and no tracked generated rewrites. Fresh logs and snapshots: `artifacts/prd564/closure-9dl6i10_/`. No browser measurement was authorized or run, so every phase/acceptance box remains open. +- 2026-10-10 (agent, single authorized hardware invocation): unchanged harness SHA-256 `325104f3c7afa76e5bffe5949db90caa9ade20c4c06cbd71a118a1728a4c4beb` ran once from 21:39:50.950 to 21:40:47.996 UTC, exited 1 after 57.05 s, and stopped without retry or source changes. The authored asset cook completed; after an unqualified async readiness check, `page.evaluate` failed at harness line 47 with `TypeError: Cannot read properties of undefined (reading 'renderer')` at expression `game.ctx.renderer`. No step rows, adapter observation, pipeline census, or descriptor-byte observations were captured. The doubtful assumption is that dynamically importing `/src/game.ts` yields the running game context used by the authored `/src/main.ts` → `./game.js` → React `App`/`GameCanvas` bootstrap; `IGame.ctx` is explicitly optional. These source anchors alone do not establish the failure cause. Pre-run desktop load was 9.05/13.39/12.83 with 25,559 MiB available and NVIDIA GPU utilization 30%, so timing conclusions would remain restricted under shared load. Command, before/after load metadata, source/bundle hashes, asset cook and browser logs are preserved in `artifacts/prd564/hardware-yego1v8z/`. The harness cleanup completed and no node/Chromium/Xvfb/xcompmgr process with this checkout as cwd remained. All gate boxes stay open; root review is required before another invocation. +- 2026-10-10 (agent, readiness/context correction): read-only Opus 5.5 HIGH diagnosis showed that the old async `waitForFunction` predicate could satisfy the poll with a truthy Promise rather than a ready verdict. The previous "ready observed" claim is withdrawn: the failed artifact cannot distinguish pending boot from rejected boot and does not prove a duplicate module. The harness now reuses `PlaywrightTransport.waitForBridge`, `describe`, and the installed `waitForStartupReady`/`waitFrames` pattern without clock freezing or a longer custom timeout; missing bridge/startup and GameCanvas boot rejection fail with named errors. After readiness it checks context existence, actual ready phase, mounted-canvas identity, stable context through probe completion, and main-frame navigation. Red: four new context cases failed before the guard; green: six probe/context cases plus eighteen existing startup-ready cases passed (24 total). Playtest package typecheck, Biome formatting, and diff check passed. No browser/build/commit/push ran; all boxes remain open. The next bounded component must qualify preflight observations against the actual census adapter schema and running probe identities; the unsupported `TN_ADAPTER` lookup, census adapter path, gate verdict calculation, and possible FrameBudget/TextureUtils identity or binding issues remain unchanged and unverified. +- 2026-10-10 (agent, preflight qualification component): `--preflight` selects one fresh authored starter context and stops after readiness/context checks and one observed running FrameBudget call; it performs no scale steps or allocation probe. The census adapter field `adapter.identity` was verified against the actual core interface and renderer adapter-facts source; the unsupported `TN_ADAPTER` prefix lookup is removed. Preflight requires WebGPU, scale 1, compiling false, a complete census, and a nonempty correctly encoded identity without a software-rasterizer name, reusing the installed software-adapter detector rather than requesting another adapter or inferring one from features. A temporary prototype wrapper must observe the running budget receiver with exact prototype identity and restores itself on success/failure. Texture bytes now use the running WebGPU backend textureUtils method bound to that instance; Three constructs that utility on its backend and the installed rgba8unorm case returns 4 bytes. The separate source-class import and its late prebundle override are removed. Red: nine added preflight cases failed before implementation; green: 15 probe/preflight cases and 18 existing startup-ready cases passed (33 total). Typecheck initially rejected an undeclared core type import; removing that coupling and keeping only the checked structural fields passed playtest typecheck. Biome and diff check passed. Prior manifest searches inspected every returned detail; no installed helper qualified the running budget receiver. No browser/build/commit/push ran, so hardware preflight remains unverified and the gate verdict computation remains a separate open component. Actual before-copies and the unified review patch are in `artifacts/prd564/preflight-before-kmpxavnc/`; all measurement boxes stay open. +- 2026-10-10 (agent, single hardware preflight attempt): the unchanged reviewed harness SHA-256 `9683ac17ca81156484fd6eff5273a662b8f2c9719f07a8a0b202517da0ef93d2` ran once with `--preflight` from 23:01:03.092 to 23:01:33.571 UTC and exited 1 after 30.48 s. Authored starter asset cook completed, but the installed startup pump failed in `steps.ts:670` with `page.evaluate: ReferenceError: __name is not defined`, called by harness `measure-resolution-scale.ts:67` through `waitForStartupReady` (`startupReady.ts:115`). The imported source helper's serialized callback is not qualified under this tsx execution path. No startup-ready verdict, running-context/canvas identity, FrameBudget receiver, bound texture-byte result, complete census, or actual adapter identity was captured; no scale steps or allocation probe ran. Stopped without retry, build, or source change. Desktop load was 6.66/9.40/12.38 before and 7.54/9.27/12.22 after; NVIDIA GPU utilization was 40% then 20% with unrelated GPU processes, so no timing claim is possible. Exact command, before/after resources, source/bundle hashes and traceback are in `artifacts/prd564/preflight-hardware-9xw5y6h7/`. Harness cleanup left no process in its invocation group or node/Chromium/Xvfb/xcompmgr process with this checkout as cwd. Preflight is unqualified, all boxes remain open, and the PRD stays at 0%. +- 2026-10-10 (agent, source-harness startup pump correction): read-only Claude Opus 5.5 HIGH diagnosis identified tsx keepNames rewriting the nested `next` function in installed source `waitFrames` (`steps.ts:675`) to a `__name` call that Playwright serializes into a browser without that Node helper. This is a harness execution/serialization failure; startup success remains unverified. The compiled CLI is unaffected and its public exports do not expose all required internal startup helpers. Only this source harness now uses an existing-probe helper passing literal JavaScript to `page.evaluate` for exactly one RAF; installed bridge, startup readiness, context guards, authored starter and timeouts are unchanged, with no production export or browser-global shim. The initial regression investigation loaded actual installed `waitFrames` through tsx and reproduced the original `__name` error before any frame; that observation is preserved in the red log. The enduring regression loads only the corrected helper through tsx, executes serialized callbacks in an isolated VM with only RAF supplied, and verifies exactly one frame completes without a shim; it does not require the shared helper to remain broken. Red: the focused case failed because the corrected helper was absent after reproducing the original error. Green: the focused case passed; the nearest suite passed 16 probe cases plus 18 existing startup-ready cases (34 total). After removing the lasting assertion that the shared helper remains broken, the corrected-helper-only focused case passed again (1 passed, 15 skipped), with Biome and diff check passing; the broader suite was not rerun for that test-only correction. Playtest typecheck, Biome formatting and diff check passed. Exact before-copies/unified delta are in `artifacts/prd564/startup-frame-before-up0qsjed/`; red/green/suite/typecheck logs are under `artifacts/prd564/startup-frame-*.log`. No browser/build/commit/push ran; hardware preflight and all measurement/gate boxes remain unverified/open at 0%. +- 2026-10-10 (agent, single corrected-pump hardware preflight): reviewed harness SHA-256 `06d8eb8d492093012e70678225a5d5f7c256dc9bdbceca253fbe0b8a6921abee` and probe `966bd5a008768c2c6d9b62fb811f1a5a56355a605e3fcfdb0aed177847dfbb0e` ran once with `--preflight` from 23:15:34.959 to 23:16:44.884 UTC, exited 1 after 69.93 s, and stopped without retry or code change. Authored cook and startup pumping advanced beyond the prior serialization failure. Control flow reached `assertResolutionScalePreflight` after startup-ready, running-context/mounted-canvas guards, the bound rgba8unorm 4-byte check and observed FrameBudget receiver qualification; these checks returned without throwing, but no completed preflight payload was persisted. The first failure was `TN_RESOLUTION_SURFACE_NOT_READY` in the served probe, called by harness line 77: scale was not exactly 1 or compiling was not false; the log does not distinguish those alternatives or record actual surface values. Adapter identity and complete census were not qualified, and no run JSON, scale steps, allocation probe, or gate verdict exist. Shared desktop load was 19.15/14.65/12.63 before and 26.72/18.23/14.04 after, available memory 26,839 then 26,928 MiB, GPU utilization 29% then 8%; no timing/cost claim is possible. Command, traceback, before/after resources and unchanged source/bundle hashes are in `artifacts/prd564/preflight-pump-rj9t8dyp/`; cleanup found no remaining invocation-group or checkout node/Chromium/Xvfb/xcompmgr process. All boxes remain open at 0%; root review is required before another invocation or correction. +- 2026-10-10 (agent, surface-contract/partial-failure correction): read-only Opus 5.5 HIGH diagnosis identified a harness-only mismatch: core emits `compiling: true` while active and omits the optional field while idle, so requiring exactly false rejected the actual idle producer shape. Preflight now accepts omitted/false compiling, rejects true as not ready, and rejects nonundefined nonboolean values including null/string as malformed. Scale 1, WebGPU, complete census and hardware identity requirements remain fail closed. Errors include observed scale/source/compiling presence/value or census incomplete reasons and adapter identity as relevant. The existing run JSON is now written with `status: failed` and collected startup/partial context, bound bytes, budget receiver, surface/census/adapter observations before rethrowing failure; uncollected stages are not reported as absent observations, and errors preserve browser name/message/stack. Partial records contain serializable observations rather than renderer/game objects. Red: actual idle-shape cases and new malformed/persistence cases produced 10 failures, 11 passes. Green: focused probe passed 21 cases; nearest probe plus existing startup-ready suite passed 39 total. The persistence seam verifies a partial record is stored before the identical error object is rethrown; browser failure persistence remains unverified. Playtest typecheck, Biome formatting and diff check passed. Existing startup/bridge/context guards, authored scene/config, nine dependency links and timeouts are unchanged; no engine exports, waits, browser globals, build or browser invocation were added. Before-copies/unified review delta: `artifacts/prd564/surface-before-0buf19__/`; logs: `artifacts/prd564/surface-{red,green,suite,typecheck}.log`. All boxes remain open at 0%; no commit/push ran. +- 2026-10-10 (agent, qualified functional hardware preflight): the reviewed harness `fac1354bf0f5259d5428bdc853174bea19a8dfe7bf848f833787f0e4111b991c` and probe `f535a0b771387e0ad3e41f9dd49c1683777df92498d34c5c72cec11377d50c29` ran once with `--preflight` from 23:28:38.960 to 23:29:15.216 UTC, exited 0 after 36.26 s, and persisted `run-1.json`. Actual startup was ready with compileSettled true; mounted canvas/context identity and stable context checks passed. Surface was pinned at resolutionScale 1, 1280x720, sampleCount 4, with compiling omitted. The running FrameBudget prototype receiver was observed and the bound renderer texture utility returned rgba8unorm 4 bytes. Complete WebGPU census reported adapter `webgpu:architecture=turing|vendor=nvidia`, 50 device creations, 50 unique pipelines, 0 failures/pending/dropped events and no incomplete reasons; these are cumulative preflight counts, not scale-step builds. No PAGE_ERROR was logged. Headed private Xvfb strategy was 1600x900x24. Source/bundle hashes were unchanged; cleanup found no remaining invocation-group or checkout node/Chromium/Xvfb/xcompmgr process. Exact command, logs, run JSON, before/after resources and provenance: `artifacts/prd564/preflight-surface-6cov_kq2/`. Shared load was 15.32/19.06/16.47 before and 13.73/18.24/16.29 after, GPU utilization 26% then 12%; this proves functional preflight only, not cost. No scale/allocation probe or full three-context gate ran (`rows: []`), failure persistence remains browser-unverified, and the gate verdict calculator remains a separate component. All boxes stay open at 0%; no code change/build/install/commit/push ran. +- 2026-10-10 (agent, strict gate calculator/policy): root resolved pipeline attribution strictly: only creation tied to the first rendered scale-change frame can earn continuation; following-30-frame builds remain raw context and cannot earn by themselves. The existing probe now snapshots the installed census at that first frame boundary, retaining the original before/window-after census, full frames/allocations and descriptor-byte totals. A typed pure calculator requires three ordered contexts, all three expected scales, step plus thirty contiguous finite CPU frames, complete window census, consistent counts and descriptor payload totals. It recomputes following-frame p50 from `frameMs` (never RAF spacing) and uses strict CPU step >2×p50 or a qualified first-frame creation delta. Incomplete/pending/failed first-frame census cannot establish zero or earn a pipeline verdict; later async completion is conservatively unqualified because this component does not join originating event identities. Decline requires qualified timing and all pipeline attributions qualified. Harness timing defaults explicitly unqualified pending independent host-load review, so shared/unknown timing cannot earn or decline; reliable step pipeline creation remains independent evidence. The final third-run JSON/console includes the aggregate gate, and raw rows remain preserved including calculator failure. Manifest request search returned 15 hits and every detail was inspected; installed census/FrameBudget were reused, with no installed PRD-specific verdict helper found. Red: 11 new gate cases failed before implementation. Focused green: 32 probe cases passed; after adding empty/missing/pending coverage and direct first-frame snapshot assertion, nearest suite passed 35 probe plus 18 startup-ready cases (53 total). Typecheck initially found strict indexed-access errors after introducing row types; checked-boundary/test fixture annotations corrected them and final playtest typecheck passed. Biome and diff check passed. Before-copies/actual delta: `artifacts/prd564/gate-before-md_kyuo6/`; gate red/green/suite/typecheck logs remain under `artifacts/prd564/`. No browser/build/engine change/commit/push ran; the calculator and first-frame census capture remain hardware-unverified, all boxes open at 0%. +- 2026-10-10 (agent, single full-measurement deadline): the reviewed harness `841994e45d26df2d09a3953f477da59cb8f176660dac07f17b513edf00b492a6` and probe `731f1cc525b38a4d099f8bf6d413180bb4d109a3109d90213b84435f7db542e7` ran once in full measurement mode from 23:35:58.831 to 23:39:59.760 UTC. The authorized 240 s deadline expired (wrapper exit 124, 240.93 s including termination); only its invocation process group was killed, with no retry, source change or timeout increase. Authored asset cook completed (ktx2 pass 91.944 s); the final log lines show Vite dependency scanning/bundling. No run JSON/log, qualified preflight, adapter observation, captured step, CPU ratio, first/window census delta, descriptor-byte row, or aggregate gate was persisted. Coverage is 0 of 3 completed contexts and 0 of 9 measured steps, not evidence for continue or decline. Hard deadline termination prevented the harness catch/finally from persisting a failed partial run JSON; only wrapper command/status, full invocation log, source/bundle provenance and resource metadata survived. The exact pending operation at termination is unknown. Shared desktop load increased from 24.21/17.83/16.09 to 57.63/36.25/23.72, available memory 20,708 to 19,864 MiB, NVIDIA GPU utilization 23% to 27%; timing remains unqualified and no cost claim is possible. Source/bundle hashes were unchanged; post-termination inventory found no remaining invocation-group or checkout node/Chromium/Xvfb/xcompmgr process. Artifacts and actual PRD delta: `artifacts/prd564/measurement-y67np6r_/`. All boxes remain open at 0%; root review is required before any further invocation or correction, and no build/install/engine change/commit/push ran. +- 2026-10-10 (agent, owned lint correction awaiting review): first local commit `7a091deb93d78f0398c71e0c678cc455f541f78c` succeeded with normal pre-commit hooks, but the single normal push failed its lint stage; remote branch and draft PR were not created. Read-only error extraction then found 23 owned errors (20 explicit-any, one implicit-any, two multiple-variable declarations) and 10 preexisting donor naming errors. This correction replaces owned any types with Playwright Browser/Page types, exact used-method/descriptor/census/row/observation contracts and inferred callback parameters; malformed-input tests use explicit Reflect calls/mutations rather than type escape casts, and declarations are split. No scene, startup, scale, frame count, timing qualification, census policy or runtime mechanism changed. Initial typecheck found undeclared Vite types and strict indexed-access errors; the authored server uses a minimal exact resolvedUrls/close structural contract and checked-boundary annotations now pass. Actual `biome check` on all three owned files (error-only/high diagnostic limit) checked 3 files with zero errors; nearest 35 probe plus 18 startup-ready cases passed (53), final playtest typecheck and diff check passed. Earlier Biome passes were formatting checks only. Exact hook scripts use `scripts/ci-fast.sh:39` → `pnpm lint` → root package.json `biome check .`, with no lint-specific environment/config override in that script; prior extraction used `pnpm exec biome check .` with reporting flags. The allowed same-real-command rerun `pnpm lint --diagnostic-level=error --max-diagnostics=1000 --colors=off` exited 1 with exactly 10 donor `useNamingConvention` errors, unchanged from base, and no owned errors. The retained truncated hook log reported 22 whereas full extraction reported 33 before correction; that historical count discrepancy is unresolved, not attributed to an invented policy difference. Donor source/config/suppressions are untouched. Before-copies/actual delta: `artifacts/prd564/owned-lint-before-djkx0fvo/`; exact owned/root lint, typecheck and suite logs: `artifacts/prd564/owned-lint-*.log`. Changes remain uncommitted for root review; no browser/build/push retry/PR ran and PRD stays 0% with all boxes open. +- 2026-10-10 (agent, donor notation attempt stopped): root authorized quoting only the ten existing object-literal keys to retain public/schema spelling without renaming properties, suppressing rules or changing configuration. An initial mechanical match assertion stopped before any file write because substring matching also matched wF; the unchanged-file check from that orchestration is preserved separately. The corrected bounded edit quotes exactly D/DL/T/F/H/D/W/U/F/Lf at the ten diagnostic sites; all references/string lists/imports/header remain unchanged. `node --check` passed, but the actual `pnpm lint --diagnostic-level=error --max-diagnostics=1000 --colors=off` candidate check exited 1: Biome 1.9.4 still reports all ten quoted-key `useNamingConvention` errors plus one formatter error (11 total). Stopped without further workaround, formatting rewrite, commit or push. Root subsequently authorized restoration; exact current-candidate and before-copy SHA guards passed, and only the ten notation edits were restored to donor SHA-256 `5d939df262cad364d139f7d7ec7fea8e3d205fe72dfb6770a00b270eaa9159b4`; failed logs/copies remain preserved. Before-copies/actual delta: `artifacts/prd564/donor-notation-before-bw_fmjt1/`; candidate parse/lint logs: `artifacts/prd564/donor-notation-{parse,lint}-candidate.log`. This notation attempt establishes no runtime/visual result, and PRD stays 0% with all boxes open. +- 2026-10-10 (agent, root-reviewed fixed-schema naming exception): installed Biome 1.9.4 baseline reproduced ten unchanged donor property naming errors; the exact-file candidate removed those errors. An isolated disk-fixture matrix passed: D/DL/T/F/H/W/U/Lf and ordinary camelCase exited 0; DD, Lff, ZZ and bad_name each retained one property naming error; the same eight allowed keys outside the exact donor path retained eight errors. The appended override preserves the original interface convention, camelCase property format, strictCase:false, requireAscii:false and every other existing selector/rule. Its implicit-anchor capture pattern exempts only these fixed public/math-schema spellings while keeping other property names checked; donor source and public contract remain unchanged. Config/fixture commands, results and proposed patch are retained in `artifacts/prd564/naming-conformance-0yzq_af8/`. The first exact-hook `pnpm lint` run exited 1 on formatting of the added include/formats arrays. After root review, installed Biome formatted biome.json only; its complete extra diff consists of collapsing those two added arrays, with no unrelated formatting. Final `pnpm lint` exited 0 in 9.93 s (3,749 files, zero errors, 1,430 warnings); exact-PRD `pnpm prd:progress` and `git diff --check` passed, reporting 0/3 phases, 0/5 phase boxes, 0/2 acceptance boxes and prd:0%. Actual diff and lint log: `artifacts/prd564/config-apply-jzo0kid1/`. This style-only repair earns no runtime, visual or measurement gate; implementation remains unearned and all phase boxes stay open at 0%. +- 2026-10-10 (agent, root-reviewed pending-startup observability): the existing run JSON/log now persist before long startup operations, including the first authored starter bootstrap before run 1 and fresh evidence/logs before each subsequent context. Pending records identify starter imports, target/scaffold/dependency linking, authored server creation/listen, display/browser/context/page creation, navigation, bridge wait/describe, startup readiness and browser evaluation. Browser evaluation remains one honest pending operation; no internal substage is inferred. Existing final success/failure payloads replace pending records, and original operation errors remain unchanged. A ten-line internal probe primitive is reused solely to await checkpoint persistence before invoking an operation; it adds no Node runtime imports or public package export. The actual-tsx disk regression failed before that primitive existed, then passed: a deferred operation sees its pending record before completion, final success/failure records replace it, and the identical error is preserved. Final nearest checks passed 36 probe plus 18 startup-ready cases (54 total), playtest typecheck, actual Biome check of all three owned files, and diff check. Initial checks found one mutable-display lint diagnostic and its assumed string type; a mutable cell using the actual display return type fixed both. Before-copies and actual delta: `artifacts/prd564/checkpoint-before-9mtrqzya/`; red/green/nearest/typecheck/lint logs: `artifacts/prd564/checkpoint-*.log`. No browser/build/install/commit/push ran during this implementation component; runtime checkpoint persistence was unverified at that point and was subsequently observed in the single diagnostic invocation below. Measurement defaults and gate policy are unchanged, and all boxes remain open at 0%. +- 2026-10-10 (agent, single checkpoint-instrumented diagnostic measurement): the reviewed current tsx harness ran once from 2026-10-11 00:12:39.428 to 00:13:59.882 UTC, exiting 1 after 80.45 s without reaching the unchanged 240 s deadline. Before an awaited startup operation, the existing run-1.json was observed with status pending, pendingOperation starter.create-server, and startup/partial collected:false. The invocation subsequently created its headed private Xvfb browser and failed at measure-resolution-scale.ts:86 with TN_PLAYTEST_BRIDGE_MISSING: starter did not install its bridge. Existing catch/finally persisted run-1.json with status failed, the original error/stack and both observation flags false, plus run-1.log. Final failure replaced the pending record as designed; the last pendingOperation immediately before failure was not independently sampled/preserved, so starter.create-server is only the last directly observed pending value, not a claim that server creation remained blocked. Browser console contains Failed to load resource: net::ERR_NETWORK_CHANGED followed by Vite connecting/connected; the missing bridge cause remains undiagnosed. Coverage remains 0/3 completed contexts, 0/9 steps: no startup-ready verdict, actual adapter identity, qualified preflight, CPU rows, first/window census, descriptor bytes or gate verdict was collected. Owned Chromium command metadata verifies the unchanged nine headed hardware-request flags but does not establish an actual NVIDIA adapter. Shared host load was 22.42/19.79/20.12 to 22.09/20.67/20.43, available memory 18,295 to 24,155 MiB, GPU utilization 29% to 25%; timing remains unqualified and no cost claim is possible. Source and compiled-bundle guards were unchanged throughout the run. Cleanup inventory contains no surviving owned process, including the recorded browser/server invocation/Xvfb/xcompmgr identities; port 5174 is not listening, although its ownership was not sampled before termination and the private display number was not recorded. Receipt/raw run JSON/log, source/bundle provenance, resources, owned process metadata, cleanup and this PRD delta are retained in `artifacts/prd564/measurement-checkpoint-546d2hwl/`. No retry, source fix, build/install, commit or push ran. All boxes stay open at 0%; root review is required before further work. +- 2026-10-12 (agent, bootstrap and NVIDIA preflight): `pnpm install --frozen-lockfile --reporter append-only` completed in 13.9 s in the fresh worktree. This checkout initially had no root `node_modules`. After installation, a direct starter reproduction reached readiness and exposed its bridge; the historical `TN_PLAYTEST_BRIDGE_MISSING` artifact's cause is not established by this successful run. With the repository's matching NVIDIA 615.71.09 environment sourced from `/home/joao/.cache/nvidia-615.71.09/env.sh`, `pnpm exec tsx packages/playtest/src/runner/measure-resolution-scale.ts artifacts/prd564/measurement-nvidia --preflight` exited 0 on the actual RTX 2080 (`webgpu:architecture=turing|vendor=nvidia`). The preflight census was complete with 50 device creations, 50 unique pipelines, zero failures/pending/dropped events, and `rgba8unorm=4`; evidence: `artifacts/prd564/measurement-nvidia/run-1.json`. No source fix was needed for the missing bridge. +- 2026-10-12 (agent, Phase 1 full measurement): `source /home/joao/.cache/nvidia-615.71.09/env.sh && pnpm exec tsx packages/playtest/src/runner/measure-resolution-scale.ts artifacts/prd564/measurement-nvidia-full` exited 0 across the required three fresh contexts. All 9/9 forced steps captured 31 frames (step plus 30), complete before/step/after census data, zero pipeline builds, and nonzero texture descriptor payloads on every step: scale `0.85` = `122734560` bytes, `0.61` = `63185499`, and `1.0` = `169876480` in each run. The observed step/p50 CPU milliseconds were run 1: `0.85=11.2/6.1`, `0.61=8.0/5.3`, `1.0=7.9/6.1`; run 2: `0.85=16.7/12.8`, `0.61=23.7/10.1`, `1.0=88.1/14.2`; run 3: `0.85=59.3/13.0`, `0.61=10.7/16.1`, `1.0=41.5/11.5`. Raw rows are in `artifacts/prd564/measurement-nvidia-full/run-{1,2,3}.json`; the aggregate gate is in `run-3.json`. The gate remains `unqualified` (`timingQualified=false`) because host load was `34.47/29.90/23.63` before and `32.01/31.52/25.19` after; raw ratios over 2x cannot earn continuation. Pipeline evidence earns no continuation (`0` builds) and cannot justify decline while timing is unqualified. Per the Phase 1 gate, Phase 2/3 are not started; Phase 1, AC-1, and AC-2 remain open pending a quiet qualified rerun or owner decision. - 2026-10-12 (Unreal source review): checked every Unreal claim, all correct. Fixed drifted repo and three line numbers. Added the `PassNode.setSize()` finding: the viewport and scissor reach the render target only through that call, so Phase 2 must call it on each scale change and prove it allocates nothing. The up-step default 0.9 and the 2% change threshold are the only Unreal numbers this PRD copies as constants. Unreal's own minimum fraction is 0.5 (`DynamicRenderScaling.h:35`), while this controller goes to 0.23, so the finer controller keeps PRD-384's floor rules. +- 2026-10-12 (agent, post-merge prerequisites): at `770f50862`, frozen-lockfile install, sequential MCP/core/scaffolder builds (including declarations and publint), the 36 resolution-probe tests, and browser machine-doctor prerequisites passed. The measurement script bypasses the shipped capture lock; a diagnostic invocation of `acquireRunnerCaptureLock` successfully acquired and released it. A subsequent 90-second per-core check found no quiet pair of physical cores, so no new browser measurement launched (setup exit 75). The final 10-second sample's least-busy physical core averaged 25% competing utilization; host load was `36.89/35.80/31.59`. Next measurement must use the existing capture lease, current builds, and independently qualified host timing; these setup checks do not close Phase 1. +- 2026-10-12 (agent, browser module load repair): the new no-`process` VM regression first failed with `ReferenceError: process is not defined` while evaluating transpiled `browser.ts`, then passed after guarding the host-display environment read with `typeof process !== "undefined"`. The focused browser-input, resolution-scale-probe, and render-chain-adapter suites passed: 3 files, 140 tests. Host-display flag coverage remains green. No phase or acceptance box is earned; this is a playtest integration repair only. +- 2026-10-12 (agent, startup resource cleanup): the mocked entrypoint first preserved the display failure but leaked the acquired Vite server (`close` called 0 times). After moving startup acquisition under the existing `try/finally` with optional handles and closing a created server when `listen()` rejects, the regression passed display, browser-launch, and listen failure paths while preserving each original error. No phase or acceptance box is earned; this is a playtest integration repair only. - 2026-10-09 (João, via the Unreal review request): filed as a new PRD, not as a box in PRD-384. PRD-384 is a narrow GPU-headroom repair with one open box; this is a new mechanism with its own measurement gate. + +- 2026-10-12 (agent, capture serialization repair): the existing measurement entrypoint now acquires the shipped runner capture lease before opening its display and releases it even when display cleanup fails. The startup regression first failed because no lease was acquired, then passed; the focused suites pass 39 tests, including acquisition refusal and cleanup failure. Three fresh RTX contexts with CPU affinity and host observations still earn no qualified timing decision: all nine steps build zero pipelines and retain the previously measured descriptor bytes. The controlled run has step/p50 milliseconds `6.9/4.0, 5.7/4.1, 5.7/3.9; 6.9/4.2, 9.2/4.5, 5.2/3.7; 7.0/4.1, 5.8/4.1, 5.5/4.0`; its 2.04× outlier coincides with competing CPU bursts. Raw rows and host observations: `artifacts/prd564/measurement-nvidia-controlled/`. An exploratory thread-CPU qualification path was rejected after independent review found observer subtraction was not a guaranteed lower bound; its code is not retained. No phase or acceptance box is earned. + +- 2026-10-12 (agent, Phase 1 continuation): the headed RTX 2080 run in `artifacts/prd564/measurement-nvidia-render-core/run-{1,2,3}.json` exited 0 with three fresh browser contexts and all 279 individual frame observations. The shipped capture lease was held; workers ran on CPUs 8–10/20–22, only owned renderer main threads on physical core 11/23, and the observer on CPUs 0–5/12–17. Matching NVIDIA userspace was sourced per run; GPU preflight was 1% utilization at 44°C and package outputs were stable. Main-thread-only CPU accounting on the reserved physical core reported 0–7.35% competing use across stable measurement-window samples. A fresh read-only reviewer judged these controls proportionate for the original wall-time gate, while noting that roughly 0.33-second samples cannot exclude brief preemption. Step/p50 milliseconds: run 1 `10.2/5.0, 8.2/4.5, 5.6/4.4`; run 2 `7.8/4.9, 6.4/4.3, 6.2/4.5`; run 3 `5.9/4.4, 6.2/4.5, 7.8/4.1`. Run 1 scale 0.85 crosses the strict >2× condition by 0.20 ms; the other eight steps do not. Every step builds zero pipelines and allocates descriptor payloads of 122734560, 63185499 and 169876480 bytes respectively. **Continue**, exactly as Phase 1 specifies; no reproducibility or sustained-performance claim is made. The immutable raw aggregate retains its default `timingQualified=false`; post-run independent qualification and invoking the existing `resolutionScaleGate` with `true` produced the qualified `continue` verdict without changing timings or thresholds. +- 2026-10-12 (agent, current checks): full workspace build, full typecheck, lint and the 39 focused probe/startup cases pass. `pnpm test` reaches native package tests but fails because this fresh checkout lacks the required native V8/QuickJS host and contract binaries; a native prerequisite build is underway. Native acceptance remains open. + +- 2026-10-13 (manager, implementation intake): commit `a0fcca68e` adds retained target lifetimes, generated sub-rectangle sampling, continuous scaling and the native conformance scene. Manager verification passed 96 focused renderer/scaler/chain/template tests and 10 native adapter/handle tests; the native tests pass with the existing matching NVIDIA userspace environment sourced. The first current-source starter preflight failed at `page.goto` after 30 seconds, before page console observations or hardware qualification; artifacts: `artifacts/prd564/subrect-preflight-a0/`. Runtime allocation/timing, the scale-0.72 visual comparison, native parity and full merged-candidate CI remain unverified. No additional phase or acceptance box is earned. + +- 2026-10-13 (manager, review correction): the selected manual-pass native/browser scene passed with zero pixel and perceptual difference, but `.afk/current-subrect-review-result.txt` identifies five implementation defects, including a conformance case that bypasses the shipped controller. The native phase box is reopened until that case exercises controller activation and down/up changes. An active AFK repair owner is modifying the current checkout; those edits are preserved. Develop merged cleanly at `f14626db5`; no rebuild or additional GPU invocation will overlap that owner’s capture session. + +- 2026-10-13 (AFK review repair): fresh review reproduced stale capacity on reactivation, scene fractions incorrectly applied to full-size intermediate targets, bloom default-UV bypass, missing continuous GPU-insensitivity probes, and native proof bypassing the controller. Core now restores actual capacity, exposes per-texture fractions and releases retired bindings. Generated render source retains GTAO, bloom, SMAA and composition RTTs; per-instance UV setup and cloned samplers apply the appropriate half-texel clamp. Bloom registers after stock shader setup and clamps small mips to one texel. Continuous probes restore their exact prior fraction and preserve the existing fixed-GPU-cost hold. A second review's request to lower resolution after returning to the same insensitive cost was rejected against a passing discrete/continuous policy control; its small-mip and retired-generation findings received failing regressions and fixes. Expanded focused checks pass 290 cases across 20 files; core build/publint and core/scaffolder typechecks pass. Navigation now uses the existing bounded startup wait budget and DOM readiness, with a red-green cleanup regression; the earlier forced-step run failed at cold navigation before collecting timings. No threshold changed. +- 2026-10-13 (AFK visual observation): two headed hardware RTX captures per arm at scales 1 and 0.72 confirm the subrect path is active and preserve the same pose. Fresh independent judge returned NEUTRAL on all four pairs: no visible crop, stale strips, AO/bloom misalignment or clarity regression, and no claimed improvement. Captures: `artifacts/prd564/visual/before-{1,2}` and `after-lazy-{1,2}`. This remains short of the owner's required IMPROVEMENT verdict; no visual acceptance or performance-win claim is earned. Later small-mip/disposal repairs still need their final runtime rerun. + +- 2026-10-13 (AFK navigation correction): the first navigation repair incorrectly reused the 20-second bridge budget; it timed out before DOM readiness and earned no runtime evidence. A CPU-only probe of the same scaffold returned root HTML in 83 ms, while a main-module request blocked for 37,125 ms during Vite dependency optimization. Navigation now uses the existing 180-second startup-readiness budget instead; the four navigation/resource-cleanup cases and playtest typecheck pass. This changes only cold startup waiting, not the forced-frame measurements, continuation rule or acceptance thresholds. + +- 2026-10-13 (agent, retained-target hardware and duplicate traversal): `subrect-measure-ready` completed three hardware NVIDIA contexts and nine forced steps with zero texture descriptor bytes and zero pipeline builds in every step/window. AC-1 remains open: run 1 scales 0.85 and 0.61 took 6.5/5.5 ms against following-30-frame nearest-rank p95 values 5.9/5.1 ms; the other seven steps were within p95. A CPU regression reproduced four stock node resize calls instead of two because renderer scale application calls `setScale` followed by unchanged full `setSize`. The controller now skips that redundant active full-size traversal and updates `viewSize` immediately on scale changes. Red-green renderer/chain checks pass 153 cases across nine files; core build/publint and core typecheck pass; Biome reports zero errors and one existing complexity warning. The same nine-step hardware rerun is queued behind another owner’s capture lease, with the supported lease-wait option only; no runtime timing threshold changed. Current-source native parity, final visual comparison and timing acceptance remain unverified for this optimization. + +- 2026-10-13 (manager, measurement validity): independent read-only review confirms asymmetric initial census timing. Hardware attribution measured census cost of 0.20 ms p50, 0.30 ms p95 and 0.40 ms max; these separate-run values cannot be subtracted from historical frames. Phase 1 is reopened. The original unretained baseline and retained candidate must run with the same corrected probe and unchanged gates; if the baseline does not earn continuation, follow the existing decline branch. Current-source native/browser parity remains verified independently of this timing decision. + +- 2026-10-13 (agent, measurement bias and visual acceptance): final two hardware AFTER capture runs succeed at scales 1/0.72 with the subrectangle active. A fresh read-only judge inspected all six blind samples and all eight labeled before/after images: all blind scores 3; four paired verdicts and overall NEUTRAL. `pnpm visuals:ab` exits 0, duplicate-derived MDE 0, both deltas 0 classified INDETERMINATE, no aggregate; this is not an IMPROVEMENT verdict or a claimed visual gate pass. The optimized nine-step run `subrect-measure-once-network` retains zero bytes/pipelines throughout but two following-window p95 crossings remain (5.2>5.1 and 5.9>5.4 ms). Shipped hardware trace separately measured census collection p50 0.20/p95 0.30 ms and real scale application p50 0.10/p95 0.10 ms. The probe charged initial census collection only inside the step frame. Fresh independent review confirms asymmetric instrumentation and requires reopening Phase 1; no historical time is subtracted. A red-green regression now keeps census collection after captured end clocks and conservatively carries terminal counts across windows; existing 93-frame/three-context/gate thresholds stay unchanged. Corrected baseline and candidate reruns are pending. The original-path control copies the exact canonical worldEnvironment source from commit `11b0a70cf`, records its SHA-256, and rejects an active subrectangle. Current grade differences from that commit are compatibility declarations only. The manager subsequently completed the fresh 7d886/caba current-source native run, recorded in the Phase 3 proof above; both selected rows pass with zero pixel/perceptual difference and a stable controller hash. +- 2026-10-13 (agent, corrected measurements and resource controls): the exact original render-source control completed three corrected-probe hardware contexts, with the same texture payloads as before and zero pipelines. One wall-time trigger remains, 14.8 ms versus following-window p50 6.0 ms (2.467×); independent review classifies it UNQUALIFIED because coarse residual CPU activity of 4–26.5% cannot bound scheduling delay in that frame. The corrected candidate retains zero bytes/pipelines across nine steps, with two raw p95 comparisons still failing (one below the practical clock precision); neither threshold nor numerical tolerance is changed. Debian offload refuses at 100°C, so no remote work is started. Next measurements add only frame clock epoch metadata, actual Linux per-thread runqueue-wait samples, per-process GPU observations and the same per-run priority for our owned Chromium main threads. Frame durations and gates remain unchanged; kernel counters qualify resource observations without subtracting time from measured frames. This is pending evidence, not an acceptance claim. + +- 2026-10-13 (merged-source full-test follow-up): laptop Vitest exited 1 with 10,286 passed, 67 failed, 49 skipped (14 failed files). Its rsync mirror lacked Git HEAD/index and temporary-project commands resolved stale Corepack metadata for pnpm 12.10.1. Mirror metadata now comes from fetched published commit `aa31eb2ee`; a lane-local wrapper uses installed project pnpm 10.25.0 without changing global defaults. Recovery there refused at 100°C before testing. Desktop rerun of all 14 failed files passes 347 tests, including the assets receipt failure (18 tests also passed separately). This resolves the focused failures, not a claim of a fresh green full run. The stage proof arm timed out after writing its partial work; coordinator review restored AO per-read uniform uniqueness and strengthened bloom rebinding to use the same compiled shader, then verified all 8 compiler cases. Timing qualification and visual acceptance remain open. + +- 2026-10-13 (bounded full-gate repair plan; Complexity: 1 → LOW, no risk override): the current candidate’s full Vitest runs both reached 10,357 passes but exited 1: eight workers hit a 60-second build ceiling (that case passes on desktop in 21 seconds); four workers instead reproduced the earlier laptop undeclared-output receipt failure. Stop full reruns until the latter has a deterministic regression. Layer: engine build-time assets validation, not game rendering. Consumer: `compileAssets` executes a pass that creates an undeclared file under its output root; the existing guard compares filesystem mtime with `Date.now()` plus 1 ms. Plan: extend the existing receipt integration case with a newly written file whose mtime is backdated, prove rejection fails before the fix, then inventory output paths before passes and reject newly appearing undeclared paths regardless of timestamp. Reuse `walkOutputFiles`; preserve current validation of pre-existing outputs and previous-receipt ownership. Files: `packages/assets/src/compile.ts`, `packages/assets/__tests__/bake-receipt.spec.ts`. Success: real `compileAssets` rejects both ordinary and backdated undeclared writes; existing static files, caches and receipts retain their contract. Checks: focused receipt spec, assets typecheck/build, changed-file Biome, then affected/full required gates. No public API, output bytes, render policy, gate ceiling or acceptance threshold changes are planned. Integration remains the existing public compiler entry point. Result: the new backdated-file case fails against the original guard (1 failed, 17 passed), and the corrected guard passes all 20 receipt cases. Review also verified that a pre-existing overwrite remains rejected; that control failed against an intermediate candidate which incorrectly skipped existing paths, then passed when the original mtime check was restored. Source remains limited to the existing compiler entry point and helper. Coordinator verification passes all 20 receipt tests, assets build/publint, full workspace typecheck, changed-file Biome and documentation checks. Fresh complete `pnpm exec vitest run --maxWorkers=4` on immutable source commit `eec2e3695` passes: 780 files, 10,360 tests, 49 skipped, exit 0 (530.96 seconds). The laptop rebuilt assets first and checked exact HEAD plus a clean tracked tree. This is full Vitest coverage with four workers, not the complete native/CI board or a pass under the default eight-worker load. All observed focused/full test failures now have a passing complete run on the corrected source. Current phase/acceptance boxes unchanged. + +- 2026-10-15 (administrator reservation result): explicit authorization enabled a bounded per-task root affinity guardian with verified backups and automatic restoration, without permanent host/service/cgroup changes or terminating other owners. The first attempt stopped during browser startup on unproven new-task inheritance; all 6,388 changed masks restored, and 5,928 surviving original tasks audited with zero differences. Fresh-reviewed fixes preserve independently restricted masks and reject PID1 adoption as inheritance evidence. The corrected baseline completed three contexts; 6,276 masks restored with no unresolved tasks/errors, and 5,945 surviving original tasks audited with zero differences. Guardian exit 125 reports an owner-exit identity race 4.071 seconds after the last measured bound; it is not a green guard verdict. Nsight GPU profile/stop/export exit 0. Correct chronological decoding records 115,896 events, one 16-bit sequence wrap and 57,947 matched context pairs; partial boundaries lie outside measured coverage. Conservative clock uncertainty yields 20/18/19 possible foreign or unknown overlaps by context, not actual GPU durations; an unmapped PID and post-stop tail remain unresolved, and tail records alone do not prove loss. CPU interference independently rejects the cohort. Administrator authentication was invalidated and its terminal closed. No qualified baseline decision, candidate acceptance, archive or merge is claimed; ordinary product/native/visual checks above remain current because executable source is unchanged. diff --git a/packages/assets/__tests__/bake-receipt.spec.ts b/packages/assets/__tests__/bake-receipt.spec.ts index fa889879a..906b9cf75 100644 --- a/packages/assets/__tests__/bake-receipt.spec.ts +++ b/packages/assets/__tests__/bake-receipt.spec.ts @@ -278,34 +278,69 @@ describe("bake receipt", () => { ); }); - it.each([false, true])( - "should throw when a pass writes a file under the output root it did not declare (preserved old timestamp: %s)", - async (preserveTimestamp) => { - const root = await makeTempDir("threenative-receipt-stray-"); - await mkdir(path.join(root, "assets")); - await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); - - await expect( - compileAssets({ - cwd: root, - transcoder: TRANSCODER, - passes: [ - { - name: "stray-writer", - apply: async (input: Buffer) => { - await mkdir(path.join(root, "public"), { recursive: true }); - const strayPath = path.join(root, "public", "stray.bin"); - await writeFile(strayPath, "undeclared"); - // A copied output can retain its source's timestamp despite being new to public/. - if (preserveTimestamp) await utimes(strayPath, new Date(0), new Date(0)); - return input; - }, + it.each([ + ["ordinary", false], + ["backdated", true], + ])("should throw when a pass writes an undeclared file (%s)", async (_label, backdate) => { + const root = await makeTempDir("threenative-receipt-stray-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); + + await expect( + compileAssets({ + cwd: root, + transcoder: TRANSCODER, + passes: [ + { + name: "stray-writer", + apply: async (input: Buffer) => { + await mkdir(path.join(root, "public"), { recursive: true }); + await writeFile(path.join(root, "public", "stray.bin"), "undeclared"); + if (backdate) { + await utimes( + path.join(root, "public", "stray.bin"), + new Date("2000-01-01T00:00:00Z"), + new Date("2000-01-01T00:00:00Z"), + ); + } + return input; + }, + }, + ], + }), + ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); + }); + + it("should throw when a pass overwrites a pre-existing undeclared file", async () => { + const root = await makeTempDir("threenative-receipt-stray-overwrite-"); + await mkdir(path.join(root, "assets")); + await mkdir(path.join(root, "public")); + await writeFile(path.join(root, "public", "stray.bin"), "old"); + await utimes( + path.join(root, "public", "stray.bin"), + new Date("2000-01-01T00:00:00Z"), + new Date("2000-01-01T00:00:00Z"), + ); + await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); + + await expect( + compileAssets({ + cwd: root, + transcoder: TRANSCODER, + passes: [ + { + name: "stray-writer", + apply: async (input: Buffer) => { + await writeFile(path.join(root, "public", "stray.bin"), "undeclared"); + const future = new Date(Date.now() + 1000); + await utimes(path.join(root, "public", "stray.bin"), future, future); + return input; }, - ], - }), - ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); - }, - ); + }, + ], + }), + ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); + }); it("should leave a static file the project authored alone", async () => { const root = await makeTempDir("threenative-receipt-static-"); diff --git a/packages/core/__tests__/render-chain.spec.ts b/packages/core/__tests__/render-chain.spec.ts index 47454ba02..7f2149a8f 100644 --- a/packages/core/__tests__/render-chain.spec.ts +++ b/packages/core/__tests__/render-chain.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { type IRenderChainStage, + RENDER_CHAIN_STAGE_ORDER, RenderChain, readRenderChainObservation, } from "../src/render/chain.js"; @@ -896,3 +897,40 @@ describe("RenderChain", () => { ).toThrow(/afterOutputTransform/u); }); }); + +describe("subrect stage admission", () => { + it.each(RENDER_CHAIN_STAGE_ORDER)("requires explicit sampling support for %s", (name) => { + for (const supported of [false, true]) { + const adapter = renderer("webgpu"); + const install = vi.fn(); + adapter.setOutputNode = install; + const handle = {} as import("../src/render/resolution-subrect.js").ResolutionSubrect; + const report = vi.fn(); + const chain = new RenderChain({ + renderer: adapter, + input: {}, + resolutionSubrect: handle, + report, + request: { stages: [name], tier: "high" }, + stages: [ + { + name, + requiresVelocity: false, + supportsResolutionSubrect: supported, + build: (input) => input, + }, + ], + }); + expect(install.mock.calls[0]?.[2]?.resolutionSubrect).toBe(supported ? handle : undefined); + expect( + report.mock.calls.some( + ([line]) => + line.includes("TN_RESOLUTION_SUBRECT") && + line.includes(supported ? "applied" : "refused") && + line.includes(name), + ), + ).toBe(true); + chain.dispose(); + } + }); +}); diff --git a/packages/core/__tests__/renderer-resolution-subrect.spec.ts b/packages/core/__tests__/renderer-resolution-subrect.spec.ts new file mode 100644 index 000000000..290d2d6d5 --- /dev/null +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -0,0 +1,425 @@ +import { DepthTexture, RenderTarget, Texture } from "three"; +import { PerspectiveCamera, Scene } from "three"; +import { pass } from "three/tsl"; +import { describe, expect, it, vi } from "vitest"; +import { readRenderChainObservation } from "../src/render/chain.js"; +import { ResolutionSubrect } from "../src/render/resolution-subrect.js"; +import { createRenderer } from "../src/renderer.js"; + +describe("retained render targets", () => { + it("changes the active rectangle without disposing the allocation, and grows once", () => { + const target = new RenderTarget(1, 1); + const disposed = vi.fn(); + target.addEventListener("dispose", disposed); + const node = { setSize: (width: number, height: number) => target.setSize(width, height) }; + const original = node.setSize; + const controller = new ResolutionSubrect(); + controller.retain(node, [target]); + controller.activate(1); + node.setSize(1280, 720); + expect([target.width, target.height]).toEqual([1280, 720]); + const allocations = disposed.mock.calls.length; + for (const scale of [0.85, 0.61, 1]) { + controller.setScale(scale); + expect(disposed).toHaveBeenCalledTimes(allocations); + expect(target.viewport.toArray()).toEqual([ + 0, + 0, + Math.ceil((1280 * scale) / 8) * 8, + Math.ceil((720 * scale) / 8) * 8, + ]); + expect(target.scissor.toArray()).toEqual(target.viewport.toArray()); + expect(target.scissorTest).toBe(true); + const view = controller.textureView(target.texture); + if (!view) throw new Error("Missing retained texture view"); + expect(view.viewFraction.x).toBe(target.viewport.z / target.width); + expect(view.halfTexel.x).toBe(0.5 / target.width); + } + node.setSize(640, 360); + expect([target.width, target.height]).toEqual([1280, 720]); + expect(disposed).toHaveBeenCalledTimes(allocations); + node.setSize(1401, 801); + expect([target.width, target.height]).toEqual([1408, 808]); + expect(disposed).toHaveBeenCalledTimes(allocations + 1); + controller.dispose(); + expect(node.setSize).toBe(original); + node.setSize(800, 600); + expect([target.width, target.height]).toEqual([800, 600]); + target.dispose(); + }); + + it("keeps the stock resize path until the graph declares sampling support", () => { + const target = new RenderTarget(1, 1); + const controller = new ResolutionSubrect(); + const node = { setSize: (width: number, height: number) => target.setSize(width, height) }; + controller.retain(node, [target]); + node.setSize(321, 181); + expect([target.width, target.height]).toEqual([321, 181]); + expect(controller.textureView(target.texture)?.viewFraction.toArray()).toEqual([1, 1]); + controller.dispose(); + target.dispose(); + }); + + it("reapplies the retained viewport after a real stock PassNode updates its target", () => { + const scenePass = pass(new Scene(), new PerspectiveCamera()); + const controller = new ResolutionSubrect(); + controller.retain(scenePass, [scenePass.renderTarget]); + controller.activate(0.72); + scenePass.setSize(1280, 720); + expect([scenePass.renderTarget.width, scenePass.renderTarget.height]).toEqual([1280, 720]); + expect(scenePass.renderTarget.viewport.toArray()).toEqual([0, 0, 928, 520]); + expect(scenePass.renderTarget.scissorTest).toBe(true); + controller.dispose(); + scenePass.dispose(); + }); + + it("rejects duplicate registration before changing methods", () => { + const target = new RenderTarget(); + const node = { setSize: (w: number, h: number) => target.setSize(w, h) }; + const original = target.setSize; + const controller = new ResolutionSubrect(); + expect(() => controller.retain(node, [target, target])).toThrow("TN_RESOLUTION_SUBRECT"); + expect(target.setSize).toBe(original); + controller.dispose(); + }); + + it("leaves stock resizing intact after invalid activation", () => { + const target = new RenderTarget(); + const node = { setSize: (w: number, h: number) => target.setSize(w, h) }; + const controller = new ResolutionSubrect(); + controller.retain(node, [target]); + expect(() => controller.activate(0)).toThrow(); + expect(controller.active).toBe(false); + node.setSize(321, 181); + expect(target.width).toBe(321); + controller.dispose(); + }); + + it("rejects an unreserved direct resize without destroying the target", () => { + const target = new RenderTarget(100, 80); + const disposed = vi.fn(); + target.addEventListener("dispose", disposed); + const controller = new ResolutionSubrect(); + controller.retain({ setSize: (w, h) => target.setSize(w, h) }, [target]); + controller.activate(1); + expect(() => target.setSize(20, 20)).toThrow("TN_RESOLUTION_SUBRECT"); + expect([target.width, target.height]).toEqual([100, 80]); + expect(disposed).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("refuses disposed mutations and forgets texture ownership", () => { + const target = new RenderTarget(); + const controller = new ResolutionSubrect(); + controller.retain({ setSize: (w, h) => target.setSize(w, h) }, [target]); + expect(controller.textureView(target.texture)).toBeDefined(); + controller.dispose(); + expect(() => controller.setScale(0.7)).toThrow("disposed"); + expect(() => controller.setSize(100, 80)).toThrow("disposed"); + expect(controller.textureView(target.texture)).toBeUndefined(); + }); + + it("tracks current colour and depth attachments", () => { + const target = new RenderTarget(); + target.depthTexture = new DepthTexture(1, 1); + const colour = target.texture; + const depth = target.depthTexture; + const controller = new ResolutionSubrect(); + controller.retain({ setSize: (w, h) => target.setSize(w, h) }, [target]); + expect(controller.textureView(colour)).toBeDefined(); + expect(controller.textureView(depth)).toBeDefined(); + target.texture = new Texture(); + target.depthTexture = new DepthTexture(1, 1); + expect(controller.textureView(colour)).toBeUndefined(); + expect(controller.textureView(depth)).toBeUndefined(); + expect(controller.textureView(target.texture)).toBeDefined(); + expect(controller.textureView(target.depthTexture)).toBeDefined(); + controller.dispose(); + }); + + it.each([0, -1, 1.01, Number.NaN, Number.POSITIVE_INFINITY])( + "refuses invalid scale %s", + (scale) => { + expect(() => new ResolutionSubrect().activate(scale)).toThrow("TN_RESOLUTION_SUBRECT"); + }, + ); +}); + +it("owns subrect installations while keeping the full drawing buffer", async () => { + const canvas = Object.assign(new EventTarget(), { + clientWidth: 320, + clientHeight: 180, + parentElement: null, + }) as unknown as HTMLCanvasElement; + const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); + const resize = vi.fn(); + const renderer = await createRenderer({ + canvas, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: () => undefined, + setSize: resize, + }), + }); + try { + const handle = renderer.createResolutionSubrect?.(); + if (!handle) throw new Error("Missing controller factory"); + const scenePass = pass(new Scene(), new PerspectiveCamera()); + handle.retain(scenePass, [scenePass.renderTarget]); + const node = {}; + const first = renderer.setOutputNode(node, scenePass, { resolutionSubrect: handle }); + renderer.setResolutionScale(0.72, "auto"); + expect(renderer.surface().drawingBufferWidth).toBe(320); + expect(scenePass.renderTarget.width).toBe(320); + expect(scenePass.renderTarget.viewport.z).toBe(232); + const calls = resize.mock.calls.length; + renderer.setResolutionScale(0.61, "auto"); + expect(resize).toHaveBeenCalledTimes(calls); + const second = renderer.setOutputNode(node, scenePass, { resolutionSubrect: handle }); + if (!first || !second) throw new Error("Missing output receipts"); + first.dispose(); + expect(handle.active).toBe(true); + expect(second.isCurrent()).toBe(true); + second.dispose(); + expect(handle.active).toBe(false); + expect(renderer.surface().drawingBufferWidth).toBe(195); + scenePass.dispose(); + } finally { + renderer.dispose(); + if (prior) Object.defineProperty(globalThis, "navigator", prior); + else Reflect.deleteProperty(globalThis, "navigator"); + } +}); + +it("updates scene viewport/scissor without allocations and reallocates once on renderer canvas growth", async () => { + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1280, + clientHeight: 720, + parentElement: null, + }) as unknown as HTMLCanvasElement; + const resize = vi.fn(); + const renderer = await createRenderer({ + canvas, + source: { + createCanvas: () => canvas, + hasWebGPU: () => true, + readSize: () => [canvas.clientWidth, canvas.clientHeight], + observeResize: (element, callback) => { + element.addEventListener("resize", callback); + return () => element.removeEventListener("resize", callback); + }, + }, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: () => undefined, + setSize: resize, + }), + }); + const scenePass = pass(new Scene(), new PerspectiveCamera()); + const target = scenePass.renderTarget; + const allocations = vi.fn(); + // Three invalidates the target allocation via dispose when its extent changes; no GPU is used. + target.addEventListener("dispose", allocations); + try { + const handle = renderer.createResolutionSubrect?.(); + if (!handle) throw new Error("Missing controller factory"); + handle.retain(scenePass, [target]); + renderer.setOutputNode({}, scenePass, { resolutionSubrect: handle }); + expect([target.width, target.height]).toEqual([1280, 720]); + expect(allocations).toHaveBeenCalledTimes(1); + allocations.mockClear(); + resize.mockClear(); + + for (const [scale, width, height] of [ + [0.72, 928, 520], + [0.61, 784, 440], + [1, 1280, 720], + ] as const) { + renderer.setResolutionScale(scale, "auto"); + expect(target.viewport.toArray()).toEqual([0, 0, width, height]); + expect(target.scissor.toArray()).toEqual([0, 0, width, height]); + expect(target.scissorTest).toBe(true); + expect([target.width, target.height]).toEqual([1280, 720]); + expect(allocations).not.toHaveBeenCalled(); + expect(resize).not.toHaveBeenCalled(); + } + + Object.assign(canvas, { clientWidth: 1401, clientHeight: 801 }); + canvas.dispatchEvent(new Event("resize")); + expect(resize).toHaveBeenCalledExactlyOnceWith(1401, 801, false); + expect(renderer.surface()).toMatchObject({ + drawingBufferWidth: 1401, + drawingBufferHeight: 801, + }); + expect([target.width, target.height]).toEqual([1408, 808]); + expect(target.viewport.toArray()).toEqual([0, 0, 1408, 808]); + expect(target.scissor.toArray()).toEqual([0, 0, 1408, 808]); + expect(target.scissorTest).toBe(true); + expect(allocations).toHaveBeenCalledTimes(1); + + Object.assign(canvas, { clientWidth: 640, clientHeight: 360 }); + canvas.dispatchEvent(new Event("resize")); + expect(resize).toHaveBeenLastCalledWith(640, 360, false); + expect(renderer.surface()).toMatchObject({ drawingBufferWidth: 640, drawingBufferHeight: 360 }); + expect([target.width, target.height]).toEqual([1408, 808]); + expect(target.viewport.toArray()).toEqual([0, 0, 640, 360]); + expect(target.scissor.toArray()).toEqual([0, 0, 640, 360]); + expect(target.scissorTest).toBe(true); + expect(allocations).toHaveBeenCalledTimes(1); + } finally { + renderer.dispose(); + scenePass.dispose(); + } +}); + +it("keeps the ordinary resize and allocation path when an installed stage refuses the sub-rectangle", async () => { + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1280, + clientHeight: 720, + parentElement: null, + }) as unknown as HTMLCanvasElement; + const resize = vi.fn(); + const renderer = await createRenderer({ + canvas, + source: { + createCanvas: () => canvas, + hasWebGPU: () => true, + readSize: () => [canvas.clientWidth, canvas.clientHeight], + observeResize: (element, callback) => { + element.addEventListener("resize", callback); + return () => element.removeEventListener("resize", callback); + }, + }, + webgpuFactory: () => ({ + domElement: canvas, + init: async () => undefined, + render: () => undefined, + setSize: resize, + }), + }); + const scenePass = pass(new Scene(), new PerspectiveCamera()); + const target = scenePass.renderTarget; + const allocations = vi.fn(); + target.addEventListener("dispose", allocations); + try { + const handle = renderer.createResolutionSubrect?.(); + if (!handle) throw new Error("Missing controller factory"); + handle.retain(scenePass, [target]); + const report: string[] = []; + const createChain = renderer.createRenderChain; + if (!createChain) throw new Error("Missing chain factory"); + // The stage declares no sampling support, so the chain composes its graph and refuses the + // rectangle instead of dropping the stage or claiming it. + const chain = createChain({ + input: {}, + report: (line) => report.push(line), + request: { stages: ["bloom"], tier: "high" }, + resolutionSubrect: handle, + stages: [{ build: (input) => input, name: "bloom", supportsResolutionSubrect: false }], + worldPass: scenePass, + }); + + const refusal = report.find((line) => line.includes("TN_RESOLUTION_SUBRECT")); + if (!refusal) throw new Error("Missing named refusal"); + expect(JSON.parse(refusal.slice(refusal.indexOf("{")))).toEqual({ + applied: [], + refused: [{ name: "bloom", reason: "sampling support not declared" }], + }); + // A refusal that dropped the stage instead of installing it would land here as install:*. + expect(chain.applied.stages).toEqual(["bloom"]); + expect(chain.applied.dropped).toEqual([]); + expect(readRenderChainObservation(renderer)?.stages).toEqual(["bloom"]); + // The refused graph reached the real renderer without the controller: setOutputNode activates + // the handle it is given, so an inactive handle is the observable absence. + expect(handle.active).toBe(false); + expect(renderer.surface().resolutionSubrect).toBeUndefined(); + + // Ordinary scaling survives: the drawing buffer follows the requested scale instead of + // holding the full canvas for a rectangle nothing samples. + renderer.setResolutionScale(0.5, "auto"); + Object.assign(canvas, { clientWidth: 1000, clientHeight: 500 }); + canvas.dispatchEvent(new Event("resize")); + expect(resize).toHaveBeenLastCalledWith(500, 250, false); + expect(renderer.surface()).toMatchObject({ + drawingBufferWidth: 500, + drawingBufferHeight: 250, + resolutionScale: 0.5, + }); + + // Ordinary target allocation survives: the stock pass resize reallocates at exactly the asked + // extent, with the full-target viewport the retained rectangle would have shrunk. + allocations.mockClear(); + scenePass.setSize(500, 250); + expect([target.width, target.height]).toEqual([500, 250]); + expect(target.viewport.toArray()).toEqual([0, 0, 500, 250]); + expect(target.scissorTest).toBe(false); + expect(allocations).toHaveBeenCalledTimes(1); + expect(handle.textureView(target.texture)?.viewFraction.toArray()).toEqual([1, 1]); + chain.dispose(); + } finally { + renderer.dispose(); + scenePass.dispose(); + } +}); + +it("restores actual capacity after deactivation and reactivation", () => { + const target = new RenderTarget(); + const controller = new ResolutionSubrect(); + controller.retain({ setSize: (w, h) => target.setSize(w, h) }, [target]); + controller.activate(0.5); + controller.setSize(1920, 1080); + controller.deactivate(); + expect([target.width, target.height]).toEqual([960, 540]); + controller.activate(1); + expect([target.width, target.height]).toEqual([1920, 1080]); + expect(target.viewport.toArray()).toEqual([0, 0, 1920, 1080]); + controller.dispose(); +}); + +it("sizes targets registered after lazy shader setup at the current scale", () => { + const controller = new ResolutionSubrect(); + controller.activate(0.72); + controller.setSize(1280, 720); + const target = new RenderTarget(); + controller.retain({ setSize: (w, h) => target.setSize(w, h) }, [target]); + expect([target.width, target.height]).toEqual([1280, 720]); + expect(target.viewport.toArray()).toEqual([0, 0, 928, 520]); + controller.dispose(); +}); + +it("releases a retired stage before subsequent scale changes", () => { + const controller = new ResolutionSubrect(); + const target = new RenderTarget(); + const node = { setSize: (w: number, h: number) => target.setSize(w, h) }; + const original = node.setSize; + controller.retain(node, [target]); + controller.activate(1); + controller.setSize(1280, 720); + controller.release(node); + expect(node.setSize).toBe(original); + expect(controller.textureView(target.texture)).toBeUndefined(); + const resize = vi.spyOn(target, "setSize"); + controller.setScale(0.72); + expect(resize).not.toHaveBeenCalled(); + controller.dispose(); +}); + +it("does not resize retained nodes twice when only scale changes", () => { + const target = new RenderTarget(); + const resize = vi.fn((w: number, h: number) => target.setSize(w, h)); + const controller = new ResolutionSubrect(); + controller.retain({ setSize: resize }, [target]); + controller.activate(1); + controller.setSize(1280, 720); + resize.mockClear(); + controller.setScale(0.72); + const calls = resize.mock.calls.length; + controller.setSize(1280, 720); + expect(resize).toHaveBeenCalledTimes(calls); + expect(controller.viewSize.toArray()).toEqual([928, 520]); + expect(target.viewport.toArray()).toEqual([0, 0, 928, 520]); + controller.dispose(); +}); diff --git a/packages/core/__tests__/resolution-scaler-continuous.spec.ts b/packages/core/__tests__/resolution-scaler-continuous.spec.ts new file mode 100644 index 000000000..2220f269b --- /dev/null +++ b/packages/core/__tests__/resolution-scaler-continuous.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { ResolutionScaler } from "../src/resolution-scaler.js"; + +const surface = { + drawingBufferHeight: 1080, + drawingBufferWidth: 1920, + resolutionScale: 1, + resolutionSubrect: true, +}; + +const windowOf = (gpuMs: number, frames = 1) => ({ + fps: gpuMs <= 17 ? 60 : 45, + frames, + gpuAgeFrames: 0, + gpuMs, + presented: { max: 20, p50: gpuMs <= 17 ? 16 : 20, p95: 20, p99: 20 }, + surface, +}); + +describe("ResolutionScaler continuous sub-rectangle mode", () => { + it.each([{}, { gpuMs: 34, gpuAgeFrames: 17 }, { gpu: { p50: 34, samples: 3 } }])( + "retains the timer-less probe and refund with unusable GPU timing %j", + (gpu) => { + const scaler = new ResolutionScaler({ start: 1, targetFps: 60 }); + const slow = { + fps: 20, + presented: { max: 60, p50: 50, p95: 55, p99: 58 }, + surface, + ...gpu, + }; + expect(scaler.observe(slow)).toBeUndefined(); // warmup + expect(scaler.observe(slow)).toBe(0.85); + expect(scaler.observe(slow)).toBeUndefined(); // resize cooldown + expect(scaler.observe(slow)).toBe(1); + for (let index = 0; index < 20; index++) expect(scaler.observe(slow)).toBeUndefined(); + expect(scaler.scaleSource).toBe("auto"); + expect(scaler.atFloor).toBe(false); + }, + ); + + it("retains the timer-less floor and refunds a failed probe at that floor", () => { + const scaler = new ResolutionScaler({ start: 0.72, minScale: 0.61, targetFps: 60 }); + const slow = { + fps: 20, + presented: { max: 60, p50: 50, p95: 55, p99: 58 }, + surface, + }; + scaler.observe(slow); + expect(scaler.observe(slow)).toBe(0.61); + scaler.observe(slow); + expect(scaler.observe({ ...slow, fps: 25 })).toBeUndefined(); + expect(scaler.atFloor).toBe(true); + + const refund = new ResolutionScaler({ start: 0.72, minScale: 0.61, targetFps: 60 }); + refund.observe(slow); + expect(refund.observe(slow)).toBe(0.61); + refund.observe(slow); + expect(refund.observe(slow)).toBe(0.72); + expect(refund.atFloor).toBe(false); + }); + + it("retains timer-less tail deferral and climbs only one rung after four clean windows", () => { + const scaler = new ResolutionScaler({ start: 0.72, targetFps: 60 }); + const clean = { + fps: 60, + presented: { max: 17, p50: 16.67, p95: 17, p99: 17 }, + surface, + }; + scaler.observe(clean); + for (let index = 0; index < 4; index++) + expect( + scaler.observe({ ...clean, presented: { ...clean.presented, p95: 20 } }), + ).toBeUndefined(); + for (let index = 0; index < 3; index++) expect(scaler.observe(clean)).toBeUndefined(); + expect(scaler.observe(clean)).toBe(0.85); + }); + + it("uses the legacy timer-less route after a valid continuous fraction loses GPU timing", () => { + const scaler = new ResolutionScaler({ start: 1, targetFps: 60 }); + scaler.observe(windowOf(34, 8)); + expect(scaler.observe(windowOf(34, 8))).toBeCloseTo(1360 / 1920, 6); + const slow = { + fps: 20, + presented: { max: 60, p50: 50, p95: 55, p99: 58 }, + surface, + }; + expect(scaler.observe(slow)).toBeUndefined(); // resize cooldown + expect(scaler.observe(slow)).toBe(0.61); + expect(scaler.observe(slow)).toBeUndefined(); // resize cooldown + expect(scaler.observe(slow)).toBe(0.72); + }); + + it("uses the quadratic estimate, eight-pixel quantization, and the eight-frame gap", () => { + const scaler = new ResolutionScaler({ minScale: 0.23, start: 1, targetFps: 60 }); + expect(scaler.observe(windowOf(34))).toBeUndefined(); + const first = scaler.observe(windowOf(34, 7)); + expect(first).toBeCloseTo(1360 / 1920, 6); + expect(first && ((first * 1920) / 8) % 1).toBe(0); + expect(scaler.observe(windowOf(34))).toBeUndefined(); + }); + + it("quantizes against retained capacity after the scale has already changed", () => { + const scaler = new ResolutionScaler({ minScale: 0.23, start: 0.72, targetFps: 60 }); + const activeSurface = { ...surface, resolutionScale: 0.72 }; + expect(scaler.observe({ ...windowOf(34), surface: activeSurface })).toBeUndefined(); + const next = scaler.observe({ ...windowOf(34, 7), surface: activeSurface }); + expect(next).toBeCloseTo(552 / 1080, 6); + }); + + it("ignores estimates under two percent and amortizes an up-step", () => { + const holds = new ResolutionScaler({ start: 1, targetFps: 60 }); + expect(holds.observe(windowOf(16.8))).toBeUndefined(); + expect(holds.observe(windowOf(16.8))).toBeUndefined(); + + const climbs = new ResolutionScaler({ start: 0.72, targetFps: 60 }); + expect(climbs.observe(windowOf(10))).toBeUndefined(); + for (let index = 0; index < 3; index += 1) + expect(climbs.observe(windowOf(10, 1))).toBeUndefined(); + const up = climbs.observe(windowOf(10, 1)); + expect(up).toBeGreaterThan(0.72); + expect(up).toBeLessThan(1); + }); +}); + +it("refunds a continuous step when fresh GPU cost does not answer to pixels", () => { + const scaler = new ResolutionScaler({ start: 1, minScale: 0.23, targetFps: 60 }); + for (let index = 0; index < 40; index++) + scaler.observe({ ...windowOf(34, 8), surface: { ...surface, resolutionScale: scaler.scale } }); + expect(scaler.scale).toBe(1); +}); + +it("refunds to the actual continuous fraction rather than its nearest rung", () => { + const scaler = new ResolutionScaler({ start: 1, minScale: 0.23, targetFps: 60 }); + scaler.observe(windowOf(34, 8)); + const first = scaler.observe(windowOf(34, 8)); + if (first === undefined) throw new Error("Missing initial step"); + scaler.observe(windowOf(24, 8)); + scaler.observe(windowOf(24, 8)); + const second = scaler.scale; + expect(second).toBeLessThan(first); + scaler.observe(windowOf(24, 8)); + expect(scaler.observe(windowOf(24, 8))).toBe(first); +}); + +it.each([false, true])( + "preserves fixed-GPU-cost memory across recovery in continuous=%s", + (continuous) => { + const scaler = new ResolutionScaler({ start: 0.72, minScale: 0.23, targetFps: 60 }); + const feed = (gpuMs: number, count: number) => { + for (let index = 0; index < count; index++) + scaler.observe({ + frames: 8, + fps: Math.min(60, 1000 / gpuMs), + gpuAgeFrames: 0, + gpuMs, + presented: { max: gpuMs, p50: gpuMs, p95: gpuMs, p99: gpuMs }, + surface: { ...surface, resolutionScale: scaler.scale, resolutionSubrect: continuous }, + }); + }; + feed(34, 6); + feed(10, 30); + const recovered = scaler.scale; + expect(recovered).toBeGreaterThan(0.9); + feed(34, 30); + // Returning to the same proven fixed cost earns no reduction; a sustained rise reopens it. + expect(scaler.scale).toBe(recovered); + }, +); diff --git a/packages/core/__tests__/resolution-scaler.spec.ts b/packages/core/__tests__/resolution-scaler.spec.ts index 57ac8c516..7e150c7ec 100644 --- a/packages/core/__tests__/resolution-scaler.spec.ts +++ b/packages/core/__tests__/resolution-scaler.spec.ts @@ -227,6 +227,26 @@ describe("ResolutionScaler", () => { expect(scaler.observe(budget(UNDER_TARGET))).toBeLessThan(1); }); + it("emits the lower pinned rung when timer-less oscillation ends on an upward step", () => { + const scaler = new ResolutionScaler({ targetFps: 60, start: 0.85 }); + feed(scaler, AT_TARGET, RESOLUTION_SCALER.warmupWindows); + for (let cycle = 0; cycle < 2; cycle += 1) { + feed(scaler, AT_TARGET, RESOLUTION_SCALER.upWindows); + expect(scaler.scale).toBe(1); + scaler.observe(budget(AT_TARGET)); // resize cooldown + expect(scaler.observe(budget(58))).toBe(0.85); + scaler.observe(budget(AT_TARGET)); // resize cooldown + } + feed(scaler, AT_TARGET, RESOLUTION_SCALER.upWindows - 1); + expect(scaler.observe(budget(AT_TARGET))).toBe(0.85); + expect(scaler.scale).toBe(0.85); + expect(scaler.scaleSource).toBe("auto-pinned"); + feed(scaler, AT_TARGET, RESOLUTION_SCALER.oscillationWindows - 1); + expect(scaler.scale).toBe(0.85); + expect(scaler.observe(budget(AT_TARGET))).toBe(0.85); + expect(scaler.scaleSource).toBe("auto"); + }); + it("scales its triggers to the configured target rather than assuming 60", () => { const scaler = new ResolutionScaler({ targetFps: 30 }); feed(scaler, 30, RESOLUTION_SCALER.warmupWindows, 33.3); diff --git a/packages/core/src/frame-budget.ts b/packages/core/src/frame-budget.ts index fb4527da7..d36464d2a 100644 --- a/packages/core/src/frame-budget.ts +++ b/packages/core/src/frame-budget.ts @@ -99,6 +99,7 @@ export interface IFramePhaseSample { * convention off does not turn its measurement off. */ export interface IFrameSurfaceState { + readonly resolutionSubrect?: boolean; /** The applied drawing-buffer scale, in `(0, 1]`. */ readonly resolutionScale: number; /** @@ -136,12 +137,15 @@ function requireSurface(surface: IFrameSurfaceState): IFrameSurfaceState { compiling, drawingBufferHeight, drawingBufferWidth, + resolutionSubrect, resolutionScale, sampleCount, scaleSource, } = surface; if (compiling !== undefined && typeof compiling !== "boolean") throw new Error("Frame budget surface compiling must be a boolean when observed."); + if (resolutionSubrect !== undefined && typeof resolutionSubrect !== "boolean") + throw new Error("Frame budget surface resolutionSubrect must be a boolean when observed."); if (typeof atFloor !== "boolean") throw new Error( `Frame budget surface atFloor must say whether the scaler had room left, received ${String(atFloor)}.`, @@ -172,6 +176,7 @@ function requireSurface(surface: IFrameSurfaceState): IFrameSurfaceState { ...(compiling === undefined ? {} : { compiling }), drawingBufferHeight, drawingBufferWidth, + ...(resolutionSubrect === undefined ? {} : { resolutionSubrect }), resolutionScale, sampleCount, scaleSource, diff --git a/packages/core/src/render/chain.ts b/packages/core/src/render/chain.ts index 17d0610b8..8436039f7 100644 --- a/packages/core/src/render/chain.ts +++ b/packages/core/src/render/chain.ts @@ -9,6 +9,7 @@ import type { RenderOutputSetter, RendererKind, } from "../renderer.js"; +import type { IResolutionSubrect, IResolutionTextureView } from "./resolution-subrect.js"; import { VELOCITY_OUTPUT_NAME, velocityTexture, withVelocityContext } from "./velocity.js"; import type { IVelocityRenderPass } from "./velocity.js"; @@ -125,12 +126,15 @@ export interface IRenderChainRequest { export interface IRenderChainStageContext { readonly tier: RenderChainTier; readonly velocity: IRenderChainVelocityReport; + /** Mutable target fractions supplied to generated stages for sub-rectangle sampling. */ + readonly resolutionSubrect?: IResolutionTextureView; /** TSL source handed to temporal stages when the request owns a scene pass. */ readonly velocityNode?: Node; readonly quality: (typeof RENDER_CHAIN_TIERS)[RenderChainTier]; } export interface IRenderChainStage { + readonly supportsResolutionSubrect?: boolean; readonly name: RenderChainStageId; /** Place an authored stage immediately before this built-in or supplied stage. */ readonly before?: RenderChainStageId; @@ -202,6 +206,7 @@ export interface IRenderChainBudgetWindow { } export interface IRenderChainOptions { + readonly resolutionSubrect?: IResolutionSubrect; readonly renderer: IRenderChainRenderer; readonly input?: unknown; /** Explicit scene pass for output retargeting; avoids traversing a composed graph to find it. */ @@ -275,6 +280,7 @@ export class RenderChain { readonly #renderer: IRenderChainRenderer; readonly #input: unknown; readonly #worldPass: unknown; + readonly #resolutionSubrect: IResolutionSubrect | undefined; readonly #report: (line: string) => void; readonly #stageDefinitions: ReadonlyMap; readonly #requested: readonly RenderChainStageId[]; @@ -312,6 +318,7 @@ export class RenderChain { this.#renderer = options.renderer; this.#input = options.input; this.#worldPass = options.worldPass; + this.#resolutionSubrect = options.resolutionSubrect; this.#report = options.report ?? ((line) => console.log(line)); this.#stageDefinitions = createStageDefinitions(options.stages ?? []); this.#requested = normalizeRequestedStages( @@ -398,7 +405,12 @@ export class RenderChain { dropped.push({ name, reason: "velocity:missing" }); continue; } - const context = stageContext(this.#tier, velocity, velocityNode); + const context = stageContext( + this.#tier, + velocity, + velocityNode, + this.#resolutionSubrect?.view, + ); const availability = definition.available?.(context); if (availability === false) { dropped.push({ name, reason: `unavailable:${this.#renderer.kind}` }); @@ -426,7 +438,12 @@ export class RenderChain { texture.name === VELOCITY_OUTPUT_NAME && !priorTextures.includes(texture), ); } - const buildContext = stageContext(this.#tier, velocity, velocityNode); + const buildContext = stageContext( + this.#tier, + velocity, + velocityNode, + this.#resolutionSubrect?.view, + ); const next = definition.build(node, buildContext); if (next === undefined || next === null) throw new Error("stage returned no node"); contributions.push({ graphOutputChanged: next !== node, name }); @@ -462,6 +479,23 @@ export class RenderChain { ) ? [{ outputColorTransform: false }] : []; + if (this.#resolutionSubrect !== undefined) { + const refused = builtStageDefinitions + .filter((stage) => stage.supportsResolutionSubrect !== true) + .map((stage) => stage.name); + this.#report( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: refused.length === 0 ? stages : [], refused: refused.map((name) => ({ name, reason: "sampling support not declared" })) })}`, + ); + if (refused.length === 0) { + if (outputOptions.length === 0) + outputOptions.push({ resolutionSubrect: this.#resolutionSubrect }); + else + outputOptions[0] = { + ...outputOptions[0], + resolutionSubrect: this.#resolutionSubrect, + }; + } + } const installation = this.#renderer.setOutputNode( node, this.#worldPass ?? this.#requestVelocity.pass, @@ -836,12 +870,14 @@ function stageContext( tier: RenderChainTier, velocity: IRenderChainVelocityReport, velocityNode: Node | undefined, + resolutionSubrect: IResolutionTextureView | undefined, ): IRenderChainStageContext { return { quality: RENDER_CHAIN_TIERS[tier], tier, velocity, ...(velocityNode === undefined ? {} : { velocityNode }), + ...(resolutionSubrect === undefined ? {} : { resolutionSubrect }), }; } diff --git a/packages/core/src/render/resolution-subrect.ts b/packages/core/src/render/resolution-subrect.ts new file mode 100644 index 000000000..bf6aa5289 --- /dev/null +++ b/packages/core/src/render/resolution-subrect.ts @@ -0,0 +1,251 @@ +import { type RenderTarget, type Texture, Vector2 } from "three"; + +export interface IResolutionTextureView { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +} +export interface IResolutionSubrectNode { + setSize(width: number, height: number): void; +} +interface IRetainedTarget { + readonly target: RenderTarget; + readonly view: IResolutionTextureView; + readonly original: RenderTarget["setSize"]; + readonly wrapped: RenderTarget["setSize"]; + width: number; + height: number; + viewWidth: number; + viewHeight: number; +} +interface IRetainedNode { + readonly node: IResolutionSubrectNode; + readonly original: IResolutionSubrectNode["setSize"]; + readonly wrapped: IResolutionSubrectNode["setSize"]; + readonly targets: readonly IRetainedTarget[]; + width: number; + height: number; +} + +const quantize = (size: number): number => Math.max(8, Math.ceil(size / 8) * 8); +const validateSize = (width: number, height: number): void => { + if (![width, height].every((value) => Number.isFinite(value) && value > 0)) + throw new Error("TN_RESOLUTION_SUBRECT: invalid target extent"); +}; + +const validateScale = (scale: number): void => { + if (!Number.isFinite(scale) || scale <= 0 || scale > 1) + throw new Error("TN_RESOLUTION_SUBRECT: invalid scale"); +}; + +/** Owns capacity and viewport lifetime; generated render source owns sampling. */ +export interface IResolutionSubrect { + readonly active?: boolean; + readonly view?: IResolutionTextureView; + textureView(texture: Texture): IResolutionTextureView | undefined; + activate?(scale: number): void; + deactivate?(): void; + setScale?(scale: number): void; + setSize?(width: number, height: number): void; + retain(node: IResolutionSubrectNode, targets: readonly RenderTarget[]): void; + release(node: IResolutionSubrectNode): void; + dispose(): void; +} + +export class ResolutionSubrect implements IResolutionSubrect { + readonly viewSize = new Vector2(); + readonly #fullSize = new Vector2(); + readonly #nodes = new Map(); + readonly #targets = new Map(); + #active = false; + #disposed = false; + #reserving = false; + #scale = 1; + + get active(): boolean { + return this.#active; + } + get scale(): number { + return this.#scale; + } + + get view(): IResolutionTextureView | undefined { + for (const binding of this.#targets.values()) return binding.view; + return undefined; + } + + activate(scale: number): void { + if (this.#disposed) throw new Error("TN_RESOLUTION_SUBRECT: disposed controller"); + validateScale(scale); + this.#active = true; + this.setScale(scale); + } + + deactivate(): void { + if (!this.#active) return; + this.#active = false; + for (const binding of this.#nodes.values()) { + if (binding.width <= 0 || binding.height <= 0) continue; + binding.original.call( + binding.node, + Math.max(1, Math.round(binding.width * this.#scale)), + Math.max(1, Math.round(binding.height * this.#scale)), + ); + } + for (const binding of this.#targets.values()) { + const width = Math.max(1, binding.target.width); + const height = Math.max(1, binding.target.height); + binding.target.viewport.set(0, 0, width, height); + binding.target.scissor.copy(binding.target.viewport); + binding.target.scissorTest = false; + binding.view.viewFraction.set(1, 1); + binding.view.halfTexel.set(0.5 / width, 0.5 / height); + } + } + + setScale(scale: number): void { + if (this.#disposed) throw new Error("TN_RESOLUTION_SUBRECT: disposed controller"); + validateScale(scale); + this.#scale = scale; + if (this.#fullSize.x > 0) + this.viewSize.set(quantize(this.#fullSize.x * scale), quantize(this.#fullSize.y * scale)); + for (const binding of this.#nodes.values()) { + if (binding.width > 0) binding.node.setSize(binding.width, binding.height); + } + } + + setSize(width: number, height: number): void { + if (this.#disposed) throw new Error("TN_RESOLUTION_SUBRECT: disposed controller"); + validateSize(width, height); + if (this.#active && this.#fullSize.x === width && this.#fullSize.y === height) return; + this.#fullSize.set(width, height); + this.viewSize.set(quantize(width * this.#scale), quantize(height * this.#scale)); + for (const binding of this.#nodes.values()) binding.node.setSize(width, height); + } + + retain(node: IResolutionSubrectNode, targets: readonly RenderTarget[]): void { + if (this.#disposed) throw new Error("TN_RESOLUTION_SUBRECT: disposed controller"); + if (this.#nodes.has(node)) return; + if ( + targets.length === 0 || + new Set(targets).size !== targets.length || + targets.some((target) => this.#targets.has(target)) + ) + throw new Error("TN_RESOLUTION_SUBRECT: missing or multiply owned target"); + const retained = targets.map((target) => { + const view = { viewFraction: new Vector2(1, 1), halfTexel: new Vector2() }; + const original = target.setSize; + const binding: IRetainedTarget = { + target, + view, + original, + width: 0, + height: 0, + viewWidth: 0, + viewHeight: 0, + wrapped: (width, height, depth = target.depth) => { + validateSize(width, height); + if (!this.#active) { + original.call(target, width, height, depth); + view.viewFraction.set(1, 1); + view.halfTexel.set(0.5 / width, 0.5 / height); + return; + } + let capacityChanged = false; + if (this.#reserving) { + const widthCapacity = Math.max(binding.width, quantize(width)); + const heightCapacity = Math.max(binding.height, quantize(height)); + capacityChanged = widthCapacity !== binding.width || heightCapacity !== binding.height; + binding.width = widthCapacity; + binding.height = heightCapacity; + } else { + if (binding.width === 0 || binding.height === 0) + throw new Error("TN_RESOLUTION_SUBRECT: target capacity not reserved"); + if (Math.round(width) > binding.width || Math.round(height) > binding.height) + throw new Error("TN_RESOLUTION_SUBRECT: viewport exceeds retained capacity"); + binding.viewWidth = Math.max(1, Math.round(width)); + binding.viewHeight = Math.max(1, Math.round(height)); + } + if (capacityChanged || target.width !== binding.width || target.height !== binding.height) + original.call(target, binding.width, binding.height, depth); + if (!this.#reserving) this.#applyViewport(binding); + }, + }; + target.setSize = binding.wrapped; + this.#targets.set(target, binding); + return binding; + }); + const original = node.setSize; + const binding: IRetainedNode = { + node, + original, + targets: retained, + width: 0, + height: 0, + wrapped: (width, height) => { + validateSize(width, height); + binding.width = width; + binding.height = height; + if (!this.#active) { + original.call(node, width, height); + return; + } + this.#reserving = true; + try { + original.call(node, width, height); + } finally { + this.#reserving = false; + } + original.call(node, quantize(width * this.#scale), quantize(height * this.#scale)); + // PassNode copies its own viewport/scissor after target.setSize(). + for (const target of retained) this.#applyViewport(target); + }, + }; + node.setSize = binding.wrapped; + this.#nodes.set(node, binding); + if (this.#active && this.#fullSize.x > 0) node.setSize(this.#fullSize.x, this.#fullSize.y); + } + + textureView(texture: Texture): IResolutionTextureView | undefined { + for (const { target, view } of this.#targets.values()) { + if (target.textures.includes(texture) || target.depthTexture === texture) { + return view; + } + } + return undefined; + } + + release(node: IResolutionSubrectNode): void { + const binding = this.#nodes.get(node); + if (!binding) return; + if (node.setSize === binding.wrapped) node.setSize = binding.original; + for (const { target, original, wrapped } of binding.targets) { + if (target.setSize === wrapped) target.setSize = original; + this.#targets.delete(target); + } + this.#nodes.delete(node); + } + + dispose(): void { + if (this.#disposed) return; + this.deactivate(); + this.#disposed = true; + for (const { node, original, wrapped } of this.#nodes.values()) { + if (node.setSize === wrapped) node.setSize = original; + } + for (const { target, original, wrapped } of this.#targets.values()) { + if (target.setSize === wrapped) target.setSize = original; + } + this.#nodes.clear(); + this.#targets.clear(); + } + + #applyViewport({ target, view, width, height, viewWidth, viewHeight }: IRetainedTarget): void { + if (viewWidth > width || viewHeight > height || viewWidth <= 0 || viewHeight <= 0) + throw new Error("TN_RESOLUTION_SUBRECT: viewport exceeds retained capacity"); + target.viewport.set(0, 0, viewWidth, viewHeight); + target.scissor.copy(target.viewport); + target.scissorTest = true; + view.viewFraction.set(viewWidth / width, viewHeight / height); + view.halfTexel.set(0.5 / width, 0.5 / height); + } +} diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 4b9775057..1c01b8b60 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -30,6 +30,7 @@ import { type IRenderChainOptions, RenderChain, } from "./render/chain.js"; +import { type IResolutionSubrect, ResolutionSubrect } from "./render/resolution-subrect.js"; import { type IStorageBufferLease, @@ -51,6 +52,7 @@ export interface IRenderOutputInstallation { */ export interface IRenderOutputOptions { readonly outputColorTransform?: boolean; + readonly resolutionSubrect?: IResolutionSubrect; } /** Union of callbacks preserves TypeScript's legacy void-callback return-value compatibility. */ @@ -304,6 +306,7 @@ export interface IRendererLike { // 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; + createResolutionSubrect?: () => IResolutionSubrect; setSize(width: number, height: number, updateStyle?: boolean): void; /** * The GPU time the last resolved frame actually cost, in milliseconds, or `undefined` when the @@ -780,6 +783,7 @@ function wrapRenderer( drawingBufferHeight: applied.height, drawingBufferWidth: applied.width, resolutionScale: state.resolutionScale, + ...(state.resolutionSubrect?.active === true ? { resolutionSubrect: true } : {}), // Three reports 0 for a single sample per pixel; a sample count of zero would describe no // image at all. sampleCount: resolveSampleCount(raw), @@ -1084,6 +1088,8 @@ function wrapRenderer( return; } disposed = true; + state.resolutionSubrect?.deactivate?.(); + state.resolutionSubrect = undefined; for (const preparation of texturePreparations) preparation.abort(new Error("Renderer disposed during texture preparation.")); texturePreparations.clear(); @@ -1123,6 +1129,7 @@ function wrapRenderer( renderingFrame -= 1; } }, + createResolutionSubrect: () => new ResolutionSubrect(), setOutputNode: (node, worldPass, options) => { if (kind !== "webgpu") throw new Error(`setOutputNode is unavailable on the ${kind} renderer.`); @@ -1136,10 +1143,15 @@ function wrapRenderer( if (options?.outputColorTransform !== undefined) { nextPipeline.outputColorTransform = options.outputColorTransform; } + if (state.resolutionSubrect !== options?.resolutionSubrect) + state.resolutionSubrect?.deactivate?.(); + state.resolutionSubrect = options?.resolutionSubrect; + state.resolutionSubrect?.activate?.(state.resolutionScale); outputPipeline?.dispose(); outputPass = nextOutputPass; outputPipeline = nextPipeline; outputInput = node; + reapply.resize?.(); return { isCurrent: () => outputPipeline === nextPipeline, dispose: () => { @@ -1148,6 +1160,9 @@ function wrapRenderer( outputInput = undefined; outputPass = undefined; nextPipeline.dispose(); + state.resolutionSubrect?.deactivate?.(); + state.resolutionSubrect = undefined; + reapply.resize?.(); }, }; }, @@ -1157,6 +1172,9 @@ function wrapRenderer( outputPipeline = undefined; outputInput = undefined; outputPass = undefined; + state.resolutionSubrect?.deactivate?.(); + state.resolutionSubrect = undefined; + reapply.resize?.(); }, renderChainUsesPerObjectVelocity: () => renderChainUsesPerObjectVelocity, setRenderChainVelocityEnabled: (enabled) => { @@ -1265,11 +1283,20 @@ function addResizeHandling( // Recorded as it is applied rather than read back off the canvas: the canvas dimensions are // the host's to define and on native they have been the physical surface, which is exactly // the number this scale exists to stop a game from paying by hand. - renderer.setSize( - Math.max(1, Math.round(width * pixelRatio * state.resolutionScale)), - Math.max(1, Math.round(height * pixelRatio * state.resolutionScale)), - false, - ); + const subrect = state.resolutionSubrect; + const scale = subrect?.active ? 1 : state.resolutionScale; + const physicalWidth = Math.max(1, Math.round(width * pixelRatio * scale)); + const physicalHeight = Math.max(1, Math.round(height * pixelRatio * scale)); + const surface = renderer.surface(); + if ( + surface.drawingBufferWidth !== physicalWidth || + surface.drawingBufferHeight !== physicalHeight + ) + renderer.setSize(physicalWidth, physicalHeight, false); + if (subrect?.active) { + subrect.setScale?.(state.resolutionScale); + subrect.setSize?.(physicalWidth, physicalHeight); + } }; resize(); const stop = @@ -1280,6 +1307,7 @@ function addResizeHandling( /** The live scale, mutated only by `setResolutionScale`, read by every window report. */ interface ISurfaceState { + resolutionSubrect?: IResolutionSubrect | undefined; resolutionScale: number; scaleSource: "pinned" | "auto" | "auto-pinned"; } diff --git a/packages/core/src/resolution-scaler.ts b/packages/core/src/resolution-scaler.ts index 3dd079205..8aae23c3d 100644 --- a/packages/core/src/resolution-scaler.ts +++ b/packages/core/src/resolution-scaler.ts @@ -104,6 +104,12 @@ export const RESOLUTION_SCALER = { minWindowGpuSamples: 4, /** The resize frame is itself a hitch and must never feed the controller. */ cooldownWindows: 1, + /** Continuous sub-rectangle changes are separated by this many rendered frames. */ + continuousChangeFrames: 8, + /** Ignore a continuous estimate that moves less than this fraction of the current scale. */ + continuousChangeThreshold: 0.02, + /** Move this fraction of the way toward an up-estimate to preserve temporal history. */ + continuousUpFraction: 0.9, /** * Windows discarded outright at the start. "Window 1 always lies" is already the perf CLI's * rule; the controller was the one consumer that believed it. A scaffolded template took its @@ -209,6 +215,15 @@ export interface IScalerWindow { readonly gpu?: { readonly p50: number; readonly samples: number }; /** Frames per second this window achieved, from the mean presented interval. */ readonly fps: number; + /** Loop frames represented by this closed window, used for the sub-rectangle cadence guard. */ + readonly frames?: number; + /** Current drawing surface, used to quantize a continuous estimate to eight pixels. */ + readonly surface?: { + readonly drawingBufferWidth: number; + readonly drawingBufferHeight: number; + readonly resolutionScale: number; + readonly resolutionSubrect?: boolean; + }; /** * `p50` says what an ordinary frame cost; `p99` and `max` expose recurring and isolated stalls. * The controller needs them to tell a slow game from a stalled one: `fps` alone cannot, because @@ -236,6 +251,7 @@ export class ResolutionScaler { /** presented p95 above this is dropping frames, whatever the mean says. */ tailMs = 0; #index: number; + #continuousScale: number; /** The lowest rung this platform may take; the whole ladder's last index when it states none. */ readonly #minIndex: number; readonly #temporalUpscale: (() => boolean) | undefined; @@ -266,17 +282,22 @@ export class ResolutionScaler { // downward step; `#insensitiveFloor` is the rung a refunded one proved worth holding (no // downward step may cross it while it stands), `#insensitiveSignal` its measured GPU time, and // `#insensitiveRise` the consecutive windows of sustained rise that clear both. - #insensitiveProbe: { fromIndex: number; toIndex: number; signal: number } | undefined = undefined; + #insensitiveProbe: + | { fromIndex: number; toIndex: number; fromScale?: number; toScale?: number; signal: number } + | undefined = undefined; #insensitiveFloor = -1; #insensitiveSignal = 0; #insensitiveRise = 0; #insensitiveHold = 0; #lastInsensitiveMarker = Number.NEGATIVE_INFINITY; + #continuousFrames = 0; + #continuousLastChangeFrame = Number.NEGATIVE_INFINITY; constructor(options: IResolutionScalerOptions) { const { minScale, start, targetFps, temporalUpscale } = options; this.retarget(targetFps); this.#index = start === undefined ? 0 : rungIndex(start, "start"); + this.#continuousScale = RESOLUTION_SCALER.rungs[this.#index] ?? 1; this.#minIndex = minScale === undefined ? RESOLUTION_SCALER.rungs.length - 1 : rungIndex(minScale, "minScale"); this.#temporalUpscale = temporalUpscale; @@ -302,7 +323,7 @@ export class ResolutionScaler { /** The scale the renderer should be applying right now. */ get scale(): number { - return RESOLUTION_SCALER.rungs[this.#index] ?? 1; + return this.#continuousScale; } /** @@ -333,6 +354,9 @@ export class ResolutionScaler { */ observe(window: IScalerWindow): number | undefined { this.#windowIndex += 1; + const frames = window.frames; + this.#continuousFrames += + frames !== undefined && Number.isFinite(frames) && frames > 0 ? Math.floor(frames) : 1; if (this.#scaleSource === "auto-pinned" && --this.#pinWindows === 0) { this.#scaleSource = "auto"; this.#guardLegs = this.#guardCycles = this.#cleanWindows = 0; @@ -366,6 +390,8 @@ export class ResolutionScaler { this.#trackInsensitiveRise(gpuMs); const refunded = this.#settleInsensitiveProbe(gpuMs); if (refunded !== undefined) return refunded; + if (gpuMs !== undefined && this.#continuousActive(window)) + return this.#observeContinuous(window, gpuMs, stalled); if (window.fps < this.targetFps && (gpuMs === undefined || gpuMs > this.budgetMs)) { // **Only the mean is under target: hold.** `fps` is `1000 / mean`, and on the one panel // arrangement every game actually ships into — `display.maxFps` equal to the refresh rate, @@ -458,15 +484,20 @@ export class ResolutionScaler { this.#insensitiveProbe = undefined; const gain = (probe.signal - gpuMs) / probe.signal; if (gain >= RESOLUTION_SCALER.insensitiveGainFraction) return undefined; - const restored = RESOLUTION_SCALER.rungs[probe.fromIndex] ?? 1; + const restored = probe.fromScale ?? RESOLUTION_SCALER.rungs[probe.fromIndex] ?? 1; this.#index = probe.fromIndex; + this.#continuousScale = restored; this.#insensitiveFloor = probe.fromIndex; this.#insensitiveSignal = probe.signal; this.#insensitiveHold = RESOLUTION_SCALER.insensitiveHoldWindows; this.#cleanWindows = 0; // The restore is itself a resize and must not feed the controller. this.#cooldown = RESOLUTION_SCALER.cooldownWindows; - this.#emitInsensitive(RESOLUTION_SCALER.rungs[probe.toIndex] ?? 1, restored, gain); + this.#emitInsensitive( + probe.toScale ?? RESOLUTION_SCALER.rungs[probe.toIndex] ?? 1, + restored, + gain, + ); return restored; } @@ -536,6 +567,130 @@ export class ResolutionScaler { this.#probeFps = window.fps; return this.#step(1); } + + #continuousActive(window: IScalerWindow): boolean { + return window.surface?.resolutionSubrect === true; + } + + #observeContinuous( + window: IScalerWindow, + gpuMs: number | undefined, + stalled: boolean, + ): number | undefined { + const overBudget = + window.fps < this.targetFps && (gpuMs === undefined || gpuMs > this.budgetMs); + const floor = RESOLUTION_SCALER.rungs[this.#limitIndex] ?? 0.23; + if (overBudget) { + this.#cleanWindows = 0; + if (!this.#overBudget(window) || gpuMs === undefined) { + this.#atFloor = this.#continuousScale <= floor; + return undefined; + } + if (this.#insensitiveHold > 0 || this.#insensitiveFloor >= 0) return undefined; + const estimate = this.#continuousScale * Math.sqrt(this.budgetMs / gpuMs); + const target = this.#quantizeContinuous(Math.max(floor, estimate), window); + this.#atFloor = target <= floor; + const fromIndex = this.#index; + const fromScale = this.#continuousScale; + const changed = this.#continuousChange(Math.max(floor, target), false); + if ( + changed !== undefined && + this.#scaleSource === "auto" && + 1 - (changed / fromScale) ** 2 >= RESOLUTION_SCALER.insensitiveMinPixelDrop + ) + this.#insensitiveProbe = { + fromIndex, + toIndex: this.#index, + fromScale, + toScale: changed, + signal: gpuMs, + }; + return changed; + } + this.#atFloor = false; + if (stalled || this.#scaleSource === "auto-pinned") return undefined; + const nextScale = Math.min(1, this.#continuousScale + 0.01); + const canClimb = + gpuMs === undefined + ? window.presented.p95 <= this.tailMs + : gpuMs * (nextScale / this.#continuousScale) ** 2 <= this.budgetMs; + if (!canClimb) { + this.#cleanWindows = 0; + return undefined; + } + this.#cleanWindows += 1; + if (this.#cleanWindows < RESOLUTION_SCALER.upWindows) return undefined; + this.#cleanWindows = 0; + const estimate = + gpuMs === undefined + ? 1 + : Math.min(1, this.#continuousScale * Math.sqrt(this.budgetMs / gpuMs)); + const amortized = + this.#continuousScale + + RESOLUTION_SCALER.continuousUpFraction * + (Math.max(this.#continuousScale, estimate) - this.#continuousScale); + return this.#continuousChange(this.#quantizeContinuous(amortized, window), true); + } + + #quantizeContinuous(scale: number, window: IScalerWindow): number { + const surface = window.surface; + if ( + surface === undefined || + !Number.isFinite(surface.drawingBufferWidth) || + !Number.isFinite(surface.drawingBufferHeight) || + !Number.isFinite(surface.resolutionScale) || + surface.drawingBufferWidth <= 0 || + surface.drawingBufferHeight <= 0 || + surface.resolutionScale <= 0 + ) + return Math.min(1, Math.max(0, scale)); + const fullWidth = + surface.resolutionSubrect === true + ? surface.drawingBufferWidth + : surface.drawingBufferWidth / surface.resolutionScale; + const fullHeight = + surface.resolutionSubrect === true + ? surface.drawingBufferHeight + : surface.drawingBufferHeight / surface.resolutionScale; + const width = Math.max(8, Math.ceil((fullWidth * scale) / 8) * 8); + const height = Math.max(8, Math.ceil((fullHeight * scale) / 8) * 8); + return Math.min(1, width / fullWidth, height / fullHeight); + } + + #continuousChange(target: number, up: boolean): number | undefined { + const current = this.#continuousScale; + const change = Math.abs(target - current) / Math.max(current, Number.EPSILON); + if (change < RESOLUTION_SCALER.continuousChangeThreshold) return undefined; + if ( + this.#continuousFrames - this.#continuousLastChangeFrame < + RESOLUTION_SCALER.continuousChangeFrames + ) + return undefined; + const next = Math.min(1, Math.max(RESOLUTION_SCALER.rungs[this.#limitIndex] ?? 0.23, target)); + if (next === current) return undefined; + const previousIndex = this.#index; + this.#continuousScale = next; + this.#syncRungIndex(next); + this.#continuousLastChangeFrame = this.#continuousFrames; + this.#cooldown = RESOLUTION_SCALER.cooldownWindows; + const direction = next < current ? 1 : -1; + const boundary = direction > 0 ? previousIndex : this.#index; + this.#noteForOscillationGuard(boundary, direction); + return this.#continuousScale; + } + + #syncRungIndex(scale: number): void { + let closest = 0; + let distance = Number.POSITIVE_INFINITY; + for (const [index, rung] of RESOLUTION_SCALER.rungs.entries()) { + const nextDistance = Math.abs(rung - scale); + if (nextDistance >= distance) continue; + closest = index; + distance = nextDistance; + } + this.#index = closest; + } + /** * True when the median or tail corroborates the missed presentation budget. This alone cannot * identify a pixel bottleneck; fresh GPU timing takes precedence in observe(). @@ -637,6 +792,7 @@ export class ResolutionScaler { // A refund is a measurement correction, not workload oscillation: counting it would let // probe/refund cycles pin the scaler at a rung pixels were just proven not to earn. if (countGuard) this.#noteForOscillationGuard(boundary, direction); + this.#continuousScale = RESOLUTION_SCALER.rungs[this.#index] ?? 1; return this.scale; } diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 6c6de1cf0..92fd08cfd 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -190,6 +190,10 @@ const BUG_REPORT_SKILL_PATHS = [ // Recomputed from clean committed c22e5ba8d for the independently reviewed bounded-upload // package patch embedded by every scaffold. No template, capability or appearance change. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { + // PR489: all thirteen createProject trees change only by the four-line AA dispose closure; + // removing that closure from each generated tree recovers its previous fingerprint. + // PRD-564: measured all thirteen trees after retained-target sampling changes in + // worldEnvironment.ts and starter's grade/grain support declarations. // PRD-569 merge: measured all thirteen createProject trees; action-rpg retains onCull. // PRD-563: every kit's render/quality.ts and scenes carry the GPU-class start tier. // Measured by the actual thirteen-tree createProject equality test on the Oct 6 @@ -251,22 +255,22 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // createProject on the merged tree. // PRD-566/569/609 merge: measured all thirteen trees; action-rpg carries emitParticle bursts and onCull instructions. // Recomputed 2026-10-13: the onCull paragraph in action-rpg AGENTS.md folded to one line to meet the 100-line budget. - // PRD-565: only the copied canonical Three patch changes; substituting its parent bytes - // recovers all thirteen prior hashes. Measured through the actual createProject tree test. - "action-rpg": "2ad57690b87fe80faa26931ea11d6ea210ac787bb58c7bb0f83e7b653426eeb7", - minimal: "6e9d255a6033e239e73dfabec172dfebb4a88849c01ca461d476ab8b17b91cbd", - platformer: "2ccf93236a0d6c1b8aa00bd3f86585427149f3788640dccb491f8d451c2c6894", - puzzle: "a25a9129f070b8a4cd25c1d3a174950b0367eba85d5a414b62325a571e2c40f3", - racing: "0fb3b6a6eacfc54acd74ae086cb5502789fdfe83e0466306202053843314f663", - rain: "9523312a5c0573b6ae886bd63ade36922a86f7a9b8e3730f7ead743609357bd7", - rts: "8a8d80b543d77717243274896b5b7a6a723b92e3a3f252fd470b2fb6dcfc12c8", - runner: "b14123d911dc3eb7d73e1ab3f91269b480a028938efff4a2bea4f56f2e570906", + // Merged develop 1d056cec3 into PRD-564: AA disposal and the PR486 Three patch ship, so all thirteen + // trees are measured through createProject on this combined worktree, not either side's value. + "action-rpg": "6c096ad4d318afea70a1deb38a24f93653d38fb601f98eca26dfebf9fabe3a81", + minimal: "10fdb8f43de4e235b8dcd915550f4f1a3e5f6cfaca245789d167e2967f3e8ec4", + platformer: "98a84182735a6dd2b1e00883790519e9cabf38839636cf1882ff7332370378a3", + puzzle: "4646480a4d65cf256a40aef65990ca97939d6f9fc6008959fb5c8d7e51edf3ab", + racing: "36f926e31ac2983aa49ce77c312b4db1858c685aeac0ed428a1226f87648e70d", + rain: "2dc28ae7ca1cac1043bcfd1158bb38e5fc673a33d0aaa93c0b74ff5dd455be23", + rts: "c9f009bffd1a20764e5a689d52450981e849607661a3df249fa7dbfb15640f63", + runner: "0563b9eee13e74f237d62d245b740974b5a487d09eae69b2934e07e4609ca46d", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "73da167ba1fdc6360c58e61b51a2f3dba047fbe1c4e88648e9bce4abb64325da", - shooter: "ca09b0b25f3ba689af53772d82be0978d82cdaff8f997b04b160874428ec3e54", - snow: "482c1c7f1cd2f233d43cc3fd378c983b4ab8f13dca4bd6fca008e5b120cb0705", - starter: "74df32f5ea8f1aeeea4d2eaf9c423f2fca93fbb1fa392de7acdb6b8bd994db8c", - "tower-defense": "7d816e530aee8842027a1666782d96c51b2cbfde55c4c23a7cdd1b0fd6a267ac", + sailing: "e9dc35b8862ef287db6c8614d875dd1af183688d9f8820d160662553315c88a6", + shooter: "15258f5e74010cba3dd0aa915c5a3668ca6d7d804fcd30cd0467030a6594948b", + snow: "c5078ff66a9744f1888ed1b6cc34ca6c327f0a1e06b561dc9f245ae55fd7f0cd", + starter: "8ce6d4d01b480ac925df58948683494dadcc722758682b1dbecae171d3cd308a", + "tower-defense": "7b78fa2607655a5a4f6571d6f2f152cdab98d592d7133fd65bb16c96e474a830", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts index b34d61779..aa533b3a3 100644 --- a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts @@ -1,8 +1,10 @@ import { PerspectiveCamera, Scene, Texture } from "three"; import BloomNode from "three/addons/tsl/display/BloomNode.js"; +import FXAANode from "three/addons/tsl/display/FXAANode.js"; import { rtt, texture, vec4 } from "three/tsl"; -import type { Node } from "three/webgpu"; +import type { Node, NodeBuilder } from "three/webgpu"; import { expect, it, vi } from "vitest"; +import { ResolutionSubrect } from "../../core/src/render/resolution-subrect.js"; import { type OutputRenderer, WorldEnvironment } from "../template-assets/worldEnvironment.js"; it.each([undefined, 0, 4])( @@ -65,6 +67,99 @@ it("releases the bloom effect and its materialized input when its chain is repla for (const release of releases) expect(release).toHaveBeenCalledTimes(1); }); +it.each(["smaa", "fxaa"] as const)( + "releases retired %s targets on repeated stage rebuilds with and without subrect support", + (screenSpaceAA) => { + vi.stubGlobal( + "Image", + class { + src = ""; + onload = () => {}; + }, + ); + try { + for (const subrect of [true, false]) { + const controller = new ResolutionSubrect(); + const renderer: OutputRenderer = { + kind: "webgpu", + raw: {}, + ...(subrect ? { createResolutionSubrect: () => controller } : {}), + createRenderChain(options) { + const stage = options.stages?.find((entry) => entry.name === "antialias"); + if (!stage) throw new Error("Missing antialias"); + controller.activate(0.72); + for (let generation = 1; generation <= 3; generation++) { + const graph = stage.build(options.input, { tier: "high" }) as Node; + const targets: import("three").RenderTarget[] = []; + const releases: ReturnType[] = []; + const seen = new Set(); + graph.traverse((node) => { + if (!(node instanceof FXAANode) && !Reflect.has(node, "_renderTargetEdges")) return; + if (seen.has(node)) return; + seen.add(node); + releases.push(vi.spyOn(node, "dispose")); + const scratch = Reflect.get(node, "textureNode") as { + renderTarget: import("three").RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + targets.push(scratch.renderTarget); + releases.push(vi.spyOn(scratch._quadMesh.material, "dispose")); + if (Reflect.has(node, "_renderTargetEdges")) { + const owned = node as unknown as { + _renderTargetEdges: import("three").RenderTarget; + _renderTargetWeights: import("three").RenderTarget; + _renderTargetBlend: import("three").RenderTarget; + }; + targets.push( + owned._renderTargetEdges, + owned._renderTargetWeights, + owned._renderTargetBlend, + ); + } + }); + expect(targets).toHaveLength(screenSpaceAA === "smaa" ? 4 : 1); + controller.setSize(128 * generation, 64 * generation); + for (const target of targets) { + if (subrect) expect(controller.textureView(target.texture)).toBeDefined(); + releases.push(vi.spyOn(target, "dispose")); + } + stage.dispose?.(); + stage.dispose?.(); + for (const release of releases) expect(release).toHaveBeenCalledTimes(1); + const retiredSizes = targets.map((target) => [ + target.width, + target.height, + target.viewport.toArray(), + ]); + for (const target of targets) + expect(controller.textureView(target.texture)).toBeUndefined(); + controller.setScale(0.61); + controller.setSize(128 * (generation + 1), 64 * (generation + 1)); + expect( + targets.map((target) => [target.width, target.height, target.viewport.toArray()]), + ).toEqual(retiredSizes); + for (const release of releases) expect(release).toHaveBeenCalledTimes(1); + } + return { + applied: { stages: ["antialias"], dropped: [] }, + dispose: () => stage.dispose?.(), + }; + }, + }; + const applied = new WorldEnvironment({ bloomEnabled: true, screenSpaceAA }).apply( + renderer, + new Scene(), + new PerspectiveCamera(), + ); + applied.dispose?.(); + controller.dispose(); + } + } finally { + vi.unstubAllGlobals(); + } + }, +); + it("keeps default exposure direct and installs the authored opt-in on its existing world pass", () => { const output = vi.fn(); const renderer: OutputRenderer = { @@ -252,3 +347,198 @@ it("releases the unique direct installation receipt rather than the legacy node expect(dispose).toHaveBeenCalledTimes(1); expect(clearOutputNode).not.toHaveBeenCalled(); }); + +it("admits default spatial stages and explicitly refuses unadapted graphs", () => { + const handle = { + active: false, + retain: vi.fn(), + release: vi.fn(), + textureView: () => undefined, + dispose: vi.fn(), + }; + const optionsSeen: Parameters>[0][] = []; + const renderer: OutputRenderer = { + kind: "webgpu", + raw: {}, + createResolutionSubrect: () => handle, + createRenderChain(options) { + optionsSeen.push(options); + return { applied: { stages: [], dropped: [] }, dispose() {} }; + }, + }; + const graph = new WorldEnvironment({ gtaoEnabled: true, bloomEnabled: true }).apply( + renderer, + new Scene(), + new PerspectiveCamera(), + ); + const options = optionsSeen[0]; + expect(options?.resolutionSubrect).toBe(handle); + for (const name of ["ambientOcclusion", "bloom", "vignette", "antialias"]) + expect(options?.stages?.find((stage) => stage.name === name)?.supportsResolutionSubrect).toBe( + true, + ); + for (const name of ["ssgi", "ssr", "godRays", "sharpen"]) + expect( + options?.stages?.find((stage) => stage.name === name)?.supportsResolutionSubrect, + ).not.toBe(true); + expect(handle.retain).toHaveBeenCalled(); + graph.dispose?.(); + expect(handle.dispose).toHaveBeenCalledOnce(); +}); + +it.each([ + { + name: "custom base colour", + environment: new WorldEnvironment({ bloomEnabled: true }), + target: { baseColour: (scenePass: ReturnType) => scenePass }, + refusal: "baseColour", + }, + { + name: "automatic exposure", + environment: new WorldEnvironment({ autoExposureEnabled: true, bloomEnabled: true }), + target: undefined, + refusal: "autoExposure", + }, +])("keeps the sub-rectangle fallback for $name", ({ environment, target, refusal }) => { + const createResolutionSubrect = vi.fn(() => ({ + active: false, + textureView: () => undefined, + retain: vi.fn(), + release: vi.fn(), + dispose: vi.fn(), + })); + const createRenderChain = vi.fn( + (_options: Parameters>[0]) => ({ + applied: { stages: [], dropped: [] }, + dispose() {}, + }), + ); + const report = vi.spyOn(console, "info").mockImplementation(() => {}); + try { + const renderer: OutputRenderer = { + kind: "webgpu", + raw: {}, + createRenderChain, + createResolutionSubrect, + }; + const applied = environment.apply(renderer, new Scene(), new PerspectiveCamera(), target); + expect(createResolutionSubrect).not.toHaveBeenCalled(); + expect(createRenderChain.mock.calls[0]?.[0].resolutionSubrect).toBeUndefined(); + expect(report.mock.calls.some(([line]) => line.includes(refusal))).toBe(true); + applied.dispose?.(); + } finally { + report.mockRestore(); + } +}); + +it("retains every spatial intermediate target rather than only the scene pass", () => { + const retain = vi.fn(); + const handle = { + active: false, + retain, + release: vi.fn(), + textureView: () => undefined, + dispose: vi.fn(), + }; + vi.stubGlobal( + "Image", + class { + src = ""; + onload = () => {}; + }, + ); + let release: (() => void) | undefined; + try { + const renderer: OutputRenderer = { + kind: "webgpu", + raw: {}, + createResolutionSubrect: () => handle, + createRenderChain(options) { + let node = options.input; + for (const name of ["ambientOcclusion", "bloom", "vignette", "antialias"]) { + const stage = options.stages?.find((entry) => entry.name === name); + if (!stage) throw new Error(`Missing ${name}`); + node = stage.build(node, { tier: "high" }); + } + const prepared = new Set(); + (node as Node).traverse((entry) => { + if (entry instanceof BloomNode && !prepared.has(entry)) { + prepared.add(entry); + entry.setup({ getSharedContext: () => ({}) } as unknown as NodeBuilder); + } + }); + return { applied: { stages: [], dropped: [] }, dispose() {} }; + }, + }; + const graph = new WorldEnvironment({ gtaoEnabled: true, bloomEnabled: true }).apply( + renderer, + new Scene(), + new PerspectiveCamera(), + ); + release = graph.dispose; + const targets = retain.mock.calls.flatMap(([, targets]) => targets as unknown[]); + // Scene + GTAO + bloom input/11 mips + SMAA input/3 targets. + expect(targets.length).toBeGreaterThanOrEqual(18); + expect(new Set(targets).size).toBe(targets.length); + } finally { + release?.(); + vi.unstubAllGlobals(); + } +}); + +it("keeps lazy bloom mips valid at small viewports and releases retired bindings", () => { + const controller = new ResolutionSubrect(); + let effect: BloomNode | undefined; + let stageDispose: (() => void) | undefined; + const renderer: OutputRenderer = { + kind: "webgpu", + raw: {}, + createResolutionSubrect: () => controller, + createRenderChain(options) { + const stage = options.stages?.find((entry) => entry.name === "bloom"); + if (!stage) throw new Error("Missing bloom"); + const graph = stage.build(options.input, { tier: "high" }) as Node; + graph.traverse((node) => { + if (node instanceof BloomNode) effect ??= node; + }); + stageDispose = stage.dispose; + return { + applied: { stages: ["bloom"], dropped: [] }, + dispose() { + stageDispose?.(); + }, + }; + }, + }; + const graph = new WorldEnvironment({ bloomEnabled: true, screenSpaceAA: "disabled" }).apply( + renderer, + new Scene(), + new PerspectiveCamera(), + ); + try { + if (!effect) throw new Error("Missing stock bloom"); + controller.activate(0.23); + controller.setSize(128, 64); + expect(() => + effect?.setup({ getSharedContext: () => ({}) } as unknown as NodeBuilder), + ).not.toThrow(); + const retained = effect as unknown as { + _renderTargetBright: import("three").RenderTarget; + _renderTargetsHorizontal: import("three").RenderTarget[]; + _separableBlurMaterials: { invSize: { value: import("three").Vector2 } }[]; + }; + for (const target of retained._renderTargetsHorizontal) { + expect(target.width).toBeGreaterThan(0); + expect(target.height).toBeGreaterThan(0); + } + for (const material of retained._separableBlurMaterials) + expect(material.invSize.value.toArray().every(Number.isFinite)).toBe(true); + stageDispose?.(); + expect(controller.textureView(retained._renderTargetBright.texture)).toBeUndefined(); + const resize = vi.spyOn(retained._renderTargetBright, "setSize"); + controller.setScale(0.72); + expect(resize).not.toHaveBeenCalled(); + } finally { + graph.dispose?.(); + } +}); diff --git a/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts b/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts new file mode 100644 index 000000000..fde6bf37d --- /dev/null +++ b/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts @@ -0,0 +1,657 @@ +import { + Mesh, + PerspectiveCamera, + PlaneGeometry, + type RenderTarget, + Scene, + type Texture, + Vector2, +} from "three"; +import { + type Node, + NodeFrame, + type NodeMaterial, + type NodeUniform, + RenderPipeline, + WGSLNodeBuilder, + WebGPURenderer, +} from "three/webgpu"; +import { describe, expect, it, vi } from "vitest"; +import { + type IResolutionTextureView, + ResolutionSubrect, +} from "../../core/src/render/resolution-subrect.js"; +import { type OutputRenderer, WorldEnvironment } from "../template-assets/worldEnvironment.js"; + +/** The real `WGSLNodeBuilder` output: shader proof only, never pixel proof. */ +type IBuilt = WGSLNodeBuilder & { + build(): void; + fragmentShader: string; + updateNodes: Node[]; + uniforms: { fragment: NodeUniform[] }; + bindings: Record>; +}; + +const realRenderer = (): WebGPURenderer => { + const renderer = new WebGPURenderer({ canvas: new EventTarget() as HTMLCanvasElement }); + Reflect.set(renderer.backend, "renderer", renderer); + vi.spyOn(Reflect.get(renderer.backend, "capabilities"), "getUniformBufferLimit").mockReturnValue( + 65536, + ); + vi.spyOn(renderer, "hasFeature").mockReturnValue(false); + return renderer; +}; + +const compile = ( + renderer: WebGPURenderer, + object: Mesh, + camera: PerspectiveCamera, + scene: Scene, +): IBuilt => { + const builder = new WGSLNodeBuilder(object, renderer) as IBuilt; + Reflect.set(builder, "camera", camera); + Reflect.set(builder, "scene", scene); + objects.set(builder as IBuilt, object); + builder.build(); + return builder as IBuilt; +}; + +const objects = new WeakMap(); + +const objectOf = (built: IBuilt): Mesh => { + const object = objects.get(built); + if (!object) throw new Error("Shader was not compiled from a mesh"); + return object; +}; + +const render = ( + built: IBuilt, + object: Mesh, + renderer: WebGPURenderer, + camera: PerspectiveCamera, + scene: Scene, +): void => { + const frame = new NodeFrame(); + Reflect.set(frame, "renderer", renderer); + Reflect.set(frame, "camera", camera); + Reflect.set(frame, "object", object); + Reflect.set(frame, "scene", scene); + // The first pass needs the render id the renderer has already advanced by the time a material draws. + frame.renderId = 1; + for (const node of built.updateNodes) frame.updateNode(node); +}; + +/** WGSL identifiers the real compiler gave the bindings that hold this exact texture. */ +const bindingNames = (built: IBuilt, texture: Texture): string[] => { + const names = Object.values(built.bindings.fragment ?? {}) + .flat() + .filter((binding) => binding.texture === texture) + .map((binding) => binding.name); + if (names.length === 0) throw new Error("texture is not bound in the compiled fragment stage"); + return names; +}; + +/** WGSL names of the uniforms whose live value is this exact retained view vector. */ +const namesOf = (built: IBuilt, vectors: readonly Vector2[]): string[] => { + const names = built.uniforms.fragment + .filter( + (uniform) => uniform.node.value instanceof Vector2 && vectors.includes(uniform.node.value), + ) + .map((uniform) => uniform.name); + if (names.length === 0) throw new Error("no compiled uniform carries a retained view vector"); + return names; +}; + +const at = (items: readonly T[], index: number): T => items[index] as T; + +const balanced = (source: string, open: number): string => { + let depth = 0; + for (let index = open; index < source.length; index += 1) { + if (source[index] === "(") depth += 1; + else if (source[index] === ")") { + depth -= 1; + if (depth === 0) return source.slice(open, index + 1); + } + } + throw new Error("unbalanced WGSL argument list"); +}; + +/** Every texture the compiled stage binds, by the name the compiler gave it. */ +const allBindingNames = (built: IBuilt): string[] => [ + ...new Set( + Object.values(built.bindings.fragment ?? {}) + .flat() + .filter((binding) => binding.texture !== undefined) + .map((binding) => binding.name), + ), +]; + +/** Every argument list that reads one of these exact texture bindings. */ +const reads = (wgsl: string, textureNames: string[]): string[] => + [ + ...wgsl.matchAll( + // Every WGSL way of reading a texture: sample, biased, levelled, compared, gathered, loaded. + new RegExp(`texture(?:Sample|Load|Gather)\\w*\\(\\s*(?:${textureNames.join("|")})\\b`, "g"), + ), + ].map((match) => balanced(wgsl, wgsl.indexOf("(", match.index ?? 0))); + +/** + * The uniform names a read is clamped by, or `undefined` when it is not clamped to a retained view. + */ +const retainedViewOf = ( + read: string, + fraction: string[], + half: string[], +): { fraction: string; half: string } | undefined => { + const view = new RegExp( + String.raw`^\(\s*\(.*\*\s*(?:object\.)?(${fraction.join("|")})\s*\)\s*,\s*(?:object\.)?(${half.join( + "|", + )})\s*,\s*\(\s*(?:object\.)?\1\s*-\s*(?:object\.)?\2\s*\)\s*\)$`, + "s", + ); + for (let open = read.indexOf("clamp("); open >= 0; open = read.indexOf("clamp(", open + 1)) { + const match = view.exec(balanced(read, open + "clamp".length)); + if (match) return { fraction: match[1] as string, half: match[2] as string }; + } + return undefined; +}; + +/** Uniform names whose live value is this exact vector, tolerating an absent one. */ +const maybeNamesOf = (built: IBuilt, vectors: readonly Vector2[]): string[] => + built.uniforms.fragment + .filter( + (uniform) => uniform.node.value instanceof Vector2 && vectors.includes(uniform.node.value), + ) + .map((uniform) => uniform.name); + +const vectorOf = (built: IBuilt, name: string): unknown => + built.uniforms.fragment.find((entry) => entry.name === name)?.node.value; + +/** Every texture a compiled stage material binds, by the name the compiler gave it. */ +const boundTextures = (built: IBuilt): [string, Texture][] => [ + ...new Map( + Object.values(built.bindings.fragment ?? {}) + .flat() + .filter((binding) => binding.texture !== undefined) + .map((binding) => [binding.name, binding.texture]), + ).entries(), +]; + +/** Every read of a retained texture carries the clamp pair of that texture's own view. */ +const expectOwnViewClamp = (built: IBuilt, view: IResolutionTextureView, sites: string[]): void => { + const fraction = maybeNamesOf(built, [view.viewFraction]); + const half = maybeNamesOf(built, [view.halfTexel]); + for (const read of sites) { + const pair = retainedViewOf(read, fraction, half); + expect(pair).toBeDefined(); + if (pair === undefined) continue; + // The pair is this texture's own view, not another target's. + expect(vectorOf(built, pair.fraction)).toBe(view.viewFraction); + expect(vectorOf(built, pair.half)).toBe(view.halfTexel); + } +}; + +/** + * Every read of a texture the controller owns carries the clamp pair of that texture's own view, + * and no read of a texture it does not own carries any retained pair at all. + */ +const expectOwnViewClamps = (built: IBuilt, controller: ResolutionSubrect) => { + const bound = boundTextures(built); + const guarded = { fraction: [] as string[], half: [] as string[] }; + for (const [, texture] of bound) { + const view = controller.textureView(texture); + if (!view) continue; + guarded.fraction.push(...maybeNamesOf(built, [view.viewFraction])); + guarded.half.push(...maybeNamesOf(built, [view.halfTexel])); + } + let retained = 0; + let whole = 0; + for (const [name, texture] of bound) { + const view = controller.textureView(texture); + const sites = reads(built.fragmentShader, [name]); + if (view === undefined) { + whole += sites.length; + if (guarded.fraction.length === 0) continue; + for (const read of sites) + expect(retainedViewOf(read, guarded.fraction, guarded.half)).toBeUndefined(); + continue; + } + retained += sites.length; + expectOwnViewClamp(built, view, sites); + } + return { retained, whole }; +}; + +/** The real, canonical graph of one stage, its compiled output pass, and its controller. */ +const canonical = (stageName: string, worldOptions: Record = {}) => { + vi.stubGlobal( + "Image", + class { + src = ""; + onload = () => {}; + }, + ); + const renderer = realRenderer(); + const controller = new ResolutionSubrect(); + const camera = new PerspectiveCamera(); + const scene = new Scene(); + let graph: Node | undefined; + let worldPass: + | { getTextureNode(name: string): { value: Texture }; renderTarget: object } + | undefined; + const nodes: Node[] = []; + const capture: OutputRenderer = { + kind: "webgpu", + raw: renderer, + createResolutionSubrect: () => controller, + createRenderChain(options) { + worldPass = options.worldPass as typeof worldPass; + const stage = options.stages?.find((entry) => entry.name === stageName); + if (!stage) throw new Error(`Missing the canonical ${stageName} stage`); + graph = stage.build(options.input, { tier: "high" }) as Node; + graph.traverse((node) => { + if (!nodes.includes(node)) nodes.push(node); + }); + return { applied: { stages: [stageName], dropped: [] }, dispose() {} }; + }, + }; + const world = new WorldEnvironment({ gtaoEnabled: true, denoiseEnabled: true, ...worldOptions }); + const applied = world.apply(capture, scene, camera); + controller.activate(0.72); + controller.setSize(1280, 720); + if (!graph) throw new Error(`Missing the composed ${stageName} graph`); + if (!worldPass) throw new Error("Missing the scene pass"); + const view = (texture: Texture): IResolutionTextureView => { + const found = controller.textureView(texture); + if (!found) throw new Error("A texture this stage samples carries no retained view"); + return found; + }; + // The renderer's own output-pipeline build is what gives the stage nodes their materials. + const pipeline = new RenderPipeline(renderer as never, graph as never); + Reflect.get(pipeline as object, "_update").call(pipeline); + const outputQuad = Reflect.get(pipeline as object, "_quadMesh") as Mesh; + const output = compile(renderer, outputQuad, camera, scene); + const draw = (built: IBuilt): void => render(built, objectOf(built), renderer, camera, scene); + // The first frame's per-render pass, as the renderer runs it before the material draws. + draw(output); + /** The one real node of the stage, by the internal target it owns. */ + const own = (property: string, what: string): T => { + const found = ownAll(property, what); + if (found.length !== 1) throw new Error(`Expected one ${what}, found ${found.length}`); + return found[0] as T; + }; + const ownAll = (property: string, what: string): T[] => + nodes.filter((node) => Reflect.get(node, property) !== undefined) as T[]; + /** The stage's own quad materials, compiled through the real WGSL builder. */ + const compileMaterial = (material: unknown): IBuilt => { + const built = compile( + renderer, + new Mesh(new PlaneGeometry(), material as never), + camera, + scene, + ); + draw(built); + return built; + }; + const close = (): void => { + applied.dispose?.(); + controller.dispose(); + vi.unstubAllGlobals(); + }; + return { + close, + colour: worldPass.getTextureNode("output").value, + compileMaterial, + controller, + depth: worldPass.getTextureNode("depth").value, + draw, + normal: worldPass.getTextureNode("normal").value, + output, + own, + ownAll, + view, + }; +}; + +const stage = () => { + const proof = canonical("ambientOcclusion"); + const ao = proof.own( + "_aoRenderTarget", + "real GTAO node", + ); + const denoiser = proof.own( + "_resolution", + "real denoiser", + ); + const gtao = proof.compileMaterial(Reflect.get(ao, "_material")); + const depth = proof.depth; + const normal = proof.normal; + const occlusion = ao._aoRenderTarget.texture; + const colour = proof.colour; + // Every texture the controller retains, and therefore every binding that may carry a view clamp. + const retained = [colour, depth, normal, occlusion]; + for (const texture of retained) proof.view(texture); + const unretained = (built: IBuilt): string[] => { + const kept = new Set(retained.flatMap((texture) => bindingNames(built, texture))); + return allBindingNames(built).filter((name) => !kept.has(name)); + }; + return { ...proof, ao, depth, denoiser, gtao, normal, occlusion, unretained }; +}; + +describe("canonical ambient occlusion under a retained sub-rectangle", () => { + it("clamps every GTAO input read to the retained view the controller owns", () => { + const proof = stage(); + try { + const scene = proof.view(proof.depth); + const fraction = namesOf(proof.gtao, [scene.viewFraction]); + const half = namesOf(proof.gtao, [scene.halfTexel]); + expect(fraction).not.toHaveLength(0); + const depthReads = reads(proof.gtao.fragmentShader, bindingNames(proof.gtao, proof.depth)); + const normalReads = reads(proof.gtao.fragmentShader, bindingNames(proof.gtao, proof.normal)); + expect(depthReads.length).toBeGreaterThan(0); + expect(normalReads.length).toBeGreaterThan(0); + const pairs = new Set( + [...depthReads, ...normalReads].map( + (read) => retainedViewOf(read, fraction, half)?.fraction, + ), + ); + // Every read is clamped by a live view uniform, and each sampler carries its own pair. + expect(pairs.has(undefined)).toBe(false); + // GTAO marches depth along its horizons, so every read site owns its own live pair. + expect(pairs.size).toBe(depthReads.length + normalReads.length); + expect(pairs.size).toBeGreaterThanOrEqual(4); + // The half texel is the retained capacity's, not the viewport's. + for (const name of half) { + const uniform = proof.gtao.uniforms.fragment.find((entry) => entry.name === name); + expect(uniform?.node.value).toBe(scene.halfTexel); + } + expect(scene.viewFraction.toArray()).toEqual([928 / 1280, 520 / 720]); + } finally { + proof.close(); + } + }); + + it("clamps the denoised occlusion reads to the AO target's own retained view", () => { + const proof = stage(); + try { + const view = proof.view(proof.occlusion); + const fraction = namesOf(proof.output, [view.viewFraction]); + const half = namesOf(proof.output, [view.halfTexel]); + const occlusion = bindingNames(proof.output, proof.occlusion); + const occlusionReads = reads(proof.output.fragmentShader, occlusion); + expect(occlusionReads.length).toBeGreaterThan(1); + for (const read of occlusionReads) { + expect(retainedViewOf(read, fraction, half)).toBeDefined(); + } + // The denoiser marches the AO texture in view pixels, so its resolution follows the rectangle. + expect(proof.denoiser).toBeDefined(); + proof.denoiser?.updateBefore(); + expect(proof.denoiser?._resolution.value.toArray()).toEqual([928, 520]); + // Every other sampled texture is not a retained target, so the shader reads it whole. + const unretained = proof.unretained(proof.output); + expect(unretained.length).toBeGreaterThan(0); + for (const name of unretained) { + const whole = reads(proof.output.fragmentShader, [name]); + expect(whole.length).toBeGreaterThan(0); + for (const read of whole) expect(retainedViewOf(read, fraction, half)).toBeUndefined(); + } + } finally { + proof.close(); + } + }); + + it("carries 0.72 to 0.61 into the same view uniforms without reallocating capacity", () => { + const proof = stage(); + try { + const view = proof.view(proof.occlusion); + const fraction = namesOf(proof.output, [view.viewFraction]); + const half = namesOf(proof.output, [view.halfTexel]); + const live = (names: string[]): Vector2 => { + const value = proof.output.uniforms.fragment.find((entry) => names.includes(entry.name)) + ?.node.value; + if (!(value instanceof Vector2)) throw new Error("no live view uniform holds a vector"); + return value; + }; + expect(live(fraction)).toBe(view.viewFraction); + expect(live(fraction).toArray()).toEqual([928 / 1280, 520 / 720]); + expect(live(half).toArray()).toEqual([0.5 / 1280, 0.5 / 720]); + const capacity = [proof.ao._aoRenderTarget.width, proof.ao._aoRenderTarget.height]; + const reallocations = { count: 0 }; + proof.ao._aoRenderTarget.addEventListener("dispose", () => { + reallocations.count += 1; + }); + + proof.controller.setScale(0.61); + proof.draw(proof.output); + + expect(live(fraction)).toBe(view.viewFraction); + expect(live(fraction).toArray()).toEqual([784 / 1280, 440 / 720]); + expect(live(half).toArray()).toEqual([0.5 / 1280, 0.5 / 720]); + expect([proof.ao._aoRenderTarget.width, proof.ao._aoRenderTarget.height]).toEqual(capacity); + expect(reallocations.count).toBe(0); + // The compiled shader is unchanged: the same names still carry the new rectangle. + for (const read of reads( + proof.output.fragmentShader, + bindingNames(proof.output, proof.occlusion), + )) { + expect(retainedViewOf(read, fraction, half)).toBeDefined(); + } + // An inactive controller keeps the ordinary full-texture sampling. + proof.controller.deactivate(); + proof.draw(proof.output); + expect(live(fraction).toArray()).toEqual([1, 1]); + expect(live(half).toArray()).toEqual([0, 0]); + } finally { + proof.close(); + } + }); +}); + +interface IBlurMaterial extends NodeMaterial { + colorTexture: { value: Texture | null }; + invSize: { value: Vector2 }; +} +interface IBloom extends Node { + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _separableBlurMaterials: IBlurMaterial[]; + _highPassFilterMaterial: NodeMaterial; + _compositeMaterial: NodeMaterial; +} +interface ISmaa extends Node { + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _areaTexture: Texture; + _searchTexture: Texture; + _materialEdges: NodeMaterial; + _materialWeights: NodeMaterial; + _materialBlend: NodeMaterial; +} + +describe("canonical bloom under a retained sub-rectangle", () => { + it("clamps every bloom pass read to the view of the target that pass binds", () => { + const proof = canonical("bloom"); + try { + const bloom = proof.own("_renderTargetBright", "real bloom node"); + // The pass hands each blur material its colour texture while it runs. Binding the very + // target it draws from is what lets the compiled shader name the texture it will sample. + for (const [index, material] of bloom._separableBlurMaterials.entries()) { + material.colorTexture.value = at(bloom._renderTargetsHorizontal, index).texture; + } + const passes: [string, IBuilt][] = [ + ["high pass", proof.compileMaterial(bloom._highPassFilterMaterial)], + ...bloom._separableBlurMaterials.map((material, index): [string, IBuilt] => [ + `blur ${index}`, + proof.compileMaterial(material), + ]), + ["composite", proof.compileMaterial(bloom._compositeMaterial)], + ["output", proof.output], + ]; + let retained = 0; + for (const [name, built] of passes) { + const count = expectOwnViewClamps(built, proof.controller); + expect(count.retained, `${name} retained reads`).toBeGreaterThan(0); + retained += count.retained; + } + // Five blur materials, the bright target they all trace back to, five vertical mips and + // the composite output: the whole chain is sampled inside its own views. + expect(retained).toBeGreaterThanOrEqual(10); + } finally { + proof.close(); + } + }); + + it("gives every mip its own view and a texel size that follows the view, not the capacity", () => { + const proof = canonical("bloom"); + try { + const bloom = proof.own("_renderTargetBright", "real bloom node"); + const fractions: number[] = []; + for (const [index, material] of bloom._separableBlurMaterials.entries()) { + const horizontal = at(bloom._renderTargetsHorizontal, index); + const view = proof.view(horizontal.texture); + const { width, height } = horizontal; + expect(view.viewFraction.x).toBeLessThan(1); + expect(view.viewFraction.y).toBeLessThan(1); + // The capacity is whole; the viewport is the retained rectangle inside it. + expect(horizontal.viewport.z).toBe(Math.round(view.viewFraction.x * width)); + expect(horizontal.viewport.w).toBe(Math.round(view.viewFraction.y * height)); + expect(horizontal.scissorTest).toBe(true); + // One blur texel is one viewport texel, so the kernel width is the rectangle's. + expect(material.invSize.value.x).toBeCloseTo(1 / horizontal.viewport.z, 6); + expect(material.invSize.value.y).toBeCloseTo(1 / horizontal.viewport.w, 6); + expect(view.halfTexel.toArray()).toEqual([0.5 / width, 0.5 / height]); + fractions.push(view.viewFraction.x); + } + // Every mip halves the one above it, capacity and rectangle together. + expect(new Set(fractions).size).toBe(1); + const capacities = bloom._separableBlurMaterials.map( + (_, index) => at(bloom._renderTargetsHorizontal, index).width, + ); + expect(capacities).toEqual([640, 320, 160, 80, 40]); + // The whole chain stays inside the half of the frame bloom resolves at. + expect(bloom._renderTargetBright.width).toBe(640); + expect(bloom._renderTargetBright.viewport.z).toBe(464); + } finally { + proof.close(); + } + }); + + it("moves a blur material's clamp to the new view when the pass rebinds its texture", () => { + const proof = canonical("bloom"); + try { + const bloom = proof.own("_renderTargetBright", "real bloom node"); + const material = at(bloom._separableBlurMaterials, 0); + // The uniform a read is clamped by, and the view that uniform is carrying right now. + const carried = (built: IBuilt, names: string[], view: IResolutionTextureView) => { + const sites = reads(built.fragmentShader, names); + expect(sites.length).toBeGreaterThan(0); + const pair = retainedViewOf( + sites[0] as string, + maybeNamesOf(built, [view.viewFraction]), + maybeNamesOf(built, [view.halfTexel]), + ); + expect(pair).toBeDefined(); + return pair === undefined ? undefined : vectorOf(built, pair.fraction); + }; + // The horizontal pass of mip 0 draws from the bright target... + const bright = bloom._renderTargetBright.texture; + material.colorTexture.value = bright; + const horizontal = proof.compileMaterial(material); + const textureNames = bindingNames(horizontal, bright); + const brightView = proof.view(bright); + expect(carried(horizontal, textureNames, brightView)).toBe(brightView.viewFraction); + // ...and the vertical pass of the same material draws from its own horizontal mip. One + // shader serves both, so the clamp can only follow the binding if the uniform is live. + const mip = at(bloom._renderTargetsHorizontal, 0).texture; + material.colorTexture.value = mip; + proof.draw(horizontal); + const mipView = proof.view(mip); + expect(mipView).not.toBe(brightView); + expect(carried(horizontal, textureNames, mipView)).toBe(mipView.viewFraction); + expectOwnViewClamp(horizontal, mipView, reads(horizontal.fragmentShader, textureNames)); + } finally { + proof.close(); + } + }); +}); + +describe("canonical spatial antialiasing under a retained sub-rectangle", () => { + it("clamps every SMAA pass read to its own view and reads both LUTs whole", () => { + const proof = canonical("antialias"); + try { + const smaa = proof.own("_renderTargetEdges", "real SMAA node"); + const passes: [string, IBuilt][] = [ + ["edges", proof.compileMaterial(smaa._materialEdges)], + ["weights", proof.compileMaterial(smaa._materialWeights)], + ["blend", proof.compileMaterial(smaa._materialBlend)], + ["output", proof.output], + ]; + let retained = 0; + let whole = 0; + for (const [name, built] of passes) { + const count = expectOwnViewClamps(built, proof.controller); + expect(count.retained, `${name} retained reads`).toBeGreaterThan(0); + retained += count.retained; + whole += count.whole; + } + expect(retained).toBeGreaterThanOrEqual(4); + // The area and search tables are indexed by edge geometry, not by screen position. + for (const lut of [smaa._areaTexture, smaa._searchTexture]) { + expect(proof.controller.textureView(lut)).toBeUndefined(); + } + // expectOwnViewClamps has already refused a retained clamp on every one of these reads. + for (const lut of [smaa._areaTexture, smaa._searchTexture]) { + const sites = passes.flatMap(([, built]) => + boundTextures(built) + .filter(([, texture]) => texture === lut) + .flatMap(([name]) => reads(built.fragmentShader, [name])), + ); + expect(sites.length, lut.name).toBeGreaterThan(0); + } + expect(whole).toBeGreaterThan(0); + // The three targets the pass owns each keep their own rectangle and capacity. + const views = [ + smaa._renderTargetEdges, + smaa._renderTargetWeights, + smaa._renderTargetBlend, + ].map((target) => proof.view(target.texture)); + for (const view of views) { + expect(view.viewFraction.toArray()).toEqual([928 / 1280, 520 / 720]); + expect(view.halfTexel.toArray()).toEqual([0.5 / 1280, 0.5 / 720]); + } + } finally { + proof.close(); + } + }); + + it("scales FXAA's inverse size to the retained view, because FXAA owns no target", () => { + const proof = canonical("antialias", { screenSpaceAA: "fxaa" }); + try { + const fxaa = proof.own( + "_invSize", + "real FXAA node", + ); + // FXAA samples the frame through the chain input, which the controller does retain. + expect(proof.ownAll("_renderTargetEdges", "SMAA node FXAA must not build")).toHaveLength(0); + const names = maybeNamesOf(proof.output, [fxaa._invSize.value]); + expect(names.length).toBeGreaterThan(0); + fxaa.updateBefore(); + expect(maybeNamesOf(proof.output, [fxaa._invSize.value])).toEqual(names); + const input = Reflect.get(fxaa, "textureNode") as { value: { renderTarget: RenderTarget } }; + const view = proof.view(input.value.renderTarget.texture); + expect(fxaa._invSize.value.x).toBeCloseTo( + 1 / input.value.renderTarget.width / view.viewFraction.x, + 6, + ); + expect(fxaa._invSize.value.y).toBeCloseTo( + 1 / input.value.renderTarget.height / view.viewFraction.y, + 6, + ); + expect(expectOwnViewClamps(proof.output, proof.controller).retained).toBeGreaterThan(0); + } finally { + proof.close(); + } + }); +}); diff --git a/packages/create-threenative/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); 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 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/create-threenative/templates/starter/src/render/grade.ts b/packages/create-threenative/templates/starter/src/render/grade.ts index fffe7f000..83b7dd625 100644 --- a/packages/create-threenative/templates/starter/src/render/grade.ts +++ b/packages/create-threenative/templates/starter/src/render/grade.ts @@ -117,6 +117,7 @@ export function gradeStages( return [ { name: "grade", + supportsResolutionSubrect: true, afterOutputTransform: true, available: () => refusal("grade", settings, loaded), build: (input) => { @@ -141,6 +142,7 @@ export function gradeStages( }, { name: "grain", + supportsResolutionSubrect: true, after: "grade", available: () => refusal("grain", settings, loaded), build: (input) => diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); 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 7fa978e09..c149c7e01 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts @@ -21,7 +21,10 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +36,7 @@ import { ssgi } from "three/addons/tsl/display/SSGINode.js"; import { ssr } from "three/addons/tsl/display/SSRNode.js"; import { sharpen } from "three/addons/tsl/display/SharpenNode.js"; import { + clamp, color, convertToTexture, float, @@ -43,12 +47,13 @@ import { output, pass, roughness, - screenUV, smoothstep, + uniform, + uv, vec2, vec4, } from "three/tsl"; -import { BlendMode, type Node } from "three/webgpu"; +import { BlendMode, type Node, type NodeBuilder, type TextureNode } from "three/webgpu"; import { AutoExposureNode, applyExposure } from "./autoExposure.js"; import { type IExposureSettings, exposureSettings } from "./exposure.js"; @@ -276,6 +281,10 @@ export interface IWorldEnvironmentTarget { * compiler checks the composition instead of taking a cast's word for it. */ type ChainNode = Node<"vec4">; +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; /** * The scene pass's depth texture, as the pass itself types it. * @@ -286,6 +295,7 @@ export type DepthTextureNode = ReturnType["getTextureNod type ChainTier = "high" | "medium" | "low" | "off"; type ChainContext = { readonly tier: ChainTier; + readonly resolutionSubrect?: ResolutionSubrectView; /** * The sampled velocity texture, present only when this chain request provisioned one. * @@ -308,6 +318,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +355,7 @@ export type ChainStage = { */ function stage(definition: { readonly name: ChainStage["name"]; + readonly supportsResolutionSubrect?: boolean; readonly dispose?: () => void; readonly available?: (context: ChainContext) => boolean | string; readonly build: (input: ChainNode, context: ChainContext) => ChainNode; @@ -365,13 +377,82 @@ function denoised(node: ReturnType): ChainNode { return node as unknown as ChainNode; } type OutputInstallation = { isCurrent(): boolean; dispose(): void }; +type ResolutionSubrectTarget = { + setSize(width: number, height: number, depth?: number): void; +}; +type ResolutionSubrectController = { + readonly active?: boolean; + readonly view?: ResolutionSubrectView; + retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; + release(node: ResolutionSubrectTarget): void; + textureView(texture: Texture): ResolutionSubrectView | undefined; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); +type SampledTexture = TextureNode & { + setupUV(builder: NodeBuilder, coordinates: Node<"vec2">): Node<"vec2">; +}; + +/** Cloned neighbourhood samplers retain the same per-texture viewport clamp. */ +function bindSubrectSampler( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (bound.has(node)) return; + bound.add(node); + const sampled = node as SampledTexture; + const full = new Vector2(1, 1); + const zero = new Vector2(); + const fraction = uniform(full).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.viewFraction ?? full) : full, + ); + const half = uniform(zero).onRenderUpdate(() => + view.active ? (view.textureView(node.value)?.halfTexel ?? zero) : zero, + ); + const setup = sampled.setupUV; + sampled.uvNode ??= uv(); + sampled.setupUV = function (builder, coordinates) { + return setup.call(this, builder, coordinates.mul(fraction).clamp(half, fraction.sub(half))); + }; + const clone = sampled.clone; + sampled.clone = function () { + const next = clone.call(this); + bindSubrectSampler(next, view, bound); + return next; + }; +} + +function bindSubrectTexture( + node: TextureNode, + view: ResolutionSubrectController, + bound: WeakSet, +): void { + if (Reflect.get(node, "isRTTNode") === true && !bound.has(node)) { + const rtt = node as TextureNode & { + width: number | null; + setSize(w: number, h: number): void; + renderTarget: RenderTarget; + updateBefore(frame: unknown): void; + }; + view.retain(rtt, [rtt.renderTarget]); + const update = rtt.updateBefore; + rtt.updateBefore = function (frame) { + // Stock RTT bypasses setSize during auto-resize; the controller owns it while active. + this.width = view.active ? 1 : null; + update.call(this, frame); + }; + } + bindSubrectSampler(node, view, bound); +} + export type OutputRenderer = { kind: string; raw: unknown; + createResolutionSubrect?: () => ResolutionSubrectController; /** * Installs a node graph directly. Only reached when a `baseColour` is composed and every * stage is off: the chain installs nothing for an empty stage list, and dropping the game's @@ -397,6 +478,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +616,28 @@ export class WorldEnvironment { this.#reportApplied([], dropped); return { dropped, stages: [] }; } + const resolutionSubrectRefusal = + target.baseColour !== undefined + ? { name: "baseColour", reason: "custom base colour sampling is not supported" } + : options.autoExposureEnabled + ? { name: "autoExposure", reason: "automatic exposure sampling is not supported" } + : undefined; + if (resolutionSubrectRefusal !== undefined) + console.info( + `TN_RESOLUTION_SUBRECT ${JSON.stringify({ applied: [], refused: [resolutionSubrectRefusal] })}`, + ); raw.toneMappingExposure = 1; const scenePass = pass(scene, camera, { samples: options.samples }); + const resolutionSubrect = + requested.length === 0 || resolutionSubrectRefusal !== undefined + ? undefined + : renderer.createResolutionSubrect?.(); + resolutionSubrect?.retain(scenePass, [scenePass.renderTarget]); + const boundSamplers = new WeakSet(); + const sample = (node: TextureNode): void => { + if (resolutionSubrect) bindSubrectTexture(node, resolutionSubrect, boundSamplers); + }; // Normals come from the multi-render-target alongside the colour. SSGI can derive them // from depth alone, but the derived normal is flat across a triangle, which shows up as // faceted bounce on exactly the smooth surfaces GI is bought for. Metalness and @@ -560,6 +661,7 @@ export class WorldEnvironment { const cached = nodes.get(name); if (cached !== undefined) return cached; const node = scenePass.getTextureNode(name); + sample(node); nodes.set(name, node); return node; }; @@ -573,6 +675,7 @@ export class WorldEnvironment { const base = target.baseColour?.(scenePass) ?? scenePass.getTextureNode("output"); const colour = convertToTexture(base); + sample(colour); const exposure = options.autoExposureEnabled ? new AutoExposureNode(colour, options.exposurePolicy, options.exposure) : undefined; @@ -595,12 +698,20 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const subrectTexture = (input: ChainNode): ReturnType => { + const texture = convertToTexture(input); + sample(texture); + return texture; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; let releaseBloom = (): void => {}; + let releaseAo = (): void => {}; + let releaseAa = (): void => {}; const stages: ChainStage[] = [ stage({ name: "ssgi", @@ -649,12 +760,27 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", + supportsResolutionSubrect: true, + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, build: (input) => { - const contact = ao(depth(), normal(), view); + const contact = ao(subrectTexture(depth()), subrectTexture(normal()), view); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; contact.resolutionScale = options.gtaoResolutionScale; + const retained = contact as unknown as { + setSize(w: number, h: number): void; + _aoRenderTarget: RenderTarget; + }; + resolutionSubrect?.retain(retained, [retained._aoRenderTarget]); + sample(contact.getTextureNode()); + releaseAo = () => { + resolutionSubrect?.release(retained); + contact.dispose(); + }; // `RedFormat`, like SSGI's AO target: `.r` is the whole occlusion term, and // multiplying by the vec4 would zero green and blue and render the scene red. // Applied before SSGI by the chain's canonical order; composing it after the GI @@ -664,9 +790,25 @@ export class WorldEnvironment { // Denoised with the same depth-aware filter as the GI gather: GTAO's interleaved // sampling otherwise reads as a speckled halo around every contact — around feet on a // plain floor most of all — which is exactly the "not smooth" a player notices first. - const occlusion = options.denoiseEnabled - ? denoised(denoise(contact.getTextureNode(), depth(), normal(), view)) - : contact; + const filter = options.denoiseEnabled + ? denoise(contact.getTextureNode(), depth(), normal(), view) + : undefined; + if (filter && resolutionSubrect) { + const denoiser = filter as unknown as { + textureNode: TextureNode; + _resolution: { value: Vector2 }; + updateBefore(): void; + }; + const update = denoiser.updateBefore; + denoiser.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(this.textureNode.value)?.viewFraction + : undefined; + if (fraction) this._resolution.value.multiply(fraction); + }; + } + const occlusion = filter ? denoised(filter) : contact; return input.mul(occlusion.r); }, }), @@ -753,19 +895,83 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, build: (input) => { - const texture = convertToTexture(input); + const texture = subrectTexture(input); + const colour = input; const effect = bloom( texture, options.bloomStrength, options.bloomRadius, options.bloomThreshold, ); + if (resolutionSubrect) { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + setup(builder: NodeBuilder): unknown; + getResolutionScale(): number; + _separableBlurMaterials: { invSize: { value: Vector2 } }[]; + _renderTargetBright: RenderTarget; + _renderTargetsHorizontal: RenderTarget[]; + _renderTargetsVertical: RenderTarget[]; + _textureOutput: TextureNode; + _textureNodeBright: TextureNode; + _textureNodeBlur0: TextureNode; + _textureNodeBlur1: TextureNode; + _textureNodeBlur2: TextureNode; + _textureNodeBlur3: TextureNode; + _textureNodeBlur4: TextureNode; + _getSeparableBlurMaterial( + builder: NodeBuilder, + radius: number, + ): { colorTexture: TextureNode }; + }; + // Stock bloom floors its last mip to zero on small views; clamp both target and texel size. + retained.setSize = function (width, height) { + let x = Math.max(1, Math.floor(width * this.getResolutionScale())); + let y = Math.max(1, Math.floor(height * this.getResolutionScale())); + this._renderTargetBright.setSize(x, y); + for (const [index, horizontal] of this._renderTargetsHorizontal.entries()) { + horizontal.setSize(x, y); + this._renderTargetsVertical[index]?.setSize(x, y); + this._separableBlurMaterials[index]?.invSize.value.set(1 / x, 1 / y); + x = Math.max(1, Math.floor(x / 2)); + y = Math.max(1, Math.floor(y / 2)); + } + }; + const setup = retained.setup; + retained.setup = function (builder) { + const result = setup.call(this, builder); + resolutionSubrect.retain(retained, [ + retained._renderTargetBright, + ...retained._renderTargetsHorizontal, + ...retained._renderTargetsVertical, + ]); + return result; + }; + for (const node of [ + retained._textureNodeBright, + retained._textureNodeBlur0, + retained._textureNodeBlur1, + retained._textureNodeBlur2, + retained._textureNodeBlur3, + retained._textureNodeBlur4, + retained._textureOutput, + ]) + sample(node); + const material = retained._getSeparableBlurMaterial; + retained._getSeparableBlurMaterial = function (builder, radius) { + const result = material.call(this, builder, radius); + sample(result.colorTexture); + return result; + }; + } releaseBloom = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); effect.dispose(); // convertToTexture may return its input. Only the newly allocated RTT is ours. const scratch = texture as unknown as { @@ -774,23 +980,26 @@ export class WorldEnvironment { _quadMesh: { material: { dispose(): void } }; }; if (texture !== input && scratch.isRTTNode === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); scratch.renderTarget.dispose(); scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", + supportsResolutionSubrect: true, build: (input) => { + const colour = input; // Radius measured from the centre in screen-diagonal units, so the corner is 1 // and the falloff does not change shape when the window does. It starts at 0.55 — // well outside the subject in a 16:9 frame — so the vignette is a corner, not a // spotlight. - const radius = screenUV.sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); + const radius = uv().sub(vec2(0.5, 0.5)).mul(vec2(1.78, 1)).length().mul(1.04); const fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,6 +1007,11 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", + supportsResolutionSubrect: true, + dispose: () => { + releaseAa(); + releaseAa = () => {}; + }, build: (input) => { const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is @@ -806,9 +1020,58 @@ export class WorldEnvironment { // for the filter and expands it back afterwards, so edges resolve without the halo. const peak = max(colour.r, max(colour.g, colour.b)); const squeezed = colour.div(peak.add(1)); - const filtered = (options.screenSpaceAA === "fxaa" - ? fxaa(squeezed) - : smaa(squeezed)) as unknown as ChainNode; + const texture = convertToTexture(squeezed); + sample(texture); + const effect = options.screenSpaceAA === "fxaa" ? fxaa(texture) : smaa(texture); + if (resolutionSubrect && options.screenSpaceAA !== "fxaa") { + const retained = effect as unknown as { + setSize(w: number, h: number): void; + _renderTargetEdges: RenderTarget; + _renderTargetWeights: RenderTarget; + _renderTargetBlend: RenderTarget; + _edgesTextureUniform: TextureNode; + _weightsTextureUniform: TextureNode; + _textureNode: TextureNode; + }; + resolutionSubrect.retain(retained, [ + retained._renderTargetEdges, + retained._renderTargetWeights, + retained._renderTargetBlend, + ]); + for (const node of [ + retained._edgesTextureUniform, + retained._weightsTextureUniform, + retained._textureNode, + ]) + sample(node); + } else if (resolutionSubrect) { + const filter = effect as unknown as { + updateBefore(): void; + _invSize: { value: Vector2 }; + }; + const update = filter.updateBefore; + filter.updateBefore = function () { + update.call(this); + const fraction = resolutionSubrect.active + ? resolutionSubrect.textureView(texture.value)?.viewFraction + : undefined; + if (fraction) this._invSize.value.divide(fraction); + }; + } + releaseAa = () => { + resolutionSubrect?.release(effect as unknown as ResolutionSubrectTarget); + effect.dispose(); + if (Reflect.get(texture, "isRTTNode") === true) { + resolutionSubrect?.release(texture as unknown as ResolutionSubrectTarget); + const owned = texture as unknown as { + renderTarget: RenderTarget; + _quadMesh: { material: { dispose(): void } }; + }; + owned.renderTarget.dispose(); + owned._quadMesh.material.dispose(); + } + }; + const filtered = effect as unknown as ChainNode; const back = max(filtered.r, max(filtered.g, filtered.b)); return filtered.div(float(1).sub(back).max(1e-4)); }, @@ -880,6 +1143,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/playtest/__tests__/browser-input.spec.ts b/packages/playtest/__tests__/browser-input.spec.ts index b284b5401..8a780519f 100644 --- a/packages/playtest/__tests__/browser-input.spec.ts +++ b/packages/playtest/__tests__/browser-input.spec.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; import { expect, test, vi } from "vitest"; import { @@ -51,6 +54,21 @@ test("performance probes can bypass the virtual display compositor cadence", () ]); }); +test("browser module loads without Node process and keeps software adapter detection", () => { + const source = readFileSync(new URL("../src/runner/browser.ts", import.meta.url), "utf8"); + const module = { exports: {} as Record }; + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + + runInNewContext(compiled, { exports: module.exports, module }); + + const softwareAdapterName = module.exports.softwareAdapterName as ( + adapter: Readonly> | undefined, + ) => string | undefined; + expect(softwareAdapterName({ architecture: "swiftshader" })).toBe("swiftshader"); +}); + test("the host-display webgpu recipe selects the Vulkan hardware adapter", async () => { vi.stubEnv("TN_PLAYTEST_HOST_DISPLAY", "1"); vi.resetModules(); diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts new file mode 100644 index 000000000..169b7cd5c --- /dev/null +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -0,0 +1,160 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PLAYTEST_STARTUP_READY_TIMEOUT_MS } from "../src/protocol.js"; + +const fixture = vi.hoisted(() => { + const server = { + close: vi.fn(), + listen: vi.fn(), + resolvedUrls: { local: ["http://127.0.0.1:4173"] }, + }; + const display = { + env: {}, + release: vi.fn(), + strategy: { kind: "existing" as const, display: ":0" }, + }; + return { + browserLaunch: vi.fn(), + captureLease: { release: vi.fn() }, + acquireCaptureLease: vi.fn(), + createProject: vi.fn(), + createServer: vi.fn(), + display, + fs: { + mkdir: vi.fn(), + symlink: vi.fn(), + writeFile: vi.fn(), + }, + provideDisplay: vi.fn(), + server, + }; +}); + +vi.mock("node:fs/promises", () => fixture.fs); +vi.mock("node:module", () => ({ + createRequire: () => ({ + resolve: (specifier: string) => specifier, + }), +})); +vi.mock("playwright", () => ({ chromium: { launch: fixture.browserLaunch } })); +vi.mock("vite", () => ({ createServer: fixture.createServer })); +vi.mock("../src/runner/captureEnvironment.js", () => ({ provideDisplay: fixture.provideDisplay })); +vi.mock("../src/runner/runner-support.js", () => ({ acquireRunnerCaptureLock: fixture.acquireCaptureLease })); +vi.mock("../../create-threenative/src/index.ts", () => ({ createProject: fixture.createProject })); +vi.mock("../src/runner/bridgeClient.js", () => ({ + PlaywrightTransport: class { + async waitForBridge() { return true; } + async call() { return {}; } + }, + bridgeWaitTimeoutMs: () => 20_000, +})); +vi.mock("../src/runner/startupReady.js", () => ({ waitForStartupReady: async () => ({ phase: "ready" }) })); + +const importMeasurement = () => { + vi.resetModules(); + return import("../src/runner/measure-resolution-scale.js"); +}; + +beforeEach(() => { + vi.resetModules(); + fixture.browserLaunch.mockReset(); + fixture.captureLease.release.mockReset().mockResolvedValue(undefined); + fixture.acquireCaptureLease.mockReset().mockResolvedValue(fixture.captureLease); + fixture.createProject.mockReset().mockResolvedValue(undefined); + fixture.createServer.mockReset().mockResolvedValue(fixture.server); + fixture.display.release.mockReset().mockResolvedValue(undefined); + fixture.fs.mkdir.mockReset().mockResolvedValue(undefined); + fixture.fs.symlink.mockReset().mockResolvedValue(undefined); + fixture.fs.writeFile.mockReset().mockResolvedValue(undefined); + fixture.provideDisplay.mockReset().mockResolvedValue(fixture.display); + fixture.server.close.mockReset().mockResolvedValue(undefined); + fixture.server.listen.mockReset().mockResolvedValue(undefined); +}); + +test("startup failures close acquired resources and preserve the originating error", async () => { + const displayError = new Error("TN_PLAYTEST_HOST_DISPLAY_UNAVAILABLE: xdpyinfo failed"); + fixture.provideDisplay.mockRejectedValue(displayError); + await expect(importMeasurement()).rejects.toBe(displayError); + expect(fixture.server.close).toHaveBeenCalledOnce(); + expect(fixture.browserLaunch).not.toHaveBeenCalled(); + expect(fixture.acquireCaptureLease).toHaveBeenCalledOnce(); + expect(fixture.captureLease.release).toHaveBeenCalledOnce(); + + const browserError = new Error("browser launch failed"); + fixture.server.close.mockClear(); + fixture.provideDisplay.mockResolvedValue(fixture.display); + fixture.browserLaunch.mockRejectedValue(browserError); + await expect(importMeasurement()).rejects.toBe(browserError); + expect(fixture.display.release).toHaveBeenCalledOnce(); + expect(fixture.server.close).toHaveBeenCalledOnce(); + + const listenError = new Error("server listen failed"); + fixture.server.close.mockClear(); + fixture.provideDisplay.mockClear(); + fixture.server.listen.mockRejectedValue(listenError); + await expect(importMeasurement()).rejects.toBe(listenError); + expect(fixture.server.close).toHaveBeenCalledOnce(); + expect(fixture.provideDisplay).not.toHaveBeenCalled(); +}); + +test("capture acquisition failure closes the server before opening a display", async () => { + const error = new Error("capture lease timeout"); + fixture.acquireCaptureLease.mockRejectedValue(error); + await expect(importMeasurement()).rejects.toBe(error); + expect(fixture.server.close).toHaveBeenCalledOnce(); + expect(fixture.provideDisplay).not.toHaveBeenCalled(); + expect(fixture.captureLease.release).not.toHaveBeenCalled(); +}); + +test("capture lease is released even when display cleanup rejects", async () => { + const error = new Error("display cleanup failed"); + fixture.browserLaunch.mockRejectedValue(new Error("browser launch failed")); + fixture.display.release.mockRejectedValue(error); + await expect(importMeasurement()).rejects.toBe(error); + expect(fixture.captureLease.release).toHaveBeenCalledOnce(); +}); + +test("cold navigation uses the existing startup readiness budget and releases resources on failure", async () => { + const error = new Error("navigation failed"); + const goto = vi.fn().mockRejectedValue(error); + const context = {newPage: vi.fn().mockResolvedValue({on: vi.fn(), goto}), close: vi.fn()}; + const browser = {newContext: vi.fn().mockResolvedValue(context), close: vi.fn()}; + fixture.browserLaunch.mockResolvedValue(browser); + await expect(importMeasurement()).rejects.toBe(error); + expect(goto).toHaveBeenCalledWith("http://127.0.0.1:4173", {waitUntil: "domcontentloaded", timeout: PLAYTEST_STARTUP_READY_TIMEOUT_MS}); + expect(context.close).toHaveBeenCalledOnce(); + expect(browser.close).toHaveBeenCalledOnce(); + expect(fixture.captureLease.release).toHaveBeenCalledOnce(); +}); + +test.each(["missing completion", "stalled stop", "completed"])("trace flush %s releases resources and clears its watchdog", async (mode) => { + vi.useFakeTimers(); + const cdp = Object.assign(new EventEmitter(), { + send: vi.fn(async (method: string) => { + if (method !== "Tracing.end") return; + if (mode === "stalled stop") await new Promise(() => {}); + if (mode === "completed") cdp.emit("Tracing.tracingComplete"); + }), + detach: vi.fn().mockResolvedValue(undefined), + }); + const page = { + on: vi.fn(), goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ partial: {}, failure: { name: "Error", message: "observed failure" } }), + }; + const context = { newPage: vi.fn().mockResolvedValue(page), newCDPSession: vi.fn().mockResolvedValue(cdp), close: vi.fn() }; + const browser = { newContext: vi.fn().mockResolvedValue(context), close: vi.fn() }; + fixture.browserLaunch.mockResolvedValue(browser); + try { + const result = expect(importMeasurement()).rejects.toThrow(mode === "completed" ? "observed failure" : "TN_RESOLUTION_TRACE_FLUSH_TIMEOUT"); + await vi.waitFor(() => expect(cdp.send).toHaveBeenCalledWith("Tracing.end")); + if (mode !== "completed") await vi.advanceTimersByTimeAsync(20_000); + await result; + expect(cdp.detach).toHaveBeenCalledOnce(); + expect(context.close).toHaveBeenCalledOnce(); + expect(browser.close).toHaveBeenCalledOnce(); + expect(fixture.captureLease.release).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts new file mode 100644 index 000000000..be64bc76d --- /dev/null +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -0,0 +1,419 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { tsImport } from "tsx/esm/api"; +import type { Page } from "playwright"; +import { expect, it, vi } from "vitest"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; +import { + textureDescriptorBytes, + installResolutionScaleProbe, + assertResolutionScaleContext, + assertResolutionScalePreflight, + qualifyResolutionScaleBudget, + resolutionScaleTextureBytes, + preserveResolutionScaleFailure, + resolutionScaleGate, +} from "../src/runner/resolution-scale-probe.js"; + +const hardwareCensus = { + complete: true, + backend: { kind: "webgpu" as const, identity: "WebGPUBackend" }, + adapter: { identity: "webgpu:architecture=turing|vendor=nvidia", thermal: "unavailable" }, +}; + +it("reads hardware identity from the installed census contract", () => { + expect(assertResolutionScalePreflight("webgpu", { resolutionScale: 1 }, hardwareCensus)).toBe( + hardwareCensus.adapter.identity, + ); +}); + +it.each([ + [undefined, "TN_RESOLUTION_CENSUS_INCOMPLETE"], + [{ ...hardwareCensus, complete: false }, "TN_RESOLUTION_CENSUS_INCOMPLETE"], + [ + { ...hardwareCensus, adapter: { ...hardwareCensus.adapter, identity: "unavailable" } }, + "TN_RESOLUTION_ADAPTER_MISSING", + ], + [ + { ...hardwareCensus, adapter: { ...hardwareCensus.adapter, identity: "webgpu:vendor=%20" } }, + "TN_RESOLUTION_ADAPTER_MISSING", + ], + [ + { + ...hardwareCensus, + adapter: { + ...hardwareCensus.adapter, + identity: "webgpu:device=Microsoft%20Basic%20Render%20Driver", + }, + }, + "TN_RESOLUTION_SOFTWARE_ADAPTER", + ], +])("refuses unqualified adapter/census %j", (census, error) => { + expect(() => assertResolutionScalePreflight("webgpu", { resolutionScale: 1 }, census)).toThrow( + error, + ); +}); + +it("requires the unchanged WebGPU surface before measurement", () => { + expect(() => + assertResolutionScalePreflight("webgl2", { resolutionScale: 1 }, hardwareCensus), + ).toThrow("TN_RESOLUTION_WEBGPU_REQUIRED"); + expect(() => + assertResolutionScalePreflight( + "webgpu", + { resolutionScale: 0.85, compiling: false }, + hardwareCensus, + ), + ).toThrow("TN_RESOLUTION_SURFACE_NOT_READY"); + expect(() => + assertResolutionScalePreflight( + "webgpu", + { resolutionScale: 1, compiling: true }, + hardwareCensus, + ), + ).toThrow("TN_RESOLUTION_SURFACE_NOT_READY"); +}); + +it("qualifies the actual frame-budget receiver and restores its method", async () => { + const prototype = { beginFrame: vi.fn() }; + const begin = prototype.beginFrame; + const budget = Object.create(prototype); + await qualifyResolutionScaleBudget(prototype, async () => budget.beginFrame(1, 2)); + expect(prototype.beginFrame).toBe(begin); + expect(begin).toHaveBeenCalledWith(1, 2); + await expect(qualifyResolutionScaleBudget(prototype, async () => {})).rejects.toThrow( + "TN_RESOLUTION_FRAME_BUDGET_IDENTITY", + ); + await expect( + qualifyResolutionScaleBudget(prototype, async () => prototype.beginFrame.call({}, 1, 2)), + ).rejects.toThrow("TN_RESOLUTION_FRAME_BUDGET_IDENTITY"); + await expect( + qualifyResolutionScaleBudget(prototype, async () => { + throw new Error("pump failed"); + }), + ).rejects.toThrow("pump failed"); + expect(prototype.beginFrame).toBe(begin); +}); + +it("binds byte accounting to the running renderer's texture utility", () => { + const utils = { + bytes: 4, + _getBytesPerTexel(format: string) { + if (format !== "rgba8unorm") throw new Error("format"); + return this.bytes; + }, + }; + const bytesPerTexel = resolutionScaleTextureBytes(utils); + expect( + textureDescriptorBytes({ size: [2, 3], format: "rgba8unorm" }, bytesPerTexel("rgba8unorm")), + ).toBe(24); + expect(() => resolutionScaleTextureBytes(undefined)).toThrow( + "TN_RESOLUTION_TEXTURE_UTILS_MISSING", + ); +}); + +it("refuses a missing running context", () => { + expect(() => assertResolutionScaleContext(undefined, {})).toThrow( + "TN_RESOLUTION_CONTEXT_MISSING", + ); +}); + +it("refuses a context whose canvas differs from the mounted canvas", () => { + const ctx = { renderer: { domElement: {} }, startup: { phase: "ready" } }; + expect(() => assertResolutionScaleContext(ctx, {})).toThrow("TN_RESOLUTION_CONTEXT_IDENTITY"); +}); + +it("refuses a context that has not reached readiness", () => { + const canvas = {}; + const ctx = { renderer: { domElement: canvas }, startup: { phase: "compiling" } }; + expect(() => assertResolutionScaleContext(ctx, canvas)).toThrow("TN_PLAYTEST_STARTUP_NOT_READY"); +}); + +it("returns the same ready context when its canvas is mounted", () => { + const canvas = {}; + const ctx = { renderer: { domElement: canvas }, startup: { phase: "ready" } }; + expect(assertResolutionScaleContext(ctx, canvas)).toBe(ctx); +}); + +it("counts mip/layer/sample payloads and refuses an unmeasured format", () => { + expect(textureDescriptorBytes({ size: [8, 4, 2], mipLevelCount: 3, sampleCount: 4 }, 4)).toBe( + 1344, + ); + expect(textureDescriptorBytes({ size: [8, 4, 2], dimension: "3d", mipLevelCount: 3 }, 4)).toBe( + 296, + ); + expect(() => textureDescriptorBytes({ size: [8, 4], format: "unknown" }, undefined!)).toThrow( + "Unmeasured", + ); + expect(() => textureDescriptorBytes({ size: [0, 4] }, 4)).toThrow("dimensions"); +}); + +it("captures exactly step +30 frames for each scale, counts allocations/builds, restores probes", async () => { + const log = vi.spyOn(console, "info").mockImplementation(() => {}); + let insideFrame = false; + const censusInsideFrames: boolean[] = []; + const budget = { + beginFrame(_timestamp: number, _clock: number) { insideFrame = true; }, + endFrame(_now: number, _want: boolean) { + insideFrame = false; + return true; + }, + }; + const device = { + createTexture(_descriptor: unknown) { + return {}; + }, + }; + const begin = budget.beginFrame; + const end = budget.endFrame; + const create = device.createTexture; + let scale = 1; + let builds = 3; + const renderer = { + surface: () => ({ resolutionScale: scale }), + pipelineCensus: () => { + censusInsideFrames.push(insideFrame); + return {complete: true, counts: { deviceCreations: builds, failures: 0, pending: 0, droppedEvents: 0 }}; + }, + setResolutionScale(next: number) { + scale = next; + builds++; + device.createTexture({ size: [4, 4], format: "rgba8unorm" }); + }, + }; + const marks = vi.spyOn(performance, "mark").mockImplementation((name, options) => { + expect(insideFrame).toBe(false); + expect(name).toMatch(/^TN_RESOLUTION_FRAME:\d+$/); + expect(options?.startTime).toBeTypeOf("number"); + return {} as PerformanceMark; + }); + const result = installResolutionScaleProbe(budget, renderer, device, () => 4); + for (let frame = 0; frame < 213; frame++) { + if (frame === 151) builds++; // A pipeline created between windows must remain counted. + budget.beginFrame(frame * 16, frame * 16); + budget.endFrame(frame * 16 + (frame === 120 ? 12 : 3), true); + } + const rows = await result; + expect( + rows.map((row) => [row.scale, row.frames.length, row.textureBytes, row.pipelineBuilds]), + ).toEqual([ + [0.85, 31, 64, 1], + [0.61, 31, 64, 2], + [1, 31, 64, 1], + ]); + expect(rows.map((row) => row.stepCensusAfter.counts.deviceCreations)).toEqual([4, 6, 7]); + expect(rows[0]!.stepRatio).toBe(4); + const first = rows[0]!.frames[0]!; + expect(first.clockEpochEndMs! - first.clockEpochStartMs!).toBeCloseTo(first.frameMs, 2); + expect(censusInsideFrames.every((inside) => !inside)).toBe(true); + expect(marks).toHaveBeenCalledTimes(93); + rows + .flatMap((row) => row.frames) + .forEach((frame) => { + const calibration = frame.clockCalibration!; + expect(calibration.marker).toBe(`TN_RESOLUTION_FRAME:${frame.frame}`); + expect(calibration.performanceBeforeMs).toBeLessThanOrEqual(calibration.performanceAfterMs); + expect(calibration.wallEpochMs).toBeGreaterThan(0); + expect(marks).toHaveBeenCalledWith(calibration.marker, { + startTime: calibration.performanceAfterMs, + }); + }); + marks.mockRestore(); + expect([budget.beginFrame, budget.endFrame, device.createTexture]).toEqual([begin, end, create]); + log.mockRestore(); +}); + +it("rejects missing marker instrumentation and restores the measurement seams", async () => { + const budget = { + beginFrame: vi.fn((_now: number, _clock: number) => {}), + endFrame: vi.fn((_now: number, _want: boolean) => {}), + }; + const device = { createTexture: vi.fn() }; + const originals = [budget.beginFrame, budget.endFrame, device.createTexture]; + const renderer = { + surface: () => ({ resolutionScale: 0.85 }), + setResolutionScale: vi.fn(), + pipelineCensus: () => ({ + complete: true, + counts: { deviceCreations: 0, failures: 0, pending: 0, droppedEvents: 0 }, + }), + }; + const marks = vi.spyOn(performance, "mark").mockImplementation(() => { + throw new Error("marker unavailable"); + }); + const result = installResolutionScaleProbe(budget, renderer, device, () => 4); + for (let frame = 0; frame < 120; frame++) { + budget.beginFrame(frame, frame); + budget.endFrame(frame + 1, true); + } + // The first measured frame requires a ready scale-1 surface before the forced step. + renderer.surface = () => ({ + resolutionScale: renderer.setResolutionScale.mock.calls.length ? 0.85 : 1, + }); + budget.beginFrame(120, 120); + budget.endFrame(121, true); + await expect(result).rejects.toThrow("marker unavailable"); + expect([budget.beginFrame, budget.endFrame, device.createTexture]).toEqual(originals); + marks.mockRestore(); +}); + +it("startup frame survives actual tsx serialization without browser helpers", async () => { + const corrected = await tsImport("../src/runner/resolution-scale-probe.ts", import.meta.url); + let frames = 0; + const browser = { + requestAnimationFrame: (callback: () => void) => { + frames += 1; + callback(); + }, + }; + const page = { + evaluate: (script: string | ((arg: unknown) => unknown), arg?: unknown) => + Promise.resolve().then(() => + runInNewContext( + typeof script === "string" ? script : `(${String(script)})(${JSON.stringify(arg)})`, + browser, + ), + ), + } as Pick; + await corrected.resolutionScaleFrame(page); + expect(frames).toBe(1); + expect(Object.hasOwn(browser, "__name")).toBe(false); +}); + +it.each([null, "no", 0])("rejects malformed compile observation %j", (compiling) => { + expect(() => + Reflect.apply(assertResolutionScalePreflight, undefined, [ + "webgpu", + { resolutionScale: 1, compiling }, + hardwareCensus, + ]), + ).toThrow("TN_RESOLUTION_SURFACE_MALFORMED"); +}); +it("accepts an explicit idle compile observation", () => { + expect( + assertResolutionScalePreflight( + "webgpu", + { resolutionScale: 1, compiling: false }, + hardwareCensus, + ), + ).toBe(hardwareCensus.adapter.identity); +}); +it("persists partial failure observations before rethrowing the identical failure", async () => { + const error = new Error("TN_RESOLUTION_SURFACE_NOT_READY"); + const partial = { startup: { phase: "ready" }, surface: { resolutionScale: 1, compiling: true } }; + let persisted: unknown; + const result = preserveResolutionScaleFailure(error, async () => { + persisted = structuredClone(partial); + }); + await expect(result).rejects.toBe(error); + expect(persisted).toEqual(partial); + expect(Object.hasOwn(persisted as object, "adapter")).toBe(false); +}); + +function gateRuns() { + const census = (deviceCreations: number) => ({ + complete: true, + counts: { deviceCreations, failures: 0, pending: 0, droppedEvents: 0 }, + }); + return [1, 2, 3].map((run) => ({ + run, + rows: [0.85, 0.61, 1].map((scale, step) => ({ + scale, + frames: Array.from({ length: 31 }, (_, index) => ({ + frame: step * 31 + index, + frameMs: index === 0 ? 2 : 1, + rafIntervalMs: 1000, + })), + allocations: [], + textureBytes: 0, + pipelineBuilds: 0, + windowP50Ms: 1, + stepRatio: 2, + censusBefore: census(10), + stepCensusAfter: census(10), + censusAfter: census(10), + })), + })); +} +it.each([ + [2, true, "decline"], + [2.001, true, "continue"], + [3, false, "unqualified"], +])("uses strict CPU spike boundary %s with timing qualified %s", (cpu, qualified, verdict) => { + const runs = gateRuns(); + runs[0]!.rows[0]!.frames[0]!.frameMs = cpu; + expect(resolutionScaleGate(runs, qualified).verdict).toBe(verdict); +}); +it("earns only first-step-frame pipeline builds under loaded timing", () => { + const later = gateRuns(); + later[0]!.rows[0]!.censusAfter.counts.deviceCreations = 11; + later[0]!.rows[0]!.pipelineBuilds = 1; + expect(resolutionScaleGate(later, false).verdict).toBe("unqualified"); + expect(resolutionScaleGate(later, true).verdict).toBe("decline"); + later[0]!.rows[0]!.stepCensusAfter.counts.deviceCreations = 11; + expect(resolutionScaleGate(later, false).verdict).toBe("continue"); +}); +it("does not treat an incomplete step snapshot as zero pipeline builds", () => { + const runs = gateRuns(); + runs[0]!.rows[0]!.stepCensusAfter.complete = false; + expect(resolutionScaleGate(runs, true).verdict).toBe("unqualified"); +}); +it.each([ + "empty contexts", + "missing context", + "missing step", + "missing frame", + "malformed CPU", + "incomplete window", + "pending window", + "missing census", + "inconsistent count", +])("fails closed for %s", (fault) => { + const runs = gateRuns(); + if (fault === "empty contexts") runs.length = 0; + if (fault === "missing context") runs.pop(); + if (fault === "missing step") runs[0]!.rows.pop(); + if (fault === "missing frame") runs[0]!.rows[0]!.frames.pop(); + if (fault === "malformed CPU") runs[0]!.rows[0]!.frames[0]!.frameMs = Number.NaN; + if (fault === "incomplete window") runs[0]!.rows[0]!.censusAfter.complete = false; + if (fault === "pending window") runs[0]!.rows[0]!.censusAfter.counts.pending = 1; + if (fault === "missing census") Reflect.set(runs[0]!.rows[0]!, "censusBefore", undefined); + if (fault === "inconsistent count") runs[0]!.rows[0]!.pipelineBuilds = 1; + expect(() => resolutionScaleGate(runs, true)).toThrow("TN_RESOLUTION_GATE_INVALID"); +}); + +it("startup checkpoint persists before a deferred operation and is replaced by its final record", async () => { + const corrected = await tsImport("../src/runner/resolution-scale-probe.ts", import.meta.url); + const directory = await makeTempDir("tn-resolution-checkpoint-"); + const path = join(directory, "run-1.json"); + const evidence = { startup: { collected: false }, partial: { collected: false } }; + const record = (value: object) => writeFile(path, JSON.stringify(value)); + let finish: (() => void) | undefined; + const deferred = new Promise((resolve) => { finish = resolve; }); + let observedPending: (() => void) | undefined; + const pendingObserved = new Promise((resolve) => { observedPending = resolve; }); + const operation = corrected.resolutionScalePending( + "starter.listen", + (pendingOperation: string) => record({ status: "pending", pendingOperation, ...evidence }), + async () => { + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + status: "pending", pendingOperation: "starter.listen", ...evidence, + }); + observedPending!(); + await deferred; + await record({ status: "complete", ...evidence }); + }, + ); + await Promise.race([pendingObserved, operation]); + expect(JSON.parse(await readFile(path, "utf8")).status).toBe("pending"); + finish!(); + await operation; + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ status: "complete", ...evidence }); + const failure = new Error("startup rejected"); + await expect(corrected.resolutionScalePending("starter.listen", async () => {}, async () => { + throw failure; + })).rejects.toBe(failure); + await expect(preserveResolutionScaleFailure(failure, () => record({ status: "failed", error: failure.message, ...evidence }))).rejects.toBe(failure); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ status: "failed", error: failure.message, ...evidence }); +}); diff --git a/packages/playtest/src/runner/browser.ts b/packages/playtest/src/runner/browser.ts index b0fe9c440..f837c768f 100644 --- a/packages/playtest/src/runner/browser.ts +++ b/packages/playtest/src/runner/browser.ts @@ -9,7 +9,7 @@ export const WEBGPU_BROWSER_ARGS = [ "--enable-features=Vulkan", // Forced ANGLE/Vulkan selects NVIDIA locally but breaks hosted software adapters, especially headed. // Apply that workaround only when the caller explicitly requests the host display. - ...(process.env.TN_PLAYTEST_HOST_DISPLAY === "1" + ...(typeof process !== "undefined" && process.env.TN_PLAYTEST_HOST_DISPLAY === "1" ? ["--use-angle=vulkan", "--use-vulkan", "--disable-vulkan-surface"] : []), ] as const; diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts new file mode 100644 index 000000000..5ae389baa --- /dev/null +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -0,0 +1,394 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, type Browser } from "playwright"; +import { WEBGPU_BROWSER_ARGS } from "./browser.js"; +import { provideDisplay } from "./captureEnvironment.js"; +import { acquireRunnerCaptureLock } from "./runner-support.js"; +import { PlaywrightTransport, bridgeWaitTimeoutMs } from "./bridgeClient.js"; +import { waitForStartupReady } from "./startupReady.js"; +import { + resolutionScaleFrame, + preserveResolutionScaleFailure, + resolutionScaleGate, + resolutionScalePending, +} from "./resolution-scale-probe.js"; +import { PLAYTEST_STARTUP_READY_TIMEOUT_MS, type IPlaytestBridgeDescription, type IPlaytestBridgeReady } from "../protocol.js"; + +import type { ITraceEvent } from "./trace.js"; + +import type { IResolutionScaleRun, IResolutionScaleRow } from "./resolution-scale-probe.js"; + +// Use a served starter with resolutionScale pinned to 1; all other render settings stay authored. +const output = resolve(process.argv[2] ?? "artifacts/prd564"); +const controlPath = process.env.TN_RESOLUTION_CONTROL_SOURCE; +const controlCode = controlPath === undefined ? undefined : await readFile(controlPath, "utf8"); +const control = controlCode === undefined ? undefined : { + path: resolve(controlPath!), sha256: createHash("sha256").update(controlCode).digest("hex"), +}; +const preflightOnly = process.argv[3] === "--preflight"; +if (process.argv[3] !== undefined && !preflightOnly) + throw new Error("Expected --preflight or no mode"); +await mkdir(output, { recursive: true }); +let checkpointRun = 1; +let checkpointEvidence: Record = { + startup: { collected: false }, partial: { collected: false }, +}; +let checkpointLogs: string[] = []; +const checkpointDisplay: { value?: Awaited>["strategy"] } = {}; +const checkpoint = async (pendingOperation: string): Promise => { + await writeFile(resolve(output, `run-${checkpointRun}.json`), JSON.stringify({ + run: checkpointRun, mode: preflightOnly ? "preflight" : "measurement", + status: "pending", pendingOperation, display: checkpointDisplay.value, ...checkpointEvidence, + }, null, 2)); + await writeFile(resolve(output, `run-${checkpointRun}.log`), checkpointLogs.join("\n")); +}; +const pending = (name: string, operation: () => Promise): Promise => + resolutionScalePending(name, checkpoint, operation); +let server: Awaited> | undefined; +let display: Awaited> | undefined; +let browser: Browser | undefined; +let captureLease: Awaited> | undefined; +const measurements: IResolutionScaleRun[] = []; +try { + const startedServer = await pending("starter.bootstrap", () => serveStarter(output, checkpoint)); + server = startedServer; + const url = startedServer.resolvedUrls!.local[0]!; + captureLease = await pending("capture.lease", () => acquireRunnerCaptureLock()); + const providedDisplay = await pending("display.provide", () => provideDisplay()); + display = providedDisplay; + checkpointDisplay.value = providedDisplay.strategy; + browser = await pending("browser.launch", () => chromium.launch({ + headless: false, + args: [ + ...WEBGPU_BROWSER_ARGS, + "--use-angle=vulkan", + "--use-vulkan", + "--disable-vulkan-surface", + "--no-sandbox", + ], + env: providedDisplay.env, + })); + for (let run = 1; run <= (preflightOnly ? 1 : 3); run++) { + checkpointRun = run; + const logs: string[] = []; + checkpointLogs = logs; + const evidence: { + startup: { + collected: boolean; + value?: IPlaytestBridgeReady | Awaited>; + }; + partial: { collected: boolean; value?: Record }; + rows?: IResolutionScaleRow[]; + markers?: ITraceEvent[]; + } = { startup: { collected: false }, partial: { collected: false } }; + checkpointEvidence = evidence; + const launchedBrowser = browser; + const context = await pending("browser.context", () => launchedBrowser.newContext({ + viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, + })); + const page = await pending("browser.page", () => context.newPage()); + page.on("console", (message) => logs.push(message.text())); + page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); + page.on("requestfailed", (request) => + logs.push(`REQUEST_FAILED:${request.url()}:${request.failure()?.errorText}`), + ); + try { + await pending("page.goto", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: PLAYTEST_STARTUP_READY_TIMEOUT_MS })); + const transport = new PlaywrightTransport(page); + const bootError = async () => + (await page.locator("[data-threenative-canvas-error]").allTextContents()).join("\n"); + if (!(await pending("bridge.wait", () => transport.waitForBridge(bridgeWaitTimeoutMs())))) { + const error = await bootError(); + throw new Error( + error + ? `TN_RESOLUTION_BOOT_REJECTED: ${error}` + : "TN_PLAYTEST_BRIDGE_MISSING: starter did not install its bridge", + ); + } + const description = await pending("bridge.describe", () => transport.call( + "describe", + undefined, + bridgeWaitTimeoutMs(), + )); + const startup = await pending("startup.ready", () => waitForStartupReady({ + bridge: { + description, + readiness: async () => { + const value = await transport.call("ready"); + evidence.startup = { collected: true, value }; + return value; + }, + }, + pump: async () => { + const error = await bootError(); + if (error) throw new Error(`TN_RESOLUTION_BOOT_REJECTED: ${error}`); + await resolutionScaleFrame(page); + }, + })); + evidence.startup = { collected: true, value: startup }; + if (startup === undefined) + throw new Error("TN_PLAYTEST_STARTUP_NOT_READY: starter does not report startup"); + const navigations: string[] = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) navigations.push(frame.url()); + }); + const cdp = await context.newCDPSession(page); + const markers: ITraceEvent[] = []; + cdp.on("Tracing.dataCollected", (event: { value?: ITraceEvent[] }) => { + markers.push( + ...(event.value ?? []).filter((value) => value.name?.startsWith("TN_RESOLUTION_FRAME:")), + ); + }); + const traceComplete = new Promise((resolve) => + cdp.once("Tracing.tracingComplete", () => resolve()), + ); + // Reuse the shipped CDP transport, with only markers; no CPU profile/render timeline. + await cdp.send("Tracing.start", { + traceConfig: { includedCategories: ["blink.user_timing"], excludedCategories: ["*"] }, + transferMode: "ReportEvents", + }); + await checkpoint("page.evaluate"); + const observed = await page + .evaluate<{ + preflight?: { + adapter: string; + surface: Record; + census: unknown; + frameBudget: { prototypeObserved: boolean }; + textureBytes: { rgba8unorm: number }; + }; + rows?: IResolutionScaleRow[]; + partial: Record; + failure?: { name: string; message: string; stack?: string }; + }>(`(async () => { + const partial = {}; + try { + const load = (path) => import(/* @vite-ignore */ path); + const { default: game } = await load("/src/game.ts"); + const { FrameBudget } = await load("/__resolution-core.js"); + const { installResolutionScaleProbe, assertResolutionScaleContext, assertResolutionScalePreflight, qualifyResolutionScaleBudget, resolutionScaleTextureBytes } = await load("/__resolution-probe.js"); + const mountedCanvas = document.querySelector('[data-threenative-canvas="true"] canvas'); + partial.context = { exists: game.ctx !== undefined, phase: game.ctx?.startup?.phase, mountedCanvasPresent: mountedCanvas !== null, canvasMatches: mountedCanvas !== null && game.ctx?.renderer?.domElement === mountedCanvas }; + const ctx = assertResolutionScaleContext(game.ctx, mountedCanvas); + partial.context.qualified = true; + const bytesPerTexel = resolutionScaleTextureBytes(ctx.renderer.raw?.backend?.textureUtils); + const rgba8unormBytes = bytesPerTexel("rgba8unorm"); + partial.textureBytes = { rgba8unorm: rgba8unormBytes }; + if (rgba8unormBytes !== 4) throw new Error("TN_RESOLUTION_TEXTURE_BYTES_UNQUALIFIED: rgba8unorm did not report 4 bytes"); + await qualifyResolutionScaleBudget(FrameBudget?.prototype, () => new Promise(resolve => requestAnimationFrame(() => resolve()))); + partial.frameBudget = { prototypeObserved: true }; + const surface = ctx.renderer.surface(); + partial.surface = { ...surface, compilingPresent: Object.hasOwn(surface, "compiling") }; + const census = ctx.renderer.pipelineCensus?.(); + partial.census = { collected: true, present: census !== undefined, ...(census === undefined ? {} : { value: census }) }; + partial.adapter = { collected: true, present: census?.adapter?.identity !== undefined, ...(census?.adapter?.identity === undefined ? {} : { identity: census.adapter.identity }) }; + const adapter = assertResolutionScalePreflight(ctx.renderer.kind, surface, census); + const preflight = { surface, census, adapter, frameBudget: { prototypeObserved: true }, textureBytes: { rgba8unorm: rgba8unormBytes } }; + const rows = ${preflightOnly} ? [] : await installResolutionScaleProbe( + FrameBudget.prototype, + ctx.renderer, + globalThis.GPUDevice.prototype, + bytesPerTexel, + ); + if (game.ctx !== ctx) throw new Error("TN_RESOLUTION_CONTEXT_IDENTITY: game context changed during measurement"); + assertResolutionScaleContext(game.ctx, document.querySelector('[data-threenative-canvas="true"] canvas')); + return { preflight, rows, partial }; + } catch (error) { + return { partial, failure: { name: error instanceof Error ? error.name : "Error", message: error instanceof Error ? error.message : String(error), ...(error instanceof Error && error.stack ? { stack: error.stack } : {}) } }; + } + })()`) + .catch((error: unknown) => { + if (navigations.length) + throw new Error(`TN_RESOLUTION_NAVIGATION: ${navigations.join(", ")}`, { + cause: error, + }); + throw error; + }); + let flushTimer: ReturnType | undefined; + try { + await Promise.race([ + (async () => { + await cdp.send("Tracing.end"); + await traceComplete; + })(), + new Promise((_resolve, reject) => { + flushTimer = setTimeout(() => reject(new Error("TN_RESOLUTION_TRACE_FLUSH_TIMEOUT")), bridgeWaitTimeoutMs()); + }), + ]); + } finally { + clearTimeout(flushTimer); + await cdp.detach(); + } + evidence.markers = markers; + evidence.partial = { collected: true, value: observed.partial }; + if (observed.failure) { + const error = new Error(observed.failure.message); + error.name = observed.failure.name; + if (observed.failure.stack) error.stack = observed.failure.stack; + throw error; + } + if (navigations.length) + throw new Error(`TN_RESOLUTION_NAVIGATION: ${navigations.join(", ")}`); + const { preflight, rows = [] } = observed; + if (control !== undefined && preflight?.surface.resolutionSubrect === true) + throw new Error("TN_RESOLUTION_CONTROL_INVALID: control installed the subrectangle path"); + evidence.rows = rows; + const adapter = preflight!.adapter; + if (logs.some((line) => line.startsWith("PAGE_ERROR:"))) + throw new Error("Browser page errors; inspect raw log"); + if (!preflightOnly) measurements.push({ run, rows }); + // Timing stays unqualified until the host-load observation is independently reviewed. + const gate = + !preflightOnly && run === 3 ? resolutionScaleGate(measurements, false) : undefined; + await writeFile( + resolve(output, `run-${run}.json`), + JSON.stringify( + { + run, + mode: preflightOnly ? "preflight" : "measurement", + display: providedDisplay.strategy, + startup, + adapter, + ...(control === undefined ? {} : { control }), + preflight, + rows, + markers, + ...(gate === undefined ? {} : { gate }), + }, + null, + 2, + ), + ); + if (preflightOnly) { + console.info(JSON.stringify({ run, adapter, preflight })); + continue; + } + console.info( + JSON.stringify({ + run, + adapter, + ...(gate === undefined ? {} : { gate }), + steps: rows.map((row) => ({ + scale: row.scale, + stepMs: row.frames[0]!.frameMs, + p50Ms: row.windowP50Ms, + ratio: row.stepRatio, + pipelines: row.pipelineBuilds, + textureBytes: row.textureBytes, + })), + }), + ); + } catch (error) { + await preserveResolutionScaleFailure(error, () => + writeFile( + resolve(output, `run-${run}.json`), + JSON.stringify( + { + run, + mode: preflightOnly ? "preflight" : "measurement", + status: "failed", + display: providedDisplay.strategy, + ...evidence, + error: { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && error.stack ? { stack: error.stack } : {}), + }, + }, + null, + 2, + ), + ), + ); + } finally { + await writeFile(resolve(output, `run-${run}.log`), logs.join("\n")); + await context.close(); + } + } +} finally { + try { + await browser?.close(); + await display?.release(); + await server?.close(); + } finally { + await captureLease?.release(); + } +} + +async function serveStarter( + output: string, + checkpoint: (pendingOperation: string) => Promise, +): Promise<{ resolvedUrls: { local: string[] } | null; close(): Promise }> { + const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); + const load = (path: string) => import(path); + const tooling = createRequire(resolve(repo, "examples/abyss-framework/package.json")); + await checkpoint("starter.import-vite"); + const { createServer } = await load(tooling.resolve("vite")); + await checkpoint("starter.import-scaffolder"); + const { createProject } = await load(resolve(repo, "packages/create-threenative/src/index.ts")); + await checkpoint("starter.create-target"); + // Retain this artifact; fail before overwriting an earlier run's starter project. + const target = resolve(output, "starter-project"); + await mkdir(target); + await checkpoint("starter.scaffold"); + await createProject({ target, template: "starter", install: false }); + if (controlCode !== undefined) + await writeFile(resolve(target, "src/render/worldEnvironment.ts"), controlCode); + await checkpoint("starter.link-dependencies"); + await mkdir(resolve(target, "node_modules")); + for (const name of [ + "three", + "react", + "react-dom", + "@threenative", + "create-threenative", + "vite", + "@vitejs", + "@tailwindcss", + "tailwindcss", + ]) + await symlink( + resolve(repo, "examples/abyss-framework/node_modules", name), + resolve(target, "node_modules", name), + "dir", + ); + await checkpoint("starter.create-server"); + const server = await createServer({ + root: target, + cacheDir: resolve(target, "node_modules/.vite"), + plugins: [ + { + name: "resolution-measurement-fixture", + resolveId(id: string) { + if (id.startsWith("/__resolution-")) return id; + }, + load(id: string) { + const modules: Record = { + "/__resolution-core.js": "export { FrameBudget } from '@threenative/core';", + "/__resolution-probe.js": `export { installResolutionScaleProbe, assertResolutionScaleContext, assertResolutionScalePreflight, qualifyResolutionScaleBudget, resolutionScaleTextureBytes } from '${repo}/packages/playtest/src/runner/resolution-scale-probe.ts';`, + }; + return modules[id]; + }, + transform(code: string, id: string) { + if (id === resolve(target, "threenative.config.ts")) { + if (!code.includes('resolutionScale: "auto"')) + throw new Error("Starter resolutionScale auto pin source is missing"); + return code.replace('resolutionScale: "auto"', "resolutionScale: 1"); + } + }, + }, + ], + server: { host: "127.0.0.1", port: 0, fs: { allow: [repo] } }, + }); + await checkpoint("starter.listen"); + try { + await server.listen(); + } catch (error) { + await server.close(); + throw error; + } + return server; +} diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts new file mode 100644 index 000000000..55863d7fa --- /dev/null +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -0,0 +1,417 @@ +import type { Page } from "playwright"; +import { softwareAdapterName } from "./browser.js"; + +// Literal source avoids tsx keepNames helpers crossing Playwright's serialization boundary. +export const resolutionScaleFrame = (page: Pick): Promise => + page.evaluate("new Promise((resolve) => requestAnimationFrame(() => resolve()))"); + +/** Browser-only measurement seam; no renderer behavior is changed in the engine. */ +export function assertResolutionScaleContext< + T extends { renderer: { domElement: unknown }; startup: { phase: string } }, +>(ctx: T | undefined, mountedCanvas: unknown): T { + if (ctx === undefined) + throw new Error("TN_RESOLUTION_CONTEXT_MISSING: ready bridge has no game context"); + if (ctx.startup?.phase !== "ready") + throw new Error(`TN_PLAYTEST_STARTUP_NOT_READY: context phase ${ctx.startup?.phase}`); + if (mountedCanvas == null || ctx.renderer.domElement !== mountedCanvas) + throw new Error("TN_RESOLUTION_CONTEXT_IDENTITY: mounted canvas differs from game context"); + return ctx; +} + +export function assertResolutionScalePreflight( + kind: string, + surface: { resolutionScale: number; scaleSource?: string; compiling?: boolean }, + census: + | { + complete: boolean; + incompleteReasons?: readonly string[]; + backend: { kind: string }; + adapter: { identity: string }; + } + | undefined, +): string { + if (kind !== "webgpu" || (census !== undefined && census.backend.kind !== "webgpu")) + throw new Error(`TN_RESOLUTION_WEBGPU_REQUIRED: kind=${kind}, backend=${census?.backend.kind}`); + const observed = `resolutionScale=${surface.resolutionScale}, scaleSource=${surface.scaleSource}, compilingPresent=${Object.hasOwn(surface, "compiling")}, compiling=${surface.compiling}`; + if (surface.compiling !== undefined && typeof surface.compiling !== "boolean") + throw new Error(`TN_RESOLUTION_SURFACE_MALFORMED: ${observed}`); + if (surface.resolutionScale !== 1 || surface.compiling === true) + throw new Error(`TN_RESOLUTION_SURFACE_NOT_READY: ${observed}`); + if (census?.complete !== true) + throw new Error( + `TN_RESOLUTION_CENSUS_INCOMPLETE: complete=${census?.complete}, incompleteReasons=${census?.incompleteReasons?.join(", ")}`, + ); + const identity = census.adapter?.identity; + let decoded: string; + try { + decoded = decodeURIComponent(identity); + } catch { + throw new Error(`TN_RESOLUTION_ADAPTER_MISSING: invalid encoded identity=${identity}`); + } + if ( + typeof identity !== "string" || + !/^webgpu:(architecture|description|device|vendor)=\S/.test(decoded) + ) + throw new Error(`TN_RESOLUTION_ADAPTER_MISSING: identity=${identity}`); + if (softwareAdapterName({ identity: decoded })) + throw new Error(`TN_RESOLUTION_SOFTWARE_ADAPTER: ${decoded}`); + return identity; +} + +export async function qualifyResolutionScaleBudget( + budgetPrototype: { beginFrame?(now: number, clock: number): void } | undefined, + pump: () => Promise, +): Promise { + const begin = budgetPrototype?.beginFrame; + if (budgetPrototype === undefined || typeof begin !== "function") + throw new Error("TN_RESOLUTION_FRAME_BUDGET_IDENTITY: imported prototype is missing"); + let receiver: object | undefined; + budgetPrototype.beginFrame = function (...args: Parameters) { + receiver = this; + return begin.apply(this, args); + }; + try { + await pump(); + if (receiver === undefined || Object.getPrototypeOf(receiver) !== budgetPrototype) + throw new Error( + "TN_RESOLUTION_FRAME_BUDGET_IDENTITY: imported prototype did not observe the running budget", + ); + } finally { + budgetPrototype.beginFrame = begin; + } +} + +export function resolutionScaleTextureBytes( + textureUtils: { _getBytesPerTexel?(format: string): number } | undefined, +): (format: string) => number { + if (typeof textureUtils?._getBytesPerTexel !== "function") + throw new Error("TN_RESOLUTION_TEXTURE_UTILS_MISSING"); + return textureUtils._getBytesPerTexel.bind(textureUtils); +} + +interface IResolutionTextureDescriptor { + size: number[] | { width: number; height?: number; depthOrArrayLayers?: number }; + format?: string; + mipLevelCount?: number; + sampleCount?: number; + dimension?: string; +} +export function textureDescriptorBytes( + descriptor: IResolutionTextureDescriptor, + bytesPerTexel: number, +): number { + if (!Number.isFinite(bytesPerTexel) || bytesPerTexel <= 0) + throw new Error(`Unmeasured texture format: ${descriptor.format}`); + const size = descriptor.size; + const [width, height = 1, layers = 1] = Array.isArray(size) + ? size + : [size.width, size.height ?? 1, size.depthOrArrayLayers ?? 1]; + const levels = descriptor.mipLevelCount ?? 1; + const samples = descriptor.sampleCount ?? 1; + if (![width, height, layers, levels, samples].every((n) => Number.isInteger(n) && n! > 0)) + throw new Error("Invalid texture allocation dimensions"); + let bytes = 0; + for (let mip = 0; mip < levels; mip++) { + bytes += + Math.max(1, Math.floor(width! / 2 ** mip)) * + Math.max(1, Math.floor(height / 2 ** mip)) * + (descriptor.dimension === "3d" ? Math.max(1, Math.floor(layers / 2 ** mip)) : layers) * + samples * + bytesPerTexel; + } + return bytes; +} + +interface IResolutionScaleCensus { + incompleteReasons?: readonly string[]; + complete: boolean; + counts: { deviceCreations?: number; failures: number; pending: number; droppedEvents: number }; +} +export interface IResolutionScaleRow { + scale: number; + frames: { + frame: number; + frameMs: number; + clockEpochStartMs?: number; + clockEpochEndMs?: number; + clockCalibration?: { + performanceBeforeMs: number; + wallEpochMs: number; + performanceAfterMs: number; + marker: string; + }; + rafIntervalMs?: number; + surface?: unknown; + }[]; + allocations: { bytes: number; [key: string]: unknown }[]; + textureBytes: number; + pipelineBuilds: number; + censusBefore: IResolutionScaleCensus; + stepCensusAfter: IResolutionScaleCensus; + censusAfter: IResolutionScaleCensus; + windowP50Ms: number; + stepRatio: number; +} +export interface IResolutionScaleRun { + run: number; + rows: IResolutionScaleRow[]; +} + +/** PRD-564: only first-frame CPU work or attributed first-frame creations can earn continuation. */ +export function resolutionScaleGate(runs: IResolutionScaleRun[], timingQualified: boolean) { + const invalid = (reason: string): never => { + throw new Error(`TN_RESOLUTION_GATE_INVALID: ${reason}`); + }; + const count = (census: IResolutionScaleCensus, requireComplete: boolean): number => { + if (!census || typeof census.complete !== "boolean" || !census.counts) + return invalid("missing census"); + const { deviceCreations, failures, pending, droppedEvents } = census.counts; + if ( + ![deviceCreations, failures, pending, droppedEvents].every( + (value) => Number.isSafeInteger(value) && (value as number) >= 0, + ) + ) + return invalid("malformed census counts"); + if ( + requireComplete && + (!census.complete || failures !== 0 || pending !== 0 || droppedEvents !== 0) + ) + return invalid("incomplete window census"); + return deviceCreations as number; + }; + if (typeof timingQualified !== "boolean" || !Array.isArray(runs) || runs.length !== 3) + return invalid("expected three fresh contexts and explicit timing qualification"); + const steps = runs.flatMap((run, context) => { + if (run?.run !== context + 1 || !Array.isArray(run.rows) || run.rows.length !== 3) + return invalid("context/step coverage"); + return run.rows.map((row, step) => { + if ( + !row || + row.scale !== [0.85, 0.61, 1][step] || + !Array.isArray(row.frames) || + row.frames.length !== 31 + ) + return invalid("expected step plus thirty following CPU frames"); + row.frames.forEach((frame, index) => { + if ( + frame?.frame !== step * 31 + index || + !Number.isFinite(frame.frameMs) || + frame.frameMs < 0 + ) + invalid("missing/malformed CPU frame"); + }); + const before = count(row.censusBefore, true); + const first = count(row.stepCensusAfter, false); + const after = count(row.censusAfter, true); + if (first < before || after < first || row.pipelineBuilds !== after - before) + return invalid("inconsistent pipeline counts"); + if ( + !Array.isArray(row.allocations) || + !Number.isSafeInteger(row.textureBytes) || + row.textureBytes < 0 || + row.allocations.some( + (allocation) => !Number.isSafeInteger(allocation?.bytes) || allocation.bytes < 0, + ) || + row.allocations.reduce((sum, allocation) => sum + allocation.bytes, 0) !== row.textureBytes + ) + return invalid("malformed descriptor payload bytes"); + const following = row.frames + .slice(1) + .map((frame) => frame.frameMs) + .sort((a, b) => a - b); + const p50Ms = following[14]!; + const pipelineQualified = + row.stepCensusAfter.complete && + row.stepCensusAfter.counts.failures === 0 && + row.stepCensusAfter.counts.pending === 0 && + row.stepCensusAfter.counts.droppedEvents === 0; + return { + run: run.run, + scale: row.scale, + stepCpuMs: row.frames[0]!.frameMs, + windowP50Ms: p50Ms, + timingQualified, + cpuSpike: timingQualified ? row.frames[0]!.frameMs > 2 * p50Ms : null, + pipelineQualified, + stepPipelineBuilds: pipelineQualified ? first - before : null, + windowPipelineBuilds: row.pipelineBuilds, + descriptorBytes: row.textureBytes, + }; + }); + }); + const earned = steps.some( + (step) => + step.cpuSpike === true || (step.pipelineQualified && (step.stepPipelineBuilds as number) > 0), + ); + return { + verdict: earned + ? "continue" + : timingQualified && steps.every((step) => step.pipelineQualified) + ? "decline" + : "unqualified", + timingQualified, + steps, + bytesMeaning: "texture descriptor payload; excludes driver padding/metadata", + }; +} + +export function installResolutionScaleProbe( + budgetPrototype: { + beginFrame(now: number, clock: number): void; + endFrame(now: number, wantSample: boolean): unknown; + }, + renderer: { + surface(): { resolutionScale: number; compiling?: boolean }; + pipelineCensus?(): IResolutionScaleCensus | undefined; + setResolutionScale(scale: number, source: string): void; + }, + devicePrototype: { + createTexture(descriptor: IResolutionTextureDescriptor & { format: string }): unknown; + }, + bytesPerTexel: (format: string) => number, +): Promise { + const begin = budgetPrototype.beginFrame; + const end = budgetPrototype.endFrame; + const createTexture = devicePrototype.createTexture; + const rows: IResolutionScaleRow[] = []; + let frame = -120; + let start = 0; + let timestamp = 0; + let previousTimestamp: number | undefined; + let initialCensus: IResolutionScaleCensus; + let stepCensusAfter: IResolutionScaleCensus; + let allocations: IResolutionScaleRow["allocations"] = []; + let frames: IResolutionScaleRow["frames"] = []; + let step = -1; + const scales = [0.85, 0.61, 1]; + const restore = () => { + budgetPrototype.beginFrame = begin; + budgetPrototype.endFrame = end; + devicePrototype.createTexture = createTexture; + }; + const census = () => { + const value = renderer.pipelineCensus?.(); + if (!value?.complete || !Number.isInteger(value.counts.deviceCreations)) + throw new Error(`Incomplete pipeline census: ${JSON.stringify(value?.incompleteReasons)}`); + return value; + }; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + restore(); + reject(new Error("Resolution measurement did not capture all 93 frames within 60s")); + }, 60_000); + const fail = (error: unknown) => { + clearTimeout(timeout); + restore(); + reject(error); + }; + devicePrototype.createTexture = function ( + descriptor: IResolutionTextureDescriptor & { format: string }, + ) { + // Descriptor payload bytes, using Three's format census; excludes driver padding/metadata. + const bytes = textureDescriptorBytes(descriptor, bytesPerTexel(descriptor.format)); + const texture = createTexture.call(this, descriptor); + if (frame >= 0) allocations.push({ frame, descriptor, bytes }); + return texture; + }; + budgetPrototype.beginFrame = function (now: number, clock: number) { + begin.call(this, now, clock); + start = clock; + timestamp = now; + try { + if (frame < 0) return; + if (frame % 31 === 0) { + frames = []; + allocations = []; + step++; + const expected = step === 0 ? 1 : scales[step - 1]; + if (renderer.surface().resolutionScale !== expected || renderer.surface().compiling) + throw new Error("Scale changed outside the forced step, or compile still active"); + renderer.setResolutionScale(scales[step]!, "auto-pinned"); + } + } catch (error) { + fail(error); + } + }; + budgetPrototype.endFrame = function (now: number, wantSample: boolean) { + const result = end.call(this, now, wantSample); + try { + // Census collection must not charge observer work only to the forced-step frame. + if (frame === -1) initialCensus = census(); + if (frame >= 0) { + if (renderer.surface().resolutionScale !== scales[step]) + throw new Error("Forced scale was overridden"); + // End clock is already captured; marker/calibration must stay outside measured work. + const performanceBeforeMs = performance.now(); + const wallEpochMs = Date.now(); + const performanceAfterMs = performance.now(); + const marker = `TN_RESOLUTION_FRAME:${frame}`; + performance.mark(marker, { startTime: performanceAfterMs }); + performance.clearMarks(marker); + frames.push({ + frame, + frameMs: now - start, + clockCalibration: { performanceBeforeMs, wallEpochMs, performanceAfterMs, marker }, + clockEpochStartMs: performance.timeOrigin + start, + clockEpochEndMs: performance.timeOrigin + now, + rafIntervalMs: + previousTimestamp === undefined ? undefined : timestamp - previousTimestamp, + surface: renderer.surface(), + }); + if (frame % 31 === 0) stepCensusAfter = renderer.pipelineCensus?.()!; + if (frame % 31 === 30) { + const after = census(); + const next30 = frames + .slice(1) + .map((row) => row.frameMs) + .sort((a, b) => a - b); + const p50 = next30[Math.ceil(next30.length * 0.5) - 1]; + const row = { + scale: scales[step]!, + frames: [...frames], + allocations: [...allocations], + textureBytes: allocations.reduce((sum, value) => sum + value.bytes, 0), + pipelineBuilds: after.counts.deviceCreations! - initialCensus.counts.deviceCreations!, + censusBefore: initialCensus, + stepCensusAfter, + censusAfter: after, + windowP50Ms: p50!, + stepRatio: frames[0]!.frameMs / p50!, + }; + rows.push(row); + initialCensus = after; + console.info(`TN_RESOLUTION_STEP:${JSON.stringify(row)}`); + if (step === 2) { + clearTimeout(timeout); + restore(); + resolve(rows); + } + } + } + previousTimestamp = timestamp; + frame++; + } catch (error) { + fail(error); + } + return result; + }; + }); +} + +export async function preserveResolutionScaleFailure( + error: unknown, + record: () => Promise, +): Promise { + await record(); + throw error; +} + +// Await persistence before starting work so a hard deadline leaves the pending operation on disk. +export async function resolutionScalePending( + pendingOperation: string, + persist: (pendingOperation: string) => Promise, + operation: () => Promise, +): Promise { + await persist(pendingOperation); + return operation(); +} diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 18ecd85b7..3ece38f4f 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -154,6 +154,22 @@ "desktopGate": false, "required": false }, + { + "id": "resolution-subrect", + "title": "retained target resolution scale through a viewport sub-rectangle", + "category": "rendering", + "status": "implemented", + "scene": "conformance/scenes/shared/resolution-subrect.js", + "captureFrames": 12, + "browserRecipe": "webgpu", + "requiresHardwareAdapter": true, + "tolerance": { + "pixelMismatchRatio": 0.06, + "perceptualDeltaE": 9.0 + }, + "desktopGate": true, + "required": true + }, { "id": "lut-grade", "title": "colour grade table and film grain", diff --git a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js new file mode 100644 index 000000000..5fe5f2b1b --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js @@ -0,0 +1,105 @@ +import * as THREE from "three/webgpu"; +import { clamp, pass, screenUV, uniform } from "three/tsl"; +import { ResolutionSubrect } from "../../../../core/src/render/resolution-subrect.ts"; +import { ResolutionScaler } from "../../../../core/src/resolution-scaler.ts"; +import { startVisualScene } from "./scene-support.js"; + +const SCALE = 0.72; +const quantize = (size) => Math.max(8, Math.ceil(size / 8) * 8); + +export function startScene(canvas, dimensions) { + const scaler = new ResolutionScaler({ start: SCALE, minScale: 0.61, targetFps: 60 }); + const timerless = { + fps: 20, + presented: { max: 60, p50: 50, p95: 55, p99: 58 }, + surface: { + drawingBufferWidth: dimensions.width, + drawingBufferHeight: dimensions.height, + resolutionScale: SCALE, + resolutionSubrect: true, + }, + }; + scaler.observe(timerless); // warmup + if (scaler.observe(timerless) !== 0.61) + throw new Error("Timer-less sub-rectangle policy did not probe one rung to the floor."); + scaler.observe(timerless); // resize cooldown + if (scaler.observe(timerless) !== SCALE || scaler.atFloor || scaler.scaleSource !== "auto") + throw new Error("Timer-less sub-rectangle policy did not refund the failed probe."); + scaler.observe(timerless); // resize cooldown + const clean = { + ...timerless, + fps: 60, + presented: { max: 17, p50: 16.67, p95: 17, p99: 17 }, + }; + for (let index = 0; index < 3; index++) + if (scaler.observe(clean) !== undefined) + throw new Error("Timer-less sub-rectangle policy climbed before four clean windows."); + if (scaler.observe(clean) !== 0.85) + throw new Error("Timer-less sub-rectangle policy did not climb exactly one rung."); + + return startVisualScene(canvas, dimensions, "resolution-subrect", ({ renderer, scene, camera }) => { + const subject = new THREE.Mesh( + new THREE.TorusKnotGeometry(0.62, 0.19, 96, 20), + new THREE.MeshBasicNodeMaterial({ color: 0x38bdf8 }), + ); + subject.rotation.set(0.3, 0.4, 0.1); + scene.add(subject); + + const scenePass = pass(scene, camera); + const controller = new ResolutionSubrect(); + controller.retain(scenePass, [scenePass.renderTarget]); + controller.activate(1); + controller.setSize(dimensions.width, dimensions.height); + + const view = controller.textureView(scenePass.renderTarget.texture); + if (!view) throw new Error("Resolution sub-rectangle did not retain the scene pass texture."); + const viewFraction = uniform(new THREE.Vector2(1, 1)).onRenderUpdate(() => view.viewFraction); + const halfTexel = uniform(new THREE.Vector2()).onRenderUpdate(() => view.halfTexel); + const uvNode = clamp( + screenUV.mul(viewFraction), + halfTexel, + viewFraction.sub(halfTexel), + ); + const pipeline = new THREE.RenderPipeline(renderer); + pipeline.outputNode = scenePass.getTextureNode("output").sample(uvNode); + + const capacityWidth = quantize(dimensions.width); + const capacityHeight = quantize(dimensions.height); + const assertTransition = (scale) => { + const viewWidth = quantize(dimensions.width * scale); + const viewHeight = quantize(dimensions.height * scale); + const target = scenePass.renderTarget; + if ( + target.width !== capacityWidth || + target.height !== capacityHeight || + target.viewport.z !== viewWidth || + target.viewport.w !== viewHeight || + target.scissor.z !== viewWidth || + target.scissor.w !== viewHeight || + !target.scissorTest || + view.viewFraction.x !== viewWidth / capacityWidth || + view.viewFraction.y !== viewHeight / capacityHeight || + view.halfTexel.x !== 0.5 / capacityWidth || + view.halfTexel.y !== 0.5 / capacityHeight + ) + throw new Error(`Resolution sub-rectangle transition failed at scale ${scale}.`); + }; + + for (const scale of [SCALE, 1, SCALE]) { + controller.setScale(scale); + controller.setSize(dimensions.width, dimensions.height); + pipeline.render(); + assertTransition(scale); + } + + return { + render: () => pipeline.render(), + detail: { + capacity: [capacityWidth, capacityHeight], + view: [quantize(dimensions.width * SCALE), quantize(dimensions.height * SCALE)], + scale: SCALE, + sampling: "viewFraction*screenUV-clamped-to-half-texel", + }, + }; + }); +} diff --git a/packages/runtime-native/tests/conformance-runner.test.mjs b/packages/runtime-native/tests/conformance-runner.test.mjs index 60a082a85..09a08793d 100644 --- a/packages/runtime-native/tests/conformance-runner.test.mjs +++ b/packages/runtime-native/tests/conformance-runner.test.mjs @@ -1484,6 +1484,33 @@ function bundleFixture(body = "export const choice = import.meta.env?.DEV;") { return { dir, entry, out, compiler: join(root, "node_modules/.bin/esbuild") }; } +test("resolution-subrect conformance bundles the shipped controller through down/up/down transitions", async () => { + const dir = makeTempDirSync("threenative-resolution-subrect-"); + const scene = "conformance/scenes/shared/resolution-subrect.js"; + try { + const entry = makeEntry( + { captureFrames: 2, id: "resolution-subrect-contract", scene }, + "browser", + 1234, + dir, + ); + const output = join(dir, "resolution-subrect.js"); + const result = {}; + assert.equal( + await bundle(entry, output, result, "browser", join(root, "node_modules/.bin/esbuild"), true), + true, + result.browser?.stderr, + ); + assert.match( + readFileSync(output, "utf8"), + /TN_RESOLUTION_SUBRECT: viewport exceeds retained capacity/u, + "the conformance bundle must contain the shipped controller, not a hand-written substitute", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("conformance API preserves changing NODE_PATH lookup, bytes and maps", async () => { const fixture = bundleFixture('export { choice } from "conformance-node-path-only";'); try {