Skip to content

Commit 0b51ee8

Browse files
akre54claude
andcommitted
feat(core) Add animationsInProgress + layersRendered to onFrameComplete
Extends the onFrameComplete callback payload with animation state so consumers (notably headless capture pipelines) can tell when a frame is visually settled vs. when timer-driven animations would change the next frame. `animationsInProgress` aggregates three sources: - layer uniform transitions (existing `Layer#hasUniformTransition`) - GPU attribute interpolations (new `AttributeManager#hasActiveTransitions`, delegating to a new `AttributeTransitionManager#isInProgress`) - viewport transitions (new `Controller#hasActiveTransition`, surfaced through new `ViewManager#hasActiveTransitions`) A new `Layer#hasActiveTransitions` aggregates uniform + attribute state per layer; `LayerManager#hasActiveTransitions` aggregates across layers. Time-based layer props such as `TripsLayer.currentTime` are intentionally not tracked — they are driven explicitly by the application each frame. `layersRendered` exposes the count of layers submitted to the renderer for the just-finished pass, useful for instrumentation. Animation state is re-read at fire time so that values reported via the async GPU-timing poll path reflect the freshest state. Tests: - updated existing onFrameComplete tests to assert the new fields - added a viewport-transition test that confirms animationsInProgress flips true → false across the lifetime of a flyTo-style transition Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e3dd4e8 commit 0b51ee8

9 files changed

Lines changed: 164 additions & 11 deletions

File tree

docs/api-reference/core/deck.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -576,22 +576,29 @@ Receives arguments:
576576
577577
#### `onFrameComplete` (Function) {#onframecomplete}
578578
579-
Called after each on-screen frame completes rendering, with CPU and (when supported) GPU timing information. Picking and other off-screen passes do not invoke this callback.
579+
Called after each on-screen frame completes rendering, with CPU and (when supported) GPU timing information plus animation state. Picking and other off-screen passes do not invoke this callback.
580580
581-
This is useful for performance instrumentation, frame-pacing logic, and headless capture pipelines that need to know when the GPU has finished a frame before reading pixels.
581+
This is useful for performance instrumentation, frame-pacing logic, and headless capture pipelines that need to know when the GPU has finished a frame before reading pixels and whether any animations would change the next frame.
582582
583583
Receives a single object argument with:
584584
585585
* `cpuTime` (number) - CPU time spent in the deck.gl draw pipeline for this frame, in milliseconds.
586586
* `gpuTime` (number | null) - GPU time for this frame in milliseconds, when available. `null` if GPU timing is not supported on the current device or the timing query did not resolve in time.
587587
* `gpuTimingSupported` (boolean) - whether the underlying device exposes the `timestamp-query` feature (WebGL2: `EXT_disjoint_timer_query_webgl2`, WebGPU: `timestamp-query`).
588588
* `timestamp` (number) - high-resolution `performance.now()` timestamp of when the callback fired.
589+
* `layersRendered` (number) - the number of layers submitted to the renderer for this frame (includes sub-layers expanded from composite layers).
590+
* `animationsInProgress` (boolean) - `true` while any timer-driven animation is still running:
591+
- layer uniform transitions (e.g. `transitions: {opacity: 300}`)
592+
- GPU attribute interpolations (e.g. `transitions: {getRadius: {duration: 300}}`)
593+
- viewport transitions (e.g. `flyTo`/`linearTransition` triggered by setting `transitionDuration`).
594+
595+
Time-based layer props such as `TripsLayer.currentTime` are *not* included since they are driven explicitly by the application each frame. For headless video export, treating each frame as independent (no transitions) is preferred — disable layer/viewport transitions in your scene rather than relying on `animationsInProgress` to settle.
589596
590597
GPU timing is read asynchronously: the callback may be deferred a few frames while the GPU pipeline flushes. If the timing query does not resolve after a small number of `requestAnimationFrame` polls, `gpuTime` is reported as `null`.
591598
592599
Errors thrown from `onFrameComplete` are caught and logged via `log.warn` and do not interrupt the render loop.
593600
594-
Example:
601+
Example — performance instrumentation:
595602
596603
```js
597604
new Deck({
@@ -606,6 +613,19 @@ new Deck({
606613
});
607614
```
608615
616+
Example — headless capture: wait until animations settle before capturing.
617+
618+
```js
619+
const deck = new Deck({
620+
// ...
621+
onFrameComplete: ({animationsInProgress}) => {
622+
if (!animationsInProgress) {
623+
capturePixels(deck);
624+
}
625+
}
626+
});
627+
```
628+
609629
610630
#### `onError` (Function) {#onerror}
611631

modules/core/src/controllers/controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
199199
this.transitionManager.finalize();
200200
}
201201

202+
/** Returns `true` if a viewport transition (e.g. flyTo) is currently animating. */
203+
hasActiveTransition(): boolean {
204+
return Boolean(this.transitionManager.transition?.inProgress);
205+
}
206+
202207
/**
203208
* Callback for events
204209
*/

modules/core/src/lib/attribute/attribute-manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ export default class AttributeManager {
115115
this.needsRedraw = true;
116116
}
117117

118+
/** Returns `true` if any attribute is currently being interpolated by a GPU transition. */
119+
hasActiveTransitions(): boolean {
120+
return this.attributeTransitionManager.isInProgress();
121+
}
122+
118123
// Adds attributes
119124
add(attributes: {[id: string]: AttributeOptions}) {
120125
this._add(attributes);

modules/core/src/lib/attribute/attribute-transition-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@ export default class AttributeTransitionManager {
9696
return transition && transition.inProgress;
9797
}
9898

99+
/** Returns `true` if any tracked attribute is currently transitioning. */
100+
isInProgress(): boolean {
101+
for (const attributeName in this.transitions) {
102+
if (this.transitions[attributeName].inProgress) {
103+
return true;
104+
}
105+
}
106+
return false;
107+
}
108+
99109
// Get all the animated attributes
100110
getAttributes(): {[id: string]: Attribute} {
101111
const animatedAttributes = {};

modules/core/src/lib/deck.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,19 +209,30 @@ export type DeckProps<ViewsT extends ViewOrViews = null> = {
209209
/** Called right after the canvas rerenders. */
210210
onAfterRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
211211
/**
212-
* Called after each frame completes rendering, with CPU and GPU timing info.
212+
* Called after each on-screen frame completes rendering, with CPU and GPU
213+
* timing info plus animation state.
214+
*
213215
* GPU timing is reported when supported by the underlying device
214-
* (WebGL: `EXT_disjoint_timer_query_webgl2`, WebGPU: `timestamp-query`); otherwise
215-
* `gpuTime` is `null` and `gpuTimingSupported` is `false`.
216+
* (WebGL: `EXT_disjoint_timer_query_webgl2`, WebGPU: `timestamp-query`);
217+
* otherwise `gpuTime` is `null` and `gpuTimingSupported` is `false`.
218+
*
219+
* `animationsInProgress` is `true` while any timer-driven animation is still
220+
* running: layer uniform transitions, GPU attribute interpolations, or
221+
* viewport transitions (e.g. `flyTo`). Time-based layer props such as
222+
* `TripsLayer.currentTime` are not included since they are driven explicitly
223+
* by the application each frame.
216224
*
217225
* Useful for performance instrumentation, frame pacing, and headless capture
218-
* pipelines that need to know when the GPU is finished with a frame.
226+
* pipelines that need to know when the GPU is finished with a frame and
227+
* whether any animations would change the next frame.
219228
*/
220229
onFrameComplete?: (info: {
221230
cpuTime: number;
222231
gpuTime: number | null;
223232
gpuTimingSupported: boolean;
224233
timestamp: number;
234+
layersRendered: number;
235+
animationsInProgress: boolean;
225236
}) => void;
226237
/** Called once after gl context and all Deck components are created. */
227238
onLoad?: () => void;
@@ -1515,7 +1526,8 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
15151526
// passes don't pollute the timing stream consumed by capture pipelines.
15161527
if (opts.pass === 'screen' && this.props.onFrameComplete !== noop) {
15171528
const cpuTime = (typeof performance !== 'undefined' ? performance.now() : 0) - cpuStart;
1518-
this._fireFrameComplete(device, cpuTime);
1529+
const layersRendered = opts.layers.length;
1530+
this._fireFrameComplete(device, cpuTime, layersRendered);
15191531
}
15201532
}
15211533

@@ -1526,16 +1538,23 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
15261538
* timestamp-query). When unavailable, we still fire the callback so consumers
15271539
* can track frame completion and CPU timing.
15281540
*/
1529-
private _fireFrameComplete(device: Device, cpuTime: number): void {
1541+
private _fireFrameComplete(device: Device, cpuTime: number, layersRendered: number): void {
15301542
const gpuTimingSupported = Boolean(device?.features?.has?.('timestamp-query'));
15311543

15321544
const fire = (gpuTime: number | null) => {
1545+
// Re-read animation state at fire time so async GPU-poll deferrals
1546+
// still report the freshest value.
1547+
const animationsInProgress =
1548+
Boolean(this.layerManager?.hasActiveTransitions()) ||
1549+
Boolean(this.viewManager?.hasActiveTransitions());
15331550
try {
15341551
this.props.onFrameComplete?.({
15351552
cpuTime,
15361553
gpuTime,
15371554
gpuTimingSupported,
1538-
timestamp: typeof performance !== 'undefined' ? performance.now() : 0
1555+
timestamp: typeof performance !== 'undefined' ? performance.now() : 0,
1556+
layersRendered,
1557+
animationsInProgress
15391558
});
15401559
} catch (error) {
15411560
log.warn('onFrameComplete handler threw', error)();

modules/core/src/lib/layer-manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ export default class LayerManager {
166166
: this.layers;
167167
}
168168

169+
/** Returns `true` if any managed layer has an in-progress uniform or attribute transition. */
170+
hasActiveTransitions(): boolean {
171+
for (const layer of this.layers) {
172+
if (layer.hasActiveTransitions()) return true;
173+
}
174+
return false;
175+
}
176+
169177
/** Set props needed for layer rendering and picking. */
170178
setProps(props: any): void {
171179
if ('debug' in props) {

modules/core/src/lib/layer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,18 @@ export default abstract class Layer<PropsT extends {} = {}> extends Component<
613613
return this.internalState?.uniformTransitions.active || false;
614614
}
615615

616+
/**
617+
* Checks whether the layer has any in-progress timer-driven animations:
618+
* uniform transitions or GPU attribute interpolations. Used by Deck to
619+
* report animation state to `onFrameComplete` consumers.
620+
*/
621+
hasActiveTransitions(): boolean {
622+
if (this.hasUniformTransition()) return true;
623+
const attributeManager = this.getAttributeManager();
624+
if (attributeManager?.hasActiveTransitions()) return true;
625+
return false;
626+
}
627+
616628
/** Called when this layer is rendered into the given viewport */
617629
activateViewport(viewport: Viewport): void {
618630
if (!this.internalState) {

modules/core/src/lib/view-manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ export default class ViewManager<ViewsT extends View[]> {
133133
this._needsRedraw = this._needsRedraw || reason;
134134
}
135135

136+
/** Returns `true` if any controller is currently animating a viewport transition. */
137+
hasActiveTransitions(): boolean {
138+
for (const id in this.controllers) {
139+
if (this.controllers[id]?.hasActiveTransition()) return true;
140+
}
141+
return false;
142+
}
143+
136144
/** Checks each viewport for transition updates */
137145
updateViewStates(): void {
138146
for (const viewId in this.controllers) {

test/modules/core/lib/deck.spec.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -887,12 +887,14 @@ test('Deck#getView with multiple views', async () => {
887887
});
888888
});
889889

890-
test('Deck#onFrameComplete fires per frame with timing info', async () => {
890+
test('Deck#onFrameComplete fires per frame with timing and animation info', async () => {
891891
const calls: Array<{
892892
cpuTime: number;
893893
gpuTime: number | null;
894894
gpuTimingSupported: boolean;
895895
timestamp: number;
896+
layersRendered: number;
897+
animationsInProgress: boolean;
896898
}> = [];
897899

898900
await new Promise<void>((resolve, reject) => {
@@ -930,6 +932,13 @@ test('Deck#onFrameComplete fires per frame with timing info', async () => {
930932
'boolean'
931933
);
932934
expect(typeof call.timestamp, 'timestamp is a number').toBe('number');
935+
expect(typeof call.layersRendered, 'layersRendered is a number').toBe('number');
936+
expect(call.layersRendered, 'layersRendered is non-negative').toBeGreaterThanOrEqual(0);
937+
expect(typeof call.animationsInProgress, 'animationsInProgress is a boolean').toBe(
938+
'boolean'
939+
);
940+
// No layer transitions or viewport transitions in this scene.
941+
expect(call.animationsInProgress, 'no animations active in static scene').toBe(false);
933942
if (call.gpuTime !== null) {
934943
expect(call.gpuTime).toBeGreaterThanOrEqual(0);
935944
}
@@ -945,6 +954,63 @@ test('Deck#onFrameComplete fires per frame with timing info', async () => {
945954
});
946955
});
947956

957+
test('Deck#onFrameComplete reports animationsInProgress during viewport transition', async () => {
958+
let sawTransitionInProgress = false;
959+
let sawTransitionEnded = false;
960+
961+
await new Promise<void>((resolve, reject) => {
962+
const deck = new Deck({
963+
device,
964+
width: 1,
965+
height: 1,
966+
967+
controller: true,
968+
initialViewState: {
969+
longitude: 0,
970+
latitude: 0,
971+
zoom: 0
972+
},
973+
974+
views: [new MapView({id: 'main'})],
975+
layers: [],
976+
977+
onFrameComplete: info => {
978+
if (info.animationsInProgress) sawTransitionInProgress = true;
979+
else if (sawTransitionInProgress) sawTransitionEnded = true;
980+
},
981+
982+
onLoad: async () => {
983+
try {
984+
// Trigger a viewport transition.
985+
deck.setProps({
986+
initialViewState: {
987+
longitude: 10,
988+
latitude: 10,
989+
zoom: 5,
990+
transitionDuration: 200
991+
}
992+
});
993+
// Wait for transition to start, run, and complete.
994+
await sleep(500);
995+
996+
expect(sawTransitionInProgress, 'animationsInProgress observed during transition').toBe(
997+
true
998+
);
999+
expect(sawTransitionEnded, 'animationsInProgress flips back to false after end').toBe(
1000+
true
1001+
);
1002+
1003+
deck.finalize();
1004+
resolve();
1005+
} catch (error) {
1006+
deck.finalize();
1007+
reject(error);
1008+
}
1009+
}
1010+
});
1011+
});
1012+
});
1013+
9481014
test('Deck#onFrameComplete is not called for picking passes', async () => {
9491015
let frameCallCount = 0;
9501016

0 commit comments

Comments
 (0)