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

visgl / luma.gl / 21612985007

03 Feb 2026 01:23AM UTC coverage: 76.322% (-0.04%) from 76.366%
21612985007

Pull #2505

github

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

2294 of 2982 branches covered (76.93%)

Branch coverage included in aggregate %.

10 of 37 new or added lines in 1 file covered. (27.03%)

1 existing line in 1 file now uncovered.

28611 of 37511 relevant lines covered (76.27%)

70.19 hits per line

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

76.83
/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
/** Convenient animation loop */
1✔
38
export class AnimationLoop {
1✔
39
  static defaultAnimationLoopProps = {
1✔
40
    device: null!,
1✔
41

1✔
42
    onAddHTML: () => '',
1✔
43
    onInitialize: async () => null,
1✔
44
    onRender: () => {},
1✔
45
    onFinalize: () => {},
1✔
46
    onError: error => console.error(error), // eslint-disable-line no-console
1✔
47

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

1✔
50
    // view parameters
1✔
51
    autoResizeViewport: false
1✔
52
  } as const satisfies Readonly<Required<AnimationLoopProps>>;
1✔
53

1✔
54
  device: Device | null = null;
1✔
55
  canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
1✔
56

1✔
57
  props: Required<AnimationLoopProps>;
1✔
58
  animationProps: AnimationProps | null = null;
1✔
59
  timeline: Timeline | null = null;
1✔
60
  stats: Stats;
1✔
61
  cpuTime: Stat;
1✔
62
  gpuTime: Stat;
1✔
63
  frameRate: Stat;
1✔
64

1✔
65
  display: any;
1✔
66

1✔
67
  private _needsRedraw: string | false = 'initialized';
1✔
68

1✔
69
  _initialized: boolean = false;
1✔
70
  _running: boolean = false;
1✔
71
  _animationFrameId: any = null;
1✔
72
  _nextFramePromise: Promise<AnimationLoop> | null = null;
1✔
73
  _resolveNextFrame: ((animationLoop: AnimationLoop) => void) | null = null;
1✔
74
  _cpuStartTime: number = 0;
1✔
75
  _error: Error | null = null;
1✔
76

1✔
77
  /** GPU time query for measuring GPU execution time (WebGL only with EXT_disjoint_timer_query) */
1✔
78
  _gpuTimeQuery: QuerySet | null = null;
1✔
79

1✔
80
  /*
1✔
81
   * @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context
1✔
82
   */
1✔
83
  constructor(props: AnimationLoopProps) {
1✔
84
    this.props = {...AnimationLoop.defaultAnimationLoopProps, ...props};
7✔
85
    props = this.props;
7✔
86

7✔
87
    if (!props.device) {
7!
88
      throw new Error('No device provided');
×
89
    }
×
90

7✔
91
    // state
7✔
92
    this.stats = props.stats || new Stats({id: 'animation-loop-stats'});
7!
93
    this.cpuTime = this.stats.get('CPU Time');
7✔
94
    this.gpuTime = this.stats.get('GPU Time');
7✔
95
    this.frameRate = this.stats.get('Frame Rate');
7✔
96

7✔
97
    this.setProps({autoResizeViewport: props.autoResizeViewport});
7✔
98

7✔
99
    // Bind methods
7✔
100
    this.start = this.start.bind(this);
7✔
101
    this.stop = this.stop.bind(this);
7✔
102

7✔
103
    this._onMousemove = this._onMousemove.bind(this);
7✔
104
    this._onMouseleave = this._onMouseleave.bind(this);
7✔
105
  }
7✔
106

1✔
107
  destroy(): void {
1✔
108
    this.stop();
1✔
109
    this._setDisplay(null);
1✔
110
    if (this._gpuTimeQuery) {
1!
NEW
111
      this._gpuTimeQuery.destroy();
×
NEW
112
      this._gpuTimeQuery = null;
×
NEW
113
    }
×
114
  }
1✔
115

1✔
116
  /** @deprecated Use .destroy() */
1✔
117
  delete(): void {
1✔
118
    this.destroy();
×
119
  }
×
120

1✔
121
  reportError(error: Error): void {
1✔
122
    this.props.onError(error);
×
123
    this._error = error;
×
124
  }
×
125

1✔
126
  /** Flags this animation loop as needing redraw */
1✔
127
  setNeedsRedraw(reason: string): this {
1✔
128
    this._needsRedraw = this._needsRedraw || reason;
5✔
129
    return this;
5✔
130
  }
5✔
131

1✔
132
  /** Query redraw status. Clears the flag. */
1✔
133
  needsRedraw(): false | string {
1✔
134
    const reason = this._needsRedraw;
×
135
    this._needsRedraw = false;
×
136
    return reason;
×
137
  }
×
138

1✔
139
  setProps(props: MutableAnimationLoopProps): this {
1✔
140
    if ('autoResizeViewport' in props) {
7✔
141
      this.props.autoResizeViewport = props.autoResizeViewport || false;
7✔
142
    }
7✔
143
    return this;
7✔
144
  }
7✔
145

1✔
146
  /** Starts a render loop if not already running */
1✔
147
  async start() {
1✔
148
    if (this._running) {
9✔
149
      return this;
2✔
150
    }
2✔
151
    this._running = true;
7✔
152

7✔
153
    try {
7✔
154
      let appContext;
7✔
155
      if (!this._initialized) {
9✔
156
        this._initialized = true;
6✔
157
        // Create the WebGL context
6✔
158
        await this._initDevice();
6✔
159
        this._initialize();
6✔
160

6✔
161
        // Note: onIntialize can return a promise (e.g. in case app needs to load resources)
6✔
162
        await this.props.onInitialize(this._getAnimationProps());
6✔
163
      }
6✔
164

7✔
165
      // check that we haven't been stopped
7✔
166
      if (!this._running) {
9✔
167
        return null;
1✔
168
      }
1✔
169

6✔
170
      // Start the loop
6✔
171
      if (appContext !== false) {
6✔
172
        // cancel any pending renders to ensure only one loop can ever run
6✔
173
        this._cancelAnimationFrame();
6✔
174
        this._requestAnimationFrame();
6✔
175
      }
6✔
176

6✔
177
      return this;
6✔
178
    } catch (err: unknown) {
9!
179
      const error = err instanceof Error ? err : new Error('Unknown error');
×
180
      this.props.onError(error);
×
181
      // this._running = false; // TODO
×
182
      throw error;
×
183
    }
×
184
  }
9✔
185

1✔
186
  /** Stops a render loop if already running, finalizing */
1✔
187
  stop() {
1✔
188
    // console.debug(`Stopping ${this.constructor.name}`);
8✔
189
    if (this._running) {
8✔
190
      // call callback
7✔
191
      // If stop is called immediately, we can end up in a state where props haven't been initialized...
7✔
192
      if (this.animationProps && !this._error) {
7✔
193
        this.props.onFinalize(this.animationProps);
7✔
194
      }
7✔
195

7✔
196
      this._cancelAnimationFrame();
7✔
197
      this._nextFramePromise = null;
7✔
198
      this._resolveNextFrame = null;
7✔
199
      this._running = false;
7✔
200
    }
7✔
201
    return this;
8✔
202
  }
8✔
203

1✔
204
  /** Explicitly draw a frame */
1✔
205
  redraw(): this {
1✔
206
    if (this.device?.isLost || this._error) {
13!
207
      return this;
×
208
    }
×
209

13✔
210
    this._beginFrameTimers();
13✔
211

13✔
212
    this._setupFrame();
13✔
213
    this._updateAnimationProps();
13✔
214

13✔
215
    this._renderFrame(this._getAnimationProps());
13✔
216

13✔
217
    // clear needsRedraw flag
13✔
218
    this._clearNeedsRedraw();
13✔
219

13✔
220
    if (this._resolveNextFrame) {
13✔
221
      this._resolveNextFrame(this);
5✔
222
      this._nextFramePromise = null;
5✔
223
      this._resolveNextFrame = null;
5✔
224
    }
5✔
225

13✔
226
    this._endFrameTimers();
13✔
227

13✔
228
    return this;
13✔
229
  }
13✔
230

1✔
231
  /** Add a timeline, it will be automatically updated by the animation loop. */
1✔
232
  attachTimeline(timeline: Timeline): Timeline {
1✔
233
    this.timeline = timeline;
×
234
    return this.timeline;
×
235
  }
×
236

1✔
237
  /** Remove a timeline */
1✔
238
  detachTimeline(): void {
1✔
239
    this.timeline = null;
×
240
  }
×
241

1✔
242
  /** Wait until a render completes */
1✔
243
  waitForRender(): Promise<AnimationLoop> {
1✔
244
    this.setNeedsRedraw('waitForRender');
5✔
245

5✔
246
    if (!this._nextFramePromise) {
5✔
247
      this._nextFramePromise = new Promise(resolve => {
5✔
248
        this._resolveNextFrame = resolve;
5✔
249
      });
5✔
250
    }
5✔
251
    return this._nextFramePromise;
5✔
252
  }
5✔
253

1✔
254
  /** TODO - should use device.deviceContext */
1✔
255
  async toDataURL(): Promise<string> {
1✔
256
    this.setNeedsRedraw('toDataURL');
×
257
    await this.waitForRender();
×
258
    if (this.canvas instanceof HTMLCanvasElement) {
×
259
      return this.canvas.toDataURL();
×
260
    }
×
261
    throw new Error('OffscreenCanvas');
×
262
  }
×
263

1✔
264
  // PRIVATE METHODS
1✔
265

1✔
266
  _initialize(): void {
1✔
267
    this._startEventHandling();
6✔
268

6✔
269
    // Initialize the callback data
6✔
270
    this._initializeAnimationProps();
6✔
271
    this._updateAnimationProps();
6✔
272

6✔
273
    // Default viewport setup, in case onInitialize wants to render
6✔
274
    this._resizeViewport();
6✔
275

6✔
276
    // Initialize GPU time query if supported (WebGL with EXT_disjoint_timer_query_webgl2)
6✔
277
    if (this.device?.features.has('timer-query-webgl')) {
6!
NEW
278
      try {
×
NEW
279
        this._gpuTimeQuery = this.device.createQuerySet({type: 'timestamp', count: 1});
×
NEW
280
      } catch {
×
NEW
281
        // GPU timing not available - ignore
×
NEW
282
        this._gpuTimeQuery = null;
×
NEW
283
      }
×
NEW
284
    }
×
285
  }
6✔
286

1✔
287
  _setDisplay(display: any): void {
1✔
288
    if (this.display) {
1!
289
      this.display.destroy();
×
290
      this.display.animationLoop = null;
×
291
    }
×
292

1✔
293
    // store animation loop on the display
1✔
294
    if (display) {
1!
295
      display.animationLoop = this;
×
296
    }
×
297

1✔
298
    this.display = display;
1✔
299
  }
1✔
300

1✔
301
  _requestAnimationFrame(): void {
1✔
302
    if (!this._running) {
18✔
303
      return;
1✔
304
    }
1✔
305

17✔
306
    // VR display has a separate animation frame to sync with headset
17✔
307
    // TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
17✔
308
    // See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
17✔
309
    // if (this.display && this.display.requestAnimationFrame) {
17✔
310
    //   this._animationFrameId = this.display.requestAnimationFrame(this._animationFrame.bind(this));
17✔
311
    // }
17✔
312
    this._animationFrameId = requestAnimationFramePolyfill(this._animationFrame.bind(this));
17✔
313
  }
18✔
314

1✔
315
  _cancelAnimationFrame(): void {
1✔
316
    if (this._animationFrameId === null) {
13✔
317
      return;
7✔
318
    }
7✔
319

6✔
320
    // VR display has a separate animation frame to sync with headset
6✔
321
    // TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
6✔
322
    // See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
6✔
323
    // if (this.display && this.display.cancelAnimationFramePolyfill) {
6✔
324
    //   this.display.cancelAnimationFrame(this._animationFrameId);
6✔
325
    // }
6✔
326
    cancelAnimationFramePolyfill(this._animationFrameId);
6✔
327
    this._animationFrameId = null;
6✔
328
  }
13✔
329

1✔
330
  _animationFrame(): void {
1✔
331
    if (!this._running) {
12!
332
      return;
×
333
    }
×
334
    this.redraw();
12✔
335
    this._requestAnimationFrame();
12✔
336
  }
12✔
337

1✔
338
  // Called on each frame, can be overridden to call onRender multiple times
1✔
339
  // to support e.g. stereoscopic rendering
1✔
340
  _renderFrame(animationProps: AnimationProps): void {
1✔
341
    // Allow e.g. VR display to render multiple frames.
13✔
342
    if (this.display) {
13!
343
      this.display._renderFrame(animationProps);
×
344
      return;
×
345
    }
×
346

13✔
347
    // call callback
13✔
348
    this.props.onRender(this._getAnimationProps());
13✔
349
    // end callback
13✔
350

13✔
351
    // Submit commands (necessary on WebGPU)
13✔
352
    this.device?.submit();
13✔
353
  }
13✔
354

1✔
355
  _clearNeedsRedraw(): void {
1✔
356
    this._needsRedraw = false;
13✔
357
  }
13✔
358

1✔
359
  _setupFrame(): void {
1✔
360
    this._resizeViewport();
13✔
361
  }
13✔
362

1✔
363
  // Initialize the  object that will be passed to app callbacks
1✔
364
  _initializeAnimationProps(): void {
1✔
365
    const canvasContext = this.device?.getDefaultCanvasContext();
6✔
366
    if (!this.device || !canvasContext) {
6!
367
      throw new Error('loop');
×
368
    }
×
369

6✔
370
    const canvas = canvasContext?.canvas;
6✔
371
    const useDevicePixels = canvasContext.props.useDevicePixels;
6✔
372

6✔
373
    this.animationProps = {
6✔
374
      animationLoop: this,
6✔
375

6✔
376
      device: this.device,
6✔
377
      canvasContext,
6✔
378
      canvas,
6✔
379
      // @ts-expect-error Deprecated
6✔
380
      useDevicePixels,
6✔
381

6✔
382
      timeline: this.timeline,
6✔
383

6✔
384
      needsRedraw: false,
6✔
385

6✔
386
      // Placeholders
6✔
387
      width: 1,
6✔
388
      height: 1,
6✔
389
      aspect: 1,
6✔
390

6✔
391
      // Animation props
6✔
392
      time: 0,
6✔
393
      startTime: Date.now(),
6✔
394
      engineTime: 0,
6✔
395
      tick: 0,
6✔
396
      tock: 0,
6✔
397

6✔
398
      // Experimental
6✔
399
      _mousePosition: null // Event props
6✔
400
    };
6✔
401
  }
6✔
402

1✔
403
  _getAnimationProps(): AnimationProps {
1✔
404
    if (!this.animationProps) {
32!
405
      throw new Error('animationProps');
×
406
    }
×
407
    return this.animationProps;
32✔
408
  }
32✔
409

1✔
410
  // Update the context object that will be passed to app callbacks
1✔
411
  _updateAnimationProps(): void {
1✔
412
    if (!this.animationProps) {
19!
413
      return;
×
414
    }
×
415

19✔
416
    // Can this be replaced with canvas context?
19✔
417
    const {width, height, aspect} = this._getSizeAndAspect();
19✔
418
    if (width !== this.animationProps.width || height !== this.animationProps.height) {
19!
419
      this.setNeedsRedraw('drawing buffer resized');
×
420
    }
×
421
    if (aspect !== this.animationProps.aspect) {
19!
422
      this.setNeedsRedraw('drawing buffer aspect changed');
×
423
    }
×
424

19✔
425
    this.animationProps.width = width;
19✔
426
    this.animationProps.height = height;
19✔
427
    this.animationProps.aspect = aspect;
19✔
428

19✔
429
    this.animationProps.needsRedraw = this._needsRedraw;
19✔
430

19✔
431
    // Update time properties
19✔
432
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
19✔
433

19✔
434
    if (this.timeline) {
19!
435
      this.timeline.update(this.animationProps.engineTime);
×
436
    }
×
437

19✔
438
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
19✔
439
    this.animationProps.tock++;
19✔
440

19✔
441
    // For back compatibility
19✔
442
    this.animationProps.time = this.timeline
19!
443
      ? this.timeline.getTime()
×
444
      : this.animationProps.engineTime;
19✔
445
  }
19✔
446

1✔
447
  /** Wait for supplied device */
1✔
448
  async _initDevice() {
1✔
449
    this.device = await this.props.device;
6✔
450
    if (!this.device) {
6!
451
      throw new Error('No device provided');
×
452
    }
×
453
    this.canvas = this.device.getDefaultCanvasContext().canvas || null;
6!
454
    // this._createInfoDiv();
6✔
455
  }
6✔
456

1✔
457
  _createInfoDiv(): void {
1✔
458
    if (this.canvas && this.props.onAddHTML) {
×
459
      const wrapperDiv = document.createElement('div');
×
460
      document.body.appendChild(wrapperDiv);
×
461
      wrapperDiv.style.position = 'relative';
×
462
      const div = document.createElement('div');
×
463
      div.style.position = 'absolute';
×
464
      div.style.left = '10px';
×
465
      div.style.bottom = '10px';
×
466
      div.style.width = '300px';
×
467
      div.style.background = 'white';
×
468
      if (this.canvas instanceof HTMLCanvasElement) {
×
469
        wrapperDiv.appendChild(this.canvas);
×
470
      }
×
471
      wrapperDiv.appendChild(div);
×
472
      const html = this.props.onAddHTML(div);
×
473
      if (html) {
×
474
        div.innerHTML = html;
×
475
      }
×
476
    }
×
477
  }
×
478

1✔
479
  _getSizeAndAspect(): {width: number; height: number; aspect: number} {
1✔
480
    if (!this.device) {
19!
481
      return {width: 1, height: 1, aspect: 1};
×
482
    }
×
483
    // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html
19✔
484
    const [width, height] = this.device?.getDefaultCanvasContext().getDevicePixelSize() || [1, 1];
19!
485

19✔
486
    // https://webglfundamentals.org/webgl/lessons/webgl-anti-patterns.html
19✔
487
    let aspect = 1;
19✔
488
    const canvas = this.device?.getDefaultCanvasContext().canvas;
19✔
489

19✔
490
    // @ts-expect-error
19✔
491
    if (canvas && canvas.clientHeight) {
19✔
492
      // @ts-expect-error
19✔
493
      aspect = canvas.clientWidth / canvas.clientHeight;
19✔
494
    } else if (width > 0 && height > 0) {
19!
495
      aspect = width / height;
×
496
    }
×
497

19✔
498
    return {width, height, aspect};
19✔
499
  }
19✔
500

1✔
501
  /** @deprecated Default viewport setup */
1✔
502
  _resizeViewport(): void {
1✔
503
    // TODO can we use canvas context to code this in a portable way?
19✔
504
    // @ts-expect-error Expose on canvasContext
19✔
505
    if (this.props.autoResizeViewport && this.device.gl) {
19!
506
      // @ts-expect-error Expose canvasContext
×
507
      this.device.gl.viewport(
×
508
        0,
×
509
        0,
×
510
        // @ts-expect-error Expose canvasContext
×
511
        this.device.gl.drawingBufferWidth,
×
512
        // @ts-expect-error Expose canvasContext
×
513
        this.device.gl.drawingBufferHeight
×
514
      );
×
515
    }
×
516
  }
19✔
517

1✔
518
  _beginFrameTimers() {
1✔
519
    this.frameRate.timeEnd();
13✔
520
    this.frameRate.timeStart();
13✔
521

13✔
522
    // Check if timer for last frame has completed.
13✔
523
    // GPU timer results are never available in the same frame they are captured.
13✔
524
    if (this._gpuTimeQuery) {
13!
NEW
525
      // WEBGLQuerySet has these methods for timer queries
×
NEW
526
      const query = this._gpuTimeQuery as QuerySet & {
×
NEW
527
        isResultAvailable(): boolean;
×
NEW
528
        isTimerDisjoint(): boolean;
×
NEW
529
        getTimerMilliseconds(): number;
×
NEW
530
        beginTimestampQuery(): void;
×
NEW
531
      };
×
NEW
532

×
NEW
533
      if (query.isResultAvailable() && !query.isTimerDisjoint()) {
×
NEW
534
        this.gpuTime.addTime(query.getTimerMilliseconds());
×
NEW
535
      }
×
UNCOV
536

×
NEW
537
      // Start GPU time query for this frame
×
NEW
538
      query.beginTimestampQuery();
×
NEW
539
    }
×
540

13✔
541
    this.cpuTime.timeStart();
13✔
542
  }
13✔
543

1✔
544
  _endFrameTimers() {
1✔
545
    this.cpuTime.timeEnd();
13✔
546

13✔
547
    // End GPU time query. Results will be available on next frame.
13✔
548
    if (this._gpuTimeQuery) {
13!
NEW
549
      const query = this._gpuTimeQuery as QuerySet & {endTimestampQuery(): void};
×
NEW
550
      query.endTimestampQuery();
×
NEW
551
    }
×
552
  }
13✔
553

1✔
554
  // Event handling
1✔
555

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

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

1✔
569
  _onMouseleave(event: Event) {
1✔
570
    this._getAnimationProps()._mousePosition = null;
×
571
  }
×
572
}
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