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

visgl / luma.gl / 13226235795

09 Feb 2025 01:36PM UTC coverage: 75.257% (-0.04%) from 75.296%
13226235795

push

github

web-flow
website: fixes (#2328)

2021 of 2649 branches covered (76.29%)

Branch coverage included in aggregate %.

181 of 216 new or added lines in 8 files covered. (83.8%)

2 existing lines in 1 file now uncovered.

26329 of 35022 relevant lines covered (75.18%)

50.22 hits per line

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

77.44
/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} 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
  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
  // _gpuTimeQuery: Query | null = null;
1✔
78

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

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

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

6✔
96
    this.setProps({autoResizeViewport: props.autoResizeViewport});
6✔
97

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

6✔
102
    this._onMousemove = this._onMousemove.bind(this);
6✔
103
    this._onMouseleave = this._onMouseleave.bind(this);
6✔
104
  }
6✔
105

1✔
106
  destroy(): void {
1✔
107
    this.stop();
×
108
    this._setDisplay(null);
×
109
  }
×
110

1✔
111
  /** @deprecated Use .destroy() */
1✔
112
  delete(): void {
1✔
113
    this.destroy();
×
114
  }
×
115

1✔
116
  setError(error: Error): void {
1✔
117
    this.props.onError(error);
×
118
    this._error = Error();
×
119
    const canvas = this.device?.getDefaultCanvasContext().canvas;
×
120
    if (canvas instanceof HTMLCanvasElement) {
×
NEW
121
      canvas.style.overflow = 'visible';
×
NEW
122
      let errorDiv = document.getElementById('animation-loop-error');
×
NEW
123
      errorDiv?.remove();
×
NEW
124
      errorDiv = document.createElement('h1');
×
NEW
125
      errorDiv.id = 'animation-loop-error';
×
126
      errorDiv.innerHTML = error.message;
×
127
      errorDiv.style.position = 'absolute';
×
NEW
128
      errorDiv.style.top = '10px'; // left: 50%; transform: translate(-50%, -50%);';
×
129
      errorDiv.style.left = '10px';
×
130
      errorDiv.style.color = 'black';
×
131
      errorDiv.style.backgroundColor = 'red';
×
NEW
132
      canvas.parentElement?.appendChild(errorDiv);
×
133
      // canvas.style.position = 'absolute';
×
134
    }
×
135
  }
×
136

1✔
137
  clearError(): void {
1✔
NEW
138
    this._error = null;
×
NEW
139
    const errorDiv = document.getElementById('animation-loop-error');
×
NEW
140
    if (errorDiv) {
×
NEW
141
      errorDiv.remove();
×
NEW
142
    }
×
NEW
143
  }
×
144

1✔
145
  /** Flags this animation loop as needing redraw */
1✔
146
  setNeedsRedraw(reason: string): this {
1✔
147
    this.needsRedraw = this.needsRedraw || reason;
4✔
148
    return this;
4✔
149
  }
4✔
150

1✔
151
  setProps(props: MutableAnimationLoopProps): this {
1✔
152
    if ('autoResizeViewport' in props) {
6✔
153
      this.props.autoResizeViewport = props.autoResizeViewport || false;
6✔
154
    }
6✔
155
    return this;
6✔
156
  }
6✔
157

1✔
158
  /** Starts a render loop if not already running */
1✔
159
  async start() {
1✔
160
    if (this._running) {
8✔
161
      return this;
2✔
162
    }
2✔
163
    this._running = true;
6✔
164

6✔
165
    try {
6✔
166
      let appContext;
6✔
167
      if (!this._initialized) {
8✔
168
        this._initialized = true;
5✔
169
        // Create the WebGL context
5✔
170
        await this._initDevice();
5✔
171
        this._initialize();
5✔
172

5✔
173
        // Note: onIntialize can return a promise (e.g. in case app needs to load resources)
5✔
174
        await this.props.onInitialize(this._getAnimationProps());
5✔
175
      }
5✔
176

6✔
177
      // check that we haven't been stopped
6✔
178
      if (!this._running) {
8✔
179
        return null;
1✔
180
      }
1✔
181

5✔
182
      // Start the loop
5✔
183
      if (appContext !== false) {
5✔
184
        // cancel any pending renders to ensure only one loop can ever run
5✔
185
        this._cancelAnimationFrame();
5✔
186
        this._requestAnimationFrame();
5✔
187
      }
5✔
188

5✔
189
      return this;
5✔
190
    } catch (err: unknown) {
8!
191
      const error = err instanceof Error ? err : new Error('Unknown error');
×
192
      this.props.onError(error);
×
193
      // this._running = false; // TODO
×
194
      throw error;
×
195
    }
×
196
  }
