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..5b6a66aedc --- /dev/null +++ b/docs/PRDs/Unreal/performance/PRD-755-the-default-frame-stops-copying-textures-it-already-holds.md @@ -0,0 +1,62 @@ +# 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 + +- [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 + +- [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 + +- [ ] 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 + +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 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..6cc42387ef --- /dev/null +++ b/docs/PRDs/Unreal/performance/PRD-756-the-temporal-resolve-feeds-one-shared-post-chain.md @@ -0,0 +1,66 @@ +# PRD-756 — The temporal resolve feeds one shared post chain and keeps a half-size history + +**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 +**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 + +- [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. + +### 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 + +- 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 diff --git a/packages/core/__tests__/render-camera-cull.spec.ts b/packages/core/__tests__/render-camera-cull.spec.ts index 584a3dc7a6..f120b01656 100644 --- a/packages/core/__tests__/render-camera-cull.spec.ts +++ b/packages/core/__tests__/render-camera-cull.spec.ts @@ -19,16 +19,64 @@ 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, // 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, 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", () => { let seed = 7; const random = () => { @@ -85,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(); @@ -96,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); 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(); 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); } 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); + }); });