Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/api-reference/core/deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,43 @@ Parameters:
* `force` (boolean) - if `false`, only redraw if necessary (e.g. changes have been made to views or layers). If `true`, skip the check. Default `false`.


#### `waitForFrameReady` {#waitforframeready}

Wait until all pending updates have settled and a frame has been rendered. Useful for headless capture, video export, or any flow that needs to read back canvas pixels and must know that the next read will reflect a fully-settled scene.

The returned Promise resolves once:

* All layers report `layer.isLoaded === true` (no pending async props or resources)
* The layer manager has no pending updates (`needsUpdate() === false`)
* No redraw is queued (`needsRedraw() === false`)
* If the scene was not already settled, an `onAfterRender` cycle has completed since the call started

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?

// result: {layersReady: boolean, attributesReady: boolean, duration: number}
```

Parameters:

* `options.timeout` (number, optional) - maximum wait time in milliseconds before the Promise rejects. Default `5000`.
* `options.checkLayers` (boolean, optional) - if `false`, skip the per-layer `isLoaded` check. Default `true`.
* `options.checkAttributes` (boolean, optional) - if `false`, skip the layer-manager `needsUpdate` check. Default `true`.

Returns:

* 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?


Notes:

* `waitForFrameReady` does not force a redraw on its own. If you need to ensure a render happens, call `setProps`, mutate `layers`, or call `redraw('forced')` before/while awaiting.
* The implementation chains its own `onAfterRender` handler over the user-provided one and restores the original handler before resolving or rejecting.


#### `pickObjectAsync` {#pickobjectasync}

Get the closest pickable and visible object at the given screen coordinate.
Expand Down
116 changes: 116 additions & 0 deletions modules/core/src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,122 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
}
}

/**
* Wait until all pending updates have settled and a frame has been rendered.
*
* Resolves when:
* - All layers report `isLoaded === true` (no pending async props/resources)
* - The layer manager has no pending updates (`needsUpdate() === false`)
* - No redraw is queued (`needsRedraw() === false`)
* - An `onAfterRender` cycle has completed since the call started
*
* This is intended for use cases such as headless capture / video export where
* a caller needs to know that the next read of the canvas will reflect a
* fully-settled scene.
*
* @param options.timeout Maximum wait time in ms before rejecting. Default 5000.
* @param options.checkLayers If false, skip the per-layer `isLoaded` check. Default true.
* @param options.checkAttributes If false, skip the layer-manager `needsUpdate` check. Default true.
* @returns Resolves with a status object describing the final state and elapsed time.
* Rejects with an Error if the deadline is reached before settle.
*/
async waitForFrameReady(options?: {
timeout?: number;
checkLayers?: boolean;
checkAttributes?: boolean;
}): Promise<{
layersReady: boolean;
attributesReady: boolean;
duration: number;
}> {
const {timeout = 5000, checkLayers = true, checkAttributes = true} = options || {};

if (!this.layerManager) {
throw new Error('Deck.waitForFrameReady: Deck is not initialized');
}

const start = Date.now();
const deadline = start + timeout;

const layersAreReady = (): boolean => {
if (!checkLayers) return true;
const layers = this.layerManager!.getLayers();
for (const layer of layers) {
if (!layer.isLoaded) return false;
}
return true;
};

const attributesAreReady = (): boolean => {
if (!checkAttributes) return true;
return this.layerManager!.needsUpdate() === false;
};

const sceneIsSettled = (): boolean => {
if (!layersAreReady()) return false;
if (!attributesAreReady()) return false;
if (this.needsRedraw({clearRedrawFlags: false})) return false;
return true;
};

// If the scene is already settled, resolve immediately - no need to wait for a frame.
if (sceneIsSettled()) {
return {
layersReady: layersAreReady(),
attributesReady: attributesAreReady(),
duration: Date.now() - start
};
}

// Capture the user-installed onAfterRender once; we'll wrap it with a chained
// handler that signals each completed frame, and restore it before returning.
const userOnAfterRender = this.props.onAfterRender;
const signalState: {pending: (() => void) | null} = {pending: null};
this.setProps({
onAfterRender: (ctx: {device: Device; gl: WebGL2RenderingContext}) => {
userOnAfterRender?.(ctx);
const signal = signalState.pending;
signalState.pending = null;
signal?.();
}
});

const waitOneFrame = (remaining: number): Promise<void> =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signalState.pending = null;
reject(new Error(`Deck.waitForFrameReady: timed out after ${timeout}ms`));
}, remaining);
signalState.pending = () => {
clearTimeout(timer);
resolve();
};
});

try {
for (;;) {
if (Date.now() > deadline) {
throw new Error(
`Deck.waitForFrameReady: timed out after ${timeout}ms (` +
`layersReady=${layersAreReady()}, attributesReady=${attributesAreReady()})`
);
}

await waitOneFrame(Math.max(0, deadline - Date.now()));

if (sceneIsSettled()) {
return {
layersReady: layersAreReady(),
attributesReady: attributesAreReady(),
duration: Date.now() - start
};
}
}
} finally {
this.setProps({onAfterRender: userOnAfterRender});
}
}

/** Flag indicating that the Deck instance has initialized its resources and it's safe to call public methods. */
get isInitialized(): boolean {
return this.viewManager !== null;
Expand Down
212 changes: 212 additions & 0 deletions test/modules/core/lib/deck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,218 @@ test('Deck#getView with multiple views', async () => {
});
});

test('Deck#waitForFrameReady resolves after a render with no pending updates', async () => {
const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [
new ScatterplotLayer({
id: 'sync-layer',
data: [{position: [0, 0]}],
getPosition: d => d.position,
getRadius: 1,
getFillColor: [255, 0, 0]
})
]
});

try {
await waitForRender(deck);

const result = await deck.waitForFrameReady({timeout: 2000});
expect(result.layersReady, 'layers report ready').toBe(true);
expect(result.attributesReady, 'attributes report ready').toBe(true);
expect(typeof result.duration, 'duration is a number').toBe('number');
expect(result.duration >= 0, 'duration is non-negative').toBe(true);
} finally {
deck.finalize();
}
});

test('Deck#waitForFrameReady waits for an async layer prop to settle', async () => {
const deferred = createDeferred<{position: number[]}[]>();

const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [
new ScatterplotLayer({
id: 'async-layer',
data: deferred.promise,
getPosition: d => d.position,
getRadius: 1,
getFillColor: [0, 255, 0]
})
]
});

try {
await waitForRender(deck);

const waiter = deck.waitForFrameReady({timeout: 5000});

let waiterResolved = false;
waiter.then(() => {
waiterResolved = true;
});

await sleep(50);
expect(waiterResolved, 'waiter is still pending while data unresolved').toBe(false);

deferred.resolve([{position: [0, 0]}]);

const result = await waiter;
expect(result.layersReady, 'layers report ready after data resolves').toBe(true);
expect(result.attributesReady, 'attributes report ready after data resolves').toBe(true);
} finally {
deck.finalize();
}
});

test('Deck#waitForFrameReady rejects when timeout elapses with pending data', async () => {
const deferred = createDeferred<{position: number[]}[]>();

const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [
new ScatterplotLayer({
id: 'never-loaded',
data: deferred.promise,
getPosition: d => d.position
})
]
});

try {
await waitForRender(deck);

let caught: Error | null = null;
try {
await deck.waitForFrameReady({timeout: 100});
} catch (err) {
caught = err as Error;
}
expect(caught, 'waitForFrameReady rejects on timeout').toBeTruthy();
expect(caught?.message.includes('timed out'), 'error mentions timeout').toBe(true);
} finally {
deferred.resolve([]);
deck.finalize();
}
});

test('Deck#waitForFrameReady checkLayers=false skips per-layer readiness', async () => {
const deferred = createDeferred<{position: number[]}[]>();

const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [
new ScatterplotLayer({
id: 'pending-layer',
data: deferred.promise,
getPosition: d => d.position
})
]
});

try {
await waitForRender(deck);
const result = await deck.waitForFrameReady({timeout: 2000, checkLayers: false});
expect(result.attributesReady, 'attributes are ready').toBe(true);
} finally {
deferred.resolve([]);
deck.finalize();
}
});

test('Deck#waitForFrameReady throws if Deck is not initialized', async () => {
const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: []
});
deck.finalize();

let caught: Error | null = null;
try {
await deck.waitForFrameReady();
} catch (err) {
caught = err as Error;
}
expect(caught, 'rejects when not initialized').toBeTruthy();
expect(caught?.message.includes('not initialized'), 'message mentions initialization').toBe(true);
});

test('Deck#waitForFrameReady preserves user-provided onAfterRender', async () => {
let userCallCount = 0;
const deferred = createDeferred<{position: number[]}[]>();
const userOnAfterRender = () => {
userCallCount++;
};

const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [
new ScatterplotLayer({
id: 'restore-layer',
data: deferred.promise,
getPosition: d => d.position
})
],
onAfterRender: userOnAfterRender
});

try {
// Use a one-shot resolve via direct setProps so we don't pollute the user's handler.
await new Promise<void>(resolve => {
deck.setProps({
onAfterRender: ctx => {
userOnAfterRender();
deck.setProps({onAfterRender: userOnAfterRender});
resolve();
}
});
});

// Confirm starting state: deck's onAfterRender is the user's function.
expect(deck.props.onAfterRender, 'precondition: user handler installed').toBe(
userOnAfterRender
);

// Start the wait - data is pending, so it will install our chained handler.
const waiter = deck.waitForFrameReady({timeout: 2000});
const beforeData = userCallCount;

deferred.resolve([{position: [0, 0]}]);
await waiter;

// While resolving, at least one frame fired and the chained handler should have
// invoked the user callback.
expect(userCallCount > beforeData, 'user onAfterRender invoked during wait').toBe(true);

// After the wait, the user's original handler should still be in place.
expect(deck.props.onAfterRender, 'user onAfterRender restored after wait').toBe(
userOnAfterRender
);
} finally {
deck.finalize();
}
});

test('Deck#props omitted are unchanged', async () => {
const layer = new ScatterplotLayer({
id: 'scatterplot-global-data',
Expand Down
Loading