Skip to content

Commit 3d7d186

Browse files
author
Adam Krebs
committed
feat(core): Enhance onAfterRender and add hasActiveTransitions
Enhances existing APIs instead of adding new callbacks, reducing API proliferation while providing the same functionality. Changes: -------- 1. onAfterRender callback now includes 'pass' parameter - Distinguishes 'screen' from 'picking' and other render passes - Enables efficient frame capture (skip non-screen renders) 2. New deck.hasActiveTransitions() method - Checks for active viewport transitions - Checks for active layer uniform transitions - Returns true if any animations are in progress Use Cases: ---------- - Video export: Wait for transitions before capturing frames - Screenshots: Ensure scene is settled before capture - Testing: Verify animations complete Example: -------- deck.setProps({ onAfterRender: ({pass}) => { if (pass === 'screen' && !deck.hasActiveTransitions()) { const allLoaded = deck.props.layers.every(l => l.isLoaded); if (allLoaded) { captureFrame(); } } } }); Benefits over new callback: --------------------------- - No new API surface (enhances existing onAfterRender) - Composable with existing patterns - Clear separation of concerns (pass detection vs transition detection) - Backward compatible (pass parameter is new but optional) Testing: -------- - Unit tests for onAfterRender pass parameter - Unit tests for hasActiveTransitions() with layer transitions - Documentation with complete examples
1 parent c87afb2 commit 3d7d186

3 files changed

