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

visgl / luma.gl / 24003098659

05 Apr 2026 02:02PM UTC coverage: 74.232% (-0.005%) from 74.237%
24003098659

push

github

web-flow
chore: Typecheck examples in CI (#2593)

5177 of 7888 branches covered (65.63%)

Branch coverage included in aggregate %.

6 of 13 new or added lines in 10 files covered. (46.15%)

11707 of 14857 relevant lines covered (78.8%)

777.19 hits per line

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

69.97
/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;
71✔
15
const ANIMATION_LOOP_STATS = 'Animation Loop';
71✔
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 = {
71✔
41
    device: null!,
42

43
    onAddHTML: () => '',
×
44
    onInitialize: async () => null,
4✔
45
    onRender: () => {},
46
    onFinalize: () => {},
47
    onError: error => {
48
      // biome-ignore lint/suspicious/noConsole: default fallback error reporting for app loops.
NEW
49
      console.error(error);
×
50
    },
51

52
    stats: undefined!,
53

54
    // view parameters
55
    autoResizeViewport: false
56
  } as const satisfies Readonly<Required<AnimationLoopProps>>;
57

58
  device: Device | null = null;
10✔
59
  canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
10✔
60

61
  props: Required<AnimationLoopProps>;
62
  animationProps: AnimationProps | null = null;
10✔
63
  timeline: Timeline | null = null;
10✔
64
  stats: Stats;
65
  sharedStats: Stats;
66
  cpuTime: Stat;
67
  gpuTime: Stat;
68
  frameRate: Stat;
69

70
  display: any;
71

72
  private _needsRedraw: string | false = 'initialized';
10✔
73

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

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

90
    if (!props.device) {
10!
91
      throw new Error('No device provided');
×
92
    }
93

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

102
    this.setProps({autoResizeViewport: props.autoResizeViewport});
10✔
103

104
    // Bind methods
105
    this.start = this.start.bind(this);
10✔
106
    this.stop = this.stop.bind(this);
10✔
107

108
    this._onMousemove = this._onMousemove.bind(this);
10✔
109
    this._onMouseleave = this._onMouseleave.bind(this);
10✔
110
  }
111

112
  destroy(): void {
113
    this.stop();
3✔
114
    this._setDisplay(null);
3✔
115
    this.device?._disableDebugGPUTime();
3✔
116
  }
117

118
  /** @deprecated Use .destroy() */
119
  delete(): void {
120
    this.destroy();
×
121
  }
122

123
  reportError(error: Error): void {
124
    this.props.onError(error);
×
125
    this._error = error;
×
126
  }
127

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

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

141
  setProps(props: MutableAnimationLoopProps): this {
142
    if ('autoResizeViewport' in props) {
10!
143
      this.props.autoResizeViewport = props.autoResizeViewport || false;
10✔
144
    }
145
    return this;
10✔
146
  }
147

148
  /** Starts a render loop if not already running */
149
  async start() {
150
    if (this._running) {
12✔
151
      return this;
2✔
152
    }
153
    this._running = true;
10✔
154

155
    try {
10✔
156
      let appContext;
157
      if (!this._initialized) {
10✔
158
        this._initialized = true;
9✔
159
        // Create the WebGL context
160
        await this._initDevice();
9✔
161
        this._initialize();
9✔
162
        if (!this._running) {
9✔
163
          return null;
1✔
164
        }
165

166
        // Note: onIntialize can return a promise (e.g. in case app needs to load resources)
167
        await this.props.onInitialize(this._getAnimationProps());
8✔
168
      }
169

170
      // check that we haven't been stopped
171
      if (!this._running) {
9✔
172
        return null;
1✔
173
      }
174

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

182
      return this;
8✔
183
    } catch (err: unknown) {
184
      const error = err instanceof Error ? err : new Error('Unknown error');
×
185
      this.props.onError(error);
×
186
      // this._running = false; // TODO
187
      throw error;
×
188
    }
189
  }
190

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

201
      this._cancelAnimationFrame();
10✔
202
      this._nextFramePromise = null;
10✔
203
      this._resolveNextFrame = null;
10✔
204
      this._running = false;
