• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / luma.gl / 23103187949

15 Mar 2026 04:18AM UTC coverage: 75.933% (-0.03%) from 75.961%
23103187949

Pull #2505

github

web-flow
Merge c9c0b2a89 into b0d20561b
Pull Request #2505: fix(engine): Re-enable GPU timing metrics in AnimationLoop

2485 of 3198 branches covered (77.7%)

Branch coverage included in aggregate %.

18 of 40 new or added lines in 1 file covered. (45.0%)

1 existing line in 1 file now uncovered.

29633 of 39100 relevant lines covered (75.79%)

90.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.71
/modules/engine/src/animation-loop/animation-loop.ts
1
// luma.gl
1✔
2
// SPDX-License-Identifier: MIT
1✔
3
// Copyright (c) vis.gl contributors
1✔
4

1✔
5
import {luma, Device, QuerySet} from '@luma.gl/core';
1✔
6
import {
1✔
7
  requestAnimationFramePolyfill,
1✔
8
  cancelAnimationFramePolyfill
1✔
9
} from './request-animation-frame';
1✔
10
import {Timeline} from '../animation/timeline';
1✔
11
import {AnimationProps} from './animation-props';
1✔
12
import {Stats, Stat} from '@probe.gl/stats';
1✔
13

1✔
14
let statIdCounter = 0;
1✔
15

1✔
16
/** AnimationLoop properties */
1✔
17
export type AnimationLoopProps = {
1✔
18
  device: Device | Promise<Device>;
1✔
19

1✔
20
  onAddHTML?: (div: HTMLDivElement) => string; // innerHTML
1✔
21
  onInitialize?: (animationProps: AnimationProps) => Promise<unknown>;
1✔
22
  onRender?: (animationProps: AnimationProps) => unknown;
1✔
23
  onFinalize?: (animationProps: AnimationProps) => void;
1✔
24
  onError?: (reason: Error) => void;
1✔
25

1✔
26
  stats?: Stats;
1✔
27

1✔
28
  // view parameters - TODO move to CanvasContext?
1✔
29
  autoResizeViewport?: boolean;
1✔
30
};
1✔
31

1✔
32
export type MutableAnimationLoopProps = {
1✔
33
  // view parameters
1✔
34
  autoResizeViewport?: boolean;
1✔
35
};
1✔
36

1✔
37
type WebGLQuerySet = QuerySet & {
1✔
38
  isResultAvailable(): boolean;
1✔
39
  isTimerDisjoint(): boolean;
1✔
40
  getTimerMilliseconds(): number;
1✔
41
  beginTimestampQuery(): void;
1✔
42
  endTimestampQuery(): void;
1✔
43
};
1✔
44

