Module: Animation Parity Testing — Deterministic Frame Sync
Scope:
lab/,scripts/capture-golden.ts,tests/lite/parity/
Purpose
Animated scenes (skeleton, morph targets) must be captured at the exact same animation frame in both the Babylon.js reference and Babylon Lite. Without synchronisation, frame-rate differences cause different poses and produce false parity failures. This module documents the deterministic frame synchronisation protocol that eliminates that class of flake.
Problem
Each machine/GPU renders at a different native frame rate. Babylon.js adapts its animation timestep to the real delta time, so a 144 Hz monitor and a 60 Hz monitor will show a different pose after the same wall-clock duration. Screenshot comparisons therefore produce spurious diffs unless both sides agree on which animation frame to capture and use identical timesteps to reach it.
Solution: Freeze-at-Frame-N + Constant Timestep
The protocol has two parts:
- Constant timestep — both engines advance animations by exactly 16 ms per frame, regardless of the real delta time.
- Frame freeze — at a predetermined frame count (e.g. frame 300), both engines pause all animations but continue rendering. A DOM signal tells the test harness that the frozen frame is ready for capture.
Constant Timestep (Both Sides)
BJS Reference (babylon-ref-sceneN.html)
engine.getDeltaTime = function () { return 16;};scene.useConstantAnimationDeltaTime = true;Lite (sceneN.ts)
engine._fixedDeltaMs = 16.0;Both sides now advance at a fixed 16 ms / frame. Animation state after N frames is fully deterministic.
Frame Freeze
At a specific frame count both engines pause animation but keep rendering.
BJS Reference
let frameCount = 0;scene.registerBeforeRender(() => { frameCount++; if (frameCount === 300) { scene.animationGroups.forEach((g) => g.pause()); canvas.dataset.animationFrozen = "true"; }});Lite (gated behind ?freeze query param)
if (new URLSearchParams(location.search).has("freeze")) { let frameCount = 0; engine._beforeRender.push(() => { frameCount++; if (frameCount === 300) { engine.pauseAnimations(); canvas.dataset.animationFrozen = "true"; } });}The ?freeze query-param gate keeps interactive browsing unaffected — only
tests and the golden-capture script append it.
Signal Protocol
canvas.dataset.animationFrozen = 'true' is the DOM signal consumed by:
- Golden capture (
scripts/capture-golden.ts) — waits for this signal before taking the reference screenshot. - Parity tests (
tests/lite/parity/sceneN-*.spec.ts) — waits for the signal on the Lite page before pixel-comparing against the golden.
Parity Test Pattern
test("Scene N — Animated model matches reference", async ({ page }) => { // Load Lite scene with freeze param await page.goto("/sceneN.html?freeze"); await page.waitForFunction(() => document.querySelector("canvas")?.dataset.ready === "true", { timeout: 30_000 }); // Wait for exact animation frame await page.waitForFunction(() => document.querySelector("canvas")?.dataset.animationFrozen === "true", { timeout: 30_000 }); // Screenshot and compare against golden const screenshot = await page.screenshot({/* … */}); // … pixel comparison …});Golden Capture
Scene startup reliability (scenes 112 and 121)
Scene 112 serves the Flight Helmet KTX2 model from
/models/FlightHelmetKTX/. pnpm fetch:flight-helmet restores the model,
buffer and 15 textures from BabylonJS/Assets commit
183f26290601abe1d5282eed00c306f4c8228307. Every file is checked against its
Git blob digest before being written or reused. Downloads have a bounded
timeout and failures stop setup with the asset URL and HTTP status. The
generated asset directory is ignored by git; binaries remain in
BabylonJS/Assets, not the Lite package. Lab startup, static lab builds and
bundle builds selecting scene 112 prepare these files before serving them.
Scene 121 uses the existing checked-in /splats/Halo_Believe.splat. Its BJS
reference constructs an unloaded mesh and calls loadFileAsync once.
Both scenes report initialization failures through canvas.dataset.error
as well as console.error. Their parity tests use waitForCanvasReady,
which polls from Node to keep the BrowserStack connection active and
rejects immediately on data-error. The 150-second readiness budget fits
inside the 180-second test deadline, preserving the actual startup error
instead of a teardown-induced "browser closed" error.
These two parity tests only compare Lite output with committed goldens;
they never open a live BJS reference or replace a golden at test time.
The scene 112 golden is unchanged. The scene 121 golden is the reference
attachment from its passing test in
CI build 60995 / 20261019.6:
data/37dcbe75cd41caf80e7133f2cb2675fa1e91e9b1.png.
It was captured by Babylon.js, not Lite, and the run reported MAD 0.014.
Focused non-visual startup tests block jsDelivr, exercise both local asset paths, and inject asset failures to verify that the error reaches the readiness waiter. Downloader unit tests cover verified reuse, HTTP errors and corrupt downloads. Existing MAD and bundle-size ceilings are unchanged.
scripts/capture-golden.ts is a CLI tool that captures golden reference PNGs:
pnpm exec tsx scripts/capture-golden.ts 5 # Capture scene 5 goldenFor animated scenes it:
- Opens the BJS reference HTML.
- Waits for
canvas.dataset.animationFrozen === 'true'. - Takes screenshot →
reference/lite/sceneN-*/babylon-ref-golden.png.
Key Design Decisions
| Decision | Rationale |
|---|---|
Exact frame (=== N), not >= N | Both BJS and Lite must freeze at the identical animation pose. An inequality would allow off-by-one drift. |
| Constant 16 ms delta | Without this, BJS adapts to actual frame rate, making poses non-deterministic across different hardware. |
Query-param gating (?freeze) | Interactive browsing stays unaffected. Only tests and golden capture append the param. |
| Frame 300 | Late enough that all assets are loaded and animations are mid-cycle (interesting poses), early enough for fast tests (~5 s at 60 fps). |
File Manifest
| File | Role |
|---|---|
lab/lite/babylon-ref-scene5.html | BJS reference with frame sync |
lab/lite/babylon-ref-scene7.html | BJS reference with frame sync |
lab/lite/src/lite/scene5.ts | Lite scene with freeze support |
lab/lite/src/lite/scene7.ts | Lite scene with freeze support |
scripts/capture-golden.ts | Golden-capture CLI |
tests/lite/parity/scene5-alien.spec.ts | Animated parity test |
tests/lite/parity/scene7-chibirex.spec.ts | Animated parity test |
Applying to New Animated Scenes
When adding a new animated scene:
- BJS ref — add
getDeltaTime = () => 16,useConstantAnimationDeltaTime, frame counter, freeze + signal. - Lite scene — add
_fixedDeltaMs = 16, freeze behind?freezeparam, signal. - Parity test — wait for both
readyandanimationFrozensignals before screenshotting. - capture-golden.ts — add scene to the registry with
animated: trueand the freeze frame count.