From 7a091deb93d78f0398c71e0c678cc455f541f78c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 16:41:51 -0700 Subject: [PATCH 01/53] test(playtest): add PRD-564 resolution measurement harness --- ...e-changes-without-reallocating-a-target.md | 15 + .../__tests__/resolution-scale-probe.spec.ts | 318 ++++++++++++++++ .../src/runner/measure-resolution-scale.ts | 283 ++++++++++++++ .../src/runner/resolution-scale-probe.ts | 360 ++++++++++++++++++ 4 files changed, 976 insertions(+) create mode 100644 packages/playtest/__tests__/resolution-scale-probe.spec.ts create mode 100644 packages/playtest/src/runner/measure-resolution-scale.ts create mode 100644 packages/playtest/src/runner/resolution-scale-probe.ts diff --git a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 48658f0971..bacbbf97ea 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -117,4 +117,19 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Decisions +- 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-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. 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 0000000000..b5d855cf54 --- /dev/null +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -0,0 +1,318 @@ +import { runInNewContext } from "node:vm"; +import { tsImport } from "tsx/esm/api"; +import type { Page } from "playwright"; +import { expect, it, vi } from "vitest"; +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(() => {}); + const budget = { + beginFrame(_timestamp: number, _clock: number) {}, + endFrame(_now: number, _want: boolean) { + return true; + }, + }; + const device = { + createTexture(_descriptor: any) { + return {}; + }, + }; + const begin = budget.beginFrame, + end = budget.endFrame, + create = device.createTexture; + let scale = 1, + builds = 3; + const renderer = { + surface: () => ({ resolutionScale: scale }), + pipelineCensus: () => ({ complete: true, counts: { deviceCreations: builds } }), + setResolutionScale(next: number) { + scale = next; + builds++; + device.createTexture({ size: [4, 4] }); + }, + }; + const result = installResolutionScaleProbe(budget, renderer, device, () => 4); + for (let frame = 0; frame < 213; frame++) { + 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, 1], + [1, 31, 64, 1], + ]); + expect(rows.map((row) => row.stepCensusAfter.counts.deviceCreations)).toEqual([4, 5, 6]); + expect(rows[0]!.stepRatio).toBe(4); + expect([budget.beginFrame, budget.endFrame, device.createTexture]).toEqual([begin, end, create]); + log.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(() => + assertResolutionScalePreflight( + "webgpu", + { resolutionScale: 1, compiling } as any, + 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") runs[0]!.rows[0]!.censusBefore = undefined as any; + if (fault === "inconsistent count") runs[0]!.rows[0]!.pipelineBuilds = 1; + expect(() => resolutionScaleGate(runs, true)).toThrow("TN_RESOLUTION_GATE_INVALID"); +}); 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 0000000000..6bf9242608 --- /dev/null +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -0,0 +1,283 @@ +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { WEBGPU_BROWSER_ARGS } from "./browser.js"; +import { provideDisplay } from "./captureEnvironment.js"; +import { PlaywrightTransport, bridgeWaitTimeoutMs } from "./bridgeClient.js"; +import { waitForStartupReady } from "./startupReady.js"; +import { + resolutionScaleFrame, + preserveResolutionScaleFailure, + resolutionScaleGate, +} from "./resolution-scale-probe.js"; +import type { IPlaytestBridgeDescription, IPlaytestBridgeReady } from "../protocol.js"; + +import type { IResolutionScaleRun } 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 preflightOnly = process.argv[3] === "--preflight"; +if (process.argv[3] !== undefined && !preflightOnly) + throw new Error("Expected --preflight or no mode"); +await mkdir(output, { recursive: true }); +const server = await serveStarter(output); +const url = server.resolvedUrls.local[0]; +const display = await provideDisplay(); +let browser; +const measurements: IResolutionScaleRun[] = []; +try { + browser = await chromium.launch({ + headless: false, + args: [ + ...WEBGPU_BROWSER_ARGS, + "--use-angle=vulkan", + "--use-vulkan", + "--disable-vulkan-surface", + "--no-sandbox", + ], + env: display.env, + }); + for (let run = 1; run <= (preflightOnly ? 1 : 3); run++) { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + const logs: string[] = []; + page.on("console", (message) => logs.push(message.text())); + page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); + const evidence: any = { startup: { collected: false }, partial: { collected: false } }; + try { + await page.goto(url); + const transport = new PlaywrightTransport(page); + const bootError = async () => + (await page.locator("[data-threenative-canvas-error]").allTextContents()).join("\n"); + if (!(await 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 transport.call( + "describe", + undefined, + bridgeWaitTimeoutMs(), + ); + const startup = await 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 observed = await page + .evaluate<{ + preflight?: any; + rows?: any[]; + partial: any; + 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; + }); + 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; + 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: display.strategy, + startup, + adapter, + preflight, + rows, + ...(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: any) => ({ + 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: display.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 { + await browser?.close(); + await display.release(); + await server.close(); +} + +async function serveStarter(output: string): 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")); + const { createServer } = await load(tooling.resolve("vite")); + const { createProject } = await load(resolve(repo, "packages/create-threenative/src/index.ts")); + const target = await mkdtemp(resolve(output, "starter-")); + await createProject({ target, template: "starter", install: false }); + 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", + ); + 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 server.listen(); + 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 0000000000..9c52a742b1 --- /dev/null +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -0,0 +1,360 @@ +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: any, + pump: () => Promise, +): Promise { + const begin = budgetPrototype?.beginFrame; + if (typeof begin !== "function") + throw new Error("TN_RESOLUTION_FRAME_BUDGET_IDENTITY: imported prototype is missing"); + let receiver: object | undefined; + budgetPrototype.beginFrame = function (...args: any[]) { + 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); +} + +export function textureDescriptorBytes(descriptor: any, 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 { + complete: boolean; + counts: { deviceCreations?: number; failures: number; pending: number; droppedEvents: number }; +} +export interface IResolutionScaleRow { + scale: number; + frames: { frame: number; frameMs: number; 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: any, + renderer: any, + devicePrototype: any, + 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: any; + let stepCensusAfter: any; + let allocations: any[] = []; + let frames: any[] = []; + 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: any) { + // 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) { + initialCensus = census(); + 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 { + if (frame >= 0) { + if (renderer.surface().resolutionScale !== scales[step]) + throw new Error("Forced scale was overridden"); + frames.push({ + frame, + frameMs: now - start, + 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); + 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; +} From 2bec4fef0a909c02d1b845353252be693530e9be Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 16:49:03 -0700 Subject: [PATCH 02/53] fix(playtest): type PRD-564 measurement contracts --- ...e-changes-without-reallocating-a-target.md | 1 + .../__tests__/resolution-scale-probe.spec.ts | 27 +++++---- .../src/runner/measure-resolution-scale.ts | 39 ++++++++---- .../src/runner/resolution-scale-probe.ts | 60 +++++++++++++------ 4 files changed, 84 insertions(+), 43 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index bacbbf97ea..668178312a 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -132,4 +132,5 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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-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. diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts index b5d855cf54..8aa0542a41 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -155,22 +155,25 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, }, }; const device = { - createTexture(_descriptor: any) { + createTexture(_descriptor: unknown) { return {}; }, }; - const begin = budget.beginFrame, - end = budget.endFrame, - create = device.createTexture; - let scale = 1, - builds = 3; + const begin = budget.beginFrame; + const end = budget.endFrame; + const create = device.createTexture; + let scale = 1; + let builds = 3; const renderer = { surface: () => ({ resolutionScale: scale }), - pipelineCensus: () => ({ complete: true, counts: { deviceCreations: builds } }), + pipelineCensus: () => ({ + complete: true, + counts: { deviceCreations: builds, failures: 0, pending: 0, droppedEvents: 0 }, + }), setResolutionScale(next: number) { scale = next; builds++; - device.createTexture({ size: [4, 4] }); + device.createTexture({ size: [4, 4], format: "rgba8unorm" }); }, }; const result = installResolutionScaleProbe(budget, renderer, device, () => 4); @@ -217,11 +220,11 @@ it("startup frame survives actual tsx serialization without browser helpers", as it.each([null, "no", 0])("rejects malformed compile observation %j", (compiling) => { expect(() => - assertResolutionScalePreflight( + Reflect.apply(assertResolutionScalePreflight, undefined, [ "webgpu", - { resolutionScale: 1, compiling } as any, + { resolutionScale: 1, compiling }, hardwareCensus, - ), + ]), ).toThrow("TN_RESOLUTION_SURFACE_MALFORMED"); }); it("accepts an explicit idle compile observation", () => { @@ -312,7 +315,7 @@ it.each([ 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") runs[0]!.rows[0]!.censusBefore = undefined as any; + 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"); }); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 6bf9242608..396afe0d95 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { chromium } from "playwright"; +import { chromium, type Browser } from "playwright"; import { WEBGPU_BROWSER_ARGS } from "./browser.js"; import { provideDisplay } from "./captureEnvironment.js"; import { PlaywrightTransport, bridgeWaitTimeoutMs } from "./bridgeClient.js"; @@ -14,7 +14,7 @@ import { } from "./resolution-scale-probe.js"; import type { IPlaytestBridgeDescription, IPlaytestBridgeReady } from "../protocol.js"; -import type { IResolutionScaleRun } from "./resolution-scale-probe.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"); @@ -23,9 +23,9 @@ if (process.argv[3] !== undefined && !preflightOnly) throw new Error("Expected --preflight or no mode"); await mkdir(output, { recursive: true }); const server = await serveStarter(output); -const url = server.resolvedUrls.local[0]; +const url = server.resolvedUrls!.local[0]!; const display = await provideDisplay(); -let browser; +let browser: Browser | undefined; const measurements: IResolutionScaleRun[] = []; try { browser = await chromium.launch({ @@ -48,7 +48,14 @@ try { const logs: string[] = []; page.on("console", (message) => logs.push(message.text())); page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); - const evidence: any = { startup: { collected: false }, partial: { collected: false } }; + const evidence: { + startup: { + collected: boolean; + value?: IPlaytestBridgeReady | Awaited>; + }; + partial: { collected: boolean; value?: Record }; + rows?: IResolutionScaleRow[]; + } = { startup: { collected: false }, partial: { collected: false } }; try { await page.goto(url); const transport = new PlaywrightTransport(page); @@ -91,9 +98,15 @@ try { }); const observed = await page .evaluate<{ - preflight?: any; - rows?: any[]; - partial: any; + 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 = {}; @@ -150,7 +163,7 @@ try { throw new Error(`TN_RESOLUTION_NAVIGATION: ${navigations.join(", ")}`); const { preflight, rows = [] } = observed; evidence.rows = rows; - const adapter = preflight.adapter; + 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 }); @@ -183,9 +196,9 @@ try { run, adapter, ...(gate === undefined ? {} : { gate }), - steps: rows.map((row: any) => ({ + steps: rows.map((row) => ({ scale: row.scale, - stepMs: row.frames[0].frameMs, + stepMs: row.frames[0]!.frameMs, p50Ms: row.windowP50Ms, ratio: row.stepRatio, pipelines: row.pipelineBuilds, @@ -226,7 +239,9 @@ try { await server.close(); } -async function serveStarter(output: string): Promise { +async function serveStarter( + output: string, +): 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")); diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts index 9c52a742b1..8a43516755 100644 --- a/packages/playtest/src/runner/resolution-scale-probe.ts +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -59,14 +59,14 @@ export function assertResolutionScalePreflight( } export async function qualifyResolutionScaleBudget( - budgetPrototype: any, + budgetPrototype: { beginFrame?(now: number, clock: number): void } | undefined, pump: () => Promise, ): Promise { const begin = budgetPrototype?.beginFrame; - if (typeof begin !== "function") + 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: any[]) { + budgetPrototype.beginFrame = function (...args: Parameters) { receiver = this; return begin.apply(this, args); }; @@ -89,7 +89,17 @@ export function resolutionScaleTextureBytes( return textureUtils._getBytesPerTexel.bind(textureUtils); } -export function textureDescriptorBytes(descriptor: any, bytesPerTexel: number): number { +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; @@ -98,12 +108,12 @@ export function textureDescriptorBytes(descriptor: any, bytesPerTexel: number): : [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)) + 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(width! / 2 ** mip)) * Math.max(1, Math.floor(height / 2 ** mip)) * (descriptor.dimension === "3d" ? Math.max(1, Math.floor(layers / 2 ** mip)) : layers) * samples * @@ -113,6 +123,7 @@ export function textureDescriptorBytes(descriptor: any, bytesPerTexel: number): } interface IResolutionScaleCensus { + incompleteReasons?: readonly string[]; complete: boolean; counts: { deviceCreations?: number; failures: number; pending: number; droppedEvents: number }; } @@ -232,9 +243,18 @@ export function resolutionScaleGate(runs: IResolutionScaleRun[], timingQualified } export function installResolutionScaleProbe( - budgetPrototype: any, - renderer: any, - devicePrototype: any, + 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; @@ -245,10 +265,10 @@ export function installResolutionScaleProbe( let start = 0; let timestamp = 0; let previousTimestamp: number | undefined; - let initialCensus: any; - let stepCensusAfter: any; - let allocations: any[] = []; - let frames: any[] = []; + 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 = () => { @@ -272,7 +292,9 @@ export function installResolutionScaleProbe( restore(); reject(error); }; - devicePrototype.createTexture = function (descriptor: any) { + 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); @@ -293,7 +315,7 @@ export function installResolutionScaleProbe( 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"); + renderer.setResolutionScale(scales[step]!, "auto-pinned"); } } catch (error) { fail(error); @@ -312,7 +334,7 @@ export function installResolutionScaleProbe( previousTimestamp === undefined ? undefined : timestamp - previousTimestamp, surface: renderer.surface(), }); - if (frame % 31 === 0) stepCensusAfter = renderer.pipelineCensus?.(); + if (frame % 31 === 0) stepCensusAfter = renderer.pipelineCensus?.()!; if (frame % 31 === 30) { const after = census(); const next30 = frames @@ -325,12 +347,12 @@ export function installResolutionScaleProbe( frames: [...frames], allocations: [...allocations], textureBytes: allocations.reduce((sum, value) => sum + value.bytes, 0), - pipelineBuilds: after.counts.deviceCreations - initialCensus.counts.deviceCreations, + pipelineBuilds: after.counts.deviceCreations! - initialCensus.counts.deviceCreations!, censusBefore: initialCensus, stepCensusAfter, censusAfter: after, - windowP50Ms: p50, - stepRatio: frames[0].frameMs / p50, + windowP50Ms: p50!, + stepRatio: frames[0]!.frameMs / p50!, }; rows.push(row); console.info(`TN_RESOLUTION_STEP:${JSON.stringify(row)}`); From a66652c73d61e747b275f30bdbba47742e95721d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 17:05:59 -0700 Subject: [PATCH 03/53] fix(lint): preserve donor motion schema names --- biome.json | 31 +++++++++++++++++++ ...e-changes-without-reallocating-a-target.md | 2 ++ 2 files changed, 33 insertions(+) diff --git a/biome.json b/biome.json index 3986db9351..2dc6baa87b 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-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 668178312a..4bce7b5103 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -133,4 +133,6 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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-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. From e5cf6d966863af6a2c950cee375b33e8fd8719a9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 17:15:58 -0700 Subject: [PATCH 04/53] test(playtest): persist resolution startup checkpoints --- ...e-changes-without-reallocating-a-target.md | 2 + .../__tests__/resolution-scale-probe.spec.ts | 42 +++++++++++++ .../src/runner/measure-resolution-scale.ts | 63 ++++++++++++++----- .../src/runner/resolution-scale-probe.ts | 10 +++ 4 files changed, 100 insertions(+), 17 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 4bce7b5103..62b36884f5 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -135,4 +135,6 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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-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. diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts index 8aa0542a41..967717a773 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runInNewContext } from "node:vm"; import { tsImport } from "tsx/esm/api"; import type { Page } from "playwright"; @@ -319,3 +322,42 @@ it.each([ 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 mkdtemp(join(tmpdir(), "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; }); + try { + 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 }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 396afe0d95..7a1fb7c01c 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -11,6 +11,7 @@ import { resolutionScaleFrame, preserveResolutionScaleFailure, resolutionScaleGate, + resolutionScalePending, } from "./resolution-scale-probe.js"; import type { IPlaytestBridgeDescription, IPlaytestBridgeReady } from "../protocol.js"; @@ -22,13 +23,29 @@ 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 }); -const server = await serveStarter(output); +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); +const server = await pending("starter.bootstrap", () => serveStarter(output, checkpoint)); const url = server.resolvedUrls!.local[0]!; -const display = await provideDisplay(); +const display = await pending("display.provide", () => provideDisplay()); +checkpointDisplay.value = display.strategy; let browser: Browser | undefined; const measurements: IResolutionScaleRun[] = []; try { - browser = await chromium.launch({ + browser = await pending("browser.launch", () => chromium.launch({ headless: false, args: [ ...WEBGPU_BROWSER_ARGS, @@ -38,16 +55,11 @@ try { "--no-sandbox", ], env: display.env, - }); + })); for (let run = 1; run <= (preflightOnly ? 1 : 3); run++) { - const context = await browser.newContext({ - viewport: { width: 1280, height: 720 }, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); + checkpointRun = run; const logs: string[] = []; - page.on("console", (message) => logs.push(message.text())); - page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); + checkpointLogs = logs; const evidence: { startup: { collected: boolean; @@ -56,12 +68,20 @@ try { partial: { collected: boolean; value?: Record }; rows?: IResolutionScaleRow[]; } = { 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}`)); try { - await page.goto(url); + await pending("page.goto", () => page.goto(url)); const transport = new PlaywrightTransport(page); const bootError = async () => (await page.locator("[data-threenative-canvas-error]").allTextContents()).join("\n"); - if (!(await transport.waitForBridge(bridgeWaitTimeoutMs()))) { + if (!(await pending("bridge.wait", () => transport.waitForBridge(bridgeWaitTimeoutMs())))) { const error = await bootError(); throw new Error( error @@ -69,12 +89,12 @@ try { : "TN_PLAYTEST_BRIDGE_MISSING: starter did not install its bridge", ); } - const description = await transport.call( + const description = await pending("bridge.describe", () => transport.call( "describe", undefined, bridgeWaitTimeoutMs(), - ); - const startup = await waitForStartupReady({ + )); + const startup = await pending("startup.ready", () => waitForStartupReady({ bridge: { description, readiness: async () => { @@ -88,7 +108,7 @@ try { 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"); @@ -96,6 +116,7 @@ try { page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) navigations.push(frame.url()); }); + await checkpoint("page.evaluate"); const observed = await page .evaluate<{ preflight?: { @@ -241,14 +262,20 @@ try { 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"); const target = await mkdtemp(resolve(output, "starter-")); + await checkpoint("starter.scaffold"); await createProject({ target, template: "starter", install: false }); + await checkpoint("starter.link-dependencies"); await mkdir(resolve(target, "node_modules")); for (const name of [ "three", @@ -266,6 +293,7 @@ async function serveStarter( resolve(target, "node_modules", name), "dir", ); + await checkpoint("starter.create-server"); const server = await createServer({ root: target, cacheDir: resolve(target, "node_modules/.vite"), @@ -293,6 +321,7 @@ async function serveStarter( ], server: { host: "127.0.0.1", port: 0, fs: { allow: [repo] } }, }); + await checkpoint("starter.listen"); await server.listen(); return server; } diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts index 8a43516755..8999382a2c 100644 --- a/packages/playtest/src/runner/resolution-scale-probe.ts +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -380,3 +380,13 @@ export async function preserveResolutionScaleFailure( 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(); +} From e540c001df5abfaa61d1ffb4467a75e34fa43ec6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 21:11:10 -0700 Subject: [PATCH 05/53] docs(prd564): record NVIDIA phase one measurements --- ...olution-scale-changes-without-reallocating-a-target.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 62b36884f5..dc117cc198 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -1,7 +1,7 @@ # 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. +**Status:** IN PROGRESS +**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 NVIDIA hardware evidence, but its timing gate is still unqualified under shared host load. **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 **Owner:** João (decline decision after Phase 1), agent (implementation) **Depends on:** [PRD-384](../performance/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](../rendering/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (a reconstructor needs a scale that can change often and cheaply). @@ -99,7 +99,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** NOT STARTED +**Status:** IN PROGRESS — NVIDIA hardware measurement complete; timing qualification and the continue/decline decision remain open. **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. @@ -137,4 +137,6 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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. The earlier `TN_PLAYTEST_BRIDGE_MISSING` artifact was setup-only: the worktree had no root `node_modules`, so the scaffold's nine dependency links resolved into an absent directory. 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-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. From 78f74d26d0c80520daf52ddaf8d22cba6085832d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 21:25:34 -0700 Subject: [PATCH 06/53] docs(prd564): record post-merge qualification prerequisites --- ...the-resolution-scale-changes-without-reallocating-a-target.md | 1 + 1 file changed, 1 insertion(+) 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 e0f5c16ad0..226d2767be 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 @@ -143,4 +143,5 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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-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. From 3cf6ee42119bb7aabd8639ec419e2f4b4d36bba4 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 21:56:18 -0700 Subject: [PATCH 07/53] fix(playtest): load browser runner without process --- ...le-changes-without-reallocating-a-target.md | 1 + .../playtest/__tests__/browser-input.spec.ts | 18 ++++++++++++++++++ packages/playtest/src/runner/browser.ts | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) 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 226d2767be..aa066f51af 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 @@ -144,4 +144,5 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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-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. diff --git a/packages/playtest/__tests__/browser-input.spec.ts b/packages/playtest/__tests__/browser-input.spec.ts index b284b5401c..8a780519fb 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/src/runner/browser.ts b/packages/playtest/src/runner/browser.ts index b0fe9c4405..f837c768fe 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; From 6aa4362b37cc1123c38d17cea690ae9bd4017394 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 22:10:40 -0700 Subject: [PATCH 08/53] fix(playtest): clean up resolution measurement startup --- ...e-changes-without-reallocating-a-target.md | 1 + .../measure-resolution-scale.spec.ts | 83 +++++++++++++++++++ .../src/runner/measure-resolution-scale.ts | 29 ++++--- 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 packages/playtest/__tests__/measure-resolution-scale.spec.ts 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 aa066f51af..fed4d32ed2 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 @@ -145,4 +145,5 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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. 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 0000000000..07812a7363 --- /dev/null +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +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(), + createProject: vi.fn(), + createServer: vi.fn(), + display, + fs: { + mkdir: vi.fn(), + mkdtemp: 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("../../create-threenative/src/index.ts", () => ({ createProject: fixture.createProject })); + +const importMeasurement = () => { + vi.resetModules(); + return import("../src/runner/measure-resolution-scale.js"); +}; + +beforeEach(() => { + vi.resetModules(); + fixture.browserLaunch.mockReset(); + 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.mkdtemp.mockReset().mockResolvedValue("/tmp/prd564-starter-"); + 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(); + + 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(); +}); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 7a1fb7c01c..3a7512c1d3 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -38,13 +38,17 @@ const checkpoint = async (pendingOperation: string): Promise => { }; const pending = (name: string, operation: () => Promise): Promise => resolutionScalePending(name, checkpoint, operation); -const server = await pending("starter.bootstrap", () => serveStarter(output, checkpoint)); -const url = server.resolvedUrls!.local[0]!; -const display = await pending("display.provide", () => provideDisplay()); -checkpointDisplay.value = display.strategy; +let server: Awaited> | undefined; +let display: Awaited> | undefined; let browser: Browser | undefined; const measurements: IResolutionScaleRun[] = []; try { + const startedServer = await pending("starter.bootstrap", () => serveStarter(output, checkpoint)); + server = startedServer; + const url = startedServer.resolvedUrls!.local[0]!; + const providedDisplay = await pending("display.provide", () => provideDisplay()); + display = providedDisplay; + checkpointDisplay.value = providedDisplay.strategy; browser = await pending("browser.launch", () => chromium.launch({ headless: false, args: [ @@ -54,7 +58,7 @@ try { "--disable-vulkan-surface", "--no-sandbox", ], - env: display.env, + env: providedDisplay.env, })); for (let run = 1; run <= (preflightOnly ? 1 : 3); run++) { checkpointRun = run; @@ -197,7 +201,7 @@ try { { run, mode: preflightOnly ? "preflight" : "measurement", - display: display.strategy, + display: providedDisplay.strategy, startup, adapter, preflight, @@ -236,7 +240,7 @@ try { run, mode: preflightOnly ? "preflight" : "measurement", status: "failed", - display: display.strategy, + display: providedDisplay.strategy, ...evidence, error: { name: error instanceof Error ? error.name : "Error", @@ -256,8 +260,8 @@ try { } } finally { await browser?.close(); - await display.release(); - await server.close(); + await display?.release(); + await server?.close(); } async function serveStarter( @@ -322,6 +326,11 @@ async function serveStarter( server: { host: "127.0.0.1", port: 0, fs: { allow: [repo] } }, }); await checkpoint("starter.listen"); - await server.listen(); + try { + await server.listen(); + } catch (error) { + await server.close(); + throw error; + } return server; } From 11b0a70cf6e9822908050f5d58210e3dffa48edb Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 23:01:15 -0700 Subject: [PATCH 09/53] test(playtest): qualify PRD-564 resize continuation --- ...e-changes-without-reallocating-a-target.md | 13 ++++++---- .../measure-resolution-scale.spec.ts | 24 +++++++++++++++++++ .../src/runner/measure-resolution-scale.ts | 13 +++++++--- 3 files changed, 43 insertions(+), 7 deletions(-) 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 fed4d32ed2..dbc7fdce6e 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** IN PROGRESS -**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 NVIDIA hardware evidence, but its timing gate is still unqualified under shared host load. +**Priority:** P2 — Phase 1 earns continuation on a controlled RTX 2080 run; retained targets, sub-rectangle sampling, finer scaling and native parity remain open. **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 **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). @@ -101,12 +101,12 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** IN PROGRESS — NVIDIA hardware measurement complete; timing qualification and the continue/decline decision remain open. +**Status:** COMPLETE — controlled timing qualified; the 0.85 step in run 1 crosses the unchanged strict 2× gate. **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. +- [x] 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`. See Decisions for the per-run controls and narrow crossing. #### Phase 2: Draw into the sub-rectangle -**Status:** PARTIAL +**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`. @@ -147,3 +147,8 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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. diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts index 07812a7363..767256dee4 100644 --- a/packages/playtest/__tests__/measure-resolution-scale.spec.ts +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -13,6 +13,8 @@ const fixture = vi.hoisted(() => { }; return { browserLaunch: vi.fn(), + captureLease: { release: vi.fn() }, + acquireCaptureLease: vi.fn(), createProject: vi.fn(), createServer: vi.fn(), display, @@ -36,6 +38,7 @@ vi.mock("node:module", () => ({ 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 })); const importMeasurement = () => { @@ -46,6 +49,8 @@ const importMeasurement = () => { 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); @@ -64,6 +69,8 @@ test("startup failures close acquired resources and preserve the originating err 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(); @@ -81,3 +88,20 @@ test("startup failures close acquired resources and preserve the originating err 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(); +}); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 3a7512c1d3..fb4e9baaad 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -5,6 +5,7 @@ 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 { @@ -41,11 +42,13 @@ const pending = (name: string, operation: () => Promise): Promise => 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; @@ -259,9 +262,13 @@ try { } } } finally { - await browser?.close(); - await display?.release(); - await server?.close(); + try { + await browser?.close(); + await display?.release(); + await server?.close(); + } finally { + await captureLease?.release(); + } } async function serveStarter( From a0fcca68e2439d6c003f28a607f631a4d1ccd50d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 09:05:00 -0700 Subject: [PATCH 10/53] perf: retain render targets for resolution scaling --- packages/core/__tests__/render-chain.spec.ts | 38 +++ .../renderer-resolution-subrect.spec.ts | 193 +++++++++++++++ .../resolution-scaler-continuous.spec.ts | 51 ++++ packages/core/src/frame-budget.ts | 5 + packages/core/src/render/chain.ts | 40 ++- .../core/src/render/resolution-subrect.ts | 231 ++++++++++++++++++ packages/core/src/renderer.ts | 38 ++- packages/core/src/resolution-scaler.ts | 141 ++++++++++- .../world-environment-lifetime.spec.ts | 75 ++++++ .../template-assets/worldEnvironment.ts | 105 +++++++- .../action-rpg/src/render/worldEnvironment.ts | 105 +++++++- .../minimal/src/render/worldEnvironment.ts | 105 +++++++- .../platformer/src/render/worldEnvironment.ts | 105 +++++++- .../puzzle/src/render/worldEnvironment.ts | 105 +++++++- .../racing/src/render/worldEnvironment.ts | 105 +++++++- .../rain/src/render/worldEnvironment.ts | 105 +++++++- .../rts/src/render/worldEnvironment.ts | 105 +++++++- .../runner/src/render/worldEnvironment.ts | 105 +++++++- .../sailing/src/render/worldEnvironment.ts | 105 +++++++- .../shooter/src/render/worldEnvironment.ts | 105 +++++++- .../snow/src/render/worldEnvironment.ts | 105 +++++++- .../templates/starter/src/render/grade.ts | 39 ++- .../starter/src/render/worldEnvironment.ts | 105 +++++++- .../src/render/worldEnvironment.ts | 105 +++++++- .../runtime-native/conformance/registry.json | 16 ++ .../scenes/shared/resolution-subrect.js | 55 +++++ 26 files changed, 2210 insertions(+), 182 deletions(-) create mode 100644 packages/core/__tests__/renderer-resolution-subrect.spec.ts create mode 100644 packages/core/__tests__/resolution-scaler-continuous.spec.ts create mode 100644 packages/core/src/render/resolution-subrect.ts create mode 100644 packages/runtime-native/conformance/scenes/shared/resolution-subrect.js diff --git a/packages/core/__tests__/render-chain.spec.ts b/packages/core/__tests__/render-chain.spec.ts index 47454ba028..7f2149a8f1 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 0000000000..4377dc385a --- /dev/null +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -0,0 +1,193 @@ +import { DepthTexture, RenderTarget, Texture } from "three"; +import { PerspectiveCamera, Scene } from "three"; +import { pass } from "three/tsl"; +import { describe, expect, it, vi } from "vitest"; +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"); + } +}); 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 0000000000..f1415e9361 --- /dev/null +++ b/packages/core/__tests__/resolution-scaler-continuous.spec.ts @@ -0,0 +1,51 @@ +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("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({ continuous: true, start: 1, targetFps: 60 }); + expect(holds.observe(windowOf(16.8))).toBeUndefined(); + expect(holds.observe(windowOf(16.8))).toBeUndefined(); + + const climbs = new ResolutionScaler({ continuous: true, 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); + }); +}); diff --git a/packages/core/src/frame-budget.ts b/packages/core/src/frame-budget.ts index 7dbc61dfd6..01b6a4510a 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 17d0610b84..8436039f7e 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 0000000000..a098fa063a --- /dev/null +++ b/packages/core/src/render/resolution-subrect.ts @@ -0,0 +1,231 @@ +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; + activate?(scale: number): void; + deactivate?(): void; + setScale?(scale: number): void; + setSize?(width: number, height: number): void; + retain(node: IResolutionSubrectNode, targets: readonly RenderTarget[]): void; + dispose(): void; +} + +export class ResolutionSubrect implements IResolutionSubrect { + readonly viewSize = 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; + 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); + 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) 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); + } + + 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; + } + + 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 4b97750575..1c01b8b60e 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 3dd0792059..d7f09e27ca 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 @@ -195,6 +201,8 @@ export interface IResolutionScalerOptions { * lifts the floor for as long as it is installed. */ readonly temporalUpscale?: () => boolean; + /** Enable continuous estimates even when the observed surface does not expose a sub-rectangle. */ + readonly continuous?: boolean | (() => boolean); } /** A closed frame-budget window's presentation distribution and optional GPU observation. */ @@ -209,6 +217,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,9 +253,11 @@ 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; + readonly #continuous: boolean | (() => boolean) | undefined; #scaleSource: Exclude = "auto"; #cleanWindows = 0; #cooldown = 0; @@ -272,14 +291,18 @@ export class ResolutionScaler { #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; + this.#continuous = options.continuous; } /** @@ -302,7 +325,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 +356,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 +392,7 @@ export class ResolutionScaler { this.#trackInsensitiveRise(gpuMs); const refunded = this.#settleInsensitiveProbe(gpuMs); if (refunded !== undefined) return refunded; + if (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, @@ -460,6 +487,7 @@ export class ResolutionScaler { if (gain >= RESOLUTION_SCALER.insensitiveGainFraction) return undefined; const restored = 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; @@ -536,6 +564,116 @@ export class ResolutionScaler { this.#probeFps = window.fps; return this.#step(1); } + + #continuousActive(window: IScalerWindow): boolean { + const explicit = + typeof this.#continuous === "function" ? this.#continuous() : this.#continuous === true; + return explicit || 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; + } + const estimate = this.#continuousScale * Math.sqrt(this.budgetMs / gpuMs); + const target = this.#quantizeContinuous(Math.max(floor, estimate), window); + this.#atFloor = target <= floor; + return this.#continuousChange(Math.max(floor, target), false); + } + 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(). @@ -633,6 +771,7 @@ export class ResolutionScaler { // more rungs than the floor allows, and it must land on the floor rather than past it. const next = this.#index + direction; this.#index = direction > 0 ? Math.min(next, this.#limitIndex) : Math.max(0, next); + this.#continuousScale = RESOLUTION_SCALER.rungs[this.#index] ?? 1; this.#cooldown = RESOLUTION_SCALER.cooldownWindows; // 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. diff --git a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts index b34d617796..3a0b8d9bc6 100644 --- a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts @@ -252,3 +252,78 @@ 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(), 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, + retain: 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(); + } +}); diff --git a/packages/create-threenative/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 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,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 fffe7f000f..1b00928316 100644 --- a/packages/create-threenative/templates/starter/src/render/grade.ts +++ b/packages/create-threenative/templates/starter/src/render/grade.ts @@ -22,10 +22,20 @@ // `(x/W)^g / (1 + (x/W)^g)` with `W = 4, g = 1/2.2` cost 4.3. `__tests__/grade.spec.ts` holds the // arithmetic; the value of the transform's own seat is that an identity table there is under one // step by construction rather than by a fitted curve. -import { type Data3DTexture, DataUtils, FloatType, HalfFloatType } from "three"; +import { type Data3DTexture, DataUtils, FloatType, HalfFloatType, type Vector2 } from "three"; import { film } from "three/addons/tsl/display/FilmNode.js"; import { lut3D } from "three/addons/tsl/display/Lut3DNode.js"; -import { float, renderOutput, texture3D, time, uv } from "three/tsl"; +import { + clamp, + convertToTexture, + float, + renderOutput, + screenUV, + texture3D, + time, + uniform, + uv, +} from "three/tsl"; import type { Node } from "three/webgpu"; import type { QualityTier } from "./quality.js"; import type { ChainStage } from "./worldEnvironment.js"; @@ -60,6 +70,19 @@ export interface IGradeTable { readonly texture: Data3DTexture; } +type ResolutionSubrectView = { + readonly viewFraction: Vector2; + readonly halfTexel: Vector2; +}; + +function subrectColour(input: Node<"vec4">, view: ResolutionSubrectView | undefined): Node<"vec4"> { + if (view === undefined) return input; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const uvNode = clamp(screenUV.mul(fraction), halfTexel, fraction.sub(halfTexel)); + return convertToTexture(input).sample(uvNode) as unknown as Node<"vec4">; +} + /** * The table `LUTCubeLoader` parsed as `FloatType`, uploaded as half floats so the GPU can filter it. * @@ -117,16 +140,19 @@ export function gradeStages( return [ { name: "grade", + supportsResolutionSubrect: true, afterOutputTransform: true, available: () => refusal("grade", settings, loaded), - build: (input) => { + build: (input, context) => { if (table === undefined) throw new Error( "grade built before its table loaded; available() should have refused it.", ); // The pipeline's own transform, applied here because `afterOutputTransform` took it away // from the renderer. Same tone curve, same encode, same exposure — applied once. - const display = renderOutput(input as Node<"vec4">); + const display = renderOutput( + subrectColour(input as Node<"vec4">, context.resolutionSubrect), + ); const graded = lut3D( display, texture3D(table.texture), @@ -141,11 +167,12 @@ export function gradeStages( }, { name: "grain", + supportsResolutionSubrect: true, after: "grade", available: () => refusal("grain", settings, loaded), - build: (input) => + build: (input, context) => film( - input as Node<"vec4">, + subrectColour(input as Node<"vec4">, context.resolutionSubrect), float(settings.grainIntensity), // `film()` adds the clock to whatever uv it is handed, so a still grain hands it back. settings.grainAnimated ? undefined : uv().sub(time), diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index 7fa978e095..3ca5e1fbe2 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -21,7 +21,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,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 7fa978e095..3ca5e1fbe2 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,9 @@ import { MaterialBlending, NeutralToneMapping, type PerspectiveCamera, + type RenderTarget, type Scene, + type Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -33,6 +35,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, @@ -45,6 +48,7 @@ import { roughness, screenUV, smoothstep, + uniform, vec2, vec4, } from "three/tsl"; @@ -276,6 +280,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 +294,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 +317,7 @@ export interface IWorldEnvironmentStageContext { export type ChainStage = { readonly name: string; + readonly supportsResolutionSubrect?: boolean; readonly before?: string; readonly after?: string; /** @@ -344,6 +354,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,6 +376,16 @@ 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; + textureView?(texture: unknown): unknown; + dispose(): void; +}; type OutputSetter = | ((node: unknown, worldPass?: unknown) => void) | ((node: unknown, worldPass?: unknown) => OutputInstallation); @@ -372,6 +393,7 @@ type OutputSetter = 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 +419,7 @@ export type OutputRenderer = { }; }; stages?: readonly ChainStage[]; + resolutionSubrect?: ResolutionSubrectController; }) => { applied: { dropped: readonly { name: string; reason: string }[]; stages: readonly string[] }; dispose(): void; @@ -534,9 +557,24 @@ 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]); // 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 @@ -595,8 +633,33 @@ export class WorldEnvironment { owned.renderTarget.dispose(); owned._quadMesh.material.dispose(); } + resolutionSubrect?.dispose(); scenePass.dispose(); }; + const remappedTextures = new WeakMap>(); + const subrectTexture = ( + input: ChainNode, + context: ChainContext, + ): ReturnType => { + const texture = convertToTexture(input); + const view = context.resolutionSubrect; + if (view === undefined) return texture; + const existing = remappedTextures.get(texture); + if (existing !== undefined) return existing; + const fraction = uniform(view.viewFraction); + const halfTexel = uniform(view.halfTexel); + const originalSample = texture.sample.bind(texture); + texture.sample = (uvNode) => + originalSample( + clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), + ); + remappedTextures.set(texture, texture); + return texture; + }; + const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { + if (context.resolutionSubrect === undefined) return input; + return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; + }; const giDenoise = (node: ChainNode): ChainNode => options.denoiseEnabled ? denoised(denoise(node, depth(), normal(), view)) : node; @@ -649,8 +712,13 @@ export class WorldEnvironment { }), stage({ name: "ambientOcclusion", - build: (input) => { - const contact = ao(depth(), normal(), view); + supportsResolutionSubrect: true, + build: (input, context) => { + const contact = ao( + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ); contact.radius.value = options.gtaoRadius; contact.scale.value = options.gtaoScale; contact.samples.value = options.gtaoSamples; @@ -665,9 +733,16 @@ export class WorldEnvironment { // 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; - return input.mul(occlusion.r); + ? denoised( + denoise( + subrectTexture(contact.getTextureNode(), context), + subrectTexture(depth(), context), + subrectTexture(normal(), context), + view, + ), + ) + : subrectTexture(contact.getTextureNode(), context); + return subrectColour(input, context).mul(occlusion.r); }, }), stage({ @@ -753,12 +828,14 @@ export class WorldEnvironment { }), stage({ name: "bloom", + supportsResolutionSubrect: true, dispose: () => { releaseBloom(); releaseBloom = () => {}; }, - build: (input) => { - const texture = convertToTexture(input); + build: (input, context) => { + const texture = subrectTexture(input, context); + const colour = subrectColour(input, context); const effect = bloom( texture, options.bloomStrength, @@ -778,19 +855,21 @@ export class WorldEnvironment { scratch._quadMesh.material.dispose(); } }; - return input.add(effect); + return colour.add(effect); }, }), stage({ name: "vignette", - build: (input) => { + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input, context); // 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 fall = smoothstep(float(0.55), float(1.02), radius).mul(options.vignetteAmount); - return input.mul(fall.oneMinus()); + return colour.mul(fall.oneMinus()); }, }), { @@ -798,8 +877,9 @@ export class WorldEnvironment { // stage after it would put the stairsteps back. name: "antialias", after: "gradualBackground", - build: (input) => { - const colour = input as ChainNode; + supportsResolutionSubrect: true, + build: (input, context) => { + const colour = subrectColour(input as ChainNode, context); // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) @@ -880,6 +960,7 @@ export class WorldEnvironment { }, }, stages, + resolutionSubrect, worldPass: scenePass, }); this.#reportApplied(chain.applied.stages, chain.applied.dropped); diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 761802cf6e..fd27948fef 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 0000000000..cf8b2dfc6e --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js @@ -0,0 +1,55 @@ +import * as THREE from "three/webgpu"; +import { clamp, pass, screenUV, uniform } from "three/tsl"; +import { startVisualScene } from "./scene-support.js"; + +const SCALE = 0.72; + +export function startScene(canvas, dimensions) { + 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 capacityWidth = dimensions.width; + const capacityHeight = dimensions.height; + const viewWidth = Math.ceil((capacityWidth * SCALE) / 8) * 8; + const viewHeight = Math.ceil((capacityHeight * SCALE) / 8) * 8; + const scenePass = pass(scene, camera); + scenePass.setSize(capacityWidth, capacityHeight); + scenePass.setViewport(0, 0, viewWidth, viewHeight); + scenePass.setScissor(0, 0, viewWidth, viewHeight); + + const viewFraction = uniform(new THREE.Vector2(viewWidth / capacityWidth, viewHeight / capacityHeight)); + const halfTexel = uniform(new THREE.Vector2(0.5 / capacityWidth, 0.5 / capacityHeight)); + const uvNode = clamp( + screenUV.mul(viewFraction), + halfTexel, + viewFraction.sub(halfTexel), + ); + const pipeline = new THREE.RenderPipeline(renderer); + pipeline.outputNode = scenePass.getTextureNode("output").sample(uvNode); + pipeline.render(); + + if ( + scenePass.renderTarget.width !== capacityWidth || + scenePass.renderTarget.height !== capacityHeight || + scenePass.renderTarget.viewport.z !== viewWidth || + scenePass.renderTarget.viewport.w !== viewHeight || + scenePass.renderTarget.scissor.z !== viewWidth || + scenePass.renderTarget.scissor.w !== viewHeight + ) + throw new Error("Resolution sub-rectangle did not retain the full target capacity."); + return { + render: () => pipeline.render(), + detail: { + capacity: [capacityWidth, capacityHeight], + view: [viewWidth, viewHeight], + scale: SCALE, + sampling: "viewFraction*screenUV-clamped-to-half-texel", + }, + }; + }); +} From f62319652136a71342ada01d59184da7e3f544ef Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:18:33 -0700 Subject: [PATCH 11/53] docs(prd564): record implementation and pending runtime proof --- ...on-scale-changes-without-reallocating-a-target.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 dbc7fdce6e..643114f453 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 @@ -2,7 +2,7 @@ **Status:** IN PROGRESS **Priority:** P2 — Phase 1 earns continuation on a controlled RTX 2080 run; retained targets, sub-rectangle sampling, finer scaling and native parity remain open. -**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 +**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). @@ -106,13 +106,15 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - [x] 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`. 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 +**Status:** PARTIAL — implementation committed; runtime allocation and visual acceptance remain open. +**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. - [ ] 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`. #### Phase 3: Finer steps and native -**Status:** NOT STARTED +**Status:** PARTIAL — continuous scaler and conformance scene committed; native execution remains open. **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`. @@ -152,3 +154,5 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - 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. From 729d821dfae74c0786954cc9c8eb3f471f7b171c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:22:14 -0700 Subject: [PATCH 12/53] docs(prd564): record exact native subrect parity --- ...he-resolution-scale-changes-without-reallocating-a-target.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 643114f453..2d20fa0b5c 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 @@ -117,7 +117,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu **Status:** PARTIAL — continuous scaler and conformance scene committed; native execution remains open. **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 native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect --reference artifacts/prd564/parity-web-a0 --out artifacts/prd564/parity-desktop-a0` with matching NVIDIA userspace — selected row PASS, 300 native frames, pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Both bounded reports retain 103 unselected rows as blocked and exit 2; no full-registry claim. Reports: `packages/runtime-native/artifacts/prd564/parity-{web,desktop}-a0/report.json`. ## Decisions From 0c54f7f4fd1f247bef7626f80e831747e0d0740f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:25:11 -0700 Subject: [PATCH 13/53] docs(prd564): reopen native proof for shipped controller --- ...-resolution-scale-changes-without-reallocating-a-target.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2d20fa0b5c..42efaab647 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 @@ -117,7 +117,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu **Status:** PARTIAL — continuous scaler and conformance scene committed; native execution remains open. **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`. -- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect --reference artifacts/prd564/parity-web-a0 --out artifacts/prd564/parity-desktop-a0` with matching NVIDIA userspace — selected row PASS, 300 native frames, pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Both bounded reports retain 103 unselected rows as blocked and exit 2; no full-registry claim. Reports: `packages/runtime-native/artifacts/prd564/parity-{web,desktop}-a0/report.json`. +- [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect`. — Current manual-pass scene matches exactly across browser/desktop (pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors), but fresh review found it bypasses the shipped controller and scale changes. Reopened pending a conformance scene exercising the shipped mechanism. Bounded reports: `packages/runtime-native/artifacts/prd564/parity-{web,desktop}-a0/report.json`; unselected rows remain blocked, exit 2. ## Decisions @@ -156,3 +156,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. From 3bea8eeccf718b3081103de64b939f8de312c511 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:38:11 -0700 Subject: [PATCH 14/53] test(native): exercise retained resolution controller --- .../scenes/shared/resolution-subrect.js | 59 ++++++++++++------- .../tests/conformance-runner.test.mjs | 48 +++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js index cf8b2dfc6e..2446b14ae1 100644 --- a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js +++ b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js @@ -1,8 +1,10 @@ import * as THREE from "three/webgpu"; import { clamp, pass, screenUV, uniform } from "three/tsl"; +import { ResolutionSubrect } from "../../../../core/src/render/resolution-subrect.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) { return startVisualScene(canvas, dimensions, "resolution-subrect", ({ renderer, scene, camera }) => { @@ -13,17 +15,16 @@ export function startScene(canvas, dimensions) { subject.rotation.set(0.3, 0.4, 0.1); scene.add(subject); - const capacityWidth = dimensions.width; - const capacityHeight = dimensions.height; - const viewWidth = Math.ceil((capacityWidth * SCALE) / 8) * 8; - const viewHeight = Math.ceil((capacityHeight * SCALE) / 8) * 8; const scenePass = pass(scene, camera); - scenePass.setSize(capacityWidth, capacityHeight); - scenePass.setViewport(0, 0, viewWidth, viewHeight); - scenePass.setScissor(0, 0, viewWidth, viewHeight); + const controller = new ResolutionSubrect(); + controller.retain(scenePass, [scenePass.renderTarget]); + controller.activate(1); + controller.setSize(dimensions.width, dimensions.height); - const viewFraction = uniform(new THREE.Vector2(viewWidth / capacityWidth, viewHeight / capacityHeight)); - const halfTexel = uniform(new THREE.Vector2(0.5 / capacityWidth, 0.5 / capacityHeight)); + 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, @@ -31,22 +32,40 @@ export function startScene(canvas, dimensions) { ); const pipeline = new THREE.RenderPipeline(renderer); pipeline.outputNode = scenePass.getTextureNode("output").sample(uvNode); - pipeline.render(); - if ( - scenePass.renderTarget.width !== capacityWidth || - scenePass.renderTarget.height !== capacityHeight || - scenePass.renderTarget.viewport.z !== viewWidth || - scenePass.renderTarget.viewport.w !== viewHeight || - scenePass.renderTarget.scissor.z !== viewWidth || - scenePass.renderTarget.scissor.w !== viewHeight - ) - throw new Error("Resolution sub-rectangle did not retain the full target capacity."); + 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); + pipeline.render(); + assertTransition(scale); + } + return { render: () => pipeline.render(), detail: { capacity: [capacityWidth, capacityHeight], - view: [viewWidth, viewHeight], + 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 05238b39a4..c67bccfb4e 100644 --- a/packages/runtime-native/tests/conformance-runner.test.mjs +++ b/packages/runtime-native/tests/conformance-runner.test.mjs @@ -1339,6 +1339,54 @@ 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 source = readFileSync(join(root, scene), "utf8"); + assert.match( + source, + /import \{ ResolutionSubrect \} from "\.\.\/\.\.\/\.\.\/\.\.\/core\/src\/render\/resolution-subrect\.ts"/u, + ); + assert.match(source, /controller\.retain\(scenePass, \[scenePass\.renderTarget\]\)/u); + assert.match(source, /controller\.activate\(1\)[\s\S]*controller\.setSize\(/u); + assert.match( + source, + /for \(const scale of \[SCALE, 1, SCALE\]\) \{[\s\S]*controller\.setScale\(scale\);[\s\S]*pipeline\.render\(\);[\s\S]*assertTransition\(scale\);[\s\S]*\}/u, + ); + for (const assertion of [ + /target\.width !== capacityWidth/u, + /target\.viewport\.z !== viewWidth/u, + /target\.scissor\.z !== viewWidth/u, + /view\.viewFraction\.x !==/u, + /view\.halfTexel\.x !==/u, + ]) + assert.match(source, assertion); + assert.doesNotMatch(source, /scenePass\.set(?:Viewport|Scissor)\(/u); + + 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 { From f335aca1f4b920827a5c66a27d9b9650658231e9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:49:02 -0700 Subject: [PATCH 15/53] fix(render): retain post-process targets and preserve scaler guards --- ...e-changes-without-reallocating-a-target.md | 5 +- .../renderer-resolution-subrect.spec.ts | 42 +++ .../resolution-scaler-continuous.spec.ts | 49 ++- .../core/src/render/resolution-subrect.ts | 19 +- packages/core/src/resolution-scaler.ts | 38 ++- .../world-environment-lifetime.spec.ts | 125 +++++++- .../template-assets/worldEnvironment.ts | 285 ++++++++++++++---- .../action-rpg/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../minimal/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../platformer/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../puzzle/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../racing/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../rain/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../rts/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../runner/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../sailing/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../shooter/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../snow/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../templates/starter/src/render/grade.ts | 37 +-- .../starter/src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../src/render/worldEnvironment.ts | 285 ++++++++++++++---- .../measure-resolution-scale.spec.ts | 14 + .../src/runner/measure-resolution-scale.ts | 2 +- 23 files changed, 3530 insertions(+), 791 deletions(-) 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 42efaab647..c7ad58c50c 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 @@ -116,7 +116,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu #### Phase 3: Finer steps and native **Status:** PARTIAL — continuous scaler and conformance scene committed; native execution remains open. **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`. +- [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. - [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect`. — Current manual-pass scene matches exactly across browser/desktop (pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors), but fresh review found it bypasses the shipped controller and scale changes. Reopened pending a conformance scene exercising the shipped mechanism. Bounded reports: `packages/runtime-native/artifacts/prd564/parity-{web,desktop}-a0/report.json`; unselected rows remain blocked, exit 2. ## Decisions @@ -158,3 +158,6 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. diff --git a/packages/core/__tests__/renderer-resolution-subrect.spec.ts b/packages/core/__tests__/renderer-resolution-subrect.spec.ts index 4377dc385a..2e0dc945e5 100644 --- a/packages/core/__tests__/renderer-resolution-subrect.spec.ts +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -191,3 +191,45 @@ it("owns subrect installations while keeping the full drawing buffer", async () else Reflect.deleteProperty(globalThis, "navigator"); } }); + +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(); +}); diff --git a/packages/core/__tests__/resolution-scaler-continuous.spec.ts b/packages/core/__tests__/resolution-scaler-continuous.spec.ts index f1415e9361..4522f38af1 100644 --- a/packages/core/__tests__/resolution-scaler-continuous.spec.ts +++ b/packages/core/__tests__/resolution-scaler-continuous.spec.ts @@ -36,11 +36,11 @@ describe("ResolutionScaler continuous sub-rectangle mode", () => { }); it("ignores estimates under two percent and amortizes an up-step", () => { - const holds = new ResolutionScaler({ continuous: true, start: 1, targetFps: 60 }); + 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({ continuous: true, start: 0.72, targetFps: 60 }); + 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(); @@ -49,3 +49,48 @@ describe("ResolutionScaler continuous sub-rectangle mode", () => { 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/src/render/resolution-subrect.ts b/packages/core/src/render/resolution-subrect.ts index a098fa063a..2d7bc103df 100644 --- a/packages/core/src/render/resolution-subrect.ts +++ b/packages/core/src/render/resolution-subrect.ts @@ -41,16 +41,19 @@ const validateScale = (scale: number): void => { 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; @@ -111,6 +114,7 @@ export class ResolutionSubrect implements IResolutionSubrect { setSize(width: number, height: number): void { if (this.#disposed) throw new Error("TN_RESOLUTION_SUBRECT: disposed controller"); validateSize(width, height); + 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); } @@ -158,7 +162,8 @@ export class ResolutionSubrect implements IResolutionSubrect { binding.viewWidth = Math.max(1, Math.round(width)); binding.viewHeight = Math.max(1, Math.round(height)); } - if (capacityChanged) original.call(target, binding.width, binding.height, depth); + if (capacityChanged || target.width !== binding.width || target.height !== binding.height) + original.call(target, binding.width, binding.height, depth); if (!this.#reserving) this.#applyViewport(binding); }, }; @@ -194,6 +199,7 @@ export class ResolutionSubrect implements IResolutionSubrect { }; 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 { @@ -205,6 +211,17 @@ export class ResolutionSubrect implements IResolutionSubrect { 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(); diff --git a/packages/core/src/resolution-scaler.ts b/packages/core/src/resolution-scaler.ts index d7f09e27ca..197023e426 100644 --- a/packages/core/src/resolution-scaler.ts +++ b/packages/core/src/resolution-scaler.ts @@ -201,8 +201,6 @@ export interface IResolutionScalerOptions { * lifts the floor for as long as it is installed. */ readonly temporalUpscale?: () => boolean; - /** Enable continuous estimates even when the observed surface does not expose a sub-rectangle. */ - readonly continuous?: boolean | (() => boolean); } /** A closed frame-budget window's presentation distribution and optional GPU observation. */ @@ -257,7 +255,6 @@ export class ResolutionScaler { /** The lowest rung this platform may take; the whole ladder's last index when it states none. */ readonly #minIndex: number; readonly #temporalUpscale: (() => boolean) | undefined; - readonly #continuous: boolean | (() => boolean) | undefined; #scaleSource: Exclude = "auto"; #cleanWindows = 0; #cooldown = 0; @@ -285,7 +282,9 @@ 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; @@ -302,7 +301,6 @@ export class ResolutionScaler { this.#minIndex = minScale === undefined ? RESOLUTION_SCALER.rungs.length - 1 : rungIndex(minScale, "minScale"); this.#temporalUpscale = temporalUpscale; - this.#continuous = options.continuous; } /** @@ -485,7 +483,7 @@ 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; @@ -494,7 +492,11 @@ export class ResolutionScaler { 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; } @@ -566,9 +568,7 @@ export class ResolutionScaler { } #continuousActive(window: IScalerWindow): boolean { - const explicit = - typeof this.#continuous === "function" ? this.#continuous() : this.#continuous === true; - return explicit || window.surface?.resolutionSubrect === true; + return window.surface?.resolutionSubrect === true; } #observeContinuous( @@ -585,10 +585,26 @@ export class ResolutionScaler { 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; - return this.#continuousChange(Math.max(floor, target), false); + 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; diff --git a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts index 3a0b8d9bc6..fd78cc0280 100644 --- a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts @@ -1,8 +1,9 @@ import { PerspectiveCamera, Scene, Texture } from "three"; import BloomNode from "three/addons/tsl/display/BloomNode.js"; import { rtt, texture, vec4 } from "three/tsl"; -import type { Node } from "three/webgpu"; +import 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])( @@ -254,7 +255,13 @@ it("releases the unique direct installation receipt rather than the legacy node }); it("admits default spatial stages and explicitly refuses unadapted graphs", () => { - const handle = { active: false, retain: vi.fn(), textureView: () => undefined, dispose: vi.fn() }; + const handle = { + active: false, + retain: vi.fn(), + release: vi.fn(), + textureView: () => undefined, + dispose: vi.fn(), + }; const optionsSeen: Parameters>[0][] = []; const renderer: OutputRenderer = { kind: "webgpu", @@ -301,7 +308,9 @@ it.each([ ])("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( @@ -327,3 +336,115 @@ it.each([ 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/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, 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 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/create-threenative/templates/starter/src/render/grade.ts b/packages/create-threenative/templates/starter/src/render/grade.ts index 1b00928316..83b7dd625b 100644 --- a/packages/create-threenative/templates/starter/src/render/grade.ts +++ b/packages/create-threenative/templates/starter/src/render/grade.ts @@ -22,20 +22,10 @@ // `(x/W)^g / (1 + (x/W)^g)` with `W = 4, g = 1/2.2` cost 4.3. `__tests__/grade.spec.ts` holds the // arithmetic; the value of the transform's own seat is that an identity table there is under one // step by construction rather than by a fitted curve. -import { type Data3DTexture, DataUtils, FloatType, HalfFloatType, type Vector2 } from "three"; +import { type Data3DTexture, DataUtils, FloatType, HalfFloatType } from "three"; import { film } from "three/addons/tsl/display/FilmNode.js"; import { lut3D } from "three/addons/tsl/display/Lut3DNode.js"; -import { - clamp, - convertToTexture, - float, - renderOutput, - screenUV, - texture3D, - time, - uniform, - uv, -} from "three/tsl"; +import { float, renderOutput, texture3D, time, uv } from "three/tsl"; import type { Node } from "three/webgpu"; import type { QualityTier } from "./quality.js"; import type { ChainStage } from "./worldEnvironment.js"; @@ -70,19 +60,6 @@ export interface IGradeTable { readonly texture: Data3DTexture; } -type ResolutionSubrectView = { - readonly viewFraction: Vector2; - readonly halfTexel: Vector2; -}; - -function subrectColour(input: Node<"vec4">, view: ResolutionSubrectView | undefined): Node<"vec4"> { - if (view === undefined) return input; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const uvNode = clamp(screenUV.mul(fraction), halfTexel, fraction.sub(halfTexel)); - return convertToTexture(input).sample(uvNode) as unknown as Node<"vec4">; -} - /** * The table `LUTCubeLoader` parsed as `FloatType`, uploaded as half floats so the GPU can filter it. * @@ -143,16 +120,14 @@ export function gradeStages( supportsResolutionSubrect: true, afterOutputTransform: true, available: () => refusal("grade", settings, loaded), - build: (input, context) => { + build: (input) => { if (table === undefined) throw new Error( "grade built before its table loaded; available() should have refused it.", ); // The pipeline's own transform, applied here because `afterOutputTransform` took it away // from the renderer. Same tone curve, same encode, same exposure — applied once. - const display = renderOutput( - subrectColour(input as Node<"vec4">, context.resolutionSubrect), - ); + const display = renderOutput(input as Node<"vec4">); const graded = lut3D( display, texture3D(table.texture), @@ -170,9 +145,9 @@ export function gradeStages( supportsResolutionSubrect: true, after: "grade", available: () => refusal("grain", settings, loaded), - build: (input, context) => + build: (input) => film( - subrectColour(input as Node<"vec4">, context.resolutionSubrect), + input as Node<"vec4">, float(settings.grainIntensity), // `film()` adds the clock to whatever uv it is handed, so a still grain hands it back. settings.grainAnimated ? undefined : uv().sub(time), diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, 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 3ca5e1fbe2..ed90f7a21c 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts @@ -23,7 +23,8 @@ import { type PerspectiveCamera, type RenderTarget, type Scene, - type Vector2, + type Texture, + Vector2, } from "three"; import { bloom } from "three/addons/tsl/display/BloomNode.js"; import { denoise } from "three/addons/tsl/display/DenoiseNode.js"; @@ -46,13 +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"; @@ -383,13 +384,71 @@ type ResolutionSubrectController = { readonly active?: boolean; readonly view?: ResolutionSubrectView; retain(node: ResolutionSubrectTarget, targets: readonly RenderTarget[]): void; - textureView?(texture: unknown): unknown; + 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; @@ -575,6 +634,10 @@ export class WorldEnvironment { ? 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 @@ -598,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; }; @@ -611,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; @@ -636,34 +701,17 @@ export class WorldEnvironment { resolutionSubrect?.dispose(); scenePass.dispose(); }; - const remappedTextures = new WeakMap>(); - const subrectTexture = ( - input: ChainNode, - context: ChainContext, - ): ReturnType => { + const subrectTexture = (input: ChainNode): ReturnType => { const texture = convertToTexture(input); - const view = context.resolutionSubrect; - if (view === undefined) return texture; - const existing = remappedTextures.get(texture); - if (existing !== undefined) return existing; - const fraction = uniform(view.viewFraction); - const halfTexel = uniform(view.halfTexel); - const originalSample = texture.sample.bind(texture); - texture.sample = (uvNode) => - originalSample( - clamp((uvNode as Node<"vec2">).mul(fraction), halfTexel, fraction.sub(halfTexel)), - ); - remappedTextures.set(texture, texture); + sample(texture); return texture; }; - const subrectColour = (input: ChainNode, context: ChainContext): ChainNode => { - if (context.resolutionSubrect === undefined) return input; - return subrectTexture(input, context).sample(screenUV) as unknown as ChainNode; - }; 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", @@ -713,16 +761,26 @@ export class WorldEnvironment { stage({ name: "ambientOcclusion", supportsResolutionSubrect: true, - build: (input, context) => { - const contact = ao( - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ); + dispose: () => { + releaseAo(); + releaseAo = () => {}; + }, + build: (input) => { + 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 @@ -732,17 +790,26 @@ 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( - subrectTexture(contact.getTextureNode(), context), - subrectTexture(depth(), context), - subrectTexture(normal(), context), - view, - ), - ) - : subrectTexture(contact.getTextureNode(), context); - return subrectColour(input, context).mul(occlusion.r); + 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); }, }), stage({ @@ -833,16 +900,78 @@ export class WorldEnvironment { releaseBloom(); releaseBloom = () => {}; }, - build: (input, context) => { - const texture = subrectTexture(input, context); - const colour = subrectColour(input, context); + build: (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 { @@ -851,6 +980,7 @@ 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(); } @@ -861,13 +991,13 @@ export class WorldEnvironment { stage({ name: "vignette", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input, context); + 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 colour.mul(fall.oneMinus()); }, @@ -878,17 +1008,66 @@ export class WorldEnvironment { name: "antialias", after: "gradualBackground", supportsResolutionSubrect: true, - build: (input, context) => { - const colour = subrectColour(input as ChainNode, context); + build: (input) => { + const colour = input as ChainNode; // SMAA and FXAA find edges by local contrast and expect display-range colour, but this is // the linear HDR frame before the tone curve — a sun-lit edge at 20 against sky at 3 // would blend into a bright halo. The reversible Karis weighting squeezes it into [0, 1) // 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)); }, diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts index 767256dee4..09bd12fc7f 100644 --- a/packages/playtest/__tests__/measure-resolution-scale.spec.ts +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, expect, test, vi } from "vitest"; +import { bridgeWaitTimeoutMs } from "../src/runner/bridgeClient.js"; const fixture = vi.hoisted(() => { const server = { @@ -105,3 +106,16 @@ test("capture lease is released even when display cleanup rejects", async () => await expect(importMeasurement()).rejects.toBe(error); expect(fixture.captureLease.release).toHaveBeenCalledOnce(); }); + +test("cold navigation uses the existing startup wait 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: bridgeWaitTimeoutMs()}); + expect(context.close).toHaveBeenCalledOnce(); + expect(browser.close).toHaveBeenCalledOnce(); + expect(fixture.captureLease.release).toHaveBeenCalledOnce(); +}); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index fb4e9baaad..301cbf8461 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -84,7 +84,7 @@ try { page.on("console", (message) => logs.push(message.text())); page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); try { - await pending("page.goto", () => page.goto(url)); + await pending("page.goto", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: bridgeWaitTimeoutMs() })); const transport = new PlaywrightTransport(page); const bootError = async () => (await page.locator("[data-threenative-canvas-error]").allTextContents()).join("\n"); From 1a4e2cbe3822df2fe395969a20a2853415ce8442 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:53:58 -0700 Subject: [PATCH 16/53] docs(prd564): verify shipped controller desktop parity --- ...-resolution-scale-changes-without-reallocating-a-target.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c7ad58c50c..a84609f470 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 @@ -114,10 +114,10 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [ ] 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`. #### Phase 3: Finer steps and native -**Status:** PARTIAL — continuous scaler and conformance scene committed; native execution remains open. +**Status:** COMPLETE — continuous policy regressions pass; shipped-controller down/up native parity matches the browser exactly. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` - [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. -- [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target desktop --only-tests resolution-subrect`. — Current manual-pass scene matches exactly across browser/desktop (pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors), but fresh review found it bypasses the shipped controller and scale changes. Reopened pending a conformance scene exercising the shipped mechanism. Bounded reports: `packages/runtime-native/artifacts/prd564/parity-{web,desktop}-a0/report.json`; unselected rows remain blocked, exit 2. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. Scene commit `3bea8eecc` imports the shipped controller and asserts retained capacity, viewport/scissor and sampling through 0.72 → 1 → 0.72. Both selected rows PASS; desktop pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Controller source SHA-256 `bd3ad7813b2d5f46ca77f2e437142e0b12559e6992f89d96dab869b3a95b2cad` was identical before/after and is present in repair commit `f335aca1f`. Reports: `artifacts/prd564/controller-parity-{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. ## Decisions From 203ab90882d6a00e91cccdc0c1ec7cfc11bc0114 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:56:16 -0700 Subject: [PATCH 17/53] fix(playtest): allow cold navigation within startup readiness budget --- ...esolution-scale-changes-without-reallocating-a-target.md | 2 ++ .../playtest/__tests__/measure-resolution-scale.spec.ts | 6 +++--- packages/playtest/src/runner/measure-resolution-scale.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) 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 a84609f470..e273b253b9 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 @@ -161,3 +161,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts index 09bd12fc7f..3d17ac8be6 100644 --- a/packages/playtest/__tests__/measure-resolution-scale.spec.ts +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, test, vi } from "vitest"; -import { bridgeWaitTimeoutMs } from "../src/runner/bridgeClient.js"; +import { PLAYTEST_STARTUP_READY_TIMEOUT_MS } from "../src/protocol.js"; const fixture = vi.hoisted(() => { const server = { @@ -107,14 +107,14 @@ test("capture lease is released even when display cleanup rejects", async () => expect(fixture.captureLease.release).toHaveBeenCalledOnce(); }); -test("cold navigation uses the existing startup wait budget and releases resources on failure", async () => { +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: bridgeWaitTimeoutMs()}); + 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(); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 301cbf8461..0a941e7ab8 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -14,7 +14,7 @@ import { resolutionScaleGate, resolutionScalePending, } from "./resolution-scale-probe.js"; -import type { IPlaytestBridgeDescription, IPlaytestBridgeReady } from "../protocol.js"; +import { PLAYTEST_STARTUP_READY_TIMEOUT_MS, type IPlaytestBridgeDescription, type IPlaytestBridgeReady } from "../protocol.js"; import type { IResolutionScaleRun, IResolutionScaleRow } from "./resolution-scale-probe.js"; @@ -84,7 +84,7 @@ try { page.on("console", (message) => logs.push(message.text())); page.on("pageerror", (error) => logs.push(`PAGE_ERROR:${error.message}`)); try { - await pending("page.goto", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: bridgeWaitTimeoutMs() })); + 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"); From bcf872bc9e93b69bff177fce2134e83ffebcdab3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:00:03 -0700 Subject: [PATCH 18/53] test(native): simplify resolution bundle contract --- .../tests/conformance-runner.test.mjs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/packages/runtime-native/tests/conformance-runner.test.mjs b/packages/runtime-native/tests/conformance-runner.test.mjs index c67bccfb4e..5f5c852aa7 100644 --- a/packages/runtime-native/tests/conformance-runner.test.mjs +++ b/packages/runtime-native/tests/conformance-runner.test.mjs @@ -1343,27 +1343,6 @@ test("resolution-subrect conformance bundles the shipped controller through down const dir = makeTempDirSync("threenative-resolution-subrect-"); const scene = "conformance/scenes/shared/resolution-subrect.js"; try { - const source = readFileSync(join(root, scene), "utf8"); - assert.match( - source, - /import \{ ResolutionSubrect \} from "\.\.\/\.\.\/\.\.\/\.\.\/core\/src\/render\/resolution-subrect\.ts"/u, - ); - assert.match(source, /controller\.retain\(scenePass, \[scenePass\.renderTarget\]\)/u); - assert.match(source, /controller\.activate\(1\)[\s\S]*controller\.setSize\(/u); - assert.match( - source, - /for \(const scale of \[SCALE, 1, SCALE\]\) \{[\s\S]*controller\.setScale\(scale\);[\s\S]*pipeline\.render\(\);[\s\S]*assertTransition\(scale\);[\s\S]*\}/u, - ); - for (const assertion of [ - /target\.width !== capacityWidth/u, - /target\.viewport\.z !== viewWidth/u, - /target\.scissor\.z !== viewWidth/u, - /view\.viewFraction\.x !==/u, - /view\.halfTexel\.x !==/u, - ]) - assert.match(source, assertion); - assert.doesNotMatch(source, /scenePass\.set(?:Viewport|Scissor)\(/u); - const entry = makeEntry( { captureFrames: 2, id: "resolution-subrect-contract", scene }, "browser", From 7d886e7bfb9ef422f11e4b39c44398fa101f1d92 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:13:07 -0700 Subject: [PATCH 19/53] perf(render): avoid duplicate retained-target resize traversal --- ...ale-changes-without-reallocating-a-target.md | 2 ++ .../renderer-resolution-subrect.spec.ts | 17 +++++++++++++++++ packages/core/src/render/resolution-subrect.ts | 3 +++ 3 files changed, 22 insertions(+) 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 e273b253b9..52b17e060d 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 @@ -163,3 +163,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. diff --git a/packages/core/__tests__/renderer-resolution-subrect.spec.ts b/packages/core/__tests__/renderer-resolution-subrect.spec.ts index 2e0dc945e5..b97d3a2593 100644 --- a/packages/core/__tests__/renderer-resolution-subrect.spec.ts +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -233,3 +233,20 @@ it("releases a retired stage before subsequent scale changes", () => { 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/src/render/resolution-subrect.ts b/packages/core/src/render/resolution-subrect.ts index 2d7bc103df..bf6aa52894 100644 --- a/packages/core/src/render/resolution-subrect.ts +++ b/packages/core/src/render/resolution-subrect.ts @@ -106,6 +106,8 @@ export class ResolutionSubrect implements IResolutionSubrect { 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); } @@ -114,6 +116,7 @@ export class ResolutionSubrect implements IResolutionSubrect { 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); From caba3775accbf5989bdbc4bdbd0fb8ae276b22fb Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:22:00 -0700 Subject: [PATCH 20/53] test(native): exercise retained resize fast path --- .../conformance/scenes/shared/resolution-subrect.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js index 2446b14ae1..bbe7a77379 100644 --- a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js +++ b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js @@ -57,6 +57,7 @@ export function startScene(canvas, dimensions) { for (const scale of [SCALE, 1, SCALE]) { controller.setScale(scale); + controller.setSize(dimensions.width, dimensions.height); pipeline.render(); assertTransition(scale); } From 989d053d5be4b5305c6a4e6463b0ca58ea9fc32d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:34:58 -0700 Subject: [PATCH 21/53] docs: refresh current-source resolution native parity --- ...he-resolution-scale-changes-without-reallocating-a-target.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 52b17e060d..4d66098733 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 @@ -117,7 +117,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu **Status:** COMPLETE — continuous policy regressions pass; shipped-controller down/up native parity matches the browser exactly. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` - [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. -- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. Scene commit `3bea8eecc` imports the shipped controller and asserts retained capacity, viewport/scissor and sampling through 0.72 → 1 → 0.72. Both selected rows PASS; desktop pixelMismatchRatio 0, perceptualDeltaE 0, no GPU validation errors. Controller source SHA-256 `bd3ad7813b2d5f46ca77f2e437142e0b12559e6992f89d96dab869b3a95b2cad` was identical before/after and is present in repair commit `f335aca1f`. Reports: `artifacts/prd564/controller-parity-{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. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. ## Decisions From bbe732da32b7a2ede4240fe483eb5deb0ebaec5c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:35:53 -0700 Subject: [PATCH 22/53] docs: reopen resolution baseline after probe bias review --- ...olution-scale-changes-without-reallocating-a-target.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 4d66098733..64796e785b 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** IN PROGRESS -**Priority:** P2 — Phase 1 earns continuation on a controlled RTX 2080 run; retained targets, sub-rectangle sampling, finer scaling and native parity remain open. +**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). @@ -101,9 +101,9 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** COMPLETE — controlled timing qualified; the 0.85 step in run 1 crosses the unchanged strict 2× gate. +**Status:** REOPENED — the initial pipeline census was inside only the step-frame clock. Rerun the original unretained baseline with the corrected probe before deciding continuation; the historical 0.20 ms crossing is not sufficient current evidence. **Files:** a forced-step playtest scenario under `packages/playtest`; no engine change -- [x] 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`. See Decisions for the per-run controls and narrow crossing. +- [ ] 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:** PARTIAL — implementation committed; runtime allocation and visual acceptance remain open. @@ -165,3 +165,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. From 8be6eb88a35fdae1028cb3b1822769e3c8d1bbc3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:36:56 -0700 Subject: [PATCH 23/53] fix(playtest): exclude step-only census work from frame timing --- ...e-changes-without-reallocating-a-target.md | 6 ++++-- .../__tests__/resolution-scale-probe.spec.ts | 19 ++++++++++++------- .../src/runner/measure-resolution-scale.ts | 16 +++++++++++++++- .../src/runner/resolution-scale-probe.ts | 4 +++- 4 files changed, 34 insertions(+), 11 deletions(-) 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 64796e785b..fecedd0fb9 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 @@ -114,10 +114,10 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [ ] 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`. #### Phase 3: Finer steps and native -**Status:** COMPLETE — continuous policy regressions pass; shipped-controller down/up native parity matches the browser exactly. +**Status:** PARTIAL — continuous policy regressions pass; current-source native rerun and reopened Phase 1 decision remain pending. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` - [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. -- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. +- [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. ## Decisions @@ -167,3 +167,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. Native prior captures remain valid for their recorded source hash, but the 7d886 controller optimization needs a fresh native run. diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts index 967717a773..82f04244dc 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -151,9 +151,12 @@ it("counts mip/layer/sample payloads and refuses an unmeasured format", () => { 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) {}, + beginFrame(_timestamp: number, _clock: number) { insideFrame = true; }, endFrame(_now: number, _want: boolean) { + insideFrame = false; return true; }, }; @@ -169,10 +172,10 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, let builds = 3; const renderer = { surface: () => ({ resolutionScale: scale }), - pipelineCensus: () => ({ - complete: true, - counts: { deviceCreations: builds, failures: 0, pending: 0, droppedEvents: 0 }, - }), + pipelineCensus: () => { + censusInsideFrames.push(insideFrame); + return {complete: true, counts: { deviceCreations: builds, failures: 0, pending: 0, droppedEvents: 0 }}; + }, setResolutionScale(next: number) { scale = next; builds++; @@ -181,6 +184,7 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, }; 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); } @@ -189,11 +193,12 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, rows.map((row) => [row.scale, row.frames.length, row.textureBytes, row.pipelineBuilds]), ).toEqual([ [0.85, 31, 64, 1], - [0.61, 31, 64, 1], + [0.61, 31, 64, 2], [1, 31, 64, 1], ]); - expect(rows.map((row) => row.stepCensusAfter.counts.deviceCreations)).toEqual([4, 5, 6]); + expect(rows.map((row) => row.stepCensusAfter.counts.deviceCreations)).toEqual([4, 6, 7]); expect(rows[0]!.stepRatio).toBe(4); + expect(censusInsideFrames.every((inside) => !inside)).toBe(true); expect([budget.beginFrame, budget.endFrame, device.createTexture]).toEqual([begin, end, create]); log.mockRestore(); }); diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 0a941e7ab8..7767119483 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -1,4 +1,5 @@ -import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, 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"; @@ -20,6 +21,11 @@ import type { IResolutionScaleRun, IResolutionScaleRow } from "./resolution-scal // 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"); @@ -83,6 +89,9 @@ try { 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); @@ -190,6 +199,8 @@ try { 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:"))) @@ -207,6 +218,7 @@ try { display: providedDisplay.strategy, startup, adapter, + ...(control === undefined ? {} : { control }), preflight, rows, ...(gate === undefined ? {} : { gate }), @@ -286,6 +298,8 @@ async function serveStarter( const target = await mkdtemp(resolve(output, "starter-")); 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 [ diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts index 8999382a2c..1dfa573758 100644 --- a/packages/playtest/src/runner/resolution-scale-probe.ts +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -308,7 +308,6 @@ export function installResolutionScaleProbe( try { if (frame < 0) return; if (frame % 31 === 0) { - initialCensus = census(); frames = []; allocations = []; step++; @@ -324,6 +323,8 @@ export function installResolutionScaleProbe( 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"); @@ -355,6 +356,7 @@ export function installResolutionScaleProbe( stepRatio: frames[0]!.frameMs / p50!, }; rows.push(row); + initialCensus = after; console.info(`TN_RESOLUTION_STEP:${JSON.stringify(row)}`); if (step === 2) { clearTimeout(timeout); From c27c85a41a939663f33734772493ab213f91081c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 12:39:17 -0700 Subject: [PATCH 24/53] docs: reconcile verified native parity after baseline reopening --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 fecedd0fb9..0fbe87817a 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 @@ -114,10 +114,10 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [ ] 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`. #### Phase 3: Finer steps and native -**Status:** PARTIAL — continuous policy regressions pass; current-source native rerun and reopened Phase 1 decision remain pending. +**Status:** COMPLETE — continuous policy regressions pass; current-source native parity matches the browser exactly. Overall continuation remains pending the reopened Phase 1 baseline. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` - [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. -- [ ] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. ## Decisions @@ -168,4 +168,4 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. Native prior captures remain valid for their recorded source hash, but the 7d886 controller optimization needs a fresh native run. +- 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. From e7e9e1143d1ea599384ffbe72cdae485e7840ce1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 13:49:44 -0700 Subject: [PATCH 25/53] test(playtest): align resolution frames with host observations --- ...ion-scale-changes-without-reallocating-a-target.md | 1 + .../playtest/__tests__/resolution-scale-probe.spec.ts | 2 ++ .../playtest/src/runner/resolution-scale-probe.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) 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 0fbe87817a..469735a23c 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 @@ -169,3 +169,4 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts index 82f04244dc..70bb5b28b2 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -198,6 +198,8 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, ]); 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([budget.beginFrame, budget.endFrame, device.createTexture]).toEqual([begin, end, create]); log.mockRestore(); diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts index 1dfa573758..271a51a944 100644 --- a/packages/playtest/src/runner/resolution-scale-probe.ts +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -129,7 +129,14 @@ interface IResolutionScaleCensus { } export interface IResolutionScaleRow { scale: number; - frames: { frame: number; frameMs: number; rafIntervalMs?: number; surface?: unknown }[]; + frames: { + frame: number; + frameMs: number; + clockEpochStartMs?: number; + clockEpochEndMs?: number; + rafIntervalMs?: number; + surface?: unknown; + }[]; allocations: { bytes: number; [key: string]: unknown }[]; textureBytes: number; pipelineBuilds: number; @@ -331,6 +338,8 @@ export function installResolutionScaleProbe( frames.push({ frame, frameMs: now - start, + clockEpochStartMs: performance.timeOrigin + start, + clockEpochEndMs: performance.timeOrigin + now, rafIntervalMs: previousTimestamp === undefined ? undefined : timestamp - previousTimestamp, surface: renderer.surface(), From 3283bc69ca4ca429e1b2bffcbeeee8b3c7a1dc88 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 14:10:56 -0700 Subject: [PATCH 26/53] docs: identify remaining PRD-564 phase two proof gaps --- ...resolution-scale-changes-without-reallocating-a-target.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 469735a23c..5724c9bbd6 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 @@ -110,8 +110,8 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame **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. -- [ ] 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`. +- [ ] 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. — Current passing `renderer-resolution-subrect.spec.ts`: `changes the active rectangle without disposing the allocation, and grows once` counts target disposal events (none for scale changes, one for capacity growth; shrink retains capacity); `reapplies the retained viewport after a real stock PassNode updates its target` checks the scene-pass viewport/scissor; `owns subrect installations while keeping the full drawing buffer` checks unchanged renderer resize-call count on a scale change. Missing: a canvas-resize event through the renderer with retained-target allocation counting. These unit observations do not establish actual GPU allocation counts or a historical red run. +- [ ] 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. Missing: actual built-in stage UV/clamp sampling checks and an integrated refused-stage resize/allocation assertion; neither sampling nor visual acceptance is earned by admission-only coverage. #### Phase 3: Finer steps and native **Status:** COMPLETE — continuous policy regressions pass; current-source native parity matches the browser exactly. Overall continuation remains pending the reopened Phase 1 baseline. @@ -121,6 +121,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions +- 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. From 1a7e2445a914e501ba0e393c1cc70ed30034fead Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 14:41:22 -0700 Subject: [PATCH 27/53] test(core): prove retained target canvas resize lifecycle --- ...e-changes-without-reallocating-a-target.md | 3 +- .../renderer-resolution-subrect.spec.ts | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) 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 5724c9bbd6..59634bd157 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 @@ -110,7 +110,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame **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. -- [ ] 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. — Current passing `renderer-resolution-subrect.spec.ts`: `changes the active rectangle without disposing the allocation, and grows once` counts target disposal events (none for scale changes, one for capacity growth; shrink retains capacity); `reapplies the retained viewport after a real stock PassNode updates its target` checks the scene-pass viewport/scissor; `owns subrect installations while keeping the full drawing buffer` checks unchanged renderer resize-call count on a scale change. Missing: a canvas-resize event through the renderer with retained-target allocation counting. These unit observations do not establish actual GPU allocation counts or a historical red run. +- [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. — 19 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. - [ ] 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. Missing: actual built-in stage UV/clamp sampling checks and an integrated refused-stage resize/allocation assertion; neither sampling nor visual acceptance is earned by admission-only coverage. #### Phase 3: Finer steps and native @@ -121,6 +121,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions +- 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/`. diff --git a/packages/core/__tests__/renderer-resolution-subrect.spec.ts b/packages/core/__tests__/renderer-resolution-subrect.spec.ts index b97d3a2593..e248ce9e78 100644 --- a/packages/core/__tests__/renderer-resolution-subrect.spec.ts +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -192,6 +192,88 @@ it("owns subrect installations while keeping the full drawing buffer", async () } }); +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("restores actual capacity after deactivation and reactivation", () => { const target = new RenderTarget(); const controller = new ResolutionSubrect(); From 4637c8510a19b26260f94da052700499fd74f7b7 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 15:20:01 -0700 Subject: [PATCH 28/53] test(playtest): collect bounded resolution clock and thread evidence --- ...e-changes-without-reallocating-a-target.md | 7 ++- .../measure-resolution-scale.spec.ts | 41 ++++++++++++++ .../__tests__/resolution-scale-probe.spec.ts | 53 +++++++++++++++++++ .../src/runner/measure-resolution-scale.ts | 35 ++++++++++++ .../src/runner/resolution-scale-probe.ts | 14 +++++ 5 files changed, 149 insertions(+), 1 deletion(-) 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 59634bd157..359eb84acd 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 @@ -101,7 +101,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED — the initial pipeline census was inside only the step-frame clock. Rerun the original unretained baseline with the corrected probe before deciding continuation; the historical 0.20 ms crossing is not sufficient current evidence. +**Status:** REOPENED / UNQUALIFIED — corrected original baseline completed all 9 steps on NVIDIA with zero new pipelines. Runs 1 and 3 stay below 2×; run 2 crosses at 20.6/7 ms (2.94×) and 10.5/4 ms (2.63×), with observed runqueue waits and competing CPU. Clock/thread/affinity/GPU coverage remain unqualified; neither continue nor decline is earned. **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: `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. @@ -121,6 +121,11 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions +- 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. diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts index 3d17ac8be6..6b7cf35f94 100644 --- a/packages/playtest/__tests__/measure-resolution-scale.spec.ts +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -1,4 +1,5 @@ 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(() => { @@ -41,6 +42,14 @@ 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(); @@ -119,3 +128,35 @@ test("cold navigation uses the existing startup readiness budget and releases re 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 index 70bb5b28b2..247b4cc29f 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -182,6 +182,12 @@ it("captures exactly step +30 frames for each scale, counts allocations/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. @@ -201,10 +207,57 @@ it("captures exactly step +30 frames for each scale, counts allocations/builds, 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; diff --git a/packages/playtest/src/runner/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 7767119483..371e1e2a98 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -17,6 +17,8 @@ import { } 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. @@ -80,6 +82,7 @@ try { }; partial: { collected: boolean; value?: Record }; rows?: IResolutionScaleRow[]; + markers?: ITraceEvent[]; } = { startup: { collected: false }, partial: { collected: false } }; checkpointEvidence = evidence; const launchedBrowser = browser; @@ -132,6 +135,21 @@ try { 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<{ @@ -189,6 +207,22 @@ try { }); 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); @@ -221,6 +255,7 @@ try { ...(control === undefined ? {} : { control }), preflight, rows, + markers, ...(gate === undefined ? {} : { gate }), }, null, diff --git a/packages/playtest/src/runner/resolution-scale-probe.ts b/packages/playtest/src/runner/resolution-scale-probe.ts index 271a51a944..55863d7fa0 100644 --- a/packages/playtest/src/runner/resolution-scale-probe.ts +++ b/packages/playtest/src/runner/resolution-scale-probe.ts @@ -134,6 +134,12 @@ export interface IResolutionScaleRow { frameMs: number; clockEpochStartMs?: number; clockEpochEndMs?: number; + clockCalibration?: { + performanceBeforeMs: number; + wallEpochMs: number; + performanceAfterMs: number; + marker: string; + }; rafIntervalMs?: number; surface?: unknown; }[]; @@ -335,9 +341,17 @@ export function installResolutionScaleProbe( 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: From 402d92b0e85dfc73226ddecaa3282fa64a406af5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 16:00:19 -0700 Subject: [PATCH 29/53] fix(core): retain timerless scaler policy with active subrect --- ...e-changes-without-reallocating-a-target.md | 12 +++- .../resolution-scaler-continuous.spec.ts | 72 +++++++++++++++++++ packages/core/src/resolution-scaler.ts | 3 +- .../scenes/shared/resolution-subrect.js | 30 ++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) 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 359eb84acd..9ecbd0fd3c 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 @@ -114,13 +114,19 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [ ] 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. Missing: actual built-in stage UV/clamp sampling checks and an integrated refused-stage resize/allocation assertion; neither sampling nor visual acceptance is earned by admission-only coverage. #### Phase 3: Finer steps and native -**Status:** COMPLETE — continuous policy regressions pass; current-source native parity matches the browser exactly. Overall continuation remains pending the reopened Phase 1 baseline. +**Status:** COMPLETE — 86 scaler regressions pass after the timer-less routing repair; exact-current-source browser/desktop sub-rectangle parity passes. Overall continuation remains pending the reopened Phase 1 baseline; timing and visual acceptance remain open. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` -- [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`. — 80 scaler cases pass, including exact-fraction insensitivity refund and unchanged discrete-policy recovery controls; continuous estimates require an observed active sub-rectangle. -- [x] The native desktop host draws the same sub-rectangle frame as the browser. proof: `pnpm parity --target web --only-tests resolution-subrect` then `pnpm parity --target desktop --only-tests resolution-subrect --reference ` under the existing capture lease and matching NVIDIA environment. 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. +- [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. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. 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. ## Decisions +- 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. diff --git a/packages/core/__tests__/resolution-scaler-continuous.spec.ts b/packages/core/__tests__/resolution-scaler-continuous.spec.ts index 4522f38af1..2220f269b3 100644 --- a/packages/core/__tests__/resolution-scaler-continuous.spec.ts +++ b/packages/core/__tests__/resolution-scaler-continuous.spec.ts @@ -18,6 +18,78 @@ const windowOf = (gpuMs: number, frames = 1) => ({ }); 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(); diff --git a/packages/core/src/resolution-scaler.ts b/packages/core/src/resolution-scaler.ts index 197023e426..4d47923ede 100644 --- a/packages/core/src/resolution-scaler.ts +++ b/packages/core/src/resolution-scaler.ts @@ -390,7 +390,8 @@ export class ResolutionScaler { this.#trackInsensitiveRise(gpuMs); const refunded = this.#settleInsensitiveProbe(gpuMs); if (refunded !== undefined) return refunded; - if (this.#continuousActive(window)) return this.#observeContinuous(window, gpuMs, stalled); + 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, diff --git a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js index bbe7a77379..5fe5f2b1b6 100644 --- a/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js +++ b/packages/runtime-native/conformance/scenes/shared/resolution-subrect.js @@ -1,12 +1,42 @@ 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), From 06192a3bc899bf326e5868e2636c2febeaf9bab3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:00:26 -0700 Subject: [PATCH 30/53] fix(playtest): distinguish retained artifacts from test temporary files --- ...e-changes-without-reallocating-a-target.md | 3 + .../measure-resolution-scale.spec.ts | 2 - .../__tests__/resolution-scale-probe.spec.ts | 56 +++++++++---------- .../src/runner/measure-resolution-scale.ts | 6 +- 4 files changed, 33 insertions(+), 34 deletions(-) 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 9ecbd0fd3c..f7fbc948ed 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 @@ -121,6 +121,9 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions +- 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. Scaffold byte hashes still differ because the candidate changes generated render source; they remain unchanged pending the Phase 1 decision. 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. diff --git a/packages/playtest/__tests__/measure-resolution-scale.spec.ts b/packages/playtest/__tests__/measure-resolution-scale.spec.ts index 6b7cf35f94..169b7cd5cd 100644 --- a/packages/playtest/__tests__/measure-resolution-scale.spec.ts +++ b/packages/playtest/__tests__/measure-resolution-scale.spec.ts @@ -22,7 +22,6 @@ const fixture = vi.hoisted(() => { display, fs: { mkdir: vi.fn(), - mkdtemp: vi.fn(), symlink: vi.fn(), writeFile: vi.fn(), }, @@ -65,7 +64,6 @@ beforeEach(() => { fixture.createServer.mockReset().mockResolvedValue(fixture.server); fixture.display.release.mockReset().mockResolvedValue(undefined); fixture.fs.mkdir.mockReset().mockResolvedValue(undefined); - fixture.fs.mkdtemp.mockReset().mockResolvedValue("/tmp/prd564-starter-"); fixture.fs.symlink.mockReset().mockResolvedValue(undefined); fixture.fs.writeFile.mockReset().mockResolvedValue(undefined); fixture.provideDisplay.mockReset().mockResolvedValue(fixture.display); diff --git a/packages/playtest/__tests__/resolution-scale-probe.spec.ts b/packages/playtest/__tests__/resolution-scale-probe.spec.ts index 247b4cc29f..be64bc76de 100644 --- a/packages/playtest/__tests__/resolution-scale-probe.spec.ts +++ b/packages/playtest/__tests__/resolution-scale-probe.spec.ts @@ -1,10 +1,10 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +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, @@ -385,7 +385,7 @@ it.each([ 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 mkdtemp(join(tmpdir(), "resolution-checkpoint-")); + 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)); @@ -393,31 +393,27 @@ it("startup checkpoint persists before a deferred operation and is replaced by i const deferred = new Promise((resolve) => { finish = resolve; }); let observedPending: (() => void) | undefined; const pendingObserved = new Promise((resolve) => { observedPending = resolve; }); - try { - 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 }); - } finally { - await rm(directory, { recursive: true, force: true }); - } + 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/measure-resolution-scale.ts b/packages/playtest/src/runner/measure-resolution-scale.ts index 371e1e2a98..5ae389baae 100644 --- a/packages/playtest/src/runner/measure-resolution-scale.ts +++ b/packages/playtest/src/runner/measure-resolution-scale.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +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"; @@ -330,7 +330,9 @@ async function serveStarter( await checkpoint("starter.import-scaffolder"); const { createProject } = await load(resolve(repo, "packages/create-threenative/src/index.ts")); await checkpoint("starter.create-target"); - const target = await mkdtemp(resolve(output, "starter-")); + // 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) From 4dfb1bf5dba085fb0bc511a64139605ef7899e91 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:43:49 -0700 Subject: [PATCH 31/53] test(core): verify refused subrect fallback and scaffold bytes --- ...e-changes-without-reallocating-a-target.md | 6 +- .../renderer-resolution-subrect.spec.ts | 91 +++++++++++++++++++ .../__tests__/scaffold.spec.ts | 28 +++--- 3 files changed, 109 insertions(+), 16 deletions(-) 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 f7fbc948ed..a5c9cfed53 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 @@ -110,8 +110,8 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame **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. — 19 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. -- [ ] 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. Missing: actual built-in stage UV/clamp sampling checks and an integrated refused-stage resize/allocation assertion; neither sampling nor visual acceptance is earned by admission-only coverage. +- [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. +- [ ] 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 built-in stage UV/clamp sampling remains missing, so this box and visual acceptance stay open. #### Phase 3: Finer steps and native **Status:** COMPLETE — 86 scaler regressions pass after the timer-less routing repair; exact-current-source browser/desktop sub-rectangle parity passes. Overall continuation remains pending the reopened Phase 1 baseline; timing and visual acceptance remain open. @@ -122,7 +122,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions - 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. Scaffold byte hashes still differ because the candidate changes generated render source; they remain unchanged pending the Phase 1 decision. The full repository board and hosted CI are not green. +- 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. diff --git a/packages/core/__tests__/renderer-resolution-subrect.spec.ts b/packages/core/__tests__/renderer-resolution-subrect.spec.ts index e248ce9e78..290d2d6d5d 100644 --- a/packages/core/__tests__/renderer-resolution-subrect.spec.ts +++ b/packages/core/__tests__/renderer-resolution-subrect.spec.ts @@ -2,6 +2,7 @@ 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"; @@ -274,6 +275,96 @@ it("updates scene viewport/scissor without allocations and reallocates once on r } }); +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(); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 7ba2cc1f07..5eec77e2d1 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -189,6 +189,8 @@ 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> = { + // 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 @@ -249,20 +251,20 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-11 on the merge of develop e6ed27869 into PRD-563: measured through // createProject on the merged tree. // PRD-566/569 merge: measured all thirteen trees, including action-rpg onCull instructions. - "action-rpg": "cdedb47ee03791a866fdb6ca98c51e61ba05ad75dab5aa23925a3d153acfa389", - minimal: "8d834289ea3940529d925f53a4946cc718771dc4fb8577c2199ba6a29faf51d4", - platformer: "f5d4a0362bd3a6f7bb3f8530178e22d5cbe0bd8625d0c83121b53f8f5aa3a2ec", - puzzle: "cdacbec8e3543a394263bd73c42d7482b61c433df6cb60ce42bc8306f91f71e9", - racing: "cec5c42b232f5f858fdefae9275b324378c2914d5113185e3ec3a748162caf23", - rain: "05027f3370b26deb260e158240a09904ea54e593a8184767eead660810bf7551", - rts: "6f331bb87e10340cd33263edbff4f7ce1fc40d24b601ea786a06ea41e1f065a5", - runner: "4f7a6b3ace688771a7c96e67062a8b696d0b50c09376183f2f02eead97361c12", + "action-rpg": "91765dedcb6a1efda93e62f949c075b1770b93f894c5d46f30b169cf11a4b9bd", + minimal: "7a3ae9ed643f9a72cd46d50aa43620f54a766eb3df10a8489f131520c2695706", + platformer: "04551c97362f641f94f03bd81549044a0df2b47b032c60842d2d69c14d120d14", + puzzle: "58220a6d882f4b08db9dad2dd2e0a42fb035da41ac2ed7679dba66b96661dd52", + racing: "30b2b908fae609f015f4f0daa2b53408ba19c552ad38cc1e05bb8ec1a9c68491", + rain: "ba6e4f6e0b528690297f876fcf1f7901d350e87d5a64ffc315060727f72c1d46", + rts: "499790c479fe2d3304ca444c3bcc0d6ed1681da907871a7ab2c6e0cb437c9919", + runner: "99fe2ddb2c60c551d94361caa437cd739b04deac5efaf9cc9fdab9aa5e49c68d", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "9c9edb14b880fc446e9b921b60dd99053f45032f053c4fcb5cc820a323c43904", - shooter: "699758de47b7642a38b998dad0437b17c6a68df046d8807ddbaf35a61d9f1423", - snow: "837eee8058f3bf043eabe5161207e62fbc5accd83cd1353902b79bb55f2dd1cf", - starter: "21b5ccb273b27ff8b711c7b2c5f42345adb7ab941fa19e605b64b01626f5aa68", - "tower-defense": "95e514738bd04ba2a561f3500d877af1d0c540a9e40fb00829770455006fd14c", + sailing: "20082d2e4574b28a2efd4f5aa9ca245c8586b90c86df69fc2db6f6457deebc85", + shooter: "fc0503597d90763e80f2b0dbcb2c15273c413e2f64768af477ecbe40df9e1307", + snow: "24739f16658e769aab629c3ebc16e9dd68246ef5e055991f0ae694c9b8a11e89", + starter: "a19c09c42a815cca4a54737a119facc81d6356826ccece3438e6f322dfd99bed", + "tower-defense": "7ba4b14cafb1c0b395b1f5cbda04bae0bde4640c038fdfaf54d71801ebc89b22", }; const GENERATED_SCAFFOLD_METADATA = From 1c101b9f79141c28cd03792840699784f98690cb Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:47:21 -0700 Subject: [PATCH 32/53] docs: refresh integrated resolution-subrect parity proof --- ...-resolution-scale-changes-without-reallocating-a-target.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 93e27aa37b..3475ea8ee0 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 @@ -114,10 +114,10 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [ ] 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 built-in stage UV/clamp sampling remains missing, so this box and visual acceptance stay open. #### Phase 3: Finer steps and native -**Status:** IN PROGRESS — continuous-policy regressions remain verified; browser/desktop parity must be refreshed after integrating develop 5f7143895, which changes the native host. Phase 1 timing and visual acceptance remain open. +**Status:** COMPLETE for the two bounded criteria below — continuous-policy regressions and fresh browser/desktop parity pass after integrating develop 5f7143895. Phase 1 timing and visual acceptance remain open. **Files:** `packages/core/src/resolution-scaler.ts`; `packages/runtime-native/conformance/scenes/shared/resolution-subrect.js` (new), `packages/runtime-native/conformance/registry.json` - [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. -- [ ] The native desktop host draws the same sub-rectangle frame as the browser. — Reopened for the develop 5f7143895 integration: fresh current-host build and browser/desktop captures are pending; the following evidence is historical. 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. +- [x] The native desktop host draws the same sub-rectangle frame as the browser. — 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. ## Decisions From aa31eb2ee3c1b6e658469b549d15012d6262e481 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:49:03 -0700 Subject: [PATCH 33/53] test(render): compile canonical AO retained-view sampling --- ...e-changes-without-reallocating-a-target.md | 2 +- ...world-environment-subrect-sampling.spec.ts | 349 ++++++++++++++++++ 2 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts 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 3475ea8ee0..a1b7f79bcb 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 @@ -111,7 +111,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame 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. -- [ ] 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 built-in stage UV/clamp sampling remains missing, so this box and visual acceptance stay open. +- [ ] 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 sampling proof remains missing, so this box and visual acceptance stay open. #### Phase 3: Finer steps and native **Status:** COMPLETE for the two bounded criteria below — continuous-policy regressions and fresh browser/desktop parity pass after integrating develop 5f7143895. Phase 1 timing and visual acceptance remain open. 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 0000000000..89ad5a1ce9 --- /dev/null +++ b/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts @@ -0,0 +1,349 @@ +import { + Mesh, + PerspectiveCamera, + PlaneGeometry, + type RenderTarget, + Scene, + type Texture, + Vector2, +} from "three"; +import { + type Node, + NodeFrame, + 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 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( + new RegExp(`texture(?:Sample|Load|\\w*Level)\\(\\s*(?:${textureNames.join("|")})\\b`, "g"), + ), + ].map((match) => balanced(wgsl, wgsl.indexOf("(", match.index ?? 0))); + +/** + * The `viewFraction` a read is clamped by, or `undefined` when it is not clamped to a retained view. + */ +const retainedViewOf = (read: string, fraction: string[], half: string[]): 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*\)$`, + ); + 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 match[1]; + } + return undefined; +}; + +const stage = () => { + 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 ao: (Node & { _aoRenderTarget: RenderTarget }) | undefined; + let denoiser: (Node & { updateBefore(): void; _resolution: { value: Vector2 } }) | undefined; + let worldPass: + | { getTextureNode(name: string): { value: Texture }; renderTarget: object } + | undefined; + 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 === "ambientOcclusion"); + if (!stage) throw new Error("Missing the canonical ambient-occlusion stage"); + graph = stage.build(options.input, { tier: "high" }) as Node; + graph.traverse((node) => { + const pass = Reflect.get(node, "passNode") as Node | undefined; + if (pass && Reflect.get(pass, "_aoRenderTarget") !== undefined) { + ao = pass as typeof ao; + } + if (Reflect.get(node, "_resolution") !== undefined && Reflect.get(node, "textureNode")) { + denoiser = node as typeof denoiser; + } + }); + return { applied: { stages: ["ambientOcclusion"], dropped: [] }, dispose() {} }; + }, + }; + const world = new WorldEnvironment({ gtaoEnabled: true, denoiseEnabled: true }); + const applied = world.apply(capture, scene, camera); + controller.activate(0.72); + controller.setSize(1280, 720); + if (!graph) throw new Error("Missing the composed ambient-occlusion graph"); + if (!ao) throw new Error("Missing the real GTAO node"); + 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 GTAO node its fragment node. + 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 gtaoQuad = new Mesh(new PlaneGeometry(), Reflect.get(ao, "_material") as never); + const output = compile(renderer, outputQuad, camera, scene); + const gtao = compile(renderer, gtaoQuad, camera, scene); + const depth = worldPass.getTextureNode("depth").value; + const normal = worldPass.getTextureNode("normal").value; + const occlusion = ao._aoRenderTarget.texture; + const colour = worldPass.getTextureNode("output").value; + // 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) 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)); + }; + 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); + draw(gtao); + const close = (): void => { + applied.dispose?.(); + controller.dispose(); + vi.unstubAllGlobals(); + }; + return { + ao, + close, + controller, + denoiser, + depth, + draw, + gtao, + normal, + occlusion, + output, + unretained, + view, + }; +}; + +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)), + ); + // 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(); + } + }); +}); From 05226773d3abe8b79fe9d59f4aacb78f9d530b58 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 20:13:03 -0700 Subject: [PATCH 34/53] test(render): prove bloom and spatial-AA retained sampling --- ...e-changes-without-reallocating-a-target.md | 6 +- ...world-environment-subrect-sampling.spec.ts | 394 ++++++++++++++++-- 2 files changed, 355 insertions(+), 45 deletions(-) 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 a1b7f79bcb..294602b1ef 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 @@ -106,12 +106,12 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - [ ] 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:** PARTIAL — implementation committed; runtime allocation and visual acceptance remain open. +**Status:** COMPLETE for lifecycle, actual supported-stage shader sampling and named refusal/resize criteria below. GPU pixel equivalence remains an open acceptance criterion. **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. -- [ ] 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 sampling proof remains missing, so this box and visual acceptance 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:** COMPLETE for the two bounded criteria below — continuous-policy regressions and fresh browser/desktop parity pass after integrating develop 5f7143895. Phase 1 timing and visual acceptance remain open. @@ -186,3 +186,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. diff --git a/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts b/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts index 89ad5a1ce9..fde6bf37da 100644 --- a/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-subrect-sampling.spec.ts @@ -10,6 +10,7 @@ import { import { type Node, NodeFrame, + type NodeMaterial, type NodeUniform, RenderPipeline, WGSLNodeBuilder, @@ -101,6 +102,8 @@ const namesOf = (built: IBuilt, vectors: readonly Vector2[]): string[] => { 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) { @@ -127,27 +130,100 @@ const allBindingNames = (built: IBuilt): string[] => [ const reads = (wgsl: string, textureNames: string[]): string[] => [ ...wgsl.matchAll( - new RegExp(`texture(?:Sample|Load|\\w*Level)\\(\\s*(?:${textureNames.join("|")})\\b`, "g"), + // 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 `viewFraction` a read is clamped by, or `undefined` when it is not clamped to a retained view. + * 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[]): string | undefined => { +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 match[1]; + if (match) return { fraction: match[1] as string, half: match[2] as string }; } return undefined; }; -const stage = () => { +/** 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 { @@ -160,87 +236,108 @@ const stage = () => { const camera = new PerspectiveCamera(); const scene = new Scene(); let graph: Node | undefined; - let ao: (Node & { _aoRenderTarget: RenderTarget }) | undefined; - let denoiser: (Node & { updateBefore(): void; _resolution: { value: Vector2 } }) | 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 === "ambientOcclusion"); - if (!stage) throw new Error("Missing the canonical ambient-occlusion stage"); + 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) => { - const pass = Reflect.get(node, "passNode") as Node | undefined; - if (pass && Reflect.get(pass, "_aoRenderTarget") !== undefined) { - ao = pass as typeof ao; - } - if (Reflect.get(node, "_resolution") !== undefined && Reflect.get(node, "textureNode")) { - denoiser = node as typeof denoiser; - } + if (!nodes.includes(node)) nodes.push(node); }); - return { applied: { stages: ["ambientOcclusion"], dropped: [] }, dispose() {} }; + return { applied: { stages: [stageName], dropped: [] }, dispose() {} }; }, }; - const world = new WorldEnvironment({ gtaoEnabled: true, denoiseEnabled: true }); + 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 ambient-occlusion graph"); - if (!ao) throw new Error("Missing the real GTAO node"); + 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 GTAO node its fragment node. + // 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 gtaoQuad = new Mesh(new PlaneGeometry(), Reflect.get(ao, "_material") as never); const output = compile(renderer, outputQuad, camera, scene); - const gtao = compile(renderer, gtaoQuad, camera, scene); - const depth = worldPass.getTextureNode("depth").value; - const normal = worldPass.getTextureNode("normal").value; - const occlusion = ao._aoRenderTarget.texture; - const colour = worldPass.getTextureNode("output").value; - // 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) 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)); - }; 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); - draw(gtao); + /** 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 { - ao, close, + colour: worldPass.getTextureNode("output").value, + compileMaterial, controller, - denoiser, - depth, + depth: worldPass.getTextureNode("depth").value, draw, - gtao, - normal, - occlusion, + normal: worldPass.getTextureNode("normal").value, output, - unretained, + 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(); @@ -254,7 +351,9 @@ describe("canonical ambient occlusion under a retained sub-rectangle", () => { expect(depthReads.length).toBeGreaterThan(0); expect(normalReads.length).toBeGreaterThan(0); const pairs = new Set( - [...depthReads, ...normalReads].map((read) => retainedViewOf(read, fraction, half)), + [...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); @@ -347,3 +446,212 @@ describe("canonical ambient occlusion under a retained sub-rectangle", () => { } }); }); + +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(); + } + }); +}); From eec2e369580f2b1eea1ac9821d5d30b5889b64d9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 20:49:51 -0700 Subject: [PATCH 35/53] fix(assets): reject new undeclared outputs with old timestamps --- ...e-changes-without-reallocating-a-target.md | 2 + .../assets/__tests__/bake-receipt.spec.ts | 45 ++++++++++++++++++- packages/assets/src/compile.ts | 28 +++++++----- 3 files changed, 63 insertions(+), 12 deletions(-) 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 294602b1ef..866dd09dd3 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 @@ -188,3 +188,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. A fresh complete suite remains required before publication. Current phase/acceptance boxes unchanged. diff --git a/packages/assets/__tests__/bake-receipt.spec.ts b/packages/assets/__tests__/bake-receipt.spec.ts index 35cf8cd19c..906b9cf75f 100644 --- a/packages/assets/__tests__/bake-receipt.spec.ts +++ b/packages/assets/__tests__/bake-receipt.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; @@ -278,7 +278,10 @@ describe("bake receipt", () => { ); }); - it("should throw when a pass writes a file under the output root it did not declare", async () => { + 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 })); @@ -293,6 +296,44 @@ describe("bake receipt", () => { 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; }, }, diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index 93d7acefed..f2dc3ea09b 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -2271,12 +2271,14 @@ async function walkOutputFiles(root: string, prefix = ""): Promise { * delete less than the bake produced, the second run would still find the undeleted file, and the * gate would pass while proving nothing. Only files touched during this run are considered — a * project's own hand-authored `public/icon.png` is not a bake output and is left alone. + * Pre-pass paths identify new files even when their mtime is backdated. */ async function assertNoUndeclaredOutputs( outputRoot: string, declared: ReadonlySet, since: number, writtenBefore: ReadonlySet, + pathsBefore: ReadonlySet, ): Promise { const undeclared: string[] = []; for (const relative of await walkOutputFiles(outputRoot)) { @@ -2287,10 +2289,14 @@ async function assertNoUndeclaredOutputs( ) continue; if (declared.has(relative)) continue; - // A file the previous receipt lists was written by an earlier bake, however close its mtime - // is to this run's start: with the cache skipping unchanged inputs, two bakes can be one - // millisecond apart, and the previous bake's now-stale output is not this bake's leak. + // Prior-receipt files belong to an earlier bake, not this run's leak. if (writtenBefore.has(relative)) continue; + // A new path appearing during this run is undeclared regardless of mtime. + if (!pathsBefore.has(relative)) { + undeclared.push(relative); + continue; + } + // A pre-existing path keeps the old mtime guard for touches/overwrites. const info = await stat(path.join(outputRoot, relative)); if (info.mtimeMs + 1 >= since) undeclared.push(relative); } @@ -2400,13 +2406,9 @@ export async function compileAssets( } const previous = await readExistingManifest(manifestPath); - // Read before the first write *and before the source walk*, so the undeclared-output guard can - // tell this run's files from the project's own static ones. Taking it after the walk shrinks the - // margin by however long the walk took, and `TN_ASSETS_UNDECLARED_OUTPUT` then misses a stray - // file whose mtime the filesystem truncated below it — which is exactly how - // `bake-receipt.spec.ts` went red on a loaded CI shard while passing locally. It never reaches - // the receipt: that stays deterministic. const runStart = Date.now(); + // Pre-pass snapshot: new-file presence beats mtime; pre-existing touches keep the old guard. + const outputPathsBefore = new Set(await walkOutputFiles(layout.outputRoot)); const sources = await walkSources(layout.sourceRoot); const excluded = sources.filter((logical) => @@ -3026,7 +3028,13 @@ export async function compileAssets( ); } const currentReceiptPaths = new Set(receiptOutputs.map((output) => output.path)); - await assertNoUndeclaredOutputs(layout.outputRoot, currentReceiptPaths, runStart, writtenBefore); + await assertNoUndeclaredOutputs( + layout.outputRoot, + currentReceiptPaths, + runStart, + writtenBefore, + outputPathsBefore, + ); const staleOutputs = await resolveStaleReceiptOutputs( layout.outputRoot, new Set([...writtenBefore, ...pendingPaths]), From a8ffb3cbd0dcd54e1a6c306d5d9f4a04ec647852 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 21:02:31 -0700 Subject: [PATCH 36/53] docs: record full tests and remaining PRD-564 owner gates --- ...tion-scale-changes-without-reallocating-a-target.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 866dd09dd3..fec22a41df 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,6 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target -**Status:** IN PROGRESS +**Status:** BLOCKED +**Blocker:** Agent checks pass; corrected timing qualification needs local scheduler authentication, and visual acceptance needs an owner decision on the equivalence/NEUTRAL rule conflict. **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) @@ -119,6 +120,11 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [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. - [x] The native desktop host draws the same sub-rectangle frame as the browser. — 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 + +- João: run the prepared `.afk/pr489-scheduler-trace.sh` through local sudo authentication so the agent can qualify the original three-context baseline and make the Phase 1 continue/decline decision. `sudo -n true` still fails with password required; no scheduler-qualified capture exists. The timing gate remains unqualified. +- João: resolve the visual-rule conflict. AC-2 requires pixel equivalence, while the repository rejects a NEUTRAL visual verdict. Existing pairs are identical and the judge returned NEUTRAL. The requested decision is pending; no exception or acceptance is assumed. + ## Decisions - 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. @@ -189,4 +195,4 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. A fresh complete suite remains required before publication. Current phase/acceptance boxes unchanged. +- 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. From ae040509635f9e7d13f1fc760bec51e154c2f0f4 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 17:09:59 -0700 Subject: [PATCH 37/53] fix(render): release spatial AA resources on chain rebuild --- ...e-changes-without-reallocating-a-target.md | 2 +- .../world-environment-lifetime.spec.ts | 94 +++++++++++++++++++ .../template-assets/worldEnvironment.ts | 4 + .../action-rpg/src/render/worldEnvironment.ts | 4 + .../minimal/src/render/worldEnvironment.ts | 4 + .../platformer/src/render/worldEnvironment.ts | 4 + .../puzzle/src/render/worldEnvironment.ts | 4 + .../racing/src/render/worldEnvironment.ts | 4 + .../rain/src/render/worldEnvironment.ts | 4 + .../rts/src/render/worldEnvironment.ts | 4 + .../runner/src/render/worldEnvironment.ts | 4 + .../sailing/src/render/worldEnvironment.ts | 4 + .../shooter/src/render/worldEnvironment.ts | 4 + .../snow/src/render/worldEnvironment.ts | 4 + .../starter/src/render/worldEnvironment.ts | 4 + .../src/render/worldEnvironment.ts | 4 + 16 files changed, 151 insertions(+), 1 deletion(-) 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 9b4dda45f1..1ab5f8c64f 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 @@ -111,7 +111,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame **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. +- [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 spec passes 65/66; its byte-stability hash fixture rejects the changed generated source and remains untouched outside this lane’s ownership. 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 diff --git a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts index fd78cc0280..aa533b3a30 100644 --- a/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts +++ b/packages/create-threenative/__tests__/world-environment-lifetime.spec.ts @@ -1,5 +1,6 @@ import { PerspectiveCamera, Scene, 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, NodeBuilder } from "three/webgpu"; import { expect, it, vi } from "vitest"; @@ -66,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 = { diff --git a/packages/create-threenative/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 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 ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 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 ed90f7a21c..c149c7e01b 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts @@ -1008,6 +1008,10 @@ export class WorldEnvironment { 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 From 085595ed2241df2b572a8c71d6198380fb3e2ed9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 17:13:15 -0700 Subject: [PATCH 38/53] test(scaffold): refresh hashes for spatial AA disposal --- ...e-changes-without-reallocating-a-target.md | 2 +- .../__tests__/scaffold.spec.ts | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) 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 1ab5f8c64f..70aba80ffe 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 @@ -111,7 +111,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame **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 spec passes 65/66; its byte-stability hash fixture rejects the changed generated source and remains untouched outside this lane’s ownership. 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] 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 diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 3760bd8445..80b9c72cb9 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -190,6 +190,8 @@ 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. @@ -255,20 +257,20 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-13: the onCull paragraph in action-rpg AGENTS.md folded to one line to meet the 100-line budget. // Merged develop 5f7143895 into PRD-564: both sides' template bytes ship, so all thirteen // trees are measured through createProject on this combined worktree, not either side's value. - "action-rpg": "d3e0be1c948fe8f812fbc38fcb627544a13139030da446cea6cd988d672eedbe", - minimal: "7a3ae9ed643f9a72cd46d50aa43620f54a766eb3df10a8489f131520c2695706", - platformer: "04551c97362f641f94f03bd81549044a0df2b47b032c60842d2d69c14d120d14", - puzzle: "58220a6d882f4b08db9dad2dd2e0a42fb035da41ac2ed7679dba66b96661dd52", - racing: "30b2b908fae609f015f4f0daa2b53408ba19c552ad38cc1e05bb8ec1a9c68491", - rain: "ba6e4f6e0b528690297f876fcf1f7901d350e87d5a64ffc315060727f72c1d46", - rts: "499790c479fe2d3304ca444c3bcc0d6ed1681da907871a7ab2c6e0cb437c9919", - runner: "99fe2ddb2c60c551d94361caa437cd739b04deac5efaf9cc9fdab9aa5e49c68d", + "action-rpg": "ae9837084c7faabdca87cf9eae5662d1eb61fc2753be8739e07634f818fc8867", + minimal: "3d939290717ffe222c5d17f25c8ba5b100dbd6d6a96eecbe750d5e27a4fd228b", + platformer: "e6a822971db37c2422db9336c575c7aaab161a8be4205d48e0801f5b63d12eeb", + puzzle: "c1c33d5f460bc79578cf852def028eade8ab67dbf1836dbd0a1f1f7fd944be7b", + racing: "d6cd383617ffcea4f9f47ef8bdcb7d46408e3f7ca404b063ec873a4392c9f298", + rain: "3951248bc2446a51e82354e8f97e994b3c486a5d269033cb2cdaf6a175b169f1", + rts: "300c35dbc5d5cf7bac1bd4b4b88fc72bc1ff48a684c2f2cf68417af7e10b17f1", + runner: "db1e25fcb9c30334d3ab1211027238a3e62d94adf0428a829c65b61f6f970514", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "20082d2e4574b28a2efd4f5aa9ca245c8586b90c86df69fc2db6f6457deebc85", - shooter: "fc0503597d90763e80f2b0dbcb2c15273c413e2f64768af477ecbe40df9e1307", - snow: "24739f16658e769aab629c3ebc16e9dd68246ef5e055991f0ae694c9b8a11e89", - starter: "a19c09c42a815cca4a54737a119facc81d6356826ccece3438e6f322dfd99bed", - "tower-defense": "7ba4b14cafb1c0b395b1f5cbda04bae0bde4640c038fdfaf54d71801ebc89b22", + sailing: "c040afd12179b94aa23b19916eb3e53bc6d01b169f9c008cdb3195e3bba95601", + shooter: "8428f876e0ae14ef80463fb3bceadb12ee41dc0627bdfd7ff0dea124fca7a1bb", + snow: "eaadbf9aded8860e780c7f8bbbca6b831690cf10a42e5cc59bc86b45d10f52ab", + starter: "ae1ff4afa41a3b2e2b9c6a59ec53f5fda3d8358c58277e3b7512a8d5ade8a611", + "tower-defense": "741a97a5e95edd496d77a2bfe6ff6ebf9fb90cb00340ecbd40c43159f95cf8a3", }; const GENERATED_SCAFFOLD_METADATA = From 96234a3a95a8b36173502765fee684318a2f236a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 19:44:21 -0700 Subject: [PATCH 39/53] docs(prd564): accept matched-quality visual preservation proof --- ...-scale-changes-without-reallocating-a-target.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 70aba80ffe..e9735f6a63 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** BLOCKED -**Blocker:** Corrected timing qualification needs local scheduler authentication; AC-2 remains open pending review of the visual evidence under the current owner rule allowing NEUTRAL for appearance-preserving performance work. +**Blocker:** Baseline timing remains unqualified: Docker v1 lacks owned-thread mapping; v2 overlaps a foreign native process. A qualified baseline and candidate are still required. AC-2 passes the owner’s visual-preservation gate; screenshots are published. **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) @@ -89,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: pending scheduler-qualified original baseline and candidate. +- [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 @@ -107,7 +107,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame - [ ] 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:** COMPLETE for lifecycle, actual supported-stage shader sampling and named refusal/resize criteria below. GPU pixel equivalence remains an open acceptance criterion. +**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. @@ -122,11 +122,13 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Blocked on -- João: run the prepared `.afk/pr489-scheduler-trace.sh` through local sudo authentication so the agent can qualify the original three-context baseline and make the Phase 1 continue/decline decision. `sudo -n true` still fails with password required; no scheduler-qualified capture exists. The timing gate remains unqualified. -- Visual evidence review remains pending for AC-2. The owner’s current primary-checkout `AGENTS.md` permits NEUTRAL for performance-only and appearance-preserving work when there is no visible regression; no owner rule decision is needed. Existing NEUTRAL pairs still need review against AC-2 before acceptance. +- Obtain scheduler-qualified original baseline and candidate measurements; Docker v1/v2 are rejected. +- Complete fresh candidate qualification before delivery; AC-2 visual acceptance and screenshot publication are complete. ## Decisions +- 2026-10-14 (candidate and evidence refresh): `347b8bf28` integrates develop; `ae0405096` fixes AA disposal (119 focused tests, typecheck pass); current `085595ed2` refreshes 13 scaffold hashes (66 scaffold, 101 focused tests pass). Docker baseline v1 is rejected for missing owned-thread mapping; v2 overlaps an actual foreign native process. Both remain timing-unqualified; no timing boxes change. Fresh NVIDIA/turing BEFORE captures at `artifacts/prd564/visual-qualified-before-live-tier-085595ed2-root-access-lease600` match retained AFTER in `artifacts/prd564/visual-static-ae0405096/after-retry`: live tier high, no dropped stages, 240 ticks, static grain 0.0039, run-1 live/run-2 frozen RAF; temporary sources restored. Source SHA-256 BEFORE `502ba75647b59067fe6aac1174faf2cad0b82c763d0bd988f313b30b1cd78099`, AFTER `9d87d65b64c6d634bfaf17e3b096c20f5630d7fa80963473e86e9747ce207a63`. A fresh read-only judge returns NEUTRAL for both 0.72 pairs and the scale-1 control. Root applies the current owner rule: NEUTRAL passes appearance-preserving performance acceptance, closing AC-2. PixelMismatchRatio/ΔE: 0.72 run 1 `0.6473828125/0.497017760`, run 2 `0.6477582465/0.497420701`; scale 1 `0.135687934/0.089608370`. Images are nonidentical; shipped `visuals:ab` quality-MDE scoring is INDETERMINATE, with no numeric pixel-pass claim. Screenshots: orphan `prd-564-screenshots`, immutable `2549f6aba14a344da4d35dd6a4f015998f5d7954`, [PR evidence comment](https://github.com/ThreeNativeHQ/threenative-engine/pull/489#issuecomment-6183175841). Root docs checks pass 3,097 links and 246 contract tests. Earlier access restrictions are historical. + - 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. From ec2e938cdd98a20f9cc5b3309c7f6cfad9462f1f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 20:04:16 -0700 Subject: [PATCH 40/53] docs(prd564): record bounded timing qualification result --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e9735f6a63..7f84adaaf3 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** BLOCKED -**Blocker:** Baseline timing remains unqualified: Docker v1 lacks owned-thread mapping; v2 overlaps a foreign native process. A qualified baseline and candidate are still required. AC-2 passes the owner’s visual-preservation gate; screenshots are published. +**Blocker:** Baseline v3 is rejected for unresolved userspace CPU ownership. Timing needs a quiet desktop window on CPU 11/23 and RTX 2080; user priority is pending. Capture retries stop after three failed repairs. AC-2 and screenshot publication remain complete. **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) @@ -122,12 +122,12 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Blocked on -- Obtain scheduler-qualified original baseline and candidate measurements; Docker v1/v2 are rejected. +- Obtain a quiet CPU 11/23 + RTX 2080 desktop window and user priority; v1/v2/v3 timing is rejected. No further capture retries after three failed repairs. - Complete fresh candidate qualification before delivery; AC-2 visual acceptance and screenshot publication are complete. ## Decisions -- 2026-10-14 (candidate and evidence refresh): `347b8bf28` integrates develop; `ae0405096` fixes AA disposal (119 focused tests, typecheck pass); current `085595ed2` refreshes 13 scaffold hashes (66 scaffold, 101 focused tests pass). Docker baseline v1 is rejected for missing owned-thread mapping; v2 overlaps an actual foreign native process. Both remain timing-unqualified; no timing boxes change. Fresh NVIDIA/turing BEFORE captures at `artifacts/prd564/visual-qualified-before-live-tier-085595ed2-root-access-lease600` match retained AFTER in `artifacts/prd564/visual-static-ae0405096/after-retry`: live tier high, no dropped stages, 240 ticks, static grain 0.0039, run-1 live/run-2 frozen RAF; temporary sources restored. Source SHA-256 BEFORE `502ba75647b59067fe6aac1174faf2cad0b82c763d0bd988f313b30b1cd78099`, AFTER `9d87d65b64c6d634bfaf17e3b096c20f5630d7fa80963473e86e9747ce207a63`. A fresh read-only judge returns NEUTRAL for both 0.72 pairs and the scale-1 control. Root applies the current owner rule: NEUTRAL passes appearance-preserving performance acceptance, closing AC-2. PixelMismatchRatio/ΔE: 0.72 run 1 `0.6473828125/0.497017760`, run 2 `0.6477582465/0.497420701`; scale 1 `0.135687934/0.089608370`. Images are nonidentical; shipped `visuals:ab` quality-MDE scoring is INDETERMINATE, with no numeric pixel-pass claim. Screenshots: orphan `prd-564-screenshots`, immutable `2549f6aba14a344da4d35dd6a4f015998f5d7954`, [PR evidence comment](https://github.com/ThreeNativeHQ/threenative-engine/pull/489#issuecomment-6183175841). Root docs checks pass 3,097 links and 246 contract tests. Earlier access restrictions are historical. +- 2026-10-14 (candidate and evidence refresh): `347b8bf28` integrates develop; `ae0405096` fixes AA disposal (119 focused tests, typecheck pass); current `085595ed2` refreshes 13 scaffold hashes (66 scaffold, 101 focused tests pass). Timing rejects v1 (missing owned-thread mapping), v2 (foreign native process), and v3 (unresolved userspace CPU ownership: node TID 678988, CPU 11, 36 frames/175.105 ms; rg TID 693395, 2 frames/65.622 ms). V3 clocks/affinity/nice/scheduler are valid for all 279 frames; no foreign GPU was observed. Raw crossings are unaccepted. A quiet CPU 11/23 + RTX 2080 window and user priority are pending; retries stop after three failed repairs. Timing boxes stay open; root’s full unit run is ongoing. Fresh NVIDIA/turing BEFORE captures at `artifacts/prd564/visual-qualified-before-live-tier-085595ed2-root-access-lease600` match retained AFTER in `artifacts/prd564/visual-static-ae0405096/after-retry`: live tier high, no dropped stages, 240 ticks, static grain 0.0039, run-1 live/run-2 frozen RAF; temporary sources restored. Source SHA-256 BEFORE `502ba75647b59067fe6aac1174faf2cad0b82c763d0bd988f313b30b1cd78099`, AFTER `9d87d65b64c6d634bfaf17e3b096c20f5630d7fa80963473e86e9747ce207a63`. A fresh read-only judge returns NEUTRAL for both 0.72 pairs and the scale-1 control. Root applies the current owner rule: NEUTRAL passes appearance-preserving performance acceptance, closing AC-2. PixelMismatchRatio/ΔE: 0.72 run 1 `0.6473828125/0.497017760`, run 2 `0.6477582465/0.497420701`; scale 1 `0.135687934/0.089608370`. Images are nonidentical; shipped `visuals:ab` quality-MDE scoring is INDETERMINATE, with no numeric pixel-pass claim. Screenshots: orphan `prd-564-screenshots`, immutable `2549f6aba14a344da4d35dd6a4f015998f5d7954`, [PR evidence comment](https://github.com/ThreeNativeHQ/threenative-engine/pull/489#issuecomment-6183175841). Root docs checks pass 3,097 links and 246 contract tests. Earlier access restrictions are historical. - 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. From a9e691c56121dc43601e033a5b71422ce551b516 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 20:42:08 -0700 Subject: [PATCH 41/53] docs: record integrated PR489 unit qualification --- ...-resolution-scale-changes-without-reallocating-a-target.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7d36ac21fc..07dd466022 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 @@ -123,11 +123,11 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Blocked on - Let other jobs finish first, then obtain a quiet CPU 11/23 + RTX 2080 desktop window; v1/v2/v3 timing is rejected. No further capture retries after three failed repairs. -- Complete fresh candidate qualification before delivery; AC-2 visual acceptance and screenshot publication are complete. +- Fresh candidate Vitest and core package checks pass; timing qualification remains required before delivery. AC-2 and screenshot publication are complete. ## Decisions -- 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. Old-candidate full Vitest ran 780 files/10,558 tests but exited 1 on an unhandled Node20 module-loader worker crash: not green. Implicated texture file passes alone (31 passed/1 skipped). 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). V3 clocks/affinity/nice/scheduler validate all 279 frames; no foreign GPU observed. Raw crossings remain unaccepted. User priority: let other jobs finish; quiet CPU 11/23 + RTX 2080 timing waits without interruption. No further capture retries or timing ticks. 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 (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). V3 clocks/affinity/nice/scheduler validate all 279 frames; no foreign GPU observed. Raw crossings remain unaccepted. User priority: let other jobs finish; quiet CPU 11/23 + RTX 2080 timing waits without interruption. No further capture retries or timing ticks. 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. From c4907f917c507bc864d70fe07d7764b20f8a8d6a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 22:05:46 -0700 Subject: [PATCH 42/53] docs: record CPU interference in fresh resolution baseline --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 07dd466022..4c93303ed6 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** BLOCKED -**Blocker:** Baseline v3 is rejected for unresolved userspace CPU ownership. Timing waits for other jobs to finish, then needs a quiet desktop window on CPU 11/23 and RTX 2080. Capture retries stop after three failed repairs. AC-2 and screenshot publication remain complete. +**Blocker:** Fresh post-jobs baseline is UNQUALIFIED: unowned tsc TID 1462226 overlaps run 1/frame 19 on CPU 11 for 0.996892 ms. Raw ratios ≤2 with zero pipelines do not earn DECLINE. Awaiting priority for a reserved ten-minute CPU 11/23 + RTX window. **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) @@ -122,12 +122,12 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Blocked on -- Let other jobs finish first, then obtain a quiet CPU 11/23 + RTX 2080 desktop window; v1/v2/v3 timing is rejected. No further capture retries after three failed repairs. +- Await the user’s priority answer for a reserved ten-minute CPU 11/23 + RTX 2080 window; the earlier instruction gives other jobs priority. No further timing capture while awaiting it. - Fresh candidate Vitest and core package checks pass; timing qualification remains required before delivery. AC-2 and screenshot publication are complete. ## Decisions -- 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). V3 clocks/affinity/nice/scheduler validate all 279 frames; no foreign GPU observed. Raw crossings remain unaccepted. User priority: let other jobs finish; quiet CPU 11/23 + RTX 2080 timing waits without interruption. No further capture retries or timing ticks. 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 (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. Raw ratios ≤2 and zero pipelines earn no qualified DECLINE. Await user priority for a reserved ten-minute CPU 11/23 + RTX window; 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. From e77260f09b1db5c4f64243239f34ebd045c00e74 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 23:17:51 -0700 Subject: [PATCH 43/53] docs: record reserved baseline and recorder interference --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 4c93303ed6..cac803c76f 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target -**Status:** BLOCKED -**Blocker:** Fresh post-jobs baseline is UNQUALIFIED: unowned tsc TID 1462226 overlaps run 1/frame 19 on CPU 11 for 0.996892 ms. Raw ratios ≤2 with zero pipelines do not earn DECLINE. Awaiting priority for a reserved ten-minute CPU 11/23 + RTX window. +**Status:** PARTIAL +**Blocker:** Timing qualification remains open: the first reserved baseline failed CPU isolation when root dockerd TID 1354 ran inside forced-step windows on CPU 11. The owner-approved reserved-window rerun is executing with direct-file trace recording; no qualified DECLINE is earned. **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) @@ -127,7 +127,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Decisions -- 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. Raw ratios ≤2 and zero pipelines earn no qualified DECLINE. Await user priority for a reserved ten-minute CPU 11/23 + RTX window; 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 (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. From 1664a981dbe26520361e91a61c10863ded1b4c79 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 23:39:43 -0700 Subject: [PATCH 44/53] fix(core): emit guarded rung after scaler oscillation --- .../core/__tests__/resolution-scaler.spec.ts | 20 +++++++++++++++++++ packages/core/src/resolution-scaler.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/__tests__/resolution-scaler.spec.ts b/packages/core/__tests__/resolution-scaler.spec.ts index 57ac8c516a..7e150c7ec8 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/resolution-scaler.ts b/packages/core/src/resolution-scaler.ts index 4d47923ede..8aae23c3d8 100644 --- a/packages/core/src/resolution-scaler.ts +++ b/packages/core/src/resolution-scaler.ts @@ -788,11 +788,11 @@ export class ResolutionScaler { // more rungs than the floor allows, and it must land on the floor rather than past it. const next = this.#index + direction; this.#index = direction > 0 ? Math.min(next, this.#limitIndex) : Math.max(0, next); - this.#continuousScale = RESOLUTION_SCALER.rungs[this.#index] ?? 1; this.#cooldown = RESOLUTION_SCALER.cooldownWindows; // 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; } From 46c3b843f4435cafd8c1df6193595b26a988d492 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 23:42:57 -0700 Subject: [PATCH 45/53] docs(prd564): record latest baseline gaps and scaler repair --- ...on-scale-changes-without-reallocating-a-target.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 cac803c76f..74e8f9c5f8 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Timing qualification remains open: the first reserved baseline failed CPU isolation when root dockerd TID 1354 ran inside forced-step windows on CPU 11. The owner-approved reserved-window rerun is executing with direct-file trace recording; no qualified DECLINE is earned. +**Blocker:** Timing qualification remains open. The completed direct-file baseline `scheduler-qualified-baseline-lease-direct-v2-e77260f09` records one raw >2× crossing, but clock/ownership coverage and GPU-tail attribution remain incomplete; neither qualified continue nor decline is earned. Current-source full-suite and native verification are in progress. **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) @@ -89,7 +89,7 @@ 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 scheduler-qualified original baseline and candidate. +- [ ] 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: original baseline `artifacts/prd564/scheduler-qualified-baseline-lease-direct-v2-e77260f09` is complete but unqualified (one raw 7.6/3.3 ms crossing, incomplete clock/ownership and GPU-tail coverage); qualified baseline decision and candidate proof 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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — corrected original baseline completed all 9 steps on NVIDIA with zero new pipelines. Runs 1 and 3 stay below 2×; run 2 crosses at 20.6/7 ms (2.94×) and 10.5/4 ms (2.63×), with observed runqueue waits and competing CPU. Clock/thread/affinity/GPU coverage remain unqualified; neither continue nor decline is earned. +**Status:** REOPENED / UNQUALIFIED — latest corrected original baseline `artifacts/prd564/scheduler-qualified-baseline-lease-direct-v2-e77260f09` completed three contexts, nine steps and all 279 frame observations with zero new pipelines; scheduler recording reports zero LOST events and the root wrapper exits 0. Only run 1 scale 0.85 crosses the unchanged wall-time gate: 7.6/3.3 ms (2.303×). Attribution remains unqualified: only 275/279 frames have guaranteed clock interiors, ownership observations miss run 1 frames 88–92 and run 3 frames 70–92, and GPU-tail coverage is missing 1.20 seconds. Neither continue nor decline is earned. Qualification does not require zero OS/kernel activity; the remaining requirement is credible coverage and attribution, without subtracting work or changing thresholds. Earlier baseline cohorts remain in Decisions. **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: `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. @@ -115,14 +115,14 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [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:** COMPLETE for the two bounded criteria below — continuous-policy regressions and fresh browser/desktop parity pass after integrating develop 5f7143895. Phase 1 timing and visual acceptance remain open. +**Status:** COMPLETE for the historically verified bounded criteria below. Current-source full-suite and native refresh are in progress after the oscillation repair; Phase 1 timing and AC-1 remain open. 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` -- [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. +- [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-suite/native refresh is in progress; no additional acceptance box is earned. - [x] The native desktop host draws the same sub-rectangle frame as the browser. — 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 -- Await the user’s priority answer for a reserved ten-minute CPU 11/23 + RTX 2080 window; the earlier instruction gives other jobs priority. No further timing capture while awaiting it. +- The owner authorized the bounded ten-minute CPU 11/23 + RTX 2080 window. Its latest baseline completed, but clock/ownership and GPU-tail coverage remain insufficient for timing qualification. - Fresh candidate Vitest and core package checks pass; timing qualification remains required before delivery. AC-2 and screenshot publication are complete. ## Decisions From 2417d32d4814376c08a0b0b3184b56b8ce6e9bae Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 23:45:58 -0700 Subject: [PATCH 46/53] docs: refresh PRD-564 native parity after scaler repair --- ...he-resolution-scale-changes-without-reallocating-a-target.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 74e8f9c5f8..c818395b15 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 @@ -118,7 +118,7 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu **Status:** COMPLETE for the historically verified bounded criteria below. Current-source full-suite and native refresh are in progress after the oscillation repair; Phase 1 timing and AC-1 remain open. 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` - [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-suite/native refresh is in progress; no additional acceptance box is earned. -- [x] The native desktop host draws the same sub-rectangle frame as the browser. — 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. +- [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 From 21c5d5548d52e2cf006130a7c9a04cb83db49dec Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 14 Oct 2026 23:59:39 -0700 Subject: [PATCH 47/53] docs(prd564): record unqualified final timing attempt --- ...on-scale-changes-without-reallocating-a-target.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 c818395b15..6625e09b86 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Timing qualification remains open. The completed direct-file baseline `scheduler-qualified-baseline-lease-direct-v2-e77260f09` records one raw >2× crossing, but clock/ownership coverage and GPU-tail attribution remain incomplete; neither qualified continue nor decline is earned. Current-source full-suite and native verification are in progress. +**Blocker:** Latest corrected baseline `scheduler-qualified-baseline-tail-v3-1664a981` remains UNQUALIFIED despite complete measured-frame observation coverage: its sole raw >2× crossing overlaps renderer scheduling delay whose upper bound exceeds the crossing margin. The bounded retry cap is reached; automatic timing retries stop. AC-1 and final qualification remain open; no 100% or merge claim. **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) @@ -89,7 +89,7 @@ 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: original baseline `artifacts/prd564/scheduler-qualified-baseline-lease-direct-v2-e77260f09` is complete but unqualified (one raw 7.6/3.3 ms crossing, incomplete clock/ownership and GPU-tail coverage); qualified baseline decision and candidate proof remain 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-tail-v3-1664a981` is complete but attribution remains unqualified; qualified baseline decision and candidate proof remain pending. Bounded automatic retries have stopped. - [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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — latest corrected original baseline `artifacts/prd564/scheduler-qualified-baseline-lease-direct-v2-e77260f09` completed three contexts, nine steps and all 279 frame observations with zero new pipelines; scheduler recording reports zero LOST events and the root wrapper exits 0. Only run 1 scale 0.85 crosses the unchanged wall-time gate: 7.6/3.3 ms (2.303×). Attribution remains unqualified: only 275/279 frames have guaranteed clock interiors, ownership observations miss run 1 frames 88–92 and run 3 frames 70–92, and GPU-tail coverage is missing 1.20 seconds. Neither continue nor decline is earned. Qualification does not require zero OS/kernel activity; the remaining requirement is credible coverage and attribution, without subtracting work or changing thresholds. Earlier baseline cohorts remain in Decisions. +**Status:** REOPENED / UNQUALIFIED — latest baseline `artifacts/prd564/scheduler-qualified-baseline-tail-v3-1664a981` completes all 279 frame observations, clock/control identity, ownership, affinity/nice, NVML brackets and measured scheduler continuity. Decoder exit 7 reflects 13,519 globally lost events at monotonic 22450.657 seconds, about 99 seconds before measurement; global zero-LOST is not claimed. All nine steps record 26 texture allocations and zero pipeline builds. The sole raw crossing is run 3 scale 0.61: 23.7/5.3 ms (4.472×), with renderer runqueue-wait upper bound 13.3921 ms exceeding the 13.1 ms crossing margin. Foreign CPU-pair work is observed; neither qualified continue nor decline is earned. Zero OS/kernel activity is not a criterion. The bounded retry cap is reached; stop automatic timing retries and retain the draft at 75% with AC-1 open. **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: `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. @@ -122,11 +122,13 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## Blocked on -- The owner authorized the bounded ten-minute CPU 11/23 + RTX 2080 window. Its latest baseline completed, but clock/ownership and GPU-tail coverage remain insufficient for timing qualification. -- Fresh candidate Vitest and core package checks pass; timing qualification remains required before delivery. AC-2 and screenshot publication are complete. +- 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. +- Full Vitest passed on earlier source `97c920f65`; current product `1664a981d` has 87 focused scaler tests, typecheck and core build passing. The new laptop full-suite job result is unread: read-only status refused at 98°C. Current native proof is recorded in `2417d32d4`; final full-suite/CI and timing qualification remain open. 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; current full-suite result remains unread after the laptop status guard refused at 98°C. 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. From 8e255609c03f9f8b00dc905641ea56a523d8559c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 00:16:00 -0700 Subject: [PATCH 48/53] docs: record repaired scaler full-suite result --- ...-resolution-scale-changes-without-reallocating-a-target.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6625e09b86..cfa0378dfa 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 @@ -123,11 +123,11 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu ## 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. -- Full Vitest passed on earlier source `97c920f65`; current product `1664a981d` has 87 focused scaler tests, typecheck and core build passing. The new laptop full-suite job result is unread: read-only status refused at 98°C. Current native proof is recorded in `2417d32d4`; final full-suite/CI and timing qualification remain open. AC-2 and screenshot publication are complete. +- 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, typecheck, core build and current native parity also pass; current workspace lint is pending. 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; current full-suite result remains unread after the laptop status guard refused at 98°C. Earlier full-suite and current native evidence retain their recorded revisions. +- 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 workspace lint is pending; Phase 1/AC-1 remain open and progress remains 75%. 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`. From 345893fb45e4898ef697448e61ff4b13d055a363 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 00:17:22 -0700 Subject: [PATCH 49/53] docs: finalize current scaler verification status --- ...olution-scale-changes-without-reallocating-a-target.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 cfa0378dfa..29b35e8d9a 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 @@ -115,19 +115,19 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - [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:** COMPLETE for the historically verified bounded criteria below. Current-source full-suite and native refresh are in progress after the oscillation repair; Phase 1 timing and AC-1 remain open. AC-2 visual preservation is verified. +**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` -- [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-suite/native refresh is in progress; no additional acceptance box is earned. +- [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, typecheck, core build and current native parity also pass; current workspace lint is pending. Hosted CI and timing qualification remain open; draft/75% is unchanged. AC-2 and screenshot publication are complete. +- 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 workspace lint is pending; Phase 1/AC-1 remain open and progress remains 75%. Earlier full-suite and current native evidence retain their recorded revisions. +- 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`. From d50cc831e043b7cae6b3427644b3bd8841698ef1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 05:55:57 -0700 Subject: [PATCH 50/53] docs(prd564): record administrator-reserved timing outcome --- ...olution-scale-changes-without-reallocating-a-target.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 29b35e8d9a..318517c812 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Latest corrected baseline `scheduler-qualified-baseline-tail-v3-1664a981` remains UNQUALIFIED despite complete measured-frame observation coverage: its sole raw >2× crossing overlaps renderer scheduling delay whose upper bound exceeds the crossing margin. The bounded retry cap is reached; automatic timing retries stop. AC-1 and final qualification remain open; no 100% or merge claim. +**Blocker:** Administrator-reserved baseline `scheduler-qualified-baseline-admin-v2-1664a981` records all 279 frames but remains UNQUALIFIED: newly launched container tasks execute on CPUs 11/23 between affinity reconciliation passes. Raw step/p50 ratios are 1.424–1.788× with zero pipelines; below-threshold loaded data cannot earn qualified decline. GPU interval qualification also remains unresolved. All modified CPU masks were restored; further affinity-only captures stop. AC-1 and Phase 1 remain open; no 100% or merge claim. **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) @@ -89,7 +89,7 @@ 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: latest original baseline `artifacts/prd564/scheduler-qualified-baseline-tail-v3-1664a981` is complete but attribution remains unqualified; qualified baseline decision and candidate proof remain pending. Bounded automatic retries have stopped. +- [ ] 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-admin-v2-1664a981` records all 279 frames but remains unqualified because new container tasks execute on renderer CPUs. Qualified baseline decision and candidate proof remain pending; further affinity-only captures stop. - [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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — latest baseline `artifacts/prd564/scheduler-qualified-baseline-tail-v3-1664a981` completes all 279 frame observations, clock/control identity, ownership, affinity/nice, NVML brackets and measured scheduler continuity. Decoder exit 7 reflects 13,519 globally lost events at monotonic 22450.657 seconds, about 99 seconds before measurement; global zero-LOST is not claimed. All nine steps record 26 texture allocations and zero pipeline builds. The sole raw crossing is run 3 scale 0.61: 23.7/5.3 ms (4.472×), with renderer runqueue-wait upper bound 13.3921 ms exceeding the 13.1 ms crossing margin. Foreign CPU-pair work is observed; neither qualified continue nor decline is earned. Zero OS/kernel activity is not a criterion. The bounded retry cap is reached; stop automatic timing retries and retain the draft at 75% with AC-1 open. +**Status:** REOPENED / UNQUALIFIED — administrator-reserved baseline `artifacts/prd564/scheduler-qualified-baseline-admin-v2-1664a981` completes all 279 observations, with scheduler recorder/decode exit 0, complete measured continuity and no LOST reports. All nine steps record 26 texture allocations and zero pipeline builds. Raw step/p50 ratios: run 1 `1.553/1.559/1.424`, run 2 `1.694/1.515/1.484`, run 3 `1.788/1.452/1.500`. Actual runc/nginx/perl execution on CPUs 11/23 rejects qualification despite administrator affinity control; new tasks can execute before reconciliation or reset their masks. Neither qualified continue nor decline is earned. GPU observation remains unqualified as detailed below. Zero OS/kernel activity is not a criterion. Stop further affinity-only captures and retain the draft at 75% with AC-1 open. **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: `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. @@ -202,3 +202,5 @@ Implementation boundary: core retains target capacity, sets viewport/scissor, pu - 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. From bfcfcd075ecd7fc84d6fc99ad5ab64c232b28de1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 07:56:58 -0700 Subject: [PATCH 51/53] docs(pr489): record atomic quiet-window timing blocker --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 318517c812..c8c3f7bc31 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Administrator-reserved baseline `scheduler-qualified-baseline-admin-v2-1664a981` records all 279 frames but remains UNQUALIFIED: newly launched container tasks execute on CPUs 11/23 between affinity reconciliation passes. Raw step/p50 ratios are 1.424–1.788× with zero pipelines; below-threshold loaded data cannot earn qualified decline. GPU interval qualification also remains unresolved. All modified CPU masks were restored; further affinity-only captures stop. AC-1 and Phase 1 remain open; no 100% or merge claim. +**Blocker:** Atomic CPU reservation and reversible desktop hiding work, but baseline `scheduler-qualified-baseline-host4-quiet-v3-1664a981` remains UNQUALIFIED: a foreign Brave GPU context falls inside run 3. All 279 frames and recorders pass; scheduler coverage is complete with zero continuity gaps or LOST reports. Raw ratios cannot earn continuation without qualified timing. Whole-cohort CPU ownership brackets also remain incomplete. AC-1 and Phase 1 remain open; 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) @@ -89,7 +89,7 @@ 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: latest original baseline `artifacts/prd564/scheduler-qualified-baseline-admin-v2-1664a981` records all 279 frames but remains unqualified because new container tasks execute on renderer CPUs. Qualified baseline decision and candidate proof remain pending; further affinity-only captures stop. +- [ ] 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-host4-quiet-v3-1664a981` records all 279 frames with zero scheduler gaps/loss, but a foreign Brave GPU context inside run 3 rejects qualification. 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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — administrator-reserved baseline `artifacts/prd564/scheduler-qualified-baseline-admin-v2-1664a981` completes all 279 observations, with scheduler recorder/decode exit 0, complete measured continuity and no LOST reports. All nine steps record 26 texture allocations and zero pipeline builds. Raw step/p50 ratios: run 1 `1.553/1.559/1.424`, run 2 `1.694/1.515/1.484`, run 3 `1.788/1.452/1.500`. Actual runc/nginx/perl execution on CPUs 11/23 rejects qualification despite administrator affinity control; new tasks can execute before reconciliation or reset their masks. Neither qualified continue nor decline is earned. GPU observation remains unqualified as detailed below. Zero OS/kernel activity is not a criterion. Stop further affinity-only captures and retain the draft at 75% with AC-1 open. +**Status:** REOPENED / UNQUALIFIED — `artifacts/prd564/scheduler-qualified-baseline-host4-quiet-v3-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 native-acknowledged delayed perf recording. Baseline, recorder, decoder, ownership collector and GPU profile/stop/export exit 0. Scheduler coverage is complete with no gaps or LOST reports; whole-cohort Chrome identity brackets remain incomplete, without establishing foreign CPU work. All nine steps record 26 texture allocations and zero pipelines; raw step/p50 ratios are run 1 `1.963/1.538/1.625`, run 2 `1.625/1.696/1.542`, run 3 `2.083/1.750/1.583`. Runs 1–2 have no foreign/unknown GPU overlap, but a Brave context residency interval lies inside run 3's guaranteed context interior: observed duration 4.576 ms, conservative envelope 22.621 ms, which does not prove contention with an individual frame. Fourteen unmapped GPU intervals lie outside measured contexts. Neither qualified continue nor decline is earned. Independent cleanup restores UI and removes the reservation/client; its exit 125 retains a vanished-cgroup verification failure. A separate root audit finds all 93 surviving original cgroups unchanged, three original groups gone, root CPUs 0–23 and owned scope/client absent. No repeated quiet cohort or acceptance relaxation is authorized by these results. **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: `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. From eec24ab194be5aad8ff7a713c9bd4e1459727340 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 10:59:15 -0700 Subject: [PATCH 52/53] docs(prd564): record competing native GPU timing blocker --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c8c3f7bc31..8a84ab73e7 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Atomic CPU reservation and reversible desktop hiding work, but baseline `scheduler-qualified-baseline-host4-quiet-v3-1664a981` remains UNQUALIFIED: a foreign Brave GPU context falls inside run 3. All 279 frames and recorders pass; scheduler coverage is complete with zero continuity gaps or LOST reports. Raw ratios cannot earn continuation without qualified timing. Whole-cohort CPU ownership brackets also remain incomplete. AC-1 and Phase 1 remain open; draft stays at 75%. +**Blocker:** Latest original baseline `scheduler-qualified-baseline-host4-dpms-v2-1664a981` remains UNQUALIFIED despite all 279 frames and successful collection: foreign native GPU process 2578099 overlaps all three measured contexts. The CDP/kernel clock intersection is empty by 17.703 µs, a GPU event exceeds the reported capture duration by 78.615 ms, and two CPU identities lack complete lifetime proof without establishing foreign CPU work. CPU, desktop output and UI state are fully restored. 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) @@ -89,7 +89,7 @@ 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: latest original baseline `artifacts/prd564/scheduler-qualified-baseline-host4-quiet-v3-1664a981` records all 279 frames with zero scheduler gaps/loss, but a foreign Brave GPU context inside run 3 rejects qualification. Baseline decision and candidate timing remain 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-host4-dpms-v2-1664a981` records all 279 frames, with collection statuses 0 and no scheduler gaps or LOST reports, but foreign native GPU process 2578099 overlaps all three measured contexts. Clock/capture-boundary inconsistencies and CPU lifetime-proof gaps also reject qualification. The original gate was not evaluated; 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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — `artifacts/prd564/scheduler-qualified-baseline-host4-quiet-v3-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 native-acknowledged delayed perf recording. Baseline, recorder, decoder, ownership collector and GPU profile/stop/export exit 0. Scheduler coverage is complete with no gaps or LOST reports; whole-cohort Chrome identity brackets remain incomplete, without establishing foreign CPU work. All nine steps record 26 texture allocations and zero pipelines; raw step/p50 ratios are run 1 `1.963/1.538/1.625`, run 2 `1.625/1.696/1.542`, run 3 `2.083/1.750/1.583`. Runs 1–2 have no foreign/unknown GPU overlap, but a Brave context residency interval lies inside run 3's guaranteed context interior: observed duration 4.576 ms, conservative envelope 22.621 ms, which does not prove contention with an individual frame. Fourteen unmapped GPU intervals lie outside measured contexts. Neither qualified continue nor decline is earned. Independent cleanup restores UI and removes the reservation/client; its exit 125 retains a vanished-cgroup verification failure. A separate root audit finds all 93 surviving original cgroups unchanged, three original groups gone, root CPUs 0–23 and owned scope/client absent. No repeated quiet cohort or acceptance relaxation is authorized by these results. +**Status:** REOPENED / UNQUALIFIED — `artifacts/prd564/scheduler-qualified-baseline-host4-dpms-v2-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 delayed perf recording. Baseline, run, ACK, ownership collector, recorder status, decoder and GPU profile/stop/export are 0; recorder client exit 143 is retained. Scheduler continuity has no gaps or LOST reports. Native GPU process 2578099 (`tn-native-engin`), outside verified capture ownership, overlaps all three measured contexts (60/58/68 intervals). The CDP/kernel clock intersection is empty by 17.703 µs, and the last GPU event exceeds reported capture duration by 78.615 ms. MemoryInfra TID 2581657 (64.380 µs) and kworker-named TID 2443704 (74.249 µs) lack complete CPU lifetime proof; these gaps do not prove foreign CPU work or kernel identity from a name. The strict receipt `.afk/pr489-host4-dpms-v2-qualification.json` remains UNQUALIFIED, so raw ratios earn neither continuation nor decline and the original gate was not evaluated. Guardian exit 0 and independent restoration checks confirm owned scope absent, root CPUs 0–23, both outputs ON and original UI state restored. AC-1 and Phase 1 remain open; draft progress remains 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: `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. From 194a78fd61ae7585ab3516ede77bfc5b80732f53 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 15 Oct 2026 13:13:21 -0700 Subject: [PATCH 53/53] docs(prd564): record unqualified host24 baseline --- ...esolution-scale-changes-without-reallocating-a-target.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8a84ab73e7..5bb5e36d77 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,7 +1,7 @@ # PRD-564 — The resolution scale changes without reallocating a render target **Status:** PARTIAL -**Blocker:** Latest original baseline `scheduler-qualified-baseline-host4-dpms-v2-1664a981` remains UNQUALIFIED despite all 279 frames and successful collection: foreign native GPU process 2578099 overlaps all three measured contexts. The CDP/kernel clock intersection is empty by 17.703 µs, a GPU event exceeds the reported capture duration by 78.615 ms, and two CPU identities lack complete lifetime proof without establishing foreign CPU work. CPU, desktop output and UI state are fully restored. The original gate was not evaluated; AC-1 and Phase 1 remain open and the draft stays at 75%. +**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) @@ -89,7 +89,7 @@ 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: latest original baseline `artifacts/prd564/scheduler-qualified-baseline-host4-dpms-v2-1664a981` records all 279 frames, with collection statuses 0 and no scheduler gaps or LOST reports, but foreign native GPU process 2578099 overlaps all three measured contexts. Clock/capture-boundary inconsistencies and CPU lifetime-proof gaps also reject qualification. The original gate was not evaluated; baseline decision and candidate timing remain 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 @@ -102,7 +102,7 @@ Layer: mechanism only, `packages/core` and the native host. No appearance parame ## Execution Phases #### Phase 1: Price the resize -**Status:** REOPENED / UNQUALIFIED — `artifacts/prd564/scheduler-qualified-baseline-host4-dpms-v2-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 delayed perf recording. Baseline, run, ACK, ownership collector, recorder status, decoder and GPU profile/stop/export are 0; recorder client exit 143 is retained. Scheduler continuity has no gaps or LOST reports. Native GPU process 2578099 (`tn-native-engin`), outside verified capture ownership, overlaps all three measured contexts (60/58/68 intervals). The CDP/kernel clock intersection is empty by 17.703 µs, and the last GPU event exceeds reported capture duration by 78.615 ms. MemoryInfra TID 2581657 (64.380 µs) and kworker-named TID 2443704 (74.249 µs) lack complete CPU lifetime proof; these gaps do not prove foreign CPU work or kernel identity from a name. The strict receipt `.afk/pr489-host4-dpms-v2-qualification.json` remains UNQUALIFIED, so raw ratios earn neither continuation nor decline and the original gate was not evaluated. Guardian exit 0 and independent restoration checks confirm owned scope absent, root CPUs 0–23, both outputs ON and original UI state restored. AC-1 and Phase 1 remain open; draft progress remains 75%. +**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: `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.