8✔
197

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

6✔
208
      this._cancelAnimationFrame();
6✔
209
      this._nextFramePromise = null;
6✔
210
      this._resolveNextFrame = null;
6✔
211
      this._running = false;
6✔
212
    }
6✔
213
    return this;
6✔
214
  }
6✔
215

1✔
216
  /** Explicitly draw a frame */
1✔
217
  redraw(): this {
1✔
218
    if (this.device?.isLost || this._error) {
11!
219
      return this;
×
220
    }
×
221

11✔
222
    this._beginFrameTimers();
11✔
223

11✔
224
    this._setupFrame();
11✔
225
    this._updateAnimationProps();
11✔
226

11✔
227
    this._renderFrame(this._getAnimationProps());
11✔
228

11✔
229
    // clear needsRedraw flag
11✔
230
    this._clearNeedsRedraw();
11✔
231

11✔
232
    if (this._resolveNextFrame) {
11✔
233
      this._resolveNextFrame(this);
4✔
234
      this._nextFramePromise = null;
4✔
235
      this._resolveNextFrame = null;
4✔
236
    }
4✔
237

11✔
238
    this._endFrameTimers();
11✔
239

11✔
240
    return this;
11✔
241
  }
11✔
242

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

1✔
249
  /** Remove a timeline */
1✔
250
  detachTimeline(): void {
1✔
251
    this.timeline = null;
×
252
  }
×
253

1✔
254
  /** Wait until a render completes */
1✔
255
  waitForRender(): Promise<AnimationLoop> {
1✔
256
    this.setNeedsRedraw('waitForRender');
4✔
257

4✔
258
    if (!this._nextFramePromise) {
4✔
259
      this._nextFramePromise = new Promise(resolve => {
4✔
260
        this._resolveNextFrame = resolve;
4✔
261
      });
4✔
262
    }
4✔
263
    return this._nextFramePromise;
4✔
264
  }
4✔
265

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

1✔
276
  // PRIVATE METHODS
1✔
277

1✔
278
  _initialize(): void {
1✔
279
    this._startEventHandling();
5✔
280

5✔
281
    // Initialize the callback data
5✔
282
    this._initializeAnimationProps();
5✔
283
    this._updateAnimationProps();
5✔
284

5✔
285
    // Default viewport setup, in case onInitialize wants to render
5✔
286
    this._resizeViewport();
5✔
287

5✔
288
    // this._gpuTimeQuery = Query.isSupported(this.gl, ['timers']) ? new Query(this.gl) : null;
5✔
289
  }
5✔
290

1✔
291
  _setDisplay(display: any): void {
1✔
292
    if (this.display) {
×
293
      this.display.destroy();
×
294
      this.display.animationLoop = null;
×
295
    }
×
296

×
297
    // store animation loop on the display
×
298
    if (display) {
×
299
      display.animationLoop = this;
×
300
    }
×
301

×
302
    this.display = display;
×
303
  }
×
304

1✔
305
  _requestAnimationFrame(): void {
1✔
306
    if (!this._running) {
15✔
307
      return;
1✔
308
    }
1✔
309

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

1✔
319
  _cancelAnimationFrame(): void {
1✔
320
    if (this._animationFrameId === null) {
11✔
321
      return;
6✔
322
    }
6✔
323

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

1✔
334
  _animationFrame(): void {
1✔
335
    if (!this._running) {
10!
336
      return;
×
337
    }
×
338
    this.redraw();
10✔
339
    this._requestAnimationFrame();
10✔
340
  }
10✔
341

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

11✔
351
    // call callback
11✔
352
    this.props.onRender(this._getAnimationProps());
11✔
353
    // end callback
11✔
354

11✔
355
    // Submit commands (necessary on WebGPU)
11✔
356
    this.device?.submit();
11✔
357
  }
11✔
358

1✔
359
  _clearNeedsRedraw(): void {
1✔
360
    this.needsRedraw = false;
11✔
361
  }
11✔
362

1✔
363
  _setupFrame(): void {
1✔
364
    this._resizeViewport();
11✔
365
  }
11✔
366

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

5✔
374
    const canvas = canvasContext?.canvas;
5✔
375
    const useDevicePixels = canvasContext.props.useDevicePixels;
5✔
376

5✔
377
    this.animationProps = {
5✔
378
      animationLoop: this,
5✔
379

5✔
380
      device: this.device,
5✔
381
      canvasContext,
5✔
382
      canvas,
5✔
383
      // @ts-expect-error Deprecated
5✔
384
      useDevicePixels,
5✔
385

5✔
386
      timeline: this.timeline,
5✔
387

5✔
388
      needsRedraw: false,
5✔
389

5✔
390
      // Placeholders
5✔
391
      width: 1,
5✔
392
      height: 1,
5✔
393
      aspect: 1,
5✔
394

5✔
395
      // Animation props
5✔
396
      time: 0,
5✔
397
      startTime: Date.now(),
5✔
398
      engineTime: 0,
5✔
399
      tick: 0,
5✔
400
      tock: 0,
5✔
401

5✔
402
      // Experimental
5✔
403
      _mousePosition: null // Event props
5✔
404
    };
5✔
405
  }
