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
PrivateidleTimeout ID for auto-pause functionality
Private ReadonlycallbacksPer-frame callbacks, one insertion-ordered map per phase (keyed by ID for safe add/remove). Iterating the live maps, rather than a sorted snapshot, keeps the long-standing semantics for a callback that registers or removes another mid-frame: a removed one that has not run yet is skipped, one added to the phase being run (or a later one) runs this frame.
Private ReadonlyphasePhase each registered callback id lives in.
Private ReadonlyfailedIds of per-frame callbacks whose failure has already been logged.
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.
PrivaterenderRender only when something changed (config.animation.renderOnChange;
?renderAlways turns it off, see setRenderOnChange). Off = the
historical loop: every tick renders.
PrivatedirtyWhy the next tick must render, when an explicit request marked it dirty
(wake / event / once); null while clean. Cleared just BEFORE the
render, so a request made during the render (an onAfterRender hook, a
sort landing) dirties the NEXT tick instead of being swallowed.
PrivateviewCamera + drawing-buffer signature of the last rendered frame (null until wired).
PrivaterenderNumber of registered callbacks with renderEveryFrame (checked per tick).
PrivatepreviousWhether the previous tick rendered (adaptive-DPR sampling contiguity).
Privateaudit?renderAudit (debug only): render every tick and verify the skips.
PrivatetickLoop ticks since construction (labels the audit log).
Private ReadonlyselfActive reasons the loop may not sustain ITSELF (see suspendSelfSustainedAnimation); empty = normal behaviour.
PrivateselfExempts the loop from a self-sustain hold while true (a capture is running).
PrivatecontrolsControls manager for camera updates each frame
PrivatepostPost-processing manager for HDR rendering
Private ReadonlydriverSchedules frames; each runs tick.
Whether render-on-change is active (false under ?renderAlways).
Whether a suspendSelfSustainedAnimation hold is in EFFECT: a reason is active and no capture exempts the loop. Re-evaluated on every read, so a capture starting under a hold lifts it and its end restores it.
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. 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, in its phase's turn (camera → view → pre-render → ui), and within a phase in registration order.
Unique identifier for this callback (for later removal)
Function to call each frame
Optionaloptions: PerFrameCallbackOptions
Options controlling callback behavior
Options of AnimationController.addPerFrameCallback.
Optionalcontinuous?: booleanOptionalphase?: FramePhaseOptionalrenderEveryFrame?: boolean// On-demand callback: runs when animating but doesn't prevent idle pause;
// returns whether it changed what is drawn
animController.addPerFrameCallback('dynamic-clipping', () =>
sceneManager.updateDynamicClippingPlanes()
);
// Continuous callback: keeps animation loop alive
animController.addPerFrameCallback('dimension-animation', () =>
animationManager.onFrame()
, { continuous: true });
PrivatedetachUndo an existing registration's bookkeeping before id is registered
again in phase. Re-registering in the SAME phase keeps the callback's
position (the map's own overwrite rule); a phase change moves it to the
end of its new phase.
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.
Turn render-on-change on or off. On (the default, from
config.animation.renderOnChange): a tick renders only when something
changed — see RenderReason for what counts. Off (?renderAlways,
the kill switch): every tick renders, exactly the historical loop,
including adaptive-DPR sampling on every tick.
Either way the loop keeps TICKING as before (controls, callbacks, the idle timer); only the redundant re-render of an unchanged state is skipped.
Where the loop reads the live camera and drawing buffer for the camera
half of the dirty check (see view-signature.ts). Without a source only
explicit requests, callbacks and continuous consumers make a tick render
— camera input still does through the controls' change event, which
wakes the loop. The init pipeline wires the scene manager's camera and
canvas. Pass null to drop it.
?renderAudit (debug only): render every tick and check, on each tick
the scheduler would have skipped, that the pixels did not change — see
render-audit.ts. Pass null to turn it off.
Keep the loop TICKING without asking for a redraw: controls, per-frame callbacks and the idle timer run as for any wake, but the tick renders only if something then turns out to have changed. For a subsystem that polls per frame for work landing asynchronously (a lazy LOD level loading): the landing itself requests the render.
Something that the next frame draws changed outside the loop (a geometry
commit, a resize, a sort result, a material edit): mark the next tick
dirty and wake the loop. detail names the source for the render audit's
log; the frame is tallied as render.byReason.event.
PrivatemarkMark the next tick dirty (keeping the first reason since the last render).
Optionaldetail: stringPrivatemustTrue while every tick must draw whether or not state changed: cinematic
detector noise animates per frame (needsContinuousAnimation, which is
also what keeps the loop from idling), and a registered renderEveryFrame
consumer films the canvas.
One frame of work — the heart of HDR 3D rendering.
Run once per frame by the driver, which has already armed the next frame (so the loop survives an exception thrown here):
frame-start)frame-end, { rendered })Public so a driver other than the window's requestAnimationFrame can run frames. It does not check whether the loop is running: that is the driver's decision.
PrivateframePrivateisTrue while the loop must issue no draw of its own: the rendering context is lost, or another owner (an offline capture) is driving the pipeline.
PrivatenoteFirst render after one or more skipped ticks: the frame-interval clock still reads the last rendered tick, and the gap is idle time, not a slow frame. Re-base it (no sample; nothing learned is forgotten).
PrivaterecordRecord the frame the PREVIOUS tick rendered for adaptive DPR. The sample
is the interval between two tick starts, which measures the earlier tick
— so it is a render-frame sample only when that tick rendered. A skipped
tick costs nothing, and its "speed" would drive bogus scale-ups and
falsely settle U-shape probes, exactly like a context-lost or
render-skipped frame (which are excluded for the same reason). Under
?renderAlways every tick renders and every tick records, as it always
did.
A PACED frame is recorded on the RAW clock like any other: the achieved
frame rate really is lower, and the FPS readout must not lie. A steady
paced cadence is absorbed by the stall detector's outlier test
(rendering/adaptive-dpr/stall-detector.ts compares against the recent
MEDIAN, not an absolute threshold), so it does not manufacture gap
resets; and the isolated-hiccup interactions — a paced interval read off
a freshly cleared window as a collapsed frame rate, or a
just-under-gapResetMs frame pushed just over it — cannot arise at all,
because pacing requires a STREAK (see PACING_SLOW_FRAME_STREAK in
raf-driver.ts) and an isolated slow frame is therefore never paced.
PrivaterenderWhy this tick must render, or null when nothing changed since the last rendered frame. Precedence as documented on RenderReason.
PrivateskipA tick with nothing new to draw. Under ?renderAudit it is rendered
anyway and its pixels checked against the last real frame.
PrivaterenderRender the frame for reason and remember it as the last rendered one.
PrivaterunRun every per-frame callback, each isolated from the others.
A callback that throws is logged (once per id, not once per frame: a callback that fails every frame would otherwise flood the console at the frame rate) and the remaining callbacks and the render still run. Without the isolation one broken subsystem skipped every callback registered after it, and the render, on every frame, freezing the view while the loop kept spinning.
the id of the first callback that returned true (changed what
is drawn), or null. Every callback runs either way.
PrivaterunRun one callback, logging its first failure (see runPerFrameCallbacks).
whether it reported a drawn-state change (a throw reports none)
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
PrivaterenderIdle restore: the static frame the user is about to study should be at full native sharpness — reduced DPR only ever traded quality for interaction smoothness. This lives ONLY in the idle path (never in stopAnimation itself, which also runs on tab-hide and dispose where rendering would be wrong). prepareIdleFrame() returns true only when the DPR actually changed; the resize clears the canvas, so exactly then we render ONE frame directly — NOT via startAnimation(), which would re-arm the idle timer and feed ceiling-DPR frames back into the FPS evaluator.
The render-skip predicate is checked here too — this is the loop's OTHER
render call site, and the predicate's claim is "nobody but the pipeline's
current owner may draw", not "the tick() path may not draw". Today it is
redundant (a capture disables adaptive DPR, so isActive() is already
false, and the idle-restore predicate is off for the whole capture), but
the guard that makes it redundant lives in another file: drop
captureDPR from the capture's saveRecordingState and this would paint a
ceiling-DPR frame through the capture scrim, possibly inside the raw-HDR
window. It must come BEFORE prepareIdleFrame(), which RESIZES on its way
to returning true — skipping the render after that resize would leave the
canvas cleared with nothing to repaint it.
PrivatemayWhether the idle restore may run at all (checked BEFORE it resizes).
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.
Every call also marks the next tick dirty (render.byReason.wake): a
caller that wakes the loop gets at least one rendered frame, whatever it
changed. Equivalent to requestRender + resume + idle-timer reset.
On a stopped loop this ARMS the next animation frame; it does not draw synchronously (see RafDriver.start). Use renderOnce for a frame now.
Uses arrow function to maintain 'this' context when used as event handler.
Draw a frame NOW if the loop is stopped, then keep it running as startAnimation does.
startAnimation() only arms the next animation frame. This is for the
few callers that must have a frame on the canvas before they return,
such as a video capture that starts recording the canvas in the same
turn. When the loop is already running it draws nothing extra: it marks
the next tick dirty, and the running loop paints it at the next
animation frame.
Bring the view state up to date for the current camera without drawing:
controls.update() and every per-frame callback, as a frame would run
them, but no render and no frame events.
For a caller that renders its own pipeline pass outside the loop (an embedder screenshot): after a camera change the per-frame view callbacks (clipping planes, LOD selection, depth sort) have not run yet until the loop's next frame, so a pass drawn before it would use the previous pose's near/far and LOD. A callback that changed drawn state here marks the loop's next tick dirty, so the canvas catches up too.
PrivateresumeStart the driver if it is stopped.
true on the stopped→running edge
PrivateresetRestart the idle countdown (see handleIdleTimeout).
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.
Stop the loop from sustaining itself while reason holds.
Wired to the data server becoming unreachable (the scene-identity
watchdog's unreachable banner): continuous consumers — cinematic noise,
auto-rotate, dimension playback — and loaders polling through
requestTick would otherwise render indefinitely against a scene
that can no longer stream. User input still works: startAnimation
and requestRender wake the loop as usual so the already-loaded
scene stays inspectable, and the loop then idles out after
config.animation.idleTimeoutMs because nothing counts as continuous.
While held, the turntable and auto-dolly do not drive the camera
(ControlsManager.setAutoCameraSuspended) and dimension playback holds
its playhead (DimensionAnimationManager reads
isSelfSustainSuspended). Never in effect during a capture — see
setSelfSustainExemptPredicate.
Stops the loop at once unless a gesture is in progress (a press held through the transition keeps its frames until it ends). Reasons stack; resumeSelfSustainedAnimation releases one.
Release a suspendSelfSustainedAnimation reason. When the last one is released the loop wakes (unless the page is hidden), so a continuous consumer that was suspended (cinematic mode, playback) resumes and the next tick redraws.
Exempt the loop from every self-sustain hold while predicate returns
true. Wired to RecordingPanel.isCurrentlyRecording(): a real-time video
films the canvas this loop paints and an offline turntable drives its own
frames through it, so a hold would record a frozen view. A registered
renderEveryFrame consumer is exempt on its own. A predicate that throws
counts as exempt (rendering on is the safe failure). Pass null to clear.
PrivateisWhether a capture currently exempts the loop from a hold.
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:
Scheduling vs work: The controller owns WHAT a frame does (AnimationController.tick) and when the loop may go idle; RafDriver owns WHEN frames run (requestAnimationFrame, and frame pacing for pathologically slow frames, #1724). Another driver can run the same
tick().