@luma.gl/engine
Version:
3D Engine Components for luma.gl
461 lines • 16.7 kB
JavaScript
// luma.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import { luma } from '@luma.gl/core';
import { requestAnimationFramePolyfill, cancelAnimationFramePolyfill } from "./request-animation-frame.js";
import { Stats } from '@probe.gl/stats';
let statIdCounter = 0;
const ANIMATION_LOOP_STATS = 'Animation Loop';
/** Convenient animation loop */
export class AnimationLoop {
static defaultAnimationLoopProps = {
device: null,
onAddHTML: () => '',
onInitialize: async () => null,
onRender: () => { },
onFinalize: () => { },
onError: error => console.error(error), // eslint-disable-line no-console
stats: undefined,
// view parameters
autoResizeViewport: false
};
device = null;
canvas = null;
props;
animationProps = null;
timeline = null;
stats;
sharedStats;
cpuTime;
gpuTime;
frameRate;
display;
_needsRedraw = 'initialized';
_initialized = false;
_running = false;
_animationFrameId = null;
_nextFramePromise = null;
_resolveNextFrame = null;
_cpuStartTime = 0;
_error = null;
_lastFrameTime = 0;
/*
* @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context
*/
constructor(props) {
this.props = { ...AnimationLoop.defaultAnimationLoopProps, ...props };
props = this.props;
if (!props.device) {
throw new Error('No device provided');
}
// state
this.stats = props.stats || new Stats({ id: `animation-loop-${statIdCounter++}` });
this.sharedStats = luma.stats.get(ANIMATION_LOOP_STATS);
this.frameRate = this.stats.get('Frame Rate');
this.frameRate.setSampleSize(1);
this.cpuTime = this.stats.get('CPU Time');
this.gpuTime = this.stats.get('GPU Time');
this.setProps({ autoResizeViewport: props.autoResizeViewport });
// Bind methods
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this._onMousemove = this._onMousemove.bind(this);
this._onMouseleave = this._onMouseleave.bind(this);
}
destroy() {
this.stop();
this._setDisplay(null);
this.device?._disableDebugGPUTime();
}
/** @deprecated Use .destroy() */
delete() {
this.destroy();
}
reportError(error) {
this.props.onError(error);
this._error = error;
}
/** Flags this animation loop as needing redraw */
setNeedsRedraw(reason) {
this._needsRedraw = this._needsRedraw || reason;
return this;
}
/** Query redraw status. Clears the flag. */
needsRedraw() {
const reason = this._needsRedraw;
this._needsRedraw = false;
return reason;
}
setProps(props) {
if ('autoResizeViewport' in props) {
this.props.autoResizeViewport = props.autoResizeViewport || false;
}
return this;
}
/** Starts a render loop if not already running */
async start() {
if (this._running) {
return this;
}
this._running = true;
try {
let appContext;
if (!this._initialized) {
this._initialized = true;
// Create the WebGL context
await this._initDevice();
this._initialize();
if (!this._running) {
return null;
}
// Note: onIntialize can return a promise (e.g. in case app needs to load resources)
await this.props.onInitialize(this._getAnimationProps());
}
// check that we haven't been stopped
if (!this._running) {
return null;
}
// Start the loop
if (appContext !== false) {
// cancel any pending renders to ensure only one loop can ever run
this._cancelAnimationFrame();
this._requestAnimationFrame();
}
return this;
}
catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.props.onError(error);
// this._running = false; // TODO
throw error;
}
}
/** Stops a render loop if already running, finalizing */
stop() {
// console.debug(`Stopping ${this.constructor.name}`);
if (this._running) {
// call callback
// If stop is called immediately, we can end up in a state where props haven't been initialized...
if (this.animationProps && !this._error) {
this.props.onFinalize(this.animationProps);
}
this._cancelAnimationFrame();
this._nextFramePromise = null;
this._resolveNextFrame = null;
this._running = false;
this._lastFrameTime = 0;
}
return this;
}
/** Explicitly draw a frame */
redraw(time) {
if (this.device?.isLost || this._error) {
return this;
}
this._beginFrameTimers(time);
this._setupFrame();
this._updateAnimationProps();
this._renderFrame(this._getAnimationProps());
// clear needsRedraw flag
this._clearNeedsRedraw();
if (this._resolveNextFrame) {
this._resolveNextFrame(this);
this._nextFramePromise = null;
this._resolveNextFrame = null;
}
this._endFrameTimers();
return this;
}
/** Add a timeline, it will be automatically updated by the animation loop. */
attachTimeline(timeline) {
this.timeline = timeline;
return this.timeline;
}
/** Remove a timeline */
detachTimeline() {
this.timeline = null;
}
/** Wait until a render completes */
waitForRender() {
this.setNeedsRedraw('waitForRender');
if (!this._nextFramePromise) {
this._nextFramePromise = new Promise(resolve => {
this._resolveNextFrame = resolve;
});
}
return this._nextFramePromise;
}
/** TODO - should use device.deviceContext */
async toDataURL() {
this.setNeedsRedraw('toDataURL');
await this.waitForRender();
if (this.canvas instanceof HTMLCanvasElement) {
return this.canvas.toDataURL();
}
throw new Error('OffscreenCanvas');
}
// PRIVATE METHODS
_initialize() {
this._startEventHandling();
// Initialize the callback data
this._initializeAnimationProps();
this._updateAnimationProps();
// Default viewport setup, in case onInitialize wants to render
this._resizeViewport();
this.device?._enableDebugGPUTime();
}
_setDisplay(display) {
if (this.display) {
this.display.destroy();
this.display.animationLoop = null;
}
// store animation loop on the display
if (display) {
display.animationLoop = this;
}
this.display = display;
}
_requestAnimationFrame() {
if (!this._running) {
return;
}
// VR display has a separate animation frame to sync with headset
// TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
// See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
// if (this.display && this.display.requestAnimationFrame) {
// this._animationFrameId = this.display.requestAnimationFrame(this._animationFrame.bind(this));
// }
this._animationFrameId = requestAnimationFramePolyfill(this._animationFrame.bind(this));
}
_cancelAnimationFrame() {
if (this._animationFrameId === null) {
return;
}
// VR display has a separate animation frame to sync with headset
// TODO WebVR API discontinued, replaced by WebXR: https://immersive-web.github.io/webxr/
// See https://developer.mozilla.org/en-US/docs/Web/API/VRDisplay/requestAnimationFrame
// if (this.display && this.display.cancelAnimationFramePolyfill) {
// this.display.cancelAnimationFrame(this._animationFrameId);
// }
cancelAnimationFramePolyfill(this._animationFrameId);
this._animationFrameId = null;
}
_animationFrame(time) {
if (!this._running) {
return;
}
this.redraw(time);
this._requestAnimationFrame();
}
// Called on each frame, can be overridden to call onRender multiple times
// to support e.g. stereoscopic rendering
_renderFrame(animationProps) {
// Allow e.g. VR display to render multiple frames.
if (this.display) {
this.display._renderFrame(animationProps);
return;
}
// call callback
this.props.onRender(this._getAnimationProps());
// end callback
// Submit commands (necessary on WebGPU)
this.device?.submit();
}
_clearNeedsRedraw() {
this._needsRedraw = false;
}
_setupFrame() {
this._resizeViewport();
}
// Initialize the object that will be passed to app callbacks
_initializeAnimationProps() {
const canvasContext = this.device?.getDefaultCanvasContext();
if (!this.device || !canvasContext) {
throw new Error('loop');
}
const canvas = canvasContext?.canvas;
const useDevicePixels = canvasContext.props.useDevicePixels;
this.animationProps = {
animationLoop: this,
device: this.device,
canvasContext,
canvas,
// @ts-expect-error Deprecated
useDevicePixels,
timeline: this.timeline,
needsRedraw: false,
// Placeholders
width: 1,
height: 1,
aspect: 1,
// Animation props
time: 0,
startTime: Date.now(),
engineTime: 0,
tick: 0,
tock: 0,
// Experimental
_mousePosition: null // Event props
};
}
_getAnimationProps() {
if (!this.animationProps) {
throw new Error('animationProps');
}
return this.animationProps;
}
// Update the context object that will be passed to app callbacks
_updateAnimationProps() {
if (!this.animationProps) {
return;
}
// Can this be replaced with canvas context?
const { width, height, aspect } = this._getSizeAndAspect();
if (width !== this.animationProps.width || height !== this.animationProps.height) {
this.setNeedsRedraw('drawing buffer resized');
}
if (aspect !== this.animationProps.aspect) {
this.setNeedsRedraw('drawing buffer aspect changed');
}
this.animationProps.width = width;
this.animationProps.height = height;
this.animationProps.aspect = aspect;
this.animationProps.needsRedraw = this._needsRedraw;
// Update time properties
this.animationProps.engineTime = Date.now() - this.animationProps.startTime;
if (this.timeline) {
this.timeline.update(this.animationProps.engineTime);
}
this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60);
this.animationProps.tock++;
// For back compatibility
this.animationProps.time = this.timeline
? this.timeline.getTime()
: this.animationProps.engineTime;
}
/** Wait for supplied device */
async _initDevice() {
this.device = await this.props.device;
if (!this.device) {
throw new Error('No device provided');
}
this.canvas = this.device.getDefaultCanvasContext().canvas || null;
// this._createInfoDiv();
}
_createInfoDiv() {
if (this.canvas && this.props.onAddHTML) {
const wrapperDiv = document.createElement('div');
document.body.appendChild(wrapperDiv);
wrapperDiv.style.position = 'relative';
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.left = '10px';
div.style.bottom = '10px';
div.style.width = '300px';
div.style.background = 'white';
if (this.canvas instanceof HTMLCanvasElement) {
wrapperDiv.appendChild(this.canvas);
}
wrapperDiv.appendChild(div);
const html = this.props.onAddHTML(div);
if (html) {
div.innerHTML = html;
}
}
}
_getSizeAndAspect() {
if (!this.device) {
return { width: 1, height: 1, aspect: 1 };
}
// Match projection setup to the actual render target dimensions, which may
// differ from the CSS size when device-pixel scaling or backend clamping applies.
const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize();
const aspect = width > 0 && height > 0 ? width / height : 1;
return { width, height, aspect };
}
/** @deprecated Default viewport setup */
_resizeViewport() {
// TODO can we use canvas context to code this in a portable way?
// @ts-expect-error Expose on canvasContext
if (this.props.autoResizeViewport && this.device.gl) {
// @ts-expect-error Expose canvasContext
this.device.gl.viewport(0, 0,
// @ts-expect-error Expose canvasContext
this.device.gl.drawingBufferWidth,
// @ts-expect-error Expose canvasContext
this.device.gl.drawingBufferHeight);
}
}
_beginFrameTimers(time) {
const now = time ?? (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (this._lastFrameTime) {
const frameTime = now - this._lastFrameTime;
if (frameTime > 0) {
this.frameRate.addTime(frameTime);
}
}
this._lastFrameTime = now;
if (this.device?._isDebugGPUTimeEnabled()) {
this._consumeEncodedGpuTime();
}
this.cpuTime.timeStart();
}
_endFrameTimers() {
if (this.device?._isDebugGPUTimeEnabled()) {
this._consumeEncodedGpuTime();
}
this.cpuTime.timeEnd();
this._updateSharedStats();
}
_consumeEncodedGpuTime() {
if (!this.device) {
return;
}
const gpuTimeMs = this.device.commandEncoder._gpuTimeMs;
if (gpuTimeMs !== undefined) {
this.gpuTime.addTime(gpuTimeMs);
this.device.commandEncoder._gpuTimeMs = undefined;
}
}
_updateSharedStats() {
if (this.stats === this.sharedStats) {
return;
}
for (const name of Object.keys(this.sharedStats.stats)) {
if (!this.stats.stats[name]) {
delete this.sharedStats.stats[name];
}
}
this.stats.forEach(sourceStat => {
const targetStat = this.sharedStats.get(sourceStat.name, sourceStat.type);
targetStat.sampleSize = sourceStat.sampleSize;
targetStat.time = sourceStat.time;
targetStat.count = sourceStat.count;
targetStat.samples = sourceStat.samples;
targetStat.lastTiming = sourceStat.lastTiming;
targetStat.lastSampleTime = sourceStat.lastSampleTime;
targetStat.lastSampleCount = sourceStat.lastSampleCount;
targetStat._count = sourceStat._count;
targetStat._time = sourceStat._time;
targetStat._samples = sourceStat._samples;
targetStat._startTime = sourceStat._startTime;
targetStat._timerPending = sourceStat._timerPending;
});
}
// Event handling
_startEventHandling() {
if (this.canvas) {
this.canvas.addEventListener('mousemove', this._onMousemove.bind(this));
this.canvas.addEventListener('mouseleave', this._onMouseleave.bind(this));
}
}
_onMousemove(event) {
if (event instanceof MouseEvent) {
this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY];
}
}
_onMouseleave(event) {
this._getAnimationProps()._mousePosition = null;
}
}
//# sourceMappingURL=animation-loop.js.map