Skip to content

Commit e3dd4e8

Browse files
akre54claude
andcommitted
feat(core) Add Deck#onFrameComplete callback with CPU/GPU timing
Adds a per-frame callback that fires after each on-screen render with CPU time, optional GPU time, and a flag indicating whether GPU timing is supported on the device. GPU timing is sourced from luma.gl's command-encoder timer (timestamp-query feature, backed by EXT_disjoint_timer_query_webgl2 on WebGL2 and timestamp-query on WebGPU). When the timing query is unresolved or the extension is unavailable, gpuTime is reported as null and the callback still fires so consumers can rely on it as a frame-completion signal. Use cases: - Performance instrumentation - Frame-pacing logic in custom render loops - Headless capture pipelines that need to know when the GPU is finished with a frame before reading pixels Picking and other off-screen passes do not invoke the callback. Errors thrown from the user handler are caught and logged so they do not interrupt the render loop. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6795cc9 commit e3dd4e8

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

docs/api-reference/core/deck.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,39 @@ Receives arguments:
574574
* `gl` - the WebGL context.
575575
576576
577+
#### `onFrameComplete` (Function) {#onframecomplete}
578+
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.
580+
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.
582+
583+
Receives a single object argument with:
584+
585+
* `cpuTime` (number) - CPU time spent in the deck.gl draw pipeline for this frame, in milliseconds.
586+
* `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.
587+
* `gpuTimingSupported` (boolean) - whether the underlying device exposes the `timestamp-query` feature (WebGL2: `EXT_disjoint_timer_query_webgl2`, WebGPU: `timestamp-query`).
588+
* `timestamp` (number) - high-resolution `performance.now()` timestamp of when the callback fired.
589+
590+
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`.
591+
592+
Errors thrown from `onFrameComplete` are caught and logged via `log.warn` and do not interrupt the render loop.
593+
594+
Example:
595+
596+
```js
597+
new Deck({
598+
// ...
599+
onFrameComplete: ({cpuTime, gpuTime, gpuTimingSupported}) => {
600+
if (gpuTimingSupported && gpuTime !== null) {
601+
console.log(`frame: cpu=${cpuTime.toFixed(2)}ms gpu=${gpuTime.toFixed(2)}ms`);
602+
} else {
603+
console.log(`frame: cpu=${cpuTime.toFixed(2)}ms (no gpu timing)`);
604+
}
605+
}
606+
});
607+
```
608+
609+
577610
#### `onError` (Function) {#onerror}
578611
579612
* Default: `console.error`

