Skip to content

feat(core): add Deck#waitForFrameReady() Promise API#10362

Draft
akre54 wants to merge 1 commit into
visgl:masterfrom
akre54:frame-ready-api
Draft

feat(core): add Deck#waitForFrameReady() Promise API#10362
akre54 wants to merge 1 commit into
visgl:masterfrom
akre54:frame-ready-api

Conversation

@akre54

@akre54 akre54 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a waitForFrameReady() Promise API to Deck for detecting when a frame is safe to capture during sequential rendering (video export, image sequences, headless rendering).

Motivation

When exporting video from deck.gl applications (e.g., noodles.gl), detecting when a frame is "ready to capture" is critical for correctness. The existing onAfterRender callback fires after GPU command submission but doesn't wait for:

  • Async layer data to load (layer.isLoaded)
  • Attribute updates to complete (AttributeManager cycles)
  • Multiple render passes to settle

This leads to captured frames with stale/incomplete data. Polling onAfterRender with debounced timeouts works but is slow (~300ms per frame). This PR adds a deterministic signal for frame readiness, achieving 8.6× faster video export (308ms→36ms/frame) in noodles.gl.

API

deck.waitForFrameReady(options?: {
  timeout?: number,          // Max wait time in ms (default: 5000)
  checkLayers?: boolean,     // Check layer.isLoaded (default: true)
  checkAttributes?: boolean  // Check AttributeManager updates (default: true)
}): Promise<{
  layersReady: boolean,
  attributesReady: boolean,
  duration: number           // Time spent waiting in ms
}>

Resolves when:

  • All layers have isLoaded === true (if checkLayers enabled)
  • LayerManager has no pending update cycles (if checkAttributes enabled)
  • No redraw is queued
  • One onAfterRender cycle has completed

Rejects if timeout is reached before the above conditions are met.

Usage Example

// Sequential frame export for video
const deck = new Deck({...});

for (let frame = 0; frame < totalFrames; frame++) {
  const time = frame / fps;
  
  // Update layers with new timeline position
  deck.setProps({
    layers: [
      new ScatterplotLayer({
        data: getDataAtTime(time),
        transitions: null,  // Disable animations during export
        ...
      })
    ]
  });
  
  // Wait for frame to be ready
  await deck.waitForFrameReady();
  
  // Capture from canvas
  const imageData = canvas.toDataURL('image/png');
}

Implementation Details

  • Chains onto user-provided onAfterRender (preserved via try/finally)
  • Polls layer state and LayerManager update cycles
  • Skips waiting if scene is already settled
  • Timeout prevents indefinite hangs

Tests

6 new tests in test/modules/core/lib/deck.spec.ts:

  • ✅ Resolves when layers loaded
  • ✅ Waits for async data to settle
  • ✅ Rejects on timeout
  • ✅ checkLayers: false bypasses layer checks
  • ✅ Throws if deck not initialized
  • ✅ Preserves user onAfterRender

All 27 deck.spec.ts tests pass. All 272 core module tests pass.

Performance

Less than 1ms overhead per frame when layers are already loaded (single RAF + state check).

Limitations

Does not check for in-progress animations (layer transitions, viewport easing). For video export use cases, disable animations:

// Disable layer transitions during export
new ScatterplotLayer({
  data,
  transitions: null,  // Disable all transitions
  ...
})

// Disable viewport animations
deck.setProps({
  controller: { inertia: 0, transitionDuration: 0 }
})

Or use the onFrameComplete callback (#10361) which provides animationsInProgress flag.

Breaking Changes

None - purely additive API.

Related PRs

Companion MapLibre PRs

Benchmarks

Tested in noodles.gl video export (30fps, 100 frames, cached tiles):

Metric Before (idle polling) After (waitForFrameReady) Improvement
Time per frame 308ms 36ms 8.6× faster
Export FPS 3.2 27.8 8.7×
Realtime factor 0.11× 0.93× Near-realtime

Co-authored-by: Claude Sonnet 4.5 noreply@anthropic.com

Adds a public method on the Deck class that resolves once all pending
updates have settled and a frame has been rendered. Useful for headless
capture, video export, and any flow that needs to know the next canvas
read will reflect a fully-settled scene.

Settled means:
  - All layers report `layer.isLoaded === true`
  - LayerManager.needsUpdate() returns false
  - Deck.needsRedraw() returns false
  - An onAfterRender cycle has completed since the call started
    (skipped when the scene is already settled at call time)

Options:
  - timeout (default 5000 ms): rejects with an Error if exceeded
  - checkLayers (default true): include the per-layer isLoaded check
  - checkAttributes (default true): include the LayerManager.needsUpdate check

The implementation chains a one-shot frame-completion handler over the
user-provided onAfterRender and restores the original handler before
resolving or rejecting, so existing render hooks continue to fire.

Motivating use case: noodles.gl video export currently relies on a
"skip first render + 16ms timeout" heuristic to avoid stale data; a
deterministic Promise API lets the exporter wait for actual readiness.

Tests cover the happy path, async-data settle, timeout rejection,
checkLayers=false escape hatch, the not-initialized error case, and
that the user-provided onAfterRender is preserved across the wait.
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 83.407% (+0.02%) from 83.39% — akre54:frame-ready-api into visgl:master

* A Promise that resolves with an object describing the final state:
+ `layersReady` (boolean) - whether all layers reported loaded
+ `attributesReady` (boolean) - whether the attribute manager reported settled
+ `duration` (number) - elapsed time in milliseconds

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the majority of applications need duration? Can't they measure this trivially if they want it?

If the deadline passes before the scene settles, the Promise rejects with an `Error`.

```ts
const result = await deck.waitForFrameReady({timeout: 5000});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good pattern, but I don't see why this needs to be a part of deck's API.

Wouldn't a utility function with deck as a parameter be just as effective?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants