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

visgl / luma.gl / 28184273585

25 Jun 2026 04:15PM UTC coverage: 70.309%. First build
28184273585

push

github

web-flow
feat(engine) add deferred binding infrastructure (#2684)

9665 of 15585 branches covered (62.01%)

Branch coverage included in aggregate %.

48 of 55 new or added lines in 4 files covered. (87.27%)

19357 of 25693 relevant lines covered (75.34%)

4362.28 hits per line

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

68.53
/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;
133✔
15
const ANIMATION_LOOP_STATS = 'Animation Loop';
133✔
16

17
/** Experimental v10 callback shape for browser or custom animation frames. */
18
export type AnimationFrameCallback = (time: DOMHighResTimeStamp, animationFrame?: unknown) => void;
19

20
/** Experimental v10 work-in-progress animation frame source. */
21
export interface AnimationFrameProvider {
22
  requestAnimationFrame(callback: AnimationFrameCallback): number;
23
  cancelAnimationFrame(animationFrameId: number): void;
24
}
25

26
const defaultAnimationFrameProvider: AnimationFrameProvider = {
133✔
27
  requestAnimationFrame: callback => requestAnimationFramePolyfill(callback),
23✔
28
  cancelAnimationFrame: animationFrameId => cancelAnimationFramePolyfill(animationFrameId)
8✔
29
};
30

31
/** AnimationLoop properties */
32
export type AnimationLoopProps = {
33
  device: Device | Promise<Device>;
34

35
  onAddHTML?: (div: HTMLDivElement) => string; // innerHTML
36
  onInitialize?: (animationProps: AnimationProps) => Promise<unknown>;
37
  onRender?: (animationProps: AnimationProps) => unknown;
38
  onFinalize?: (animationProps: AnimationProps) => void;
39
  onError?: (reason: Error) => void;
40

41
  stats?: Stats;
42

43
  // view parameters - TODO move to CanvasContext?
44
  autoResizeViewport?: boolean;
45
  /** Experimental v10 work-in-progress frame source. */
46
  animationFrameProvider?: AnimationFrameProvider;
47
};
48

49
export type MutableAnimationLoopProps = {
50
  // view parameters
51
  autoResizeViewport?: boolean;
52
  /** Experimental v10 work-in-progress frame source. */
53
  animationFrameProvider?: AnimationFrameProvider;
54
};
55

56
/** Convenient animation loop */
57
export class AnimationLoop {
58
  static defaultAnimationLoopProps = {
133✔
59
    device: null!,
60

61
    onAddHTML: () => '',
×
62
    onInitialize: async () => null,
5✔
63
    onRender: () => {},
64
    onFinalize: () => {},
65
    onError: error => {
66
      // biome-ignore lint/suspicious/noConsole: default fallback error reporting for app loops.
67
      console.error(error);
×
68
    },
69

70
    stats: undefined!,
71

72
    // view parameters
73
    autoResizeViewport: false,
74
    animationFrameProvider: defaultAnimationFrameProvider
75
  } as const satisfies Readonly<Required<AnimationLoopProps>>;
76

77
  device: Device | null = null;
12✔
78
  canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
12✔
79

80
  props: Required<AnimationLoopProps>;
81
  animationProps: AnimationProps | null = null;
12✔
82
  timeline: Timeline | null = null;
12✔
83
  stats: Stats;
84
  sharedStats: Stats;
85
  cpuTime: Stat;
86
  gpuTime: Stat;
87
  frameRate: Stat;
88

89
  display: any;
90

91
  private _needsRedraw: string | false = 'initialized';
12✔
92

93
  _initialized: boolean = false;
12✔
94
  _running: boolean = false;
12✔
95
  _animationFrameId: any = null;
12✔
96
  _nextFramePromise: Promise<AnimationLoop> | null = null;
12✔
97
  _resolveNextFrame: ((animationLoop: AnimationLoop) => void) | null = null;
12✔
98
  _cpuStartTime: number = 0;
12✔
99
  _error: Error | null = null;
12✔
100
  _lastFrameTime: number = 0;
12✔
101

102
  /*
103
   * @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context
104
   */
105
  constructor(props: AnimationLoopProps) {
106
    this.props = {...AnimationLoop.defaultAnimationLoopProps, ...props};
12✔
107
    props = this.props;
12✔
108

109
    if (!props.device) {
12!
110
      throw new Error('No device provided');
×
111
    }
112

113
    // state
114
    this.stats = props.stats || new Stats({id: `animation-loop-${statIdCounter++}`});
12✔
115
    this.sharedStats = luma.stats.get(ANIMATION_LOOP_STATS);
12✔
116
    this.frameRate = this.stats.get('Frame Rate');
12✔
117
    this.frameRate.setSampleSize(1);
12✔
118
    this.cpuTime = this.stats.get('CPU Time');
12✔
119
    this.gpuTime = this.stats.get('GPU Time');
12✔
120

121
    this.setProps({
12✔
122
      autoResizeViewport: props.autoResizeViewport,
123
      animationFrameProvider: props.animationFrameProvider
124
    });
125

126
    // Bind methods
127
    this.start = this.start.bind(this);
12✔
128
    this.stop = this.stop.bind(this);
12✔
129

130
    this._onMousemove = this._onMousemove.bind(this);
12✔
131
    this._onMouseleave = this._onMouseleave.bind(this);
12✔
132
  }
133

134
  destroy(): void {
135
    this.stop();
5✔
136
    this._setDisplay(null);
5✔
137
    this.device?._disableDebugGPUTime();
5✔
138
  }
139

140
  /** @deprecated Use .destroy() */
141
  delete(): void {
142
    this.destroy();
×
143
  }
144

145
  reportError(error: Error): void {
146
    this.props.onError(error);
×
147
    this._error = error;
×
148
  }
149

150
  /** Flags this animation loop as needing redraw */
151
  setNeedsRedraw(reason: string): this {
152
    this._needsRedraw = this._needsRedraw || reason;
8✔
153
    return this;
8✔
154
  }
155

156
  /** Query redraw status. Clears the flag. */
157
  needsRedraw(): false | string {
158
    const reason = this._needsRedraw;
×
159
    this._needsRedraw = false;
×
160
    return reason;
×
161
  }
162

163
  setProps(props: MutableAnimationLoopProps): this {
164
    if ('autoResizeViewport' in props) {
12!
165
      this.props.autoResizeViewport = props.autoResizeViewport || false;
12✔
166
    }
167
    if ('animationFrameProvider' in props) {
12!
168
      const animationFrameProvider = props.animationFrameProvider || defaultAnimationFrameProvider;
12!
169
      if (animationFrameProvider !== this.props.animationFrameProvider) {
12!
NEW
170
        const animationFrameWasScheduled = this._animationFrameId !== null;
×
NEW
171
        if (animationFrameWasScheduled) {
×
NEW
172
          this._cancelAnimationFrame();
×
173
        }
NEW
174
        this.props.animationFrameProvider = animationFrameProvider;
×
NEW
175
        if (animationFrameWasScheduled) {
×
NEW
176
          this._requestAnimationFrame();
×
177
        }
178
      }
179
    }
180
    return this;
12✔
181
  }
182

183
  /** Starts a render loop if not already running */
184
  async start() {
185
    if (this._running) {
14✔
186
      return this;
2✔
187
    }
188
    this._running = true;
12✔
189

190
    try {
12✔
191
      let appContext;
192
      if (!this._initialized) {
12✔
193
        this._initialized = true;
11✔
194
        // Create the WebGL context
195
        await this._initDevice();
11✔
196
        this._initialize();
11✔
197
        if (!this._running) {
11✔
198
          return null;
1✔
199
        }
200

201
        // Note: onIntialize can return a promise (e.g. in case app needs to load resources)
202
        await this.props.onInitialize(this._getAnimationProps());
10✔
203
      }
204

205
      // check that we haven't been stopped
206
      if (!this._running) {
11✔
207
        return null;
2✔
208
      }
209

210
      // Start the loop
211
      if (appContext !== false) {
9!
212
        // cancel any pending renders to ensure only one loop can ever run
213
        this._cancelAnimationFrame();
9✔
214
        this._requestAnimationFrame();
9✔
215
      }
216

217
      return this;
9✔
218
    } catch (err: unknown) {
219
      const error = err instanceof Error ? err : new Error('Unknown error');
×
220
      this.props.onError(error);
×
221
      // this._running = false; // TODO
222
      throw error;
×
223
    }
224
  }
225

226
  /** Stops a render loop if already running, finalizing */
227
  stop() {
228
    // console.debug(`Stopping ${this.constructor.name}`);
229
    if (this._running) {
17✔
230
      // call callback
231
      // If stop is called immediately, we can end up in a state where props haven't been initialized...
232
      if (this.animationProps && !this._error) {
12✔
233
        this.props.onFinalize(this.animationProps);
11✔
234
      }
235

236
      this._cancelAnimationFrame();
12✔
237
      this._nextFramePromise = null;
12✔
238
      this._resolveNextFrame = null;
12✔
239
      this._running = false;
12✔
240
      this._lastFrameTime = 0;
12✔
241
    }
242
    return this;
17✔
243
  }
244

245
  /** Explicitly draw a frame */
246
  redraw(time?: number, animationFrame: unknown | null = null): this {
1✔
247
    if (this.device?.isLost || this._error) {
18!
248
      return this;
×
249
    }
250

251
    this._beginFrameTimers(time);
18✔
252

253
    this._setupFrame();
18✔
254
    if (this.animationProps) {
18!
255
      this.animationProps.animationFrame = animationFrame;
18✔
256
    }
257
    this._updateAnimationProps();
18✔
258

259
    this._renderFrame(this._getAnimationProps());
18✔
260

261
    // clear needsRedraw flag
262
    this._clearNeedsRedraw();
18✔
263

264
    if (this._resolveNextFrame) {
18✔
265
      this._resolveNextFrame(this);
8✔
266
      this._nextFramePromise = null;
8✔
267
      this._resolveNextFrame = null;
8✔
268
    }
269

270
    this._endFrameTimers();
18✔
271

272
    return this;
18✔
273
  }
274

275
  /** Add a timeline, it will be automatically updated by the animation loop. */
276
  attachTimeline(timeline: Timeline): Timeline {
277
    this.timeline = timeline;
×
278
    return this.timeline;
×
279
  }
280

281
  /** Remove a timeline */
282
  detachTimeline(): void {
283
    this.timeline = null;
×
284
  }
285

286
  /** Wait until a render completes */
287
  waitForRender(): Promise<AnimationLoop> {
288
    this.setNeedsRedraw('waitForRender');
8✔
289

290
    if (!this._nextFramePromise) {
8!
291
      this._nextFramePromise = new Promise(resolve => {
8✔
292
        this._resolveNextFrame = resolve;
8✔
293
      });
294
    }
295
    return this._nextFramePromise;
8✔
296
  }
297

298
  /** TODO - should use device.deviceContext */
299
  async toDataURL(): Promise<string> {
300
    this.setNeedsRedraw('toDataURL');
×
301
    await this.waitForRender();
×
302
    if (this.canvas instanceof HTMLCanvasElement) {
×
303
      return this.canvas.toDataURL();
×
304
    }
305
    throw new Error('OffscreenCanvas');
×
306
  }
307

308
  // PRIVATE METHODS
309

310
  _initialize(): void {
311
    this._startEventHandling();
11✔
312

313
    // Initialize the callback data
314
    this._initializeAnimationProps();
11✔
315
    this._updateAnimationProps();
11✔
316

317
    // Default viewport setup, in case onInitialize wants to render
318
    this._resizeViewport();
11✔
319

320
    this.device?._enableDebugGPUTime();
11✔
321
  }
322

323
  _setDisplay(display: any): void {
324
    if (this.display) {
5!
325
      this.display.destroy();
×
326
      this.display.animationLoop = null;
×
327
    }
328

329
    // store animation loop on the display
330
    if (display) {
5!
331
      display.animationLoop = this;
×
332
    }
333

334
    this.display = display;
5✔
335
  }
336

337
  _requestAnimationFrame(): void {
338
    if (!this._running) {
26✔
339
      return;
2✔
340
    }
341

342
    this._animationFrameId = this.props.animationFrameProvider.requestAnimationFrame(
24✔
343
      this._animationFrame.bind(this)
344
    );
345
  }
346

347
  _cancelAnimationFrame(): void {
348
    if (this._animationFrameId === null) {
21✔
349
      return;
12✔
350
    }
351

352
    this.props.animationFrameProvider.cancelAnimationFrame(this._animationFrameId);
9✔
353
    this._animationFrameId = null;
9✔
354
  }
355

356
  _animationFrame(time: number, animationFrame?: unknown): void {
357
    if (!this._running) {
17!
358
      return;
×
359
    }
360
    this.redraw(time, animationFrame ?? null);
17✔
361
    this._requestAnimationFrame();
17✔
362
  }
363

364
  // Called on each frame, can be overridden to call onRender multiple times
365
  // to support e.g. stereoscopic rendering
366
  _renderFrame(animationProps: AnimationProps): void {
367
    // Allow e.g. VR display to render multiple frames.
368
    if (this.display) {
18!
369
      this.display._renderFrame(animationProps);
×
370
      return;
×
371
    }
372

373
    // call callback
374
    this.props.onRender(this._getAnimationProps());
18✔
375
    // end callback
376

377
    // Submit commands (necessary on WebGPU).
378
    if (this.device) {
18!
379
      this.device.submit();
18✔
380
    }
381
  }
382

383
  _clearNeedsRedraw(): void {
384
    this._needsRedraw = false;
18✔
385
  }
386

387
  _setupFrame(): void {
388
    this._resizeViewport();
18✔
389
  }
390

391
  // Initialize the  object that will be passed to app callbacks
392
  _initializeAnimationProps(): void {
393
    const canvasContext = this.device?.getDefaultCanvasContext();
11✔
394
    if (!this.device || !canvasContext) {
11!
395
      throw new Error('loop');
×
396
    }
397

398
    const canvas = canvasContext?.canvas;
11✔
399
    const useDevicePixels = canvasContext.props.useDevicePixels;
11✔
400

401
    this.animationProps = {
11✔
402
      animationLoop: this,
403

404
      device: this.device,
405
      canvasContext,
406
      canvas,
407
      // @ts-expect-error Deprecated
408
      useDevicePixels,
409

410
      timeline: this.timeline,
411

412
      needsRedraw: false,
413

414
      // Placeholders
415
      width: 1,
416
      height: 1,
417
      aspect: 1,
418

419
      // Animation props
420
      time: 0,
421
      startTime: Date.now(),
422
      engineTime: 0,
423
      tick: 0,
424
      tock: 0,
425

426
      // Experimental
427
      animationFrame: null,
428
      _mousePosition: null // Event props
429
    };
430
  }
431

432
  _getAnimationProps(): AnimationProps {
433
    if (!this.animationProps) {
46!
434
      throw new Error('animationProps');
×
435
    }
436
    return this.animationProps;
46✔
437
  }
438

439
  // Update the context object that will be passed to app callbacks
440
  _updateAnimationProps(): void {
441
    if (!this.animationProps) {
29!
442
      return;
×
443
    }
444

445
    // Can this be replaced with canvas context?
446
    const {width, height, aspect} = this._getSizeAndAspect();
29✔
447
    if (width !== this.animationProps.width || height !== this.animationProps.height) {
29!
448
      this.setNeedsRedraw('drawing buffer resized');
×
449
    }
450
    if (aspect !== this.animationProps.aspect) {
29!
451
      this.setNeedsRedraw('drawing buffer aspect changed');
×
452
    }
453

454
    this.animationProps.width = width;
29✔
455
    this.animationProps.height = height;
29✔
456
    this.animationProps.aspect = aspect;
29✔
457

458
    this.animationProps.needsRedraw = this._needsRedraw;
29✔
459

460
    // Update time properties
461
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
29✔
462

463
    if (this.timeline) {
29!
464
      this.timeline.update(this.animationProps.engineTime);
×
465
    }
466

467
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
29✔
468
    this.animationProps.tock++;
29✔
469

470
    // For back compatibility
471
    this.animationProps.time = this.timeline
29!
472
      ? this.timeline.getTime()
473
      : this.animationProps.engineTime;
474
  }
475

476
  /** Wait for supplied device */
477
  async _initDevice() {
478
    this.device = await this.props.device;
11✔
479
    if (!this.device) {
11!
480
      throw new Error('No device provided');
×
481
    }
482
    this.canvas = this.device.getDefaultCanvasContext().canvas || null;
11!
483
    // this._createInfoDiv();
484
  }
485

486
  _createInfoDiv(): void {
487
    if (this.canvas && this.props.onAddHTML) {
×
488
      const wrapperDiv = document.createElement('div');
×
489
      document.body.appendChild(wrapperDiv);
×
490
      wrapperDiv.style.position = 'relative';
×
491
      const div = document.createElement('div');
×
492
      div.style.position = 'absolute';
×
493
      div.style.left = '10px';
×
494
      div.style.bottom = '10px';
×
495
      div.style.width = '300px';
×
496
      div.style.background = 'white';
×
497
      if (this.canvas instanceof HTMLCanvasElement) {
×
498
        wrapperDiv.appendChild(this.canvas);
×
499
      }
500
      wrapperDiv.appendChild(div);
×
501
      const html = this.props.onAddHTML(div);
×
502
      if (html) {
×
503
        div.innerHTML = html;
×
504
      }
505
    }
506
  }
507

508
  _getSizeAndAspect(): {width: number; height: number; aspect: number} {
509
    if (!this.device) {
29!
510
      return {width: 1, height: 1, aspect: 1};
×
511
    }
512
    // Match projection setup to the actual render target dimensions, which may
513
    // differ from the CSS size when device-pixel scaling or backend clamping applies.
514
    const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize();
29✔
515
    const aspect = width > 0 && height > 0 ? width / height : 1;
29!
516

517
    return {width, height, aspect};
29✔
518
  }
519

520
  /** @deprecated Default viewport setup */
521
  _resizeViewport(): void {
522
    // TODO can we use canvas context to code this in a portable way?
523
    // @ts-expect-error Expose on canvasContext
524
    if (this.props.autoResizeViewport && this.device.gl) {
29!
525
      // @ts-expect-error Expose canvasContext
526
      this.device.gl.viewport(
×
527
        0,
528
        0,
529
        // @ts-expect-error Expose canvasContext
530
        this.device.gl.drawingBufferWidth,
531
        // @ts-expect-error Expose canvasContext
532
        this.device.gl.drawingBufferHeight
533
      );
534
    }
535
  }
536

537
  _beginFrameTimers(time?: number) {
538
    const now = time ?? (typeof performance !== 'undefined' ? performance.now() : Date.now());
18!
539
    if (this._lastFrameTime) {
18✔
540
      const frameTime = now - this._lastFrameTime;
8✔
541
      if (frameTime > 0) {
8!
542
        this.frameRate.addTime(frameTime);
8✔
543
      }
544
    }
545
    this._lastFrameTime = now;
18✔
546

547
    if (this.device?._isDebugGPUTimeEnabled()) {
18!
548
      this._consumeEncodedGpuTime();
18✔
549
    }
550

551
    this.cpuTime.timeStart();
18✔
552
  }
553

554
  _endFrameTimers() {
555
    if (this.device?._isDebugGPUTimeEnabled()) {
18!
556
      this._consumeEncodedGpuTime();
18✔
557
    }
558

559
    this.cpuTime.timeEnd();
18✔
560
    this._updateSharedStats();
18✔
561
  }
562

563
  _consumeEncodedGpuTime(): void {
564
    if (!this.device) {
36!
565
      return;
×
566
    }
567

568
    const gpuTimeMs = this.device.commandEncoder._gpuTimeMs;
36✔
569
    if (gpuTimeMs !== undefined) {
36!
570
      this.gpuTime.addTime(gpuTimeMs);
×
571
      this.device.commandEncoder._gpuTimeMs = undefined;
×
572
    }
573
  }
574

575
  _updateSharedStats(): void {
576
    if (this.stats === this.sharedStats) {
18!
577
      return;
×
578
    }
579

580
    for (const name of Object.keys(this.sharedStats.stats)) {
18✔
581
      if (!this.stats.stats[name]) {
71✔
582
        delete this.sharedStats.stats[name];
10✔
583
      }
584
    }
585

586
    this.stats.forEach(sourceStat => {
18✔
587
      const targetStat = this.sharedStats.get(sourceStat.name, sourceStat.type);
74✔
588
      targetStat.sampleSize = sourceStat.sampleSize;
74✔
589
      targetStat.time = sourceStat.time;
74✔
590
      targetStat.count = sourceStat.count;
74✔
591
      targetStat.samples = sourceStat.samples;
74✔
592
      targetStat.lastTiming = sourceStat.lastTiming;
74✔
593
      targetStat.lastSampleTime = sourceStat.lastSampleTime;
74✔
594
      targetStat.lastSampleCount = sourceStat.lastSampleCount;
74✔
595
      targetStat._count = sourceStat._count;
74✔
596
      targetStat._time = sourceStat._time;
74✔
597
      targetStat._samples = sourceStat._samples;
74✔
598
      targetStat._startTime = sourceStat._startTime;
74✔
599
      targetStat._timerPending = sourceStat._timerPending;
74✔
600
    });
601
  }
602

603
  // Event handling
604

605
  _startEventHandling() {
606
    if (this.canvas) {
11!
607
      this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
11✔
608
      this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
11✔
609
    }
610
  }
611

612
  _onMousemove(event: Event) {
613
    if (event instanceof MouseEvent) {
×
614
      this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY];
×
615
    }
616
  }
617

618
  _onMouseleave(event: Event) {
619
    this._getAnimationProps()._mousePosition = null;
×
620
  }
621
}
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