modules/core/src/lib/deck.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,21 @@ export type DeckProps<ViewsT extends ViewOrViews = null> = {
208208
onBeforeRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
209209
/** Called right after the canvas rerenders. */
210210
onAfterRender?: (context: {device: Device; gl: WebGL2RenderingContext}) => void;
211+
/**
212+
* Called after each frame completes rendering, with CPU and GPU timing info.
213+
* 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+
*
217+
* Useful for performance instrumentation, frame pacing, and headless capture
218+
* pipelines that need to know when the GPU is finished with a frame.
219+
*/
220+
onFrameComplete?: (info: {
221+
cpuTime: number;
222+
gpuTime: number | null;
223+
gpuTimingSupported: boolean;
224+
timestamp: number;
225+
}) => void;
211226
/** Called once after gl context and all Deck components are created. */
212227
onLoad?: () => void;
213228
/** Called if deck.gl encounters an error.
@@ -279,6 +294,7 @@ const defaultProps: DeckProps = {
279294
onInteractionStateChange: noop,
280295
onBeforeRender: noop,
281296
onAfterRender: noop,
297+
onFrameComplete: noop,
282298
onLoad: noop,
283299
onError: (error: Error) => log.error(error.message, error.cause)(),
284300
onHover: null,
@@ -1470,6 +1486,8 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
14701486

14711487
this.props.onBeforeRender({device, gl});
14721488

1489+
const cpuStart = typeof performance !== 'undefined' ? performance.now() : 0;
1490+
14731491
const opts = {
14741492
target: this.props._framebuffer,
14751493
layers: this.layerManager!.getLayers(),
@@ -1492,6 +1510,68 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
14921510
}
14931511

14941512
this.props.onAfterRender({device, gl});
1513+
1514+
// Fire onFrameComplete only for actual on-screen draws so picking/texture
1515+
// passes don't pollute the timing stream consumed by capture pipelines.
1516+
if (opts.pass === 'screen' && this.props.onFrameComplete !== noop) {
1517+
const cpuTime = (typeof performance !== 'undefined' ? performance.now() : 0) - cpuStart;
1518+
this._fireFrameComplete(device, cpuTime);
1519+
}
1520+
}
1521+
1522+
/**
1523+
* Resolve GPU timing for the just-rendered frame and fire onFrameComplete.
1524+
* GPU time is read asynchronously from the device's command encoder when
1525+
* available (WebGL2 via EXT_disjoint_timer_query_webgl2, WebGPU via
1526+
* timestamp-query). When unavailable, we still fire the callback so consumers
1527+
* can track frame completion and CPU timing.
1528+
*/
1529+
private _fireFrameComplete(device: Device, cpuTime: number): void {
1530+
const gpuTimingSupported = Boolean(device?.features?.has?.('timestamp-query'));
1531+
1532+
const fire = (gpuTime: number | null) => {
1533+
try {
1534+
this.props.onFrameComplete?.({
1535+
cpuTime,
1536+
gpuTime,
1537+
gpuTimingSupported,
1538+
timestamp: typeof performance !== 'undefined' ? performance.now() : 0
1539+
});
1540+
} catch (error) {
1541+
log.warn('onFrameComplete handler threw', error)();
1542+
}
1543+
};
1544+
1545+
if (!gpuTimingSupported) {
1546+
fire(null);
1547+
return;
1548+
}
1549+
1550+
// luma.gl writes the resolved GPU duration to commandEncoder._gpuTimeMs
1551+
// once the timer query result is available. Poll a few frames to give the
1552+
// GPU pipeline time to flush, then deliver whatever we have.
1553+
const commandEncoder = (device as unknown as {commandEncoder?: {_gpuTimeMs?: number}})
1554+
.commandEncoder;
1555+
let attempts = 0;
1556+
const maxAttempts = 10;
1557+
const poll = () => {
1558+
const gpuTimeMs = commandEncoder?._gpuTimeMs;
1559+
if (typeof gpuTimeMs === 'number') {
1560+
fire(gpuTimeMs);
1561+
return;
1562+
}
1563+
if (++attempts >= maxAttempts) {
1564+
fire(null);
1565+
return;
1566+
}
1567+
if (typeof requestAnimationFrame === 'function') {
1568+
requestAnimationFrame(poll);
1569+
} else {
1570+
// Headless fallback (Node, tests): don't busy-loop.
1571+
fire(null);
1572+
}
1573+
};
1574+
poll();
14951575
}
14961576

14971577
// Callbacks

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,168 @@ test('Deck#getView with multiple views', async () => {
887887
});
888888
});
889889

