The frame's work, run once per scheduled frame AFTER the next frame has been armed (so the loop survives an exception it throws)
PrivaterunningWhether the loop is currently running
PrivateanimationRequestAnimationFrame ID for cancellation
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.
PrivateisPredicate that returns true while frame pacing must stay off because
some other owner depends on the loop's exact frame cadence. See
AnimationController.setPacingSuspendPredicate.
Private ReadonlyonThe 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.
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().
true when this call started the loop (the stopped→running edge)
Stop the loop: cancel the pending frame and any pacing cooldown.
PrivateframeOne scheduled frame: measure the previous frame's cost, arm the next
frame (immediately, or after a pacing cooldown), then run onFrame.
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.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.
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
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.
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
requestAnimationFrame driver with frame pacing.
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.