10✔
205
      this._lastFrameTime = 0;
10✔
206
    }
207
    return this;
13✔
208
  }
209

210
  /** Explicitly draw a frame */
211
  redraw(time?: number): this {
212
    if (this.device?.isLost || this._error) {
17!
213
      return this;
×
214
    }
215

216
    this._beginFrameTimers(time);
17✔
217

218
    this._setupFrame();
17✔
219
    this._updateAnimationProps();
17✔
220

221
    this._renderFrame(this._getAnimationProps());
17✔
222

223
    // clear needsRedraw flag
224
    this._clearNeedsRedraw();
17✔
225

226
    if (this._resolveNextFrame) {
17✔
227
      this._resolveNextFrame(this);
8✔
228
      this._nextFramePromise = null;
8✔
229
      this._resolveNextFrame = null;
8✔
230
    }
231

232
    this._endFrameTimers();
17✔
233

234
    return this;
17✔
235
  }
236

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

243
  /** Remove a timeline */
244
  detachTimeline(): void {
245
    this.timeline = null;
×
246
  }
247

248
  /** Wait until a render completes */
249
  waitForRender(): Promise<AnimationLoop> {
250
    this.setNeedsRedraw('waitForRender');
8✔
251

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

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

270
  // PRIVATE METHODS
271

272
  _initialize(): void {
273
    this._startEventHandling();
9✔
274

275
    // Initialize the callback data
276
    this._initializeAnimationProps();
9✔
277
    this._updateAnimationProps();
9✔
278

279
    // Default viewport setup, in case onInitialize wants to render
280
    this._resizeViewport();
9✔
281

282
    this.device?._enableDebugGPUTime();
9✔
283
  }
284

285
  _setDisplay(display: any): void {
286
    if (this.display) {
3!
287
      this.display.destroy();
×
288
      this.display.animationLoop = null;
×
289
    }
290

291
    // store animation loop on the display
292
    if (display) {
3!
293
      display.animationLoop = this;
×
294
    }
295

296
    this.display = display;
3✔
297
  }
298

299
  _requestAnimationFrame(): void {
300
    if (!this._running) {
24✔
301
      return;
1✔
302
    }
303

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

313
  _cancelAnimationFrame(): void {
314
    if (this._animationFrameId === null) {
18✔
315
      return;
10✔
316
    }
317

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

328
  _animationFrame(time: number): void {
329
    if (!this._running) {
16!
330
      return;
×
331
    }
332
    this.redraw(time);
16✔
333
    this._requestAnimationFrame();
16✔
334
  }
335

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

345
    // call callback
346
    this.props.onRender(this._getAnimationProps());
17✔
347
    // end callback
348

349
    // Submit commands (necessary on WebGPU)
350
    this.device?.submit();
17✔
351
  }
352

353
  _clearNeedsRedraw(): void {
354
    this._needsRedraw = false;
17✔
355
  }
356

357
  _setupFrame(): void {
358
    this._resizeViewport();
17✔
359
  }
360

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

368
    const canvas = canvasContext?.canvas;
9✔
369
    const useDevicePixels = canvasContext.props.useDevicePixels;
9✔
370

371
    this.animationProps = {
9✔
372
      animationLoop: this,
373

374
      device: this.device,
375
      canvasContext,
376
      canvas,
377
      // @ts-expect-error Deprecated
378
      useDevicePixels,
379

380
      timeline: this.timeline,
381

382
      needsRedraw: false,
383

384
      // Placeholders
385
      width: 1,
386
      height: 1,
387
      aspect: 1,
388

389
      // Animation props
390
      time: 0,
391
      startTime: Date.now(),
392
      engineTime: 0,
393
      tick: 0,
394
      tock: 0,
395

396
      // Experimental
397
      _mousePosition: null // Event props
398
    };
399
  }
400

401
  _getAnimationProps(): AnimationProps {
402
    if (!this.animationProps) {
42!
403
      throw new Error('animationProps');
×
404
    }
405
    return this.animationProps;
42✔
406
  }
407

408
  // Update the context object that will be passed to app callbacks
409
  _updateAnimationProps(): void {
410
    if (!this.animationProps) {
26!
411
      return;
×
412
    }
413

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

423
    this.animationProps.width = width;
26✔
424
    this.animationProps.height = height;
26✔
425
    this.animationProps.aspect = aspect;
26✔
426

427
    this.animationProps.needsRedraw = this._needsRedraw;
26✔
428

429
    // Update time properties
430
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
26✔
431

432
    if (this.timeline) {
26!
433
      this.timeline.update(this.animationProps.engineTime);
×
434
    }
435

436
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
26✔
437
    this.animationProps.tock++;
26✔
438

439
    // For back compatibility
440
    this.animationProps.time = this.timeline
26!
441
      ? this.timeline.getTime()
442
      : this.animationProps.engineTime;
443
  }
444

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

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

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

486
    return {width, height, aspect};
26✔
487
  }
