Luxar Viewer API Documentation - v2026.10.19
    Preparing search index...

    AnimationController manages the main rendering loop and performance optimization

    Key Features:

    • RequestAnimationFrame loop for browser-optimized rendering
    • Frame pacing for pathologically slow frames (see RafDriver)
    • Automatic pause/resume based on user interaction (saves power)
    • HDR post-processing pipeline with bloom effects
    • Integrated performance monitoring with stats.js
    • Proper frame timing and resource cleanup

    Technical Details:

    • Uses requestAnimationFrame for 60fps synchronized with display refresh
    • Pauses after 2 seconds of inactivity to reduce CPU/GPU usage
    • Integrates Three.js controls.update() and HDR post-processing render
    • Measures frame timing for performance analysis including post-processing

    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().

    Index
    idleTimeout: Timeout | null = null

    Timeout ID for auto-pause functionality

    callbacksByPhase: Record<FramePhase, Map<string, PerFrameEntry>> = ...

    Per-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.

    phaseOf: Map<string, FramePhase> = ...

    Phase each registered callback id lives in.

    failedCallbackIds: Set<string> = ...

    Ids of per-frame callbacks whose failure has already been logged.

    adaptiveDPRManager: AdaptiveDPRManager | null = null

    Adaptive DPR manager for dynamic resolution scaling

    isContextLost: (() => boolean) | null = null

    Predicate 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.

    canRestoreAtIdle: (() => boolean) | null = null

    Guard for the idle-pause DPR restore (null = always allowed).

    shouldSkipRender: (() => boolean) | null = null

    Predicate 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.

    renderOnChange: boolean = config.animation.renderOnChange

    Render only when something changed (config.animation.renderOnChange; ?renderAlways turns it off, see setRenderOnChange). Off = the historical loop: every tick renders.

    dirtyReason: RenderReason | null = null

    Why 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.

    viewSignature: ViewSignature | null = null

    Camera + drawing-buffer signature of the last rendered frame (null until wired).

    renderEveryFrameCount: number = 0

    Number of registered callbacks with renderEveryFrame (checked per tick).

    previousTickRendered: boolean = false

    Whether the previous tick rendered (adaptive-DPR sampling contiguity).

    audit: RenderAudit | null = null

    ?renderAudit (debug only): render every tick and verify the skips.

    tickCount: number = 0

    Loop ticks since construction (labels the audit log).

    selfSustainSuspensions: Set<string> = ...

    Active reasons the loop may not sustain ITSELF (see suspendSelfSustainedAnimation); empty = normal behaviour.

    selfSustainExempt: (() => boolean) | null = null

    Exempts the loop from a self-sustain hold while true (a capture is running).

    controls: ControlsManager

    Controls manager for camera updates each frame

    postProcessing: PostProcessingManager

    Post-processing manager for HDR rendering

    driver: RafDriver = ...

    Schedules frames; each runs tick.

    • 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:

      • Dynamic clipping plane adjustments
      • Dimension animations
      • Camera-based LOD updates
      • Custom animations or effects

      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.

      Parameters

      Returns void

      // 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 });
    • Undo 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.

      Parameters

      Returns void

    • Remove a per-frame callback by its identifier.

      Parameters

      • id: string

        Identifier of the callback to remove

      Returns boolean

      true if callback was found and removed, false otherwise

      // Remove dimension animation callback
      animController.removePerFrameCallback('dimension-animation');
    • Check if a per-frame callback with the given ID exists.

      Parameters

      • id: string

        Identifier to check

      Returns boolean

      true if callback exists

    • 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).

      Parameters

      • predicate: (() => boolean) | null

      Returns void

    • 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.

      Parameters

      • predicate: (() => boolean) | null

      Returns void

    • 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.

      Parameters

      • predicate: (() => boolean) | null

      Returns void

    • 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:

      • the real-time MediaRecorder path records the canvas THIS loop paints, so a paced gap is a dropped frame in the output video;
      • the offline capture drives its own 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.

      Parameters

      • predicate: (() => boolean) | null

      Returns void

    • 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.

      Parameters

      • enabled: boolean

      Returns void

    • 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.

      Parameters

      Returns void

    • 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.

      Returns void

    • 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.

      Parameters

      • detail: string

      Returns void

    • Mark the next tick dirty (keeping the first reason since the last render).

      Parameters

      • reason: "once" | "wake" | "event"
      • Optionaldetail: string

      Returns void

    • True 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.

      Returns boolean

    • 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):

      1. Performance measurement begins (frame-start)
      2. Record the PREVIOUS frame for adaptive DPR — only when it rendered (see recordAdaptiveDprSample), and never while the context is lost or another owner is driving the pipeline
      3. Update camera controls (handle user input, damping, constraints)
      4. Run the per-frame callbacks
      5. Render through the HDR post-processing pipeline (scene → bloom → tone mapping) — under render-on-change only when something changed since the last rendered frame (see RenderReason)
      6. Performance measurement ends (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.

      Returns void

    • True 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.

      Returns boolean

    • First 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).

      Parameters

      • tickStart: number

      Returns void

    • Record 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.

      Parameters

      • tickStart: number

      Returns void

    • Run 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.

      Returns string | null

      the id of the first callback that returned true (changed what is drawn), or null. Every callback runs either way.

    • Idle 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.

      Returns void

    • Start animation loop and reset idle timer for power efficiency

      This method is called whenever user interaction is detected:

      • Mouse movement over canvas
      • Camera control events (start, change)
      • Keyboard input
      • Touch events
      • When continuous effects are enabled (noise, auto-rotate)

      The idle timer automatically pauses rendering after inactivity to:

      • Reduce CPU/GPU usage when scene is static
      • Improve battery life on mobile devices
      • Lower thermal impact on laptops
      • Maintain 0% CPU usage when user is not interacting

      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.

      Returns void

    • 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.

      Returns void

    • 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.

      Returns void

    • Stop animation loop and clean up timers

      This method halts all rendering activity to conserve resources:

      • Stops the driver (no further frames; pending rAF and pacing cancelled)
      • Clears idle timeout to prevent memory leaks

      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.

      Returns void

    • 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.

      Parameters

      • reason: string

      Returns void

    • 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.

      Parameters

      • predicate: (() => boolean) | null

      Returns void

    • 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.

      Returns void