From f80f991962b397a5670576b9d2dd661b94090078 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:50:36 -0700
Subject: [PATCH 01/90] docs: reconcile PRD-269 motion history qualification
---
...ion-vectors-or-the-temporal-filters-lie.md | 48 ++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
index 457810b575..ebf9058e8b 100644
--- a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
+++ b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
@@ -4,7 +4,9 @@ prd_contract: v1
# PRD-269 — motion vectors for skinned and instanced geometry, or the temporal filters lie
-**Status:** PROPOSED — filed 2026-08-29, measured at `7e5a9fe1`. Depends on
+**Status:** PARTIAL — reopened for qualification on 2026-10-02 at `d7277838`; originally
+filed 2026-08-29, measured at `7e5a9fe1`. Implementation landed in `3630847a`
+(`squash: deliver PRD-269 motion history`), but the acceptance below is not fully qualified. Depends on
[PRD-266](../useful-defaults/PRD-266-the-render-chain-names-the-tier-it-actually-ran.md); lands before
[PRD-268](./PRD-268-light-that-comes-from-off-screen.md) is judged. Batch:
[docs/PRDs/lighting](./README.md).
@@ -69,6 +71,44 @@ PRD-266 chain as a provisioning stage rather than a user-facing effect:
commands, so per-sub-draw previous transforms are what the velocity pass needs — the aggregate
object transform is not enough and will read as correct in every static test.
+## Current implementation and bounded repair
+
+The problem measurements below are historical, not a claim that the shipped engine has no
+velocity implementation. Commit `3630847a` added `VelocityTracker`, RenderChain MRT provisioning,
+the patched Three.js accessors, and CPU software-rasterization coverage. Reuse those mechanisms.
+The engine-layer defect at `d7277838` is narrower: `SceneRenderProjection.reconcile()` and
+`commit()` skip temporal bookkeeping when `renderer.projection: false`, so an authored
+`BatchedMesh` has no previous-matrix texture even with temporal rendering active. Its patched
+accessor consequently uses current sub-draw matrices as history. Projection opt-out must still
+build no mirror and perform no eligibility scan; temporal-off must retain no history work.
+
+### Phase 1 — qualify shipped motion history
+
+- [ ] Verify the existing per-object transform and MRT provisioning implementation. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
+- [ ] Verify the shipped batched accessor and projection pipeline integration. proof: `pnpm exec vitest run packages/core/__tests__/batched-velocity.spec.ts packages/core/__tests__/render-projection-pipeline.spec.ts`
+
+### Phase 2 — preserve temporal history through projection opt-out
+
+- [ ] Preserve explicit BatchedMesh sub-draw history across first and subsequent rendered frames with projection disabled. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
+- [ ] Release/reset history on temporal toggles, removal and disposal without a projection scan or temporal-off traversal. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts packages/core/__tests__/renderProjection.spec.ts`
+
+### Phase 3 — close original image-space acceptance
+
+- [ ] Add a playtest fixture for an authored moving BatchedMesh with projection disabled. proof: its scenario loads through `packages/playtest` and asserts the moving versus static sub-draw result
+- [ ] Add the original animated-character ghosting playtest with a measured rejection-fraction assertion. proof: scenario drives the active temporal stage and fails if the velocity source is removed
+
+The two Phase 3 fixtures remain implementation work; existing CPU software rasterization is not
+GPU image-space or native proof. The original acceptance and mutation descriptions are preserved
+below and remain unqualified wherever no real lane has run.
+
+## Blocked on
+
+Browser/native GPU execution requires a working WebGPU device or supported native host. This
+cloud environment has no `/dev/dri`, Android device tooling or `/dev/kvm`; the batch's attempted
+Xvfb launch failed with `EPERM`. Keep the PR draft until the original image-space and native
+acceptance has real execution evidence. No GPU, frame-cost, ghosting or platform-parity result
+is inferred from the CPU tests.
+
## Acceptance criteria
1. **A moving skinned mesh produces non-zero velocity where it moved, and zero where it did not.**
@@ -105,6 +145,12 @@ paths, which have their own lifetimes.
## Verification
+2026-10-02 qualification start: existing `render-velocity.spec.ts` passes 11/11 CPU tests.
+`node --import tsx scripts/check-doc-links.ts` resolves 2,395 links; agent mirrors are in sync.
+The prose test lane passes 178/180 tests; two `evidence-budget.spec.ts` subprocess cases are
+blocked by tsx IPC `listen EPERM` in this environment. No code was changed for those failures.
+
+
`pnpm typecheck && pnpm lint && pnpm test`; the ghosting playtest with the before/after rejection
fraction pasted; `pnpm visuals:ab` on a template with an animated character. Native parity follows
PRD-270. `pnpm tsx scripts/count-loc.ts` runs against this one specifically — a velocity pass a
From 71904523246c321c6c5336c5822956fb0ae705f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Fri, 2 Oct 2026 00:02:30 -0700
Subject: [PATCH 02/90] fix(core): retain temporal history when projection is
disabled
---
...ion-vectors-or-the-temporal-filters-lie.md | 36 +++-
.../core/__tests__/render-velocity.spec.ts | 159 +++++++++++++++++-
packages/core/src/renderProjection.ts | 20 +--
3 files changed, 192 insertions(+), 23 deletions(-)
diff --git a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
index ebf9058e8b..ea2ddb0bae 100644
--- a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
+++ b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
@@ -73,7 +73,7 @@ object transform is not enough and will read as correct in every static test.
## Current implementation and bounded repair
-The problem measurements below are historical, not a claim that the shipped engine has no
+The problem measurements above are historical, not a claim that the shipped engine has no
velocity implementation. Commit `3630847a` added `VelocityTracker`, RenderChain MRT provisioning,
the patched Three.js accessors, and CPU software-rasterization coverage. Reuse those mechanisms.
The engine-layer defect at `d7277838` is narrower: `SceneRenderProjection.reconcile()` and
@@ -84,13 +84,13 @@ build no mirror and perform no eligibility scan; temporal-off must retain no his
### Phase 1 — qualify shipped motion history
-- [ ] Verify the existing per-object transform and MRT provisioning implementation. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
-- [ ] Verify the shipped batched accessor and projection pipeline integration. proof: `pnpm exec vitest run packages/core/__tests__/batched-velocity.spec.ts packages/core/__tests__/render-projection-pipeline.spec.ts`
+- [x] Verify the existing per-object transform and MRT provisioning implementation. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
+- [x] Verify the shipped batched accessor and projection pipeline integration. proof: `pnpm exec vitest run packages/core/__tests__/batched-velocity.spec.ts packages/core/__tests__/render-projection-pipeline.spec.ts`
### Phase 2 — preserve temporal history through projection opt-out
-- [ ] Preserve explicit BatchedMesh sub-draw history across first and subsequent rendered frames with projection disabled. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
-- [ ] Release/reset history on temporal toggles, removal and disposal without a projection scan or temporal-off traversal. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts packages/core/__tests__/renderProjection.spec.ts`
+- [x] Preserve explicit BatchedMesh sub-draw history across first and subsequent rendered frames with projection disabled. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts`
+- [x] Release/reset history on temporal toggles, removal and disposal without a projection scan or temporal-off traversal. proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts packages/core/__tests__/renderProjection.spec.ts`
### Phase 3 — close original image-space acceptance
@@ -103,6 +103,11 @@ below and remain unqualified wherever no real lane has run.
## Blocked on
+Actual relevant runtime screenshots must be attached to the PR before merge (owner requirement,
+2026-10-02). They remain unverified; software-rasterizer output and test-log screenshots do not
+satisfy this gate.
+
+
Browser/native GPU execution requires a working WebGPU device or supported native host. This
cloud environment has no `/dev/dri`, Android device tooling or `/dev/kvm`; the batch's attempted
Xvfb launch failed with `EPERM`. Keep the PR draft until the original image-space and native
@@ -145,12 +150,31 @@ paths, which have their own lifetimes.
## Verification
+2026-10-02 repair: before the fix, `render-velocity.spec.ts` reports 4 failed / 12 passed:
+missing `_previousMatricesTexture` in authored batch/frame-footprint tests, plus undefined
+history in toggle and removal tests. Moving temporal enablement/update ahead of the opt-out
+return and allowing its post-render commit makes the focused lane green: 117/117 tests across
+`render-velocity`, `batched-velocity`, `render-projection-pipeline`, and `renderProjection`.
+A broader direct core-suite run was interrupted before results while the targeted lane was rerun.
+The tests assert first-frame identity, non-aliased texture history, next-frame advancement,
+static/moving CPU footprints, toggle resets, removal/disposal, no eligibility scan, and no
+steady-frame history traversal while temporal rendering is off. This verifies phases 1 and 2;
+the CPU footprint fixture builds the patched Three.js nodes but is not GPU readback.
+
+`pnpm lint` passes with 1,000 repository warnings. `pnpm typecheck` is killed with exit 137
+before completing. `pnpm test` exits 2 before assertions because tsx cannot open its IPC socket
+(`listen EPERM`). A direct core typecheck/build additionally needs the playtest package's missing
+built declarations; its normal build reaches the same tsx IPC restriction. Running the same
+validator with `node --import tsx` followed by the package's tsup build succeeds, after which
+`tsc --noEmit -p packages/core/tsconfig.json` passes. The root typecheck/full test/core package
+build remain unverified. `node packages/playtest/dist/runner/cli.js doctor --text` exits 2:
+`TN_PLAYTEST_RUNNER_FAILED`, adb not found.
+
2026-10-02 qualification start: existing `render-velocity.spec.ts` passes 11/11 CPU tests.
`node --import tsx scripts/check-doc-links.ts` resolves 2,395 links; agent mirrors are in sync.
The prose test lane passes 178/180 tests; two `evidence-budget.spec.ts` subprocess cases are
blocked by tsx IPC `listen EPERM` in this environment. No code was changed for those failures.
-
`pnpm typecheck && pnpm lint && pnpm test`; the ghosting playtest with the before/after rejection
fraction pasted; `pnpm visuals:ab` on a template with an animated character. Native parity follows
PRD-270. `pnpm tsx scripts/count-loc.ts` runs against this one specifically — a velocity pass a
diff --git a/packages/core/__tests__/render-velocity.spec.ts b/packages/core/__tests__/render-velocity.spec.ts
index 632f8ce430..2d70e7c46b 100644
--- a/packages/core/__tests__/render-velocity.spec.ts
+++ b/packages/core/__tests__/render-velocity.spec.ts
@@ -17,8 +17,10 @@ import {
import { instance, pass, skinning } from "three/tsl";
import type { Node } from "three/webgpu";
import { VelocityNode, WGSLNodeBuilder } from "three/webgpu";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import * as projectionPlan from "../src/projection-plan.js";
+import { readBatchedMeshPreviousMatrices } from "../src/render/batched-velocity.js";
import { type IRenderChainRenderer, RenderChain } from "../src/render/chain.js";
import {
VelocityTracker,
@@ -318,6 +320,144 @@ describe("velocity provisioning on the shipped path", () => {
tracker.clear();
});
+ it("commits authored sub-draw history when projection is disabled", () => {
+ const { batch, movingBatchInstanceId, scene } = velocitySceneFixture();
+ const scan = vi.spyOn(projectionPlan, "scanProjection");
+ const projection = new SceneRenderProjection(scene, {
+ projection: false,
+ velocity: true,
+ onReport: () => undefined,
+ });
+ try {
+ projection.reconcile();
+ expect(projection.root).toBe(scene);
+ expect(projection.report).toMatchObject({ reasonCode: "disabled", batches: 0 });
+ const first = batchMatrixData(batch, "_matricesTexture").slice();
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).toEqual(first);
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).not.toBe(
+ batchMatrixData(batch, "_matricesTexture"),
+ );
+ projection.commit();
+
+ batch.setMatrixAt(movingBatchInstanceId, new Matrix4().makeTranslation(6.05, 0, 0));
+ projection.reconcile();
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).toEqual(first);
+ expect(batchMatrixData(batch, "_matricesTexture")).not.toEqual(first);
+ const moved = batchMatrixData(batch, "_matricesTexture").slice();
+ projection.commit();
+ // Committing cannot rewrite the history the just-rendered frame consumed.
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).toEqual(first);
+ projection.reconcile();
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).toEqual(moved);
+ expect(scan).not.toHaveBeenCalled();
+ } finally {
+ projection.dispose();
+ scan.mockRestore();
+ }
+ });
+
+ it("resets opted-out history when temporal rendering is toggled and releases owned textures", () => {
+ const { batch, movingBatchInstanceId, scene } = velocitySceneFixture();
+ batch.userData.useVelocity = false;
+ let velocity = false;
+ const projection = new SceneRenderProjection(scene, {
+ projection: false,
+ velocity: () => velocity,
+ onReport: () => undefined,
+ });
+ projection.reconcile();
+ projection.commit();
+ expect(readBatchedMeshPreviousMatrices(batch)).toBeUndefined();
+ velocity = true;
+ projection.reconcile();
+ projection.commit();
+ const firstTexture = readBatchedMeshPreviousMatrices(batch);
+ expect(firstTexture).toBeDefined();
+ let disposals = 0;
+ firstTexture?.addEventListener("dispose", () => {
+ disposals += 1;
+ });
+ expect(batch.userData.useVelocity).toBe(true);
+
+ velocity = false;
+ projection.reconcile();
+ projection.commit();
+ expect(disposals).toBe(1);
+ expect(readBatchedMeshPreviousMatrices(batch)).toBeUndefined();
+ expect(readVelocityPreviousMatrices(batch)).toBeUndefined();
+ expect(readVelocityPreviousWorldMatrix(batch)).toBeUndefined();
+ expect(batch.userData.useVelocity).toBe(false);
+
+ batch.setMatrixAt(movingBatchInstanceId, new Matrix4().makeTranslation(8, 0, 0));
+ velocity = true;
+ projection.reconcile();
+ expect(batchMatrixData(batch, "_previousMatricesTexture")).toEqual(
+ batchMatrixData(batch, "_matricesTexture"),
+ );
+ const secondTexture = readBatchedMeshPreviousMatrices(batch);
+ expect(secondTexture).not.toBe(firstTexture);
+ secondTexture?.addEventListener("dispose", () => {
+ disposals += 1;
+ });
+ projection.dispose();
+ projection.dispose();
+ expect(disposals).toBe(2);
+ expect(readBatchedMeshPreviousMatrices(batch)).toBeUndefined();
+ expect(batch.userData.useVelocity).toBe(false);
+ });
+
+ it("releases authored batch history when an opted-out scene removes the batch", () => {
+ const { batch, scene } = velocitySceneFixture();
+ const projection = new SceneRenderProjection(scene, {
+ projection: false,
+ velocity: true,
+ onReport: () => undefined,
+ });
+ projection.reconcile();
+ const texture = readBatchedMeshPreviousMatrices(batch);
+ expect(texture).toBeDefined();
+ let disposals = 0;
+ texture?.addEventListener("dispose", () => {
+ disposals += 1;
+ });
+ scene.remove(batch);
+ projection.reconcile();
+ projection.commit();
+ expect(disposals).toBe(1);
+ expect(readBatchedMeshPreviousMatrices(batch)).toBeUndefined();
+ expect(readVelocityPreviousMatrices(batch)).toBeUndefined();
+ expect(batch.userData).not.toHaveProperty("useVelocity");
+ projection.dispose();
+ expect(disposals).toBe(1);
+ });
+
+ it("does no history traversal with projection and temporal rendering disabled", () => {
+ const { batch, scene } = velocitySceneFixture();
+ const walk = vi.spyOn(scene, "traverse");
+ const matrices = vi.spyOn(scene, "updateMatrixWorld");
+ const projection = new SceneRenderProjection(scene, {
+ projection: false,
+ velocity: false,
+ onReport: () => undefined,
+ });
+ // The one-time disabled report counts renderables; steady frames do no history work.
+ projection.reconcile();
+ walk.mockClear();
+ matrices.mockClear();
+ for (let frame = 0; frame < 3; frame += 1) {
+ projection.reconcile();
+ projection.commit();
+ }
+ expect(walk).not.toHaveBeenCalled();
+ expect(matrices).not.toHaveBeenCalled();
+ expect(readBatchedMeshPreviousMatrices(batch)).toBeUndefined();
+ expect(readVelocityPreviousMatrices(batch)).toBeUndefined();
+ expect(batch.userData).not.toHaveProperty("useVelocity");
+ projection.dispose();
+ walk.mockRestore();
+ matrices.mockRestore();
+ });
+
it("freezes rigid and bone history at the scheduled frame boundary", () => {
const scene = new Scene();
const rigid = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial());
@@ -466,7 +606,7 @@ describe("velocity provisioning on the shipped path", () => {
expect(textureNames(missingProviderPass)).not.toContain("velocity");
});
- it("renders non-zero MRT footprints for moved rigid, skinned, instanced, and batched objects", () => {
+ it.each([true, false])("renders non-zero MRT footprints with projection %s", (enabled) => {
const fixture = velocitySceneFixture();
const renderer = new SoftwareVelocityRenderer(fixture.scenePass, 200, 100);
let receivedVelocityNode: Node | undefined;
@@ -486,9 +626,13 @@ describe("velocity provisioning on the shipped path", () => {
report: () => undefined,
});
- const tracker = new VelocityTracker();
- tracker.update(fixture.scene);
- tracker.commit(fixture.scene);
+ const projection = new SceneRenderProjection(fixture.scene, {
+ projection: enabled,
+ velocity: true,
+ onReport: () => undefined,
+ });
+ projection.reconcile();
+ projection.commit();
fixture.rigid.position.x += 0.75;
const rootBone = fixture.character.skeleton.bones[0];
@@ -500,7 +644,7 @@ describe("velocity provisioning on the shipped path", () => {
new Matrix4().makeTranslation(6.05, 0, 0),
);
fixture.scene.updateMatrixWorld(true);
- tracker.update(fixture.scene);
+ projection.reconcile();
renderer.setShaderState(
prepareVelocityShaderState(fixture.camera, {
@@ -508,7 +652,7 @@ describe("velocity provisioning on the shipped path", () => {
}),
);
const rendered = renderer.render(fixture.camera);
- tracker.commit(fixture.scene);
+ projection.commit();
expect(chain.applied.velocity).toMatchObject({ provisioned: true, source: "mrt" });
expect(fixture.scenePass.getMRT()?.has("velocity")).toBe(true);
@@ -525,6 +669,7 @@ describe("velocity provisioning on the shipped path", () => {
expect(rendered.footprints.get("instanced-static")?.movingPixels).toBe(0);
expect(rendered.footprints.get("batched-moving")?.movingPixels).toBeGreaterThan(0);
expect(rendered.footprints.get("batched-static")?.movingPixels).toBe(0);
+ projection.dispose();
});
it("feeds the scheduled snapshots into Three's instance and skinning update hooks", () => {
diff --git a/packages/core/src/renderProjection.ts b/packages/core/src/renderProjection.ts
index 9933db7b9f..a878a8c729 100644
--- a/packages/core/src/renderProjection.ts
+++ b/packages/core/src/renderProjection.ts
@@ -166,7 +166,7 @@ export interface IRenderProjectionOptions {
* each frame. The verdict is reported as `disabled`, not as one of the measured declines.
*/
readonly projection?: boolean | { readonly materialChecks?: ProjectionMaterialChecks };
- /** Allocates per-sub-draw previous matrices for the material-batching lane. */
+ /** Retains drawn-object history for temporal stages, independently of projection opt-out. */
readonly velocity?: boolean | (() => boolean);
/**
* The engine's world-matrix walk, when the frame owns one. The authored scene is refreshed here
@@ -287,10 +287,15 @@ export class SceneRenderProjection {
* scene is not what renders and nothing else refreshes it.
*/
reconcile(): void {
+ const velocityEnabled = resolveVelocityEnabled(this.#velocity);
+ if (this.#velocityActive !== velocityEnabled) {
+ this.#velocityTracker.clear();
+ this.#velocityActive = velocityEnabled;
+ }
if (!this.#enabled) {
- // The game declined the projection: report the verdict once, then do nothing at all. No
- // mirror is built, no eligibility scan runs, and `root` is the authored scene — the opt-out
- // costs a compare, not a re-judged decline.
+ // Temporal history belongs to the authored draw even when its optimization is declined.
+ // No mirror or eligibility scan runs; without a temporal consumer no history work runs.
+ if (velocityEnabled) this.#velocityTracker.update(this.#source);
this.#deoptimize("disabled", "the game set renderer.projection to false");
this.#publish();
return;
@@ -298,11 +303,6 @@ export class SceneRenderProjection {
const mirror = this.#mirror;
if (mirror === undefined) return;
const startedAt = globalThis.performance?.now() ?? 0;
- const velocityEnabled = resolveVelocityEnabled(this.#velocity);
- if (this.#velocityActive !== velocityEnabled) {
- this.#velocityTracker.clear();
- this.#velocityActive = velocityEnabled;
- }
if (mirror.setVelocityEnabled(velocityEnabled)) {
this.#deoptimized = true;
this.#framesSinceDeclineScan = DECLINE_RESCAN_FRAMES;
@@ -414,7 +414,7 @@ export class SceneRenderProjection {
/** Commits the rendered transform snapshot after the colour and velocity passes consume it. */
commit(): void {
- if (!this.#enabled || !this.#velocityActive) return;
+ if (!this.#velocityActive) return;
this.#velocityTracker.commit(this.root);
}
From ac9b0f76ab7df1ec7da3d4f858b56583d11f778f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Fri, 2 Oct 2026 00:07:52 -0700
Subject: [PATCH 03/90] docs(prd455): prioritize full-resolution temporal AA
milestone
---
...-reconstruction-from-dynamic-resolution.md | 22 +++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
index 5e917f42aa..db767799e7 100644
--- a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
+++ b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
@@ -4,7 +4,7 @@ prd_contract: v1
# PRD-455 — Fewer rendered pixels reconstruct into a stable full-resolution frame
-**Status:** PROPOSED — filed 2026-09-26.
+**Status:** IN PROGRESS — full-resolution temporal-AA milestone first (owner, 2026-10-02).
**Priority:** highest-value rendering project after the streaming quick wins.
**Complexity:** 8 → HIGH. The renderer already has the difficult prerequisites; the remaining risk is history correctness and proving reconstruction wins more GPU time than it costs.
**Depends on:** the landed motion-history implementation from PRD-269 (`packages/core/src/render/velocity.ts`, commit `3630847a`), the existing `RenderChain`, and [PRD-384 adaptive resolution](../PRD-384-adaptive-resolution-gpu-headroom.md).
@@ -50,6 +50,18 @@ the existing full-resolution path remains the baseline.
public knobs, if any survive measurement, are correctness/performance limits rather than a visual
style.
+## Implementation order (owner direction, 2026-10-02)
+
+First qualify plain full-resolution temporal AA using the installed Three.js 0.185.1 TRAANode,
+RenderChain and VelocityTracker. The generated game owns the opt-in provider and history-reset
+policy; no template default changes. Add a deterministic shared fixture, CPU regressions and a
+maintained headed WebGPU screenshot lane before attempting lower-resolution reconstruction.
+Then extend the existing renderer sizing seam to distinguish internal and display dimensions,
+retain one scaler, and qualify the original reconstruction/visual/performance outcomes below.
+Every PR revision retains actual runtime screenshots when the hosted capture lane can execute;
+CPU-only checks never qualify image quality or native/hardware performance. Do not promote this
+experimental path or mark the draft ready before the outstanding acceptance evidence exists.
+
## Integration ledger
| Existing surface | Change |
@@ -59,17 +71,19 @@ the existing full-resolution path remains the baseline.
| `packages/core/src/resolution-scaler.ts` | No new policy. Expose/consume the resolved scale and reset history on size transitions. |
| `packages/runtime-native/conformance/scenes/shared/` | Add one reconstruction scene that runs the same source on browser/native targets. |
-## Phase 1 — Define history correctness before chasing image quality
+### Phase 1 — Define history correctness before chasing image quality
+
+- [ ] Full-resolution temporal AA is opt-in generated source, uses the existing velocity source and resets history on discontinuity. **proof:** focused TRAANode/RenderChain tests and the deterministic runtime temporal fixture with actual screenshots; native qualification remains separately reported.
- [ ] The reconstruction stage produces a display-sized output from a smaller colour/depth input and records input size, output size, history-valid state and rejection fraction. **proof:** focused render-chain test runs 0.67→1.0 sizing, then a mutation returning the low-resolution target directly fails the output-size assertion.
- [ ] Camera cuts, projection changes and resolution changes invalidate history for the affected frame; moving skinned and instanced fixtures use the existing velocity source rather than a camera-only approximation. **proof:** deterministic fixture covers cut/resize/skinned/instanced cases and a zero-velocity mutation fails the moving-object rejection/stability assertion.
-## Phase 2 — Prove motion stability on content that exposes temporal defects
+### Phase 2 — Prove motion stability on content that exposes temporal defects
- [ ] A fixed camera route containing thin fences, foliage, sub-pixel edges, a moving character and an instanced moving object stays within pinned temporal-stability/ghosting thresholds against a full-resolution reference. **proof:** automated frame-sequence report records edge flicker, rejected-history ratio and image delta for full-res, low-res spatial upscale and temporal reconstruction; the temporal arm must beat the spatial arm on the named stability metric.
- [ ] Newly revealed surfaces do not inherit stale colour after occlusion/disocclusion events. **proof:** foreground-occluder fixture reveals a contrasting background and asserts stale-history pixels decay within the declared frame bound; disabling disocclusion rejection makes it fail.
-## Phase 3 — Keep it only if it buys real frame time
+### Phase 3 — Keep it only if it buys real frame time
- [ ] On a GPU-bound representative game, sub-1.0 rendering plus reconstruction lowers GPU/render p95 versus full-resolution rendering while meeting the Phase-2 visual thresholds. **proof:** paired fixed-route browser WebGPU and desktop-native table records internal pixels, reconstruction cost, total GPU/render p50/p95 and visual metrics; no “FPS only” verdict.
- [ ] Automatic resolution can move between at least three scales during one run without history corruption, allocation growth or a reconstruction cost spike larger than the saved raster cost. **proof:** scripted scaler route records scale transitions, history resets, render-target allocation count and per-stage cost; repeated up/down cycles end at the initial allocation baseline.
From 9113c4f7c7d1ebfddce7c9cf35fd33801506d59d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Fri, 2 Oct 2026 00:15:51 -0700
Subject: [PATCH 04/90] docs: track original PRD-269 acceptance and core build
proof
---
...ion-vectors-or-the-temporal-filters-lie.md | 20 +++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
index ea2ddb0bae..4e7c9c38a7 100644
--- a/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
+++ b/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
@@ -107,7 +107,6 @@ Actual relevant runtime screenshots must be attached to the PR before merge (own
2026-10-02). They remain unverified; software-rasterizer output and test-log screenshots do not
satisfy this gate.
-
Browser/native GPU execution requires a working WebGPU device or supported native host. This
cloud environment has no `/dev/dri`, Android device tooling or `/dev/kvm`; the batch's attempted
Xvfb launch failed with `EPERM`. Keep the PR draft until the original image-space and native
@@ -116,28 +115,32 @@ is inferred from the CPU tests.
## Acceptance criteria
-1. **A moving skinned mesh produces non-zero velocity where it moved, and zero where it did not.**
+- [ ] **A moving skinned mesh produces non-zero velocity where it moved, and zero where it did not.** proof: PR #393 GPU fixture capture and velocity-buffer assertions (pending).
A fixture animates one skinned mesh in front of a static wall; the velocity buffer is non-zero
over the mesh's screen footprint and zero over the wall, within tolerance. *Mutation:* write the
current world matrix into the previous-transform slot and the spec fails with a zero buffer over
the mesh.
-2. **Per-instance motion is per-instance.** With an `InstancedMesh` where one instance moves and the
+- [ ] **Per-instance motion is per-instance.** proof: PR #393 `InstancedMesh` and `BatchedMesh` per-instance GPU assertions (pending).
+ With an `InstancedMesh` where one instance moves and the
rest are still, velocity is non-zero only over the moving instance. *Mutation:* track one
transform for the whole `InstancedMesh` and the spec fails by marking every instance as moving.
The same case is asserted for `BatchedMesh` sub-draws.
-3. **Ghosting is measured, not judged by eye.** A playtest drives a character across a
+- [ ] **Ghosting is measured, not judged by eye.** proof: PR #393 ghosting playtest with a pinned threshold and zero-velocity control (pending).
+ A playtest drives a character across a
GI-lit background and asserts the disocclusion-rejection fraction stays below a pinned threshold
while the temporal stage remains active. *Mutation:* feed the temporal node a zero velocity
buffer and the assertion fails on the rejection fraction — the failing number is pasted in the
PRD's red before the fix lands.
-4. **No temporal stage requested, no velocity cost.** With every temporal stage off, no velocity
+- [ ] **No temporal stage requested, no velocity cost.** proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts` plus PR #393 temporal-off/on frame-cost measurement (pending).
+ With every temporal stage off, no velocity
target is allocated and the `render` phase is unchanged within noise. *Mutation:* allocate it
unconditionally and the allocation spec fails naming the target.
-5. **Bookkeeping is frame-ordered, not incidental.** Moving an object after the velocity update
+- [ ] **Bookkeeping is frame-ordered, not incidental.** proof: `pnpm exec vitest run packages/core/__tests__/render-velocity.spec.ts` plus PR #393 live colour/velocity ordering assertions (pending).
+ Moving an object after the velocity update
point within the same frame produces a velocity consistent with the colour pass — the two agree
or the frame is wrong. *Mutation:* update previous transforms at draw time instead of at the
scheduled point and the ordering spec fails.
@@ -166,8 +169,9 @@ before completing. `pnpm test` exits 2 before assertions because tsx cannot open
(`listen EPERM`). A direct core typecheck/build additionally needs the playtest package's missing
built declarations; its normal build reaches the same tsx IPC restriction. Running the same
validator with `node --import tsx` followed by the package's tsup build succeeds, after which
-`tsc --noEmit -p packages/core/tsconfig.json` passes. The root typecheck/full test/core package
-build remain unverified. `node packages/playtest/dist/runner/cli.js doctor --text` exits 2:
+`tsc --noEmit -p packages/core/tsconfig.json` passes. After that dependency was available,
+`pnpm --filter @threenative/core build` was rerun serially and passes ESM, DTS and publint
+(exit 0). The root typecheck and full test suite remain unverified. `node packages/playtest/dist/runner/cli.js doctor --text` exits 2:
`TN_PLAYTEST_RUNNER_FAILED`, adb not found.
2026-10-02 qualification start: existing `render-velocity.spec.ts` passes 11/11 CPU tests.
From 1e7d365ea94855dd36f85e6c1c218d9047572557 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Fri, 2 Oct 2026 01:34:53 -0700
Subject: [PATCH 05/90] fix(render): pass temporal jitter the velocity accessor
---
...-reconstruction-from-dynamic-resolution.md | 7 ++
.../core/__tests__/temporal-chain.spec.ts | 66 +++++++++++++++++++
packages/core/src/render/chain.ts | 5 +-
3 files changed, 77 insertions(+), 1 deletion(-)
create mode 100644 packages/core/__tests__/temporal-chain.spec.ts
diff --git a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
index db767799e7..b01d30ef99 100644
--- a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
+++ b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
@@ -75,6 +75,13 @@ experimental path or mark the draft ready before the outstanding acceptance evid
- [ ] Full-resolution temporal AA is opt-in generated source, uses the existing velocity source and resets history on discontinuity. **proof:** focused TRAANode/RenderChain tests and the deterministic runtime temporal fixture with actual screenshots; native qualification remains separately reported.
+ Partial, 2026-10-02: the installed TRAANode setup/jitter regression reproduced
+ `setProjectionMatrix is not a function` because RenderChain supplied a sampled texture where
+ Three expects its velocity accessor. The caller now keeps accessor and texture separate;
+ `vitest run packages/core/__tests__/{temporal-chain,render-chain,render-velocity}.spec.ts
+ --maxWorkers=1` passes 56 tests after fresh-checkout red-to-green reproduction. No runtime,
+ image-quality, native, or performance qualification is claimed by these CPU tests.
+
- [ ] The reconstruction stage produces a display-sized output from a smaller colour/depth input and records input size, output size, history-valid state and rejection fraction. **proof:** focused render-chain test runs 0.67→1.0 sizing, then a mutation returning the low-resolution target directly fails the output-size assertion.
- [ ] Camera cuts, projection changes and resolution changes invalidate history for the affected frame; moving skinned and instanced fixtures use the existing velocity source rather than a camera-only approximation. **proof:** deterministic fixture covers cut/resize/skinned/instanced cases and a zero-velocity mutation fails the moving-object rejection/stability assertion.
diff --git a/packages/core/__tests__/temporal-chain.spec.ts b/packages/core/__tests__/temporal-chain.spec.ts
new file mode 100644
index 0000000000..bb92baea34
--- /dev/null
+++ b/packages/core/__tests__/temporal-chain.spec.ts
@@ -0,0 +1,66 @@
+import { PerspectiveCamera, Scene } from "three";
+import TRAANode from "three/addons/tsl/display/TRAANode.js";
+import { pass, velocity } from "three/tsl";
+import type { Node, NodeBuilder, TextureNode } from "three/webgpu";
+import { describe, expect, it } from "vitest";
+import { RenderChain } from "../src/render/chain.js";
+
+describe("the pinned TRAANode render-chain contract", () => {
+ it("keeps the sampled velocity texture separate from the jitter accessor", () => {
+ const camera = new PerspectiveCamera(50, 1280 / 720);
+ const scenePass = pass(new Scene(), camera);
+ let graph: unknown;
+ let temporal: TRAANode | undefined;
+ let sampledVelocity: Node | undefined;
+ const chain = new RenderChain({
+ renderer: {
+ kind: "webgpu",
+ raw: {},
+ setOutputNode: (node) => {
+ graph = node;
+ },
+ },
+ input: scenePass.getTextureNode(),
+ request: { stages: ["traa"], velocity: { pass: scenePass } },
+ report: () => {},
+ stages: [
+ {
+ name: "traa",
+ build: (input, context) => {
+ sampledVelocity = context.velocityNode;
+ temporal = new TRAANode(
+ input as TextureNode,
+ scenePass.getTextureNode("depth"),
+ context.velocityNode as TextureNode,
+ camera,
+ );
+ return temporal;
+ },
+ },
+ ],
+ });
+ expect(chain.applied.stages).toEqual(["traa"]);
+ expect(sampledVelocity).toBe(scenePass.getTextureNode("velocity"));
+ const wrapped = graph as { value: { velocity: unknown } };
+ temporal?.setup({
+ context: { ...wrapped.value, renderPipeline: { context: {} } },
+ renderer: {},
+ } as unknown as NodeBuilder);
+ const jitter = temporal as unknown as {
+ setViewOffset(width: number, height: number): void;
+ clearViewOffset(): void;
+ };
+ try {
+ expect(() => jitter.setViewOffset(1280, 720)).not.toThrow();
+ expect(wrapped.value.velocity).toBe(velocity);
+ expect(camera.view?.enabled).toBe(true);
+ } finally {
+ // A failed assertion must not leave Three's singleton velocity accessor jittered.
+ if (wrapped.value.velocity === velocity) jitter.clearViewOffset();
+ temporal?.dispose();
+ chain.dispose();
+ scenePass.dispose();
+ }
+ expect(camera.view?.enabled).toBe(false);
+ });
+});
diff --git a/packages/core/src/render/chain.ts b/packages/core/src/render/chain.ts
index 1e8d74bec6..f92b955071 100644
--- a/packages/core/src/render/chain.ts
+++ b/packages/core/src/render/chain.ts
@@ -1,3 +1,4 @@
+import { velocity as velocityAccessor } from "three/tsl";
import type { MRTNode, Node } from "three/webgpu";
import type { IFrameBudgetWindow } from "../frame-budget.js";
@@ -417,7 +418,9 @@ export class RenderChain {
requiresVelocityFor(this.#stageDefinitions.get(name), name),
);
if (velocityNode !== undefined && hasActiveVelocityStage)
- node = withVelocityContext(node, velocityNode);
+ // TRAANode uses context.velocity to set the unjittered projection, not to sample MRT.
+ // The sampled texture already travels to the factory via context.velocityNode.
+ node = withVelocityContext(node, velocityAccessor);
if (stages.length > 0) {
try {
From bbbf93ca8c10a30b168fdb984d31a7567ebeccaf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?=
<30300459+jonit-dev@users.noreply.github.com>
Date: Fri, 2 Oct 2026 01:53:17 -0700
Subject: [PATCH 06/90] feat(render): add opt-in temporal AA qualification
fixture
---
.github/workflows/integration-temporal.yml | 43 ++++++
...-reconstruction-from-dynamic-resolution.md | 12 ++
.../playtests/temporal-aa.playtest.json | 13 ++
examples/abyss-framework/temporal-main.js | 20 +++
examples/abyss-framework/temporal.html | 1 +
.../abyss-framework/temporal.vite.config.ts | 14 ++
.../__tests__/scaffold.spec.ts | 4 +-
.../__tests__/temporal-aa.spec.ts | 128 +++++++++++++++++
.../templates/starter/AGENTS.md | 2 +
.../templates/starter/CLAUDE.md | 2 +
.../starter/src/render/temporalAA.ts | 131 ++++++++++++++++++
.../runtime-native/conformance/registry.json | 16 +++
.../scenes/shared/temporal-aa-fixture.js | 104 ++++++++++++++
.../conformance/scenes/shared/temporal-aa.js | 9 ++
scripts/verify-temporal-aa.ts | 120 ++++++++++++++++
15 files changed, 618 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/integration-temporal.yml
create mode 100644 examples/abyss-framework/playtests/temporal-aa.playtest.json
create mode 100644 examples/abyss-framework/temporal-main.js
create mode 100644 examples/abyss-framework/temporal.html
create mode 100644 examples/abyss-framework/temporal.vite.config.ts
create mode 100644 packages/create-threenative/__tests__/temporal-aa.spec.ts
create mode 100644 packages/create-threenative/templates/starter/src/render/temporalAA.ts
create mode 100644 packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js
create mode 100644 packages/runtime-native/conformance/scenes/shared/temporal-aa.js
create mode 100644 scripts/verify-temporal-aa.ts
diff --git a/.github/workflows/integration-temporal.yml b/.github/workflows/integration-temporal.yml
new file mode 100644
index 0000000000..ea0c45c88b
--- /dev/null
+++ b/.github/workflows/integration-temporal.yml
@@ -0,0 +1,43 @@
+name: Integration temporal AA
+on:
+ pull_request:
+ paths:
+ - 'packages/core/src/render/chain.ts'
+ - 'packages/core/src/render/velocity.ts'
+ - 'packages/core/__tests__/temporal-chain.spec.ts'
+ - 'packages/create-threenative/templates/starter/src/render/temporalAA.ts'
+ - 'packages/create-threenative/__tests__/temporal-aa.spec.ts'
+ - 'packages/runtime-native/conformance/scenes/shared/temporal-aa*.js'
+ - 'examples/abyss-framework/temporal*'
+ - 'examples/abyss-framework/playtests/temporal-aa.playtest.json'
+ - 'scripts/verify-temporal-aa.ts'
+ - '.github/workflows/integration-temporal.yml'
+permissions:
+ contents: read
+jobs:
+ temporal:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/pnpm
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter @threenative/playtest build
+ - uses: ./.github/actions/playwright-chromium
+ - name: Validate actual TRAANode and history lifecycle contracts
+ run: pnpm exec vitest run packages/core/__tests__/temporal-chain.spec.ts packages/core/__tests__/render-chain.spec.ts packages/core/__tests__/render-velocity.spec.ts packages/create-threenative/__tests__/temporal-aa.spec.ts --maxWorkers=2
+ - name: Render temporal motion and reset variants
+ run: node --import tsx scripts/verify-temporal-aa.ts
+ - name: Retain actual runtime screenshots and adapter evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: temporal-aa-${{ github.event.pull_request.head.sha }}
+ path: artifacts/temporal-aa
+ if-no-files-found: error
diff --git a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
index b01d30ef99..696406cc68 100644
--- a/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
+++ b/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
@@ -82,6 +82,18 @@ experimental path or mark the draft ready before the outstanding acceptance evid
--maxWorkers=1` passes 56 tests after fresh-checkout red-to-green reproduction. No runtime,
image-quality, native, or performance qualification is claimed by these CPU tests.
+ Partial, 2026-10-02: starter-only generated `temporalAA.ts` now reuses TRAANode and owns
+ explicit cut/reset, projection/size invalidation, raster-size guards and disposal. The shared
+ fixture exercises moving rigid/skinned/instanced content; `integration-temporal.yml` captures
+ reference, temporal, cut, projection and resize variants through the existing headed playtest
+ runner. Fresh focused checks: 297/297 source/scaffold/workflow tests, 67/67 native-conformance
+ contract tests, root TypeScript check, core build, fixture web build and shared native bundle
+ all pass. These are source/contracts/builds, not native execution. Local runtime capture was
+ attempted and refused because this environment has neither Xvfb nor a usable X display;
+ the hosted lane must supply actual screenshots and adapter evidence before this box is checked.
+ The complete repository test/budget/quality board and temporal visual-quality corpus are still
+ unrun for this slice. The optional conformance row does not qualify any native target.
+
- [ ] The reconstruction stage produces a display-sized output from a smaller colour/depth input and records input size, output size, history-valid state and rejection fraction. **proof:** focused render-chain test runs 0.67→1.0 sizing, then a mutation returning the low-resolution target directly fails the output-size assertion.
- [ ] Camera cuts, projection changes and resolution changes invalidate history for the affected frame; moving skinned and instanced fixtures use the existing velocity source rather than a camera-only approximation. **proof:** deterministic fixture covers cut/resize/skinned/instanced cases and a zero-velocity mutation fails the moving-object rejection/stability assertion.
diff --git a/examples/abyss-framework/playtests/temporal-aa.playtest.json b/examples/abyss-framework/playtests/temporal-aa.playtest.json
new file mode 100644
index 0000000000..0e9b4af1d3
--- /dev/null
+++ b/examples/abyss-framework/playtests/temporal-aa.playtest.json
@@ -0,0 +1,13 @@
+{
+ "name": "full-resolution-temporal-aa",
+ "schemaVersion": 1,
+ "target": "web",
+ "viewport": { "width": 1280, "height": 720 },
+ "warmupFrames": 0,
+ "artifacts": { "screenshots": "before-after" },
+ "steps": [{ "kind": "wait", "label": "motion", "waitTicks": 4 }],
+ "assert": {
+ "resources": [{ "id": "temporal", "path": "frame", "gte": 24, "changed": true }],
+ "diagnostics": { "noConsoleErrors": true, "noNetworkErrors": true }
+ }
+}
diff --git a/examples/abyss-framework/temporal-main.js b/examples/abyss-framework/temporal-main.js
new file mode 100644
index 0000000000..d08b3da87c
--- /dev/null
+++ b/examples/abyss-framework/temporal-main.js
@@ -0,0 +1,20 @@
+import { installThreePlaytestBridge } from "@threenative/playtest/three";
+import { NoToneMapping, PerspectiveCamera, Scene, WebGPURenderer } from "three/webgpu";
+import { createTemporalAAFixture } from "../../packages/runtime-native/conformance/scenes/shared/temporal-aa-fixture.js";
+
+const renderer = new WebGPURenderer({ antialias: false });
+renderer.setPixelRatio(1);
+renderer.setSize(1280, 720);
+renderer.toneMapping = NoToneMapping;
+document.body.appendChild(renderer.domElement);
+await renderer.init();
+const scene = new Scene();
+const camera = new PerspectiveCamera(50, 1280 / 720, 0.1, 100);
+const fixture = createTemporalAAFixture(renderer, scene, camera, new URLSearchParams(location.search).get("variant") ?? "temporal");
+for (let index = 0; index < 20; index++) fixture.render();
+installThreePlaytestBridge({
+ camera, scene, renderer,
+ fixedStep: async (ticks) => { for (let index = 0; index < ticks; index++) fixture.render(); return ticks; },
+ resources: { read: () => ({ temporal: fixture.observation() }) },
+ tick: () => fixture.observation().frame,
+});
diff --git a/examples/abyss-framework/temporal.html b/examples/abyss-framework/temporal.html
new file mode 100644
index 0000000000..10583d4ebd
--- /dev/null
+++ b/examples/abyss-framework/temporal.html
@@ -0,0 +1 @@
+