488

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

506
  _beginFrameTimers(time?: number) {
507
    const now = time ?? (typeof performance !== 'undefined' ? performance.now() : Date.now());
17!
508
    if (this._lastFrameTime) {
17✔
509
      const frameTime = now - this._lastFrameTime;
8✔
510
      if (frameTime > 0) {
8!
511
        this.frameRate.addTime(frameTime);
8✔
512
      }
513
    }
514
    this._lastFrameTime = now;
17✔
515

516
    if (this.device?._isDebugGPUTimeEnabled()) {
17!
517
      this._consumeEncodedGpuTime();
17✔
518
    }
519

520
    this.cpuTime.timeStart();
17✔
521
  }
522

523
  _endFrameTimers() {
524
    if (this.device?._isDebugGPUTimeEnabled()) {
17!
525
      this._consumeEncodedGpuTime();
17✔
526
    }
527

528
    this.cpuTime.timeEnd();
17✔
529
    this._updateSharedStats();
17✔
530
  }
531

532
  _consumeEncodedGpuTime(): void {
533
    if (!this.device) {
34!
534
      return;
×
535
    }
536

537
    const gpuTimeMs = this.device.commandEncoder._gpuTimeMs;
34✔
538
    if (gpuTimeMs !== undefined) {
34!
539
      this.gpuTime.addTime(gpuTimeMs);
×
540
      this.device.commandEncoder._gpuTimeMs = undefined;
×
541
    }
542
  }
543

544
  _updateSharedStats(): void {
545
    if (this.stats === this.sharedStats) {
17!
546
      return;
×
547
    }
548

549
    for (const name of Object.keys(this.sharedStats.stats)) {
17✔
550
      if (!this.stats.stats[name]) {
68✔
551
        delete this.sharedStats.stats[name];
10✔
552
      }
553
    }
554

555
    this.stats.forEach(sourceStat => {
17✔
556
      const targetStat = this.sharedStats.get(sourceStat.name, sourceStat.type);
71✔
557
      targetStat.sampleSize = sourceStat.sampleSize;
71✔
558
      targetStat.time = sourceStat.time;
71✔
559
      targetStat.count = sourceStat.count;
71✔
560
      targetStat.samples = sourceStat.samples;
71✔
561
      targetStat.lastTiming = sourceStat.lastTiming;
71✔
562
      targetStat.lastSampleTime = sourceStat.lastSampleTime;
71✔
563
      targetStat.lastSampleCount = sourceStat.lastSampleCount;
71✔
564
      targetStat._count = sourceStat._count;
71✔
565
      targetStat._time = sourceStat._time;
71✔
566
      targetStat._samples = sourceStat._samples;
71✔
567
      targetStat._startTime = sourceStat._startTime;
71✔
568
      targetStat._timerPending = sourceStat._timerPending;
71✔
569
    });
570
  }
571

572
  // Event handling
573

574
  _startEventHandling() {
575
    if (this.canvas) {
9!
576
      this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
9✔
577
      this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
9✔
578
    }
579
  }
580

581
  _onMousemove(event: Event) {
582
    if (event instanceof MouseEvent) {
×
583
      this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY];
×
584
    }
585
  }
586

587
  _onMouseleave(event: Event) {
588
    this._getAnimationProps()._mousePosition = null;
×
589
  }
590
}
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