5✔
406

1✔
407
  _getAnimationProps(): AnimationProps {
1✔
408
    if (!this.animationProps) {
27!
409
      throw new Error('animationProps');
×
410
    }
×
411
    return this.animationProps;
27✔
412
  }
27✔
413

1✔
414
  // Update the context object that will be passed to app callbacks
1✔
415
  _updateAnimationProps(): void {
1✔
416
    if (!this.animationProps) {
16!
417
      return;
×
418
    }
×
419

16✔
420
    // Can this be replaced with canvas context?
16✔
421
    const {width, height, aspect} = this._getSizeAndAspect();
16✔
422
    if (width !== this.animationProps.width || height !== this.animationProps.height) {
16!
423
      this.setNeedsRedraw('drawing buffer resized');
×
424
    }
×
425
    if (aspect !== this.animationProps.aspect) {
16!
426
      this.setNeedsRedraw('drawing buffer aspect changed');
×
427
    }
×
428

16✔
429
    this.animationProps.width = width;
16✔
430
    this.animationProps.height = height;
16✔
431
    this.animationProps.aspect = aspect;
16✔
432

16✔
433
    this.animationProps.needsRedraw = this.needsRedraw;
16✔
434

16✔
435
    // Update time properties
16✔
436
    this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
16✔
437

16✔
438
    if (this.timeline) {
16!
439
      this.timeline.update(this.animationProps.engineTime);
×
440
    }
×
441

16✔
442
    this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
16✔
443
    this.animationProps.tock++;
16✔
444

16✔
445
    // For back compatibility
16✔
446
    this.animationProps.time = this.timeline
16!
447
      ? this.timeline.getTime()
×
448
      : this.animationProps.engineTime;
16✔
449
  }
16✔
450

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

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

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

16✔
490
    // https://webglfundamentals.org/webgl/lessons/webgl-anti-patterns.html
16✔
491
    let aspect = 1;
16✔
492
    const canvas = this.device?.getDefaultCanvasContext().canvas;
16✔
493

16✔
494
    // @ts-expect-error
16✔
495
    if (canvas && canvas.clientHeight) {
16✔
496
      // @ts-expect-error
16✔
497
      aspect = canvas.clientWidth / canvas.clientHeight;
16✔
498
    } else if (width > 0 && height > 0) {
16!
499
      aspect = width / height;
×
500
    }
×
501

16✔
502
    return {width, height, aspect};
16✔
503
  }
16✔
504

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

1✔
522
  _beginFrameTimers() {
1✔
523
    this.frameRate.timeEnd();
11✔
524
    this.frameRate.timeStart();
11✔
525

11✔
526
    // Check if timer for last frame has completed.
11✔
527
    // GPU timer results are never available in the same
11✔
528
    // frame they are captured.
11✔
529
    // if (
11✔
530
    //   this._gpuTimeQuery &&
11✔
531
    //   this._gpuTimeQuery.isResultAvailable() &&
11✔
532
    //   !this._gpuTimeQuery.isTimerDisjoint()
11✔
533
    // ) {
11✔
534
    //   this.stats.get('GPU Time').addTime(this._gpuTimeQuery.getTimerMilliseconds());
11✔
535
    // }
11✔
536

11✔
537
    // if (this._gpuTimeQuery) {
11✔
538
    //   // GPU time query start
11✔
539
    //   this._gpuTimeQuery.beginTimeElapsedQuery();
11✔
540
    // }
11✔
541

11✔
542
    this.cpuTime.timeStart();
11✔
543
  }
11✔
544

1✔
545
  _endFrameTimers() {
1✔
546
    this.cpuTime.timeEnd();
11✔
547

11✔
548
    // if (this._gpuTimeQuery) {
11✔
549
    //   // GPU time query end. Results will be available on next frame.
11✔
550
    //   this._gpuTimeQuery.end();
11✔
551
    // }
11✔
552
  }
11✔
553

1✔
554
  // Event handling
1✔
555

1✔
556
  _startEventHandling() {
1✔
557
    if (this.canvas) {
5✔
558
      this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
5✔
559
      this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
5✔
560
    }
5✔
561
  }
5✔
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