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

visgl / luma.gl / 23357510199

20 Mar 2026 06:40PM UTC coverage: 58.158% (+5.9%) from 52.213%
23357510199

push

github

web-flow
chore: Run tests on src instead of dist (#2555)

3021 of 6029 branches covered (50.11%)

Branch coverage included in aggregate %.

7102 of 11377 relevant lines covered (62.42%)

243.33 hits per line

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

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

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

14
let statIdCounter = 0;
58✔
15
const ANIMATION_LOOP_STATS = 'Animation Loop';
58✔
16

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

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

27
  stats?: Stats;
28

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

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

38
/** Convenient animation loop */
39
export class AnimationLoop {
40
  static defaultAnimationLoopProps = {
58✔
41
    device: null!,
42

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

49
    stats: undefined!,
50

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

55
  device: Device | null = null;
8✔
56
  canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
8✔
57

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

67
  display: any;
68

69
  private _needsRedraw: string | false = 'initialized';
8✔
70

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

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

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

91
    // state
92
    this.stats = props.stats || new Stats({id: `animation-loop-${statIdCounter++}`});
8✔
93
    this.sharedStats = luma.stats.get(ANIMATION_LOOP_STATS);
8✔
94
    this.frameRate = this.stats.get('Frame Rate');
8✔
95
    this.frameRate.setSampleSize(1);
8✔
96
    this.cpuTime = this.stats.get('CPU Time');
8✔
97
    this.gpuTime = this.stats.get('GPU Time');
8✔
98

99
    this.setProps({autoResizeViewport: props.autoResizeViewport});
8✔
100

101
    // Bind methods
102
    this.start = this.start.bind(this);
8✔
103
    this.stop = this.stop.bind(this);
8✔
104

105
    this._onMousemove = this._onMousemove.bind(this);
8✔
106
    this._onMouseleave = this._onMouseleave.bind(this);
8✔
107
  }
108

109
  destroy(): void {
110
    this.stop();
2✔
111
    this._setDisplay(null);
2✔
112
    this.device?._disableDebugGPUTime();
2✔
113
  }
114

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

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

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

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

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

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

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

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

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

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

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

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

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

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

210
    this._beginFrameTimers(time);
15✔
211

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

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

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

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

226
    this._endFrameTimers();
15✔
227

228
    return this;
15✔
229
  }
230

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

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

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

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

254
  /** TODO - should use device.deviceContext */
255
  async toDataURL(): Promise<string> {
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

264
  // PRIVATE METHODS
265

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

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

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

276
    this.device?._enableDebugGPUTime();
7✔
277
  }
278

279
  _setDisplay(display: any): void {
280
    if (this.display) {
2!
281
      this.display.destroy();
×
282
      this.display.animationLoop = null;
×
283
    }
284

285
    // store animation loop on the display
286
    if (display) {
2!
287
      display.animationLoop = this;
×
288
    }
289

290
    this.display = display;
2✔
291
  }
292

293
  _requestAnimationFrame(): void {
294
    if (!this._running) {
21✔
295
      return;
1✔
296
    }
297

298
    // VR display has a separate animation frame to sync with headset
299
    // TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
300
    // See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
301
    // if (this.display && this.display.requestAnimationFrame) {
302
    //   this._animationFrameId = this.display.requestAnimationFrame(this._animationFrame.bind(this));
303
    // }
304
    this._animationFrameId = requestAnimationFramePolyfill(this._animationFrame.bind(this));
20✔
305
  }
306

307
  _cancelAnimationFrame(): void {
308
    if (this._animationFrameId === null) {
15✔
309
      return;
8✔
310
    }
311

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

322
  _animationFrame(time: number): void {
323
    if (!this._running) {
14!
324
      return;
×
325
    }
326
    this.redraw(time);
14✔
327
    this._requestAnimationFrame();
14✔
328
  }
329

330
  // Called on each frame, can be overridden to call onRender multiple times
331
  // to support e.g. stereoscopic rendering
332
  _renderFrame(animationProps: AnimationProps): void {
333
    // Allow e.g. VR display to render multiple frames.
334
    if (this.display) {
15!
335
      this.display._renderFrame(animationProps);
×
336
      return;
×
337
    }
338

339
    // call callback
340
    this.props.onRender(this._getAnimationProps());
15✔
341
    // end callback
342

343
    // Submit commands (necessary on WebGPU)
344
    this.device?.submit();
15✔
345
  }
346

347
  _clearNeedsRedraw(): void {
348
    this._needsRedraw = false;
15✔
349
  }
350

351
  _setupFrame(): void {
352
    this._resizeViewport();
15✔
353
  }
354

355
  // Initialize the  object that will be passed to app callbacks
356
  _initializeAnimationProps(): void {
357
    const canvasContext = this.device?.getDefaultCanvasContext();
7✔
358
    if (!this.device || !canvasContext) {
7!
359
      throw new Error('loop');
×
360
    }
361

362
    const canvas = canvasContext?.canvas;
7✔
363
    const useDevicePixels = canvasContext.props.useDevicePixels;
7✔
364

365
    this.animationProps = {
7✔
366
      animationLoop: this,
367

368
      device: this.device,
369
      canvasContext,
370
      canvas,
371
      // @ts-expect-error Deprecated
372
      useDevicePixels,
373

374
      timeline: this.timeline,
375

376
      needsRedraw: false,
377

378
      // Placeholders
379
      width: 1,
380
      height: 1,
381
      aspect: 1,
382

383
      // Animation props
384
      time: 0,
385
      startTime: Date.now(),
386
      engineTime: 0,
387
      tick: 0,
388
      tock: 0,
389

390
      // Experimental
391
      _mousePosition: null // Event props
392
    };
393
  }
394

395
  _getAnimationProps(): AnimationProps {
396
    if (!this.animationProps) {
37!
397
      throw new Error('animationProps');
×
398
    }
399
    return this.animationProps;
37✔
400
  }
401

402
  // Update the context object that will be passed to app callbacks
403
  _updateAnimationProps(): void {
404
    if (!this.animationProps) {
22!
405
      return;
×
406
    }
407

408
    // Can this be replaced with canvas context?
409
    const {width, height, aspect} = this._getSizeAndAspect();
22✔
410
    if (width !== this.animationProps.width || height !== this.animationProps.height) {
22!
411
      this.setNeedsRedraw('drawing buffer resized');
×
412
    }
413
    if (aspect !== this.animationProps.aspect) {
22!
414
      this.setNeedsRedraw('drawing buffer aspect changed');
×
415
    }
416

417
    this.animationProps.width = width;
22✔
418
    this.animationProps.height = height;
22✔
419
    this.animationProps.aspect = aspect;
22✔
420

421
    this.animationProps.needsRedraw = this._needsRedraw;
22✔
422

423
    // Update time properties
424
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
22✔
425

426
    if (this.timeline) {
22!
427
      this.timeline.update(this.animationProps.engineTime);
×
428
    }
429

430
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
22✔
431
    this.animationProps.tock++;
22✔
432

433
    // For back compatibility
434
    this.animationProps.time = this.timeline
22!
435
      ? this.timeline.getTime()
436
      : this.animationProps.engineTime;
437
  }
438

439
  /** Wait for supplied device */
440
  async _initDevice() {
441
    this.device = await this.props.device;
7✔
442
    if (!this.device) {
7!
443
      throw new Error('No device provided');
×
444
    }
445
    this.canvas = this.device.getDefaultCanvasContext().canvas || null;
7!
446
    // this._createInfoDiv();
447
  }
448

449
  _createInfoDiv(): void {
450
    if (this.canvas && this.props.onAddHTML) {
×
451
      const wrapperDiv = document.createElement('div');
×
452
      document.body.appendChild(wrapperDiv);
×
453
      wrapperDiv.style.position = 'relative';
×
454
      const div = document.createElement('div');
×
455
      div.style.position = 'absolute';
×
456
      div.style.left = '10px';
×
457
      div.style.bottom = '10px';
×
458
      div.style.width = '300px';
×
459
      div.style.background = 'white';
×
460
      if (this.canvas instanceof HTMLCanvasElement) {
×
461
        wrapperDiv.appendChild(this.canvas);
×
462
      }
463
      wrapperDiv.appendChild(div);
×
464
      const html = this.props.onAddHTML(div);
×
465
      if (html) {
×
466
        div.innerHTML = html;
×
467
      }
468
    }
469
  }
470

471
  _getSizeAndAspect(): {width: number; height: number; aspect: number} {
472
    if (!this.device) {
22!
473
      return {width: 1, height: 1, aspect: 1};
×
474
    }
475
    // Match projection setup to the actual render target dimensions, which may
476
    // differ from the CSS size when device-pixel scaling or backend clamping applies.
477
    const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize();
22✔
478
    const aspect = width > 0 && height > 0 ? width / height : 1;
22!
479

480
    return {width, height, aspect};
22✔
481
  }
482

483
  /** @deprecated Default viewport setup */
484
  _resizeViewport(): void {
485
    // TODO can we use canvas context to code this in a portable way?
486
    // @ts-expect-error Expose on canvasContext
487
    if (this.props.autoResizeViewport && this.device.gl) {
22!
488
      // @ts-expect-error Expose canvasContext
489
      this.device.gl.viewport(
×
490
        0,
491
        0,
492
        // @ts-expect-error Expose canvasContext
493
        this.device.gl.drawingBufferWidth,
494
        // @ts-expect-error Expose canvasContext
495
        this.device.gl.drawingBufferHeight
496
      );
497
    }
498
  }
499

500
  _beginFrameTimers(time?: number) {
501
    const now = time ?? (typeof performance !== 'undefined' ? performance.now() : Date.now());
15!
502
    if (this._lastFrameTime) {
15✔
503
      const frameTime = now - this._lastFrameTime;
7✔
504
      if (frameTime > 0) {
7!
505
        this.frameRate.addTime(frameTime);
7✔
506
      }
507
    }
508
    this._lastFrameTime = now;
15✔
509

510
    if (this.device?._isDebugGPUTimeEnabled()) {
15!
511
      this._consumeEncodedGpuTime();
15✔
512
    }
513

514
    this.cpuTime.timeStart();
15✔
515
  }
516

517
  _endFrameTimers() {
518
    if (this.device?._isDebugGPUTimeEnabled()) {
15!
519
      this._consumeEncodedGpuTime();
15✔
520
    }
521

522
    this.cpuTime.timeEnd();
15✔
523
    this._updateSharedStats();
15✔
524
  }
525

526
  _consumeEncodedGpuTime(): void {
527
    if (!this.device) {
30!
528
      return;
×
529
    }
530

531
    const gpuTimeMs = this.device.commandEncoder._gpuTimeMs;
30✔
532
    if (gpuTimeMs !== undefined) {
30!
533
      this.gpuTime.addTime(gpuTimeMs);
×
534
      this.device.commandEncoder._gpuTimeMs = undefined;
×
535
    }
536
  }
537

538
  _updateSharedStats(): void {
539
    if (this.stats === this.sharedStats) {
15!
540
      return;
×
541
    }
542

543
    for (const name of Object.keys(this.sharedStats.stats)) {
15✔
544
      if (!this.stats.stats[name]) {
62✔
545
        delete this.sharedStats.stats[name];
10✔
546
      }
547
    }
548

549
    this.stats.forEach(sourceStat => {
15✔
550
      const targetStat = this.sharedStats.get(sourceStat.name, sourceStat.type);
65✔
551
      targetStat.sampleSize = sourceStat.sampleSize;
65✔
552
      targetStat.time = sourceStat.time;
65✔
553
      targetStat.count = sourceStat.count;
65✔
554
      targetStat.samples = sourceStat.samples;
65✔
555
      targetStat.lastTiming = sourceStat.lastTiming;
65✔
556
      targetStat.lastSampleTime = sourceStat.lastSampleTime;
65✔
557
      targetStat.lastSampleCount = sourceStat.lastSampleCount;
65✔
558
      targetStat._count = sourceStat._count;
65✔
559
      targetStat._time = sourceStat._time;
65✔
560
      targetStat._samples = sourceStat._samples;
65✔
561
      targetStat._startTime = sourceStat._startTime;
65✔
562
      targetStat._timerPending = sourceStat._timerPending;
65✔
563
    });
564
  }
565

566
  // Event handling
567

568
  _startEventHandling() {
569
    if (this.canvas) {
7!
570
      this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
7✔
571
      this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
7✔
572
    }
573
  }
574

575
  _onMousemove(event: Event) {
576
    if (event instanceof MouseEvent) {
×
577
      this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY];
×
578
    }
579
  }
580

581
  _onMouseleave(event: Event) {
582
    this._getAnimationProps()._mousePosition = null;
×
583
  }
584
}
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