Lines changed: 531 additions & 3 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# onAfterRender and hasActiveTransitions Enhancements
2+
3+
Enhanced APIs for detecting when deck.gl rendering is complete and the scene is "settled" - useful for frame capture, screenshots, and video export.
4+
5+
## onAfterRender Enhancement
6+
7+
The `onAfterRender` callback now includes a `pass` parameter to distinguish between different render passes.
8+
9+
### Usage
10+
11+
```typescript
12+
import {Deck} from '@deck.gl/core';
13+
14+
const deck = new Deck({
15+
onAfterRender: ({device, gl, pass}) => {
16+
console.log('Render complete:', pass);
17+
18+
if (pass === 'screen') {
19+
// Main render to screen - safe to capture frame
20+
captureFrame(gl);
21+
}
22+
// pass === 'picking' for mouse picking renders
23+
// pass === 'shadow' for shadow map renders (if using shadows)
24+
}
25+
});
26+
```
27+
28+
### Parameters
29+
30+
**`context.device`** (`Device`): The luma.gl device
31+
32+
**`context.gl`** (`WebGL2RenderingContext`): The WebGL context
33+
34+
**`context.pass`** (`string`): The render pass type:
35+
- `'screen'` - Main render to the canvas
36+
- `'picking'` - Render for mouse picking (when layers are pickable)
37+
- `'shadow'` - Shadow map render (when using ShadowEffect)
38+
- Other custom pass types from effects
39+
40+
### Why This Matters
41+
42+
Before this enhancement, `onAfterRender` was called for all render passes without distinction. This made it difficult to:
43+
- Capture frames only from screen renders (not picking passes)
44+
- Distinguish between productive renders and internal renders
45+
- Implement efficient frame capture for video export
46+
47+
**Example: Avoid Capturing Picking Passes**
48+
49+
```typescript
50+
let framesCaptured = 0;
51+
52+
const deck = new Deck({
53+
onAfterRender: ({gl, pass}) => {
54+
// Only capture screen renders, not picking passes
55+
if (pass === 'screen') {
56+
captureFrame(gl);
57+
framesCaptured++;
58+
}
59+
}
60+
});
61+
```
62+
63+
## hasActiveTransitions Method
64+
65+
New method to check if any viewport or layer uniform transitions are active.
66+
67+
### Usage
68+
69+
```typescript
70+
const hasTransitions = deck.hasActiveTransitions();
71+
72+
if (!hasTransitions) {
73+
// Scene is settled - safe to capture
74+
captureFrame();
75+
}
76+
```
77+
78+
### Returns
79+
80+
`boolean` - `true` if any transitions are active, `false` if the scene is settled.
81+
82+
### Detects
83+
84+
- **Layer uniform transitions**: Animated property changes (opacity, radius, etc.)
85+
- **Viewport transitions**: Camera movement animations
86+
87+
### Why This Matters
88+
89+
When capturing frames for video export or screenshots, you want to wait for:
90+
1. All layers to finish loading (`layer.isLoaded`)
91+
2. All transitions to complete (`!deck.hasActiveTransitions()`)
92+
3. Current frame to render (`onAfterRender`)
93+
94+
This method provides the missing piece for detecting transition state.
95+
96+
## Complete Frame Capture Example
97+
98+
```typescript
99+
import {Deck} from '@deck.gl/core';
100+
101+
/**
102+
* Wait for deck.gl to be ready for frame capture:
103+
* - All layers loaded
104+
* - No active transitions
105+
* - Screen render complete
106+
*/
107+
async function waitForFrameReady(deck: Deck): Promise<void> {
108+
return new Promise(resolve => {
109+
// Check if ready
110+
function check() {
111+
const layers = deck.props.layers || [];
112+
const allLayersLoaded = layers.every(
113+
layer => !layer || (!Array.isArray(layer) && layer.isLoaded)
114+
);
115+
116+
if (!allLayersLoaded) {
117+
// Still loading data
118+
requestAnimationFrame(check);
119+
return;
120+
}
121+
122+
if (deck.hasActiveTransitions()) {
123+
// Animations in progress
124+
requestAnimationFrame(check);
125+
return;
126+
}
127+
128+
// Wait for next screen render
129+
const cleanup = () => {
130+
deck.setProps({onAfterRender: originalCallback});
131+
};
132+
133+
const originalCallback = deck.props.onAfterRender;
134+
deck.setProps({
135+
onAfterRender: ({pass, ...rest}) => {
136+
if (pass === 'screen') {
137+
cleanup();
138+
originalCallback?.(rest as any);
139+
resolve();
140+
}
141+
}
142+
});
143+
144+
// Trigger render if needed
145+
deck.redraw();
146+
}
147+
148+
check();
149+
});
150+
}
151+
152+
// Usage
153+
async function captureVideo(deck: Deck, frames: number) {
154+
for (let i = 0; i < frames; i++) {
155+
// Update scene (timeline position, camera, etc.)
156+
updateScene(i);
157+
158+
// Wait for frame to be ready
159+
await waitForFrameReady(deck);
160+
161+
// Capture
162+
const canvas = deck.canvas as HTMLCanvasElement;
163+
const imageData = canvas.toDataURL('image/png');
164+
saveFrame(imageData, i);
165+
}
166+
}
167+
```
168+
169+
## Video Export Pattern
170+
171+
```typescript
172+
class VideoExporter {
173+
private deck: Deck;
174+
private encoder: VideoEncoder;
175+
private frameCount = 0;
176+
177+
async captureFrame(): Promise<void> {
178+
// Wait for layers to load
179+
const layers = this.deck.props.layers || [];
180+
const allLoaded = layers.every(l => !l || l.isLoaded);
181+
if (!allLoaded) {
182+
await this.waitForLoad();
183+
}
184+
185+
// Wait for transitions
186+
if (this.deck.hasActiveTransitions()) {
187+
await this.waitForTransitions();
188+
}
189+
190+
// Capture on next screen render
191+
return new Promise(resolve => {
192+
const originalCallback = this.deck.props.onAfterRender;
193+
this.deck.setProps({
194+
onAfterRender: ({device, gl, pass}) => {
195+
if (pass === 'screen') {
196+
this.deck.setProps({onAfterRender: originalCallback});
197+
this.captureToEncoder(gl);
198+
this.frameCount++;
199+
resolve();
200+
}
201+
originalCallback?.({device, gl, pass});
202+
}
203+
});
204+
this.deck.redraw();
205+
});
206+
}
207+
208+
private async waitForTransitions(): Promise<void> {
209+
return new Promise(resolve => {
210+
const check = () => {
211+
if (!this.deck.hasActiveTransitions()) {
212+
resolve();
213+
} else {
214+
requestAnimationFrame(check);
215+
}
216+
};
217+
check();
218+
});
219+
}
220+
221+
private captureToEncoder(gl: WebGL2RenderingContext): void {
222+
const canvas = this.deck.canvas as HTMLCanvasElement;
223+
const frame = new VideoFrame(canvas, {timestamp: this.frameCount * 33333});
224+
this.encoder.encode(frame);
225+
frame.close();
226+
}
227+
}
228+
```
229+
230+
## Testing Frame Capture
231+
232+
```typescript
233+
import {test, expect} from 'vitest';
234+
235+
test('Frame capture waits for transitions', async () => {
236+
const deck = new Deck({
237+
layers: [
238+
new ScatterplotLayer({
239+
opacity: 1,
240+
transitions: {opacity: 1000}
241+
})
242+
]
243+
});
244+
245+
// Trigger transition
246+
deck.setProps({
247+
layers: [
248+
new ScatterplotLayer({
249+
opacity: 0.5,
250+
transitions: {opacity: 1000}
251+
})
252+
]
253+
});
254+
255+
await new Promise(resolve => setTimeout(resolve, 50));
256+
expect(deck.hasActiveTransitions()).toBe(true);
257+
258+
// Wait for settled
259+
await waitForFrameReady(deck);
260+
expect(deck.hasActiveTransitions()).toBe(false);
261+
});
262+
```
263+
264+
## Migration from onFrameComplete
265+
266+
If you were using a custom `onFrameComplete` callback, migrate to this pattern:
267+
268+
**Before (custom callback):**
269+
```typescript
270+
deck.setProps({
271+
onFrameComplete: ({layersRendered}) => {
272+
if (layersRendered > 0) {
273+
captureFrame();
274+
}
275+
}
276+
});
277+
```
278+
279+
**After (enhanced onAfterRender):**
280+
```typescript
281+
deck.setProps({
282+
onAfterRender: ({pass}) => {
283+
if (pass === 'screen' && !deck.hasActiveTransitions()) {
284+
const layers = deck.props.layers || [];
285+
const allLoaded = layers.every(l => !l || l.isLoaded);
286+
if (allLoaded) {
287+
captureFrame();
288+
}
289+
}
290+
}
291+
});
292+
```
293+
294+
## Related
295+
296+
- [waitForFrameReady utility](./wait-for-frame-ready.md) - Higher-level utility that combines these checks
297+
- [Video Export Guide](../../developer-guide/video-export.md) - Complete video export tutorial
298+
- [onAfterRender API](./deck.md#onafterrender) - Full API reference
299+

modules/core/src/lib/deck.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,14 @@ export type DeckProps<ViewsT extends ViewOrViews = null> = {
206206
onInteractionStateChange?: (state: InteractionState) => void;
207207
/** Called just before the canvas rerenders. */
208208
onBeforeRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
209-
/** Called right after the canvas rerenders. */
210-
onAfterRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
209+
/** Called right after the canvas rerenders.
210+
* @param context.pass - The render pass type: 'screen' for main render, 'picking' for mouse picking, 'shadow' for shadow maps, etc.
211+
*/
212+
onAfterRender?: (context: {
213+
device: Device;
214+
gl: WebGL2RenderingContext;
215+
pass: string;
216+
}) => void;
211217
/** Called once after gl context and all Deck components are created. */
212218
onLoad?: () => void;
213219
/** Called if deck.gl encounters an error.
@@ -601,6 +607,45 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
601607
return redraw;
602608
}
603609

610+
/**
611+
* Check if there are any active transitions (viewport or layer uniform transitions).
612+
* Useful for determining when the scene is "settled" for frame capture.
613+
*
614+
* @returns true if any viewport or layer uniform transitions are active
615+
*
616+
* @example
617+
* ```typescript
618+
* // Wait for all transitions to complete before capturing
619+
* function waitForSettled(deck: Deck): Promise<void> {
620+
* return new Promise(resolve => {
621+
* function check() {
622+
* if (!deck.hasActiveTransitions()) {
623+
* resolve();
624+
* } else {
625+
* requestAnimationFrame(check);
626+
* }
627+
* }
628+
* check();
629+
* });
630+
* }
631+
* ```
632+
*/
633+
hasActiveTransitions(): boolean {
634+
if (!this.layerManager || !this.viewManager) {
635+
return false;
636+
}
637+
638+
// Check for viewport transitions
639+
const viewports = this.viewManager.getViewports();
640+
const hasViewportTransition = viewports.some(vp => (vp as any).isTransitioning);
641+
642+
// Check for layer uniform transitions
643+
const layers = this.layerManager.getLayers();
644+
const hasLayerTransition = layers.some(layer => layer.hasUniformTransition?.());
645+
646+
return hasViewportTransition || hasLayerTransition;
647+
}
648+
604649
/**
605650
* Redraw the GL context
606651
* @param reason If not provided, only redraw if deemed necessary. Otherwise redraw regardless of internal states.
@@ -1491,7 +1536,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
14911536
});
14921537
}
14931538

1494-
this.props.onAfterRender({device, gl});
1539+
this.props.onAfterRender({device, gl, pass: opts.pass});
14951540
}
14961541

14971542
// Callbacks

0 commit comments

Comments
 (0)