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

    requestAnimationFrame driver with frame pacing.

    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
    running: boolean = false

    Whether the loop is currently running

    animationId: number = 0

    RequestAnimationFrame ID for cancellation

    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.

    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. See AnimationController.setPacingSuspendPredicate.

    onFrame: () => void

    The frame's work, run once per scheduled frame AFTER the next frame has been armed (so the loop survives an exception it throws)

    • See AnimationController.setPacingSuspendPredicate.

      Parameters

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

      Returns void

    • Start the loop: arm its first frame for the next animation frame. No-op when already running.

      Deliberately does NOT run a frame synchronously. A wake comes from an input or state-change handler, and the next paint is already at the next animation frame; a frame run here as well rendered every wake twice before that paint. A caller that needs the frame NOW uses AnimationController.renderOnce().

      Returns boolean

      true when this call started the loop (the stopped→running edge)

    • One scheduled frame: measure the previous frame's cost, arm the next frame (immediately, or after a pacing cooldown), then run onFrame.

      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.frame). 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 running 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