From ced7bab838561f27d20a1861b689839f59ee905e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 3 Nov 2026 10:23:16 -0700 Subject: [PATCH 01/36] fix(release): the ship job publishes through npm Trusted Publishing (npm 11.18 in the job) --- .github/workflows/npm-release.yml | 4 ++++ scripts/__tests__/ci-structure.spec.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 8866cb947d..0c8afde01b 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -49,6 +49,10 @@ jobs: node-version: 20.19.0 cache: pnpm registry-url: https://registry.npmjs.org + # npm Trusted Publishing: `pnpm publish` hands the tarball to the npm CLI, which trades this + # job's OIDC token for a publish token only from npm 11.5.1. Node 20.19 bundles npm 10, which + # falls back to NPM_TOKEN, and that secret was rejected with E404 on 0.3.10 and 0.3.11. + - run: npm install -g npm@11.18.0 - uses: actions/setup-java@v5 with: distribution: temurin diff --git a/scripts/__tests__/ci-structure.spec.ts b/scripts/__tests__/ci-structure.spec.ts index 780e0bf69b..9aee923ea5 100644 --- a/scripts/__tests__/ci-structure.spec.ts +++ b/scripts/__tests__/ci-structure.spec.ts @@ -1228,6 +1228,21 @@ describe("CI pipeline structure", () => { expect(requiredJob(ci, "golden-path-template")).toContain("needs: [scope, build-artifacts]"); }); + it("publishes through npm Trusted Publishing: an OIDC token and npm 11.5.1 or later", async () => { + // NPM_TOKEN was rejected with E404 on every PUT of 0.3.10 and 0.3.11; the OIDC exchange needs + // `id-token: write` and an npm CLI that knows it, and Node 20.19 bundles npm 10. + const ship = requiredJob( + await readFile(path.join(repo, ".github/workflows/npm-release.yml"), "utf8"), + "ship", + ); + expect(ship).toContain("id-token: write"); + const install = /npm install -g npm@(\d+)\.(\d+)\.(\d+)/u.exec(ship); + if (!install) throw new Error("ship installs no pinned npm before it publishes"); + const [major = 0, minor = 0, patch = 0] = install.slice(1).map(Number); + expect(major * 1_000_000 + minor * 1_000 + patch).toBeGreaterThanOrEqual(11_005_001); + expect(ship.indexOf(install[0])).toBeLessThan(ship.indexOf("pnpm release:ship")); + }); + it("hands the emulator action a one-line script, so the arguments survive", async () => { // `android-emulator-runner` runs `script` through the emulator shell a line at a time. A // `\`-continued command therefore loses everything after its first line, and from 2026-09-01 From 09cf24625e0eed76720541d19166534505042da9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 3 Nov 2026 10:39:55 -0700 Subject: [PATCH 02/36] fix(release): wait for the install packument before the clean room, so npm create sees the new version --- scripts/__tests__/release.spec.ts | 23 +++++++++++++++++++++++ scripts/release.ts | 27 +++++++++++++++++++++------ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/__tests__/release.spec.ts b/scripts/__tests__/release.spec.ts index 88332a61ec..9d529e8de8 100644 --- a/scripts/__tests__/release.spec.ts +++ b/scripts/__tests__/release.spec.ts @@ -8,6 +8,7 @@ import { type IPublishPackage, type RegistryLookup, publishSet } from "../check- import { UPGRADE_PROOF_TEMPLATES, assertCleanPackageTree, + installableOnRegistry, packedTarballName, prepareReleaseCohort, proveUpgradeFromLatest, @@ -277,6 +278,28 @@ describe("pnpm release publishing", () => { expect(published).toEqual(["a"]); }); + + it("waits for the install packument, not the per-version endpoint", async () => { + // 0.3.10 and 0.3.11: the version endpoint answered first, and `npm create` in the clean room + // read a packument that did not list the new version yet (ETARGET). + const requests: { url: string; accept: string }[] = []; + const registry = (versions: Record) => + (async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), accept: new Headers(init?.headers).get("accept") ?? "" }); + return new Response(JSON.stringify({ versions }), { status: 200 }); + }) as typeof fetch; + + expect( + await installableOnRegistry("create-threenative", "0.2.15", registry({ "0.2.14": {} })), + ).toBe(false); + expect( + await installableOnRegistry("@threenative/core", "0.3.11", registry({ "0.3.11": {} })), + ).toBe(true); + expect(requests.at(-1)).toEqual({ + url: "https://registry.npmjs.org/@threenative%2fcore", + accept: "application/vnd.npm.install-v1+json", + }); + }); }); describe("pnpm release cohort guards", () => { diff --git a/scripts/release.ts b/scripts/release.ts index b621cbfe72..5e3fc2f1a7 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -432,19 +432,34 @@ export function unpublishedReleasePackages( return states.map(({ item }) => item); } +/** + * Whether an installer can resolve `name@version` yet: the abbreviated packument `npm install` and + * `npm create` read lists it. The per-version endpoint answers before that packument's CDN cache + * does, so 0.3.10 and 0.3.11 started the clean room on it and `npm create` failed with ETARGET. + */ +export async function installableOnRegistry( + name: string, + version: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(`https://registry.npmjs.org/${name.replace("/", "%2f")}`, { + headers: { accept: "application/vnd.npm.install-v1+json" }, + }); + if (!response.ok) return false; + const packument = (await response.json()) as { versions?: Record }; + return packument.versions?.[version] !== undefined; +} + async function waitForRegistry(name: string, version: string): Promise { // A brand-new scoped package is not readable the instant it is published, and publishing the // scaffolder before its dependencies are visible produces an install nobody can reproduce. - // The version endpoint is served straight from publish rather than through the packument's CDN - // cache, so it reflects a new version sooner; the deadline is generous because a real 0.3.1 - // publish aborted after 180s while npm was still propagating (npm itself said "a few minutes"). + // The deadline is generous because a real 0.3.1 publish aborted after 180s while npm was still + // propagating (npm itself said "a few minutes"). const timeoutMs = 15 * 60_000; const deadline = Date.now() + timeoutMs; - const url = `https://registry.npmjs.org/${name.replace("/", "%2f")}/${encodeURIComponent(version)}`; while (Date.now() < deadline) { try { - const response = await fetch(url); - if (response.ok) return; + if (await installableOnRegistry(name, version)) return; } catch { // Not visible yet; the deadline is the only thing that ends this loop. } From 33c69249f0112929ba67fb3b61077a739a31eced Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 3 Nov 2026 10:51:30 -0700 Subject: [PATCH 03/36] fix(release): packages name threenative-engine, and the clean room waits for the latest tag --- package.json | 2 +- packages/animation-mcp/package.json | 2 +- packages/assets/package.json | 2 +- packages/blender-bridge/package.json | 2 +- packages/core/package.json | 2 +- packages/create-threenative/package.json | 2 +- packages/engine-mcp/package.json | 2 +- packages/metahuman/package.json | 2 +- packages/physics/package.json | 2 +- packages/playtest/package.json | 2 +- packages/procedural-animals/package.json | 2 +- packages/raw-unreal/package.json | 2 +- packages/runtime-native/package.json | 2 +- packages/terrain/package.json | 2 +- packages/three-native/package.json | 2 +- packages/ueformat/package.json | 2 +- packages/ui/package.json | 2 +- scripts/__tests__/release.spec.ts | 32 ++++++++++++++++++------ scripts/release.ts | 8 ++++-- 19 files changed, 47 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 6772e55d76..4fc1b360e8 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git" + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git" }, "keywords": [], "author": "", diff --git a/packages/animation-mcp/package.json b/packages/animation-mcp/package.json index 3c6ece563f..c1c1a1d704 100644 --- a/packages/animation-mcp/package.json +++ b/packages/animation-mcp/package.json @@ -18,7 +18,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/animation-mcp" }, "type": "module", diff --git a/packages/assets/package.json b/packages/assets/package.json index 1b1ef38b66..1af62475f0 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/assets" }, "type": "module", diff --git a/packages/blender-bridge/package.json b/packages/blender-bridge/package.json index 2db1d7ee3a..0e99a9be31 100644 --- a/packages/blender-bridge/package.json +++ b/packages/blender-bridge/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/blender-bridge" }, "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index cde408822e..b745a11127 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,7 +19,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/core" }, "type": "module", diff --git a/packages/create-threenative/package.json b/packages/create-threenative/package.json index a84756d02a..6242fe5ad0 100644 --- a/packages/create-threenative/package.json +++ b/packages/create-threenative/package.json @@ -19,7 +19,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/create-threenative" }, "type": "module", diff --git a/packages/engine-mcp/package.json b/packages/engine-mcp/package.json index e9fbbc98a9..f83e87a5d2 100644 --- a/packages/engine-mcp/package.json +++ b/packages/engine-mcp/package.json @@ -18,7 +18,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/engine-mcp" }, "type": "module", diff --git a/packages/metahuman/package.json b/packages/metahuman/package.json index bca1a0140e..e1633ede15 100644 --- a/packages/metahuman/package.json +++ b/packages/metahuman/package.json @@ -19,7 +19,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/metahuman" }, "type": "module", diff --git a/packages/physics/package.json b/packages/physics/package.json index 7df56c2bc2..512f65d136 100644 --- a/packages/physics/package.json +++ b/packages/physics/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/physics" }, "type": "module", diff --git a/packages/playtest/package.json b/packages/playtest/package.json index e7659607b7..d75d41bb1c 100644 --- a/packages/playtest/package.json +++ b/packages/playtest/package.json @@ -18,7 +18,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/playtest" }, "type": "module", diff --git a/packages/procedural-animals/package.json b/packages/procedural-animals/package.json index b893120edb..f17039d085 100644 --- a/packages/procedural-animals/package.json +++ b/packages/procedural-animals/package.json @@ -6,7 +6,7 @@ "homepage": "https://github.com/ThreeNativeHQ/threenative#readme", "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/procedural-animals" }, "type": "module", diff --git a/packages/raw-unreal/package.json b/packages/raw-unreal/package.json index 03db45be87..7d57d8e605 100644 --- a/packages/raw-unreal/package.json +++ b/packages/raw-unreal/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/raw-unreal" }, "type": "module", diff --git a/packages/runtime-native/package.json b/packages/runtime-native/package.json index f0d00b5419..bcd73b6310 100644 --- a/packages/runtime-native/package.json +++ b/packages/runtime-native/package.json @@ -18,7 +18,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/runtime-native" }, "license": "MIT", diff --git a/packages/terrain/package.json b/packages/terrain/package.json index 782899e8ef..69e3510c02 100644 --- a/packages/terrain/package.json +++ b/packages/terrain/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/terrain" }, "type": "module", diff --git a/packages/three-native/package.json b/packages/three-native/package.json index 4dab32d8bc..16d5ceaa90 100644 --- a/packages/three-native/package.json +++ b/packages/three-native/package.json @@ -6,7 +6,7 @@ "keywords": ["threenative", "three", "native", "abi", "gamedev"], "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/three-native" }, "type": "module", diff --git a/packages/ueformat/package.json b/packages/ueformat/package.json index b0eb14faa6..b731525e74 100644 --- a/packages/ueformat/package.json +++ b/packages/ueformat/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/ueformat" }, "type": "module", diff --git a/packages/ui/package.json b/packages/ui/package.json index bc2b689da7..e546e56e87 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,7 +10,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/ThreeNativeHQ/threenative.git", + "url": "git+https://github.com/ThreeNativeHQ/threenative-engine.git", "directory": "packages/ui" }, "type": "module", diff --git a/scripts/__tests__/release.spec.ts b/scripts/__tests__/release.spec.ts index 9d529e8de8..f557d0a427 100644 --- a/scripts/__tests__/release.spec.ts +++ b/scripts/__tests__/release.spec.ts @@ -106,6 +106,22 @@ describe("pnpm release ordering", () => { expect(order).toHaveLength(publishSet(repo).length); }); + it("names the repository that publishes it, so npm accepts its CI provenance", async () => { + // npm rejects a `--provenance` publish whose `repository.url` is not the repository the + // workflow ran in; the repository was renamed from `threenative` to `threenative-engine`. + const { publishSet } = await import("../check-publish-state.js"); + const repo = path.resolve(import.meta.dirname, "../.."); + const urls = publishSet(repo).map((item) => { + const manifest = JSON.parse(fs.readFileSync(item.manifest, "utf8")) as { + repository?: { url?: string }; + }; + return [item.name, manifest.repository?.url]; + }); + expect(urls).toEqual( + urls.map(([name]) => [name, "git+https://github.com/ThreeNativeHQ/threenative-engine.git"]), + ); + }); + it("groups packages into levels that depend only on earlier levels", async () => { const levels = releaseLevels( await manifests([ @@ -283,18 +299,18 @@ describe("pnpm release publishing", () => { // 0.3.10 and 0.3.11: the version endpoint answered first, and `npm create` in the clean room // read a packument that did not list the new version yet (ETARGET). const requests: { url: string; accept: string }[] = []; - const registry = (versions: Record) => + const registry = (versions: Record, latest: string) => (async (url: string | URL | Request, init?: RequestInit) => { requests.push({ url: String(url), accept: new Headers(init?.headers).get("accept") ?? "" }); - return new Response(JSON.stringify({ versions }), { status: 200 }); + return new Response(JSON.stringify({ "dist-tags": { latest }, versions }), { status: 200 }); }) as typeof fetch; + const missing = registry({ "0.2.14": {} }, "0.2.14"); + const untagged = registry({ "0.2.14": {}, "0.2.15": {} }, "0.2.14"); + const ready = registry({ "0.3.11": {} }, "0.3.11"); - expect( - await installableOnRegistry("create-threenative", "0.2.15", registry({ "0.2.14": {} })), - ).toBe(false); - expect( - await installableOnRegistry("@threenative/core", "0.3.11", registry({ "0.3.11": {} })), - ).toBe(true); + expect(await installableOnRegistry("create-threenative", "0.2.15", missing)).toBe(false); + expect(await installableOnRegistry("create-threenative", "0.2.15", untagged)).toBe(false); + expect(await installableOnRegistry("@threenative/core", "0.3.11", ready)).toBe(true); expect(requests.at(-1)).toEqual({ url: "https://registry.npmjs.org/@threenative%2fcore", accept: "application/vnd.npm.install-v1+json", diff --git a/scripts/release.ts b/scripts/release.ts index 5e3fc2f1a7..0e1a886960 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -436,6 +436,7 @@ export function unpublishedReleasePackages( * Whether an installer can resolve `name@version` yet: the abbreviated packument `npm install` and * `npm create` read lists it. The per-version endpoint answers before that packument's CDN cache * does, so 0.3.10 and 0.3.11 started the clean room on it and `npm create` failed with ETARGET. + * `npm create threenative@latest` resolves the `latest` tag first, so the tag must name it too. */ export async function installableOnRegistry( name: string, @@ -446,8 +447,11 @@ export async function installableOnRegistry( headers: { accept: "application/vnd.npm.install-v1+json" }, }); if (!response.ok) return false; - const packument = (await response.json()) as { versions?: Record }; - return packument.versions?.[version] !== undefined; + const packument = (await response.json()) as { + "dist-tags"?: Record; + versions?: Record; + }; + return packument.versions?.[version] !== undefined && packument["dist-tags"]?.latest === version; } async function waitForRegistry(name: string, version: string): Promise { From e9707dd6cb5958ab0a529fc8e0e4a471e3ee41a6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 3 Nov 2026 12:28:00 -0700 Subject: [PATCH 04/36] fix(ci): the scope job checks out only scripts/ and the kit files blob:none still fetched every HEAD blob (488 MB) to fill the working tree. Under host load that checkout took 3m20s of the 5-minute bound and the classify step was cancelled (runs 41688746026, 41702153229). The scope and admission scripts read only scripts/ and each kit.json: a cone sparse checkout of those is 25 MB. Proven on a sparse clone: the PR classification exits 0 with the same plan. Guarded in ci-structure.spec.ts. --- .github/workflows/ci.yml | 6 ++++++ scripts/__tests__/ci-structure.spec.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84fc3b934..ea01b77b16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,12 @@ jobs: # branch of the 3.9 GB repository took over this job's 5 minutes on a loaded tn-local # runner (three cancels on PR #574), against 8 s and 20 MB blob-less with the same plan. filter: blob:none + # blob:none still fetched every HEAD blob (488 MB) to fill the working tree, and that + # checkout alone took 3m20s of the 5 minutes on a loaded runner (runs 41688746026, + # 41702153229). The scripts read only scripts/ and each kit.json: about 20 MB. + sparse-checkout: | + scripts + packages/create-threenative/templates # Until protected develop exists, retain the qualified-main scheduled flow. ref: ${{ github.event_name == 'schedule' && vars.TN_DEVELOP_CI_ENABLED == 'true' && 'develop' || github.sha }} - id: classify diff --git a/scripts/__tests__/ci-structure.spec.ts b/scripts/__tests__/ci-structure.spec.ts index 9aee923ea5..42ec5e7568 100644 --- a/scripts/__tests__/ci-structure.spec.ts +++ b/scripts/__tests__/ci-structure.spec.ts @@ -748,6 +748,11 @@ describe("CI pipeline structure", () => { expect(scope).toContain("selection: ${{ steps.classify.outputs.selection }}"); expect(scope).toContain("reason: ${{ steps.classify.outputs.reason }}"); expect(scope).not.toContain("pnpm install"); + // Scope reads only scripts/ and the kit.json files: a full blob-less checkout fetched every + // HEAD blob (488 MB) and outlasted the 5-minute bound on a loaded runner (runs 41688746026, 41702153229). + expect(scope).toMatch( + /sparse-checkout: \|\n\s+scripts\n\s+packages\/create-threenative\/templates\n/u, + ); for (const name of [ "typecheck", From 5e030ca64192539c6930f2ac2038e5db4a8ff5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:18:50 +0000 Subject: [PATCH 05/36] PRD-539: try Unreal TSR history levers and benchmark against Unreal (#606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(temporal): PRD-539 history200 and velocity-clamp gate arms (Unreal TSR levers 1-2) historyScale stores resolve and history at 2x the display raster (r.TSR.History.ScreenPercentage=200); velocityWeightClamp ports TSR's sample-count clamp under motion. Both are opt-in arms of the unchanged gate. * test(scaffold): starter tree hash for the PRD-539 temporal arms * style(temporal): sort imports; refresh starter tree hash * feat(unreal-look): screenPercentage knob and a side-by-side motion scorer for PRD-539 * feat(unreal-look): CSV GPU stats for Unreal captures, ProfileGPU/CSV reader, static-camera gate control (PRD-539) * fix(unreal-look): side-by-side sheet layout, crop row and --metric-frames range * feat(temporal): PRD-539 clean history200 control and the TSR alignment-refine arm A full-resolution input under a 2x history keeps its owned point sample, so history scale is the only variable. The tsr arm adds Unreal TSR's refine rule: the current frame enters each history pixel through the history-pixel kernel and weighs by its nearest jittered sample's alignment. * fix(unreal-look): screenPercentage marker uses the forwarded TN_CALIBRATION prefix * fix(engine): size lamp shadow maps from the unjittered camera A pixel's footprint does not move with the TRAA subpixel offset. The jittered frustum let a receiver on the view's edge enter and leave, so a lamp whose footprint sat on a mip boundary flipped its map size every frame and reallocated it. On the Wasm engine the released maps waited for JS GC: the PRD-539 cornell motion run at screenPercentage=50 grew VRAM about 65 MB per step and lost the device (VK_ERROR_OUT_OF_DEVICE_MEMORY) before frame 0. Red-green: lamp_size_ignores_temporal_jitter read 2 sizes over 16 still TRAA frames before, 1 after. lamp_atlas and 24 lamp/shadow/light/TRAA render-database cases pass. * docs(prd): PRD-539 gate run 3 rejects the alignment refine; starter hash * style: format verify-temporal-motion output path * docs(prd): PRD-539 same-scene Unreal vs ThreeNative cost and quality table * feat(temporal): PRD-539 lever 3 measured and rejected; drop the rejected refine and spatial-AA arms Gate run 4: Unreal TSR's spatial anti-aliasing of rejected input leaves edge error unchanged and raises reveal stale (full .0003 -> .0024, low input .0177 -> .0204). Gate run 3 already rejected the alignment refine. Both arms leave the provider and the gate; their numbers stay in the PRD. history200-clamp stays opt-in: as a default it presents a 2x raster. * feat(starter): resolve a 2x temporal history to the display raster Port of Unreal TSR's history resolve (UE 5.8.3 TSRResolveHistory.usf:312-357): a 4x4 Mitchell (B=C=1/3) downsample with the 1/(R+2G+B+4) HDR weight, clamped to the samples' range. At historyScale 2 the chain now reads a display-sized raster instead of the 2x history, so later post stages stop paying 4x pixels. Red: presented 3840x2160 for a 1920x1080 display. Green: 1920x1080. * fix(starter): seed the history resolve clamp from a sample WGSL has no infinity literal; gate run 5 failed the history200 arm with 'expected , for function call' at vec4(Infinity.0, ...). * feat(starter): temporal AA defaults to Unreal's Epic-tier TSR history With no weighting named, the provider keeps history at 200% of the display (UE 5.8.3 BaseScalability.ini:89) with the velocity-clamped weight, and the chain reads the display-sized Mitchell resolve. The raw4 path keeps 100%. { historyScale: 1, velocityWeightClamp: false } is the named override and the new history100 gate arm. Gate run 6 measured the two-lever arm at edge .04387 against the .04616 bar (the 100% default read .0486 reference). The resolve moves to temporalHistoryResolve.ts and the 100% reset seed into frames.draw, so every starter render file stays under 200 lines. Red: default history 1920 wide at a 1920 display. Green: 3840. * docs(prd): PRD-539 gate runs 6-7: the TSR history default passes every full-resolution check * docs(prd): PRD-539 low-input reveal failure is current-frame reconstruction, not history * docs(prd): PRD-539 low-input reveal: sub-texel bars moire, lever 4 is the next arm * feat(starter): PRD-539 lever 4, flicker-aware rejection as an opt-in arm Port of Unreal TSR's moire measurement (UE 5.8.3 TSRShadingAnalysis.ush: 225-229,263-271,381-455,477-490,586-593): a ping-pong state of luma gradient, total variation and flicker count, decayed by 0.95 on still pixels, widens the clip box by 3x the moire error. Opt in with flickerRejection: true; gate arms flicker, flicker-open and quality-flicker. No 5x5 flicker dilation yet. Red-green: the kernel holds the moire ramp only with the lever on, and the update pass compiles to WGSL with no unparseable literal. * feat(starter): lever 4 dilates the flicker over 5x5 before accumulating Unreal accumulates max3x3(max3x3()) of the flicker and its variation (UE 5.8.3 TSRShadingAnalysis.ush:441-455). Without it a sub-texel bar that moves between neighbours across jitter phases flickered too rarely at any one pixel: gate run 8 moved low-input edge .07055 -> .07048 only. The update is now a detect pass and a dilate-and-accumulate pass. * docs(prd): PRD-539 gate runs 8-9: lever 4 deferred, the low-input clip is the limit * feat(starter): PRD-539 lever 6, Unreal's low-frequency clamp release (opt-in) Static-camera gates showed the per-pixel clip to the low-input 3x3 box erases sub-texel detail that unclipped history keeps. Unreal compares input and history after a 3x3 blur and lerps the clamped history back to the unclamped one by Min3x3(Median3x3(rejection)), 0 on disocclusion (UE 5.8.3 TSRShadingAnalysis.ush:25,509-525,563-566,589-636, TSRRejectShading.usf:863, TSRUpdateHistory.usf:1256-1257). Six input-raster passes before the resolve. Gate arms release, release-open, quality-release. Red-green: the kernel reads the release only with the lever on; all six passes compile to WGSL. * feat(starter): PRD-539 levers 4+6 combined, the moire error widens the clamp-release rejection UE 5.8.3: TSRShadingAnalysis.ush:569-613. The gate's solo flicker and release arms become one flicker-release arm; their gate-10 numbers stay in the PRD. * docs(prd): PRD-539 gate run 10, lever 6 alone deferred; gate 11 runs levers 4+6 * chore(prd539): gate 11 and the low-input probes; the fence calibration scene for Unreal TN_TEMPORAL_PROBE=arm,arm captures only those arms and prints their edge error (diagnostic). * chore(unreal-reference): fence scene lit by one point light (template scenes take no motion run), bars share one region * docs(prd): PRD-539 same-scene benchmark, Unreal TSR vs ThreeNative at 66.7% on the fence * chore(unreal-reference): fence scene export (scene.glb, scene.json) from Unreal 5.8.3 * test(prd539): scripted three-scale scaler route conformance row The temporal-aa-scaler-route row steps the input raster 1 -> 0.75 -> 0.5 twice over a raster-bound backdrop and asserts: each step resets history on its own frame only, textures and geometries return to the frame-7 baseline after each cycle, and no step frame costs more than 10% + 0.05 ms over its neighbour frames. The runner keeps the route's per-stage GPU detail (scene pass vs reconstruction) in the report. A held capture now holds the presented display-sized resolve, so a 2x history can freeze. * docs(prd): PRD-539 Phase 2 box 2 ticked: scaler route, per-stage cost, allocations flat * docs(prd): PRD-539 Phase 2 game cost table: shooter route, TRAA at 0.667 saves 43% GPU p50, 22% p95 * test(prd539): low-input bars are Unreal TSR on the same fence scene (owner option 1); Phase 1 done Unreal 5.8.3 TSR at 66.7% on gi:fence reads 1.74x its own no-AA edge error, and leaves .082-.090 of a reveal stale; the gate's low-input checks take those values. The fence scene gains the fixture's occluder in a new reveal run (a visibility track per hidden box). Gate run 12 exits 0 on nvidia/turing. Co-Authored-By: Claude Opus 5.5 * docs(prd): PRD-539 box 97: reconstruction cost +0.68 ms, game judge NEUTRAL vs spatial, REGRESSION vs full res (softness); mip bias next Co-Authored-By: Claude Opus 5.5 * feat(engine): Unreal's view texture mip bias under temporal upscaling (PRD-539 lever 7) Standard-material textures sample at viewMipBias = -log2(1/fraction) - 0.3, at least -2, quantized to 1/128, while a temporal pass upscales; 0 otherwise, and shadow casters stay at 0 (UE 5.8.3 SceneVisibility.cpp:5337-5351, :179-194). Red-green: material alpha_hash and traa_validation; 247/247 cases of the touched engine test binaries pass. Co-Authored-By: Claude Opus 5.5 * docs(prd): PRD-539 box 97: lever 7 judge IMPROVEMENT, NEUTRAL against full resolution * docs(prd): PRD-539 box 97: web GPU p50 -36 to -46% per clock level after lever 7, bias cost below noise, web p95 unresolved * docs(prd): PRD-539 box 97: 4K web p50 -55 to -64%, p95 and desktop blocked on the real display or a GPU clock lock * docs(prd): PRD-539 done: TRAA 0.667 lowers GPU p50 and p95 on web and desktop with the clock locked; move to done/ * fix(engine): a node material's 2D texture() reads at the view mip bias too (PRD-539 lever 7 review) The leanpi DeepSeek review found graph texture() nodes sampled at the implicit LOD. Material lowerings mark themselves; the graph declares viewMipBias at the first 2D fragment sample, so layouts of texture-free node materials and volume reads (probe GI) keep their form. Red-green: material_test view_mip_bias_samples. Same-scene Unreal table at a locked GPU clock recorded in the PRD. * docs(prd): PRD-539 judge 3: node texture bias NEUTRAL, TRAA 0.667 softer than full res; it stays opt-in * docs(prd): reopen PRD-539: judge 3 rates TRAA 0.667 a REGRESSION against shipped MSAA Moves the PRD out of done/. What regressed: the box-97 visual verdict. Judge 2 rated TRAA 0.667 NEUTRAL (slight softness); judge 3 (commit 9b7ddb490, sheets ee4d4ed93 on prd-539-screenshots) rates it a REGRESSION with moderate softness, and a perf win counts only when the judge passes. Box 97 and acceptance 3 are unticked with their numbers kept; a textured same-scene comparison against Unreal TSR at 67% is the new open box. * feat(prd-539): TRAA 67% loses detail equal to Unreal TSR 67% on a textured same scene; PRD-539 done gi:neon-corridor brick wall, both engines at 100% and 67%, sameness PASS (16 objects, 1 px). Judge 4 (anisotropy 1): FAIL, far-wall loss larger. Judge 5 (anisotropy 8, Unreal Epic r.MaxAnisotropy): PASS, EQUAL. Adds the calibration harness knob anisotropy=. Files PRD-789 for the engine default anisotropy (deferred). * docs(prd): PRD-539 textured table reproduced by leanpi DeepSeek flash max (VERDICT OK), rounding fixed, shooter-in-Unreal decision * test(native): overlay readback waits on a time bound; refresh native coverage record for PRD-539 sources overlay_over_frame read the presented frame within 4000 polls; on a loaded host (load 12-19) the readback landed after that, so presented stayed empty (1 of 3 and 1 of 6 runs red). A 10 s bound plus CHECK(read): 12 of 12 green under the same load. The coverage record digest was stale after this branch's native changes (CI budgets red on run 41704323408). * docs(prd-539): João confirms the calibration scenes as the Unreal same-scene benchmark --------- Co-authored-by: Claude Opus 5.5 --- README.md | 10 +- docs/PRDs/Unreal/README.md | 6 +- ...hing-post-chain-matches-unreal-defaults.md | 2 +- ...r-at-unreals-epic-anisotropy-by-default.md | 30 + ...emporal-reconstruction-quality-and-cost.md | 98 --- ...ixels-clusters-shadow-levels-or-effects.md | 2 +- ...ojects-through-the-surface-that-left-it.md | 2 +- ...-detail-the-history-held-before-it-left.md | 2 +- ...ange-by-whether-the-display-can-show-it.md | 2 +- ...istory-fails-and-the-tier-sets-how-wide.md | 2 +- docs/PRDs/Unreal/research/port-history.json | 10 +- .../Unreal/research/unreal-port-progress.svg | 28 +- .../Unreal/research/unreal-port-status.svg | 34 +- ...l-visuals-and-performance-feature-map.html | 2 +- ...-reconstruction-from-dynamic-resolution.md | 2 +- ...emporal-reconstruction-quality-and-cost.md | 206 +++++ ...e-changes-without-reallocating-a-target.md | 2 +- ...mporal-resolve-writes-the-display-frame.md | 2 +- ...OOK-machinefall-120fps-with-fab-quality.md | 2 +- .../native-coverage-2026-08-28.md | 8 +- examples/abyss-framework/temporal-main.js | 3 +- .../__tests__/scaffold.spec.ts | 5 +- .../__tests__/temporal-aa.spec.ts | 39 + .../__tests__/temporal-resolve.spec.ts | 119 ++- .../templates/starter/AGENTS.md | 2 +- .../templates/starter/CLAUDE.md | 2 +- .../starter/src/render/temporalAA.ts | 15 +- .../starter/src/render/temporalAAFrame.ts | 39 +- .../starter/src/render/temporalAAResolve.ts | 16 +- .../src/render/temporalClampRelease.ts | 183 ++++ .../src/render/temporalHistoryResolve.ts | 90 ++ .../starter/src/render/temporalMoire.ts | 190 +++++ .../starter/src/render/temporalResolve.ts | 91 +- .../src/render/temporalResolveDepth.ts | 2 + .../runtime-native/conformance/registry.json | 16 + .../conformance/run-conformance.mjs | 5 + .../scenes/shared/scene-support.js | 2 +- .../scenes/shared/temporal-aa-fixture.js | 70 +- .../scenes/shared/temporal-aa-scaler-route.js | 183 ++++ .../src/engine/renderer/renderer.cpp | 18 +- .../src/engine/renderer/renderer.h | 10 +- .../src/engine/shader/graph/graph.cpp | 11 +- .../src/engine/shader/standard.cpp | 18 +- .../tests/native-engine/material_test.cpp | 33 +- .../native-engine/render_database_test.cpp | 52 +- .../tests/native-engine/renderer_test.cpp | 11 + scripts/unreal-look/profile-gpu.py | 118 +++ .../scaffold/CalibrationScene.ts.in | 23 + scripts/unreal-look/side-by-side.py | 413 +++++++++ scripts/verify-temporal-motion.ts | 96 ++- tools/unreal-reference/build_level.py | 15 + tools/unreal-reference/gi/export.py | 25 + tools/unreal-reference/gi/fence/scene.glb | Bin 0 -> 99004 bytes tools/unreal-reference/gi/fence/scene.json | 800 ++++++++++++++++++ tools/unreal-reference/gi/fence/scene.py | 67 ++ tools/unreal-reference/gi/scenes.json | 5 + 56 files changed, 3010 insertions(+), 229 deletions(-) create mode 100644 docs/PRDs/Unreal/look/PRD-789-material-textures-filter-at-unreals-epic-anisotropy-by-default.md delete mode 100644 docs/PRDs/Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md create mode 100644 docs/PRDs/done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md create mode 100644 packages/create-threenative/templates/starter/src/render/temporalClampRelease.ts create mode 100644 packages/create-threenative/templates/starter/src/render/temporalHistoryResolve.ts create mode 100644 packages/create-threenative/templates/starter/src/render/temporalMoire.ts create mode 100644 packages/runtime-native/conformance/scenes/shared/temporal-aa-scaler-route.js create mode 100644 scripts/unreal-look/profile-gpu.py create mode 100644 scripts/unreal-look/side-by-side.py create mode 100644 tools/unreal-reference/gi/fence/scene.glb create mode 100644 tools/unreal-reference/gi/fence/scene.json create mode 100644 tools/unreal-reference/gi/fence/scene.py diff --git a/README.md b/README.md index f3fe1adabe..da667d267f 100644 --- a/README.md +++ b/README.md @@ -184,14 +184,14 @@ ported from what its source does, never from its code, under a PRD in Every Unreal 5.8.3 system that sets the look or the frame cost, split into 380 subsystems and measured in lines of the Unreal source. Each one maps to the ThreeNative PRD that ports it. -**20% done · 24% in progress · 34% not started · 18% deferred · 1% with no PRD** (share of 676K Unreal lines; the tool-heavy Niagara, Landscape, World Partition, RHI and RenderCore modules are left out) -· 88 open PRDs on this branch · 51 subsystems with no PRD +**21% done · 24% in progress · 34% not started · 18% deferred · 1% with no PRD** (share of 676K Unreal lines; the tool-heavy Niagara, Landscape, World Partition, RHI and RenderCore modules are left out) +· 87 open PRDs on this branch · 51 subsystems with no PRD [![Unreal port progress map](docs/PRDs/Unreal/research/unreal-port-status.svg)](docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html) -**Pace:** 104 PRD boxes ticked a week over the last 2 weeks. The port order runs waves 1-4 first, -so at that pace the default look is done about **Nov 26, 2026**, and everything filed today about -**Dec 23, 2026**. Scope grows as PRDs are filed, so read the dates as the earliest finish. +**Pace:** 109 PRD boxes ticked a week over the last 2 weeks. The port order runs waves 1-4 first, +so at that pace the default look is done about **Nov 25, 2026**, and everything filed today about +**Dec 20, 2026**. Scope grows as PRDs are filed, so read the dates as the earliest finish. [![Unreal port burn-up](docs/PRDs/Unreal/research/unreal-port-progress.svg)](docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html) diff --git a/docs/PRDs/Unreal/README.md b/docs/PRDs/Unreal/README.md index b8cdc933a2..c4706b9482 100644 --- a/docs/PRDs/Unreal/README.md +++ b/docs/PRDs/Unreal/README.md @@ -134,7 +134,7 @@ A layer turns on by default only when five conditions hold: | L6 | Ambient occlusion on indirect light only | [PRD-635](look/PRD-635-ambient-occlusion-darkens-only-the-light-that-comes-from-around.md) | | L7 | Global illumination | The [GI set](#global-illumination-the-lumen-set-filed-2026-10-20) below: PRD-603 to PRD-605, PRD-638 to PRD-645, PRD-VQ-09; templates last through [PRD-267](../useful-defaults/PRD-267-screen-space-gi-ships-in-the-templates.md) (amended) | | L8 | Reflections | [PRD-642](gi/PRD-642-glossy-surfaces-reflect-what-the-camera-cannot-see.md) on `high`; [PRD-VQ-03](../rendering/PRD-VQ-03-local-reflection-probes.md) (amended) on `low` and `medium` | -| L9 | Bloom, vignette, local exposure, motion blur, neutral grade | [PRD-636](look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md); anti-aliasing in [PRD-539](performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (amended); exposure default in [PRD-632](look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md) | +| L9 | Bloom, vignette, local exposure, motion blur, neutral grade | [PRD-636](look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md); anti-aliasing in [PRD-539](../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (amended); exposure default in [PRD-632](look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md) | Order: PRD-631 first, then PRD-632, because every later row is compared at matched exposure. After that, PRD-633 and PRD-635 can run in parallel, then the rest. Not filed: a surface BRDF PRD. three.js @@ -261,7 +261,7 @@ PRD-459, PRD-486 and PRD-493 cite Unreal only in passing, so they stay in their | [PRD-384](../done/PRD-384-adaptive-resolution-gpu-headroom.md) | Comparison with Unreal's dynamic-resolution controller; the gaps point to PRD-563, PRD-564 and PRD-549 | | [PRD-457](rendering/PRD-457-virtual-shadows-scale-by-measurement.md) | Pool-pressure resolution bias before falling back to cached levels; cross-reference to PRD-572 | | [PRD-538](../done/PRD-538-shadow-maps-redraw-only-what-streamed-in.md) | Per-page eviction and a nearest-first page budget | -| [PRD-539](performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) | The TSR levers not yet tried, in order, starting with 200% history | +| [PRD-539](../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) | The TSR levers not yet tried, in order, starting with 200% history | | [PRD-VQ-08](rendering/PRD-VQ-08-clouds-and-overhead-transmittance.md) | Three-channel cloud shadow map that serves receivers below and inside the cloud | | [PRD-459](../open-world/PRD-459-smooth-streaming-one-admission-budget-per-frame.md) | Texture uploads charged to the admission budget; stale Phase 1 status corrected | | [PRD-460](open-world/PRD-460-invisible-streaming-transitions.md) | Distance-driven cull fade, timed LOD fade, re-crossing and camera-cut rules | @@ -318,7 +318,7 @@ not needed for its red-green test), then PRD-650 and the rest of PRD-651 in para shadow clipmap. PRD-649 measures it by ablation on `horizon`. A PRD follows only if they show, and then it traces `GPUSceneBVH`, as the GI set does. - **A shadow denoiser:** Unreal has none for virtual shadows. Its SMRT noise is resolved by temporal - reconstruction ([PRD-539](performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md)). + reconstruction ([PRD-539](../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md)). The `walk` and `sunturn` runs show whether ThreeNative needs more. - **Capsule shadows, ray-traced shadows, MegaLights, translucent coloured shadows, area-light shadows:** all off in a fresh Unreal project, so they are not the default look. Each waits for a diff --git a/docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md b/docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md index be16ddca3e..967b63d0e0 100644 --- a/docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md +++ b/docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md @@ -8,7 +8,7 @@ prd_contract: v1 **Priority:** P2 — AC-1 and AC-3 are open: the starter L9 cells still differ from Unreal in lighting, sky, shadows and ambient occlusion, which other PRDs own, and the whole `high` chain does not hold 16.7 ms while ambient occlusion costs 15 ms. **Complexity:** 5 (MEDIUM) — template post source in 13 templates (+2), a local-exposure stage that is new to the chain (+2), native proof (+1); risk override: none **Owner:** João -**Depends on:** [PRD-631](../../done/PRD-631-an-unreal-reference-scene-measures-the-default-look.md) Phase 3 (the L9 row) and [PRD-632](PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md): bloom and local exposure act on the exposed scene-linear frame, so they cannot be compared before exposure matches. Low-resolution temporal reconstruction belongs to [PRD-539](../performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md). Phase 4 makes temporal anti-aliasing the default, by owner decision on 2026-10-21. +**Depends on:** [PRD-631](../../done/PRD-631-an-unreal-reference-scene-measures-the-default-look.md) Phase 3 (the L9 row) and [PRD-632](PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md): bloom and local exposure act on the exposed scene-linear frame, so they cannot be compared before exposure matches. Low-resolution temporal reconstruction belongs to [PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md). Phase 4 makes temporal anti-aliasing the default, by owner decision on 2026-10-21. ## Context diff --git a/docs/PRDs/Unreal/look/PRD-789-material-textures-filter-at-unreals-epic-anisotropy-by-default.md b/docs/PRDs/Unreal/look/PRD-789-material-textures-filter-at-unreals-epic-anisotropy-by-default.md new file mode 100644 index 0000000000..5e0b26621c --- /dev/null +++ b/docs/PRDs/Unreal/look/PRD-789-material-textures-filter-at-unreals-epic-anisotropy-by-default.md @@ -0,0 +1,30 @@ +# PRD-789: Material textures filter at Unreal's Epic anisotropy by default + +**Status:** NOT STARTED — filed 2026-11-03 from PRD-539 (deferred, not declined). +**Priority:** P2 — Phase 1 is open: unset texture anisotropy still differs from the Unreal Epic default. + +## Problem + +Stock Unreal 5.8.3 at Epic filters World-group textures at 8x anisotropy (`r.MaxAnisotropy=8`, +`Engine/Config/BaseScalability.ini` `[TextureQuality@3]`). ThreeNative keeps three's default `texture.anisotropy = 1`. +At grazing angles the ThreeNative texture is blurrier than Unreal's on the same scene. Measured on +`gi:neon-corridor` pose `textured`, far brick wall, mean absolute Laplacian at 100%: Unreal 14.63, +ThreeNative 12.51 at 1x, 13.72 at 8x ([PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md), judges 4 and 5). The owner rule is that the Unreal defaults are the engine default +look, each with a named override. + +## Outcome + +A material texture with no authored anisotropy filters at the tier's Unreal value (Epic 8, High 4, Medium 2, Low 1). +An authored `texture.anisotropy` overrides it, and the override is reported. + +### Phase 1 — The default + +- [ ] The engine resolves an unset anisotropy to the tier's Unreal value, the authored value overriding it. proof: a spec on the resolved sampler key per tier, and a native conformance row or `--target desktop` playtest. +- [ ] `gi:neon-corridor` at L7 moves toward Unreal on the brick wall with no knob. proof: `pnpm unreal-look:compare` + on `Wall_Brick` and `compare --check` on every textured scene (neon-corridor, the shadow foliage scenes) with + lock receipts, and a fresh visual judge, IMPROVEMENT required. +- [ ] The GPU cost is stated. proof: paired locked-clock GPU p50/p95 on one textured template. + +## Acceptance criteria + +- [ ] Unset anisotropy follows the Unreal tier value, overridable and reported. proof: Phase 1 boxes. diff --git a/docs/PRDs/Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md b/docs/PRDs/Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md deleted file mode 100644 index 6c53cfdd69..0000000000 --- a/docs/PRDs/Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -prd_contract: v1 ---- - -# PRD-539 — A lower internal raster reconstructs into a stable full-resolution frame - -**Status:** NOT STARTED — successor to [PRD-455](../../done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md); no reconstructor has cleared the quality gate (2026-10-07). -**Priority:** P1 — Carried over unchanged from PRD-455, whose own note ranked it the highest-value rendering project; the owner may lower it. -**Complexity:** 8 → HIGH. Five candidates failed the same gate on hardware; the next one is a new design, not a parameter change. -**Numbering:** filed in #398 as PRD-537; #464 took that number for the far-hills PRD first, so this PRD is PRD-539 (2026-10-08). -**Depends on:** PRD-455 (opt-in provider, lifecycle, fixture and 31-arm corpus) and PRD-269 (motion history). - -## Problem - -PRD-455 shipped full-resolution temporal AA as opt-in generated source and the corpus that judges reconstruction. Every low-resolution candidate then failed the unchanged gate on nvidia/turing hardware WebGPU: - -| Evidence (2026-10-07) | Result | -| --- | --- | -| Full-res edge, moving camera | `.05849` against no-AA `.05029`, needs `<.04778` | -| Full-res edge, static camera | `.04847` against `.05245`, a 7.6% gain, still `.0007` short | -| Low-input edge | `.08062` against spatial `.07745`, needs `<.04687` (static: `.08132` against `.06930`) | -| Reveal stale after +1 frame | motion `1.77%`, quality `4.89%`, needs `<=1%` | -| Variance-clip gamma `[0.75,1.25]`, `[1,1.5]` | edge `.05822`, `.05803`; no check moves | - -Localization: accumulation works when the camera is static, and sub-pixel camera drift costs about `.010` of edge error through reprojection and resampling. Clipping, the reactive mask and the raw4 current-footprint candidate are not the lever. A 426×240 input does not recover native-resolution edge accuracy in this metric. - -## Unreal TSR levers not yet tried (UE 5.8.3, read 2026-10-09) - -Unreal's TSR attacks the same failure. Only the levers outside this PRD's "do not repeat" list are -named here. Catmull–Rom history sampling is not a new lever: TSR also uses it -(`UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRUpdateHistory.usf:952-975`), and -PRD-455 already compared it. Paths are under `Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp` -unless named otherwise. Order is by fit to the measured localization above. - -1. **History stored above output resolution.** `r.TSR.History.ScreenPercentage` (`:54-60`) keeps the - history at 100–200% of the *output* resolution. The default is 100. The scalability groups set 200 at the Epic and Cinematic tiers - (`UE 5.8.3: Engine/Config/BaseScalability.ini:89` and `:100`). - The stated reason is sampling-theorem headroom: details survive repeated reprojection without the - resampling blur that each bilinear or bicubic fetch adds. A resolve pass - (`TSRResolveHistory.usf`) filters the history down to output. This targets the `.010` drift loss - directly. The cost is 4× history memory and bandwidth, which Unreal offsets by storing history - as R11G11B10 (`:63-73`, default 1). -2. **Velocity-clamped history weight.** When an output pixel moves at 1 px/frame or more, the - history's accumulated sample count is clamped to 4 (`r.TSR.Velocity.WeightClampingSampleCount` - and `...PixelSpeed`, `:310-330`). The default history cap is 16 (`:45-50`). The clamp applies to history pixels, so it is less visible at lever 1's 200% history - (`:309-321`). Test levers 1 and 2 together. This trades a little - stability for less successive-convolution blur during motion. That is the moving-camera edge - check, which is worse than no-AA today. -3. **Spatial anti-aliasing of rejected input.** Where history is rejected, TSR runs a built-in - spatial anti-aliaser on the low-resolution input (`r.TSR.RejectionAntiAliasingQuality`, cvar default 3, set to 0 at the lowest tier and to 1 or 2 above it by - `UE 5.8.3: Engine/Config/BaseScalability.ini:59-104`, `:233-246`; `Engine/Shaders/Private/TemporalSuperResolution/TSRSpatialAntiAliasing.usf`). - This targets the low-input edge and reveal checks. The low-input arm currently loses to plain - spatial upscale. -4. **Flicker-aware shading rejection.** TSR measures luma flicker over a period of about 2 frames, - and relaxes history rejection on pixels that flicker, so the history ghosts inside a luma bound instead of resetting - (`r.TSR.ShadingRejection.Flickering`, cvar default 1, off at the Low and Medium tiers, `:137-200`, period default 2 frames at `:188`; - `TSRMeasureFlickeringLuma.usf`; the moire error in `UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRRejectShading.usf:466-500`). This is not variance-clip widening, - which is already falsified. -5. **Not recommended as a default:** thin-geometry detection (`r.TSR.ThinGeometryDetection`, `:332`) - and the reprojection field (`r.TSR.ReprojectionField`, `:290`) are **off** in the cvar defaults. - The reprojection field is on at the High tier and above through scalability (`BaseScalability.ini:82`), and thin-geometry detection turns on - with Nanite foliage. Try them only after 1–4. - -Each candidate is one arm in the unchanged gate, never a threshold change. - -## Outcome - -One reconstructor that, at a sub-1.0 internal raster, produces a stable display-resolution image during motion, lowers measured GPU/render time, and degrades safely when history cannot be trusted. If it cannot beat full resolution after its own cost, it stays opt-in. - -### Phase 1 — A reconstructor that clears the unchanged quality gate - -- [ ] One-variable control for the `.010` drift loss: the same reconstructor with history at 200% against 100% of output resolution, everything else equal, reports moving- and static-camera edge error for both arms. proof: `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` run once per arm on hardware WebGPU. -- [ ] A velocity-clamped history weight (Unreal's TSR keeps about 4 samples of history at 1 px/frame of motion) runs as one gate arm. proof: `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` run once per arm on hardware WebGPU. - -- [ ] Thin fences, foliage, sub-pixel edges, a moving character and an instanced object stay within the pinned temporal thresholds against a full-resolution reference, and the temporal arm beats the spatial arm on the named stability metric. proof: an automated frame-sequence report records edge flicker, rejected-history ratio and image delta for full-res, low-res spatial upscale and temporal reconstruction; `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` exits 0 on hardware WebGPU with the original thresholds (`edge <.04778` full-res, `<.04687` low-input). -- [ ] Newly revealed surfaces do not inherit stale colour, and disabling disocclusion rejection fails the check. proof: the same command, `revealRecovery` and `qualityRevealRecovery` true (`<=1%` stale after one frame). - -### Phase 2 — Keep it only if it buys frame time - -- [ ] On a GPU-bound representative game, sub-1.0 rendering plus reconstruction lowers GPU/render p95 versus full resolution while meeting the Phase 1 thresholds. proof: paired fixed-route browser WebGPU and desktop-native table of internal pixels, reconstruction cost, GPU/render p50/p95 and visual metrics (no FPS-only verdict). -- [ ] Automatic resolution moves between at least three scales in one run without history corruption, allocation growth or a reconstruction cost spike larger than the saved raster cost. proof: scripted scaler route recording transitions, history resets, render-target allocation count and per-stage cost; repeated cycles end at the initial allocation baseline. - -## three.js 0.187.0 levers (2026-11-02) - -- r186 adds `TAAUtils`, the shared functions of the TAA nodes (three#34458). r187 registers `TAAUNode` inputs before their consumers (three#34565) and updates the depth pass before `TemporalReprojectNode` (three#34823). Recapture the low-resolution corpus on 0.187.0 before the quality numbers of this PRD are compared, because the upstream temporal nodes changed. - -Adoption rule: the owned engine is the default, and it runs a three symbol only when its API catalog covers it (`docs/architecture/NATIVE-ENGINE-DECISION.md`). So use the upstream piece first as a measured reference arm on `engine: "threejs"`. A template uses it only after a catalog row and a native parity case land. - -## Decisions - -- The thresholds and the 31-arm corpus carry over unchanged; a candidate that needs them relaxed is rejected, not merged. -- Candidates already falsified (do not repeat): reactive mask, raw4 current-footprint weighting, variance-clip widening, and a rejection-fraction metric (it is velocity-blind; see PRD-269). -- CI: `verify-temporal-motion.ts` is not in CI today (its hardware Vulkan flags lose the device on the GPU-less runner, and four checks fail by design). Phase 1 includes returning it to the `temporal` integration job, with the unmet checks fail-closed once they pass. -- Ghosting margin to keep honest: the pinned moving-edge ratios (`<=.75`, `>=1.25x`) sit against measured `.629`/`.651` and `1.485`/`1.435` (hardware/CI). -- First step for the owner of this PRD: explain the `.010` camera-drift loss (history resampling) with a one-variable control before designing the next reconstructor. -- 2026-10-09 (João, via the Unreal source review): that one-variable control is history resolution, 200% against 100% (Unreal TSR lever 1 above). Its box is now the first box of Phase 1. Levers 2–4 follow in the listed order as gate arms. No existing box changed. -- 2026-10-12 (Unreal source review): corrected lever 4. Flicker detection relaxes history rejection to ghost within a luma bound, it does not add rejection. Also corrected the tier defaults for levers 1, 3 and 5. No box changed. -- 2026-10-19 (PRD-631 reference plan): Unreal's desktop default anti-aliasing is TSR (`r.AntiAliasingMethod=4`, `Engine/Source/Runtime/Engine/Private/SceneView.cpp:235`), with 200% history at Epic. The templates use SMAA. A reconstructor that this PRD keeps becomes the desktop `high` default only after it passes the L9 row of [PRD-631](../../done/PRD-631-an-unreal-reference-scene-measures-the-default-look.md) and this PRD's motion thresholds. [PRD-636](../look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md) owns the rest of L9. No box changed. diff --git a/docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md b/docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md index 922bd1efbe..9ad2d4ff37 100644 --- a/docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md +++ b/docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md @@ -70,7 +70,7 @@ capability manifest lists these readouts: (state, reason, transitions) per emitter. `ParticleSignificance.shed` (`packages/core/src/particle-significance.ts:77`) is the global effect budget. - PRD-539's `scripts/verify-temporal-motion.ts` reports a rejected-history ratio offline - ([PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md), its first box). + ([PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md), its first box). Two gaps are known. First, no number says how many fragments a pixel paid for. PRD-228 named the cost of that gap: a 9–11 ms flat town pass on Mali, and "nobody knows whether that is vertex/binning diff --git a/docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md b/docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md index 29a4be477b..90a43cac13 100644 --- a/docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md +++ b/docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md @@ -8,7 +8,7 @@ prd_contract: v1 **Priority:** P2 — Phase 1 is open: the default temporal resolve has no history at an uncovered pixel and no motion to borrow, and PRD-539 records a reveal check that fails (1.77% and 4.89% stale after one frame against 1%). **Complexity:** 6 (MEDIUM) — 3–5 files (+1), a new scatter buffer per frame (+2), the same rule on the owned engine (+2), a visual judge (+1); risk override: none **Owner:** João -**Depends on:** [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus, the thresholds and the verdict. This PRD supplies one gate arm for 539's reveal box and changes no threshold. `docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md` Phase 4 (on `feat/unreal-look-epic`) for the temporal default. +**Depends on:** [PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus, the thresholds and the verdict. This PRD supplies one gate arm for 539's reveal box and changes no threshold. `docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md` Phase 4 (on `feat/unreal-look-epic`) for the temporal default. ## Context diff --git a/docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md b/docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md index 8c758a972d..4afe0d2b1d 100644 --- a/docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md +++ b/docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md @@ -8,7 +8,7 @@ prd_contract: v1 **Priority:** P3 — Phase 1 is open: no number says how long an uncovered surface stays soft in the default temporal resolve, and Unreal turns this feature on at Medium and above through its scalability file. **Complexity:** 6 (MEDIUM) — a second history layer (+2), 3–5 files (+1), the same rule on the owned engine (+2), a visual judge (+1); risk override: none **Owner:** João -**Depends on:** [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus. [PRD-717](PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md) for the reveal test, which decides when a pixel counts as uncovered. +**Depends on:** [PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus. [PRD-717](PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md) for the reveal test, which decides when a pixel counts as uncovered. ## Context diff --git a/docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md b/docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md index 1cee732580..f612a6a8f1 100644 --- a/docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md +++ b/docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md @@ -8,7 +8,7 @@ prd_contract: v1 **Priority:** P3 — Phase 1 is open: the default temporal resolve clips history in the frame's own color space with one variance gamma, and no measurement says whether dark regions ghost or bright regions flicker because of it. **Complexity:** 6 (MEDIUM) — 2–4 files (+1), a color-space change in a shared resolve (+2), the same rule on the owned engine (+2), a visual judge (+1); risk override: none **Owner:** João -**Depends on:** [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus, which also names the moire error of lever 4. `docs/PRDs/Unreal/look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md` (on `feat/unreal-look-epic`) for the exposure the rejection reads. +**Depends on:** [PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus, which also names the moire error of lever 4. `docs/PRDs/Unreal/look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md` (on `feat/unreal-look-epic`) for the exposure the rejection reads. ## Context diff --git a/docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md b/docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md index 32dad5d714..695ba0e989 100644 --- a/docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md +++ b/docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md @@ -8,7 +8,7 @@ prd_contract: v1 **Priority:** P2 — Phase 1 is open: the default temporal resolve reads one input texel per output pixel and has no temporal quality tier, while Unreal reads a wider kernel where history is rejected and sets its width, its rejection anti-aliasing and its history size from four named levels. **Complexity:** 6 (MEDIUM) — 3–5 files (+1), a change in a shared resolve (+2), the same rule on the owned engine (+2), a visual judge (+1); risk override: none **Owner:** João -**Depends on:** [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus and the history-size arm. `docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md` Phase 4 (on `feat/unreal-look-epic`) for the temporal default. Coordinates with `docs/PRDs/Unreal/shadows/PRD-652-the-shadow-tier-follows-unreal-shadow-quality-levels.md`, whose budget ladder Phase 3 reuses. +**Depends on:** [PRD-539](../../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the corpus and the history-size arm. `docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md` Phase 4 (on `feat/unreal-look-epic`) for the temporal default. Coordinates with `docs/PRDs/Unreal/shadows/PRD-652-the-shadow-tier-follows-unreal-shadow-quality-levels.md`, whose budget ladder Phase 3 reuses. ## Context diff --git a/docs/PRDs/Unreal/research/port-history.json b/docs/PRDs/Unreal/research/port-history.json index b005ae1acd..44bbc0c114 100644 --- a/docs/PRDs/Unreal/research/port-history.json +++ b/docs/PRDs/Unreal/research/port-history.json @@ -153,10 +153,10 @@ }, { "date": "2026-11-03", - "done": 393, - "total": 1140, - "lookDone": 139, - "lookTotal": 489, - "open": 88 + "done": 403, + "total": 1144, + "lookDone": 149, + "lookTotal": 493, + "open": 87 } ] diff --git a/docs/PRDs/Unreal/research/unreal-port-progress.svg b/docs/PRDs/Unreal/research/unreal-port-progress.svg index 45a1c5ef45..d8646af759 100644 --- a/docs/PRDs/Unreal/research/unreal-port-progress.svg +++ b/docs/PRDs/Unreal/research/unreal-port-progress.svg @@ -1,7 +1,7 @@ -Unreal port burn-up: 393 of 1140 PRD boxes ticked · 104 a week over the last 2 weeks · 88 PRDs open +Unreal port burn-up: 403 of 1144 PRD boxes ticked · 109 a week over the last 2 weeks · 87 PRDs open Every filed PRD: share of boxes tickedDefault look (waves 1-4): share of boxes tickedDashed: finish at the last 2 weeks' pace 0% 25% @@ -9,17 +9,17 @@ 75% 100% Sep 6 -Sep 20 -Oct 4 -Oct 18 -Nov 1 -Nov 15 -Nov 29 -Dec 13 - - - -Everything filed ≈ Dec 23, 2026 - -Default look ≈ Nov 26, 2026 +Sep 20 +Oct 4 +Oct 18 +Nov 1 +Nov 15 +Nov 29 +Dec 13 + + + +Everything filed ≈ Dec 20, 2026 + +Default look ≈ Nov 25, 2026 diff --git a/docs/PRDs/Unreal/research/unreal-port-status.svg b/docs/PRDs/Unreal/research/unreal-port-status.svg index 45cf92dae0..a2eae5ef06 100644 --- a/docs/PRDs/Unreal/research/unreal-port-status.svg +++ b/docs/PRDs/Unreal/research/unreal-port-status.svg @@ -2,8 +2,8 @@ -Unreal 5.8.3 → ThreeNative: 20% done, 24% in progress (share of 676K Unreal lines) -Done 20%In progress 24%Not started 34%Deferred 18%No PRD 1%Out of scope by rule 1%Not split 1% +Unreal 5.8.3 → ThreeNative: 21% done, 24% in progress (share of 676K Unreal lines) +Done 21%In progress 24%Not started 34%Deferred 18%No PRD 1%Out of scope by rule 1%Not split 1% LIGHTING & GI · 199K @@ -404,53 +404,45 @@ TSR · Shader parameter structs and permutations · Out of scope by rule Sha… TSR · Rejection pass: moire, measure, guide update, resurrection choice · In progress - + Re… -TSR · Spatial anti-aliaser kernels · In progress - +TSR · Spatial anti-aliaser kernels · Done Sp… TSR · Thin geometry detection pass · No PRD TSR · Shading analysis library · Not started TSR · Depth and velocity analysis (depth error, bilaterals, Jacobian, edge) · In progress - + TSR · Visualization shader (16 views) · No PRD TSR · Update: setup, quality levels and kernel tables · Not started TSR · Decimate history for rejection · Not started -TSR · History setup: sizes, jitter, validity and reset · In progress - +TSR · History setup: sizes, jitter, validity and reset · Done TSR · Common types, packing and wave helpers · Out of scope by rule TSR · Reconstruction kernels and neighbourhood offsets · Not started TSR · Velocity dilation · Done -TSR · Resolve history to output · In progress - +TSR · Resolve history to output · Done TSR · CVars, tiers and defaults · In progress TSR · Velocity, occluder and decimate dispatch · Not started TSR · Rejection and spatial anti-aliasing dispatch · In progress - + TSR · Pass gating and measure passes · Done -TSR · Update: reprojection fetch, field and lens distortion · In progress - +TSR · Update: reprojection fetch, field and lens distortion · Done TSR · Closest-occluder scatter and parallax disocclusion test · Not started TSR · Update and resolve dispatch · In progress TSR · Output, history extraction and visualize dispatch · No PRD TSR · Color spaces and back-buffer quantization error · Not started TSR · Weight relaxation · No PRD -TSR · Spatial anti-aliaser pass · In progress - +TSR · Spatial anti-aliaser pass · Done TSR · Update: input filter at predictor frequency · Not started -TSR · Update: validity clamp, quantize and history write · In progress - -TSR · Reprojection field encoding · In progress - +TSR · Update: validity clamp, quantize and history write · Done +TSR · Reprojection field encoding · Done TSR · Tiled network pass · No PRD TSR · Thin geometry shared helpers · No PRD TSR · Update: history reprojection with Catmull-Rom fetch · Done TSR · Wave size, 16-bit math and async compute · No PRD TSR · Thin geometry coverage measure · No PRD -TSR · Flickering luma measure · In progress - +TSR · Flickering luma measure · Done TSR · Clear previous-frame scratch textures · Out of scope by rule WORLD, ANIM, VFX · 57K diff --git a/docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html b/docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html index afdf4c7195..2f19865c17 100644 --- a/docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html +++ b/docs/PRDs/Unreal/research/unreal-visuals-and-performance-feature-map.html @@ -285,7 +285,7 @@

Unreal 5.8.3 render features, sized and rated

const STATUS = { shipped: "Shipped", partial: "Partial", open: "Open", deferred: "Deferred", notfiled: "Not filed", rule: "Out of scope by rule" }; // PRD registry (title, path, box counts), generated from the PRD files by a script; do not hand-edit. // @prds-start -const PRDS = {"244":{"t":"The scene's BVH reaches the GPU","p":"docs/PRDs/done/PRD-244-the-scenes-bvh-reaches-the-gpu.md","r":"develop","s":"","d":0,"n":15,"f":"done"},"269":{"t":"motion vectors for skinned and instanced geometry, or the temporal filters lie","p":"docs/PRDs/done/PRD-269-motion-vectors-or-the-temporal-filters-lie.md","r":"develop","s":"DONE","d":13,"n":13,"f":"done"},"281":{"t":"a dense mesh bakes to a crack-free cluster DAG","p":"docs/PRDs/done/nanite-like/PRD-281-a-dense-mesh-bakes-to-a-crack-free-cluster-dag.md","r":"develop","s":"","d":8,"n":8,"f":"done"},"282":{"t":"the cut is chosen on the CPU first","p":"docs/PRDs/done/nanite-like/PRD-282-the-cut-is-chosen-on-the-cpu-first.md","r":"develop","s":"","d":8,"n":8,"f":"done"},"284":{"t":"the frame does not draw what the frame already hid","p":"docs/PRDs/done/nanite-like/PRD-284-the-frame-does-not-draw-what-the-frame-already-hid.md","r":"develop","s":"","d":0,"n":4,"f":"done"},"285":{"t":"clusters arrive when the camera asks for them","p":"docs/PRDs/done/nanite-like/PRD-285-clusters-arrive-when-the-camera-asks-for-them.md","r":"develop","s":"","d":0,"n":5,"f":"done"},"316":{"t":"forty-six VFX are generated render source, not an engine inside the engine","p":"docs/PRDs/Unreal/vfx/PRD-316-forty-six-vfx-are-generated-render-source-not-an-engine-inside-the-engine.md","r":"develop","s":"PROPOSED","d":0,"n":39,"f":"open"},"384":{"t":"Adaptive resolution must distinguish host delay from GPU cost","p":"docs/PRDs/done/PRD-384-adaptive-resolution-gpu-headroom.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"455":{"t":"Fewer rendered pixels reconstruct into a stable full-resolution frame","p":"docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md","r":"develop","s":"DONE","d":7,"n":7,"f":"done"},"456":{"t":"Distant world cells stay visible after their detailed cells unload","p":"docs/PRDs/open-world/PRD-456-distant-world-cell-proxies.md","r":"develop","s":"PROPOSED","d":0,"n":6,"f":"open"},"457":{"t":"Virtual shadows page only when pages are cheaper than cached clip levels","p":"docs/PRDs/Unreal/rendering/PRD-457-virtual-shadows-scale-by-measurement.md","r":"develop","s":"PROPOSED","d":0,"n":5,"f":"open"},"459":{"t":"Smooth streaming: one admission budget per frame, prefetched ahead of the camera","p":"docs/PRDs/open-world/PRD-459-smooth-streaming-one-admission-budget-per-frame.md","r":"develop","s":"PARTIAL","d":4,"n":16,"f":"open"},"460":{"t":"Invisible streaming: a dithered fade for arriving props and a crossfade across LOD levels","p":"docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md","r":"develop","s":"NOT STARTED","d":0,"n":15,"f":"open"},"486":{"t":"Characters get a LOD chain","p":"docs/PRDs/open-world/PRD-486-characters-get-a-lod-chain.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"488":{"t":"Foliage moves in the wind","p":"docs/PRDs/open-world/PRD-488-foliage-moves-in-the-wind.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"489":{"t":"Occlusion culling re-tested on the GPU scene","p":"docs/PRDs/done/PRD-489-gpu-scene-occlusion-culling.md","r":"develop","s":"CLOSED","d":2,"n":8,"f":"done"},"492":{"t":"Colour grading and film grain","p":"docs/PRDs/done/PRD-492-colour-grading-and-film-grain.md","r":"develop","s":"DONE","d":6,"n":6,"f":"done"},"493":{"t":"Terrain layers past sixteen textures","p":"docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md","r":"develop","s":"PARTIAL","d":6,"n":8,"f":"open"},"523":{"t":"The render graph owns passes and history (N14a)","p":"docs/PRDs/native-engine/N14-native-render-chain-and-advanced-visuals/PRD-523-n14a-the-render-graph-owns-passes-and-history.md","r":"develop","s":"PROPOSED","d":0,"n":7,"f":"open"},"524":{"t":"Virtual shadows run native (N14b)","p":"docs/PRDs/native-engine/N14-native-render-chain-and-advanced-visuals/PRD-524-n14b-virtual-shadows-run-native.md","r":"develop","s":"PROPOSED","d":0,"n":5,"f":"open"},"538":{"t":"Shadow maps redraw only what streamed in","p":"docs/PRDs/done/PRD-538-shadow-maps-redraw-only-what-streamed-in.md","r":"develop","s":"DONE","d":4,"n":4,"f":"done"},"539":{"t":"A lower internal raster reconstructs into a stable full-resolution frame","p":"docs/PRDs/Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md","r":"develop","s":"NOT STARTED","d":0,"n":6,"f":"open"},"560":{"t":"Height fog follows the ground, and the sun shows through it","p":"docs/PRDs/done/PRD-560-height-fog-follows-the-ground.md","r":"develop","s":"LOCAL PHASES COMPLETE","d":11,"n":11,"f":"done"},"561":{"t":"Small things touch the ground: contact shadows from screen depth","p":"docs/PRDs/done/PRD-561-contact-shadows-from-screen-depth.md","r":"develop","s":"DECLINED","d":1,"n":7,"f":"done"},"562":{"t":"Distant land takes the colour of the sky it is seen through","p":"docs/PRDs/Unreal/rendering/PRD-562-aerial-perspective-from-a-froxel-volume.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"563":{"t":"The first frame starts at the tier the GPU family can hold","p":"docs/PRDs/done/PRD-563-the-first-frame-starts-at-the-tier-the-gpu-family-holds.md","r":"develop","s":"COMPLETE","d":15,"n":15,"f":"done"},"564":{"t":"The resolution scale changes without reallocating a render target","p":"docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md","r":"develop","s":"DONE","d":7,"n":7,"f":"done"},"565":{"t":"Multisampled targets stay in tile memory on the Android host","p":"docs/PRDs/done/PRD-565-multisampled-targets-stay-in-tile-memory-on-android.md","r":"develop","s":"COMPLETE","d":4,"n":4,"f":"done"},"566":{"t":"Terrain LOD morphs on the GPU and picks its level by screen error","p":"docs/PRDs/done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"567":{"t":"Terrain layers blend by their own height, and the game owns the curve","p":"docs/PRDs/done/PRD-567-terrain-layers-blend-by-their-own-height.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"568":{"t":"Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8","p":"docs/PRDs/done/PRD-568-mobile-builds-ship-gpu-ready-astc.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"569":{"t":"GPU particle emitters out of view stop costing, and effects yield to the frame budget","p":"docs/PRDs/done/PRD-569-gpu-particle-emitters-cull-and-yield-to-the-frame-budget.md","r":"develop","s":"DONE","d":12,"n":12,"f":"done"},"570":{"t":"A character costs what the camera sees of it","p":"docs/PRDs/done/PRD-570-a-character-costs-what-the-camera-sees-of-it.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"571":{"t":"Exposure meters a histogram and ignores the sun","p":"docs/PRDs/BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md","r":"develop","s":"BLOCKED","d":8,"n":8,"f":"blocked"},"572":{"t":"Shadow casters sort themselves into static and moving","p":"docs/PRDs/done/PRD-572-shadow-casters-sort-themselves-into-static-and-moving.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"600":{"t":"A clustered mesh submits only the clusters the view holds","p":"docs/PRDs/Unreal/rendering/PRD-600-a-clustered-mesh-submits-only-the-clusters-the-view-holds.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"601":{"t":"Far clusters keep the shading of the source mesh","p":"docs/PRDs/Unreal/rendering/PRD-601-far-clusters-keep-the-shading-of-the-source-mesh.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"602":{"t":"A clustered shadow caster draws what its shadow texel resolves","p":"docs/PRDs/Unreal/rendering/PRD-602-a-clustered-shadow-caster-draws-what-its-shadow-texel-resolves.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"603":{"t":"Probes refresh by tracing the packed scene, not by drawing it six times","p":"docs/PRDs/Unreal/rendering/PRD-603-probes-refresh-by-tracing-the-packed-scene-not-by-drawing-it-six-times.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"604":{"t":"A surface is lit only by probes that can see it","p":"docs/PRDs/Unreal/rendering/PRD-604-a-surface-is-lit-only-by-probes-that-can-see-it.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"605":{"t":"The probe field follows the camera in nested rings","p":"docs/PRDs/Unreal/rendering/PRD-605-the-probe-field-follows-the-camera-in-nested-rings.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"606":{"t":"A fast-travel destination is ready before the cover lifts","p":"docs/PRDs/Unreal/open-world/PRD-606-a-fast-travel-destination-is-ready-before-the-cover-lifts.md","r":"develop","s":"NOT STARTED","d":0,"n":13,"f":"open"},"607":{"t":"Streaming reports when a hole is near and spends more to close it","p":"docs/PRDs/Unreal/open-world/PRD-607-streaming-reports-when-a-hole-is-near-and-spends-more-to-close-it.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"609":{"t":"A GPU burst starts where and when it is asked, and live particles stay put","p":"docs/PRDs/done/PRD-609-a-gpu-burst-starts-where-and-when-it-is-asked-and-live-particles-stay-put.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"610":{"t":"The native engine wins on real games","p":"docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md","r":"develop","s":"PARTIAL","d":3,"n":10,"f":"open"},"611":{"t":"The Linux HUD overlay keeps up with the game and stops burning a core","p":"docs/PRDs/performance/PRD-611-hud-overlay-latency-and-cpu.md","r":"develop","s":"PARTIAL","d":7,"n":9,"f":"open"},"631":{"t":"An Unreal reference scene measures the default look","p":"docs/PRDs/done/PRD-631-an-unreal-reference-scene-measures-the-default-look.md","r":"develop","s":"IN PROGRESS","d":15,"n":15,"f":"done"},"632":{"t":"The frame is exposed and tone-mapped the way a fresh Unreal project is","p":"docs/PRDs/Unreal/look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md","r":"develop","s":"IN PROGRESS","d":17,"n":25,"f":"open"},"633":{"t":"The sky is an atmosphere, and it lights the scene","p":"docs/PRDs/Unreal/look/PRD-633-the-sky-is-an-atmosphere-and-it-lights-the-scene.md","r":"develop","s":"PARTIAL","d":2,"n":12,"f":"open"},"634":{"t":"Sun shadows reach the horizon and soften with distance from the caster","p":"docs/PRDs/Unreal/look/PRD-634-sun-shadows-reach-the-horizon-and-soften-with-distance-from-the-caster.md","r":"develop","s":"PARTIAL","d":11,"n":18,"f":"open"},"635":{"t":"Ambient occlusion darkens only the light that comes from around","p":"docs/PRDs/Unreal/look/PRD-635-ambient-occlusion-darkens-only-the-light-that-comes-from-around.md","r":"develop","s":"IN PROGRESS","d":8,"n":17,"f":"open"},"636":{"t":"The finishing post chain matches Unreal's defaults","p":"docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md","r":"develop","s":"PARTIAL","d":18,"n":29,"f":"open"},"639":{"t":"A Surface Cache gives traced rays the texture, glow and lights of what they hit","p":"docs/PRDs/Unreal/gi/PRD-639-a-surface-cache-gives-traced-rays-the-texture-glow-and-lights-of-what-they-hit.md","r":"develop","s":"IN PROGRESS","d":6,"n":9,"f":"open"},"640":{"t":"Pixels gather bounce light from screen probes, and the radiance cache answers the rest","p":"docs/PRDs/Unreal/gi/PRD-640-pixels-gather-bounce-light-from-screen-probes-and-the-radiance-cache-answers-the-rest.md","r":"develop","s":"PARTIAL","d":2,"n":9,"f":"open"},"641":{"t":"Bounce light holds still while the camera moves and follows a light that changes","p":"docs/PRDs/Unreal/gi/PRD-641-bounce-light-holds-still-while-the-camera-moves-and-follows-a-light-that-changes.md","r":"develop","s":"IN PROGRESS","d":5,"n":14,"f":"open"},"642":{"t":"Glossy surfaces reflect what the camera cannot see","p":"docs/PRDs/Unreal/gi/PRD-642-glossy-surfaces-reflect-what-the-camera-cannot-see.md","r":"develop","s":"PARTIAL","d":6,"n":11,"f":"open"},"643":{"t":"Glass, fog and leaves take the bounce light too","p":"docs/PRDs/Unreal/gi/PRD-643-glass-fog-and-leaves-take-the-bounce-light-too.md","r":"develop","s":"IN PROGRESS","d":6,"n":11,"f":"open"},"644":{"t":"The GI tier follows Unreal's quality levels and the measured GPU time","p":"docs/PRDs/Unreal/gi/PRD-644-the-gi-tier-follows-unreal-quality-levels-and-the-measured-gpu-time.md","r":"develop","s":"IN PROGRESS","d":9,"n":12,"f":"open"},"645":{"t":"A desktop GPU with ray tracing traces GI rays in hardware","p":"docs/PRDs/done/PRD-645-a-desktop-gpu-with-ray-tracing-traces-gi-rays-in-hardware.md","r":"develop","s":"DONE","d":4,"n":4,"f":"done"},"646":{"t":"ThreeNative recreates each Unreal calibration scene exactly","p":"docs/PRDs/Unreal/gi/PRD-646-threenative-recreates-each-unreal-calibration-scene-exactly.md","r":"develop","s":"PARTIAL","d":11,"n":15,"f":"open"},"647":{"t":"A visual judge calibrates GI against the Unreal scenes until parity","p":"docs/PRDs/done/PRD-647-a-visual-judge-calibrates-gi-against-the-unreal-scenes-until-parity.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"649":{"t":"Shadow calibration scenes are built in Unreal and judged until parity","p":"docs/PRDs/Unreal/shadows/PRD-649-shadow-calibration-scenes-are-built-in-unreal-and-judged-until-parity.md","r":"develop","s":"PARTIAL","d":3,"n":12,"f":"open"},"650":{"t":"A lamp's shadow is as sharp as the pixels it covers, and softens with the lamp's size","p":"docs/PRDs/done/PRD-650-a-lamp-shadow-is-as-sharp-as-the-pixels-it-covers-and-softens-with-the-lamp-size.md","r":"develop","s":"DONE","d":12,"n":12,"f":"done"},"651":{"t":"Leaves cast leaf shadows that sway near the camera","p":"docs/PRDs/Unreal/shadows/PRD-651-leaves-cast-leaf-shadows-that-sway-near-the-camera.md","r":"develop","s":"IN PROGRESS","d":7,"n":12,"f":"open"},"652":{"t":"The shadow tier follows Unreal's shadow quality levels","p":"docs/PRDs/Unreal/shadows/PRD-652-the-shadow-tier-follows-unreal-shadow-quality-levels.md","r":"develop","s":"IN PROGRESS","d":7,"n":11,"f":"open"},"660":{"t":"Many shadowed lamps cost one fixed budget, not one shadow map each","p":"docs/PRDs/Unreal/rendering/PRD-660-many-shadowed-lamps-cost-one-fixed-budget-not-one-shadow-map-each.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"661":{"t":"Bounce light stays clean at fewer rays by reusing the rays of neighbours and of the last frame","p":"docs/PRDs/Unreal/rendering/PRD-661-bounce-light-stays-clean-at-fewer-rays-by-reusing-neighbour-and-last-frame-rays.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"662":{"t":"With dynamic GI off, the sky light stops at nearby walls and roofs","p":"docs/PRDs/Unreal/rendering/PRD-662-with-dynamic-gi-off-the-sky-light-stops-at-nearby-walls-and-roofs.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"663":{"t":"A fog bank sits where the game puts it, and the sun lights it","p":"docs/PRDs/Unreal/rendering/PRD-663-a-fog-bank-sits-where-the-game-puts-it.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"664":{"t":"Smoke and explosions are lit volumes, not flat sprites","p":"docs/PRDs/Unreal/vfx/PRD-664-smoke-and-explosions-are-lit-volumes-not-flat-sprites.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"665":{"t":"Clouds shade the haze and fog below them, so light shafts show through the gaps","p":"docs/PRDs/Unreal/rendering/PRD-665-clouds-shade-the-haze-and-fog-below-them.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"667":{"t":"A bright sun can flare the lens, and bloom can take any kernel, when the game asks","p":"docs/PRDs/Unreal/rendering/PRD-667-a-bright-sun-can-flare-the-lens-when-the-game-asks.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"668":{"t":"A displaced surface changes its silhouette near the camera","p":"docs/PRDs/Unreal/rendering/PRD-668-a-displaced-surface-changes-its-silhouette-near-the-camera.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"669":{"t":"Far foliage keeps its volume when its clusters become voxels","p":"docs/PRDs/Unreal/rendering/PRD-669-far-foliage-keeps-its-volume-when-its-clusters-become-voxels.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"670":{"t":"Rough surfaces reflect light the way a fresh Unreal project shades them","p":"docs/PRDs/Unreal/rendering/PRD-670-rough-surfaces-reflect-light-the-way-a-fresh-unreal-project-shades-them.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"671":{"t":"A character wears strand hair that catches light along each strand","p":"docs/PRDs/Unreal/rendering/PRD-671-a-character-wears-strand-hair-that-catches-light-along-each-strand.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"672":{"t":"Terrain shades its layers once into cached pages, not every pixel every frame","p":"docs/PRDs/Unreal/performance/PRD-672-terrain-shades-its-layers-once-into-cached-pages.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"673":{"t":"The GPU scene tests a placement only when its grid cell is in view","p":"docs/PRDs/Unreal/performance/PRD-673-the-gpu-scene-tests-a-placement-only-when-its-grid-cell-is-in-view.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"674":{"t":"An ambient particle is a function of its age and seed, and costs no simulation","p":"docs/PRDs/Unreal/vfx/PRD-674-an-ambient-particle-is-a-function-of-its-age-and-seed-and-costs-no-simulation.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"675":{"t":"A game turns a named layer of its world on and off inside one streaming budget","p":"docs/PRDs/Unreal/open-world/PRD-675-a-game-turns-a-named-layer-of-its-world-on-and-off-inside-one-streaming-budget.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"676":{"t":"A playtest capture tells an agent whether a frame pays for pixels, clusters, shadow levels or effects","p":"docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"677":{"t":"A dense glass or water mesh takes the cluster cut and blends like the source mesh","p":"docs/PRDs/Unreal/rendering/PRD-677-a-dense-glass-or-water-mesh-takes-the-cluster-cut-and-blends-like-the-source-mesh.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"678":{"t":"A dense skinned body takes the cluster cut in every view that sees it","p":"docs/PRDs/Unreal/rendering/PRD-678-a-dense-skinned-body-takes-the-cluster-cut-in-every-view-that-sees-it.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"679":{"t":"Sparks and glass in fog take the fog of their own distance and blend mode","p":"docs/PRDs/Unreal/rendering/PRD-679-sparks-and-glass-in-fog-take-the-fog-of-their-own-distance-and-blend-mode.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"682":{"t":"Flat parts of the frame shade at a coarser rate where the GPU allows it, and every other platform says why not","p":"docs/PRDs/Unreal/performance/PRD-682-flat-parts-of-the-frame-shade-at-a-coarser-rate-where-the-gpu-allows-it.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"683":{"t":"A frame skips the passes nothing reads and shares one target between passes that never overlap","p":"docs/PRDs/Unreal/performance/PRD-683-a-frame-skips-passes-nothing-reads-and-shares-targets-whose-lifetimes-do-not-overlap.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"684":{"t":"Screen probes fill the gaps at depth edges and aim their rays where the light comes from","p":"docs/PRDs/Unreal/rendering/PRD-684-screen-probes-fill-the-gaps-at-depth-edges-and-aim-their-rays-where-the-light-comes-from.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"685":{"t":"A ray the screen cannot answer reads the light that arrives from its own direction","p":"docs/PRDs/Unreal/rendering/PRD-685-a-ray-the-screen-cannot-answer-reads-the-light-that-arrives-from-its-own-direction.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"686":{"t":"A door that moves blocks and bounces GI rays where it now stands","p":"docs/PRDs/Unreal/rendering/PRD-686-a-door-that-moves-blocks-and-bounces-gi-rays-where-it-now-stands.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"687":{"t":"An agent sees what the GI rays see in a playtest capture","p":"docs/PRDs/Unreal/rendering/PRD-687-an-agent-sees-what-the-gi-rays-see-in-a-playtest-capture.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"694":{"t":"Traces and colliders see a clustered mesh at one fixed error, not at the camera cut","p":"docs/PRDs/Unreal/rendering/PRD-694-traces-and-colliders-see-a-clustered-mesh-at-one-fixed-error-not-at-the-camera-cut.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"695":{"t":"A clustered mesh takes its own cut in every view that draws it","p":"docs/PRDs/Unreal/rendering/PRD-695-a-clustered-mesh-takes-its-own-cut-in-every-view-that-draws-it.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"696":{"t":"Tiny triangles of a clustered shadow caster rasterize in compute","p":"docs/PRDs/Unreal/performance/PRD-696-tiny-triangles-of-a-clustered-shadow-caster-rasterize-in-compute.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"704":{"t":"A spark streaks along its own motion, and a far spark does not flicker","p":"docs/PRDs/Unreal/vfx/PRD-704-a-spark-streaks-along-its-own-motion-and-a-far-spark-does-not-flicker.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"705":{"t":"A sword swing leaves one connected trail, not a cloud of dashes","p":"docs/PRDs/Unreal/vfx/PRD-705-a-sword-swing-leaves-one-connected-trail-not-a-cloud-of-dashes.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"706":{"t":"An effect starts on the skin of a moving character","p":"docs/PRDs/Unreal/vfx/PRD-706-an-effect-starts-on-the-skin-of-a-moving-character.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"714":{"t":"The sun shadow keeps its texel size per screen pixel when the camera zooms or the window resizes","p":"docs/PRDs/Unreal/rendering/PRD-714-the-sun-shadow-keeps-one-texel-per-screen-pixel-when-the-camera-zooms-or-the-window-resizes.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"715":{"t":"A shadow lookup stops sampling where no edge is near and starts clear of its own surface","p":"docs/PRDs/Unreal/rendering/PRD-715-a-shadow-lookup-stops-sampling-where-no-edge-is-near-and-starts-clear-of-its-own-surface.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"716":{"t":"A shadow caster that no visible surface receives, or another caster hides, skips its draw","p":"docs/PRDs/Unreal/rendering/PRD-716-a-shadow-caster-that-no-visible-surface-receives-or-another-caster-hides-skips-its-draw.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"717":{"t":"A pixel a moving object just uncovered reprojects through the surface that left it","p":"docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"718":{"t":"A surface that returns to view takes back the detail the history held before it left","p":"docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"719":{"t":"History rejection judges a color change by whether the display can show it","p":"docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"720":{"t":"The temporal resolve reads a wider input where history fails, and the tier sets how wide","p":"docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"741":{"t":"The owned engine shades fewer pixels and its temporal resolve writes the display frame","p":"docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md","r":"develop","s":"DONE","d":10,"n":10,"f":"done"},"773":{"t":"Virtual shadow pages draw only the instances that touch them","p":"docs/PRDs/performance/stress-lab-2026-10-30/PRD-773-vsm-per-page-instance-culling.md","r":"develop","s":"PARTIAL","d":4,"n":6,"f":"open"},"775":{"t":"Virtual shadow pages are managed and drawn on the GPU, as in Unreal","p":"docs/PRDs/Unreal/shadows/PRD-775-virtual-shadow-pages-are-managed-and-drawn-on-the-gpu.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"785":{"t":"A dense clustered mesh shades each pixel once, when a measured visibility buffer pays for it","p":"docs/PRDs/Unreal/rendering/PRD-785-a-dense-clustered-mesh-shades-each-pixel-once-when-a-measured-visibility-buffer-pays-for-it.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"786":{"t":"A cluster that its material moves stays culled and cut by the bounds it really covers","p":"docs/PRDs/Unreal/rendering/PRD-786-a-cluster-that-its-material-moves-stays-culled-and-cut-by-the-bounds-it-really-covers.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"787":{"t":"Hardware ray tracing of a clustered mesh returns when its transport beats the software trace","p":"docs/PRDs/Unreal/rendering/PRD-787-hardware-ray-tracing-of-a-clustered-mesh-returns-when-its-transport-beats-the-software-trace.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"788":{"t":"A clustered scene reports when it drops clusters and counts what it draws per LOD band","p":"docs/PRDs/Unreal/rendering/PRD-788-a-clustered-scene-reports-when-it-drops-clusters-and-counts-what-it-draws-per-lod-band.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"VQ-03":{"t":"Off-screen specular reflections come from a complete local probe","p":"docs/PRDs/rendering/PRD-VQ-03-local-reflection-probes.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-08":{"t":"Clouds and their ground attenuation share one authored field","p":"docs/PRDs/Unreal/rendering/PRD-VQ-08-clouds-and-overhead-transmittance.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-09":{"t":"A door that opens and a lamp that switches change the bounce light","p":"docs/PRDs/rendering/PRD-VQ-09-incremental-diffuse-relighting.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"VQ-10":{"t":"Texture residency releases real GPU memory rather than only biasing sampling","p":"docs/PRDs/Unreal/performance/PRD-VQ-10-texture-mip-residency.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"VQ-12":{"t":"Overlapping transparent effects have an explicit correct or bounded-approximate path","p":"docs/PRDs/rendering/PRD-VQ-12-transparent-effects-composition.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"VQ-13":{"t":"Depth of field and motion blur are qualified optional stages, not a second camera pipeline","p":"docs/PRDs/rendering/PRD-VQ-13-cinematic-stage-integration.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-15":{"t":"Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers","p":"docs/PRDs/rendering/PRD-VQ-15-hair-card-material-qualification.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"}}; +const PRDS = {"244":{"t":"The scene's BVH reaches the GPU","p":"docs/PRDs/done/PRD-244-the-scenes-bvh-reaches-the-gpu.md","r":"develop","s":"","d":0,"n":15,"f":"done"},"269":{"t":"motion vectors for skinned and instanced geometry, or the temporal filters lie","p":"docs/PRDs/done/PRD-269-motion-vectors-or-the-temporal-filters-lie.md","r":"develop","s":"DONE","d":13,"n":13,"f":"done"},"281":{"t":"a dense mesh bakes to a crack-free cluster DAG","p":"docs/PRDs/done/nanite-like/PRD-281-a-dense-mesh-bakes-to-a-crack-free-cluster-dag.md","r":"develop","s":"","d":8,"n":8,"f":"done"},"282":{"t":"the cut is chosen on the CPU first","p":"docs/PRDs/done/nanite-like/PRD-282-the-cut-is-chosen-on-the-cpu-first.md","r":"develop","s":"","d":8,"n":8,"f":"done"},"284":{"t":"the frame does not draw what the frame already hid","p":"docs/PRDs/done/nanite-like/PRD-284-the-frame-does-not-draw-what-the-frame-already-hid.md","r":"develop","s":"","d":0,"n":4,"f":"done"},"285":{"t":"clusters arrive when the camera asks for them","p":"docs/PRDs/done/nanite-like/PRD-285-clusters-arrive-when-the-camera-asks-for-them.md","r":"develop","s":"","d":0,"n":5,"f":"done"},"316":{"t":"forty-six VFX are generated render source, not an engine inside the engine","p":"docs/PRDs/Unreal/vfx/PRD-316-forty-six-vfx-are-generated-render-source-not-an-engine-inside-the-engine.md","r":"develop","s":"PROPOSED","d":0,"n":39,"f":"open"},"384":{"t":"Adaptive resolution must distinguish host delay from GPU cost","p":"docs/PRDs/done/PRD-384-adaptive-resolution-gpu-headroom.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"455":{"t":"Fewer rendered pixels reconstruct into a stable full-resolution frame","p":"docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md","r":"develop","s":"DONE","d":7,"n":7,"f":"done"},"456":{"t":"Distant world cells stay visible after their detailed cells unload","p":"docs/PRDs/open-world/PRD-456-distant-world-cell-proxies.md","r":"develop","s":"PROPOSED","d":0,"n":6,"f":"open"},"457":{"t":"Virtual shadows page only when pages are cheaper than cached clip levels","p":"docs/PRDs/Unreal/rendering/PRD-457-virtual-shadows-scale-by-measurement.md","r":"develop","s":"PROPOSED","d":0,"n":5,"f":"open"},"459":{"t":"Smooth streaming: one admission budget per frame, prefetched ahead of the camera","p":"docs/PRDs/open-world/PRD-459-smooth-streaming-one-admission-budget-per-frame.md","r":"develop","s":"PARTIAL","d":4,"n":16,"f":"open"},"460":{"t":"Invisible streaming: a dithered fade for arriving props and a crossfade across LOD levels","p":"docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md","r":"develop","s":"NOT STARTED","d":0,"n":15,"f":"open"},"486":{"t":"Characters get a LOD chain","p":"docs/PRDs/open-world/PRD-486-characters-get-a-lod-chain.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"488":{"t":"Foliage moves in the wind","p":"docs/PRDs/open-world/PRD-488-foliage-moves-in-the-wind.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"489":{"t":"Occlusion culling re-tested on the GPU scene","p":"docs/PRDs/done/PRD-489-gpu-scene-occlusion-culling.md","r":"develop","s":"CLOSED","d":2,"n":8,"f":"done"},"492":{"t":"Colour grading and film grain","p":"docs/PRDs/done/PRD-492-colour-grading-and-film-grain.md","r":"develop","s":"DONE","d":6,"n":6,"f":"done"},"493":{"t":"Terrain layers past sixteen textures","p":"docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md","r":"develop","s":"PARTIAL","d":6,"n":8,"f":"open"},"523":{"t":"The render graph owns passes and history (N14a)","p":"docs/PRDs/native-engine/N14-native-render-chain-and-advanced-visuals/PRD-523-n14a-the-render-graph-owns-passes-and-history.md","r":"develop","s":"PROPOSED","d":0,"n":7,"f":"open"},"524":{"t":"Virtual shadows run native (N14b)","p":"docs/PRDs/native-engine/N14-native-render-chain-and-advanced-visuals/PRD-524-n14b-virtual-shadows-run-native.md","r":"develop","s":"PROPOSED","d":0,"n":5,"f":"open"},"538":{"t":"Shadow maps redraw only what streamed in","p":"docs/PRDs/done/PRD-538-shadow-maps-redraw-only-what-streamed-in.md","r":"develop","s":"DONE","d":4,"n":4,"f":"done"},"539":{"t":"A lower internal raster reconstructs into a stable full-resolution frame","p":"docs/PRDs/done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md","r":"develop","s":"DONE","d":10,"n":10,"f":"done"},"560":{"t":"Height fog follows the ground, and the sun shows through it","p":"docs/PRDs/done/PRD-560-height-fog-follows-the-ground.md","r":"develop","s":"LOCAL PHASES COMPLETE","d":11,"n":11,"f":"done"},"561":{"t":"Small things touch the ground: contact shadows from screen depth","p":"docs/PRDs/done/PRD-561-contact-shadows-from-screen-depth.md","r":"develop","s":"DECLINED","d":1,"n":7,"f":"done"},"562":{"t":"Distant land takes the colour of the sky it is seen through","p":"docs/PRDs/Unreal/rendering/PRD-562-aerial-perspective-from-a-froxel-volume.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"563":{"t":"The first frame starts at the tier the GPU family can hold","p":"docs/PRDs/done/PRD-563-the-first-frame-starts-at-the-tier-the-gpu-family-holds.md","r":"develop","s":"COMPLETE","d":15,"n":15,"f":"done"},"564":{"t":"The resolution scale changes without reallocating a render target","p":"docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md","r":"develop","s":"DONE","d":7,"n":7,"f":"done"},"565":{"t":"Multisampled targets stay in tile memory on the Android host","p":"docs/PRDs/done/PRD-565-multisampled-targets-stay-in-tile-memory-on-android.md","r":"develop","s":"COMPLETE","d":4,"n":4,"f":"done"},"566":{"t":"Terrain LOD morphs on the GPU and picks its level by screen error","p":"docs/PRDs/done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"567":{"t":"Terrain layers blend by their own height, and the game owns the curve","p":"docs/PRDs/done/PRD-567-terrain-layers-blend-by-their-own-height.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"568":{"t":"Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8","p":"docs/PRDs/done/PRD-568-mobile-builds-ship-gpu-ready-astc.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"569":{"t":"GPU particle emitters out of view stop costing, and effects yield to the frame budget","p":"docs/PRDs/done/PRD-569-gpu-particle-emitters-cull-and-yield-to-the-frame-budget.md","r":"develop","s":"DONE","d":12,"n":12,"f":"done"},"570":{"t":"A character costs what the camera sees of it","p":"docs/PRDs/done/PRD-570-a-character-costs-what-the-camera-sees-of-it.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"571":{"t":"Exposure meters a histogram and ignores the sun","p":"docs/PRDs/BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md","r":"develop","s":"BLOCKED","d":8,"n":8,"f":"blocked"},"572":{"t":"Shadow casters sort themselves into static and moving","p":"docs/PRDs/done/PRD-572-shadow-casters-sort-themselves-into-static-and-moving.md","r":"develop","s":"DONE","d":9,"n":9,"f":"done"},"600":{"t":"A clustered mesh submits only the clusters the view holds","p":"docs/PRDs/Unreal/rendering/PRD-600-a-clustered-mesh-submits-only-the-clusters-the-view-holds.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"601":{"t":"Far clusters keep the shading of the source mesh","p":"docs/PRDs/Unreal/rendering/PRD-601-far-clusters-keep-the-shading-of-the-source-mesh.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"602":{"t":"A clustered shadow caster draws what its shadow texel resolves","p":"docs/PRDs/Unreal/rendering/PRD-602-a-clustered-shadow-caster-draws-what-its-shadow-texel-resolves.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"603":{"t":"Probes refresh by tracing the packed scene, not by drawing it six times","p":"docs/PRDs/Unreal/rendering/PRD-603-probes-refresh-by-tracing-the-packed-scene-not-by-drawing-it-six-times.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"604":{"t":"A surface is lit only by probes that can see it","p":"docs/PRDs/Unreal/rendering/PRD-604-a-surface-is-lit-only-by-probes-that-can-see-it.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"605":{"t":"The probe field follows the camera in nested rings","p":"docs/PRDs/Unreal/rendering/PRD-605-the-probe-field-follows-the-camera-in-nested-rings.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"606":{"t":"A fast-travel destination is ready before the cover lifts","p":"docs/PRDs/Unreal/open-world/PRD-606-a-fast-travel-destination-is-ready-before-the-cover-lifts.md","r":"develop","s":"NOT STARTED","d":0,"n":13,"f":"open"},"607":{"t":"Streaming reports when a hole is near and spends more to close it","p":"docs/PRDs/Unreal/open-world/PRD-607-streaming-reports-when-a-hole-is-near-and-spends-more-to-close-it.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"609":{"t":"A GPU burst starts where and when it is asked, and live particles stay put","p":"docs/PRDs/done/PRD-609-a-gpu-burst-starts-where-and-when-it-is-asked-and-live-particles-stay-put.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"610":{"t":"The native engine wins on real games","p":"docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md","r":"develop","s":"PARTIAL","d":3,"n":10,"f":"open"},"611":{"t":"The Linux HUD overlay keeps up with the game and stops burning a core","p":"docs/PRDs/performance/PRD-611-hud-overlay-latency-and-cpu.md","r":"develop","s":"PARTIAL","d":7,"n":9,"f":"open"},"631":{"t":"An Unreal reference scene measures the default look","p":"docs/PRDs/done/PRD-631-an-unreal-reference-scene-measures-the-default-look.md","r":"develop","s":"IN PROGRESS","d":15,"n":15,"f":"done"},"632":{"t":"The frame is exposed and tone-mapped the way a fresh Unreal project is","p":"docs/PRDs/Unreal/look/PRD-632-the-frame-is-exposed-and-tone-mapped-the-way-a-fresh-unreal-project-is.md","r":"develop","s":"IN PROGRESS","d":17,"n":25,"f":"open"},"633":{"t":"The sky is an atmosphere, and it lights the scene","p":"docs/PRDs/Unreal/look/PRD-633-the-sky-is-an-atmosphere-and-it-lights-the-scene.md","r":"develop","s":"PARTIAL","d":2,"n":12,"f":"open"},"634":{"t":"Sun shadows reach the horizon and soften with distance from the caster","p":"docs/PRDs/Unreal/look/PRD-634-sun-shadows-reach-the-horizon-and-soften-with-distance-from-the-caster.md","r":"develop","s":"PARTIAL","d":11,"n":18,"f":"open"},"635":{"t":"Ambient occlusion darkens only the light that comes from around","p":"docs/PRDs/Unreal/look/PRD-635-ambient-occlusion-darkens-only-the-light-that-comes-from-around.md","r":"develop","s":"IN PROGRESS","d":8,"n":17,"f":"open"},"636":{"t":"The finishing post chain matches Unreal's defaults","p":"docs/PRDs/Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md","r":"develop","s":"PARTIAL","d":18,"n":29,"f":"open"},"639":{"t":"A Surface Cache gives traced rays the texture, glow and lights of what they hit","p":"docs/PRDs/Unreal/gi/PRD-639-a-surface-cache-gives-traced-rays-the-texture-glow-and-lights-of-what-they-hit.md","r":"develop","s":"IN PROGRESS","d":6,"n":9,"f":"open"},"640":{"t":"Pixels gather bounce light from screen probes, and the radiance cache answers the rest","p":"docs/PRDs/Unreal/gi/PRD-640-pixels-gather-bounce-light-from-screen-probes-and-the-radiance-cache-answers-the-rest.md","r":"develop","s":"PARTIAL","d":2,"n":9,"f":"open"},"641":{"t":"Bounce light holds still while the camera moves and follows a light that changes","p":"docs/PRDs/Unreal/gi/PRD-641-bounce-light-holds-still-while-the-camera-moves-and-follows-a-light-that-changes.md","r":"develop","s":"IN PROGRESS","d":5,"n":14,"f":"open"},"642":{"t":"Glossy surfaces reflect what the camera cannot see","p":"docs/PRDs/Unreal/gi/PRD-642-glossy-surfaces-reflect-what-the-camera-cannot-see.md","r":"develop","s":"PARTIAL","d":6,"n":11,"f":"open"},"643":{"t":"Glass, fog and leaves take the bounce light too","p":"docs/PRDs/Unreal/gi/PRD-643-glass-fog-and-leaves-take-the-bounce-light-too.md","r":"develop","s":"IN PROGRESS","d":6,"n":11,"f":"open"},"644":{"t":"The GI tier follows Unreal's quality levels and the measured GPU time","p":"docs/PRDs/Unreal/gi/PRD-644-the-gi-tier-follows-unreal-quality-levels-and-the-measured-gpu-time.md","r":"develop","s":"IN PROGRESS","d":9,"n":12,"f":"open"},"645":{"t":"A desktop GPU with ray tracing traces GI rays in hardware","p":"docs/PRDs/done/PRD-645-a-desktop-gpu-with-ray-tracing-traces-gi-rays-in-hardware.md","r":"develop","s":"DONE","d":4,"n":4,"f":"done"},"646":{"t":"ThreeNative recreates each Unreal calibration scene exactly","p":"docs/PRDs/Unreal/gi/PRD-646-threenative-recreates-each-unreal-calibration-scene-exactly.md","r":"develop","s":"PARTIAL","d":11,"n":15,"f":"open"},"647":{"t":"A visual judge calibrates GI against the Unreal scenes until parity","p":"docs/PRDs/done/PRD-647-a-visual-judge-calibrates-gi-against-the-unreal-scenes-until-parity.md","r":"develop","s":"DONE","d":8,"n":8,"f":"done"},"649":{"t":"Shadow calibration scenes are built in Unreal and judged until parity","p":"docs/PRDs/Unreal/shadows/PRD-649-shadow-calibration-scenes-are-built-in-unreal-and-judged-until-parity.md","r":"develop","s":"PARTIAL","d":3,"n":12,"f":"open"},"650":{"t":"A lamp's shadow is as sharp as the pixels it covers, and softens with the lamp's size","p":"docs/PRDs/done/PRD-650-a-lamp-shadow-is-as-sharp-as-the-pixels-it-covers-and-softens-with-the-lamp-size.md","r":"develop","s":"DONE","d":12,"n":12,"f":"done"},"651":{"t":"Leaves cast leaf shadows that sway near the camera","p":"docs/PRDs/Unreal/shadows/PRD-651-leaves-cast-leaf-shadows-that-sway-near-the-camera.md","r":"develop","s":"IN PROGRESS","d":7,"n":12,"f":"open"},"652":{"t":"The shadow tier follows Unreal's shadow quality levels","p":"docs/PRDs/Unreal/shadows/PRD-652-the-shadow-tier-follows-unreal-shadow-quality-levels.md","r":"develop","s":"IN PROGRESS","d":7,"n":11,"f":"open"},"660":{"t":"Many shadowed lamps cost one fixed budget, not one shadow map each","p":"docs/PRDs/Unreal/rendering/PRD-660-many-shadowed-lamps-cost-one-fixed-budget-not-one-shadow-map-each.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"661":{"t":"Bounce light stays clean at fewer rays by reusing the rays of neighbours and of the last frame","p":"docs/PRDs/Unreal/rendering/PRD-661-bounce-light-stays-clean-at-fewer-rays-by-reusing-neighbour-and-last-frame-rays.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"662":{"t":"With dynamic GI off, the sky light stops at nearby walls and roofs","p":"docs/PRDs/Unreal/rendering/PRD-662-with-dynamic-gi-off-the-sky-light-stops-at-nearby-walls-and-roofs.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"663":{"t":"A fog bank sits where the game puts it, and the sun lights it","p":"docs/PRDs/Unreal/rendering/PRD-663-a-fog-bank-sits-where-the-game-puts-it.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"664":{"t":"Smoke and explosions are lit volumes, not flat sprites","p":"docs/PRDs/Unreal/vfx/PRD-664-smoke-and-explosions-are-lit-volumes-not-flat-sprites.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"665":{"t":"Clouds shade the haze and fog below them, so light shafts show through the gaps","p":"docs/PRDs/Unreal/rendering/PRD-665-clouds-shade-the-haze-and-fog-below-them.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"667":{"t":"A bright sun can flare the lens, and bloom can take any kernel, when the game asks","p":"docs/PRDs/Unreal/rendering/PRD-667-a-bright-sun-can-flare-the-lens-when-the-game-asks.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"668":{"t":"A displaced surface changes its silhouette near the camera","p":"docs/PRDs/Unreal/rendering/PRD-668-a-displaced-surface-changes-its-silhouette-near-the-camera.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"669":{"t":"Far foliage keeps its volume when its clusters become voxels","p":"docs/PRDs/Unreal/rendering/PRD-669-far-foliage-keeps-its-volume-when-its-clusters-become-voxels.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"670":{"t":"Rough surfaces reflect light the way a fresh Unreal project shades them","p":"docs/PRDs/Unreal/rendering/PRD-670-rough-surfaces-reflect-light-the-way-a-fresh-unreal-project-shades-them.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"671":{"t":"A character wears strand hair that catches light along each strand","p":"docs/PRDs/Unreal/rendering/PRD-671-a-character-wears-strand-hair-that-catches-light-along-each-strand.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"672":{"t":"Terrain shades its layers once into cached pages, not every pixel every frame","p":"docs/PRDs/Unreal/performance/PRD-672-terrain-shades-its-layers-once-into-cached-pages.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"673":{"t":"The GPU scene tests a placement only when its grid cell is in view","p":"docs/PRDs/Unreal/performance/PRD-673-the-gpu-scene-tests-a-placement-only-when-its-grid-cell-is-in-view.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"674":{"t":"An ambient particle is a function of its age and seed, and costs no simulation","p":"docs/PRDs/Unreal/vfx/PRD-674-an-ambient-particle-is-a-function-of-its-age-and-seed-and-costs-no-simulation.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"675":{"t":"A game turns a named layer of its world on and off inside one streaming budget","p":"docs/PRDs/Unreal/open-world/PRD-675-a-game-turns-a-named-layer-of-its-world-on-and-off-inside-one-streaming-budget.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"676":{"t":"A playtest capture tells an agent whether a frame pays for pixels, clusters, shadow levels or effects","p":"docs/PRDs/Unreal/performance/PRD-676-a-playtest-capture-tells-an-agent-whether-a-frame-pays-for-pixels-clusters-shadow-levels-or-effects.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"677":{"t":"A dense glass or water mesh takes the cluster cut and blends like the source mesh","p":"docs/PRDs/Unreal/rendering/PRD-677-a-dense-glass-or-water-mesh-takes-the-cluster-cut-and-blends-like-the-source-mesh.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"678":{"t":"A dense skinned body takes the cluster cut in every view that sees it","p":"docs/PRDs/Unreal/rendering/PRD-678-a-dense-skinned-body-takes-the-cluster-cut-in-every-view-that-sees-it.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"679":{"t":"Sparks and glass in fog take the fog of their own distance and blend mode","p":"docs/PRDs/Unreal/rendering/PRD-679-sparks-and-glass-in-fog-take-the-fog-of-their-own-distance-and-blend-mode.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"682":{"t":"Flat parts of the frame shade at a coarser rate where the GPU allows it, and every other platform says why not","p":"docs/PRDs/Unreal/performance/PRD-682-flat-parts-of-the-frame-shade-at-a-coarser-rate-where-the-gpu-allows-it.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"683":{"t":"A frame skips the passes nothing reads and shares one target between passes that never overlap","p":"docs/PRDs/Unreal/performance/PRD-683-a-frame-skips-passes-nothing-reads-and-shares-targets-whose-lifetimes-do-not-overlap.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"684":{"t":"Screen probes fill the gaps at depth edges and aim their rays where the light comes from","p":"docs/PRDs/Unreal/rendering/PRD-684-screen-probes-fill-the-gaps-at-depth-edges-and-aim-their-rays-where-the-light-comes-from.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"685":{"t":"A ray the screen cannot answer reads the light that arrives from its own direction","p":"docs/PRDs/Unreal/rendering/PRD-685-a-ray-the-screen-cannot-answer-reads-the-light-that-arrives-from-its-own-direction.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"686":{"t":"A door that moves blocks and bounces GI rays where it now stands","p":"docs/PRDs/Unreal/rendering/PRD-686-a-door-that-moves-blocks-and-bounces-gi-rays-where-it-now-stands.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"687":{"t":"An agent sees what the GI rays see in a playtest capture","p":"docs/PRDs/Unreal/rendering/PRD-687-an-agent-sees-what-the-gi-rays-see-in-a-playtest-capture.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"694":{"t":"Traces and colliders see a clustered mesh at one fixed error, not at the camera cut","p":"docs/PRDs/Unreal/rendering/PRD-694-traces-and-colliders-see-a-clustered-mesh-at-one-fixed-error-not-at-the-camera-cut.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"695":{"t":"A clustered mesh takes its own cut in every view that draws it","p":"docs/PRDs/Unreal/rendering/PRD-695-a-clustered-mesh-takes-its-own-cut-in-every-view-that-draws-it.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"696":{"t":"Tiny triangles of a clustered shadow caster rasterize in compute","p":"docs/PRDs/Unreal/performance/PRD-696-tiny-triangles-of-a-clustered-shadow-caster-rasterize-in-compute.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"704":{"t":"A spark streaks along its own motion, and a far spark does not flicker","p":"docs/PRDs/Unreal/vfx/PRD-704-a-spark-streaks-along-its-own-motion-and-a-far-spark-does-not-flicker.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"705":{"t":"A sword swing leaves one connected trail, not a cloud of dashes","p":"docs/PRDs/Unreal/vfx/PRD-705-a-sword-swing-leaves-one-connected-trail-not-a-cloud-of-dashes.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"706":{"t":"An effect starts on the skin of a moving character","p":"docs/PRDs/Unreal/vfx/PRD-706-an-effect-starts-on-the-skin-of-a-moving-character.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"714":{"t":"The sun shadow keeps its texel size per screen pixel when the camera zooms or the window resizes","p":"docs/PRDs/Unreal/rendering/PRD-714-the-sun-shadow-keeps-one-texel-per-screen-pixel-when-the-camera-zooms-or-the-window-resizes.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"715":{"t":"A shadow lookup stops sampling where no edge is near and starts clear of its own surface","p":"docs/PRDs/Unreal/rendering/PRD-715-a-shadow-lookup-stops-sampling-where-no-edge-is-near-and-starts-clear-of-its-own-surface.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"716":{"t":"A shadow caster that no visible surface receives, or another caster hides, skips its draw","p":"docs/PRDs/Unreal/rendering/PRD-716-a-shadow-caster-that-no-visible-surface-receives-or-another-caster-hides-skips-its-draw.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"717":{"t":"A pixel a moving object just uncovered reprojects through the surface that left it","p":"docs/PRDs/Unreal/performance/PRD-717-a-pixel-a-moving-object-just-uncovered-reprojects-through-the-surface-that-left-it.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"718":{"t":"A surface that returns to view takes back the detail the history held before it left","p":"docs/PRDs/Unreal/performance/PRD-718-a-surface-that-returns-to-view-takes-back-the-detail-the-history-held-before-it-left.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"719":{"t":"History rejection judges a color change by whether the display can show it","p":"docs/PRDs/Unreal/performance/PRD-719-history-rejection-judges-a-color-change-by-whether-the-display-can-show-it.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"720":{"t":"The temporal resolve reads a wider input where history fails, and the tier sets how wide","p":"docs/PRDs/Unreal/performance/PRD-720-the-temporal-resolve-reads-a-wider-input-where-history-fails-and-the-tier-sets-how-wide.md","r":"develop","s":"NOT STARTED","d":0,"n":7,"f":"open"},"741":{"t":"The owned engine shades fewer pixels and its temporal resolve writes the display frame","p":"docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md","r":"develop","s":"DONE","d":10,"n":10,"f":"done"},"773":{"t":"Virtual shadow pages draw only the instances that touch them","p":"docs/PRDs/performance/stress-lab-2026-10-30/PRD-773-vsm-per-page-instance-culling.md","r":"develop","s":"PARTIAL","d":4,"n":6,"f":"open"},"775":{"t":"Virtual shadow pages are managed and drawn on the GPU, as in Unreal","p":"docs/PRDs/Unreal/shadows/PRD-775-virtual-shadow-pages-are-managed-and-drawn-on-the-gpu.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"785":{"t":"A dense clustered mesh shades each pixel once, when a measured visibility buffer pays for it","p":"docs/PRDs/Unreal/rendering/PRD-785-a-dense-clustered-mesh-shades-each-pixel-once-when-a-measured-visibility-buffer-pays-for-it.md","r":"develop","s":"NOT STARTED","d":0,"n":11,"f":"open"},"786":{"t":"A cluster that its material moves stays culled and cut by the bounds it really covers","p":"docs/PRDs/Unreal/rendering/PRD-786-a-cluster-that-its-material-moves-stays-culled-and-cut-by-the-bounds-it-really-covers.md","r":"develop","s":"NOT STARTED","d":0,"n":10,"f":"open"},"787":{"t":"Hardware ray tracing of a clustered mesh returns when its transport beats the software trace","p":"docs/PRDs/Unreal/rendering/PRD-787-hardware-ray-tracing-of-a-clustered-mesh-returns-when-its-transport-beats-the-software-trace.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"788":{"t":"A clustered scene reports when it drops clusters and counts what it draws per LOD band","p":"docs/PRDs/Unreal/rendering/PRD-788-a-clustered-scene-reports-when-it-drops-clusters-and-counts-what-it-draws-per-lod-band.md","r":"develop","s":"NOT STARTED","d":0,"n":9,"f":"open"},"VQ-03":{"t":"Off-screen specular reflections come from a complete local probe","p":"docs/PRDs/rendering/PRD-VQ-03-local-reflection-probes.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-08":{"t":"Clouds and their ground attenuation share one authored field","p":"docs/PRDs/Unreal/rendering/PRD-VQ-08-clouds-and-overhead-transmittance.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-09":{"t":"A door that opens and a lamp that switches change the bounce light","p":"docs/PRDs/rendering/PRD-VQ-09-incremental-diffuse-relighting.md","r":"develop","s":"NOT STARTED","d":0,"n":8,"f":"open"},"VQ-10":{"t":"Texture residency releases real GPU memory rather than only biasing sampling","p":"docs/PRDs/Unreal/performance/PRD-VQ-10-texture-mip-residency.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"VQ-12":{"t":"Overlapping transparent effects have an explicit correct or bounded-approximate path","p":"docs/PRDs/rendering/PRD-VQ-12-transparent-effects-composition.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"},"VQ-13":{"t":"Depth of field and motion blur are qualified optional stages, not a second camera pipeline","p":"docs/PRDs/rendering/PRD-VQ-13-cinematic-stage-integration.md","r":"develop","s":"PROPOSED","d":0,"n":9,"f":"open"},"VQ-15":{"t":"Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers","p":"docs/PRDs/rendering/PRD-VQ-15-hair-card-material-qualification.md","r":"develop","s":"PROPOSED","d":0,"n":8,"f":"open"}}; // @prds-end const fmt = n => n.toLocaleString("en-US"); const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); diff --git a/docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md b/docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md index 237d08ce37..675c10517a 100644 --- a/docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md +++ b/docs/PRDs/done/PRD-455-temporal-reconstruction-from-dynamic-resolution.md @@ -4,7 +4,7 @@ prd_contract: v1 # PRD-455 — Fewer rendered pixels reconstruct into a stable full-resolution frame -**Status:** DONE (scoped) — the opt-in full-resolution temporal provider, history-correctness lifecycle and ghosting/cost controls shipped; the low-resolution reconstruction quality, frame-time and scaler criteria moved to [PRD-539](../Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (2026-10-07). +**Status:** DONE (scoped) — the opt-in full-resolution temporal provider, history-correctness lifecycle and ghosting/cost controls shipped; the low-resolution reconstruction quality, frame-time and scaler criteria moved to [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (2026-10-07). **Priority:** P1 — closed as scoped; the unmet reconstruction work continues in PRD-539. **Complexity:** 8 → HIGH. The renderer already has the difficult prerequisites; the remaining risk is history correctness and proving reconstruction wins more GPU time than it costs. **Depends on:** the landed motion-history implementation from PRD-269 (`packages/core/src/render/velocity.ts`, commit `3630847a`), its pending canonical [PR393 repair](https://github.com/ThreeNativeHQ/threenative/pull/393) at `47e188e41601a64decb4fe0f580037546d63b543`, the existing `RenderChain`, and [PRD-384 adaptive resolution](PRD-384-adaptive-resolution-gpu-headroom.md). diff --git a/docs/PRDs/done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md b/docs/PRDs/done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md new file mode 100644 index 0000000000..756e84a75b --- /dev/null +++ b/docs/PRDs/done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md @@ -0,0 +1,206 @@ +--- +prd_contract: v1 +--- + +# PRD-539 — A lower internal raster reconstructs into a stable full-resolution frame + +**Status:** DONE 2026-11-03 — Phase 1 done (gate run 12, low-input bar = Unreal TSR on the same scene by owner decision). Phase 2: the shooter at TRAA 0.667 lowers GPU p50 -40% to -47% and p95 on web and desktop native; on a textured scene built in both engines, TRAA 67% loses detail EQUAL to Unreal TSR 67% (judge 5 PASS, anisotropy matched to Unreal Epic). TRAA stays opt-in. Successor to [PRD-455](PRD-455-temporal-reconstruction-from-dynamic-resolution.md). +**Priority:** P1 — Carried over unchanged from PRD-455, whose own note ranked it the highest-value rendering project; the owner may lower it. +**Complexity:** 8 → HIGH. Five candidates failed the same gate on hardware; the next one is a new design, not a parameter change. +**Numbering:** filed in #398 as PRD-537; #464 took that number for the far-hills PRD first, so this PRD is PRD-539 (2026-10-08). +**Depends on:** PRD-455 (opt-in provider, lifecycle, fixture and 31-arm corpus) and PRD-269 (motion history). + +## Problem + +PRD-455 shipped full-resolution temporal AA as opt-in generated source and the corpus that judges reconstruction. Every low-resolution candidate then failed the unchanged gate on nvidia/turing hardware WebGPU: + +| Evidence (2026-10-07) | Result | +| --- | --- | +| Full-res edge, moving camera | `.05849` against no-AA `.05029`, needs `<.04778` | +| Full-res edge, static camera | `.04847` against `.05245`, a 7.6% gain, still `.0007` short | +| Low-input edge | `.08062` against spatial `.07745`, needs `<.04687` (static: `.08132` against `.06930`) | +| Reveal stale after +1 frame | motion `1.77%`, quality `4.89%`, needs `<=1%` | +| Variance-clip gamma `[0.75,1.25]`, `[1,1.5]` | edge `.05822`, `.05803`; no check moves | + +Localization: accumulation works when the camera is static, and sub-pixel camera drift costs about `.010` of edge error through reprojection and resampling. Clipping, the reactive mask and the raw4 current-footprint candidate are not the lever. A 426×240 input does not recover native-resolution edge accuracy in this metric. + +## Unreal TSR levers not yet tried (UE 5.8.3, read 2026-10-09) + +Unreal's TSR attacks the same failure. Only the levers outside this PRD's "do not repeat" list are +named here. Catmull–Rom history sampling is not a new lever: TSR also uses it +(`UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRUpdateHistory.usf:952-975`), and +PRD-455 already compared it. Paths are under `Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp` +unless named otherwise. Order is by fit to the measured localization above. + +1. **History stored above output resolution.** `r.TSR.History.ScreenPercentage` (`:54-60`) keeps the + history at 100–200% of the *output* resolution. The default is 100. The scalability groups set 200 at the Epic and Cinematic tiers + (`UE 5.8.3: Engine/Config/BaseScalability.ini:89` and `:100`). + The stated reason is sampling-theorem headroom: details survive repeated reprojection without the + resampling blur that each bilinear or bicubic fetch adds. A resolve pass + (`TSRResolveHistory.usf`) filters the history down to output. This targets the `.010` drift loss + directly. The cost is 4× history memory and bandwidth, which Unreal offsets by storing history + as R11G11B10 (`:63-73`, default 1). +2. **Velocity-clamped history weight.** When an output pixel moves at 1 px/frame or more, the + history's accumulated sample count is clamped to 4 (`r.TSR.Velocity.WeightClampingSampleCount` + and `...PixelSpeed`, `:310-330`). The default history cap is 16 (`:45-50`). The clamp applies to history pixels, so it is less visible at lever 1's 200% history + (`:309-321`). Test levers 1 and 2 together. This trades a little + stability for less successive-convolution blur during motion. That is the moving-camera edge + check, which is worse than no-AA today. +3. **Spatial anti-aliasing of rejected input.** Where history is rejected, TSR runs a built-in + spatial anti-aliaser on the low-resolution input (`r.TSR.RejectionAntiAliasingQuality`, cvar default 3, set to 0 at the lowest tier and to 1 or 2 above it by + `UE 5.8.3: Engine/Config/BaseScalability.ini:59-104`, `:233-246`; `Engine/Shaders/Private/TemporalSuperResolution/TSRSpatialAntiAliasing.usf`). + This targets the low-input edge and reveal checks. The low-input arm currently loses to plain + spatial upscale. +4. **Flicker-aware shading rejection.** TSR measures luma flicker over a period of about 2 frames, + and relaxes history rejection on pixels that flicker, so the history ghosts inside a luma bound instead of resetting + (`r.TSR.ShadingRejection.Flickering`, cvar default 1, off at the Low and Medium tiers, `:137-200`, period default 2 frames at `:188`; + `TSRMeasureFlickeringLuma.usf`; the moire error in `UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRRejectShading.usf:466-500`). This is not variance-clip widening, + which is already falsified. +5. **Not recommended as a default:** thin-geometry detection (`r.TSR.ThinGeometryDetection`, `:332`) + and the reprojection field (`r.TSR.ReprojectionField`, `:290`) are **off** in the cvar defaults. + The reprojection field is on at the High tier and above through scalability (`BaseScalability.ini:82`), and thin-geometry detection turns on + with Nanite foliage. Try them only after 1–4. + +Each candidate is one arm in the unchanged gate, never a threshold change. + +## Outcome + +One reconstructor that, at a sub-1.0 internal raster, produces a stable display-resolution image during motion, lowers measured GPU/render time, and degrades safely when history cannot be trusted. If it cannot beat full resolution after its own cost, it stays opt-in. + +### Phase 1 — A reconstructor that clears the unchanged quality gate + +- [x] One-variable control for the `.010` drift loss: the same reconstructor with history at 200% against 100% of output resolution, everything else equal, reports moving- and static-camera edge error for both arms. proof: `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` run once per arm on hardware WebGPU. + Result 2026-11-02, nvidia/turing, `3edfb89c7` (static: `TN_STATIC_CAMERA=1`): `temporal` moving `.05648` / static `.04682` (drift loss `.0097`, reproduces PRD-455); `history200` moving `.04850` / static `.05001` (drift loss gone). History resampling is the drift loss. The static cost `.0032` stays. A first run that also switched a full-resolution input to the Gaussian gather read `.05525` / `.05311`, so the one-variable arm keeps the owned point sample. +- [x] A velocity-clamped history weight (Unreal's TSR keeps about 4 samples of history at 1 px/frame of motion) runs as one gate arm. proof: `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` run once per arm on hardware WebGPU. + Result 2026-11-02, same runs: `velocity-clamp` moving `.05647` / static `.04533`. With 200% history (`history200-clamp`): moving `.04566` / static `.03850`, both under the full-resolution `<.04778` bar and under 0.95× the no-AA reference (`.04616`). Static residual instability rises (`.0079` to `.0192`). Low input: `quality-history200-clamp` `.07388` beats spatial `.07514` but is far from `<.04687`. Port: `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp:3219-3224`, `Engine/Shaders/Private/TemporalSuperResolution/TSRUpdateHistory.usf:1262-1276`. + +- [x] Thin fences, foliage, sub-pixel edges, a moving character and an instanced object stay within the pinned temporal thresholds against a full-resolution reference, and the temporal arm beats the spatial arm on the named stability metric. proof: an automated frame-sequence report records edge flicker, rejected-history ratio and image delta for full-res, low-res spatial upscale and temporal reconstruction; `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` exits 0 on hardware WebGPU with the original thresholds (`edge <.04778` full-res, `<.04687` low-input). + Done 2026-11-03, gate run 12 (nvidia/turing, provider default `history200-clamp` with the Mitchell resolve): `sh scripts/xvfb.sh node --import tsx scripts/verify-temporal-motion.ts` exits 0, all 15 checks true. The full-resolution bar is unchanged (`edgeImprovement` `.04386` < `.04616`, stability `.0313` < `.0450`). The low-input edge bar is the owner's option 1 (Decisions, 2026-11-03): no worse than Unreal TSR at the same input on the same scene, which reads 1.74x its own no-AA edge error. `qualityEdgeImprovement` reads `.07320` against `< .04769 x 1.74 = .0832` (1.53x moving, 1.41x still). The temporal arm beats the spatial arm on residual instability (`.0401` against `.0489`). Bar retention (the same-scene table below) stays under Unreal's: `.36` against `.56`-`.58`, and levers 4+6 reach `.53`. History so far: gate run 3 (2026-11-02, nvidia/turing) added Unreal's alignment refine (`tsr` = `history200-clamp` + refine, `UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRUpdateHistory.usf:1106-1168`, kernel `TSRKernels.ush:438-446`): moving `.05962` / static `.04982`, low input `.08079`. All three are worse than `history200-clamp`, so the refine is rejected. Moving residual instability drops (`.0317` to `.0285`). The gate still exits 1 (`edgeImprovement`, `qualityEdgeImprovement`, `qualityRevealRecovery`). Gate run 4 (2026-11-03) added lever 3, Unreal's spatial anti-aliasing of rejected input (`history200-clamp-saa`: 8-step edge march, offset up to half an input texel on invalid-history pixels; `UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRSpatialAntiAliasing.ush:128-174,280-352,456-493`, `TSRUpdateHistory.usf:1093-1101`). Edge error does not change (moving `.04575`, static `.03859`), and reveal stale gets worse (moving full `.0024` against `.0003`, low input `.0204` against `.0177`). Rejected. Low-input reveal stale is `.014`-`.018` in every arm, so the 3x3 current-frame gather at the reveal edge is the likely cause, not the history weighting. As the provider default, `history200-clamp` would pass every moving-camera full-resolution check (edge `.04566` < `.04616`, stability, reveal, zero velocity), but it presents a 2x raster, so every later post stage would run at 4x pixels. Gate runs 6 and 7 (2026-11-03, nvidia/turing, `0d0d600fd` and the default commit) added the display-sized history resolve: a 4x4 Mitchell filter (B = C = 1/3) with the `1/(R+2G+B+4)` HDR weight, clamped to its 16 samples (`UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRResolveHistory.usf:82-83,312-357`, `TSRColorSpace.ush:167-195`). The chain now reads a display-sized raster, so later post stages stay at display pixels (red-green: presented `3840x2160` at a `1920x1080` display, then `1920x1080`). It also lowers edge error: `history200-clamp` moving `.04566` to `.04387`, `history200` `.04850` to `.04674`. The 100% control repeats `.05647`/`.05648` in both runs. With `history200-clamp` as the provider default, gate run 7 passes every full-resolution check (`edgeImprovement` `.04387` < `.04616`, stability `.0314` < `.0450`, reveal `.0003`, zero velocity, both negative controls, `authoredLinearEquivalent` against `history100`). It still exits 1 on `qualityEdgeImprovement` (`.07321` against `<.04532`) and `qualityRevealRecovery` (`.0175`). Neighbourhood overshoot rises from `.00015` to `.00094`, which is 18x under the no-AA reference (`.0171`). `verify-temporal-aa.ts` exits 0. Visual judge (Opus, read-only, 2 runs per arm, frames 24/30/36): full resolution IMPROVEMENT (cleaner fence bars and rod, no ghosting, halo or colour shift), low input NEUTRAL. Gate runs 8 and 9 (2026-11-03) added lever 4 as the opt-in `flicker` arm: Unreal's moire state (gradient, total variation, count, 0.95 decay on still pixels) widens the clip box by 3x the moire error (`TSRShadingAnalysis.ush:225-229,263-271,381-455,477-490,586-593`). Without the 5x5 flicker dilation it changed about 4200 pixels but no metric. With it: static full resolution edge `.03744` to `.03559` and instability `.0193` to `.0183`; low-input frame-to-frame change down 14%; but moving edge `.04387` to `.04492`, overshoot `.00094` to `.00193`, and low-input edge and reveal unchanged (`.07319`, `.0175`). Lever 4 stays an opt-in arm. Static-camera ceiling: the unclipped `quality-unchecked-history` arm keeps every sub-texel fence bar, while the clipped arms and spatial upscale lose them in moving bands, so the per-pixel clip to the low-input 3x3 box is what erases low-input detail. Unreal does not apply that clip where input and history agree at low frequency: it lerps the clamped history back to the unclamped one by `DisableHistoryClamp` (`TSRUpdateHistory.usf:1256-1257`), which is `min(RejectionClampBlend, VelocityEdge, Min3x3(HistoryUncertainty))` and 0 on disocclusion (`TSRRejectShading.usf:863`), with `RejectionClampBlend` from a median-filtered low-frequency comparison (`TSRShadingAnalysis.ush:374`). Left: that low-frequency clamp release, as lever 6. Gate run 10 (2026-11-03, `f1d2deb5c`) added lever 6 alone as the opt-in `release` arm: six input-raster passes (guide, input and history 3x3 blurs, the rejection with Unreal's `1/7` filtering weight and `0.5/1024` quantization, median, min with the depth validity), then the clipped history lerps back to the unclipped one by that factor (`TSRShadingAnalysis.ush:25,509-525,563-566,589-620,632-636`, `TSRUpdateHistory.usf:1256-1257`). Static full resolution edge drops 10% (`.03744` to `.03352`, open edge `.03952`); moving edge `.04402` against `.04387`, overshoot `.00094` to `.00175`, reveal `.0005`. Low input does not move: moving `.07294` against `.07321`, static `.07034` against `.07055`, reveal stale unchanged (`.0175`). Where a sub-texel bar is absent from this frame's input 3x3, the blurred input range excludes the history that holds it, so the release factor is near 0 exactly there. In Unreal the moire error widens that comparison (`TSRShadingAnalysis.ush:569-613`), so levers 4 and 6 work as one. Gate run 11 (2026-11-03) measured them together as the `flicker-release` arm: static full resolution edge `.03134` (default `.03744`, `-16%`), moving `.04436` (`+1.1%`), overshoot `.00094` to `.00285`; low input unchanged again (moving `.07289`, static `.07078`, reveal `.0484 .0175 .0024`). Probes (`TN_TEMPORAL_PROBE`, static camera, one run each, low input): the default `.07055` is worse than the spatial upscale `.06721`. A Gaussian gather 9x narrower gives `.06267`, and the same without the clip `.05758`. With the stock gather and no clip, every fence bar survives but moving objects ghost (`.07093`). Unreal's alignment weight on the current sample makes it worse (`.07827` as a plain multiplier, `.06997` as `a/(a+1)`, `.07773` with a per-pixel validity in the history alpha), because the history stalls. A debug render of the combined arm shows the moire error on every bar but the release factor on only some bar columns, so the clip still erases the rest. Next: the same fence in Unreal (`tools/unreal-reference/gi/fence/scene.py`, 640x360, TSR 66.7% against 100% and a 400% no-AA reference), so the low-input target is a measured Unreal result and not a guess. + Same-scene benchmark, 2026-11-03: the fence built in Unreal 5.8.3 (`gi/fence`, the fixture's 40 bars, block, character and markers at the fixture camera, one point light, 640x360, still camera, frames 44-51) against the TN gate's static low-input arms (frames 21-28), each scored by the gate's own edge metric against its own supersampled reference, and by bar retention (each bar's peak contrast in rows 122-126 over the reference's, 1 = kept): + + | Engine | Arm | Edge error | x own no-AA | Bar retention | Bars under 0.25 | + |---|---|---|---|---|---| + | Unreal | no AA 100% | `.0468` | 1.00 | .95 | 2/39 | + | Unreal | TSR 100% (history 200%) | `.0584` | 1.25 | .73-.77 | 0-3/39 | + | Unreal | TSR 66.7% (history 200%) | `.0816` | 1.74 | .56-.58 | 6-7/39 | + | ThreeNative | no AA 100% | `.0502` | 1.00 | 1.24 | 4/41 | + | ThreeNative | temporal 66.7% (default) | `.0706` | 1.41 | .36 | 20/41 | + | ThreeNative | levers 4+6 66.7% (`flicker-release`) | `.0708` | 1.41 | .53 | 8/41 | + | ThreeNative | spatial upscale 66.7% | `.0672` | 1.34 | .73 | 16/41 | + + Unreal TSR fails this box's low-input bar too (`edge < no-AA x 0.95`): at 66.7% it reads 1.74x its own no-AA edge error, and even at 100% it reads 1.25x. The edge metric scores a sharp aliased frame above a soft resolved one. Unreal's 66.7% frame drops bars in moving bands, as ThreeNative's does, and levers 4+6 bring ThreeNative's bar retention to Unreal's (.53 against .56-.58). The scenes differ in light (one point light against the fixture's hemisphere and sun) and in the moving objects and foliage the fixture carries, so the absolute numbers are not comparable, only each engine against its own reference. Sheet: branch `prd-539-screenshots`, `fence-unreal-vs-threenative/`. +- [x] Newly revealed surfaces do not inherit stale colour, and disabling disocclusion rejection fails the check. proof: the same command, `revealRecovery` and `qualityRevealRecovery` true (`<=1%` stale after one frame). + Done 2026-11-03, gate run 12 (nvidia/turing): `revealRecovery` true (full resolution `.0174` on the reveal frame, then `.0002` at most), `qualityRevealRecovery` true, and disabling rejection fails both (`unchecked-history` and `quality-unchecked-history` `1.0`). The low-input bar is option 1, measured on the same scene in Unreal: the fence's `reveal` run shows the fixture's occluder (0.8 m, unlit red, same 3721 revealed pixels) until frame 44, scored with a port of the gate's metric that reproduces the gate's numbers on its own frames (`.0484 .0175 .0024`). Stale fraction on frames 1-7 after the reveal: + + | Engine | Arm | Stale fraction, frames 1-7 | + |---|---|---| + | Unreal 5.8.3 | TSR 66.7% (history 200%) | `.0820 .0884 .0876 .0836 .0898 .0855 .0836` | + | Unreal 5.8.3 | TSR 100% | `.1543 .1475 .0820 .0820 .0820 .0820 .0820` | + | ThreeNative | default 66.7% | `.0174 .0024 .0016 .0024 .0166 .0016 .0013` | + + Unreal's revealed area stays dark and loses the fence bars for 15 or more frames at both scales. ThreeNative recovers within 2 frames. The gate bar is the lowest Unreal value, `.082`, on every frame after the first. Before the decision: gate run 7: `revealRecovery` is true (full resolution, max `.0003`), and disabling rejection still fails it (`quality-unchecked-history` `1.0`). Low input reads `.048 .018 .002 .002 .002 .017 .002 .001` over the 8 frames after the reveal, the same in `history100`, the default and `quality-zero-velocity` (reprojection broken). A second spike at frame 5, long after any history of the occluder is gone, and no change from breaking reprojection mean the failure is not inherited history. The no-history spatial arm reads `.030`-`.066` on every frame. Crops of frames 28-35 show the cause: fence bars thinner than an input texel drop out of the low input in bands that move each frame (moire), in the spatial and the temporal arms alike, and a missing bright bar over the revealed area is what the stale metric counts. The neighbourhood clamp then discards the history that still holds the bar. That is the case lever 4 targets: Unreal relaxes rejection on pixels whose luma flickers (`TSRRejectShading.usf:466-500`), so the history keeps the bar. Lever 4 is the next arm for this box and for the low-input edge box. + +### Phase 2 — Keep it only if it buys frame time + +- [x] On a GPU-bound representative game, sub-1.0 rendering plus reconstruction lowers GPU/render p95 versus full resolution while meeting the Phase 1 thresholds. proof: paired fixed-route browser WebGPU and desktop-native table of internal pixels, reconstruction cost, GPU/render p50/p95 and visual metrics (no FPS-only verdict). + Open: no representative game yet. Same-scene calibration, 2026-11-02, gi:cornell at 1280×720, RTX 2080, shared GPU (12-72% busy from other work), 2 runs per arm: + + | Arm | Internal pixels | GPU p50 (ms) | Reconstruction p50 (ms) | Edge error vs own 100% (pan 0-60) | + |---|---|---|---|---| + | Unreal 5.8.3 TSR 100% (Epic, history 200%) | 1280×720 | 6.83 / 6.78 | TSR 1.81 / 1.82 | reference | + | Unreal 5.8.3 TSR 50% | 640×360 | 4.30 / 4.64 | TSR 0.90 / 0.93 | `.00583` | + | ThreeNative engine TRAA 100% | 1280×720 | corner 7.65-7.70 / 7.63-7.69; cornell 7.57-9.55 / 13.70-13.86 | unverified (no GPU stage split) | reference | + | ThreeNative engine TRAA 50% | 640×360 | corner 4.60-11.18 / 3.48-3.55; cornell 2.88-3.47 / 2.78-3.78 | unverified | `.00566` | + + Unreal times the pan motion run (CSV profiler, `GPUTime` over 1 ms, `GPU/TemporalSuperResolution`). ThreeNative times the still poses (`--measure 20`, `TN_FRAME_BUDGET` gpu window p50). Distance to Unreal is the same at both scales (mean absolute error `.0277` at 100%, `.0274` at 50%). Sheets: branch `prd-539-screenshots`, `benchmark-cornell-pan/`. The 50% capture needed one engine fix: the lamp shadow-map size now reads the unjittered camera. Before it, TRAA jitter flipped the cornell lamp between two mips every frame and the Wasm engine lost the device in 120 frames (red-green `lamp_size_ignores_temporal_jitter`; visual judge NEUTRAL, mean absolute error `.0002` over 180 frames). + Representative game, 2026-11-03: the shooter kit from `pnpm sandbox` (packed tarballs, a user-like install), its own `playtests/performance.playtest.json` route (1920×1080, high tier, 600 ticks), RTX 2080, GPU slot shared, `renderer.gpuTimestampFrameInterval: 1`. Arms: the shipped high tier (4× MSAA, no `traa`), and `temporalAA: true, samples: 1` at a pinned `resolutionScale` 1 and 0.667. TRAA at 0.667 renders 1281×720 into 1920×1080 (`TN_RENDER_CHAIN.stageSizes.traa`). Browser WebGPU, 5 alternating pairs (window 1 dropped), mean over runs: + + | Arm | Internal pixels | GPU p50 ms | GPU p95 ms | Render CPU p95 ms | + |---|---|---|---|---| + | Shipped 4× MSAA, scale 1 (2 runs) | 1920×1080 | 5.37 / 5.32 | 9.72 / 12.55 | 4.54 / 4.41 | + | TRAA, scale 1 | 1920×1080 | 5.75 (5.46-5.91) | 9.84 (8.46-12.75) | 4.84 | + | TRAA, scale 0.667 | 1281×720 | 3.29 (2.82-3.76), -43% | 7.68 (4.24-10.13), -22% | 4.76 | + | 4× MSAA, scale 0.667, spatial upscale (1 run) | 1281×720 | 2.95 | 6.99 | 5.05 | + + GPU p50 is lower at 0.667 in 6 of 6 pairs, including one `?tnDebug=1` pair. GPU p95 is lower in 5 of 6 pairs. Render CPU does not change, because pixels cost no CPU. Two earlier 0.667 runs read 2x on every CPU stage, physics included (`physicsStepUs` 670 against 300). They ran during a host load spike (15 min load 10.5), and the 5 later pairs at load 6-7 did not repeat it, so they are excluded as host load. Desktop native (V8 engine player, private Xvfb, `--live-clock`): GPU p50 4.42-5.69 ms at scale 1 (5 runs) against 3.33-3.64 ms at 0.667 (3 runs). Each run had 4-10 GPU samples, because the private display holds the player at about 13 fps, so desktop p95 is unverified. It needs the real display. The game's reconstruction GPU cost is unverified, because the engine reports no per-pass GPU split. Phase 1 thresholds: met since gate run 12 (option 1). + Reconstruction cost, 2026-11-03: TRAA against the same 0.667 raster with the post-AA stage (`high: { samples: 1 }`, stages `ambientOcclusion bloom vignette antialias`), alternating pairs, GPU p50: `3.43` against `2.75` (+0.68 ms, host load 6.5), and +0.61 to +1.23 ms in 3 pairs at load 17-35; a 4th pair at load 35 reads inverted (`10.09` against `19.11`) and is excluded as host load. + Game visuals, 2026-11-03: a fixed-step route (`visual-pose`: still after 90 ticks, then 12 ticks into a strafe), 2 runs per arm (same-arm runs differ by 0.0-0.45 of 255), fresh read-only Opus judge with criteria written first. TRAA 0.667 against 4x MSAA 0.667 spatial upscale: NEUTRAL (no ghosting, trails, smear, edge breakup or colour shift; the wire stays continuous). TRAA 0.667 against the shipped 4x MSAA at scale 1: REGRESSION, moderate softness (stucco, floor grain, glove weave, shutter planks), no other artifact. High-frequency energy (mean absolute Laplacian, share of scale 1): TRAA `.45`-`.65`, spatial upscale `.38`-`.52`, over floor, wall and distance crops in both frames. So the perf win does not count yet. Cause to try first: ThreeNative applies no texture mip bias under temporal upscaling. Unreal sets `MaterialTextureMipBias = -max(-log2(fraction), 0) - 0.3`, at least -2 (`UE 5.8.3: Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp:5337-5351`, defaults `:179-194`), which is -0.88 at 66.7% and -0.3 at 100%, so the jittered history accumulates texture detail above the input raster. + Lever 7, 2026-11-03 (commit 074bc7156): the view mip bias is a per-draw slot `viewMipBias` (`renderer.cpp` `Renderer::viewMipBias`), 0 without temporal upscaling, so the shipped MSAA path is pixel-identical (frame diff 0.0); material `map`, normal, PBR and bump samples use `textureSampleBias`, shadows keep bias 0. Judge (fresh read-only Opus, criteria first, 2 runs per arm, sheets `shooter-mip-bias/` on `prd-539-screenshots`): TRAA 0.667 with the bias against without it: IMPROVEMENT (more floor grain, stucco relief, glove weave; no sparkle, moire or shimmer); against the shipped 4x MSAA at scale 1: NEUTRAL, slight softness, edge stair-stepping the same as without the lever. High-frequency share of scale 1: floor `.49` to `.57`, wall `.45` to `.57`-`.59`. + Web perf after lever 7, 2026-11-03 (sandbox on the lever-7 tarball, Wasm `viewMipBias` marker checked; a second sandbox on the pre-lever tarball for the bias A/B): GPU time holds one level per run, but identical runs sit on two levels about 2.5x apart (shipped MSAA 1.0: `5.65` or `16.4` ms p50). The cause is the GPU clock state (idle P5/P8 at 735/315 MHz against 2205 MHz boost), because the worker-driven game presents at 15-30 fps. Holding all 8 GPU slots did not remove it, and a clock lock (`nvidia-smi -lgc`) needs root. Within one level, TRAA 0.667 against shipped MSAA 1.0, GPU p50: `3.5` against `6.3`-`6.7` (-46%) at boost, `10.1` against `15.7` (-36%) at the low clock. Bias on against off, 4 alternating pairs at load 4-7: `+0.53`, `+0.09`, `-0.04` ms and one `+3.17` pair across a level change; best windows `3.17` against `3.21`, so the cost is below run noise. GPU p95 is unresolved on web: at boost TRAA reads `8.5`-`9.2` ms and MSAA `6.6`-`17.3`, because clock ramps land in the tail. + Web at a 3840x2160 viewport, 2026-11-03 (GPU work 4x, 3 alternating pairs, load 6-13): GPU p50 per window, shipped MSAA 1.0 `27.2`-`38.1` ms against TRAA 0.667 `12.7`-`13.5` ms (-55% to -64%); GPU p95 `32.2`-`40.4` against `34.6`-`37.0`, about equal. One TRAA run at load 13 (`36.4` ms, render p95 `24.3` ms) is excluded as host load. TRAA's p95/p50 ratio is about 2.7, the GPU's boost/idle clock ratio: Xvfb presents 4K at about 4 fps, so the GPU idles between frames and the clock drops inside the window. + Desktop native, 2026-11-03 (V8 engine player built in this worktree with the bias, `TN_PLAYER_VSYNC=0`, private Xvfb, 4 pairs kept at load under 12): the private display is present-bound (render p50 about 52 ms) with 4-10 GPU samples per run, and the pairs read in both directions (`6.78`/`3.31`, `5.55`/`10.51` ms). The player has no offscreen mode. So the private display alone cannot hold the p95. + Done 2026-11-03 with the GPU clock locked at 1500 MHz (`nvidia-smi -lgc 1500,1500` by the owner's decision, clock logged every 10 s, 1500 in every sample, reset with `-rgc` after), lever-7 tarball and player, alternating pairs, host load 6-17 from CI runners. GPU ms per 10 s window (window 1 dropped): + + | Lane | Arm | Internal pixels | GPU p50 | GPU p95 | Render CPU p95 | + |---|---|---|---|---|---| + | Browser WebGPU, 3 pairs | shipped 4x MSAA, scale 1 | 1920x1080 | 5.72-6.01 | 8.91-15.85 | 5.48-6.48 | + | Browser WebGPU, 3 pairs | TRAA, scale 0.667 | 1281x720 | 3.21-3.74 (one window 7.94), -40% to -46% | 4.77-9.84, lower in 5 of 6 windows | 5.91-18.08 | + | Desktop native V8 player, 3 pairs | shipped 4x MSAA, scale 1 | 1280x720 | 14.50-15.29 | 15.27-16.08 | 67.9-97.1 | + | Desktop native V8 player, 3 pairs | TRAA, scale 0.667 | 853x480 | 7.63-8.17, -47% | 7.77-10.87, -32% to -50% | 64.4-82.2 | + + Reconstruction cost: +0.68 ms against post-AA at the same raster (above). Visual metrics: judge NEUTRAL against full resolution with lever 7 (above). Phase 1 thresholds: met (gate run 12). Render CPU does not move with pixels on web; on desktop it is the private display's present copy (about 52 ms p50) in both arms. Desktop GPU p50 at 1280x720 reads above web at 1920x1080 in the same clock state; that gap is not this PRD's and is unverified. + Same scene against Unreal, 2026-11-03, clock locked at 1500 MHz (36 of 36 clock samples), gi:cornell at 1280x720, 67% and 100% input on both engines, 2 runs per arm. Unreal 5.8.3 TSR (Epic, history 200%, CSV `GPUTime` over the pan motion run, the run's CSV that carries `GPU/TemporalSuperResolution`; the other CSV of each run is editor frames): 100% p50 `8.41`/`8.22`, p95 `23.57`/`22.88`, TSR `2.22`/`2.43`; 67% p50 `6.09`/`5.94` (-28%), p95 `14.79`/`13.22` (-40%), TSR `1.43`/`1.40`. ThreeNative TRAA (`--measure 20`, 4 windows per still pose, Wasm engine with lever 7): 100% p50 `7.35`-`7.55` (cornell) / `9.96`-`10.00` (corner), p95 `7.57`-`7.89` / `10.0`-`10.1`; 67% p50 `4.78`-`4.99` (-36%) / `4.87`-`6.06` (-40%), p95 `4.83`-`7.47` / `6.12`-`8.06`. At 67% ThreeNative costs no more GPU than Unreal on the same scene. The method differs in one way: Unreal times the pan motion and ThreeNative the still poses. + Lever 7 review, 2026-11-03 (leanpi on `opencode-go/deepseek-v4.1-flash`, `thinkingLevel: max` on all 39 turns, read-only): formula, stage and slot checks OK. Fixed: a node material's 2D `texture()` read at the implicit LOD (`graph.cpp`). Material lowerings mark themselves with a `viewMipBias` input; the graph declares the uniform at the first 2D fragment sample, so a node material without a texture keeps its layout (`frame_slab_is_per_program` arena hash unchanged), and volumes (probe GI, LUTs) and post graphs keep the implicit LOD (`probe_shader` green). Red-green `material_test view_mip_bias_samples` (node texture, normalMap, roughnessMap, bumpMap). Native engine ctest 527 of 530; the 3 reds also fail at HEAD sources or carry `tn-known-red`. Not unit-tested: the per-draw upload; its end-to-end proof is the lever-7 capture (detail share `.49` to `.57` between the pre- and post-lever tarballs). + Judge 3, 2026-11-03 (fresh read-only Opus, criteria first, shooter `visual-pose`, 2 runs per arm, before = the pre-fix runtime tarball, after = this fix): node `texture()` bias, after against before: NEUTRAL in both frames, no visible change and no artifact; the criteria asked for IMPROVEMENT, so the fix misses its visual target (the kit's `structure()` mixes the detail texture at a low strength). Pixels: before against after differ by mean `0.49`-`0.56` of 255 on `still` (run-to-run `0.29`-`0.33`) and equal run noise on `strafe`. High-frequency share of shipped MSAA 1.0: before `.51`-`.58`, after `.50`-`.56`. TRAA 0.667 against shipped MSAA 1.0: REGRESSION, moderate softness (stucco, floor grain, awning wood grain, shutter texture), no ghosting, trails or sparkle. + Reopened 2026-11-03 after judge 3 (TRAA 0.667 moderately softer than shipped 4x MSAA), closed the same day by the box below: the owner's option-1 bar applied to the look, TRAA 67% loses no more texture detail against its own 100% than Unreal TSR 67% does on the same scene (judge 5 PASS). The softness against full-resolution MSAA is the measured trade; TRAA stays opt-in. +- [x] On a textured calibration scene built in both engines (sameness proven), ThreeNative TRAA at 67% loses no more texture detail against its own 100% than Unreal TSR at 67% loses against its 100% (the owner's option-1 bar applied to the look). proof: `pnpm unreal-look:capture` both engines at 67% and 100%, high-frequency share per textured region, and a fresh judge. + Done 2026-11-03, `gi:neon-corridor` pose `textured` (the brick wall), 1280x720 settled stills, 2 runs per arm (ThreeNative runs bit-identical, Unreal runs within 0.002). Unreal 5.8.3 Epic TSR with `r.ScreenPercentage` 100/67 (log: `Applying CVar "r.ScreenPercentage"`); ThreeNative Wasm engine, `screenPercentage=100|67`, nvidia/turing. Sameness: `pnpm unreal-look:compare --sameness --scene gi:neon-corridor` PASS, 16 objects in 2 poses, farthest edge 1 px, `Wall_Brick` IoU 1.0000. Mean absolute Laplacian over the eroded `Wall_Brick` ID mask, 67% over 100%: + + | Arm | Whole wall | Mid wall | Far wall (x 520-640, y 130-330) | Far wall HF at 100% | + |---|---|---|---|---| + | Unreal TSR | .875 | .879 | .778 | 14.63 | + | ThreeNative TRAA, anisotropy 1 (three default) | .926 | .928 | .791 | 12.51 | + | ThreeNative TRAA, anisotropy 8 (`anisotropy=8`, Unreal Epic `r.MaxAnisotropy`) | .904 | .881 | .772 | 13.72 | + + Judge 4 (fresh Opus, anisotropy 1): FAIL by a small margin — both lose slight detail, no extra artifact, but the far-wall mortar edge contrast drops 20% against Unreal's 12%. Unreal samples the brick at 8x anisotropy (World texture group, `BaseScalability.ini [TextureQuality@3]`) and ThreeNative at 1x, so the far wall was not the same scene. Judge 5 (fresh Opus, anisotropy 8, same criteria): PASS — Unreal slight, ThreeNative slight, ThreeNative against Unreal EQUAL (far gradient energy .696 against .726, Laplacian .797 against .795, mid .919 against .921, near .974 against .962), no sparkle, moire or over-sharpening. The harness knob is `anisotropy=` in `scripts/unreal-look/scaffold/CalibrationScene.ts.in`. The Laplacian table alone does not separate judge 4 from judge 5 (anisotropy 1 far `.791` already reads above Unreal's `.778`); the verdicts rest on the judges' edge-contrast and gradient-energy reads. + Reproduction, 2026-11-03 (leanpi on `opencode-go/deepseek-v4.1-flash`, `thinkingLevel: max` on all turns, read-only, commit `33c5246e3`): all 9 ratios and the far HF at 100% recomputed from the raw frames within 0.001; knob, `r.MaxAnisotropy=8` at `[TextureQuality@3]` (the brick keeps the World LOD group), screen-percentage logs and the moved links verified. VERDICT OK. Named differences that hold within each engine's pair, so they do not bias the 67/100 ratio: local exposure (off in the Unreal capture, on in ThreeNative), the glb's re-encoded brick PNG (mean 1.4 of 255 from the source), clamp against repeat wrap, and uncompressed import against generated mips. +- [x] Automatic resolution moves between at least three scales in one run without history corruption, allocation growth or a reconstruction cost spike larger than the saved raster cost. proof: scripted scaler route recording transitions, history resets, render-target allocation count and per-stage cost; repeated cycles end at the initial allocation baseline. + Done 2026-11-03, browser WebGPU only (RTX 2080, `adapterInfo` nvidia/turing, shared GPU slot); desktop native unverified. Conformance row `temporal-aa-scaler-route` on the provider default (200% history, velocity clamp): 1280×720 display, input 1 → 0.75 → 0.5 → 0.75 → 1, twice, over an opaque backdrop with a per-pixel loop, so that the scene pass is raster bound. The row fails closed when half scale is not under 75% of full-scale GPU time. It asserts the following: + - Each of the 8 steps resets history on its own frame only (`resize`), and the next frame is valid with a rejection fraction under 1. + - The input raster is `floor(display × scale)`, and the display raster does not change. + - Textures and geometries are 9/8 at frame 7, and 9/8 again at frames 39 and 71 after each cycle. + - No step frame costs over 1.1 × its neighbour-frame median + 0.05 ms. + + Per-stage GPU time from three's per-pass timestamps, 2 runs (scene pass / reconstruction, history resolve and present): + + | Scale | Scene pass ms | Rest ms | Total ms | Step frames into this scale, total ms | + |---|---|---|---|---| + | 1 | 1.263 / 1.306 | 0.556 / 0.574 | 1.820 / 1.883 | 1.819, 1.880 / 1.881, 1.888 | + | 0.75 | 0.732 / 0.761 | 0.553 / 0.573 | 1.288 / 1.335 | 1.277, 1.283, 1.297, 1.295 / 1.365, 1.337, 1.357, 1.344 | + | 0.5 | 0.359 / 0.367 | 0.555 / 0.566 | 0.913 / 0.933 | 0.941, 0.911 / 0.951, 0.946 | + + The largest step-frame excess over its scale's steady total is +0.06 ms. The total a downward step saves is 0.38–0.40 ms (0.75 → 0.5) and 0.53–0.55 ms (1 → 0.75). The reconstruction cost stays flat across scales, because the 2× history resolve runs at the display-relative history raster. A run without the backdrop showed 0.47–0.49 ms at every scale (saved raster cost about 0.015 ms), so the guard was added. Pre-existing on `origin/develop` and unchanged here: `temporal-aa-scaled` (opening reset, 128393 samples outside the oracle) and `temporal-aa-lifecycle` (resize reset, 74107 samples) fail with identical counts on develop's own template and fixture files. `temporal-aa`, `temporal-aa-upsampled`, `temporal-aa-scaled-unchecked-reset` and `temporal-aa-quality` pass. + +## Acceptance criteria + +- [x] The reconstructor clears the quality gate at full and low input. proof: Phase 1, `scripts/verify-temporal-motion.ts` gate run 12, exit 0, 15 of 15 checks. +- [x] Automatic resolution changes keep history, allocations and cost safe. proof: Phase 2 box 2, conformance row `temporal-aa-scaler-route`. +- [x] A representative game buys GPU time, and the cost in look is measured and stated. proof: Phase 2 box 1, the locked-clock table (GPU p50 -40% to -47%, p95 lower on web and desktop); judge 3 (2026-11-03) rates TRAA 0.667 against shipped 4x MSAA a REGRESSION with moderate softness (judge 2 had rated it NEUTRAL, slight), so TRAA stays opt-in as the Outcome states. Against the owner's bar the look cost equals Unreal TSR's at the same input (Phase 2 textured box, judge 5 PASS). + +## three.js 0.187.0 levers (2026-11-02) + +- r186 adds `TAAUtils`, the shared functions of the TAA nodes (three#34458). r187 registers `TAAUNode` inputs before their consumers (three#34565) and updates the depth pass before `TemporalReprojectNode` (three#34823). Recapture the low-resolution corpus on 0.187.0 before the quality numbers of this PRD are compared, because the upstream temporal nodes changed. + +Adoption rule: the owned engine is the default, and it runs a three symbol only when its API catalog covers it (`docs/architecture/NATIVE-ENGINE-DECISION.md`). So use the upstream piece first as a measured reference arm on `engine: "threejs"`. A template uses it only after a catalog row and a native parity case land. + +## Decisions + +- The thresholds and the 31-arm corpus carry over unchanged; a candidate that needs them relaxed is rejected, not merged. +- Candidates already falsified (do not repeat): reactive mask, raw4 current-footprint weighting, variance-clip widening, and a rejection-fraction metric (it is velocity-blind; see PRD-269). +- CI: `verify-temporal-motion.ts` is not in CI today (its hardware Vulkan flags lose the device on the GPU-less runner, and four checks fail by design). Phase 1 includes returning it to the `temporal` integration job, with the unmet checks fail-closed once they pass. +- Ghosting margin to keep honest: the pinned moving-edge ratios (`<=.75`, `>=1.25x`) sit against measured `.629`/`.651` and `1.485`/`1.435` (hardware/CI). +- First step for the owner of this PRD: explain the `.010` camera-drift loss (history resampling) with a one-variable control before designing the next reconstructor. +- 2026-10-09 (João, via the Unreal source review): that one-variable control is history resolution, 200% against 100% (Unreal TSR lever 1 above). Its box is now the first box of Phase 1. Levers 2–4 follow in the listed order as gate arms. No existing box changed. +- 2026-10-12 (Unreal source review): corrected lever 4. Flicker detection relaxes history rejection to ghost within a luma bound, it does not add rejection. Also corrected the tier defaults for levers 1, 3 and 5. No box changed. +- 2026-11-03 (gate runs 3 and 4): the alignment-refine and rejection-anti-aliasing arms are deleted from the provider and the gate, with their numbers kept in Phase 1. `history200-clamp` stays an opt-in arm until the provider resolves the 2x history to a display-sized raster. No box changed. +- 2026-11-03 (gate runs 6 and 7, visual judge IMPROVEMENT): the provider default is Unreal's Epic tier, 200% history with the velocity clamp, presented through the Mitchell resolve. `{ historyScale: 1, velocityWeightClamp: false }` is the named override and the `history100` gate arm. The raw4 path keeps 100%, because it was measured only there. The provider's own GPU cost for the 2x history is unverified. No box changed. +- 2026-11-03 (gate runs 8 and 9): lever 4 is deferred, not declined. It reduces flicker but leaves both low-input checks unchanged and costs full-resolution moving edge `+2.4%`. Revisit it after lever 6 keeps low-input history detail, because then the flicker it damps is the remaining error. No box changed. +- 2026-11-03 (gate run 10): lever 6 alone is deferred, not declined. It keeps static full-resolution detail (edge `-10%`) but leaves both low-input checks unchanged. Its revisit is the combined `flicker-release` arm, Unreal's own pairing. No box changed. +- 2026-11-03 (same-scene benchmark): open for the owner. Unreal TSR at the same 66.7% input, on the same fence, misses the low-input edge bar by more than ThreeNative does (1.74x against 1.41x its own no-AA edge error). The rule above says a candidate that needs the thresholds relaxed is rejected, so the two low-input boxes stay open. The owner decides whether the low-input bar becomes "no worse than Unreal TSR at the same input on the same scene". No box changed. +- 2026-11-03 (João, option 1): the low-input bar is "no worse than Unreal TSR at the same input on the same scene", measured on `tools/unreal-reference/gi/fence`. `qualityEdgeImprovement` takes Unreal's edge ratio to its own no-AA (`.0816 / .0468` = 1.74x) in place of 0.95x, and `qualityRevealRecovery` takes Unreal's lowest stale fraction after the reveal (`.082`) in place of `.01`. The full-resolution checks keep their pinned bars. Boxes 3 and 4 of Phase 1 are ticked on gate run 12. +- 2026-11-03 (lever 7, judge IMPROVEMENT): Unreal's view mip bias under temporal upscaling ships on by default with the reconstructor. It is automatic: the engine derives it from the render scale, so no author constant exists to override. A non-temporal view keeps bias 0. No box changed. +- 2026-11-03 (João): box 97's GPU p95 is measured with the GPU clock locked (`nvidia-smi -lgc 1500,1500`), because the private Xvfb presents at 4-30 fps and the unlocked clock moved GPU time 2.5x between identical runs. The clock was reset after the run. +- 2026-11-03 (AFK decision, judge 3): TRAA 0.667 is a measured trade, not a free win: GPU -40% to -47% for moderate softness against shipped 4x MSAA. It stays opt-in, as the Outcome says. Revisit making it a default when a same-input visual comparison against Unreal TSR on a textured scene shows ThreeNative's softness no worse than Unreal's (the owner's option-1 bar). The node `texture()` bias stays as Unreal parity (all material texture samplers take the view bias) with no visual claim. +- 2026-11-03 (AFK decision, judges 4 and 5): the textured same-scene comparison uses the Unreal scene's own texture filter (8x anisotropy at Epic) through the harness knob `anisotropy=8`, because a filter difference is a sameness gap, not a reconstruction gap. Deferred, not declined: ThreeNative's default anisotropy is three's 1, while stock Unreal Epic filters World textures at 8. Making the Unreal value the engine default is filed as [PRD-789](../Unreal/look/PRD-789-material-textures-filter-at-unreals-epic-anisotropy-by-default.md). +- 2026-11-03 (AFK decision, confirmed by João: "calibration scenes are enough no need to port shooter to unreal"): the Unreal side of the same-scene benchmark runs on the calibration scenes (`gi/fence` for edge quality, `gi:cornell` for GPU cost, `gi:neon-corridor` for texture detail), not on the shooter. The shooter is a ThreeNative game with no Unreal build, and porting it to Unreal is a new scene, not a measurement; its numbers compare each arm with its own full resolution on one machine and clock. +- 2026-10-19 (PRD-631 reference plan): Unreal's desktop default anti-aliasing is TSR (`r.AntiAliasingMethod=4`, `Engine/Source/Runtime/Engine/Private/SceneView.cpp:235`), with 200% history at Epic. The templates use SMAA. A reconstructor that this PRD keeps becomes the desktop `high` default only after it passes the L9 row of [PRD-631](PRD-631-an-unreal-reference-scene-measures-the-default-look.md) and this PRD's motion thresholds. [PRD-636](../Unreal/look/PRD-636-the-finishing-post-chain-matches-unreal-defaults.md) owns the rest of L9. No box changed. diff --git a/docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md b/docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md index 5edec8b44d..705f03e7c2 100644 --- a/docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md +++ b/docs/PRDs/done/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md @@ -5,7 +5,7 @@ **Priority:** P2 **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, agent (implementation); Phase 1 decline decided by the agent under the owner's "sort this out" delegation (D2) -**Depends on:** [PRD-384](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](../Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) (a reconstructor needs a scale that can change often and cheaply). +**Depends on:** [PRD-384](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). ## Context diff --git a/docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md b/docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md index 7594456e4a..921d0976fd 100644 --- a/docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md +++ b/docs/PRDs/done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md @@ -7,7 +7,7 @@ prd_contract: v1 **Status:** DONE — implementation started 2026-10-24; closed 2026-10-27 on the owner's GPU-limit revision (see Phase 3 box 1 and Decisions). **Priority:** P1 — Phase 2 is the only measured path from about 21 ms to the 15 ms GPU frame that the starter kits need for 60 fps on Iris Xe. **Complexity:** 8 (HIGH) — two raster sizes in one C++ frame (+3), post-stage routing across the TS/C++ seam (+2), history lifetime on scale steps (+2), hardware-only gates (+1). -**Depends on:** [PRD-539](../Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the motion-quality corpus (`scripts/verify-temporal-motion.ts`). This PRD does not wait for PRD-539 to close (see Decisions). +**Depends on:** [PRD-539](PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md) for the motion-quality corpus (`scripts/verify-temporal-motion.ts`). This PRD does not wait for PRD-539 to close (see Decisions). ## Problem (measured) diff --git a/docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md b/docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md index 5cb711cad3..f94583a856 100644 --- a/docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md +++ b/docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md @@ -224,7 +224,7 @@ this host, so it is not attributed. The prewarm (`e580e76cb`) stays off this PR, is no walk to compare. Nothing ticked, nothing to capture, and that arm must not merge; the row needs one instrumented run to separate the gate's node builds from its own per-update shadow invalidation before this mechanism is retried. Details in "Where we stand". -- [ ] **A8 · [PRD-539](../Unreal/performance/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md): temporal reconstruction closes the GPU gap (successor of PRD-455).** After: A7. ⏱🌍👁 +- [ ] **A8 · [PRD-539](../done/PRD-539-low-resolution-temporal-reconstruction-quality-and-cost.md): temporal reconstruction closes the GPU gap (successor of PRD-455).** After: A7. ⏱🌍👁 - [ ] **A9 · PRD-478 acceptance.** After: A8. ⏱🌍👁 - Done when: AC-1, AC-2 and AC-3 are ticked on a quiet host 🙋; then PRD-478 may move to `done/`. - [ ] **A10 · PRD-477 Phases 2–3 and AC-1: budgets and switches become engine decisions.** After: A9. ⏱🌍👁 diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index ccbfeb0852..da0732a858 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -11,7 +11,7 @@ targets could not be built and are named below. | `src/audio/` | 1471 | 1301 | 88.44% | | `src/canvas/` | 1334 | 1110 | 83.21% | | `src/cli/` | 1691 | 1243 | 73.51% | -| `src/engine/` | 41259 | 34828 | 84.41% | +| `src/engine/` | 41272 | 34841 | 84.42% | | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | | `src/js/` | 2838 | 2230 | 78.58% | @@ -22,12 +22,12 @@ targets could not be built and are named below. | `src/storage/` | 370 | 329 | 88.92% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 10066 | 7721 | 76.70% | +| `src/webgpu/` | 10066 | 7733 | 76.82% | | `src/webtransport/` | 1391 | 1078 | 77.50% | | `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **66579** | **54669** | **82.11%** | +| **TOTAL** | **66592** | **54694** | **82.13%** | -Source digest: `sha256:8aab879c2468acfe24f3ded781745f45a84009ee91470a1a1117aaa423778cc2` +Source digest: `sha256:4d0b4c03e0132afb9884d3b9fc6dfe4630352eb4ee908c2e109c5677d7b06024` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/examples/abyss-framework/temporal-main.js b/examples/abyss-framework/temporal-main.js index ad352e59c0..735b39bd78 100644 --- a/examples/abyss-framework/temporal-main.js +++ b/examples/abyss-framework/temporal-main.js @@ -6,6 +6,7 @@ const query = new URLSearchParams(location.search); const variantName = query.get("variant") ?? "temporal"; const measurement = query.has("measure"); const raw4 = query.has("raw4"); +const staticCamera = query.has("staticCamera"); // The scaled arm is the browser control for the native conformance capture, which publishes the // 1280x720 display raster. Its measurement size would put the two hosts on different raster pairs. const scaled = variantName.startsWith("scaled"); @@ -31,7 +32,7 @@ const fixture = createTemporalAAFixture( supersampled ? `${family}reference` : variantName, measurement, scaled ? (variantName.includes("lifecycle") ? 36 : 20) : null, - { raw4 }, + { raw4, staticCamera }, ); // Three advances NodeUpdateType.FRAME from its existing animation clock. Multiple synchronous // renders would reuse one temporal result, so each deterministic step waits for that boundary. diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index f7e3b28cb9..c6e6a3bad5 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -378,7 +378,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-31: core key 0.36, traced reflections, the crate contact blob, the portable trace type (PRD-633); measured by this test. // Recomputed 2026-10-31 (PRD-775): temporalAAHooks.ts claims the 0.187 render pipeline's view // offset and drives its callback lists, so starter's render source moves again. Measured by this test. - starter: "6bc9f844c67e33e9ab7d5275f7bb8eaf31181141687f70f860e0f4913ce8f130", + starter: "9d09f538bc14e002b54d37433b569540c0335eccced0a7e4bceff36b88a5074b", "tower-defense": "bce1b6cab442c4275afade3a0ef9034726b0c29295d5514e2175214f14b00f00", }; @@ -495,6 +495,7 @@ const STARTER_PATHS = [ "src/render/temporalAA.ts", "src/render/temporalAAInput.ts", "src/render/temporalAAResolve.ts", + "src/render/temporalClampRelease.ts", "src/render/temporalCurrentArea.ts", "src/render/temporalCurrentFootprint.ts", "src/render/temporalCurrentFootprintMath.ts", @@ -502,6 +503,8 @@ const STARTER_PATHS = [ "src/render/temporalCurrentReplay.ts", "src/render/temporalCurrentSelection.ts", "src/render/temporalCurrentVisibility.ts", + "src/render/temporalHistoryResolve.ts", + "src/render/temporalMoire.ts", "src/render/temporalResolve.ts", "src/render/exposure.ts", "src/render/volumetricFog.ts", diff --git a/packages/create-threenative/__tests__/temporal-aa.spec.ts b/packages/create-threenative/__tests__/temporal-aa.spec.ts index 5517fc4be7..f3d3024b04 100644 --- a/packages/create-threenative/__tests__/temporal-aa.spec.ts +++ b/packages/create-threenative/__tests__/temporal-aa.spec.ts @@ -230,6 +230,8 @@ function fixture( pipelineCallbacks?: PipelineCallbacks; current?: boolean; inputDraw?: () => void; + /** `"default"` passes no weighting, so the provider picks its own. */ + historyScale?: number | "default"; } = {}, ) { // GPU execution belongs to the runtime fixture. Unit tests isolate sizing, reset and lifetime. @@ -259,6 +261,9 @@ function fixture( scenePass.getTextureNode("velocity"), camera, options.current ? scenePass : undefined, + options.historyScale === "default" + ? undefined + : { historyScale: options.historyScale ?? 1, velocityWeightClamp: false }, ); const stub = stubRenderer(display); const frame = { renderer: stub.renderer } as unknown as NodeFrame; @@ -405,6 +410,11 @@ function chainFixture(lowResolutionPassthrough = false) { /** The raster a presented node publishes, whether it reconstructs one or samples the input. */ function outputRaster(node: Node): { width: number; height: number } { + // A TRAANode presents whatever texture its output node reads, not its resolve target. + const presented = (node as { _textureNode?: TextureNode })._textureNode?.value.image as + | { width: number; height: number } + | undefined; + if (presented !== undefined) return { width: presented.width, height: presented.height }; const reconstruction = node as { _resolveRenderTarget?: RenderTarget }; const sampled = node as TextureNode & { isRTTNode?: boolean; @@ -468,6 +478,35 @@ describe("opt-in temporal AA", () => { passthrough.dispose(); }); + it("stores history at twice the display raster for the PRD-539 history200 arm", () => { + const { temporal, frame, copies, node, scenePass } = fixture({ historyScale: 2 }); + temporal.node.updateBefore(frame); + temporal.node.updateBefore(frame); + // Unreal TSR lever 1: resolve and history hold 2x the display raster on both axes. + expect(node._historyRenderTarget.width).toBe(DISPLAY.width * 2); + expect(node._historyRenderTarget.height).toBe(DISPLAY.height * 2); + expect(node._resolveRenderTarget.width).toBe(DISPLAY.width * 2); + // The report still names the display raster as the output, and the history copy never + // crosses from the input raster. + expect(temporal.report()).toMatchObject({ outputWidth: DISPLAY.width, frame: 2 }); + // Unreal's history resolve: the chain reads a display-sized Mitchell downsample of the 2x history. + expect(outputRaster(temporal.node)).toEqual({ width: DISPLAY.width, height: DISPLAY.height }); + expect(copies.some((copy) => copy.from === scenePass.renderTarget.texture)).toBe(false); + temporal.dispose(); + scenePass.dispose(); + expect(() => fixture({ historyScale: 3 })).toThrow("historyScale must be 1 or 2"); + // PRD-539: with no weighting named, the provider takes Unreal's Epic-tier 200% history. + const unnamed = fixture({ historyScale: "default" }); + unnamed.temporal.node.updateBefore(unnamed.frame); + expect(unnamed.node._historyRenderTarget.width).toBe(DISPLAY.width * 2); + expect(outputRaster(unnamed.temporal.node)).toEqual({ + width: DISPLAY.width, + height: DISPLAY.height, + }); + unnamed.temporal.dispose(); + unnamed.scenePass.dispose(); + }); + it("registers input-pass dependencies before temporal update and sizing", () => { const { dependencies, temporal, scenePass } = fixture(); expect(Object.values(dependencies)).toContain(temporal.node.beautyNode); diff --git a/packages/create-threenative/__tests__/temporal-resolve.spec.ts b/packages/create-threenative/__tests__/temporal-resolve.spec.ts index dbc6be2c4a..7290ee1dc8 100644 --- a/packages/create-threenative/__tests__/temporal-resolve.spec.ts +++ b/packages/create-threenative/__tests__/temporal-resolve.spec.ts @@ -5,6 +5,8 @@ import type { Node } from "three/webgpu"; import { MeshBasicNodeMaterial, WGSLNodeBuilder, WebGPURenderer } from "three/webgpu"; import { describe, expect, it, vi } from "vitest"; import { createTemporalAA } from "../templates/starter/src/render/temporalAA.js"; +import { createTemporalClampRelease } from "../templates/starter/src/render/temporalClampRelease.js"; +import { createTemporalMoire } from "../templates/starter/src/render/temporalMoire.js"; import { createTemporalRejectionCounter } from "../templates/starter/src/render/temporalRejectionCounter.js"; import { CATMULL_ROM_BASIS, @@ -188,7 +190,9 @@ describe("authored temporal reconstruction kernel", () => { expect(withFlag).not.toContain("( 1.0 > 0.5 )"); // Production retains its named current weight: luminance reweighting biases alternating // linear coverage towards the darker sample even when history is correctly registered. - expect(withFlag).not.toContain("0.2126"); + // The default's velocity clamp reads history and current luma once each (PRD-539); luminance + // reweighting would add two more reads. + expect(withFlag.match(/0\.2126/gu)).toHaveLength(2); // The chosen velocity texel and the reused centre colour have independent depth support. // An AND guard must read both previous depths; replacing the centre check admits stale red. expect(withFlag.match(/textureLoad\([^\n]*clamp\( floor\(/gu)).toHaveLength(2); @@ -201,6 +205,119 @@ describe("authored temporal reconstruction kernel", () => { mesh.material.dispose(); } }); + it("compiles lever 6's clamp release and its six input-raster passes to WGSL (PRD-539)", () => { + const renderer = offlineRenderer(); + const camera = new PerspectiveCamera(); + const scenePass = pass(new Scene(), camera); + scenePass.setSize(1280, 720); + const build = (clampRelease: boolean) => { + const temporal = createTemporalAA( + scenePass.getTextureNode(), + scenePass.getTextureNode("depth"), + scenePass.getTextureNode("velocity"), + camera, + undefined, + { historyScale: 2, velocityWeightClamp: true, clampRelease }, + ); + const builder = new WGSLNodeBuilder(null as never, renderer) as WGSLNodeBuilder & { + context: Record; + setBuildStage(stage: string): void; + }; + builder.setBuildStage("setup"); + builder.context.velocity = velocity; + temporal.node.setup(builder); + return temporal.node as unknown as TemporalResolveNode & { + _resolveMaterial: { colorNode: unknown }; + }; + }; + const mesh = new Mesh(new PlaneGeometry(2, 2), new MeshBasicNodeMaterial()); + const samples = (code: string) => code.match(/texture[A-Za-z]*\(/gu)?.length ?? 0; + // The kernel reads the release factor only where the lever is on. + expect( + samples(fragment(renderer, mesh, build(true)._resolveMaterial.colorNode)), + ).toBeGreaterThan(samples(fragment(renderer, mesh, build(false)._resolveMaterial.colorNode))); + const node = build(true); + const draw = (release: ReturnType) => { + const drawn: { fragmentNode: unknown }[] = []; + release.update( + { initRenderTarget: () => {}, setRenderTarget: () => {} } as never, + { + render: () => {}, + set material(value: { fragmentNode: unknown }) { + drawn.push(value); + }, + } as never, + createTemporalDepthRejection(node, renderer), + { x: 64, y: 36 }, + ); + release.dispose(); + return drawn.map((material) => fragment(renderer, mesh, material.fragmentNode)); + }; + // Guide, input blur, history blur, rejection, median, min. + const passes = draw(createTemporalClampRelease(node)); + expect(passes).toHaveLength(6); + for (const code of passes) expect(code).not.toMatch(/Infinity|NaN/u); + // The rejection pass carries Unreal's 10-bit quantization step, 0.5 / 1024. + expect(passes[3]).toContain("0.00048828125"); + // With lever 4 the rejection also reads the moire state, as Unreal's combined analysis does. + const combined = draw(createTemporalClampRelease(node, createTemporalMoire(node))); + expect(combined[3]).not.toMatch(/Infinity|NaN/u); + expect(samples(combined[3] ?? "")).toBeGreaterThan(samples(passes[3] ?? "")); + }); + + it("compiles lever 4's widened clip and its moire update to WGSL (PRD-539)", () => { + const renderer = offlineRenderer(); + const camera = new PerspectiveCamera(); + const scenePass = pass(new Scene(), camera); + scenePass.setSize(1280, 720); + const build = (flickerRejection: boolean) => { + const temporal = createTemporalAA( + scenePass.getTextureNode(), + scenePass.getTextureNode("depth"), + scenePass.getTextureNode("velocity"), + camera, + undefined, + { historyScale: 2, velocityWeightClamp: true, flickerRejection }, + ); + const builder = new WGSLNodeBuilder(null as never, renderer) as WGSLNodeBuilder & { + context: Record; + setBuildStage(stage: string): void; + }; + builder.setBuildStage("setup"); + builder.context.velocity = velocity; + temporal.node.setup(builder); + return temporal.node as unknown as TemporalResolveNode & { + _resolveMaterial: { colorNode: unknown }; + }; + }; + const mesh = new Mesh(new PlaneGeometry(2, 2), new MeshBasicNodeMaterial()); + // The moire ramp, saturate(count * (1 - 0.95^2) - 0.5), exists only where the lever is on. + expect(fragment(renderer, mesh, build(true)._resolveMaterial.colorNode)).toContain("0.0975"); + expect(fragment(renderer, mesh, build(false)._resolveMaterial.colorNode)).not.toContain( + "0.0975", + ); + // The update pass compiles too, with no literal WGSL cannot parse (gate run 5 lost an arm to one). + const node = build(true); + const moire = createTemporalMoire(node); + const drawn: { fragmentNode: unknown }[] = []; + moire.update( + { initRenderTarget: () => {}, setRenderTarget: () => {} } as never, + { + render: () => {}, + set material(value: { fragmentNode: unknown }) { + drawn.push(value); + }, + } as never, + createTemporalDepthRejection(node, renderer), + new Vector2(64, 36), + ); + // Detection, then the 5x5 dilation and accumulation. + expect(drawn).toHaveLength(2); + const passes = drawn.map((material) => fragment(renderer, mesh, material.fragmentNode)); + for (const code of passes) expect(code).not.toMatch(/Infinity|NaN/u); + expect(passes[1]).toContain("0.95"); + moire.dispose(); + }); it("compiles both depth supports in the actual rejection-count compute dispatch", async () => { const renderer = offlineRenderer(); const camera = new PerspectiveCamera(); diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index 83d47c29c7..05197b4808 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -79,7 +79,7 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale The shipped chain comes from `qualityPreset(tier)` and `WorldEnvironment` in `@threenative/core`. Keep this game's sky, materials, grade and effect overrides in `src/render/`; `TN_RENDER_CHAIN` reports every applied or refused stage. The neutral grade and optional film grain remain game-owned in `grade.ts` and `public/grade.cube`. Every override has a name on the same quality preset, and a missing stage observation is a failure. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. A scenario with no assertions or missing observations fails; open a real capture after visual changes. When a frame costs more than it should, find the object first: backtick opens the overlay, whose **Geometry** tab captures one frame on **Capture** and ranks the objects that submitted its triangles beside their projected size on screen; an agent gets the same report from `"assert": { "geometry": { "limit": 25 } }` in a scenario, which needs `runtime.geometry`. Those numbers are measured submissions reconciled against the frame's own pass totals, so the remainder the rows do not account for is reported, and what cannot be measured — a packed batch's per-member draws, unknown bounds — says so with a reason instead of reading as zero. -`src/render/temporalAA.ts` is an experimental, opt-in TRAANode provider for scene input reconstruction. Shipped quality tiers use the engine's built-in `traa` stage instead, and `debug().temporal` reports whichever one runs. Supply colour/depth/velocity from the same scene pass through a `traa` RenderChain stage, reset history on camera cuts, and dispose with the stage. The optional exact scene-pass argument enables a bounded raw4 current-footprint candidate for supported reduced rasters; it owns four-sample beauty and matching visibility replays and restores ordinary pass policy on unsupported inputs. Opaque RGB remains a pixel-frequency approximation; alpha-tested visibility uses sample interpolation. The ordinary path uses scene-pass MSAA disabled. `temporalResolve.ts` retains optional linear/Catmull–Rom and ordinary-blend comparisons, both depth-history vetoes, and mandatory RGB clipping. Original quality/recovery, GPU cost, automatic scaling and native gates remain unqualified. +`src/render/temporalAA.ts` is an experimental, opt-in TRAANode provider for scene input reconstruction. Shipped quality tiers use the engine's built-in `traa` stage instead, and `debug().temporal` reports whichever one runs. Supply colour/depth/velocity from the same scene pass through a `traa` RenderChain stage, reset history on camera cuts, and dispose with the stage. The optional exact scene-pass argument enables a bounded raw4 current-footprint candidate for supported reduced rasters; it owns four-sample beauty and matching visibility replays and restores ordinary pass policy on unsupported inputs. Opaque RGB remains a pixel-frequency approximation; alpha-tested visibility uses sample interpolation. The ordinary path uses scene-pass MSAA disabled. With no weighting named it keeps history at twice the display raster with a velocity-clamped history weight (Unreal's Epic-tier TSR) and presents a display-sized resolve of it; `{ historyScale: 1, velocityWeightClamp: false }` is the named override. `temporalResolve.ts` retains optional linear/Catmull–Rom and ordinary-blend comparisons, both depth-history vetoes, and mandatory RGB clipping. Original quality/recovery, GPU cost, automatic scaling and native gates remain unqualified. ## One shadow for a big outdoor level When one directional light must shadow a whole valley and a 2048² map smudges, set `sun.shadow.shadowNode = new VirtualShadowNode(sun, { clipExtents: [12, 40, 120] })` from `@threenative/core`: camera-centred, texel-snapped clip levels, cached until the window moves and shared through Three's shadow slot. Bias, normal bias, intensity, radius, blur samples, map type and filter stay on `sun.shadow`; map sizes come from the options. Movers sort themselves: a caster whose matrix changes draws through a per-level mover map and returns to the cached levels after `staticAfterFrames` quiet frames, each change redrawing only its own region. `trackCaster(object)` pins a mover for good, `pinStatic(object)` opts an object out; call `invalidateAll()` when static geometry changes. `TN_VIRTUAL_SHADOW` reports rendered, mover-map and cached-level work. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index 91496826e5..e08794cbbb 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -81,7 +81,7 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale The shipped chain comes from `qualityPreset(tier)` and `WorldEnvironment` in `@threenative/core`. Keep this game's sky, materials, grade and effect overrides in `src/render/`; `TN_RENDER_CHAIN` reports every applied or refused stage. The neutral grade and optional film grain remain game-owned in `grade.ts` and `public/grade.cube`. Every override has a name on the same quality preset, and a missing stage observation is a failure. `input.vector("move").y` is +up, so forward uses one explicit `-move.y` conversion. A scenario with no assertions or missing observations fails; open a real capture after visual changes. When a frame costs more than it should, find the object first: backtick opens the overlay, whose **Geometry** tab captures one frame on **Capture** and ranks the objects that submitted its triangles beside their projected size on screen; an agent gets the same report from `"assert": { "geometry": { "limit": 25 } }` in a scenario, which needs `runtime.geometry`. Those numbers are measured submissions reconciled against the frame's own pass totals, so the remainder the rows do not account for is reported, and what cannot be measured — a packed batch's per-member draws, unknown bounds — says so with a reason instead of reading as zero. -`src/render/temporalAA.ts` is an experimental, opt-in TRAANode provider for scene input reconstruction. Shipped quality tiers use the engine's built-in `traa` stage instead, and `debug().temporal` reports whichever one runs. Supply colour/depth/velocity from the same scene pass through a `traa` RenderChain stage, reset history on camera cuts, and dispose with the stage. The optional exact scene-pass argument enables a bounded raw4 current-footprint candidate for supported reduced rasters; it owns four-sample beauty and matching visibility replays and restores ordinary pass policy on unsupported inputs. Opaque RGB remains a pixel-frequency approximation; alpha-tested visibility uses sample interpolation. The ordinary path uses scene-pass MSAA disabled. `temporalResolve.ts` retains optional linear/Catmull–Rom and ordinary-blend comparisons, both depth-history vetoes, and mandatory RGB clipping. Original quality/recovery, GPU cost, automatic scaling and native gates remain unqualified. +`src/render/temporalAA.ts` is an experimental, opt-in TRAANode provider for scene input reconstruction. Shipped quality tiers use the engine's built-in `traa` stage instead, and `debug().temporal` reports whichever one runs. Supply colour/depth/velocity from the same scene pass through a `traa` RenderChain stage, reset history on camera cuts, and dispose with the stage. The optional exact scene-pass argument enables a bounded raw4 current-footprint candidate for supported reduced rasters; it owns four-sample beauty and matching visibility replays and restores ordinary pass policy on unsupported inputs. Opaque RGB remains a pixel-frequency approximation; alpha-tested visibility uses sample interpolation. The ordinary path uses scene-pass MSAA disabled. With no weighting named it keeps history at twice the display raster with a velocity-clamped history weight (Unreal's Epic-tier TSR) and presents a display-sized resolve of it; `{ historyScale: 1, velocityWeightClamp: false }` is the named override. `temporalResolve.ts` retains optional linear/Catmull–Rom and ordinary-blend comparisons, both depth-history vetoes, and mandatory RGB clipping. Original quality/recovery, GPU cost, automatic scaling and native gates remain unqualified. ## One shadow for a big outdoor level When one directional light must shadow a whole valley and a 2048² map smudges, set `sun.shadow.shadowNode = new VirtualShadowNode(sun, { clipExtents: [12, 40, 120] })` from `@threenative/core`: camera-centred, texel-snapped clip levels, cached until the window moves and shared through Three's shadow slot. Bias, normal bias, intensity, radius, blur samples, map type and filter stay on `sun.shadow`; map sizes come from the options. Movers sort themselves: a caster whose matrix changes draws through a per-level mover map and returns to the cached levels after `staticAfterFrames` quiet frames, each change redrawing only its own region. `trackCaster(object)` pins a mover for good, `pinStatic(object)` opts an object out; call `invalidateAll()` when static geometry changes. `TN_VIRTUAL_SHADOW` reports rendered, mover-map and cached-level work. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` diff --git a/packages/create-threenative/templates/starter/src/render/temporalAA.ts b/packages/create-threenative/templates/starter/src/render/temporalAA.ts index db3d544bd7..dbe321140f 100644 --- a/packages/create-threenative/templates/starter/src/render/temporalAA.ts +++ b/packages/create-threenative/templates/starter/src/render/temporalAA.ts @@ -13,6 +13,7 @@ import { createTemporalCurrentProducer } from "./temporalCurrentProducer.js"; import { ownsCurrentInput } from "./temporalCurrentSelection.js"; export type { ITemporalAAReport, TemporalResetReason } from "./temporalAAStage.js"; import { createTemporalAAResolve } from "./temporalAAResolve.js"; +import { type ITemporalHistoryWeighting, historyWeighting } from "./temporalResolve.js"; import type { TemporalResolveNode } from "./temporalResolveDepth.js"; /** Reuse TRAANode's jitter, depth rejection and variance clipping. In a RenderChain `traa` factory @@ -24,10 +25,11 @@ export function createTemporalAA( velocityInput: TextureNode, camera: PerspectiveCamera | OrthographicCamera, currentPass?: PassNode, + requested?: ITemporalHistoryWeighting, ) { - const currentProducer = ownsCurrentInput(colour, depthInput, velocityInput, camera, currentPass) - ? createTemporalCurrentProducer(currentPass) - : undefined; + const raw4 = ownsCurrentInput(colour, depthInput, velocityInput, camera, currentPass); + const weighting = historyWeighting(requested, raw4); + const currentProducer = raw4 ? createTemporalCurrentProducer(currentPass) : undefined; const depth = currentProducer?.depth ?? depthInput; const velocity = currentProducer?.motion ?? velocityInput; const node = traa(colour, depth, velocity, camera); @@ -46,6 +48,7 @@ export function createTemporalAA( node as TemporalResolveNode, jitterOffset, currentProducer, + weighting, ); const frames = createTemporalAAFrame( internals, @@ -54,6 +57,8 @@ export function createTemporalAA( historyValid, resolve.equations, currentProducer?.publishDepth, + weighting.historyScale, + resolve.levers, ); const hooks = createTemporalAAHooks(frames.jitterInput, () => internals.clearViewOffset()); let ownedViewOffset: { width: number; height: number } | null = null; @@ -152,10 +157,6 @@ export function createTemporalAA( pending = "resize"; const reason = pending; frames.draw(frame.renderer, reason !== null, report.frame + 1); - if (reason !== null && width === frames.display.x && height === frames.display.y) { - frame.renderer.copyTextureToTexture(source.texture, internals._resolveRenderTarget.texture); - frame.renderer.copyTextureToTexture(source.texture, internals._historyRenderTarget.texture); - } report = { frame: report.frame + 1, historyValid: reason === null, diff --git a/packages/create-threenative/templates/starter/src/render/temporalAAFrame.ts b/packages/create-threenative/templates/starter/src/render/temporalAAFrame.ts index 587ff95db6..9bdd9fc5f8 100644 --- a/packages/create-threenative/templates/starter/src/render/temporalAAFrame.ts +++ b/packages/create-threenative/templates/starter/src/render/temporalAAFrame.ts @@ -11,6 +11,9 @@ import { type TextureNode, } from "three/webgpu"; import { createTemporalAAInput } from "./temporalAAInput.js"; +import type { TemporalClampRelease } from "./temporalClampRelease.js"; +import { createHistoryPresent } from "./temporalHistoryResolve.js"; +import type { TemporalMoire } from "./temporalMoire.js"; import { type ITemporalRejectionCounter, type ITemporalRejectionMeasurement, @@ -36,11 +39,16 @@ export function createTemporalAAFrame( historyValid: { value: number }, rejection: () => TemporalDepthRejection | undefined, publishCurrentDepth?: (renderer: Renderer) => void, + historyScale = 1, + { moire, release }: { moire?: TemporalMoire; release?: TemporalClampRelease } = {}, ) { // Three 0.187's QuadMesh takes a material in the constructor; the draw replaces it before rendering. const quadMesh = new QuadMesh(internals._resolveMaterial); quadMesh.name = "Temporal AA"; const display = new Vector2(); + const history = new Vector2(); + // Above a 1x history the chain reads a display-sized resolve of it, never the history raster. + const present = historyScale === 1 ? undefined : createHistoryPresent(internals, quadMesh); let previousDepth: DepthTexture | null = null; let depthReady = false; let disposed = false; @@ -55,8 +63,8 @@ export function createTemporalAAFrame( * from sampling the seed at shifted history UVs. */ function draw(renderer: Renderer, reset: boolean, currentFrame: number): void { frame = currentFrame; - if (measure(renderer) === undefined) - throw new Error("Temporal AA requires a materialized colour input."); + const source = measure(renderer); + if (source === undefined) throw new Error("Temporal AA requires a materialized colour input."); guardTemporalAASeams(internals); if (display.x < input.x || display.y < input.y) throw new Error("Temporal AA cannot resolve to a display raster below its input raster."); @@ -102,21 +110,24 @@ export function createTemporalAAFrame( renderer.copyTextureToTexture(depth.value, depthHistory); depthReady = true; } - // Resolve and history hold the display raster whatever the input raster is. + // Resolve and history hold the display raster times historyScale, whatever the input raster is. + history.copy(display).multiplyScalar(historyScale); const restarted = - internals._historyRenderTarget.width !== display.x || - internals._historyRenderTarget.height !== display.y; - internals.setSize(display.x, display.y); + internals._historyRenderTarget.width !== history.x || + internals._historyRenderTarget.height !== history.y; + internals.setSize(history.x, history.y); if (restarted) { // A resize disposes the targets, so they need fresh GPU storage before the draw. renderer.initRenderTarget(internals._historyRenderTarget); renderer.initRenderTarget(internals._resolveRenderTarget); } if ( - internals._historyRenderTarget.width !== display.x || - internals._historyRenderTarget.height !== display.y + internals._historyRenderTarget.width !== history.x || + internals._historyRenderTarget.height !== history.y ) - throw new Error("Temporal AA history and resolve must share the display raster."); + throw new Error("Temporal AA history and resolve must share the history raster."); + const before = rejection(); + if (before !== undefined) release?.update(renderer, quadMesh, before, input); renderer.setRenderTarget(internals._resolveRenderTarget); quadMesh.material = internals._resolveMaterial; quadMesh.render(renderer); @@ -130,6 +141,7 @@ export function createTemporalAAFrame( } counter ??= createTemporalRejectionCounter(equations); counter.sample(renderer, frame, display.x, display.y); + moire?.update(renderer, quadMesh, equations, history); } publishCurrentDepth?.(renderer); // History is the resolve this frame produced, at the same size, so the copy never crosses rasters. @@ -138,9 +150,15 @@ export function createTemporalAAFrame( internals._resolveRenderTarget.texture, internals._historyRenderTarget.texture, ); + present?.draw(renderer, display); } finally { if (state !== undefined) RendererUtils.restoreRendererState(renderer, state); } + // A reset seeds a 100% history from a display-sized input; a 2x history cannot take it. + if (reset && historyScale === 1 && input.equals(display)) { + renderer.copyTextureToTexture(source.texture, internals._resolveRenderTarget.texture); + renderer.copyTextureToTexture(source.texture, internals._historyRenderTarget.texture); + } } /** Both rasters, from the real targets: the scene pass one, then the drawing buffer. */ @@ -171,6 +189,9 @@ export function createTemporalAAFrame( previousDepth?.dispose(); previousDepth = null; depthReady = false; + present?.dispose(); + moire?.dispose(); + release?.dispose(); }, }; } diff --git a/packages/create-threenative/templates/starter/src/render/temporalAAResolve.ts b/packages/create-threenative/templates/starter/src/render/temporalAAResolve.ts index 9330d90a77..e791bff613 100644 --- a/packages/create-threenative/templates/starter/src/render/temporalAAResolve.ts +++ b/packages/create-threenative/templates/starter/src/render/temporalAAResolve.ts @@ -2,12 +2,17 @@ import { Fn, int, texture } from "three/tsl"; import type { Node, Renderer } from "three/webgpu"; import type { ITemporalAANode } from "./temporalAAFrame.js"; +import { createTemporalClampRelease } from "./temporalClampRelease.js"; import { type TemporalCurrentFootprint, createTemporalCurrentFootprint, } from "./temporalCurrentFootprint.js"; import type { TemporalCurrentProducer } from "./temporalCurrentProducer.js"; -import { createExperimentalTemporalResolve } from "./temporalResolve.js"; +import { createTemporalMoire } from "./temporalMoire.js"; +import { + type ITemporalHistoryWeighting, + createExperimentalTemporalResolve, +} from "./temporalResolve.js"; import { type TemporalDepthRejection, type TemporalResolveNode, @@ -18,8 +23,14 @@ export function createTemporalAAResolve( node: TemporalResolveNode, jitterOffset: Node<"vec2">, currentProducer?: TemporalCurrentProducer, + weighting?: ITemporalHistoryWeighting, ) { const internals = node as unknown as ITemporalAANode; + const moire = weighting?.flickerRejection === true ? createTemporalMoire(node) : undefined; + const levers = { + moire, + release: weighting?.clampRelease === true ? createTemporalClampRelease(node, moire) : undefined, + }; let rejection: TemporalDepthRejection | undefined; let currentFootprint: TemporalCurrentFootprint | undefined; function configure(renderer: Renderer): void { @@ -51,11 +62,14 @@ export function createTemporalAAResolve( "ordinary", rejection, currentFootprint, + weighting, + levers, ); internals._resolveMaterial.needsUpdate = true; } return { equations: () => rejection, + levers, configure, invalidate: (renderer: Renderer) => { rejection = undefined; diff --git a/packages/create-threenative/templates/starter/src/render/temporalClampRelease.ts b/packages/create-threenative/templates/starter/src/render/temporalClampRelease.ts new file mode 100644 index 0000000000..9b5bb3cdd0 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/temporalClampRelease.ts @@ -0,0 +1,183 @@ +// Generated user source: Unreal TSR's low-frequency clamp release (PRD-539 lever 6). The per-pixel clip +// to the input's 3x3 box erases detail finer than an input texel. Unreal compares input and history only +// after a 3x3 blur, and where they agree it lerps the clamped history back to the unclamped one, so +// sub-texel detail the history gathered survives; disocclusion still forces the clamp. +// UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRShadingAnalysis.ush:25,509-525,563-566, +// 589-620,632-636; TSRRejectShading.usf:863; TSRUpdateHistory.usf:1256-1257; TSRColorSpace.ush:244-248. +import { HalfFloatType, NearestFilter } from "three"; +import { Fn, float, int, ivec2, max, min, mix, texture, uv, vec2, vec3, vec4 } from "three/tsl"; +import { + type Node, + NodeMaterial, + type QuadMesh, + RenderTarget, + type Renderer, + type TextureNode, +} from "three/webgpu"; +import type { TemporalMoire } from "./temporalMoire.js"; +import type { TemporalDepthRejection, TemporalResolveNode } from "./temporalResolveDepth.js"; + +// Unreal's 3x3 blur: centre 1, edges 0.5, corners 0.25, normalised by their sum. +const FILTERING_WEIGHT = 1 / (1 + 4 * 0.5 + 4 * 0.25); +// A 10-bit backbuffer's half step. +const QUANTIZATION = 0.5 / 1024; +const TAPS = [-1, 0, 1].flatMap((y) => [-1, 0, 1].map((x) => [x, y] as const)); + +type Load = (at: Node<"ivec2">) => Node<"vec4">; + +/** Clamped integer taps around this fragment's input pixel. */ +function taps(size: Node<"vec2">, load: Load): Node<"vec4">[] { + const centre = ivec2(uv().mul(size)); + return TAPS.map(([x, y]) => + load(ivec2(vec2(centre.add(ivec2(x, y))).clamp(vec2(0), size.sub(1)))), + ); +} + +const blur = (values: Node<"vec4">[]) => + values.reduce>((sum, value, index) => { + const [x, y] = TAPS[index] ?? [0, 0]; + return sum.add(value.mul(FILTERING_WEIGHT * 0.5 ** (Math.abs(x) + Math.abs(y)))); + }, vec4(0)); + +/** Median of nine by the 19-exchange network; every step is a min/max pair. */ +function median(values: Node<"float">[]): Node<"float"> { + const v = [...values]; + const sort = (a: number, b: number) => { + const low = min(v[a] as Node<"float">, v[b] as Node<"float">); + v[b] = max(v[a] as Node<"float">, v[b] as Node<"float">); + v[a] = low; + }; + for (const [a, b] of "12 45 78 01 34 67 12 45 78 03 58 47 36 14 25 47 42 64 42".split(" ")) + sort(Number(a), Number(b)); + return v[4] as Node<"float">; +} + +function quad(name: string, fragment: () => Node<"vec4">): NodeMaterial { + const material = new NodeMaterial(); + material.name = `Temporal AA clamp release ${name}`; + material.fragmentNode = Fn(fragment)(); + return material; +} + +/** With `moire` (lever 4), the moire error widens the comparison, as in Unreal (TSRShadingAnalysis.ush:569-613). */ +export function createTemporalClampRelease(node: TemporalResolveNode, moire?: TemporalMoire) { + const [guide, filteredInput, filteredHistory, raw, filtered, release] = [0, 1, 2, 3, 4, 5].map( + () => { + const target = new RenderTarget(1, 1, { depthBuffer: false, type: HalfFloatType }); + target.texture.minFilter = NearestFilter; + target.texture.magFilter = NearestFilter; + return target; + }, + ) as [RenderTarget, RenderTarget, RenderTarget, RenderTarget, RenderTarget, RenderTarget]; + const targets = [guide, filteredInput, filteredHistory, raw, filtered, release]; + const released: TextureNode = texture(release.texture); + let passes: + | { materials: [RenderTarget, NodeMaterial][]; equations: TemporalDepthRejection } + | undefined; + + function build(equations: TemporalDepthRejection): [RenderTarget, NodeMaterial][] { + const input = node.beautyNode as TextureNode; + const history = texture(node._historyRenderTarget.texture); + const size = () => vec2(input.size(int(0)) as Node<"uvec2">); + const loader = (target: RenderTarget): Load => { + const source = texture(target.texture); + return (at) => source.load(at) as Node<"vec4">; + }; + return [ + // Last frame's history at this frame's input pixels; alpha 0 where depth says disoccluded. + [ + guide, + quad("guide", () => { + const validity = equations.historyValidity(uv()); + const colour = history.sample(validity.get("historyUV") as Node<"vec2">); + return vec4(colour.rgb, validity.get("hasValidHistory") as Node<"float">); + }), + ], + [ + filteredInput, + quad("input blur", () => blur(taps(size(), (at) => input.load(at) as Node<"vec4">))), + ], + [filteredHistory, quad("history blur", () => blur(taps(size(), loader(guide))))], + [ + raw, + quad("rejection", () => { + const s = size(); + const inputs = taps(s, (at) => input.load(at) as Node<"vec4">); + const box = inputs.reduce((a, b) => max(a, b)).sub(inputs.reduce((a, b) => min(a, b))); + const blurred = taps(s, loader(filteredInput)); + const historyBlur = taps(s, loader(filteredHistory))[4] as Node<"vec4">; + const inputBlur = blurred[4] as Node<"vec4">; + // ponytail: Unreal also widens by the blurred total variation of the input; add it if edges flip. + const error = max(box.mul(FILTERING_WEIGHT * 0.25), QUANTIZATION).add(QUANTIZATION); + let low = blurred.reduce((a, b) => min(a, b)).sub(error); + let high = blurred.reduce((a, b) => max(a, b)).add(error); + let boxSize = box.mul(FILTERING_WEIGHT).add(QUANTIZATION * 2 * FILTERING_WEIGHT); + if (moire !== undefined) { + const historyUV = equations.historyValidity(uv()).get("historyUV") as Node<"vec2">; + const historySize = vec2(history.size(int(0)) as Node<"uvec2">); + const moireError = moire.error( + historyUV, + uv().sub(historyUV).mul(historySize).length(), + ); + const size = vec4(vec3(moireError.mul(3)), 0); + [low, high] = [min(low, high.sub(size)), max(high, low.add(size))]; + const energy = historyBlur.clamp(low, high).sub(historyBlur).abs(); + const share = energy.div(max(energy.r.add(energy.g).add(energy.b), 1e-6)); + boxSize = max(boxSize, share.mul(moireError.mul(FILTERING_WEIGHT * 3))); + } + const delta = max(inputBlur.sub(historyBlur).abs(), boxSize); + const factor = float(1) + .sub(historyBlur.clamp(low, high).sub(historyBlur).abs().div(delta)) + .saturate(); + return vec4(min(factor.r, factor.g, factor.b)); + }), + ], + [filtered, quad("median", () => vec4(median(taps(size(), loader(raw)).map((v) => v.x))))], + // ponytail: no velocity-edge term (Unreal's InputUncertainty); add it if edges of movers ghost. + [ + release, + quad("min", () => { + const guideValid = (texture(guide.texture).load(ivec2(uv().mul(size()))) as Node<"vec4">) + .a; + const lowest = taps(size(), loader(filtered)).reduce((a, b) => min(a, b)).x; + return vec4(lowest.mul(guideValid.greaterThan(0.5).select(float(1), float(0)))); + }), + ], + ]; + } + + return { + /** The clipped history lerped back towards the unclipped one where input and history agree. */ + apply(clipped: Node<"vec4">, history: Node<"vec4">, pixel: Node<"vec2">): Node<"vec4"> { + return mix(clipped, history, released.sample(pixel).x); + }, + /** Before the resolve: it reads this frame's input against last frame's history. */ + update( + renderer: Renderer, + quadMesh: QuadMesh, + equations: TemporalDepthRejection, + input: { x: number; y: number }, + ) { + for (const target of targets) + if (target.width !== input.x || target.height !== input.y) { + target.setSize(input.x, input.y); + renderer.initRenderTarget(target); + } + if (passes?.equations !== equations) { + for (const [, material] of passes?.materials ?? []) material.dispose(); + passes = { materials: build(equations), equations }; + } + for (const [target, material] of passes.materials) { + renderer.setRenderTarget(target); + quadMesh.material = material; + quadMesh.render(renderer); + } + }, + dispose() { + for (const target of targets) target.dispose(); + for (const [, material] of passes?.materials ?? []) material.dispose(); + }, + }; +} + +export type TemporalClampRelease = ReturnType; diff --git a/packages/create-threenative/templates/starter/src/render/temporalHistoryResolve.ts b/packages/create-threenative/templates/starter/src/render/temporalHistoryResolve.ts new file mode 100644 index 0000000000..5003c17ce7 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/temporalHistoryResolve.ts @@ -0,0 +1,90 @@ +// Generated user source: Unreal TSR's history resolve for a history stored above the display raster. +// The chain reads its display-sized output in place of the history, so later post stages pay +// display pixels, not history pixels. +import { HalfFloatType, type Vector2 } from "three"; +import { Fn, float, int, ivec2, max, min, texture, uv, vec2, vec4 } from "three/tsl"; +import { + type Node, + NodeMaterial, + type QuadMesh, + RenderTarget, + type Renderer, + type TextureNode, +} from "three/webgpu"; +import type { ITemporalAANode } from "./temporalResolveDepth.js"; + +/** Mitchell–Netravali, B = C = 1/3, at distance x (UE 5.8.3: Engine/Shaders/Private/ + * TemporalSuperResolution/TSRResolveHistory.usf:82-83). */ +function mitchell(x: number): number { + const b = 1 / 3; + const c = 1 / 3; + const ax = Math.abs(x); + if (ax < 1) + return ((12 - 9 * b - 6 * c) * ax ** 3 + (-18 + 12 * b + 6 * c) * ax ** 2 + (6 - 2 * b)) / 6; + if (ax < 2) + return ( + ((-b - 6 * c) * ax ** 3 + + (6 * b + 30 * c) * ax ** 2 + + (-12 * b - 48 * c) * ax + + (8 * b + 24 * c)) / + 6 + ); + return 0; +} + +/** + * Unreal TSR's history resolve (UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/ + * TSRResolveHistory.usf:312-357): a display pixel takes the 4×4 history pixels around its centre, + * Mitchell-weighted by their distance in history pixels and by Unreal's HDR weight 1 / (R + 2G + B + 4) + * (TSRColorSpace.ush:167-195), then clamped to those samples' range. At a 2x history the centre sits + * between pixels, so the taps lie at ±0.5 and ±1.5 history pixels. + */ +function createHistoryResolve(resolve: TextureNode): NodeMaterial { + const weights = [1.5, 0.5, 0.5, 1.5].map(mitchell); + const material = new NodeMaterial(); + material.name = "Temporal AA history resolve"; + material.fragmentNode = Fn(() => { + const size = vec2(resolve.size(int(0)) as Node<"uvec2">); + const base = ivec2(uv().mul(size).sub(1.5).floor()); + const sum = vec4(0).toVar(); + const weightSum = float(0).toVar(); + const samples: Node<"vec4">[] = []; + for (let y = 0; y < 4; y++) + for (let x = 0; x < 4; x++) { + const at = ivec2(vec2(base.add(ivec2(x, y))).clamp(vec2(0), size.sub(1))); + const colour = (resolve.load(at).max(0) as Node<"vec4">).toVar(); + const hdr = float(1).div(colour.r.add(colour.g.mul(2)).add(colour.b).add(4)); + const weight = hdr.mul((weights[x] ?? 0) * (weights[y] ?? 0)); + sum.addAssign(colour.mul(weight)); + weightSum.addAssign(weight); + samples.push(colour); + } + // The range starts from a real sample: WGSL has no infinity literal. + const low = samples.reduce((a, b) => min(a, b)); + const high = samples.reduce((a, b) => max(a, b)); + return sum.div(weightSum).clamp(low, high); + })(); + return material; +} + +/** The display-sized target the chain reads in place of a 2x history, and its resolve draw. */ +export function createHistoryPresent(internals: ITemporalAANode, quadMesh: QuadMesh) { + const target = new RenderTarget(1, 1, { depthBuffer: false, type: HalfFloatType }); + const material = createHistoryResolve(texture(internals._resolveRenderTarget.texture)); + return { + draw(renderer: Renderer, display: Vector2): void { + if (target.width !== display.x || target.height !== display.y) { + target.setSize(display.x, display.y); + renderer.initRenderTarget(target); + } + internals._textureNode.value = target.texture; + renderer.setRenderTarget(target); + quadMesh.material = material; + quadMesh.render(renderer); + }, + dispose(): void { + target.dispose(); + material.dispose(); + }, + }; +} diff --git a/packages/create-threenative/templates/starter/src/render/temporalMoire.ts b/packages/create-threenative/templates/starter/src/render/temporalMoire.ts new file mode 100644 index 0000000000..e177b07446 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/temporalMoire.ts @@ -0,0 +1,190 @@ +// Generated user source: Unreal TSR's flicker-aware shading rejection (PRD-539 lever 4). A pixel whose +// output luma keeps changing direction against what plain ghosting would give is moire, not new +// shading, so its clip box widens by a bounded luma error and its history survives instead of resetting. +// UE 5.8.3: Engine/Shaders/Private/TemporalSuperResolution/TSRShadingAnalysis.ush:225-229,263-271, +// 381-455,477-490,586-593; the 2-frame period and the 0.95 decay come from +// Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp:177-193,3005-3007. +import { HalfFloatType, NearestFilter, type Vector2 } from "three"; +import { + Fn, + abs, + float, + int, + ivec2, + max, + min, + mix, + texture, + uv, + vec2, + vec3, + vec4, +} from "three/tsl"; +import { + type Node, + NodeMaterial, + type QuadMesh, + RenderTarget, + type Renderer, + type TextureNode, +} from "three/webgpu"; +import type { TemporalDepthRejection, TemporalResolveNode } from "./temporalResolveDepth.js"; + +const DECAY = 0.95; +const PERIOD = 2; +const MIN_GRADIENT = 1 / 127; +const LUMA_TO_CHANNEL = 3; +// Unreal's r.TSR.ShadingRejection.Flickering.MaxParallaxVelocity, in pixels of a 1920-wide output. +const MAX_PARALLAX_PX = 10; + +/** Perceptual luma: the mean of the square-root channels (Unreal stores flicker luma in gamma space). */ +const luma = (colour: Node<"vec4">) => + colour.rgb + .max(0) + .sqrt() + .dot(vec3(1 / 3)); + +/** 1 where the pixel holds still, 0 where it moves. Unreal separates parallax from rotation; this + * counts every motion, so a rotating camera widens fewer boxes than Unreal does. */ +function stillness(speedPx: Node<"float">, displayWidth: Node<"float">) { + const limit = displayWidth.mul(MAX_PARALLAX_PX / 1920); + return float(1).sub(speedPx.div(limit).sub(0.5).saturate()); +} + +export function createTemporalMoire(node: TemporalResolveNode) { + const targets = [0, 1, 2].map(() => { + const target = new RenderTarget(1, 1, { depthBuffer: false, type: HalfFloatType }); + target.texture.minFilter = NearestFilter; + target.texture.magFilter = NearestFilter; + return target; + }); + const [first, , scratch] = targets as [RenderTarget, RenderTarget, RenderTarget]; + let read = 0; + // x: last luma gradient, y: total flicker variation, z: flicker count. Both decay by 0.95 a frame. + const previous: TextureNode = texture(first.texture); + let passes: { materials: NodeMaterial[]; equations: TemporalDepthRejection } | undefined; + + /** The moire luma error the history has measured at this pixel. `speedPx` counts history pixels, + * so the parallax limit scales with the history raster. */ + function error(historyUV: Node<"vec2">, speedPx: Node<"float">): Node<"float"> { + const state = previous.sample(historyUV); + const count = state.z; + const ramp = count + .mul(1 - DECAY ** PERIOD) + .sub(0.5) + .saturate(); + return abs(state.y.div(max(count, 1e-4))) + .add(count.div(127)) + .mul(ramp) + .mul(stillness(speedPx, vec2(previous.size(int(0)) as Node<"uvec2">).x)) + .saturate(); + } + + /** The clip box widened by that error. */ + function widen( + low: Node<"vec4">, + high: Node<"vec4">, + historyUV: Node<"vec2">, + speedPx: Node<"float">, + ) { + const size = vec4(vec3(error(historyUV, speedPx).mul(LUMA_TO_CHANNEL)), 0); + return { low: min(low, high.sub(size)), high: max(high, low.add(size)) }; + } + + /** Pass 1, per pixel: this frame's gradient, and whether it turned against the last one. */ + function detect(equations: TemporalDepthRejection): NodeMaterial { + const resolved = texture(node._resolveRenderTarget.texture); + const history = texture(node._historyRenderTarget.texture); + const result = new NodeMaterial(); + result.name = "Temporal AA moire detect"; + result.fragmentNode = Fn(() => { + const pixel = uv(); + const validity = equations.historyValidity(pixel); + const historyUV = validity.get("historyUV") as Node<"vec2">; + const last = previous.sample(historyUV).x; + // What the output did against pure ghosting (5% current), clamped like Unreal's gradient. + const ghost = mix( + luma(history.sample(historyUV)), + luma((node.beautyNode as TextureNode).sample(pixel)), + 0.05, + ); + const gradient = luma(resolved.sample(pixel)).sub(ghost).clamp(-1, 1); + const turned = gradient.mul(last).lessThan(0); + const strong = min(abs(gradient), abs(last)).greaterThanEqual(MIN_GRADIENT); + const flicker = turned + .and(strong) + .select(float(1), float(0)) + .mul(validity.get("hasValidHistory") as Node<"float">); + return vec4(gradient, min(abs(gradient), abs(last)).mul(flicker), flicker, 1); + })(); + return result; + } + + /** Pass 2: the flicker and its variation dilated over 5x5 (Unreal's max3x3 of max3x3), then + * accumulated into the decayed state of still, valid pixels. */ + function accumulate(equations: TemporalDepthRejection): NodeMaterial { + const detected = texture(scratch.texture); + const result = new NodeMaterial(); + result.name = "Temporal AA moire accumulate"; + result.fragmentNode = Fn(() => { + const pixel = uv(); + const validity = equations.historyValidity(pixel); + const historyUV = validity.get("historyUV") as Node<"vec2">; + const size = vec2(previous.size(int(0)) as Node<"uvec2">); + const centre = ivec2(pixel.mul(size)); + let peak: Node<"vec4"> = detected.load(centre) as Node<"vec4">; + for (let y = -2; y <= 2; y++) + for (let x = -2; x <= 2; x++) + if (x !== 0 || y !== 0) { + const at = ivec2(vec2(centre.add(ivec2(x, y))).clamp(vec2(0), size.sub(1))); + peak = max(peak, detected.load(at) as Node<"vec4">); + } + const state = previous.sample(historyUV); + const speed = pixel.sub(historyUV).mul(size).length(); + const valid = validity.get("hasValidHistory") as Node<"float">; + const keep = stillness(speed, size.x).mul(valid).mul(DECAY); + const own = detected.load(centre) as Node<"vec4">; + return vec4(own.x, state.y.mul(keep).add(peak.y), state.z.mul(keep).add(peak.z), 1); + })(); + return result; + } + + return { + error, + widen, + /** After the resolve, before the history copy: it reads last frame's history at this frame's UVs. */ + update( + renderer: Renderer, + quadMesh: QuadMesh, + equations: TemporalDepthRejection, + history: Vector2, + ) { + for (const target of targets) + if (target.width !== history.x || target.height !== history.y) { + target.setSize(history.x, history.y); + renderer.initRenderTarget(target); + } + if (passes?.equations !== equations) { + for (const material of passes?.materials ?? []) material.dispose(); + passes = { materials: [detect(equations), accumulate(equations)], equations }; + } + const write = targets[1 - read]; + const [detection, accumulation] = passes.materials; + if (write === undefined || detection === undefined || accumulation === undefined) return; + renderer.setRenderTarget(scratch); + quadMesh.material = detection; + quadMesh.render(renderer); + renderer.setRenderTarget(write); + quadMesh.material = accumulation; + quadMesh.render(renderer); + read = 1 - read; + previous.value = write.texture; + }, + dispose() { + for (const target of targets) target.dispose(); + for (const material of passes?.materials ?? []) material.dispose(); + }, + }; +} + +export type TemporalMoire = ReturnType; diff --git a/packages/create-threenative/templates/starter/src/render/temporalResolve.ts b/packages/create-threenative/templates/starter/src/render/temporalResolve.ts index 483717220a..0117c30b61 100644 --- a/packages/create-threenative/templates/starter/src/render/temporalResolve.ts +++ b/packages/create-threenative/templates/starter/src/render/temporalResolve.ts @@ -3,9 +3,11 @@ // from this kernel; `temporalResolveMath.ts` and `temporalResolveDepth.ts` hold its equations. // Derived resolve source: three/examples/jsm/tsl/display/TRAANode.js (pinned 0.185.1). import type TRAANode from "three/addons/tsl/display/TRAANode.js"; -import { Fn, add, float, int, luminance, max, mix, texture, uv } from "three/tsl"; +import { Fn, add, float, int, luminance, max, mix, texture, uv, uvec2, vec2 } from "three/tsl"; import type { Node } from "three/webgpu"; +import type { TemporalClampRelease } from "./temporalClampRelease.js"; import type { TemporalCurrentFootprint } from "./temporalCurrentFootprint.js"; +import type { TemporalMoire } from "./temporalMoire.js"; import { type TemporalDepthRejection, type TemporalResolveNode, @@ -19,6 +21,37 @@ import { export { CATMULL_ROM_BASIS } from "./temporalResolveMath.js"; +/** PRD-539 gate arms, ported from Unreal TSR. `historyScale` stores history and resolve at that + * multiple of the display raster (Unreal's `r.TSR.History.ScreenPercentage` / 100, 1 or 2); the + * next stage samples the resolve bilinearly at display pixel centres, a 2x2 box down-resolve. + * `velocityWeightClamp` caps the accumulated history sample count under motion. */ +export interface ITemporalHistoryWeighting { + historyScale: number; + velocityWeightClamp: boolean; + /** Unreal TSR lever 4: widen the clip box where output luma flickers (`temporalMoire.ts`). */ + flickerRejection?: boolean; + /** Lever 6: release the clip where input and history agree at low frequency. */ + clampRelease?: boolean; +} + +/** + * Unreal TSR at the Epic tier (PRD-539): history at 200% of the display (UE 5.8.3: + * Engine/Config/BaseScalability.ini:89, r.TSR.History.ScreenPercentage; Low to High keep 100) and a + * history weight clamped by velocity. The raw4 current-footprint path was measured at 100% only, so + * it keeps that history. Name `{ historyScale: 1, velocityWeightClamp: false }` to opt out. + */ +export function historyWeighting( + requested: ITemporalHistoryWeighting | undefined, + raw4: boolean, +): ITemporalHistoryWeighting { + if (requested !== undefined && requested.historyScale !== 1 && requested.historyScale !== 2) + throw new Error("Temporal AA historyScale must be 1 or 2."); + if (requested !== undefined) return requested; + return raw4 + ? { historyScale: 1, velocityWeightClamp: false } + : { historyScale: 2, velocityWeightClamp: true }; +} + /** Returns the amount of subpixel (expressed within [0, 1]) in the velocity. */ const subpixelCorrection = Fn(([velocityUV, textureSize]: [Node<"vec2">, Node<"uvec2">]) => { const velocityTexel = velocityUV.mul(textureSize); @@ -73,7 +106,10 @@ export function createExperimentalTemporalResolve( renderer, ), current?: TemporalCurrentFootprint, + weighting: ITemporalHistoryWeighting = { historyScale: 1, velocityWeightClamp: false }, + levers: { moire?: TemporalMoire; release?: TemporalClampRelease } = {}, ) { + const { historyScale, velocityWeightClamp } = weighting; const node = source as TemporalResolveNode; const { historyValidity } = rejection; const { clipAABB } = createTemporalResolveMath(node); @@ -93,9 +129,16 @@ export function createExperimentalTemporalResolve( // Reconstruction: gather the current frame's 3×3 input neighbourhood around the jittered sample // the scene pass drew, and read its moments for the variance clip from the same taps. At a 1:1 // raster the plain current sample is exact; retain it instead of adding Gaussian blur. + // A full-resolution input under a 2x history keeps its owned point (the closest jittered tap) + // rather than the Gaussian gather: native axes are judged against the output raster, so the + // history scale is the only variable that changes. + const outputSize = + historyScale === 1 + ? displaySize + : (uvec2(vec2(displaySize).div(historyScale).round()) as Node<"uvec2">); const reconstruction = current?.reconstruct(uvNode, displaySize) ?? - reconstructNeighbourhood(node.beautyNode, uvNode, inputSize, jitterOffset, displaySize); + reconstructNeighbourhood(node.beautyNode, uvNode, inputSize, jitterOffset, outputSize); const upsampled = inputSize.x.lessThan(displaySize.x).or(inputSize.y.lessThan(displaySize.y)); const currentColor = upsampled.select( reconstruction.get("color") as Node<"vec4">, @@ -106,29 +149,45 @@ export function createExperimentalTemporalResolve( interpolation === "linear" ? historyNode.sample(historyUV) : sampleCatmullRom(historyNode, historyUV).max(0); - const motionFactor = uvNode - .sub(historyUV) - .mul(displaySize) - .length() - .div(node.maxVelocityLength) - .saturate(); + // Motion counts output pixels whatever the history raster, so a 2x history changes only the + // raster the history is stored at. Subpixel correction measures history-texel resampling and + // stays in history texels. + const outputPixelSpeed = uvNode.sub(historyUV).mul(displaySize).length().div(historyScale); + const motionFactor = outputPixelSpeed.div(node.maxVelocityLength).saturate(); const currentWeight = float(0.05).toVar(); // A minimum weight if (node.useSubpixelCorrection) { // Increase the minimum weight towards the current frame when the velocity is more subpixel. currentWeight.addAssign(subpixelCorrection(offsetUV, displaySize).mul(0.25)); } + if (velocityWeightClamp) { + // Unreal TSR's velocity clamp (UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/ + // TemporalSuperResolution.cpp:3219-3224, Engine/Shaders/Private/TemporalSuperResolution/ + // TSRUpdateHistory.usf:1262-1276): a history pixel holds 16 / scale² samples; at 1 output + // px/frame its accumulated count clamps to 4, lerped by speed, unless the luma change between + // history and current is larger (stability on high-contrast edges). A history clamped to N + // samples blends the current frame at 1 / (N + 1). + const historySamples = 16 / (historyScale * historyScale); + const amplitude = Math.min(1, Math.max(0, 1 - 4 / historySamples)); + const historyLuma = luminance(historyColor.rgb); + const currentLuma = luminance(currentColor.rgb); + const stability = historyLuma + .sub(currentLuma) + .abs() + .div(max(historyLuma, currentLuma, 0.00001)); + const maxValidity = max(float(1).sub(outputPixelSpeed.saturate().mul(amplitude)), stability); + currentWeight.assign( + max(currentWeight, float(1).div(maxValidity.mul(historySamples).add(1))), + ); + } currentWeight.assign(hasValidHistory.select(currentWeight.add(motionFactor).saturate(), 1)); // Reasonable gamma range is [0.75, 2] const varianceGamma = mix(0.5, 1, motionFactor.oneMinus().pow2()); const variance = (reconstruction.get("variance") as Node<"vec4">).mul(varianceGamma); - const minColor = mean.sub(variance); - const maxColor = mean.add(variance); - const clippedHistoryColor = clipAABB( - mean.clamp(minColor, maxColor), - historyColor, - minColor, - maxColor, - ); + const box = { low: mean.sub(variance), high: mean.add(variance) }; + const { low: minColor, high: maxColor } = + levers.moire?.widen(box.low, box.high, historyUV, outputPixelSpeed.mul(historyScale)) ?? box; + const clipped = clipAABB(mean.clamp(minColor, maxColor), historyColor, minColor, maxColor); + const clippedHistoryColor = levers.release?.apply(clipped, historyColor, uvNode) ?? clipped; // Every accepted history colour still passes the raw current-tap variance box. // Diagnostic only: ordinary blending isolates luminance reweighting at the same weight. return blend === "ordinary" diff --git a/packages/create-threenative/templates/starter/src/render/temporalResolveDepth.ts b/packages/create-threenative/templates/starter/src/render/temporalResolveDepth.ts index 34ea0fd8a4..0301fa1c20 100644 --- a/packages/create-threenative/templates/starter/src/render/temporalResolveDepth.ts +++ b/packages/create-threenative/templates/starter/src/render/temporalResolveDepth.ts @@ -75,6 +75,8 @@ export interface ITemporalResolvePinned { /** Actual input jitter, in input-raster UVs, captured beside the matching depth matrices. */ _currentJitterUV?: Node<"vec2"> & ITemporalUniform; _previousJitterUV?: Node<"vec2"> & ITemporalUniform; + /** The node the chain reads: Three's pass texture over the resolve target. */ + _textureNode: TextureNode; } export type TemporalResolveNode = TRAANode & ITemporalResolvePinned; diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index eb10bcb6ba..4e88c0bf69 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -155,6 +155,22 @@ "desktopGate": false, "required": false }, + { + "id": "temporal-aa-scaler-route", + "title": "temporal history and allocations across a scripted three-scale input route", + "category": "rendering", + "status": "implemented", + "scene": "conformance/scenes/shared/temporal-aa-scaler-route.js", + "captureFrames": 76, + "browserRecipe": "webgpu", + "requiresHardwareAdapter": true, + "tolerance": { + "pixelMismatchRatio": 0.06, + "perceptualDeltaE": 9.0 + }, + "desktopGate": false, + "required": false + }, { "id": "temporal-aa-quality", "title": "display-sized temporal reconstruction of the authored alpha-tested quality corpus", diff --git a/packages/runtime-native/conformance/run-conformance.mjs b/packages/runtime-native/conformance/run-conformance.mjs index 04253ecc09..474f508d62 100644 --- a/packages/runtime-native/conformance/run-conformance.mjs +++ b/packages/runtime-native/conformance/run-conformance.mjs @@ -862,6 +862,10 @@ async function runBrowser(test, bundlePath, result, port, broker, captureRoot) { page.on("console", (message) => { if (message.text().startsWith("TN_WORLD_GPU_")) worldProofOutput.push(message.text()); }); + let routeDetail; + page.on("console", (message) => { + if (message.text().startsWith("TN_ROUTE_DETAIL ")) routeDetail = JSON.parse(message.text().slice(16)); + }); const completion = broker.wait(test.id, Number(process.env.TN_BROWSER_TIMEOUT_MS || 90_000)); broker.capture(test.id, async (frameLabel) => { const screenshot = join(captureRoot, `${test.id}${frameLabel ? `-${frameLabel}` : ""}.png`); @@ -972,6 +976,7 @@ async function runBrowser(test, bundlePath, result, port, broker, captureRoot) { result.browser.error = error instanceof Error ? error.message : String(error); } if (pageErrors.length > 0) result.status = "fail"; + if (routeDetail !== undefined) result.browser.routeDetail = routeDetail; if (test.id === "world-gpu-scene-readback") { result.browser.worldProof = validateWorldGpuReadbackOutput(worldProofOutput.join("\n")); if (result.browser.worldProof.errors.length > 0) result.status = "fail"; diff --git a/packages/runtime-native/conformance/scenes/shared/scene-support.js b/packages/runtime-native/conformance/scenes/shared/scene-support.js index 41d9e38d4a..49861894f2 100644 --- a/packages/runtime-native/conformance/scenes/shared/scene-support.js +++ b/packages/runtime-native/conformance/scenes/shared/scene-support.js @@ -7,7 +7,7 @@ export function assertCondition(condition, message) { } export async function startVisualScene(canvas, dimensions, type, build, options = {}) { - const renderer = new THREE.WebGPURenderer({ canvas, antialias: false }); + const renderer = new THREE.WebGPURenderer({ canvas, antialias: false, ...options.renderer }); await renderer.init(); renderer.setPixelRatio(1); renderer.setSize(dimensions.width, dimensions.height, false); diff --git a/packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js b/packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js index 7150d41e12..56df8d4f95 100644 --- a/packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js +++ b/packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js @@ -1,5 +1,5 @@ import * as THREE from "three/webgpu"; -import { mrt, pass, texture, uniform, vec2 } from "three/tsl"; +import { Fn, Loop, color, float, mrt, pass, screenUV, sin, texture, uniform, vec2 } from "three/tsl"; import { RenderChain } from "../../../../core/src/render/chain.ts"; import { VelocityTracker, @@ -19,6 +19,11 @@ import { createTemporalVelocityProbe } from "./temporal-velocity-probe.ts"; // any conformance viewport nor a round pixel count, so an off-by-one raster cannot pass for it. const SCALED_RESOLUTION_SCALE = 2 / 3; +// One scripted input-raster route: three scales, stepped twice, with the display raster pinned at +// full size throughout. Each entry names the frame whose render moves the pass's input raster, so +// both hosts meet every step at the same point. +export const SCALER_ROUTE = [[8, 0.75], [16, 0.5], [24, 0.75], [32, 1], [40, 0.75], [48, 0.5], [56, 0.75], [64, 1]]; + // One bounded lifecycle route on the shared fixture, on top of the height-only transition every // scaled arm already makes. Each step is keyed to the real frame, so the browser and native hosts // reach it at the same point, and the pose keeps moving through all of them, so the skinned limb and @@ -100,10 +105,46 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp // deterministic alpha-tested foliage, which is why it scores against its own supersampled // reference. `qualityRole` stays null for every pre-existing arm, so their behaviour is untouched. const qualityRole = variant.startsWith("quality-") ? variant.slice(8).replace(/-open$/u, "") : null; + // PRD-539 Phase 1: Unreal TSR levers 1 and 2, each exactly the temporal arm with one weighting + // change. `policy` already strips the quality prefix and the -open control suffix, so an + // open-history control keeps the same weighting as its paired arm. + // The provider default is both levers (Unreal's Epic tier). Its own controls follow it; the + // fragment-replacing diagnostics keep the 100% history they were measured and written against. + const history100 = { historyScale: 1, velocityWeightClamp: false }; + const followsDefault = ["temporal", "zero-velocity", "unchecked-history", "dynamic-instances", "recompile", "scaler-route"]; + const weighting = { + // The pre-PRD-539 provider default, now a named override: the control the default is scored against. + history100, + history200: { historyScale: 2, velocityWeightClamp: false }, + "velocity-clamp": { historyScale: 1, velocityWeightClamp: true }, + // Lever 4: the default plus Unreal's flicker-aware rejection. + flicker: { historyScale: 2, velocityWeightClamp: true, flickerRejection: true }, + // Lever 6: the default plus Unreal's low-frequency clamp release. + release: { historyScale: 2, velocityWeightClamp: true, clampRelease: true }, + // Levers 4 and 6 together, as Unreal runs them: the moire error widens the release's comparison. + "flicker-release": { + historyScale: 2, + velocityWeightClamp: true, + flickerRejection: true, + clampRelease: true, + }, + }[policy] ?? (followsDefault.includes(policy) ? undefined : history100); // The roles that must genuinely request nothing: no reconstruction stage, no velocity MRT. The // spatial role still renders its low input and presents it through the ordinary texture upsample. const off = variant === "reference" || qualityRole === "reference" || qualityRole === "spatial"; scene.background = new THREE.Color(0x0d1630); + if (variant === "scaler-route") { + // A raster-bound load, so a scale step saves measurable time: an opaque backdrop in the + // background colour whose per-pixel loop adds under one 8-bit step to it. + const load = Fn(() => { + const acc = float(0).toVar(); + Loop(1500, () => acc.assign(sin(acc.mul(1.1).add(screenUV.x)))); + return color(0x0d1630).add(acc.mul(1e-6)); + }); + const backdrop = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), new THREE.MeshBasicNodeMaterial({ colorNode: load() })); + backdrop.position.z = -4; + scene.add(backdrop); + } camera.position.set(0, 0.3, 6); const sun = new THREE.DirectionalLight(0xffffff, 2.0); sun.position.set(2, 5, 3); @@ -180,8 +221,8 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp name: "traa", build: (input, context) => { temporal = options.raw4 - ? createTemporalAA(input, scenePass.getTextureNode("depth"), context.velocityNode, camera, scenePass) - : createTemporalAA(input, scenePass.getTextureNode("depth"), context.velocityNode, camera); + ? createTemporalAA(input, scenePass.getTextureNode("depth"), context.velocityNode, camera, scenePass, weighting) + : createTemporalAA(input, scenePass.getTextureNode("depth"), context.velocityNode, camera, undefined, weighting); if ( options.raw4 && !ownsCurrentInput(input, scenePass.getTextureNode("depth"), context.velocityNode, camera, scenePass) @@ -350,6 +391,10 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp instanceDraw, instanceUploads, measurement, variant, setupCount, setupDuringJitter, occluderVisible: measurement && occluder.visible, raster: raster(), + memory: { + textures: renderer.info.memory.textures, + geometries: renderer.info.memory.geometries, + }, quality: foliage === null ? null : { role: qualityRole, foliageCards: foliage.cards, alphaTest: foliage.alphaTest, leafTextureSize: foliage.textureSize, }, @@ -384,12 +429,15 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp resolve !== undefined && resolve.texture !== undefined, "Freezing a capture needs a reconstruction frame, and none rendered one.", ); + // A history above the display raster presents a display-sized resolve of it: hold that one. + const held = temporal.node._textureNode.value; + const { width, height } = held.image; renderer.getDrawingBufferSize(drawingBuffer); assertCondition( - resolve.width === drawingBuffer.x && resolve.height === drawingBuffer.y, - `A held capture must hold the display raster; the resolve is ${resolve.width}x${resolve.height} against ${drawingBuffer.x}x${drawingBuffer.y}.`, + width === drawingBuffer.x && height === drawingBuffer.y, + `A held capture must hold the display raster; the presented resolve is ${width}x${height} against ${drawingBuffer.x}x${drawingBuffer.y}.`, ); - frozen = { frame, node: texture(resolve.texture), resolve: `${resolve.width}x${resolve.height}` }; + frozen = { frame, node: texture(held), resolve: `${width}x${height}` }; pipeline.outputNode = frozen.node; // The pipeline's own "the output node changed" flag, so the held texture replaces the whole // reconstruction graph rather than only taking over the frames that need no rebuild. @@ -424,7 +472,9 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp (variant === "cut" && frame >= 20) || (lifecycle && frame >= LIFECYCLE_CUT_FRAME) ? 1.2 : 0; - camera.position.x = Math.sin(pose / 50) * 0.15 + cut; + // PRD-539 static-camera control (`options.staticCamera`): the camera holds still and every object + // keeps its motion, so edge error isolates the camera-drift share of the loss. + camera.position.x = (options.staticCamera ? 0 : Math.sin(pose / 50) * 0.15) + cut; if (frame === 20 && variant === "cut") temporal?.resetHistory("camera-cut"); if (frame === 20 && variant === "projection") { camera.fov = 65; camera.updateProjectionMatrix(); } if (frame === 20 && variant === "resize") { renderer.setSize(960, 540, false); camera.aspect = 960 / 540; camera.updateProjectionMatrix(); } @@ -451,6 +501,12 @@ export function createTemporalAAFixture(renderer, scene, camera, variant = "temp // display-sized resolve, its history and the whole-display oracle all stay comparable. if (frame === LIFECYCLE_SCALE_FRAME) scenePass.setResolutionScale(1 / 2); } + // The scaler route moves only the input raster. The camera keeps its ordinary motion and the + // display raster is never touched, so each step's history decision is the raster's own. + if (variant === "scaler-route") { + const step = SCALER_ROUTE.find(([stepFrame]) => stepFrame === frame); + if (step !== undefined) scenePass.setResolutionScale(step[1]); + } tracker.update(scene); velocityProbe?.before(); pipeline.render(); diff --git a/packages/runtime-native/conformance/scenes/shared/temporal-aa-scaler-route.js b/packages/runtime-native/conformance/scenes/shared/temporal-aa-scaler-route.js new file mode 100644 index 0000000000..03e1dbb15e --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/temporal-aa-scaler-route.js @@ -0,0 +1,183 @@ +import { SCALER_ROUTE, createTemporalAAFixture } from "./temporal-aa-fixture.js"; +import { assertRejectionCounts } from "./temporal-aa-scaled.js"; +import { assertCondition, startVisualScene } from "./scene-support.js"; + +// One scripted three-scale input route on the shared fixture. The display raster never moves: only +// the pass's input raster steps between 1, 0.75 and 0.5, twice, so every history invalidation is the +// input raster's own and every allocation the step makes has to return to its starting count. +const LABEL = "temporal scaler route"; +const DIAGNOSTIC_FRAMES = 72; +const BASELINE_FRAME = 7; +const CYCLE_FRAMES = [39, 71]; +const STEP_FRAMES = new Set(SCALER_ROUTE.map(([frame]) => frame)); + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +function scaleAt(frame) { + let scale = 1; + for (const [stepFrame, stepScale] of SCALER_ROUTE) if (frame > stepFrame) scale = stepScale; + return scale; +} + +/** Every step invalidates history on its own frame only, and moves only the input raster. */ +function assertRoute(observed, raster) { + assertRejectionCounts(observed, LABEL, SCALER_ROUTE.length + 1); + const display = raster[0]; + for (const [frame, scale] of SCALER_ROUTE) { + const reset = observed.rejectionFrames[frame]; + assertCondition( + reset?.historyValid === false, + `${LABEL}: the scale step at frame ${frame} reused history instead of invalidating it.`, + ); + assertCondition( + reset?.resetReason === "resize", + `${LABEL}: the scale step at frame ${frame} reported ${String(reset?.resetReason)}, not resize.`, + ); + const next = observed.rejectionFrames[frame + 1]; + assertCondition( + next?.historyValid === true, + `${LABEL}: the reset at frame ${frame} still invalidated history at frame ${frame + 1}.`, + ); + assertCondition( + typeof next?.fraction === "number" && Number.isFinite(next.fraction) && next.fraction < 1, + `${LABEL}: frame ${frame + 1} rejected ${String(next?.fraction)} of its display, so it held no history to reuse.`, + ); + // PassNode.setSize floors each axis of the display raster times the pass's scale. + const inputWidth = Math.floor(display.displayWidth * scale); + const inputHeight = Math.floor(display.displayHeight * scale); + assertCondition( + raster[frame].inputWidth === inputWidth && raster[frame].inputHeight === inputHeight, + `${LABEL}: frame ${frame} rendered a ${raster[frame].inputWidth}x${raster[frame].inputHeight} input raster, not the ${inputWidth}x${inputHeight} the ${scale} scale asks for.`, + ); + assertCondition( + raster[frame].displayWidth === display.displayWidth && + raster[frame].displayHeight === display.displayHeight, + `${LABEL}: frame ${frame} moved the display raster to ${raster[frame].displayWidth}x${raster[frame].displayHeight}, not ${display.displayWidth}x${display.displayHeight}.`, + ); + } +} + +/** Each full cycle returns the live counts to baseline, and no step costs more than a full frame. */ +function assertSteady(memory, gpuMs) { + const baseline = memory[BASELINE_FRAME]; + for (const frame of CYCLE_FRAMES) { + for (const field of ["textures", "geometries"]) { + assertCondition( + memory[frame][field] === baseline[field], + `${LABEL}: ${field} at frame ${frame} is ${memory[frame][field]}, not the ${baseline[field]} baseline at frame ${BASELINE_FRAME}; a cycle leaked allocations.`, + ); + } + } + // The load must be raster bound, or a scale step saves nothing and the spike budget proves nothing. + const steady = steadyGpu(gpuMs); + assertCondition( + steady["0.5"] < steady["1"] * 0.75, + `${LABEL}: half scale costs ${steady["0.5"].toFixed(3)} ms against ${steady["1"].toFixed(3)} ms at full scale; the route is not raster bound.`, + ); + // A spike is the step frame against the frames around it: a GPU clock or contention change hits + // them all (one shared-GPU run read 1.9 ms on frames 63-66 around the step at 64), a reset does not. + // Raster bound, this budget sits under the raster cost a downward step saves. + for (const frame of STEP_FRAMES) { + const budget = median([-2, -1, 1, 2, 3].map((offset) => gpuMs[frame + offset])) * 1.1 + 0.05; + assertCondition( + gpuMs[frame] <= budget, + `${LABEL}: frame ${frame} took ${gpuMs[frame].toFixed(3)} ms of GPU time, over the ${budget.toFixed(3)} ms budget from its neighbour frames; frames: ${gpuMs.map((ms) => ms.toFixed(2)).join(" ")}.`, + ); + } +} + +/** Steady medians per scale, from the frames inside each scale's span and never the step itself. */ +function steadyGpu(gpuMs) { + const spans = new Map(); + for (let frame = 1; frame < DIAGNOSTIC_FRAMES; frame += 1) { + if (STEP_FRAMES.has(frame)) continue; + const scale = String(scaleAt(frame)); + if (!spans.has(scale)) spans.set(scale, []); + spans.get(scale).push(gpuMs[frame]); + } + return Object.fromEntries([...spans].map(([scale, values]) => [scale, median(values)])); +} + +export function startScene(canvas, dimensions) { + return startVisualScene( + canvas, + dimensions, + "temporal-aa-scaler-route", + async ({ renderer, scene, camera }) => { + const fixture = createTemporalAAFixture( + renderer, + scene, + camera, + "scaler-route", + true, + DIAGNOSTIC_FRAMES, + ); + // Per-stage cost: three's inspector sees every render pass's timestamp uid with its scene and + // camera, so the fixture's own scene pass is told apart from the reconstruction passes. + const sceneUids = new Set(); + const beginRender = renderer.inspector.beginRender.bind(renderer.inspector); + renderer.inspector.beginRender = (uid, drawn, eye, target) => { + if (drawn === scene && eye === camera) sceneUids.add(uid); + beginRender(uid, drawn, eye, target); + }; + const gpuMs = []; + const sceneMs = []; + const memory = []; + const raster = []; + for (let frame = 0; frame < DIAGNOSTIC_FRAMES; frame += 1) { + // A real frame boundary before every diagnostic render: a temporal node's own frame update + // waits on the animation clock, so renders issued back-to-back never advance it. + await new Promise((resolve) => requestAnimationFrame(resolve)); + fixture.render(); + await fixture.sampleVelocity(); + // three's own per-frame query pair, in milliseconds, and the same value it publishes as + // renderer.info.render.timestamp. A missing or zero number means the adapter cannot time. + const measured = await renderer.resolveTimestampsAsync("render"); + assertCondition( + typeof measured === "number" && Number.isFinite(measured) && measured > 0, + `${LABEL}: frame ${frame} reported the GPU render time ${String(measured)} (timestamp-query ${renderer.hasFeature("timestamp-query")}, tracking ${renderer.backend.trackTimestamp}); this row requires a hardware adapter with timestamp-query.`, + ); + const timed = [...renderer.backend.timestampQueryPool.render.timestamps].filter(([uid]) => + sceneUids.has(uid), + ); + assertCondition(timed.length > 0, `${LABEL}: frame ${frame} timed no scene pass.`); + const observed = fixture.observation(); + gpuMs.push(measured); + sceneMs.push(timed.reduce((sum, [, ms]) => sum + ms, 0)); + memory.push(observed.memory); + raster.push(observed.raster); + } + const observed = fixture.observation(); + assertRoute(observed, raster); + assertSteady(memory, gpuMs); + // Every diagnostic render and readback is done, so the capture holds this route's last real + // frame instead of whichever later frame each host happened to stop on. + fixture.freeze(); + const detail = { + milestone: "temporal-scaler-route", + qualification: "experimental", + route: SCALER_ROUTE, + baseline: { frame: BASELINE_FRAME, ...memory[BASELINE_FRAME] }, + cycles: CYCLE_FRAMES.map((frame) => ({ frame, ...memory[frame] })), + gpuMs: { + steady: steadyGpu(gpuMs), + steps: SCALER_ROUTE.map(([frame]) => [frame, gpuMs[frame]]), + }, + // The scene pass alone, and everything else (reconstruction, history resolve, present). + sceneMs: { + steady: steadyGpu(sceneMs), + steps: SCALER_ROUTE.map(([frame]) => [frame, sceneMs[frame]]), + }, + restMs: { steady: steadyGpu(gpuMs.map((ms, frame) => ms - sceneMs[frame])) }, + }; + // The runner keeps this line in the report (result.browser.routeDetail). + console.info(`TN_ROUTE_DETAIL ${JSON.stringify(detail)}`); + return { ...fixture, detail }; + }, + { renderer: { trackTimestamp: true } }, + ); +} diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index 2335154492..b5dbe3161f 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -196,7 +196,7 @@ constexpr const char* kSlotNames[] = { "specularIntensityMapUvTransform", "clearcoatMapUvTransform", "clearcoatRoughnessMapUvTransform", "clearcoatNormalMapUvTransform", "aoMapIntensity", "clearcoat", "clearcoatRoughness", "clearcoatNormalScale", "bumpScale", "pmremMaxMip", "pmremRotation", "screenSize", "cameraNear", "cameraFar", - "modelNormalMatrix", "subsurfaceColor", "shadowNoiseFrame"}; + "modelNormalMatrix", "subsurfaceColor", "shadowNoiseFrame", "viewMipBias"}; constexpr const char* kLightFieldNames[] = {"Color", "Direction", "Position", "Distance", "Decay", "Axis", "ConeCos", "PenumbraCos", "ShadowMatrix", "ShadowBias", "ShadowNormalBias", "ShadowRadius", @@ -1474,6 +1474,13 @@ void Renderer::resolveDepth(WGPUCommandEncoder encoder) { wgpuBindGroupLayoutRelease(layout); } +double Renderer::viewMipBias() const { + if (!temporalStatus_.active) return 0; + const double fraction = traa_ ? double(renderScale_) : 1.0; + const double bias = std::clamp(std::max(-std::max(-std::log2(fraction), 0.0) - 0.3, -2.0), -4.0, 4.0); + return std::ceil(bias * 128.0) / 128.0; // 1024 steps over [-4, 4] +} + void Renderer::setRenderScale(float scale) { if (!std::isfinite(scale) || scale < 0.25f || scale > 1.f) throw std::runtime_error("TN_RENDER_SCALE_INVALID: expected a finite fraction in [0.25, 1]"); @@ -3145,9 +3152,11 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS if (!compilation) { std::vector autoLamps; std::vector demands; - Matrix4 view; view.elements = camera.matrixWorldInverse; - Matrix4 viewProjection; viewProjection.elements = camera.projectionMatrix; viewProjection.multiply(view); - const auto& p = camera.projectionMatrix; + // The unjittered view: a pixel's footprint does not move with the TRAA subpixel offset, and a jittered + // frustum flips a receiver on the view's edge in and out, which reallocated the map every frame (PRD-539). + Matrix4 view; view.elements = unjitteredCamera.matrixWorldInverse; + Matrix4 viewProjection; viewProjection.elements = unjitteredCamera.projectionMatrix; viewProjection.multiply(view); + const auto& p = unjitteredCamera.projectionMatrix; const double screenRadius = std::min(1 / (inputWidth_ * std::abs(p[0])), 1 / (inputHeight_ * std::abs(p[5]))); const WGPULimits& limits = deviceLimits(); for (std::size_t i = 0; i < lights.direct.size(); ++i) { @@ -3687,6 +3696,7 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS } } put(block, f, fs[kShadowNoiseFrame], std::array{shadowNoiseFrame()}); + put(block, f, fs[kViewMipBias], std::array{viewMipBias()}); put(block, f, fs[kHemisphereSky], lights.hemisphereSky); put(block, f, fs[kHemisphereGround], lights.hemisphereGround); put(block, f, fs[kHemisphereDirection], lights.hemisphereUp); // world space: it meets normalWorld diff --git a/packages/runtime-native/src/engine/renderer/renderer.h b/packages/runtime-native/src/engine/renderer/renderer.h index afc4f91b4f..a74bef94a9 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.h +++ b/packages/runtime-native/src/engine/renderer/renderer.h @@ -509,6 +509,14 @@ class Renderer { const TemporalStatus& temporalStatus() const { return temporalStatus_; } /** The PCF rotation's frame term (`shadowNoiseFrame`): the temporal pass's frame mod 8 (Unreal's StateFrameIndexMod8), else 0. */ double shadowNoiseFrame() const { return temporalStatus_.active ? double(temporalStatus_.frame % 8) : 0; } + /** + * Unreal's automatic view mip bias under temporal upscaling (`viewMipBias`): material textures sample + * log2(scene fraction) - 0.3 mips sharper, at least -2, quantized to 1/128, so the jittered history gathers + * texture detail the input raster alone cannot hold (UE 5.8.3: Engine/Source/Runtime/Renderer/Private/ + * SceneVisibility.cpp:5337-5351, r.ViewTextureMipBias.Offset -0.3, .Min -2, .Quantization 1024 at :179-194). + * 0 without a temporal pass, as Unreal's non-upscaled views. + */ + double viewMipBias() const; void cutHistory(); const OutputState& output() const { return output_; } @@ -866,7 +874,7 @@ class Renderer { kSpecularIntensityMapUvTransform, kClearcoatMapUvTransform, kClearcoatRoughnessMapUvTransform, kClearcoatNormalMapUvTransform, kAoMapIntensity, kClearcoat, kClearcoatRoughness, kClearcoatNormalScale, kBumpScale, kPmremMaxMip, kPmremRotation, kScreenSize, kCameraNear, kCameraFar, - kModelNormalMatrix, kSubsurfaceColor, kShadowNoiseFrame, kSlotCount + kModelNormalMatrix, kSubsurfaceColor, kShadowNoiseFrame, kViewMipBias, kSlotCount }; // Per direct light i, `light{i}` (shader::LightLayout). enum LightField : uint8_t { kLightColor, kLightDirection, kLightPosition, kLightDistance, kLightDecay, kLightAxis, diff --git a/packages/runtime-native/src/engine/shader/graph/graph.cpp b/packages/runtime-native/src/engine/shader/graph/graph.cpp index 98a092c33e..f3a81a9a84 100644 --- a/packages/runtime-native/src/engine/shader/graph/graph.cpp +++ b/packages/runtime-native/src/engine/shader/graph/graph.cpp @@ -470,8 +470,15 @@ ExprId Lowerer::emit(Node node) { return d.sampleBias ? program_.sampleBias(texture, coordinate, level) : program_.sampleLevel(texture, coordinate, level); } - return program_.sample(program_.expr(coordinate).type == Type::vec(3) - ? program_.texture3d(d.name) : program_.texture2d(d.name), coordinate); + const bool volume = program_.expr(coordinate).type == Type::vec(3); + const auto texture = volume ? program_.texture3d(d.name) : program_.texture2d(d.name); + // A material's 2D texture() reads at the view mip bias (PRD-539 lever 7): surface detail the + // reduced raster loses. The material lowering marks itself with a `viewMipBias` input and the + // uniform is declared here, at first use, so a node material without a texture keeps its + // layout. Volumes (probe GI, LUTs) and post or compute graphs keep the implicit LOD. + if (!volume && inputs_.count("viewMipBias") && program_.stage() == Stage::Fragment) + return program_.sampleBias(texture, coordinate, program_.uniform("viewMipBias", Type::f32())); + return program_.sample(texture, coordinate); } case Kind::StorageElement: return program_.loadStorage(ensureStorage(d), index(node)); diff --git a/packages/runtime-native/src/engine/shader/standard.cpp b/packages/runtime-native/src/engine/shader/standard.cpp index 74817971fd..8cc31a9ccb 100644 --- a/packages/runtime-native/src/engine/shader/standard.cpp +++ b/packages/runtime-native/src/engine/shader/standard.cpp @@ -435,13 +435,19 @@ static ExprId perturbNormal2Arb(Program& f, ExprId eye, ExprId uv, ExprId surfac // The tint varying: the instance colour (vec3), or a vec4 once a vertex colour multiplies it. static Type tintType(const VertexVariant& variant) { return Type::vec(variant.vertexColors ? 4 : 3); } +// A material texture read at the renderer's `viewMipBias` (0 unless a temporal pass upscales; see +// Renderer::viewMipBias), as Unreal biases every material sampler of an upscaled view. +static ExprId materialSample(Program& f, uint32_t texture, ExprId at) { + return f.sampleBias(texture, at, f.uniform("viewMipBias", Type::f32())); +} + static ExprId perturbedNormal(Program& f, const VertexVariant& variant, ExprId surfaceNormal) { const ExprId eye = f.varying("positionView", Type::vec(3)); if (variant.tinted()) f.varying("instanceColor", tintType(variant)); const ExprId uv = f.varying("uv", Type::vec(2)); const ExprId transform = f.uniform("normalUvTransform", Type::mat(3, 3)); const ExprId at = f.swizzle(f.mul(transform, f.construct(Type::vec(3), {uv, f.constant(1.0f)})), "xy"); - const ExprId texel = f.swizzle(f.sample(f.texture2d("normalMap"), at), "xyz"); + const ExprId texel = f.swizzle(materialSample(f, f.texture2d("normalMap"), at), "xyz"); const ExprId decoded = f.sub(f.mul(texel, f.constant(2.0f)), f.constant(1.0f)); const ExprId scaled = f.mul(f.swizzle(decoded, "xy"), f.uniform("normalScale", Type::vec(2))); const ExprId mapN = f.construct(Type::vec(3), {scaled, f.swizzle(decoded, "z")}); @@ -474,7 +480,7 @@ static ExprId pbrTexel(Program& f, const VertexVariant& variant, PbrMap map) { if (variant.tinted()) f.varying("instanceColor", tintType(variant)); const ExprId uv = f.varying("uv", Type::vec(2)); const ExprId transform = f.uniform(name + "UvTransform", Type::mat(3, 3)); - return f.sample(f.texture2d(name), f.swizzle(f.mul(transform, f.construct(Type::vec(3), {uv, f.constant(1.0f)})), "xy")); + return materialSample(f, f.texture2d(name), f.swizzle(f.mul(transform, f.construct(Type::vec(3), {uv, f.constant(1.0f)})), "xy")); } // MaterialNode.NORMAL with a bumpMap and no normalMap: three's BumpMapNode (Mikkelsen's surface @@ -488,7 +494,7 @@ static ExprId bumpedNormal(Program& f, const VertexVariant& variant, ExprId surf const uint32_t map = f.texture2d(kPbrMapNames[kBumpMap]); const auto height = [&](ExprId coordinate) { const ExprId at = f.swizzle(f.mul(transform, f.construct(Type::vec(3), {coordinate, f.constant(1.0f)})), "xy"); - return f.swizzle(f.sample(map, at), "x"); + return f.swizzle(materialSample(f, map, at), "x"); }; const ExprId scale = f.uniform("bumpScale", Type::f32()); const ExprId Hll = height(uv); @@ -527,7 +533,7 @@ static ExprId mapTexel(Program& f, const VertexVariant& variant) { const ExprId uv = f.varying("uv", Type::vec(2)); const ExprId transform = f.uniform("uvTransform", Type::mat(3, 3)); const ExprId at = f.swizzle(f.mul(transform, f.construct(Type::vec(3), {uv, f.constant(1.0f)})), "xy"); - const ExprId texel = f.sample(f.texture2d("map"), at); + const ExprId texel = materialSample(f, f.texture2d("map"), at); if (!variant.mapSRGB) return texel; const ExprId rgb = f.construct(Type::vec(3), {srgbDecode(f, f.swizzle(texel, "x")), srgbDecode(f, f.swizzle(texel, "y")), @@ -560,7 +566,7 @@ static ExprId nodeValue(Program& f, const graph::Node& node, Type type, ExprId f ExprId normalView, ExprId geometryNormal) { if (!node) return fallback; tsl::Build build(f); - std::unordered_map inputs; + std::unordered_map inputs{{"viewMipBias", kInvalid}}; // a material: its 2D texture() nodes take the view mip bias if (normalView != kInvalid) inputs.emplace("normalView", normalView); if (geometryNormal != kInvalid) inputs.emplace("normalViewGeometry", geometryNormal); return nodeType(f, graph::lower(node, f, inputs), type); @@ -597,7 +603,7 @@ static void materialInputs(Program& f, const VertexVariant& variant, // A slot read through the program's one lowering: a node two slots share (a march feeding colour and // depth) lowers once, as three builds a material once. `normalView` is the slot input every slot sees. static graph::Lowering slotLowering(Program& f, const VertexVariant& variant, ExprId normalView) { - std::unordered_map inputs; + std::unordered_map inputs{{"viewMipBias", kInvalid}}; // a material: its 2D texture() nodes take the view mip bias if (normalView != kInvalid) inputs.emplace("normalView", normalView); materialInputs(f, variant, inputs); return graph::Lowering(f, std::move(inputs)); diff --git a/packages/runtime-native/tests/native-engine/material_test.cpp b/packages/runtime-native/tests/native-engine/material_test.cpp index 9350ffc351..33b2bbc76e 100644 --- a/packages/runtime-native/tests/native-engine/material_test.cpp +++ b/packages/runtime-native/tests/native-engine/material_test.cpp @@ -60,13 +60,17 @@ void alphaHash() { if (!vs.wgsl.ok() || !fs.wgsl.ok()) continue; CHECK(fs.wgsl.code.find("dpdx(") != std::string::npos); CHECK(fs.wgsl.code.find("i_positionLocal") != std::string::npos); + // PRD-539 lever 7: the map reads at the renderer's view mip bias, Unreal's upscaled-view rule. + CHECK(fs.wgsl.code.find("textureSampleBias(t_map, smp_map,") != std::string::npos); + CHECK(fs.wgsl.code.find("u.f_viewMipBias") != std::string::npos); stagesAgree(p.fragment, vs.wgsl.code, fs.wgsl.code); } const auto depth = buildDepth(variant); const auto vs = buildStage(depth.vertex, 0), fs = buildStage(depth.fragment, 1); CHECK(vs.wgsl.ok() && fs.wgsl.ok()); CHECK(fs.wgsl.code.find("@builtin(frag_depth)") != std::string::npos); - CHECK(fs.wgsl.code.find("textureSample(") != std::string::npos); + // The shadow pass leaves viewMipBias zero-filled: Unreal's shadow views sample at bias 0. + CHECK(fs.wgsl.code.find("textureSampleBias(t_map") != std::string::npos); CHECK(fs.wgsl.code.find("dpdx(") != std::string::npos); stagesAgree(depth.fragment, vs.wgsl.code, fs.wgsl.code); CHECK(variant.key() != VertexVariant{}.key()); @@ -781,4 +785,29 @@ void depthTextureLowering() { } // namespace -TN_TEST_MAIN({"alpha_hash", alphaHash}, {"ssr_passes", ssrPasses}, {"denoise_reads_raw_normals", denoiseReadsRawNormals}, {"ssr_rejects_inputs", ssrRejectsInputs}, {"depth_texture_lowering", depthTextureLowering}, {"depth_node_writes_frag_depth", depthNodeWritesFragDepth}, {"unsupported", unsupported}, {"emissive_output", emissiveOutput}, {"builds", builds}, {"fog", fog}, {"rough_diffuse", roughDiffuse}, {"fog_node_mixes_color", fogNodeMixesColor}, {"node_key", nodeKey}, {"bloom_passes", bloomPasses}, {"slots_share_one_lowering", slotsShareOneLowering}) +// PRD-539 lever 7: every implicit-LOD material texture reads at the view mip bias (UE 5.8.3: +// SceneVisibility.cpp:5337-5351 sets MaterialTextureMipBias for all material samplers), including a +// node material's texture() node, which Unreal compiles as a TextureSample of the material. +void viewMipBiasSamples() { + namespace g = tn::engine::shader::graph; + VertexVariant variant; + variant.normalMap = true; + variant.pbrMaps = static_cast(1u << kRoughnessMap); + VertexVariant bumped; // three's BumpMapNode runs only without a normalMap + bumped.pbrMaps = static_cast(1u << kBumpMap); + const std::string lit = WgslEmitter::emit(buildStandard({}, variant).fragment, 1).code + + WgslEmitter::emit(buildStandard({}, bumped).fragment, 1).code; + for (const char* map : {"t_normalMap", "t_roughnessMap", "t_bumpMap"}) { + const bool biased = lit.find(std::string("textureSampleBias(") + map) != std::string::npos; + if (!biased) std::fprintf(stderr, "%s reads without the view mip bias\n", map); + CHECK(biased); + } + VertexVariant node; + node.nodes.colorNode = g::texture("detail", g::uv()); + const auto colored = WgslEmitter::emit(buildBasic(node).fragment, 1); + CHECK(colored.ok()); + CHECK(colored.code.find("textureSampleBias(t_detail") != std::string::npos); + CHECK(colored.code.find("u.f_viewMipBias") != std::string::npos); +} + +TN_TEST_MAIN({"alpha_hash", alphaHash}, {"view_mip_bias_samples", viewMipBiasSamples}, {"ssr_passes", ssrPasses}, {"denoise_reads_raw_normals", denoiseReadsRawNormals}, {"ssr_rejects_inputs", ssrRejectsInputs}, {"depth_texture_lowering", depthTextureLowering}, {"depth_node_writes_frag_depth", depthNodeWritesFragDepth}, {"unsupported", unsupported}, {"emissive_output", emissiveOutput}, {"builds", builds}, {"fog", fog}, {"rough_diffuse", roughDiffuse}, {"fog_node_mixes_color", fogNodeMixesColor}, {"node_key", nodeKey}, {"bloom_passes", bloomPasses}, {"slots_share_one_lowering", slotsShareOneLowering}) diff --git a/packages/runtime-native/tests/native-engine/render_database_test.cpp b/packages/runtime-native/tests/native-engine/render_database_test.cpp index b9434fd19a..7fc80e0c39 100644 --- a/packages/runtime-native/tests/native-engine/render_database_test.cpp +++ b/packages/runtime-native/tests/native-engine/render_database_test.cpp @@ -4301,10 +4301,13 @@ void overlayOverFrame() { if (status == GpuStatus::Ok) presented = std::move(pixels); read = true; }) == GpuStatus::Ok); - for (int i = 0; i < 4000 && !read; ++i) { + // A time bound, not a poll count: on a loaded host 4000 polls can end before the readback lands. + for (const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(10); + !read && std::chrono::steady_clock::now() < until;) { renderer.poll(); events.drain(); } + CHECK(read); const size_t centre = (8 * 16 + 8) * 4; CHECK(presented.size() == 16 * 16 * 4 && presented[centre] == over[0] && presented[centre + 1] == over[1]); const uint64_t uploads = renderer.overlayUploads(); @@ -5678,6 +5681,51 @@ void lampAtlas() { [](const std::string& d) { return d.starts_with("TN_LAMP_SHADOW_MAPSIZE_OVERRIDE"); })); } +// PRD-539: a still camera under TRAA keeps one lamp map size. The footprint is a pixel's size, so the subpixel +// jitter must not move it: a jittered frustum let a receiver on the view's edge enter and leave, flipped the map +// one mip every frame and reallocated it, out of device memory in 120 Wasm frames at a 50% screen percentage. +void lampSizeIgnoresTemporalJitter() { + mystral::webgpu::Context context; + CHECK(context.initializeHeadless()); + EventQueue events; + Renderer renderer(context.getInstance(), context.getDevice(), context.getQueue(), events); + renderer.setSize(256, 256); + renderer.setTraa(TraaOptions{}); + renderer.setRenderScale(0.5f); + PerspectiveCamera camera(50, 1, 0.1, 100); + camera.position.set(0, 1, 0); + camera.lookAt(0, 1, -1); + Scene scene; + SpotLight spot{Color(1, 1, 1), 40, 0, 1.0}; + spot.position.set(0, 3, -1); + spot.target->position.set(0, 0, -1); + spot.setCastShadow(true); + scene.add(spot); + scene.add(*spot.target); + auto white = std::make_shared(MaterialType::Standard); + // A 2 mm receiver centred on the right edge of the view, half a metre away: its finest footprint decides the + // map while it is seen. The far receiver decides it otherwise, at least one mip coarser. + Mesh edge{makeBoxGeometry(0.002, 0.002, 0.002), white}; + edge.position.set(0.5 * std::tan(25 * M_PI / 180), 1, -0.5); + edge.setReceiveShadow(true); + scene.add(edge); + Mesh far{makeBoxGeometry(0.5, 0.5, 0.5), white}; + far.position.set(0, 1, -3); + far.setReceiveShadow(true); + far.setCastShadow(true); + scene.add(far); + scene.updateMatrixWorld(true); + RenderDatabase database; + database.shadowMapEnabled = true; + std::set sizes; + for (int frame = 0; frame < 16; ++frame) { + database.render(renderer, scene, camera); + sizes.insert(renderer.lampShadowSize(0)); + } + std::printf("lamp jitter: %zu sizes over 16 TRAA frames (first %u)\n", sizes.size(), *sizes.begin()); + CHECK(sizes.size() == 1); +} + void contactShadowFoot() { mystral::webgpu::Context context; CHECK(context.initializeHeadless()); @@ -8668,7 +8716,7 @@ void staticInstancesAreNotRecopied() { CHECK(frame(-12).copies == 0); } -TN_TEST_MAIN({"virtual_shadow_depth_holds", virtualShadowDepthHoldsWhileCastersFit}, {"virtual_shadow_pan_redraws_entering_rects", virtualShadowPanRedrawsTheRectsThatEnter}, {"virtual_shadow_page_jobs", virtualShadowPageJobs}, {"caster_record_allocates_per_frame", casterRecordAllocatesPerFrame}, {"virtual_shadow_instance_pages", virtualShadowInstancePages}, {"virtual_shadow_page_draws_only_its_instances", virtualShadowPageDrawsOnlyItsInstances},{"virtual_shadow_bounds_cache", virtualShadowBoundsCache}, {"gpu_joint_production", gpuJointProduction}, {"virtual_shadow_casters_batch_in_main_pass", virtualShadowCastersBatchInMainPass}, {"virtual_shadow_rigs_batch_in_main_pass", virtualShadowRigsBatchInMainPass}, {"virtual_shadow_pages_instance_skinned_casters", shadowPagesInstanceSkinnedCasters}, {"skinned_palettes_share_by_bytes", skinnedPalettesShareByBytes}, {"clone_keeps_shared_skeletons", cloneKeepsSharedSkeletons}, {"compute_kernel_walks_once", computeKernelWalksOnce}, {"bone_reach_bounds_hold_posed_vertices", boneReachBoundsHoldPosedVertices}, {"multi_material_groups", multiMaterialGroups}, {"uniform_batch_preparation", uniformBatchPreparation}, {"content_identical_geometry_batches", contentIdenticalGeometryBatches}, {"steady_state", steadyState}, {"steady_state_draw_refresh_work", steadyStateDrawRefreshWork}, {"draw_refresh_guard_holes", drawRefreshGuardHoles}, {"color_write_masks_the_draw", colorWriteMasksTheDraw}, {"depth_test_off_draws_over_nearer", depthTestOffDrawsOverNearer}, {"normal_pass_is_not_tallied", normalPassIsNotTallied}, {"gtao_screen_reach", gtaoScreenReach}, {"steady_cache_invalidation", steadyCacheInvalidation}, {"render_target", renderTarget}, {"float_render_target_keeps_float32", floatRenderTargetKeepsFloat32}, {"float_render_target_falls_back_to_half", floatRenderTargetFallsBackToHalf}, {"post_texture_resize_keeps_draw_groups", postTextureResizeKeepsDrawGroups}, {"deferred_target_follows_post_rtt", deferredTargetFollowsPostRtt}, {"deferred_target_samples_post_rtt_node", deferredTargetSamplesPostRttNode}, {"screen_size_reaches_the_vertex_stage", screenSizeReachesTheVertexStage}, {"depth_node_sets_fragment_depth", depthNodeSetsFragmentDepth}, {"depth_node_reaches_every_material_program", depthNodeReachesEveryMaterialProgram}, {"scene_environment", sceneEnvironment}, {"lit_scene", litScene}, {"scene_stage_is_metered", sceneStageIsMetered}, {"overlay_leaves_stage_totals", overlayLeavesStageTotals}, {"present_direct", presentDirect}, {"canvas_layer_overlay", canvasLayerOverlay}, {"post_survives_overlay", postSurvivesOverlay}, {"invariant_scope", invariantScope}, {"instance_counts", instanceCounts}, {"target_samples_late_traa_output", targetSamplesLateTraaOutput}, {"unchanged_frame_storage", unchangedFrameStorage}, {"static_instances_are_not_recopied", staticInstancesAreNotRecopied}, {"instance_places_survive_a_draw_leaving_the_view", instancePlacesSurviveADrawLeavingTheView}, {"instance_upload_is_stable_across_a_camera_turn", instanceUploadIsStableAcrossACameraTurn}, {"flat_lane_equivalence", flatLaneEquivalence}, {"directional_target", directionalTarget}, {"directional_target_outside_scene", directionalTargetOutsideScene}, {"scene_fog_node", sceneFogNode}, {"lit_mesh_without_normals", litMeshWithoutNormals}, {"invalidation", invalidation}, {"alpha_scene", alphaScene}, {"frame_slab_is_per_program", frameSlabIsPerProgram}, +TN_TEST_MAIN({"virtual_shadow_depth_holds", virtualShadowDepthHoldsWhileCastersFit}, {"virtual_shadow_pan_redraws_entering_rects", virtualShadowPanRedrawsTheRectsThatEnter}, {"virtual_shadow_page_jobs", virtualShadowPageJobs}, {"lamp_size_ignores_temporal_jitter", lampSizeIgnoresTemporalJitter}, {"caster_record_allocates_per_frame", casterRecordAllocatesPerFrame}, {"virtual_shadow_instance_pages", virtualShadowInstancePages}, {"virtual_shadow_page_draws_only_its_instances", virtualShadowPageDrawsOnlyItsInstances},{"virtual_shadow_bounds_cache", virtualShadowBoundsCache}, {"gpu_joint_production", gpuJointProduction}, {"virtual_shadow_casters_batch_in_main_pass", virtualShadowCastersBatchInMainPass}, {"virtual_shadow_rigs_batch_in_main_pass", virtualShadowRigsBatchInMainPass}, {"virtual_shadow_pages_instance_skinned_casters", shadowPagesInstanceSkinnedCasters}, {"skinned_palettes_share_by_bytes", skinnedPalettesShareByBytes}, {"clone_keeps_shared_skeletons", cloneKeepsSharedSkeletons}, {"compute_kernel_walks_once", computeKernelWalksOnce}, {"bone_reach_bounds_hold_posed_vertices", boneReachBoundsHoldPosedVertices}, {"multi_material_groups", multiMaterialGroups}, {"uniform_batch_preparation", uniformBatchPreparation}, {"content_identical_geometry_batches", contentIdenticalGeometryBatches}, {"steady_state", steadyState}, {"steady_state_draw_refresh_work", steadyStateDrawRefreshWork}, {"draw_refresh_guard_holes", drawRefreshGuardHoles}, {"color_write_masks_the_draw", colorWriteMasksTheDraw}, {"depth_test_off_draws_over_nearer", depthTestOffDrawsOverNearer}, {"normal_pass_is_not_tallied", normalPassIsNotTallied}, {"gtao_screen_reach", gtaoScreenReach}, {"steady_cache_invalidation", steadyCacheInvalidation}, {"render_target", renderTarget}, {"float_render_target_keeps_float32", floatRenderTargetKeepsFloat32}, {"float_render_target_falls_back_to_half", floatRenderTargetFallsBackToHalf}, {"post_texture_resize_keeps_draw_groups", postTextureResizeKeepsDrawGroups}, {"deferred_target_follows_post_rtt", deferredTargetFollowsPostRtt}, {"deferred_target_samples_post_rtt_node", deferredTargetSamplesPostRttNode}, {"screen_size_reaches_the_vertex_stage", screenSizeReachesTheVertexStage}, {"depth_node_sets_fragment_depth", depthNodeSetsFragmentDepth}, {"depth_node_reaches_every_material_program", depthNodeReachesEveryMaterialProgram}, {"scene_environment", sceneEnvironment}, {"lit_scene", litScene}, {"scene_stage_is_metered", sceneStageIsMetered}, {"overlay_leaves_stage_totals", overlayLeavesStageTotals}, {"present_direct", presentDirect}, {"canvas_layer_overlay", canvasLayerOverlay}, {"post_survives_overlay", postSurvivesOverlay}, {"invariant_scope", invariantScope}, {"instance_counts", instanceCounts}, {"target_samples_late_traa_output", targetSamplesLateTraaOutput}, {"unchanged_frame_storage", unchangedFrameStorage}, {"static_instances_are_not_recopied", staticInstancesAreNotRecopied}, {"instance_places_survive_a_draw_leaving_the_view", instancePlacesSurviveADrawLeavingTheView}, {"instance_upload_is_stable_across_a_camera_turn", instanceUploadIsStableAcrossACameraTurn}, {"flat_lane_equivalence", flatLaneEquivalence}, {"directional_target", directionalTarget}, {"directional_target_outside_scene", directionalTargetOutsideScene}, {"scene_fog_node", sceneFogNode}, {"lit_mesh_without_normals", litMeshWithoutNormals}, {"invalidation", invalidation}, {"alpha_scene", alphaScene}, {"frame_slab_is_per_program", frameSlabIsPerProgram}, {"material_unsupported", materialUnsupported}, {"shader_invalid", shaderInvalid}, {"time_uniform", timeUniform}, {"gpu_mipmaps", gpuMipmaps}, {"updates", updates}, {"multi_camera_layers", multiCameraLayers}, {"render_callback", renderCallback}, {"instanced", instanced}, {"virtual_sun_room_seam", virtualSunRoomSeam}, diff --git a/packages/runtime-native/tests/native-engine/renderer_test.cpp b/packages/runtime-native/tests/native-engine/renderer_test.cpp index b7d4377471..25d6a77959 100644 --- a/packages/runtime-native/tests/native-engine/renderer_test.cpp +++ b/packages/runtime-native/tests/native-engine/renderer_test.cpp @@ -331,6 +331,7 @@ void traaValidation() { renderer.setOutput(OutputState{}); renderer.setRenderScale(0.5f); CHECK(!renderer.temporalUpscale()); + CHECK(renderer.viewMipBias() == 0); // no temporal pass: Unreal biases only an upscaled view CHECK(renderer.inputWidth() == 32 && renderer.inputHeight() == 24); for (const float scale : {0.24f, 1.01f, std::nanf("")}) { bool invalidScale = false; @@ -357,6 +358,16 @@ void traaValidation() { renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); renderer.cutHistory(); renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); + // PRD-539 lever 7, Unreal's view mip bias: -log2(1 / fraction) - 0.3, at least -2, ceiled to 1/128. + CHECK(renderer.viewMipBias() == std::ceil((-1.0 - 0.3) * 128) / 128); // 0.5 + renderer.setRenderScale(1.0f); + renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); + CHECK(renderer.viewMipBias() == std::ceil(-0.3 * 128) / 128); // TSR at 100% still biases + renderer.setRenderScale(0.25f); + renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); + CHECK(renderer.viewMipBias() == -2.0); // -2.3 held at r.ViewTextureMipBias.Min + renderer.setRenderScale(0.5f); + renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); renderer.setSize(16, 12); CHECK(renderer.inputWidth() == 8 && renderer.inputHeight() == 6); renderer.render(items, camera, LightState{}, {0.1, 0.2, 0.3, 0.5}); diff --git a/scripts/unreal-look/profile-gpu.py b/scripts/unreal-look/profile-gpu.py new file mode 100644 index 0000000000..8509d42464 --- /dev/null +++ b/scripts/unreal-look/profile-gpu.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Reads Unreal 5.8.3 `ProfileGPU` tables from an editor log (PRD-539 side-by-side benchmark). + + python3 -I scripts/unreal-look/profile-gpu.py [--tree] [--min-ms 0.05] + python3 -I scripts/unreal-look/profile-gpu.py --csv ... + +Prints one JSON line per profiled frame: the root, the scene render (`SceneRender - ViewFamilies`) and +the temporal-upscale passes (any row whose name holds `TSR` or `TemporalSuperResolution`), each the +inclusive GPU time in ms. `--tree` also prints every row at or above `--min-ms`. A log with no +profile table exits 2: a missing observation is a failure, not zero. +""" +import json +import re +import sys + +TIME = re.compile(r"([0-9.]+) ms") + + +def blocks(lines): + current = None + for line in lines: + if "GPU Profile for Frame" in line: + current = {"title": line.split("Display:")[-1].strip(), "rows": []} + yield current + elif current is not None and line.count("┃") >= 4: + cells = line.split("┃") + inclusive = TIME.search(cells[2]) + label = cells[3].rstrip() + if inclusive is None or not label.strip(): + continue + depth = (len(label) - len(label.lstrip())) // 3 + current["rows"].append((float(inclusive.group(1)), depth, label.strip())) + + +def summarize(block): + rows = block["rows"] + root = next((ms for ms, _, name in rows if name == ""), None) + scene = sum(ms for ms, _, name in rows if name == "SceneRender - ViewFamilies") + tsr = [(name, ms) for ms, _, name in rows if "TSR" in name or "TemporalSuperResolution" in name] + # Inclusive times nest, so the TSR total is the outermost TSR row of each subtree. + outer = [] + for ms, depth, name in rows: + if "TSR" in name or "TemporalSuperResolution" in name: + if not outer or depth <= outer[0][1]: + outer = [r for r in outer if r[1] < depth] + [(ms, depth, name)] + return { + "frame": block["title"], + "rootMs": root, + "sceneRenderMs": round(scene, 3), + "tsrMs": round(sum(ms for ms, d, _ in outer if d == min(r[1] for r in outer)), 3) if outer else None, + "tsrPasses": tsr, + } + + +def main(argv): + if len(argv) < 1 or argv[0].startswith("-"): + print(__doc__, file=sys.stderr) + return 2 + tree = "--tree" in argv + min_ms = float(argv[argv.index("--min-ms") + 1]) if "--min-ms" in argv else 0.05 + with open(argv[0], encoding="utf-8", errors="replace") as handle: + found = list(blocks(handle.read().splitlines())) + if not found: + print(f"no ProfileGPU table in {argv[0]}", file=sys.stderr) + return 2 + for block in found: + print(json.dumps(summarize(block))) + if tree: + for ms, depth, name in block["rows"]: + if ms >= min_ms: + print(f"{ms:8.3f} {' ' * depth}{name[:80]}") + return 0 + + + + +def csv_summary(path, stats=("GPUTime", "GPU/TemporalSuperResolution", "GPU/PostProcessing", "GPU/SlateUI")): + """Unreal CSV profiler (`TN_UE_CSV_GPU=1`): the header row is at the end, rows grow as stats appear. + Keeps the frames that drew the scene (GPU time over 1 ms; idle editor ticks read about 0.3 ms).""" + import csv + import statistics + + with open(path, newline="") as handle: + rows = list(csv.reader(handle)) + header = rows[-2] if rows[-1][:1] == ["[HasHeaderRowAtEnd]"] else rows[0] + data = rows[1:-2] + def column(name): + if name not in header: + return None + index = header.index(name) + return [float(row[index]) if index < len(row) and row[index] else 0.0 for row in data] + gpu = column("GPUTime") + if gpu is None: + raise SystemExit(f"{path}: no GPUTime column; was r.GPUCsvStatsEnabled on?") + keep = [i for i, value in enumerate(gpu) if value > 1.0] + if not keep: + raise SystemExit(f"{path}: no frame drew the scene") + out = {"csv": path, "frames": len(keep)} + for name in stats: + values = column(name) + if values is None: + out[name] = None + continue + picked = sorted(values[i] for i in keep) + out[name] = { + "p50": round(statistics.median(picked), 3), + "p95": round(picked[max(0, int(0.95 * len(picked)) - 1)], 3), + "mean": round(statistics.fmean(picked), 3), + } + return out + + +if __name__ == "__main__": + if len(sys.argv) > 2 and sys.argv[1] == "--csv": + for item in sys.argv[2:]: + print(json.dumps(csv_summary(item))) + sys.exit(0) + sys.exit(main(sys.argv[1:])) diff --git a/scripts/unreal-look/scaffold/CalibrationScene.ts.in b/scripts/unreal-look/scaffold/CalibrationScene.ts.in index 6adda5a381..6b6ca2f8d7 100644 --- a/scripts/unreal-look/scaffold/CalibrationScene.ts.in +++ b/scripts/unreal-look/scaffold/CalibrationScene.ts.in @@ -111,6 +111,18 @@ export class CalibrationScene extends Scene { override enter(ctx: GameCtx): SceneFrame { const model = this.#model; if (model === undefined || DATA === undefined) throw new Error("TN_CALIBRATION_SCENE_NOT_LOADED"); + // Unreal's r.ScreenPercentage: draw the scene at a share of the display and let the chain's `traa` + // stage reconstruct it to the display, as Unreal pairs a low screen percentage with TSR. Unset keeps + // the engine's own scale. The renderer has no pinned-scale call here; `renderer.resolutionScale` is + // a boot config value, so this is the narrowest public call that reaches the drawing buffer. + const screenPercentage = params.get("screenPercentage"); + if (screenPercentage !== null) { + const value = Number(screenPercentage); + if (!Number.isInteger(value) || value < 25 || value > 100) + throw new Error("screenPercentage must be an integer 25..100"); + ctx.renderer.setResolutionScale(value / 100, "auto-pinned"); + console.info(`TN_CALIBRATION_SCREEN_PERCENTAGE ${value}`); + } ctx.add(model); const scene = ctx.scene; const camera = ctx.camera as PerspectiveCamera; @@ -160,6 +172,17 @@ export class CalibrationScene extends Scene { material.twoSidedFoliage = true; material.subsurfaceColor = new Color(...colour); } + // `anisotropy=`: the base colour maps' anisotropic filter, as Unreal's r.MaxAnisotropy (8 at Epic, + // BaseScalability.ini [TextureQuality@3]) filters a World-group texture. Unset keeps three's default of 1. + const anisotropy = params.get("anisotropy"); + if (anisotropy !== null) { + const value = Number(anisotropy); + if (!Number.isInteger(value) || value < 1 || value > 16) throw new Error("anisotropy must be an integer 1..16"); + for (const mesh of meshes) + for (const material of [mesh.material].flat() as MeshStandardMaterial[]) + if (material.map) material.map.anisotropy = value; + console.info(`TN_CALIBRATION_ANISOTROPY ${value}`); + } scene.background = new Color(0x000000); scene.environment = null; diff --git a/scripts/unreal-look/side-by-side.py b/scripts/unreal-look/side-by-side.py new file mode 100644 index 0000000000..4f2a19b28b --- /dev/null +++ b/scripts/unreal-look/side-by-side.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""Same-scene benchmark sheet for Unreal's pan motion run: TSR at a low screen percentage vs ThreeNative. + + python3 -I scripts/unreal-look/side-by-side.py --out --frames 30,60,90,120 \ + [--metric-frames A-B] --crop x,y,w,h ARM... + +Each ARM is `