1✔
45
/** Convenient animation loop */
1✔
46
export class AnimationLoop {
1✔
47
  static defaultAnimationLoopProps = {
1✔
48
    device: null!,
1✔
49

1✔
50
    onAddHTML: () => '',
1✔
51
    onInitialize: async () => null,
1✔
52
    onRender: () => {},
1✔
53
    onFinalize: () => {},
1✔
54
    onError: error => console.error(error), // eslint-disable-line no-console
1✔
55

1✔
56
    stats: luma.stats.get(`animation-loop-${statIdCounter++}`),
1✔
57

1✔
58
    // view parameters
1✔
59
    autoResizeViewport: false
1✔
60
  } as const satisfies Readonly<Required<AnimationLoopProps>>;
1✔
61

1✔
62
  device: Device | null = null;
1✔
63
  canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
1✔
64

1✔
65
  props: Required<AnimationLoopProps>;
1✔
66
  animationProps: AnimationProps | null = null;
1✔
67
  timeline: Timeline | null = null;
1✔
68
  stats: Stats;
1✔
69
  cpuTime: Stat;
1✔
70
  gpuTime: Stat;
1✔
71
  frameRate: Stat;
1✔
72

1✔
73
  display: any;
1✔
74

1✔
75
  private _needsRedraw: string | false = 'initialized';
1✔
76

1✔
77
  _initialized: boolean = false;
1✔
78
  _running: boolean = false;
1✔
79
  _animationFrameId: any = null;
1✔
80
  _nextFramePromise: Promise<AnimationLoop> | null = null;
1✔
81
  _resolveNextFrame: ((animationLoop: AnimationLoop) => void) | null = null;
1✔
82
  _cpuStartTime: number = 0;
1✔
83
  _error: Error | null = null;
1✔
84

1✔
85
  /** GPU time query for measuring GPU execution time (WebGL only with EXT_disjoint_timer_query) */
1✔
86
  _gpuTimeQuery: QuerySet | null = null;
1✔
87

1✔
88
  /*
1✔
89
   * @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context
1✔
90
   */
1✔
91
  constructor(props: AnimationLoopProps) {
1✔
92
    this.props = {...AnimationLoop.defaultAnimationLoopProps, ...props};
7✔
93
    props = this.props;
7✔
94

7✔
95
    if (!props.device) {
7!
96
      throw new Error('No device provided');
×
97
    }
×
98

7✔
99
    // state
7✔
100
    this.stats = props.stats || new Stats({id: 'animation-loop-stats'});
7!
101
    this.cpuTime = this.stats.get('CPU Time');
7✔
102
    this.gpuTime = this.stats.get('GPU Time');
7✔
103
    this.frameRate = this.stats.get('Frame Rate');
7✔
104

7✔
105
    this.setProps({autoResizeViewport: props.autoResizeViewport});
7✔
106

7✔
107
    // Bind methods
7✔
108
    this.start = this.start.bind(this);
7✔
109
    this.stop = this.stop.bind(this);
7✔
110

7✔
111
    this._onMousemove = this._onMousemove.bind(this);
7✔
112
    this._onMouseleave = this._onMouseleave.bind(this);
7✔
113
  }
7✔
114

1✔
115
  destroy(): void {
1✔
116
    this.stop();
1✔
117
    this._setDisplay(null);
1✔
118
    if (this._gpuTimeQuery) {
1!
NEW
119
      this._gpuTimeQuery.destroy();
×
NEW
120
      this._gpuTimeQuery = null;
×
NEW
121
    }
×
122
  }
1✔
123

1✔
124
  /** @deprecated Use .destroy() */
1✔
125
  delete(): void {
1✔
126
    this.destroy();
×
127
  }
×
128

1✔
129
  reportError(error: Error): void {
1✔
130
    this.props.onError(error);
×
131
    this._error = error;
×
132
  }
×
133

1✔
134
  /** Flags this animation loop as needing redraw */
1✔
135
  setNeedsRedraw(reason: string): this {
1✔
136
    this._needsRedraw = this._needsRedraw || reason;
5✔
137
    return this;
5✔
138
  }
5✔
139

1✔
140
  /** Query redraw status. Clears the flag. */
1✔
141
  needsRedraw(): false | string {
1✔
142
    const reason = this._needsRedraw;
×
143
    this._needsRedraw = false;
×
144
    return reason;
×
145
  }
×
146

1✔
147
  setProps(props: MutableAnimationLoopProps): this {
1✔
148
    if ('autoResizeViewport' in props) {
7✔
149
      this.props.autoResizeViewport = props.autoResizeViewport || false;
7✔
150
    }
7✔
151
    return this;
7✔
152
  }
7✔
153

1✔
154
  /** Starts a render loop if not already running */
1✔
155
  async start() {
1✔
156
    if (this._running) {
9✔
157
      return this;
2✔
158
    }
2✔
159
    this._running = true;
7✔
160

7✔
161
    try {
7✔
162
      let appContext;
7✔
163
      if (!this._initialized) {
9✔
164
        this._initialized = true;
6✔
165
        // Create the WebGL context
6✔
166
        await this._initDevice();
6✔
167
        this._initialize();
6✔
168

6✔
169
        // Note: onIntialize can return a promise (e.g. in case app needs to load resources)
6✔
170
        await this.props.onInitialize(this._getAnimationProps());
6✔
171
      }
6✔
172

7✔
173
      // check that we haven't been stopped
7✔
174
      if (!this._running) {
9✔
175
        return null;
1✔
176
      }
1✔
177

6✔
178
      // Start the loop
6✔
179
      if (appContext !== false) {
6✔
180
        // cancel any pending renders to ensure only one loop can ever run
6✔
181
        this._cancelAnimationFrame();
6✔
182
        this._requestAnimationFrame();
6✔
183
      }
6✔
184

6✔
185
      return this;
6✔
186
    } catch (err: unknown) {
9!
187
      const error = err instanceof Error ? err : new Error('Unknown error');
×
188
      this.props.onError(error);
×
189
      // this._running = false; // TODO
×
190
      throw error;
×
191
    }
×
192
  }
9✔
193

1✔
194
  /** Stops a render loop if already running, finalizing */
1✔
195
  stop() {
1✔
196
    // console.debug(`Stopping ${this.constructor.name}`);
8✔
197
    if (this._running) {
8✔
198
      // call callback
7✔
199
      // If stop is called immediately, we can end up in a state where props haven't been initialized...
7✔
200
      if (this.animationProps && !this._error) {
7✔
201
        this.props.onFinalize(this.animationProps);
7✔
202
      }
7✔
203

7✔
204
      this._cancelAnimationFrame();
7✔
205
      this._nextFramePromise = null;
7✔
206
      this._resolveNextFrame = null;
7✔
207
      this._running = false;
7✔
208
    }
7✔
209
    return this;
8✔
210
  }
8✔
211

1✔
212
  /** Explicitly draw a frame */
1✔
213
  redraw(): this {
1✔
214
    if (this.device?.isLost || this._error) {
14!
215
      return this;
×
216
    }
×
217

14✔
218
    this._beginFrameTimers();
14✔
219

14✔
220
    this._setupFrame();
14✔
221
    this._updateAnimationProps();
14✔
222

14✔
223
    this._renderFrame(this._getAnimationProps());
14✔
224

14✔
225
    // clear needsRedraw flag
14✔
226
    this._clearNeedsRedraw();
14✔
227

14✔
228
    if (this._resolveNextFrame) {
14✔
229
      this._resolveNextFrame(this);
5✔
230
      this._nextFramePromise = null;
5✔
231
      this._resolveNextFrame = null;
5✔
232
    }
5✔
233

14✔
234
    this._endFrameTimers();
14✔
235

14✔
236
    return this;
14✔
237
  }
14✔
238

1✔
239
  /** Add a timeline, it will be automatically updated by the animation loop. */
1✔
240
  attachTimeline(timeline: Timeline): Timeline {
1✔
241
    this.timeline = timeline;
×
242
    return this.timeline;
×
243
  }
×
244

1✔
245
  /** Remove a timeline */
1✔
246
  detachTimeline(): void {
1✔
247
    this.timeline = null;
×
248
  }
×
249

1✔
250
  /** Wait until a render completes */
1✔
251
  waitForRender(): Promise<AnimationLoop> {
1✔
252
    this.setNeedsRedraw('waitForRender');
5✔
253

5✔
254
    if (!this._nextFramePromise) {
5✔
255
      this._nextFramePromise = new Promise(resolve => {
5✔
256
        this._resolveNextFrame = resolve;
5✔
257
      });
5✔
258
    }
5✔
259
    return this._nextFramePromise;
5✔
260
  }
5✔
261

1✔
262
  /** TODO - should use device.deviceContext */
1✔
263
  async toDataURL(): Promise<string> {
1✔
264
    this.setNeedsRedraw('toDataURL');
×
265
    await this.waitForRender();
×
266
    if (this.canvas instanceof HTMLCanvasElement) {
×
267
      return this.canvas.toDataURL();
×
268
    }
×
269
    throw new Error('OffscreenCanvas');
×
270
  }
×
271

1✔
272
  // PRIVATE METHODS
1✔
273

1✔
274
  _initialize(): void {
1✔
275
    this._startEventHandling();
6✔
276

6✔
277
    // Initialize the callback data
6✔
278
    this._initializeAnimationProps();
6✔
279
    this._updateAnimationProps();
6✔
280

6✔
281
    // Default viewport setup, in case onInitialize wants to render
6✔
282
    this._resizeViewport();
6✔
283

6✔
284
    // Initialize GPU time query if supported (WebGL with EXT_disjoint_timer_query_webgl2)
6✔
285
    if (this.device?.features.has('timer-query-webgl')) {
6!
NEW
286
      try {
×
NEW
287
        this._gpuTimeQuery = this.device.createQuerySet({type: 'timestamp', count: 1});
×
NEW
288
      } catch {
×
NEW
289
        // GPU timing not available - ignore
×
NEW
290
        this._gpuTimeQuery = null;
×
NEW
291
      }
×
NEW
292
    }
×
293
  }
6✔
294

1✔
295
  _setDisplay(display: any): void {
1✔
296
    if (this.display) {
1!
297
      this.display.destroy();
×
298
      this.display.animationLoop = null;
×
299
    }
×
300

1✔
301
    // store animation loop on the display
1✔
302
    if (display) {
1!
303
      display.animationLoop = this;
×
304
    }
×
305

1✔
306
    this.display = display;
1✔
307
  }
1✔
308

1✔
309
  _requestAnimationFrame(): void {
1✔
310
    if (!this._running) {
19✔
311
      return;
1✔
312
    }
1✔
313

18✔
314
    // VR display has a separate animation frame to sync with headset
18✔
315
    // TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
18✔
316
    // See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
18✔
317
    // if (this.display && this.display.requestAnimationFrame) {
18✔
318
    //   this._animationFrameId = this.display.requestAnimationFrame(this._animationFrame.bind(this));
18✔
319
    // }
18✔
320
    this._animationFrameId = requestAnimationFramePolyfill(this._animationFrame.bind(this));
18✔
321
  }
19✔
322

1✔
323
  _cancelAnimationFrame(): void {
1✔
324
    if (this._animationFrameId === null) {
13✔
325
      return;
7✔
326
    }
7✔
327

6✔
328
    // VR display has a separate animation frame to sync with headset
6✔
329
    // TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
6✔
330
    // See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
6✔
331
    // if (this.display && this.display.cancelAnimationFramePolyfill) {
6✔
332
    //   this.display.cancelAnimationFrame(this._animationFrameId);
6✔
333
    // }
6✔
334
    cancelAnimationFramePolyfill(this._animationFrameId);
6✔
335
    this._animationFrameId = null;
6✔
336
  }
13✔
337

1✔
338
  _animationFrame(): void {
1✔
339
    if (!this._running) {
13!
340
      return;
×
341
    }
×
342
    this.redraw();
13✔
343
    this._requestAnimationFrame();
13✔
344
  }
13✔
345

1✔
346
  // Called on each frame, can be overridden to call onRender multiple times
1✔
347
  // to support e.g. stereoscopic rendering
1✔
348
  _renderFrame(animationProps: AnimationProps): void {
1✔
349
    // Allow e.g. VR display to render multiple frames.
14✔
350
    if (this.display) {
14!
351
      this.display._renderFrame(animationProps);
×
352
      return;
×
353
    }
×
354

14✔
355
    // call callback
14✔
356
    this.props.onRender(this._getAnimationProps());
14✔
357
    // end callback
14✔
358

14✔
359
    // Submit commands (necessary on WebGPU)
14✔
360
    this.device?.submit();
14✔
361
  }
14✔
362

1✔
363
  _clearNeedsRedraw(): void {
1✔
364
    this._needsRedraw = false;
14✔
365
  }
14✔
366

1✔
367
  _setupFrame(): void {
1✔
368
    this._resizeViewport();
14✔
369
  }
14✔
370

1✔
371
  // Initialize the  object that will be passed to app callbacks
1✔
372
  _initializeAnimationProps(): void {
1✔
373
    const canvasContext = this.device?.getDefaultCanvasContext();
6✔
374
    if (!this.device || !canvasContext) {
6!
375
      throw new Error('loop');
×
376
    }
×
377

6✔
378
    const canvas = canvasContext?.canvas;
6✔
379
    const useDevicePixels = canvasContext.props.useDevicePixels;
6✔
380

6✔
381
    this.animationProps = {
6✔
382
      animationLoop: this,
6✔
383

6✔
384
      device: this.device,
6✔
385
      canvasContext,
6✔
386
      canvas,
6✔
387
      // @ts-expect-error Deprecated
6✔
388
      useDevicePixels,
6✔
389

6✔
390
      timeline: this.timeline,
6✔
391

6✔
392
      needsRedraw: false,
6✔
393

6✔
394
      // Placeholders
6✔
395
      width: 1,
6✔
396
      height: 1,
6✔
397
      aspect: 1,
6✔
398

6✔
399
      // Animation props
6✔
400
      time: 0,
6✔
401
      startTime: Date.now(),
6✔
402
      engineTime: 0,
6✔
403
      tick: 0,
6✔
404
      tock: 0,
6✔
405

6✔
406
      // Experimental
6✔
407
      _mousePosition: null // Event props
6✔
408
    };
6✔
409
  }
6✔
410

1✔
411
  _getAnimationProps(): AnimationProps {
1✔
412
    if (!this.animationProps) {
34!
413
      throw new Error('animationProps');
×
414
    }
×
415
    return this.animationProps;
34✔
416
  }
34✔
417

1✔
418
  // Update the context object that will be passed to app callbacks
1✔
419
  _updateAnimationProps(): void {
1✔
420
    if (!this.animationProps) {
20!
421
      return;
×
422
    }
×
423

20✔
424
    // Can this be replaced with canvas context?
20✔
425
    const {width, height, aspect} = this._getSizeAndAspect();
20✔
426
    if (width !== this.animationProps.width || height !== this.animationProps.height) {
20!
427
      this.setNeedsRedraw('drawing buffer resized');
×
428
    }
×
429
    if (aspect !== this.animationProps.aspect) {
20!
430
      this.setNeedsRedraw('drawing buffer aspect changed');
×
431
    }
×
432

20✔
433
    this.animationProps.width = width;
20✔
434
    this.animationProps.height = height;
20✔
435
    this.animationProps.aspect = aspect;
20✔
436

20✔
437
    this.animationProps.needsRedraw = this._needsRedraw;
20✔
438

20✔
439
    // Update time properties
20✔
440
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
20✔
441

20✔
442
    if (this.timeline) {
20!
443
      this.timeline.update(this.animationProps.engineTime);
×
444
    }
×
445

20✔
446
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
20✔
447
    this.animationProps.tock++;
20✔
448

20✔
449
    // For back compatibility
20✔
450
    this.animationProps.time = this.timeline
20!
451
      ? this.timeline.getTime()
×
452
      : this.animationProps.engineTime;
20✔
453
  }
20✔
454

1✔
455
  /** Wait for supplied device */
1✔
456
  async _initDevice() {
1✔
457
    this.device = await this.props.device;
6✔
458
    if (!this.device) {
6!
459
      throw new Error('No device provided');
×
460
    }
×
461
    this.canvas = this.device.getDefaultCanvasContext().canvas || null;
6!
462
    // this._createInfoDiv();
6✔
463
  }
6✔
464

1✔
465
  _createInfoDiv(): void {
1✔
466
    if (this.canvas && this.props.onAddHTML) {
×
467
      const wrapperDiv = document.createElement('div');
×
468
      document.body.appendChild(wrapperDiv);
×
469
      wrapperDiv.style.position = 'relative';
×
470
      const div = document.createElement('div');
×
471
      div.style.position = 'absolute';
×
472
      div.style.left = '10px';
×
473
      div.style.bottom = '10px';
×
474
      div.style.width = '300px';
×
475
      div.style.background = 'white';
×
476
      if (this.canvas instanceof HTMLCanvasElement) {
×
477
        wrapperDiv.appendChild(this.canvas);
×
478
      }
×
479
      wrapperDiv.appendChild(div);
×
480
      const html = this.props.onAddHTML(div);
×
481
      if (html) {
×
482
        div.innerHTML = html;
×
483
      }
×
484
    }
×
485
  }
×
486

1✔
487
  _getSizeAndAspect(): {width: number; height: number; aspect: number} {
1✔
488
    if (!this.device) {
20!
489
      return {width: 1, height: 1, aspect: 1};
×
490
    }
×
491
    // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html
20✔
492
    const [width, height] = this.device?.getDefaultCanvasContext().getDevicePixelSize() || [1, 1];
20!
493

20✔
494
    // https://webglfundamentals.org/webgl/lessons/webgl-anti-patterns.html
20✔
495
    let aspect = 1;
20✔
496
    const canvas = this.device?.getDefaultCanvasContext().canvas;
20✔
497

20✔
498
    // @ts-expect-error
20✔
499
    if (canvas && canvas.clientHeight) {
20✔
500
      // @ts-expect-error
20✔
501
      aspect = canvas.clientWidth / canvas.clientHeight;
20✔
502
    } else if (width > 0 && height > 0) {
20!
503
      aspect = width / height;
×
504
    }
×
505

20✔
506
    return {width, height, aspect};
20✔
507
  }
20✔
508

1✔
509
  /** @deprecated Default viewport setup */
1✔
510
  _resizeViewport(): void {
1✔
511
    // TODO can we use canvas context to code this in a portable way?
20✔
512
    // @ts-expect-error Expose on canvasContext
20✔
513
    if (this.props.autoResizeViewport && this.device.gl) {
20!
514
      // @ts-expect-error Expose canvasContext
×
515
      this.device.gl.viewport(
×
516
        0,
×
517
        0,
×
518
        // @ts-expect-error Expose canvasContext
×
519
        this.device.gl.drawingBufferWidth,
×
520
        // @ts-expect-error Expose canvasContext
×
521
        this.device.gl.drawingBufferHeight
×
522
      );
×
523
    }
×
524
  }
20✔
525

1✔
526
  _beginFrameTimers() {
1✔
527
    this.frameRate.timeEnd();
14✔
528
    this.frameRate.timeStart();
14✔
529

14✔
530
    // Check if timer for last frame has completed.
14✔
531
    // GPU timer results are never available in the same frame they are captured.
14✔
532
    if (this.device?.type === 'webgl' && this._gpuTimeQuery) {
14!
NEW
533
      // WEBGLQuerySet has these methods for timer queries
×
NEW
534
      const query = this._gpuTimeQuery as WebGLQuerySet;
×
UNCOV
535

×
NEW
536
      if (query.isResultAvailable() && !query.isTimerDisjoint()) {
×
NEW
537
        this.gpuTime.addTime(query.getTimerMilliseconds());
×
NEW
538
      }
×
NEW
539

×
NEW
540
      // Start GPU time query for this frame
×
NEW
541
      query.beginTimestampQuery();
×
NEW
542
    }
×
543

14✔
544
    this.cpuTime.timeStart();
14✔
545
  }
14✔
546

1✔
547
  _endFrameTimers() {
1✔
548
    this.cpuTime.timeEnd();
14✔
549

14✔
550
    // End GPU time query. Results will be available on next frame.
14✔
551
    if (this._gpuTimeQuery) {
14!
NEW
552
      const query = this._gpuTimeQuery as WebGLQuerySet;
×
NEW
553
      query.endTimestampQuery();
×
NEW
554
    }
×
555
  }
14✔
556

1✔
557
  // Event handling
1✔
558

1✔
559
  _startEventHandling() {
1✔
560
    if (this.canvas) {
6✔
561
      this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
6✔
562
      this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
6✔
563
    }
6✔
564
  }
6✔
565

1✔
566
  _onMousemove(event: Event) {
1✔
567
    if (event instanceof MouseEvent) {
×
568
      this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY];
×
569
    }
×
570
  }
×
571

1✔
572
  _onMouseleave(event: Event) {
1✔
573
    this._getAnimationProps()._mousePosition = null;
×
574
  }
×
575
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc