Luxar Viewer API Documentation - v2026.9.22
    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 below)
    • 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

    Frame pacing (#1724): Because each frame re-arms requestAnimationFrame immediately, 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 CDP Runtime.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 called requestRender(), and the loop could therefore never idle — the rendering starved the very hand-off that would have let it stop. Measured on performance_benchmark_example.luxar.zarr: ~120 of 200 dispatches still outstanding after 70 s, page.evaluate timing out at 15 s throughout.

    So when a frame's own cost exceeds config.animation.pacing.slowFrameMs for PACING_SLOW_FRAME_STREAK consecutive 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:

    • no animation-frame request is outstanding while the frame is drawn, so the compositor stops driving main frames back-to-back on its own;
    • and a genuine cooldown follows it. The genuineness is why the cooldown is armed from a zero-delay hop rather than directly: the expensive part of a slow frame runs after the rAF callback returns but inside the same main-thread task, so a timer armed at the frame's START is always already overdue when the thread frees and inserts no gap at all. The hop runs at the first event-loop turn after that work; only then is the real cooldown armed.

    Frames are DELAYED, never skipped: each one that runs still emits exactly one frame-start / frame-end pair, and records itself with adaptiveDPRManager.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.

    Index
    isAnimating: boolean = false

    Whether the animation loop is currently running

    animationId: number = 0

    RequestAnimationFrame ID for cancellation

    idleTimeout: Timeout | null = null

    Timeout ID for auto-pause functionality

    pacingTimeout: Timeout | null = null

    Timeout ID for the pending frame-pacing chain (null = none armed). Holds the zero-delay hop first, then the cooldown that hop arms.

    lastFrameStartTime: number | null = null

    Start timestamp of the previous frame (null = no frame measured yet)

    appliedPacingDelayMs: number = 0

    Cooldown we inserted BEFORE the current frame, in ms

    lastFrameCostMs: number = 0

    Previous frame's own cost in ms (frame period minus our own cooldown)

    consecutiveSlowFrames: number = 0

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

    perFrameCallbacks: Map<string, { callback: () => void; continuous: boolean }> = ...

    Per-frame callbacks for additional updates (keyed by ID for safe add/remove)

    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.

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

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

    controls: ControlsManager

    Controls manager for camera updates each frame

    postProcessing: PostProcessingManager

    Post-processing manager for HDR rendering

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

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

      The callback is executed after controls.update() but before rendering.

      Parameters

      • id: string

        Unique identifier for this callback (for later removal)

      • callback: () => void

        Function to call each frame

      • Optionaloptions: { continuous?: boolean }

        Options controlling callback behavior

        • Optionalcontinuous?: boolean

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

      Returns void

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

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

      1. Measure the previous frame's own cost and update the slow-frame streak (for pacing)
      2. Performance measurement begins (frame-start)
      3. Record the frame for adaptive DPR — unless the context is lost or another owner is driving the pipeline, in which case this frame does no GPU work of its own and must not be recorded
      4. Schedule next frame — immediately via requestAnimationFrame, or after a bounded cooldown once consecutive frames have been pathologically slow (see scheduleNextFrame() below and the class JSDoc)
      5. Update camera controls (handle user input, damping, constraints)
      6. Render through HDR post-processing pipeline (scene → bloom → tone mapping)
      7. Performance measurement ends (frame-end)

      Uses arrow function to maintain 'this' context when passed as callback. Early return prevents unnecessary work when animation is paused.

      Returns void

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

      Returns void

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

      Returns boolean

    • Cooldown (ms) to insert before the next frame; 0 means "re-arm requestAnimationFrame immediately", the untouched fast path.

      Returns number

      Milliseconds to wait before the next requestAnimationFrame

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

      Uses arrow function to maintain 'this' context when used as event handler.

      Returns void

    • Stop animation loop and clean up timers

      This method halts all rendering activity to conserve resources:

      • Sets flag to prevent further animate() calls
      • Cancels pending requestAnimationFrame to stop browser scheduling
      • 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

    • 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