Create animation controller for rendering loop management.
Sets up performance monitoring and prepares animation loop. Does not start animation - call startAnimation() to begin rendering.
Controls manager for camera updates each frame
Post-processing manager for HDR rendering
PrivateisWhether the animation loop is currently running
PrivateanimationRequestAnimationFrame ID for cancellation
PrivateidleTimeout ID for auto-pause functionality
PrivatepacingTimeout ID for the pending frame-pacing chain (null = none armed). Holds the zero-delay hop first, then the cooldown that hop arms.
PrivatelastStart timestamp of the previous frame (null = no frame measured yet)
PrivateappliedCooldown we inserted BEFORE the current frame, in ms
PrivatelastPrevious frame's own cost in ms (frame period minus our own cooldown)
PrivateconsecutiveHow many consecutive measured frames have cost more than
config.animation.pacing.slowFrameMs. Pacing engages only at
PACING_SLOW_FRAME_STREAK; any fast frame resets it to 0.
PrivateperPer-frame callbacks for additional updates (keyed by ID for safe add/remove)
PrivateadaptiveAdaptive DPR manager for dynamic resolution scaling
PrivateisPredicate that returns true while the WebGL context is lost. When
set, the animation loop skips postProcessing.render() (and any
GPU-bound work) so we don't issue draw calls against a dead
context — those produce noisy GL errors and waste frame work
during the loss window. The renderer is rebuilt by SceneManager
on webgl-context-restored; until then we keep ticking
controls.update() and per-frame callbacks but skip rendering.
PrivatecanGuard for the idle-pause DPR restore (null = always allowed).
PrivateshouldPredicate that returns true while some other owner is driving the pipeline itself and the loop's own render would be thrown away. Set during an offline capture, whose every frame runs its own independent pipeline pass into an offscreen target. See setRenderSkipPredicate.
PrivateisPredicate that returns true while frame pacing must stay off because some other owner depends on the loop's exact frame cadence. Set for the whole of a recording. See setPacingSuspendPredicate.
PrivatecontrolsControls manager for camera updates each frame
PrivatepostPost-processing manager for HDR rendering
Get current animation loop state.
true if animation loop is running, false if paused
Add a per-frame callback with a unique identifier.
Multiple callbacks can be registered simultaneously (unlike setPerFrameCallback). Use unique IDs to allow safe removal without affecting other callbacks.
Useful for operations that need to run every frame:
The callback is executed after controls.update() but before rendering.
Unique identifier for this callback (for later removal)
Function to call each frame
Optionaloptions: { continuous?: boolean }
Options controlling callback behavior
Optionalcontinuous?: booleanIf true, this callback prevents the animation loop from auto-pausing due to idle timeout. Use for callbacks that need every frame (e.g., dimension animation, recording). Default: false (on-demand callbacks that only run when animation is active but don't prevent pausing).
// On-demand callback: runs when animating but doesn't prevent idle pause
animController.addPerFrameCallback('dynamic-clipping', () => {
sceneManager.updateDynamicClippingPlanes();
});
// Continuous callback: keeps animation loop alive
animController.addPerFrameCallback('dimension-animation', () => {
animationManager.onFrame();
}, { continuous: true });
Check if a per-frame callback with the given ID exists.
Identifier to check
true if callback exists
Set the adaptive DPR manager for dynamic resolution scaling.
The animation loop will call recordFrame() on the manager each frame to track FPS and adjust pixel ratio as needed.
The AdaptiveDPRManager instance, or null to disable
Inject a predicate the loop can poll to detect WebGL context
loss. When the predicate returns true, the animation loop skips
postProcessing.render() for that frame; controls and per-frame
callbacks still run so user input stays responsive. SceneManager
wires this to its own isWebGLContextLost().
Pass null to disable the guard (useful in tests / embed contexts
that can't lose the context).
Inject a predicate consulted before the idle-pause ceiling-DPR
restore. When it returns false the resting frame keeps the current
DPR — used to protect recordings, whose resolution must stay
locked for the whole capture. Mirrors setContextLostPredicate.
Pass null to always allow the restore.
Inject a predicate the loop polls to decide whether to skip its own
postProcessing.render(). When it returns true the frame still
runs controls.update() and every per-frame callback — the loop has
to keep ticking so the depth-sort scheduler and the LOD group
selector follow the camera — but issues no draw call of its own.
Wired to the offline capture, which renders its own pipeline pass
per frame into an offscreen target: the loop's render is pure waste
there, and worse, EXR capture holds global mega-shader flags (raw
HDR, effects off) across its async readback, so a loop render
landing inside that window paints a blown-out frame under the
translucent capture overlay. Must stay OFF for the real-time
MediaRecorder path, which records the canvas the loop paints.
Mirrors setContextLostPredicate. Pass null to always render.
Inject a predicate the loop polls before inserting a frame-pacing
cooldown. While it returns true, pacing is disabled and every frame
re-arms requestAnimationFrame back-to-back exactly as it did
before pacing existed.
Wired to recording, whose two capture families both depend on the loop's untouched cadence:
await requestAnimationFrame
cadence while registering one-shot per-frame orbit callbacks on
this controller, so a paced frame could miss the capture's window
and drop the orbit step.That is why this is keyed on RecordingPanel.isCurrentlyRecording()
(session.isAnyCaptureActive(), i.e. session.isRecording: the flag
that both the real-time MediaRecorder path and the offline capture set
for the whole of their run) rather than the narrower
isLoopRenderSuppressed() that gates setRenderSkipPredicate —
the breadth is the point here. A plain screenshot does NOT set it and
does not need it: it reads the canvas after its own awaited frame
rather than depending on the loop's cadence.
Mirrors setContextLostPredicate. Pass null to always allow
pacing.
PrivateanimateMain animation loop function - the heart of HDR 3D rendering
This function is called ~60 times per second (depending on display refresh rate) and handles the complete HDR render pipeline:
frame-start)scheduleNextFrame() below and the class
JSDoc)frame-end)Uses arrow function to maintain 'this' context when passed as callback. Early return prevents unnecessary work when animation is paused.
PrivatescheduleSchedule the next loop iteration, inserting a pacing cooldown once
PACING_SLOW_FRAME_STREAK consecutive frames have been pathologically
slow.
With no cooldown this is byte-for-byte the historical behaviour — a
bare requestAnimationFrame(this.animate). With one, the rAF is armed
from a chain of two setTimeouts so the main thread has an actual gap
in which the browser can deliver a worker message, a CDP evaluate, or a
network callback. Frames are only ever DELAYED here, never dropped.
PrivatepacingWhether pacing is currently suspended, with a throwing predicate read as "not suspended".
The try/catch is load-bearing because scheduleNextFrame() is the loop's
ONLY re-arm point: a throw that escaped it would leave nothing armed while
isAnimating stayed true, so startAnimation() early-returns forever and
no requestRender() can recover — an unrecoverable freeze. Today's wiring
cannot reach that: RecordingPanel.isCurrentlyRecording() is a plain flag
read, and the session it reads survives the panel's own dispose(). This
is therefore a guard on the INJECTION POINT rather than on a known
thrower — whatever gets wired here next inherits it, and the trade is a
paced frame during a capture against the viewer freezing for the rest of
the session.
Shared by the two places the answer is needed — when the cooldown is armed, and again in the hop callback before the cooldown is committed — so both read it under the same guarantee.
PrivatenextCooldown (ms) to insert before the next frame; 0 means "re-arm requestAnimationFrame immediately", the untouched fast path.
Milliseconds to wait before the next requestAnimationFrame
PrivateshouldCheck if any features require continuous animation
True if animation should continue regardless of user interaction
PrivatehandleHandle idle timeout - only stop if no continuous effects are active
Start animation loop and reset idle timer for power efficiency
This method is called whenever user interaction is detected:
The idle timer automatically pauses rendering after inactivity to:
Continuous effects (noise, auto-rotate) will keep animation running.
Uses arrow function to maintain 'this' context when used as event handler.
Stop animation loop and clean up timers
This method halts all rendering activity to conserve resources:
Called automatically after idle timeout (when no continuous effects) or manually for cleanup. Scene remains visible but static until next user interaction or continuous effect activation.
Get performance monitor for FPS and timing metrics.
Stop animation loop and clean up resources.
Stops rendering and cancels timers. The PerformanceMonitor UI
panel lives at LuxarApp; this controller emits frame-start /
frame-end on the event bus per frame, which is what the panel
listens to.
After calling dispose(), the animation controller cannot be reused.
AnimationController manages the main rendering loop and performance optimization
Key Features:
Technical Details:
Frame pacing (#1724): Because each frame re-arms
requestAnimationFrameimmediately, a scene whose frames cost ~1 s puts the main thread at a 100 % duty cycle of long tasks, and NOTHING else ever gets a slot — not worker message delivery, not a CDPRuntime.callFunctionOn. That is not a rendering inconvenience but a livelock: the depth-sort worker's replies (each 0.1 ms of actual work) were dispatched at ~0.5/s, every landed reply staged an ordering apply that calledrequestRender(), and the loop could therefore never idle — the rendering starved the very hand-off that would have let it stop. Measured onperformance_benchmark_example.luxar.zarr: ~120 of 200 dispatches still outstanding after 70 s,page.evaluatetiming out at 15 s throughout.So when a frame's own cost exceeds
config.animation.pacing.slowFrameMsforPACING_SLOW_FRAME_STREAKconsecutive frames — sustained slowness, not an isolated hiccup — the next frame is scheduled after a bounded cooldown (setTimeout→setTimeout→requestAnimationFrame) instead of back-to-back. BOTH halves of that matter:Frames are DELAYED, never skipped: each one that runs still emits exactly one
frame-start/frame-endpair, and records itself withadaptiveDPRManager.recordFrame()whenever that frame does GPU work of its own — the call is gated on the context-lost and render-skip predicates, as it was before pacing existed (see the comment at the call site). Both are on the real clock: the achieved frame rate really is lower and neither the FPS readout nor the DPR control loop may be told otherwise.