890+
test('Deck#onFrameComplete fires per frame with timing info', async () => {
891+
const calls: Array<{
892+
cpuTime: number;
893+
gpuTime: number | null;
894+
gpuTimingSupported: boolean;
895+
timestamp: number;
896+
}> = [];
897+
898+
await new Promise<void>((resolve, reject) => {
899+
const deck = new Deck({
900+
device,
901+
width: 1,
902+
height: 1,
903+
904+
viewState: {
905+
longitude: 0,
906+
latitude: 0,
907+
zoom: 0
908+
},
909+
910+
layers: [],
911+
912+
onFrameComplete: info => {
913+
calls.push(info);
914+
},
915+
916+
onAfterRender: async () => {
917+
// Use the very first onAfterRender to run assertions and finalize.
918+
if (calls.length === 0) {
919+
// GPU polling is async via rAF; give it time.
920+
await sleep(100);
921+
}
922+
if (calls.length === 0) {
923+
return;
924+
}
925+
try {
926+
for (const call of calls) {
927+
expect(typeof call.cpuTime, 'cpuTime is a number').toBe('number');
928+
expect(call.cpuTime, 'cpuTime is non-negative').toBeGreaterThanOrEqual(0);
929+
expect(typeof call.gpuTimingSupported, 'gpuTimingSupported is a boolean').toBe(
930+
'boolean'
931+
);
932+
expect(typeof call.timestamp, 'timestamp is a number').toBe('number');
933+
if (call.gpuTime !== null) {
934+
expect(call.gpuTime).toBeGreaterThanOrEqual(0);
935+
}
936+
}
937+
deck.finalize();
938+
resolve();
939+
} catch (error) {
940+
deck.finalize();
941+
reject(error);
942+
}
943+
}
944+
});
945+
});
946+
});
947+
948+
test('Deck#onFrameComplete is not called for picking passes', async () => {
949+
let frameCallCount = 0;
950+
951+
await new Promise<void>((resolve, reject) => {
952+
const deck = new Deck({
953+
device,
954+
width: 1,
955+
height: 1,
956+
957+
viewState: {
958+
longitude: 0,
959+
latitude: 0,
960+
zoom: 0
961+
},
962+
963+
layers: [
964+
new ScatterplotLayer({
965+
id: 'pick-test',
966+
data: [{position: [0, 0]}],
967+
getPosition: d => d.position,
968+
pickable: true,
969+
radiusMinPixels: 10
970+
})
971+
],
972+
973+
onFrameComplete: () => {
974+
frameCallCount++;
975+
},
976+
977+
onLoad: async () => {
978+
try {
979+
await waitForRender(deck);
980+
const baseline = frameCallCount;
981+
// Trigger a picking pass; this should not fire onFrameComplete.
982+
deck.pickObject({x: 0, y: 0, radius: 1});
983+
await sleep(50);
984+
expect(frameCallCount, 'picking pass does not fire onFrameComplete').toBe(baseline);
985+
986+
deck.finalize();
987+
resolve();
988+
} catch (error) {
989+
deck.finalize();
990+
reject(error);
991+
}
992+
}
993+
});
994+
});
995+
});
996+
997+
test('Deck#onFrameComplete handler errors do not crash render loop', async () => {
998+
let throwCount = 0;
999+
let postThrowFrameCount = 0;
1000+
1001+
await new Promise<void>((resolve, reject) => {
1002+
const deck = new Deck({
1003+
device,
1004+
width: 1,
1005+
height: 1,
1006+
1007+
viewState: {
1008+
longitude: 0,
1009+
latitude: 0,
1010+
zoom: 0
1011+
},
1012+
1013+
layers: [],
1014+
1015+
onLoad: async () => {
1016+
try {
1017+
// Wait for first render to complete from initial load.
1018+
await waitForRender(deck);
1019+
1020+
// Install a throwing handler and trigger another redraw.
1021+
deck.setProps({
1022+
onFrameComplete: () => {
1023+
throwCount++;
1024+
throw new Error('intentional test error');
1025+
},
1026+
onAfterRender: () => {
1027+
if (throwCount > 0) postThrowFrameCount++;
1028+
}
1029+
});
1030+
deck.redraw('error-trigger');
1031+
// Allow the throwing handler + a follow-up render to occur.
1032+
await sleep(100);
1033+
deck.redraw('error-trigger-2');
1034+
await sleep(100);
1035+
1036+
expect(throwCount, 'handler executed and threw').toBeGreaterThanOrEqual(1);
1037+
expect(postThrowFrameCount, 'render loop continues after handler throw').toBeGreaterThan(
1038+
0
1039+
);
1040+
1041+
deck.finalize();
1042+
resolve();
1043+
} catch (error) {
1044+
deck.finalize();
1045+
reject(error);
1046+
}
1047+
}
1048+
});
1049+
});
1050+
});
1051+
8901052
test('Deck#props omitted are unchanged', async () => {
8911053
const layer = new ScatterplotLayer({
8921054
id: 'scatterplot-global-data',

0 commit comments

Comments
 (0)