From f20f9be8a20594b1f0ac368e94a7f15fbf4111b7 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 20:43:05 -0700 Subject: [PATCH 01/10] perf(physics): a step's bodies, awake dynamics and own microseconds, counted The shooter's physics bucket grew 2.1 -> 3.4/6.6 ms a frame across two rounds on a loaded host, inside one unnamed Rapier function, and the trace cannot say whether more substeps per slow frame or more awake bodies did it. The loop already reports substeps per window; these are the two missing readings, per step, in the bag a playtest reads as TN_FRAME_BUDGET.counters.engine: physicsBodies / physicsColliders what the world holds (counts) physicsDynamicBodies dynamic bodies that could be solved (count) physicsAwakeBodies dynamic bodies Rapier will solve (count, read before world.step moves the sleep flags) physicsStepUs the step's own wall clock (microseconds) Opt-in behind core's ENGINE_COST_KNOB: reading a sleep flag is a Wasm call per dynamic body, so a shipped frame pays nothing. No rendered pixel changes. (cherry picked from commit 5cf55b577b60a754131407dd67c4e7a9d9e98416) (cherry picked from commit fbf27985a7b24dbda03e5648efd0168cf674efd2) --- .../physics/__tests__/step-counters.spec.ts | 126 ++++++++++++++++++ packages/physics/src/simulation.ts | 47 +++++++ 2 files changed, 173 insertions(+) create mode 100644 packages/physics/__tests__/step-counters.spec.ts diff --git a/packages/physics/__tests__/step-counters.spec.ts b/packages/physics/__tests__/step-counters.spec.ts new file mode 100644 index 0000000000..22b67ffb99 --- /dev/null +++ b/packages/physics/__tests__/step-counters.spec.ts @@ -0,0 +1,126 @@ +import * as RAPIER from "@dimforge/rapier3d-compat"; +import type { ICtx } from "@threenative/core"; +import { Object3D } from "three"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "../src/index.js"; +import { CollisionShape3D } from "../src/CollisionShape3D.js"; +import { RigidBody3D } from "../src/RigidBody3D.js"; +import { type IPhysicsContext, rapier } from "../src/plugin.js"; + +type PhysicsCtx = ICtx, IPhysicsContext>; +type CostHost = { + __THREENATIVE_ENGINE_COST__?: unknown; + __tnEngineCost?: Record; +}; + +const plugins: Array> = []; +let savedKnob: unknown; + +async function setup(): Promise<{ ctx: PhysicsCtx; plugin: ReturnType }> { + await RAPIER.init(); + const plugin = rapier({ gravity: { x: 0, y: -9.81, z: 0 } }); + const ctx = { physics: undefined } as unknown as PhysicsCtx; + await plugin.setup?.(ctx); + plugins.push(plugin); + return { ctx, plugin }; +} + +/** A floor to land on, `walls` fixed bodies, and `crates` dynamic ones above it. */ +function scene(ctx: PhysicsCtx, walls: number, crates: number): RigidBody3D[] { + new RigidBody3D({ + physics: ctx.physics, + position: { x: 0, y: -1, z: 0 }, + shape: CollisionShape3D.box(40, 1, 40), + type: "fixed", + }); + for (let index = 0; index < walls; index += 1) { + new RigidBody3D({ + physics: ctx.physics, + position: { x: index * 3, y: 0.5, z: 4 }, + shape: CollisionShape3D.box(1, 1, 1), + type: "fixed", + }); + } + return Array.from({ length: crates }, (_, index) => { + const object = new Object3D(); + object.position.set(index, 2, 0); + return new RigidBody3D({ + object, + physics: ctx.physics, + shape: CollisionShape3D.box(0.5, 0.5, 0.5), + }); + }); +} + +function totals(): Record { + return { ...(globalThis as CostHost).__tnEngineCost }; +} + +function delta(before: Record, name: string): number { + return (totals()[name] ?? 0) - (before[name] ?? 0); +} + +beforeEach(() => { + savedKnob = (globalThis as CostHost).__THREENATIVE_ENGINE_COST__; + (globalThis as CostHost).__THREENATIVE_ENGINE_COST__ = true; +}); + +afterEach(() => { + for (const plugin of plugins.splice(0)) plugin.dispose?.({} as PhysicsCtx); + (globalThis as CostHost).__THREENATIVE_ENGINE_COST__ = savedKnob; +}); + +// The profiler traced physics at 3.4-6.6 ms a frame on a loaded host against 2.1 ms on a quiet one, +// inside one unnamed Rapier function, with two candidate causes: more substeps per slow frame +// (fixed-step catch-up) or more awake bodies (shattered breakables). Neither is countable from the +// trace, and the loop already reports substeps, so these rows hold the two missing readings: the +// bodies the solver was handed, and the step's own cost. +describe("physics step counters", () => { + it("counts the bodies and awake dynamics a step is handed, and the step's own microseconds", async () => { + const { ctx, plugin } = await setup(); + scene(ctx, 3, 2); + plugin.update?.(ctx, 1 / 60); + const before = totals(); + plugin.update?.(ctx, 1 / 60); + plugin.update?.(ctx, 1 / 60); + // One floor, three walls, two falling crates: six bodies, one collider each, four of them fixed. + expect(delta(before, "physicsBodies")).toBe(12); + expect(delta(before, "physicsColliders")).toBe(12); + expect(delta(before, "physicsDynamicBodies")).toBe(4); + expect(delta(before, "physicsAwakeBodies")).toBe(4); + const stepUs = delta(before, "physicsStepUs"); + expect(stepUs).toBeGreaterThan(0); + expect(Number.isInteger(stepUs)).toBe(true); + }); + + it("stops counting a body that fell asleep and counts it again once an impulse wakes it", async () => { + const { ctx, plugin } = await setup(); + const [crate] = scene(ctx, 0, 1); + // Five seconds is well past Rapier's half-second sleep threshold for a crate at rest. + for (let tick = 0; tick < 300; tick += 1) plugin.update?.(ctx, 1 / 60); + const settled = totals(); + plugin.update?.(ctx, 1 / 60); + expect(delta(settled, "physicsAwakeBodies")).toBe(0); + expect(delta(settled, "physicsDynamicBodies")).toBe(1); + expect(crate).toBeDefined(); + crate?.applyImpulse({ x: 0, y: 4, z: 0 }); + const woken = totals(); + plugin.update?.(ctx, 1 / 60); + expect(delta(woken, "physicsAwakeBodies")).toBe(1); + }); + + it("counts nothing at all when no playtest asked for engine costs", async () => { + const { ctx, plugin } = await setup(); + scene(ctx, 2, 1); + plugin.update?.(ctx, 1 / 60); + (globalThis as CostHost).__THREENATIVE_ENGINE_COST__ = undefined; + const before = totals(); + for (let tick = 0; tick < 5; tick += 1) plugin.update?.(ctx, 1 / 60); + // The crossing counters stay always-on; the per-step ones must not be paid for unasked. + expect(delta(before, "physicsStepUs")).toBe(0); + expect(delta(before, "physicsAwakeBodies")).toBe(0); + expect(delta(before, "physicsBodies")).toBe(0); + expect(delta(before, "physicsColliders")).toBe(0); + expect(delta(before, "physicsDynamicBodies")).toBe(0); + }); +}); diff --git a/packages/physics/src/simulation.ts b/packages/physics/src/simulation.ts index e767da673c..5fbd613328 100644 --- a/packages/physics/src/simulation.ts +++ b/packages/physics/src/simulation.ts @@ -17,6 +17,24 @@ function countEngineCost(name: string, count: number): void { host.__tnEngineCost[name] = (host.__tnEngineCost[name] ?? 0) + count; } +/** + * The knob a playtest sets to bound engine work (`ENGINE_COST_KNOB` in core, same wire name). + * + * A step's own counters are opt-in where the crossing counters are not: reading a sleep flag is a + * Wasm call per dynamic body, and a shipped frame must not pay for a diagnostic nothing reads. + */ +const ENGINE_COST_KNOB = "__THREENATIVE_ENGINE_COST__"; + +/** Wall clock in milliseconds, from the same source the loop meters frames with. */ +function stepClock(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +/** True while a playtest has asked for engine work counters (core's `ENGINE_COST_KNOB`). */ +function engineCostAsked(): boolean { + return (globalThis as unknown as Record)[ENGINE_COST_KNOB] === true; +} + export const PHYSICS_TRANSFORM_STRIDE = 8; export const PHYSICS_COLLISION_EVENT_STRIDE = 4; @@ -1062,6 +1080,29 @@ export function createWebPhysicsSimulation( if (disposed) throw new Error("Physics simulation is disposed."); }; + /** + * The work one step is handed, into the engine-cost bag a playtest reads per frame: bodies, + * colliders, and how many dynamic bodies Rapier will solve. + * + * The awake count is read before `world.step` moves the sleep flags, because it describes the + * solve that is about to run, not what survived it. Units: `physicsAwakeBodies` and + * `physicsDynamicBodies` are bodies, `physicsColliders` is colliders. + */ + const countStepWork = (): void => { + if (!engineCostAsked()) return; + let awake = 0; + let dynamic = 0; + for (const entry of bodies.values()) { + if (entry.type !== "dynamic") continue; + dynamic += 1; + if (!entry.body.isSleeping()) awake += 1; + } + countEngineCost("physicsBodies", bodies.size); + countEngineCost("physicsColliders", byCollider.size); + countEngineCost("physicsDynamicBodies", dynamic); + countEngineCost("physicsAwakeBodies", awake); + }; + // Actuation on a fixed, kinematic or character body is not a weaker push, it is no push at // all -- Rapier discards it. Failing here beats a silently motionless body. const requireDynamic = ( @@ -1315,6 +1356,11 @@ export function createWebPhysicsSimulation( }, step: (deltaTime, inputSnapshot) => { requireLive(); + // Microseconds, because a step of a small scene costs well under one millisecond and a + // millisecond count would round most of them to zero. Counted only while a playtest asks. + const counted = engineCostAsked(); + const startedAt = counted ? stepClock() : 0; + if (counted) countStepWork(); requirePhysicsStepInput(deltaTime, inputSnapshot, (id) => bodies.has(id)); if (inputSnapshot !== undefined) { for (let index = 0; index < inputSnapshot.kinematicCount; index += 1) { @@ -1416,6 +1462,7 @@ export function createWebPhysicsSimulation( } countEngineCost("physicsForceResets", crossings.forceResets); dirtyBodies.clear(); + if (counted) countEngineCost("physicsStepUs", Math.round((stepClock() - startedAt) * 1000)); }, readVisibleTransforms: (renderBuffer) => { requireLive(); From 4ff44c25cff96bdf5243cce89222513886ca456a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 21:50:34 -0700 Subject: [PATCH 02/10] test(three-native): a crossing reads the heap's buffer once, not once per use The bridge-glue lever (9cbfd81a6, 466 -> 340 ns a crossing) kept no guard a regression would fail: all 8 per-crossing glue tests passed with the lever undone. Its win is work per crossing, not a crossing count, so this counts it: one heap-buffer read per crossing (the engine's own answer) and no HEAPU8.fill for a call's 64-byte scratch. Red (9cbfd81a6's source reverted in the working tree, never committed): bridge-crossings.spec "reads the heap's buffer once a crossing" -- AssertionError: expected 3 to be less than or equal to 1. Green (lever restored): 9 passed, that test included. No rendered pixel changes. (cherry picked from commit 43ed6a6df5df212f4236df39d92d96e94e289f1c) --- .../__tests__/bridge-crossings.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/three-native/__tests__/bridge-crossings.spec.ts b/packages/three-native/__tests__/bridge-crossings.spec.ts index dbd9f7c855..5d9503e957 100644 --- a/packages/three-native/__tests__/bridge-crossings.spec.ts +++ b/packages/three-native/__tests__/bridge-crossings.spec.ts @@ -329,4 +329,33 @@ describe("per-crossing glue on the Wasm bridge", () => { expect(scratch).toHaveLength(3); for (const seen of scratch) expect(seen.every((word) => word === 0)).toBe(true); }); + + // The glue a crossing pays, counted: `view()` used to read two `.buffer`s per use (3 uses here) and + // `alloc` filled its scratch through `HEAPU8.fill`, 466 ns a crossing against 340 with the kept views + // and the word-store zeroing (PERFORMANCE-LEARNINGS 2026-10-27, bridge glue). Both counts are zero + // again the moment the views are rebuilt per use, so undoing the lever reds this test. + it("reads the heap's buffer once a crossing, and zeroes its scratch without a fill", () => { + const { module } = growableModule(); + let bufferReads = 0; + let fills = 0; + const counted = (heap: Uint8Array) => + new Proxy(heap, { + get(target, property) { + if (property === "buffer") bufferReads += 1; + if (property === "fill") fills += 1; + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? (value as () => unknown).bind(target) : value; + }, + }); + module.HEAPU8 = counted(module.HEAPU8) as Uint8Array; + const runtime = createWasmRuntime(module); + // The fixture's own `_tn_invoke` answers through one DataView of the heap, per crossing. + runtime.invoke(self, "updateMatrixWorld", []); + const readsBefore = bufferReads; + const fillsBefore = fills; + for (let crossing = 0; crossing < 50; crossing += 1) + runtime.invoke(self, "updateMatrixWorld", []); + expect((bufferReads - readsBefore) / 50).toBeLessThanOrEqual(1); + expect(fills - fillsBefore).toBe(0); + }); }); From 977167c242c7399a4db908a60d1c75dabf0df364 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 21:56:16 -0700 Subject: [PATCH 03/10] test(core): the camera cull constructs no engine Frustum, Ray or Sphere The lever (87bfb74eb, cull 0.78 -> 0.66 ms on the laptop) removed three's Frustum, Ray and Sphere from the camera pass, which on the web engine are one boundary call per test each, 70 a frame on the shooter. Its existing guard, the plain-math equivalence test, only reds because the plain functions disappear with the undo: it would pass again if the math were right while an engine object came back to hold it. This counts the constructions in the cull's source instead. Comments are stripped first, because they name the types the plain code replaces. Red (87bfb74eb's source reverted in the working tree, never committed): render-camera-cull.spec "constructs no three Frustum, Ray or Sphere" -- AssertionError: new Frustum(: expected 'import { type Camera, Frustum, Matrix...' not to match /new Frustum\(/u. Green (lever restored): 24 passed, that test included. No rendered pixel changes. (cherry picked from commit e988829bd5ff4c8009cfcfe7b63608a3d903fae0) --- packages/core/__tests__/render-camera-cull.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/__tests__/render-camera-cull.spec.ts b/packages/core/__tests__/render-camera-cull.spec.ts index 584a3dc7a6..a931401d87 100644 --- a/packages/core/__tests__/render-camera-cull.spec.ts +++ b/packages/core/__tests__/render-camera-cull.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import { BufferAttribute, BufferGeometry, @@ -29,6 +31,17 @@ import { // The camera pass tests its frustum and forward ray in plain numbers: on the web engine three's Frustum, // Ray and Sphere are engine objects, one boundary call per test, 70 a frame on the shooter. describe("the cull's plain frustum and ray math", () => { + // The win is the class, not the answer: the equivalence test below passes just as well if the math is + // right while an engine Ray, Frustum or Sphere comes back to hold it, which is the cost the lever + // removed (66e637840: cull 0.78 -> 0.66 ms on the laptop). Count the constructions in the cull's source. + it("constructs no three Frustum, Ray or Sphere, which are engine objects on the web", () => { + const source = readFileSync(new URL("../src/render-camera-cull.ts", import.meta.url), "utf8"); + // Comments name the types the plain code replaces, so they are not constructions. + const code = source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/\/\/.*$/gmu, ""); + for (const name of ["Frustum", "Ray", "Sphere"]) + expect(code, `new ${name}(`).not.toMatch(new RegExp(`new ${name}\\(`, "u")); + }); + it("answers as three's Frustum and Ray for cameras and spheres all around", () => { let seed = 7; const random = () => { From d40914ea28f3e27177c8a24b6a68073f984752cc Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 22:07:00 -0700 Subject: [PATCH 04/10] test(core): the camera cull guard counts engine Frustum/Ray/Sphere use in a real cull frame e988829bd's guard read the cull's source text, so it passed if an engine Frustum came back through an alias, a helper or another module. It is now a behaviour count: the spec's existing `three` mock returns subclasses of Frustum, Ray and Sphere that count constructions, and one real cull frame on a small scene -- the cull's own construction included, since the lever removed fields built once per cull -- must add none. The frame asserts it reached every plain-math path first (near-band rig, band-test rig, off-camera caster, culled prop), so the zero cannot be vacuous. Red (66e637840's src reverted in the working tree, never committed): render-camera-cull.spec "constructs no three Frustum, Ray or Sphere in a real cull frame" -- AssertionError: expected { Frustum: 1, Ray: 1, Sphere: 1 } to deeply equal { Frustum: +0, Ray: +0, Sphere: +0 }. Green (lever restored): 24 passed, that test included. packages/core 4282 passed | 4 skipped; pnpm typecheck and pnpm lint pass. No rendered pixel changes. (cherry picked from commit d1e98d97e7e6962d9424d008a3f3b340d9a913f3) --- .../core/__tests__/render-camera-cull.spec.ts | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/core/__tests__/render-camera-cull.spec.ts b/packages/core/__tests__/render-camera-cull.spec.ts index a931401d87..f120b01656 100644 --- a/packages/core/__tests__/render-camera-cull.spec.ts +++ b/packages/core/__tests__/render-camera-cull.spec.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; - import { BufferAttribute, BufferGeometry, @@ -21,11 +19,13 @@ import { describe, expect, it, vi } from "vitest"; import { formatProjectionWindow } from "../src/projection-marker.js"; import { DEFAULT_MINIMUM_PROJECTED_PIXELS, + type IAnimationRateState, RenderCameraCull, alwaysRender, inFrustum, rayMeetsSphere, setFrustumPlanes, + trackAnimationRate, } from "../src/render-camera-cull.js"; // The camera pass tests its frustum and forward ray in plain numbers: on the web engine three's Frustum, @@ -33,13 +33,48 @@ import { describe("the cull's plain frustum and ray math", () => { // The win is the class, not the answer: the equivalence test below passes just as well if the math is // right while an engine Ray, Frustum or Sphere comes back to hold it, which is the cost the lever - // removed (66e637840: cull 0.78 -> 0.66 ms on the laptop). Count the constructions in the cull's source. - it("constructs no three Frustum, Ray or Sphere, which are engine objects on the web", () => { - const source = readFileSync(new URL("../src/render-camera-cull.ts", import.meta.url), "utf8"); - // Comments name the types the plain code replaces, so they are not constructions. - const code = source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/\/\/.*$/gmu, ""); - for (const name of ["Frustum", "Ray", "Sphere"]) - expect(code, `new ${name}(`).not.toMatch(new RegExp(`new ${name}\\(`, "u")); + // removed (66e637840: cull 0.78 -> 0.66 ms on the laptop, about 70 boundary calls a shooter frame). + // So count what a real cull frame constructs, not what the source spells: on the web engine each of + // these is an object across the boundary, whichever helper or alias reaches it. + it("constructs no three Frustum, Ray or Sphere in a real cull frame", () => { + // One frame of a small scene, one object per plain-math path: a rig part on the camera's forward + // ray (near band), a rig part off it (the band test), an off-camera shadow caster (the frustum + // test) and a sub-pixel prop to cull. + const onRay = new Group(); + onRay.add(ball(1, -60)); + const lateral = ball(1, -60); + lateral.position.x = 8; + const offRay = new Group(); + offRay.add(lateral); + const onRayState = rateState(); + const offRayState = rateState(); + trackAnimationRate(onRay, onRayState); + trackAnimationRate(offRay, offRayState); + const caster = ball(0.01, -1_000); + caster.position.set(100_000, 0, -1_000); + caster.castShadow = true; + const scene = sceneWith(onRay, offRay, caster, ball(0.1, -1_000)); + const view = camera(); + + // The scene above is scaffolding. Everything from here is the cull: its own fields and its frame, + // since the engine objects the lever removed were built once per cull and reused every frame. + engineObjects.Frustum = 0; + engineObjects.Ray = 0; + engineObjects.Sphere = 0; + const cull = new RenderCameraCull(); + cull.prepareAnimation(scene, view, 720); + cull.apply(scene, view, 720); + + // The frame reached every path, so the zero below is not vacuous: the on-ray rig took the near + // band, the lateral one the band test (4, not the unseen 8), and the caster the frustum test. + expect([onRayState.rate, offRayState.rate]).toEqual([1, 4]); + expect(cull.report.exemptShadowCasters).toBe(1); + expect(cull.report.culled).toBe(1); + expect({ + Frustum: engineObjects.Frustum, + Ray: engineObjects.Ray, + Sphere: engineObjects.Sphere, + }).toEqual({ Frustum: 0, Ray: 0, Sphere: 0 }); }); it("answers as three's Frustum and Ray for cameras and spheres all around", () => { @@ -98,6 +133,8 @@ describe("the cull's plain frustum and ray math", () => { */ const vector3Allocations = vi.hoisted(() => ({ count: 0 })); +/** Constructions of the three classes that are engine objects on the web engine, counted, not spied. */ +const engineObjects = vi.hoisted(() => ({ Frustum: 0, Ray: 0, Sphere: 0 })); vi.mock("three", async (importOriginal) => { const actual = await importOriginal(); @@ -109,9 +146,32 @@ vi.mock("three", async (importOriginal) => { vector3Allocations.count += 1; } }, + Frustum: class CountingFrustum extends actual.Frustum { + constructor(...planes: ConstructorParameters) { + super(...planes); + engineObjects.Frustum += 1; + } + }, + Ray: class CountingRay extends actual.Ray { + constructor(...args: ConstructorParameters) { + super(...args); + engineObjects.Ray += 1; + } + }, + Sphere: class CountingSphere extends actual.Sphere { + constructor(...args: ConstructorParameters) { + super(...args); + engineObjects.Sphere += 1; + } + }, }; }); +/** A rig's rate state, as `AnimationPlayer` registers one (the cull only writes `rate` and `frame`). */ +function rateState(): IAnimationRateState { + return { rate: 1, frame: 0, source: undefined, evaluated: false, evaluation: 0 }; +} + /** A perspective camera at the origin looking down -Z, matching the default Three camera. */ function camera(fov = 60): PerspectiveCamera { const result = new PerspectiveCamera(fov, 1, 0.1, 1_000_000); From 9531c7d2c5d0b06edbd4ecefff5a0830ee37328f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 22:17:12 -0700 Subject: [PATCH 05/10] docs(prd): PRD-755 and PRD-756, the default frame's fixed GPU cost (history copies, post blits, shared chain, half-size history) (cherry picked from commit b219860c33b233bbed64b46aca2570d0699d49ea) --- ...stops-copying-textures-it-already-holds.md | 48 ++++++++++++++++++ ...ral-resolve-feeds-one-shared-post-chain.md | 50 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md create mode 100644 docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md diff --git a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md new file mode 100644 index 0000000000..829c200fc9 --- /dev/null +++ b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md @@ -0,0 +1,48 @@ +# PRD-755 — The default frame stops copying textures it already holds + +**Status:** IN PROGRESS +**Priority:** P1 — on Iris Xe, 14.0 ms of the 19 ms Hop Garden frame does not scale with the render resolution ([PRD-741](../../done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md), final box); the copies and blits below are part of it, on every game with the default look. +**Complexity:** 4 (MEDIUM) — the temporal resolve and the post-target render path in the native renderer (+2), bit-identical output proven by readback (+1), a GPU A/B on Iris Xe (+1). +**Owner:** João +**Parent:** [PRD-400](../../performance/PRD-400-the-frame-gets-cheaper-one-measured-cost-at-a-time.md) (it owns the remaining fixed cost). Sibling: [PRD-756](PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md). Lane of the PR #574 follow-up. +**Depends on:** none. + +**Update rule:** this PRD has no PR of its own. Its commits land on `lane/pr574-gpu` and merge into the PR #574 follow-up. Tick each box, with its result, in the commit that proves it. + +## Problem + +Found by reading the code and the PRD-741 per-pass table (2026-10-27): + +- The resolve copies three textures into history every frame instead of swapping them: the display-size `RGBA16Float` colour, the input-size depth and the `RG8` motion mask (`packages/runtime-native/src/engine/renderer/post/traa.cpp:653-654`). That is about 50 MB of memory traffic a frame at 1080p (estimate: bytes read plus bytes written). The pass timer does not see copies, so no dashboard shows this cost. +- Every post-target render (bloom chain, local exposure, meters) draws its scene pass, then an `output` pass into the target (`Renderer::outputPass`, `packages/runtime-native/src/engine/renderer/renderer.cpp:4467`). The PRD-741 table holds 28 `output / 2 / N` occurrences, about 0.45 ms summed on Iris Xe (measured per pass). + +## What Unreal does (UE 5.8.3) + +- TSR keeps its history as render-graph textures and extracts the new ones for the next frame; it copies nothing. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp:3505-3518` (`QueueTextureExtraction`). + +## Goal + +The resolve and the post targets do the same work with no history copy and no extra output pass, the frame is bit-identical, and a test fails when either lever is undone. + +### Phase 1: The resolve swaps its history + +- [ ] The colour history and the motion mask are swapped each frame instead of copied, and the resolve writes the next history directly. A counter reports the resolve's texture copies per frame. proof: a new ctest, red at 3 copies a frame, green at 0 (or 1 if the scene depth stays a copy, with the reason here); every `native_engine_traa_*` ctest passes with unchanged metric values. +- [ ] The resolved frame is bit-identical to the copy path over 60 frames of a moving scene. proof: a ctest that reads back both paths. + +### Phase 2: A post target draws without the extra output pass + +- [ ] A render into a post target whose output stage is an identity draws its scene pass straight into the target. proof: a ctest, red at one `output` pass per target render, green at 0. +- [ ] The target holds the same pixels as the old path, or this box records why it cannot. proof: a readback ctest. + +### Phase 3: Prove it on games + +- [ ] Paired GPU A/B on Iris Xe, Hop Garden medium and the shooter, three pairs from one code state, GPU p50 and presented p95, load and temperature recorded. proof: the table here. +- [ ] The kit scenarios pass on both kits. proof: `pnpm test:templates` on Hop Garden and the shooter. + +## Done when + +Every box above is ticked. + +## Decisions + +## Blocked on diff --git a/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md b/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md new file mode 100644 index 0000000000..a67011e5cc --- /dev/null +++ b/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md @@ -0,0 +1,50 @@ +# PRD-756 — The temporal resolve feeds one shared post chain and keeps a half-size history + +**Status:** NOT STARTED +**Priority:** P1 — on Iris Xe at the 0.72 tier start, the TRAA resolve (4.36 ms), bloom at display size (1.55 + 1.21 ms) and local exposure (2.9-3.2 ms) are most of the 14.0 ms that does not scale with resolution ([PRD-741](../../done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md), measured per pass). +**Complexity:** 6 (MEDIUM) — the resolve outputs and the native post passes (+2), core's bloom and local exposure nodes (+1), a history format with an adapter fallback (+1), visual judge rounds (+2). +**Owner:** João +**Parent:** [PRD-400](../../performance/PRD-400-the-frame-gets-cheaper-one-measured-cost-at-a-time.md). Sibling: [PRD-755](PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md). Lane of the PR #574 follow-up. +**Depends on:** PRD-755 Phase 1 (the resolve writes its history in place). + +**Update rule:** this PRD has no PR of its own. Its commits land on `lane/pr574-gpu` and merge into the PR #574 follow-up. Tick each box, with its result, in the commit that proves it. + +## Problem + +- Bloom and local exposure each downsample the resolved display-size frame on their own (PRD-741 table: a 960x540 to 30x17 chain per effect, plus meters). +- The history is `RGBA16Float` with 32-bit shader math (`packages/runtime-native/src/engine/renderer/post/traa.cpp:503`). The resolve still costs 3.3 ms with one colour load instead of nine (PRD-741 deletion probe), so memory traffic, not arithmetic, sets its cost (inference). +- The history keeps the game's colour alpha (`traa.cpp:97`, `:346`), so a format without alpha is not a drop-in change. + +## What Unreal does (UE 5.8.3) + +- TSR writes half, quarter and eighth-resolution scene colour while it resolves, and the post chain starts from them. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalAA.h:216-235`. +- Bloom, eye adaptation and local exposure share one scene downsample chain. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/PostProcessing.cpp:214` (`r.PostProcessing.DownsampleChainQuality`). +- TSR history is `R11G11B10` and its math is 16-bit by default. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PostProcess/TemporalSuperResolution.cpp:64` (`r.TSR.History.R11G11B10`), `:99` (`r.TSR.16BitVALU`). + +## Goal + +The default frame on Iris Xe loses at least 1.5 ms of GPU time with a NEUTRAL or IMPROVEMENT visual verdict. + +### Phase 1: One shared post chain + +- [ ] Size it: the bloom chain, the display-size bloom passes and the local exposure passes, per pass, on the current build, and what a shared chain removes. If that is under 1 ms, this phase moves to Deferred with the number. proof: `?tnGpuPasses=1` on Iris Xe, two runs, table here. +- [ ] The resolve writes a half-resolution colour, and bloom and local exposure start from one shared chain. proof: ctests for the chain levels; the core look specs pass. +- [ ] Visual judge NEUTRAL or IMPROVEMENT on Hop Garden, Lantern Vale and the shooter, two runs per arm. proof: the verdict here and the sheets on the follow-up PR. + +### Phase 2: Half-size history + +- [ ] Audit who reads the history alpha and whether the adapters in the supported matrix expose `rg11b10ufloat-renderable` and `shader-f16`. proof: the findings here, with file:line. +- [ ] The history moves to `RG11B10Ufloat` (alpha kept elsewhere if it is read) with 16-bit math where the adapter allows it, and the current path where it does not. proof: ctests on both paths; the resolve ms on Iris Xe, two runs per arm. +- [ ] Visual judge NEUTRAL or IMPROVEMENT on the same kits, two runs per arm. proof: the verdict here and the sheets on the follow-up PR. + +### Phase 3: Prove it on games + +- [ ] Paired GPU A/B on Iris Xe, Hop Garden medium and the shooter, three pairs from one code state, GPU p50 and presented p95, load and temperature recorded. proof: the table here. + +## Done when + +Every box is ticked, or a phase is Deferred with its measured number. + +## Decisions + +## Blocked on From dece99cef30b4dcc26778063e667157c9169ec57 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 22:30:53 -0700 Subject: [PATCH 06/10] perf(native): the temporal resolve swaps its history instead of copying it (PRD-755) The resolve copied the frame it had just resolved into its history every frame: the display-size RGBA16Float colour, the RG8 motion mask and the input-size depth, each a read plus a write. It now reads the history from one slot, writes the next frame's history as a third colour attachment beside the resolved colour, and the two slots swap, so neither the colour nor the mask is copied. An attachment write is 8 bytes a pixel where a copy read and wrote 8; copying the caller's own target instead would move twice the bytes, which is why the resolve takes the attachment in both cases. The scene depth stays a copy: it is the caller's texture and the resolve only samples it, so swapping it would need the renderer to alternate its depth target. `copiesLastFrame()` reports the three counts, one a steady frame (the depth), zero for the colour and the mask. red: native_engine_traa_history_swap TN_TRAA_HISTORY_COPIES steady=3 (colour 1, depth 1, mask 1) green: native_engine_traa_history_swap TN_TRAA_HISTORY_COPIES steady=1 (colour 0, depth 1, mask 0), into a caller's target=1 (colour 0, depth 1, mask 0) bit-identical: native_engine_traa_history_swap_is_bit_identical 120 frames compared, 0 differing (the same dumps with the swap undone: 118 of 120 differ) native_engine_traa_* and native_engine_renderer_* ctests: 95/95 pass, every metric line unchanged against the run before this change. Estimated 25 MB a frame less traffic at 1080p. Can change what reaches the screen: no, the resolved frame is bit-identical; the lead runs the visual judge. (cherry picked from commit aecafb9bae39f67db5376ab867092004805ade3f) --- .../runtime-native/cmake/NativeEngine.cmake | 2 + .../src/engine/renderer/post/traa.cpp | 70 +++++--- .../src/engine/renderer/post/traa.h | 31 +++- .../tests/native-engine/renderer_test.cpp | 150 ++++++++++++++++++ .../native-engine/traa_reference_test.mjs | 4 +- 5 files changed, 224 insertions(+), 33 deletions(-) diff --git a/packages/runtime-native/cmake/NativeEngine.cmake b/packages/runtime-native/cmake/NativeEngine.cmake index 38818887de..2378d5fb8b 100644 --- a/packages/runtime-native/cmake/NativeEngine.cmake +++ b/packages/runtime-native/cmake/NativeEngine.cmake @@ -164,6 +164,8 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi native_engine_traa_converges_edges=traa_converges_edges native_engine_traa_velocity_footprint=traa_velocity_footprint native_engine_traa_late_resolve=traa_late_resolve + native_engine_traa_history_swap=traa_history_swap + native_engine_traa_history_swap_is_bit_identical=traa_history_swap_is_bit_identical native_engine_traa_ghosting=traa_ghosting native_engine_traa_camera_pan=traa_camera_pan native_engine_traa_rejection=traa_rejection diff --git a/packages/runtime-native/src/engine/renderer/post/traa.cpp b/packages/runtime-native/src/engine/renderer/post/traa.cpp index d93ba3542e..e993891fef 100644 --- a/packages/runtime-native/src/engine/renderer/post/traa.cpp +++ b/packages/runtime-native/src/engine/renderer/post/traa.cpp @@ -20,12 +20,13 @@ TraaPass::Matrix inverse(const TraaPass::Matrix& a) { WGPUTextureView view(WGPUTexture texture) { return wgpuTextureCreateView(texture, nullptr); } } -void TraaPass::enableDebugDump() { +void TraaPass::enableDebugDump(uint64_t first, uint64_t last) { debugDump_ = std::make_unique(); + debugDump_->first = first; debugDump_->last = last; } void TraaPass::stageDebugTexture(WGPUCommandEncoder encoder, WGPUTexture texture, const char* name) { - if (!debugDump_ || history_.frame() < 18 || history_.frame() > 23) return; + if (!debugDump_ || history_.frame() < debugDump_->first || history_.frame() > debugDump_->last) return; if (wgpuTextureGetFormat(texture) != WGPUTextureFormat_RGBA16Float) throw std::runtime_error("TN_TRAA_DUMP_FORMAT_INVALID"); const auto width = wgpuTextureGetWidth(texture), height = wgpuTextureGetHeight(texture); @@ -133,7 +134,7 @@ fn clipAABB(current: vec4, old: vec4, low: vec4, high: vec4) } const rgbToNeighborhood = mat3x3(vec3(1,2,-1), vec3(2,0,2), vec3(1,-2,-1)); const neighborhoodToRgb = mat3x3(vec3(.25), vec3(.25,0,-.25), vec3(-.25,.25,-.25)); -struct Resolved { @location(0) color: vec4, @location(1) dynamic: vec2 } +struct Resolved { @location(0) color: vec4, @location(1) dynamic: vec2, @location(2) history: vec4 } // UE 5.8.3's five-tap Catmull-Rom history fetch (TextureSampling.ush:120-157, TemporalAA.usf:1868-1877): a bilinear fetch // blurs the history at every fractional offset, so a fast pan softens it until the raw input wins. fn catmullRomHistory(uv: vec2, size: vec2) -> vec4 { @@ -370,7 +371,9 @@ fn catmullRomHistory(uv: vec2, size: vec2) -> vec4 { let luminance = vec3(0.2126, 0.7152, 0.0722); let historyWeight = (1 - currentWeight) / (dot(compressedHistory.rgb, luminance) + 1); currentWeight /= dot(compressedCurrent.rgb, luminance) + 1; - return Resolved((current * currentWeight + clipped * historyWeight) / max(currentWeight + historyWeight, 0.00001), vec2(select(0.f, 1.f, dynamic || (valid && oldDynamic)), overlay)); + let resolved = (current * currentWeight + clipped * historyWeight) / max(currentWeight + historyWeight, 0.00001); + // The same resolved colour becomes the next frame's history, written beside it rather than copied after the pass. + return Resolved(resolved, vec2(select(0.f, 1.f, dynamic || (valid && oldDynamic)), overlay), resolved); } )WGSL"; static constexpr auto stripped = wgslStrip(source); @@ -429,9 +432,11 @@ TraaPass::TraaPass(WGPUDevice device, WGPUQueue queue, TraaOptions options) setupShaderModuleWGSL(&moduleDesc, &wgsl, traaResolveWgsl()); auto module = wgpuDeviceCreateShaderModule(device_, &moduleDesc); WGPUColorTargetState color{}; color.format = WGPUTextureFormat_RGBA16Float; color.writeMask = WGPUColorWriteMask_All; - WGPUColorTargetState colors[2] = {color, color}; colors[1].format = WGPUTextureFormat_RG8Unorm; + // Resolved colour, the motion mask, and the next frame's history beside the resolved colour: the pass writes the + // history it will read, so neither it nor the mask is copied (PRD-755). + WGPUColorTargetState colors[3] = {color, color, color}; colors[1].format = WGPUTextureFormat_RG8Unorm; WGPUFragmentState fragment{}; fragment.module = module; WGPU_SET_ENTRY_POINT(fragment, "fs"); - fragment.targetCount = 2; fragment.targets = colors; + fragment.targetCount = 3; fragment.targets = colors; WGPURenderPipelineDescriptor pipeline{}; pipeline.vertex.module = module; WGPU_SET_ENTRY_POINT(pipeline.vertex, "vs"); pipeline.fragment = &fragment; pipeline.primitive.topology = WGPUPrimitiveTopology_TriangleList; @@ -465,11 +470,17 @@ TraaPass::~TraaPass() { } void TraaPass::releaseTargets() { fillGroups_.clear(); resolveGroups_.clear(); // they hold the views released here - for (auto v : {historyView_, historyDepthView_, resolveView_, velocityView_, dynamicView_, historyDynamicView_}) if (v) wgpuTextureViewRelease(v); - for (auto t : {historyColor_, historyDepth_, resolve_, velocity_, dynamic_, historyDynamic_}) if (t) wgpuTextureRelease(t); - historyView_ = historyDepthView_ = resolveView_ = velocityView_ = nullptr; - historyColor_ = historyDepth_ = resolve_ = velocity_ = dynamic_ = historyDynamic_ = nullptr; - dynamicView_ = historyDynamicView_ = nullptr; + for (auto v : {historyView_[0], historyView_[1], historyDepthView_, resolveView_, velocityView_, dynamicView_[0], + dynamicView_[1]}) if (v) wgpuTextureViewRelease(v); + for (auto t : {historyColor_[0], historyColor_[1], historyDepth_, resolve_, velocity_, dynamic_[0], dynamic_[1]}) + if (t) wgpuTextureRelease(t); + for (auto& v : historyView_) v = nullptr; + for (auto& v : dynamicView_) v = nullptr; + for (auto& t : historyColor_) t = nullptr; + for (auto& t : dynamic_) t = nullptr; + historyDepthView_ = resolveView_ = velocityView_ = nullptr; + historyDepth_ = resolve_ = velocity_ = nullptr; + historySlot_ = 0; } void TraaPass::resize(uint32_t width, uint32_t height) { resize(width, height, width, height); @@ -501,9 +512,10 @@ void TraaPass::resize(uint32_t width, uint32_t height, uint32_t outputWidth, uin WGPUTextureUsage_CopySrc | WGPUTextureUsage_CopyDst; return wgpuDeviceCreateTexture(device_, &desc); }; - dynamic_ = texture(WGPUTextureFormat_RG8Unorm, true); dynamicView_ = view(dynamic_); - historyDynamic_ = texture(WGPUTextureFormat_RG8Unorm, true); historyDynamicView_ = view(historyDynamic_); - historyColor_ = texture(WGPUTextureFormat_RGBA16Float, true); historyView_ = view(historyColor_); + dynamic_[0] = texture(WGPUTextureFormat_RG8Unorm, true); dynamicView_[0] = view(dynamic_[0]); + dynamic_[1] = texture(WGPUTextureFormat_RG8Unorm, true); dynamicView_[1] = view(dynamic_[1]); + historyColor_[0] = texture(WGPUTextureFormat_RGBA16Float, true); historyView_[0] = view(historyColor_[0]); + historyColor_[1] = texture(WGPUTextureFormat_RGBA16Float, true); historyView_[1] = view(historyColor_[1]); resolve_ = texture(WGPUTextureFormat_RGBA16Float, true); resolveView_ = view(resolve_); velocity_ = texture(WGPUTextureFormat_RGBA16Float, false); velocityView_ = view(velocity_); historyDepth_ = texture(WGPUTextureFormat_Depth32Float, false); historyDepthView_ = view(historyDepth_); @@ -589,31 +601,36 @@ void TraaPass::seedHistory(WGPUCommandEncoder encoder, WGPUTexture beauty) { if (width_ != outputWidth_ || height_ != outputHeight_) return; // TRAANode.updateBefore copies the beauty target before the resolve quad updates its scene pass. WGPUImageCopyTexture_Compat src{}, dst{}; - src.texture = beauty; dst.texture = historyColor_; + src.texture = beauty; dst.texture = historyColor_[historySlot_]; src.aspect = dst.aspect = WGPUTextureAspect_All; const WGPUExtent3D extent{width_, height_, 1}; wgpuCommandEncoderCopyTextureToTexture(encoder, &src, &dst, &extent); } void TraaPass::resolve(WGPUCommandEncoder encoder, WGPUTexture beauty, WGPUTextureView beautyView, WGPUTexture depth, WGPUTextureView depthView, WGPUTexture output, WGPUTextureView outputView) { + copiesLastFrame_ = {}; const WGPUTexture resolved = output ? output : resolve_; - auto copy = [&](WGPUTexture from, WGPUTexture to, bool isDepth) { + // The resolve reads the history slot and writes the other, then the two swap: the frame it resolved is the next + // frame's history with no copy. + const uint32_t next = historySlot_ ^ 1; + auto copy = [&](WGPUTexture from, WGPUTexture to, bool isDepth, uint32_t& count) { WGPUImageCopyTexture_Compat src{}, dst{}; src.texture = from; dst.texture = to; src.aspect = dst.aspect = isDepth ? WGPUTextureAspect_DepthOnly : WGPUTextureAspect_All; const WGPUExtent3D extent{isDepth ? width_ : outputWidth_, isDepth ? height_ : outputHeight_, 1}; wgpuCommandEncoderCopyTextureToTexture(encoder, &src, &dst, &extent); + ++count; }; // On the initial render/resize there is no previous depth allocation to sample. if (history_.frame() == 0 || history_.generation(0) > depthGeneration_) { - copy(depth, historyDepth_, true); + copy(depth, historyDepth_, true, copiesLastFrame_.depth); previousWorld_ = world_; previousInverseProjection_ = inverseProjection_; } depthGeneration_ = history_.generation(0); if (debugDump_) { stageDebugTexture(encoder, beauty, "beauty"); stageDebugTexture(encoder, velocity_, "velocity"); - stageDebugTexture(encoder, historyColor_, "history"); + stageDebugTexture(encoder, historyColor_[historySlot_], "history"); } std::array data{}; const auto rows = historyDepthRows(projection_, view_, previousWorld_, previousInverseProjection_); @@ -637,24 +654,29 @@ void TraaPass::resolve(WGPUCommandEncoder encoder, WGPUTexture beauty, WGPUTextu for (uint32_t i = 0; i < 9; ++i) entries[i].binding = i; entries[0].buffer = uniforms_; entries[0].size = sizeof data; entries[1].textureView = beautyView; entries[2].textureView = depthView; - entries[3].textureView = velocityView_; entries[4].textureView = historyView_; + entries[3].textureView = velocityView_; entries[4].textureView = historyView_[historySlot_]; entries[5].textureView = historyDepthView_; entries[6].sampler = sampler_; entries[7].buffer = stats_; entries[7].size = 8; - entries[8].textureView = historyDynamicView_; + entries[8].textureView = dynamicView_[historySlot_]; auto group = resolveGroups_.get(device_, layout_, entries); WGPURenderPassColorAttachment color{}; color.view = outputView ? outputView : resolveView_; color.loadOp = WGPULoadOp_Clear; color.storeOp = WGPUStoreOp_Store; #if defined(MYSTRAL_WEBGPU_DAWN) color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; #endif - WGPURenderPassColorAttachment colors[2] = {color, color}; colors[1].view = dynamicView_; - WGPURenderPassDescriptor desc{}; desc.colorAttachmentCount = 2; desc.colorAttachments = colors; + WGPURenderPassColorAttachment colors[3] = {color, color, color}; + colors[1].view = dynamicView_[next]; colors[2].view = historyView_[next]; + WGPURenderPassDescriptor desc{}; desc.colorAttachmentCount = 3; desc.colorAttachments = colors; auto pass = beginTimedRenderPass(encoder, &desc, 2, {"traaResolve", outputWidth_, outputHeight_}); wgpuRenderPassEncoderSetPipeline(pass, pipeline_); wgpuRenderPassEncoderSetBindGroup(pass, 0, group, 0, nullptr); wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); if (debugDump_) stageDebugTexture(encoder, resolved, "resolved"); - copy(resolved, historyColor_, false); copy(depth, historyDepth_, true); - copy(dynamic_, historyDynamic_, false); + copy(depth, historyDepth_, true, copiesLastFrame_.depth); + // Fixture only: the copied history path, which the swap replaced. + if (copyHistoryForTest_) { + copy(historyColor_[next], historyColor_[historySlot_], false, copiesLastFrame_.color); + copy(dynamic_[next], dynamic_[historySlot_], false, copiesLastFrame_.mask); + } else historySlot_ = next; history_.endFrame(); } } // namespace tn::engine diff --git a/packages/runtime-native/src/engine/renderer/post/traa.h b/packages/runtime-native/src/engine/renderer/post/traa.h index 9ec51f4d22..bd014577df 100644 --- a/packages/runtime-native/src/engine/renderer/post/traa.h +++ b/packages/runtime-native/src/engine/renderer/post/traa.h @@ -56,6 +56,16 @@ class TraaPass { WGPUTexture depth, WGPUTextureView depthView, WGPUTexture output = nullptr, WGPUTextureView outputView = nullptr); bool needsSeed() const { return !history_.historyValid(0); } + // Texture copies the last `resolve` recorded, by kind (PRD-755). The scene depth is the caller's texture and the + // resolve only reads it, so its history is the one copy a steady frame keeps. + struct Copies { + uint32_t color = 0, depth = 0, mask = 0; + uint32_t total() const { return color + depth + mask; } + }; + Copies copiesLastFrame() const { return copiesLastFrame_; } + // Fixture only: keep copying the resolved frame into the history instead of swapping the slots, the path before + // PRD-755. One test renders both ways and reads back both, so the swap is proven bit-identical. + void setHistoryCopyForTest(bool copy) { copyHistoryForTest_ = copy; } // What the host reads for `debug().temporal`: the frames begun, the sub-pixel offset the last one jittered by, and // whether the next resolve has history to read. struct Status { @@ -71,7 +81,7 @@ class TraaPass { velocity_ ? wgpuTextureGetWidth(velocity_) : 0, velocity_ ? wgpuTextureGetHeight(velocity_) : 0, resolve_ ? wgpuTextureGetWidth(resolve_) : 0, resolve_ ? wgpuTextureGetHeight(resolve_) : 0, historyDepth_ ? wgpuTextureGetWidth(historyDepth_) : 0, historyDepth_ ? wgpuTextureGetHeight(historyDepth_) : 0, - historyColor_ ? wgpuTextureGetWidth(historyColor_) : 0, historyColor_ ? wgpuTextureGetHeight(historyColor_) : 0, + historyColor_[0] ? wgpuTextureGetWidth(historyColor_[0]) : 0, historyColor_[0] ? wgpuTextureGetHeight(historyColor_[0]) : 0, jitterLength_}; } // One resolve in `kMeasureEvery` counts the pixels whose history it discarded (stats[0]) and pulled into the colour @@ -89,8 +99,9 @@ class TraaPass { return Measure{measuredFrame_, outputWidth_ * outputHeight_}; } const graph::RenderGraph& renderGraph() const { return graph_; } - // Fixture diagnostics only. No allocation/readback unless explicitly enabled by the driver. - void enableDebugDump(); + // Fixture diagnostics only. No allocation/readback unless explicitly enabled by the driver. `first` and `last` are + // the frames it stages; the default is the window the fixture's capture reads, a test may widen it. + void enableDebugDump(uint64_t first = 18, uint64_t last = 23); struct DebugDump { struct Texture { WGPUBuffer buffer; @@ -102,6 +113,7 @@ class TraaPass { std::vector pending; std::string metadata; uint64_t frame = 0; + uint64_t first = 18, last = 23; uint32_t width = 0, height = 0; ~DebugDump() { for (const auto& texture : pending) wgpuBufferRelease(texture.buffer); } }; @@ -122,11 +134,16 @@ class TraaPass { std::array jitter_{}; std::array previousJitter_{}; uint32_t depthGeneration_ = 0; - WGPUTexture historyColor_ = nullptr, historyDepth_ = nullptr, resolve_ = nullptr, velocity_ = nullptr; - WGPUTextureView historyView_ = nullptr, historyDepthView_ = nullptr, resolveView_ = nullptr, velocityView_ = nullptr; - WGPUTexture dynamic_ = nullptr, historyDynamic_ = nullptr; - WGPUTextureView dynamicView_ = nullptr, historyDynamicView_ = nullptr; + WGPUTexture historyDepth_ = nullptr, resolve_ = nullptr, velocity_ = nullptr; + WGPUTextureView historyDepthView_ = nullptr, resolveView_ = nullptr, velocityView_ = nullptr; + // The colour and the motion mask the resolve reads and writes: it reads one slot and writes the other, then they + // swap, so the frame it just resolved is the next frame's history with no copy (PRD-755). + WGPUTexture historyColor_[2] = {}, dynamic_[2] = {}; + WGPUTextureView historyView_[2] = {}, dynamicView_[2] = {}; + uint32_t historySlot_ = 0; + bool copyHistoryForTest_ = false; WGPUBuffer uniforms_ = nullptr; + Copies copiesLastFrame_{}; WGPUBuffer ownStats_ = nullptr, stats_ = nullptr; bool measured_ = false; uint64_t measuredFrame_ = 0; diff --git a/packages/runtime-native/tests/native-engine/renderer_test.cpp b/packages/runtime-native/tests/native-engine/renderer_test.cpp index fbd10291e8..be7bf855c0 100644 --- a/packages/runtime-native/tests/native-engine/renderer_test.cpp +++ b/packages/runtime-native/tests/native-engine/renderer_test.cpp @@ -1355,6 +1355,154 @@ void traaLateResolve() { CHECK(late > 40); } +// The resolve keeps the frame it just resolved as the history the next one reads: the colour and the motion mask swap +// between two targets instead of being copied (PRD-755). The scene depth is the caller's texture, so its one copy stays. +void traaHistorySwap() { + mystral::webgpu::Context context; + CHECK(context.initializeHeadless()); + CHECK(rendersPixels(context)); + if (!rendersPixels(context)) return; + const auto device = context.getDevice(); + const auto queue = context.getQueue(); + constexpr uint32_t inputWidth = 16, inputHeight = 12, outputWidth = 24, outputHeight = 18; + WGPUTextureDescriptor descriptor{}; + descriptor.dimension = WGPUTextureDimension_2D; + descriptor.size = {inputWidth, inputHeight, 1}; + descriptor.mipLevelCount = descriptor.sampleCount = 1; + descriptor.format = WGPUTextureFormat_RGBA16Float; + descriptor.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst | WGPUTextureUsage_CopySrc; + const auto beauty = wgpuDeviceCreateTexture(device, &descriptor); + const auto beautyView = wgpuTextureCreateView(beauty, nullptr); + descriptor.format = WGPUTextureFormat_Depth32Float; + descriptor.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopySrc | WGPUTextureUsage_RenderAttachment; + const auto depth = wgpuDeviceCreateTexture(device, &descriptor); + const auto depthView = wgpuTextureCreateView(depth, nullptr); + descriptor.size = {outputWidth, outputHeight, 1}; + descriptor.format = WGPUTextureFormat_RGBA16Float; + descriptor.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopySrc | WGPUTextureUsage_RenderAttachment; + const auto target = wgpuDeviceCreateTexture(device, &descriptor); + const auto targetView = wgpuTextureCreateView(target, nullptr); + TraaPass temporal(device, queue, TraaOptions{}); + temporal.resize(inputWidth, inputHeight, outputWidth, outputHeight); + const TraaPass::Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + const TraaPass::Matrix projection{1.2, 0, 0, 0, 0, 1.6, 0, 0, 0, 0, -1.002, -1, 0, 0, -0.2002, 0}; + // One frame with the pass's own result target, or the one a post graph hands it. + const auto resolve = [&](WGPUTexture output, WGPUTextureView outputView) { + temporal.begin(projection, identity, identity); + WGPUCommandEncoderDescriptor encoderDesc{}; + auto encoder = wgpuDeviceCreateCommandEncoder(device, &encoderDesc); + temporal.resolve(encoder, beauty, beautyView, depth, depthView, output, outputView); + const auto commands = wgpuCommandEncoderFinish(encoder, nullptr); + wgpuQueueSubmit(queue, 1, &commands); + wgpuCommandBufferRelease(commands); wgpuCommandEncoderRelease(encoder); + return temporal.copiesLastFrame(); + }; + std::array own{}, caller{}; + for (auto& copies : own) copies = resolve(nullptr, nullptr); + for (auto& copies : caller) copies = resolve(target, targetView); + std::fprintf(stderr, "TN_TRAA_HISTORY_COPIES steady=%u (colour %u, depth %u, mask %u), into a caller's target=%u " + "(colour %u, depth %u, mask %u)\n", own[3].total(), own[3].color, own[3].depth, own[3].mask, + caller[3].total(), caller[3].color, caller[3].depth, caller[3].mask); + // The first frame also seeds the depth history from the scene depth no earlier frame sampled. + CHECK(own[0].depth == 2); + // A steady frame copies the scene depth alone: the resolve writes the colour and the mask the next frame reads. + for (const auto& copies : {own[3], caller[3]}) { + CHECK(copies.color == 0); + CHECK(copies.mask == 0); + CHECK(copies.depth == 1); + } + wgpuTextureViewRelease(targetView); wgpuTextureRelease(target); + wgpuTextureViewRelease(depthView); wgpuTextureRelease(depth); + wgpuTextureViewRelease(beautyView); wgpuTextureRelease(beauty); +} + +// The swap must not change a pixel of what the resolve produces: 60 frames of a moving scene, once through the copy the +// pass made before PRD-755 and once through the swapped history, read back byte for byte. +void traaHistorySwapIsBitIdentical() { + mystral::webgpu::Context context; + CHECK(context.initializeHeadless()); + CHECK(rendersPixels(context)); + if (!rendersPixels(context)) return; + const auto device = context.getDevice(); + const auto queue = context.getQueue(); + constexpr uint32_t inputWidth = 16, inputHeight = 12, outputWidth = 24, outputHeight = 18; + constexpr int frames = 60; + WGPUTextureDescriptor descriptor{}; + descriptor.dimension = WGPUTextureDimension_2D; + descriptor.size = {inputWidth, inputHeight, 1}; + descriptor.mipLevelCount = descriptor.sampleCount = 1; + descriptor.format = WGPUTextureFormat_RGBA16Float; + descriptor.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst | WGPUTextureUsage_CopySrc; + const auto beauty = wgpuDeviceCreateTexture(device, &descriptor); + const auto beautyView = wgpuTextureCreateView(beauty, nullptr); + descriptor.format = WGPUTextureFormat_Depth32Float; + descriptor.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopySrc | WGPUTextureUsage_RenderAttachment; + const auto depth = wgpuDeviceCreateTexture(device, &descriptor); + const auto depthView = wgpuTextureCreateView(depth, nullptr); + const TraaPass::Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + const TraaPass::Matrix projection{1.2, 0, 0, 0, 0, 1.6, 0, 0, 0, 0, -1.002, -1, 0, 0, -0.2002, 0}; + const auto directory = std::filesystem::temp_directory_path() / + ("traa-history-swap-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + // A camera sliding over a ramp that scrolls with it. The pass's velocity target stays zero, so every pixel takes the + // dynamic clamp and the mask the resolve writes feeds the next frame: a wrong slot shows in the pixels. + const auto arm = [&](bool copyHistory) { + const auto folder = directory / (copyHistory ? "copy" : "swap"); + TraaPass temporal(device, queue, TraaOptions{}); + temporal.resize(inputWidth, inputHeight, outputWidth, outputHeight); + temporal.setHistoryCopyForTest(copyHistory); + temporal.enableDebugDump(0, frames - 1); + for (int frame = 0; frame < frames; ++frame) { + std::vector pixels(size_t{inputWidth} * inputHeight * 4); + for (uint32_t y = 0; y < inputHeight; ++y) for (uint32_t x = 0; x < inputWidth; ++x) { + const auto step = (x + y + uint32_t(frame)) % 4; + const auto texel = (size_t(y) * inputWidth + x) * 4; + pixels[texel] = uint16_t(0x3400 * step); + pixels[texel + 1] = uint16_t(0x3c00 - 0x1000 * step); + pixels[texel + 2] = uint16_t(0x2000 + 0x0800 * step); + pixels[texel + 3] = 0x3c00; // opaque half white + } + WGPUImageCopyTexture_Compat destination{}; + destination.texture = beauty; + destination.aspect = WGPUTextureAspect_All; + WGPUTextureDataLayout_Compat layout{}; + layout.bytesPerRow = inputWidth * 8; layout.rowsPerImage = inputHeight; + const WGPUExtent3D extent{inputWidth, inputHeight, 1}; + wgpuQueueWriteTexture(queue, &destination, pixels.data(), pixels.size() * 2, &layout, &extent); + auto view = identity; + view[12] = 0.03 * frame; + temporal.begin(projection, identity, view); + WGPUCommandEncoderDescriptor encoderDesc{}; + auto encoder = wgpuDeviceCreateCommandEncoder(device, &encoderDesc); + temporal.resolve(encoder, beauty, beautyView, depth, depthView); + const auto commands = wgpuCommandEncoderFinish(encoder, nullptr); + wgpuQueueSubmit(queue, 1, &commands); + wgpuCommandBufferRelease(commands); wgpuCommandEncoderRelease(encoder); + tn::fixture::finishTraaDump(temporal, context.getInstance(), folder); + } + return folder; + }; + const auto swapped = arm(false), copied = arm(true); + const auto bytes = [](const std::filesystem::path& file) { + std::ifstream in(file, std::ios::binary); + return std::vector(std::istreambuf_iterator(in), std::istreambuf_iterator()); + }; + size_t compared = 0, differing = 0; + for (int frame = 0; frame < frames; ++frame) for (const char* name : {"resolved", "history"}) { + const auto file = std::string("frame-") + std::to_string(frame) + "-" + name + ".bin"; + const auto read = bytes(swapped / file), copiedBytes = bytes(copied / file); + CHECK(!read.empty() && read.size() == copiedBytes.size()); + if (read.empty() || read.size() != copiedBytes.size()) continue; + if (read != copiedBytes) ++differing; + ++compared; + } + std::fprintf(stderr, "TN_TRAA_HISTORY_SWAP %zu frames compared, %zu differing\n", compared, differing); + CHECK(compared == size_t(frames) * 2); + CHECK(differing == 0); + std::filesystem::remove_all(directory); + wgpuTextureViewRelease(depthView); wgpuTextureRelease(depth); + wgpuTextureViewRelease(beautyView); wgpuTextureRelease(beauty); +} + // Temporal AA replaces multisampling, as one method does in Unreal. The velocity pass tests depth Equal against the colour // pass, and 4x multisampling hands it one resolved sample of depth: Equal fails on every sloped surface. The engine draws // single-sampled while a traa() effect is in the graph, says so once, and returns to the requested count when it leaves. @@ -3335,6 +3483,8 @@ TN_TEST_MAIN({"history_depth_rows", historyDepthRows}, {"raw_target_scene_only", {"traa_replaces_multisampling", traaReplacesMultisampling}, {"traa_converges_edges", traaConvergesEdges}, {"traa_late_resolve", traaLateResolve}, + {"traa_history_swap", traaHistorySwap}, + {"traa_history_swap_is_bit_identical", traaHistorySwapIsBitIdentical}, {"sibling_reads_the_live_scene", siblingReadsTheLiveScene}, {"traa_ghosting", traaGhosting}, {"traa_camera_pan", traaCameraPan}, diff --git a/packages/runtime-native/tests/native-engine/traa_reference_test.mjs b/packages/runtime-native/tests/native-engine/traa_reference_test.mjs index 5c297f42be..8319ae11d5 100644 --- a/packages/runtime-native/tests/native-engine/traa_reference_test.mjs +++ b/packages/runtime-native/tests/native-engine/traa_reference_test.mjs @@ -60,7 +60,7 @@ assert.ok(render.indexOf("traa_->seedHistory(encoder, sceneColor_)") < render.in "reset must copy previous beauty before the scene overwrites it"); const seed = source.slice(source.indexOf("void TraaPass::seedHistory("), source.indexOf("void TraaPass::resolve(")); assert.match(seed, /if \(!needsSeed\(\)\) return;/); -assert.match(seed, /src\.texture = beauty; dst\.texture = historyColor_;/); +assert.match(seed, /src\.texture = beauty; dst\.texture = historyColor_\[historySlot_\];/); assert.match(seed, /wgpuCommandEncoderCopyTextureToTexture/); assert.doesNotMatch(source.slice(source.indexOf("void TraaPass::resolve(")), /copy\(beauty, historyColor_/); console.log("TRAA reset copies previous beauty before the scene render"); @@ -91,7 +91,7 @@ function blend(current, old, neighbours) { values.clipped = Math.min(values.high, Math.max(values.low, old)); for (const name of ["compressedCurrent", "compressedHistory", "historyWeight"]) values[name] = expression(name, { ...values, luminance: 1 }); values.currentWeight /= values.compressedCurrent + 1; - const result = source.match(/return Resolved\((\(current \* currentWeight \+ clipped \* historyWeight\) \/ max\(currentWeight \+ historyWeight, 0\.00001\)),/); + const result = source.match(/let resolved = (\(current \* currentWeight \+ clipped \* historyWeight\) \/ max\(currentWeight \+ historyWeight, 0\.00001\));/); assert.ok(result, "production flicker reduction changed"); return evaluate(result[1], values); } From 750fcf1e25e1e46ecab7fadb496aef15315df50c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 22:36:56 -0700 Subject: [PATCH 07/10] docs(prd): PRD-755 Phase 1 ticked: the resolve swaps its history (aecafb9ba) (cherry picked from commit 36939744823efc8650a4ed9eb2b26d5b63c5e02f) --- ...default-frame-stops-copying-textures-it-already-holds.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md index 829c200fc9..5930088798 100644 --- a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md +++ b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md @@ -26,8 +26,8 @@ The resolve and the post targets do the same work with no history copy and no ex ### Phase 1: The resolve swaps its history -- [ ] The colour history and the motion mask are swapped each frame instead of copied, and the resolve writes the next history directly. A counter reports the resolve's texture copies per frame. proof: a new ctest, red at 3 copies a frame, green at 0 (or 1 if the scene depth stays a copy, with the reason here); every `native_engine_traa_*` ctest passes with unchanged metric values. -- [ ] The resolved frame is bit-identical to the copy path over 60 frames of a moving scene. proof: a ctest that reads back both paths. +- [x] The colour history and the motion mask are swapped each frame instead of copied, and the resolve writes the next history directly. A counter reports the resolve's texture copies per frame. proof: a new ctest, red at 3 copies a frame, green at 0 (or 1 if the scene depth stays a copy, with the reason here); every `native_engine_traa_*` ctest passes with unchanged metric values. Result (`aecafb9ba`): `native_engine_traa_history_swap` red `steady=3 (colour 1, depth 1, mask 1)`, green `steady=1 (colour 0, depth 1, mask 0)`, also into a caller's target. The depth stays one copy: it is the caller's scene depth, which the resolve only samples. With a caller target, the next history is a third colour attachment (one 8 B/px write in place of an 8 B/px read plus write). `ctest -R "native_engine_traa|native_engine_renderer|velocity_needs_traa"` 95/95 (93/93 before, metric lines unchanged), rerun by the lead. +- [x] The resolved frame is bit-identical to the copy path over 60 frames of a moving scene. proof: a ctest that reads back both paths. Result: `native_engine_traa_history_swap_is_bit_identical` `120 frames compared, 0 differing`; with the swap undone, 118 differ, so the check is not vacuous. ### Phase 2: A post target draws without the extra output pass @@ -45,4 +45,6 @@ Every box above is ticked. ## Decisions +- 2026-10-27, lead: with no caller target the resolve still writes a third attachment beside `resolve_`. Resolving straight into the next history slot would remove that write too, at the cost of a second pipeline. Left for Phase 3's measurement to price. + ## Blocked on From 46991be129a4801e36ee932179a65389544deaca Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 27 Oct 2026 22:59:24 -0700 Subject: [PATCH 08/10] docs(prd): PRD-755 Phase 2 ticked: the identity output pass was already skipped (a0ecc6f0d, 226ec257a) (cherry picked from commit 28b09a11d7e4122a2dde0bc6f9997b62d6a836be) --- ...default-frame-stops-copying-textures-it-already-holds.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md index 5930088798..4298bedbb4 100644 --- a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md +++ b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md @@ -31,8 +31,7 @@ The resolve and the post targets do the same work with no history copy and no ex ### Phase 2: A post target draws without the extra output pass -- [ ] A render into a post target whose output stage is an identity draws its scene pass straight into the target. proof: a ctest, red at one `output` pass per target render, green at 0. -- [ ] The target holds the same pixels as the old path, or this box records why it cannot. proof: a readback ctest. +- [x] A render into a post target whose output stage is an identity draws its scene pass straight into the target. proof: a ctest, red at one `output` pass per target render, green at 0. Result: already landed after the trace this PRD quotes (source `618c0670e`, 2026-10-25): `a0ecc6f0d` (PRD-748) gives a render target's renderer `skipOutputPass()` (`render_target_pass.cpp:34-35`), and `226ec257a` (PRD-741) guards it with `!sceneOnly_` (`renderer.cpp:4440`). Its red-green is that commit's: `native_engine_render_target` red at 1 output pass, green flat (57 to 33 passes a frame); rerun here, passed, and `ctest -L native-engine` 484/484 (gpu2 lane, no code change). ### Phase 3: Prove it on games @@ -45,6 +44,9 @@ Every box above is ticked. ## Decisions +- 2026-10-27, lead: the Phase 2 pixel box is moot and is removed. The skipped output pass drew into the renderer's own `RGBA8Unorm` `colorView_` (`renderer.cpp:4529, 4557`), never into the target, whose pixels are its own linear scene texture (`render_target_pass.cpp:90, 103`), so skipping it cannot change the target. Of the 0.45 ms, 23 `output / 2 / N` rows are 0.321 ms; the 5 `localExposure.*.outputBlit` rows (0.115 ms) have another producer, unverified. +- 2026-10-27, lead: follow-up, not a box here: a reflection pass still draws an unread output pass with the main tone-mapped program (`render_database.cpp:1768-1805`), about 0.3 ms at 0.5 scale per reflector per frame (estimate). Only the sailing kit places water reflectors, so it cannot move the Hop Garden number. + - 2026-10-27, lead: with no caller target the resolve still writes a third attachment beside `resolve_`. Resolving straight into the next history slot would remove that write too, at the cost of a second pipeline. Left for Phase 3's measurement to price. ## Blocked on From 5053a760faf47d8ad641e5fa18eaa50f0dd2fc0a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 28 Oct 2026 00:52:11 -0700 Subject: [PATCH 09/10] docs(prd): PRD-756 Phase 1 sized on Iris Xe and Deferred: a shared post chain removes 0.44 ms (cherry picked from commit 4f9b47176d167855173042a33019e2f35a9571da) --- ...ral-resolve-feeds-one-shared-post-chain.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md b/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md index a67011e5cc..6cc42387ef 100644 --- a/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md +++ b/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md @@ -1,6 +1,6 @@ # PRD-756 — The temporal resolve feeds one shared post chain and keeps a half-size history -**Status:** NOT STARTED +**Status:** PARTIAL — Phase 1 Deferred (0.44 ms removable, under its 1 ms bar) **Priority:** P1 — on Iris Xe at the 0.72 tier start, the TRAA resolve (4.36 ms), bloom at display size (1.55 + 1.21 ms) and local exposure (2.9-3.2 ms) are most of the 14.0 ms that does not scale with resolution ([PRD-741](../../done/PRD-741-the-owned-engine-shades-fewer-pixels-and-its-temporal-resolve-writes-the-display-frame.md), measured per pass). **Complexity:** 6 (MEDIUM) — the resolve outputs and the native post passes (+2), core's bloom and local exposure nodes (+1), a history format with an adapter fallback (+1), visual judge rounds (+2). **Owner:** João @@ -27,7 +27,21 @@ The default frame on Iris Xe loses at least 1.5 ms of GPU time with a NEUTRAL or ### Phase 1: One shared post chain -- [ ] Size it: the bloom chain, the display-size bloom passes and the local exposure passes, per pass, on the current build, and what a shared chain removes. If that is under 1 ms, this phase moves to Deferred with the number. proof: `?tnGpuPasses=1` on Iris Xe, two runs, table here. +- [x] Size it: the bloom chain, the display-size bloom passes and the local exposure passes, per pass, on the current build, and what a shared chain removes. If that is under 1 ms, this phase moves to Deferred with the number. proof: `?tnGpuPasses=1` on Iris Xe, two runs, table here. + Result (2026-10-28, Iris Xe, Hop Garden medium at 0.72, PR #574 head `916682671`, runs PB1 and PB2, 3614 and 3514 steady frames, frame-total p50 19.56 and 19.25 ms): + + | group | PB1 ms | PB2 ms | + | --- | --- | --- | + | scene (1382x778) | 4.239 | 4.234 | + | traaResolve (1920x1080) | 4.546 | 4.532 | + | bloom chain (960x540 to 30x17 and back) | 1.623 | 1.621 | + | display-size bloom | 0.596 | 0.596 | + | local exposure and small chain | 2.892 | 2.888 | + | auto-exposure meters | 0.180 | 0.180 | + | output (1920x1080) | 1.475 | 1.475 | + | other (aux, velocity, input-size post, shadow) | 3.248 | 3.223 | + + A shared chain removes at most the bloom chain's own downsample from the display frame: 960x540 0.312, 480x270 0.068, 240x135 0.029, 120x68 0.014, 60x34 0.010, 30x17 0.008 ms, **0.44 ms** in all (PB2). The local exposure passes are a bilateral grid at input size, not a mip chain, so they would not read the shared levels (inference from their extents: 240x576, 240x540, 480x9). Under 1 ms: Phase 1 is **Deferred**. - [ ] The resolve writes a half-resolution colour, and bloom and local exposure start from one shared chain. proof: ctests for the chain levels; the core look specs pass. - [ ] Visual judge NEUTRAL or IMPROVEMENT on Hop Garden, Lantern Vale and the shooter, two runs per arm. proof: the verdict here and the sheets on the follow-up PR. @@ -47,4 +61,6 @@ Every box is ticked, or a phase is Deferred with its measured number. ## Decisions +- 2026-10-28, lead: Phase 1 is Deferred with its number, as its sizing box set: a shared chain removes at most 0.44 ms on Iris Xe at 0.72. Revisit when a `?tnGpuPasses=1` profile shows the display-frame downsample levels at 1 ms or more, for example at a 4K display. The larger cost is the resolve itself (4.5 ms), which is Phase 2's target. + ## Blocked on From d5db1a40b84930181df0d8abb9e39c5cb71f1e37 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 28 Oct 2026 01:28:25 -0700 Subject: [PATCH 10/10] docs(prd): PRD-755 Iris Xe A/B on Hop Garden: no measurable gain (18.91 vs 19.07 ms, balanced pairs) (cherry picked from commit 2e1bea11a53df3da6e338606704206f942f904f4) --- ...lt-frame-stops-copying-textures-it-already-holds.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md index 4298bedbb4..5b6a66aedc 100644 --- a/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md +++ b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md @@ -36,6 +36,16 @@ The resolve and the post targets do the same work with no history copy and no ex ### Phase 3: Prove it on games - [ ] Paired GPU A/B on Iris Xe, Hop Garden medium and the shooter, three pairs from one code state, GPU p50 and presented p95, load and temperature recorded. proof: the table here. + Hop Garden done, shooter open. 2026-10-28, Iris Xe at 0.72 medium, BEFORE PR #574 head `916682671`, AFTER `aecafb9ba`, Wasm hashes checked different. A first series (B1 A1 B2 A2 B3 A3) was rejected: every AFTER run started 5-10 C hotter (88-93 C). Balanced rerun, each run admitted below 78 C, all at 1200 MHz, presented p95 16.70 ms in every run: + + | pair | order | BEFORE GPU p50 | AFTER GPU p50 | start C | + | --- | --- | --- | --- | --- | + | 1 | A then B | 18.87 | 18.68 | 74 / 76 | + | 2 | B then A | 18.91 | 19.11 | 77 / 77 | + | 3 | A then B | 18.97 | 19.07 | 75 / 80 | + | median | | **18.91** | **19.07** | | + + No measurable gain on Iris Xe: +0.16 ms, inside the AFTER spread (0.43 ms). With `?tnGpuPasses=1`, `traaResolve` is 4.546/4.532 ms BEFORE and 4.556/4.566 ms AFTER: the third colour attachment costs what the two copies cost, and the copies never showed as passes of their own (inference: the copy engine overlaps them). - [ ] The kit scenarios pass on both kits. proof: `pnpm test:templates` on Hop Garden and the shooter. ## Done when