// luma.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import {luma, Device} from '@luma.gl/core'; import { requestAnimationFramePolyfill, cancelAnimationFramePolyfill } from './request-animation-frame'; import {Timeline} from '../animation/timeline'; import {AnimationProps} from './animation-props'; import {Stats, Stat} from '@probe.gl/stats'; let statIdCounter = 0; const ANIMATION_LOOP_STATS = 'Animation Loop'; /** AnimationLoop properties */ export type AnimationLoopProps = { device: Device | Promise; onAddHTML?: (div: HTMLDivElement) => string; // innerHTML onInitialize?: (animationProps: AnimationProps) => Promise; onRender?: (animationProps: AnimationProps) => unknown; onFinalize?: (animationProps: AnimationProps) => void; onError?: (reason: Error) => void; stats?: Stats; // view parameters - TODO move to CanvasContext? autoResizeViewport?: boolean; }; export type MutableAnimationLoopProps = { // view parameters autoResizeViewport?: boolean; }; /** 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 } as const satisfies Readonly>; device: Device | null = null; canvas: HTMLCanvasElement | OffscreenCanvas | null = null; props: Required; animationProps: AnimationProps | null = null; timeline: Timeline | null = null; stats: Stats; sharedStats: Stats; cpuTime: Stat; gpuTime: Stat; frameRate: Stat; display: any; private _needsRedraw: string | false = 'initialized'; _initialized: boolean = false; _running: boolean = false; _animationFrameId: any = null; _nextFramePromise: Promise | null = null; _resolveNextFrame: ((animationLoop: AnimationLoop) => void) | null = null; _cpuStartTime: number = 0; _error: Error | null = null; _lastFrameTime: number = 0; /* * @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context */ constructor(props: AnimationLoopProps) { 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(): void { this.stop(); this._setDisplay(null); this.device?._disableDebugGPUTime(); } /** @deprecated Use .destroy() */ delete(): void { this.destroy(); } reportError(error: Error): void { this.props.onError(error); this._error = error; } /** Flags this animation loop as needing redraw */ setNeedsRedraw(reason: string): this { this._needsRedraw = this._needsRedraw || reason; return this; } /** Query redraw status. Clears the flag. */ needsRedraw(): false | string { const reason = this._needsRedraw; this._needsRedraw = false; return reason; } setProps(props: MutableAnimationLoopProps): this { 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: unknown) { 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?: number): this { 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: Timeline): Timeline { this.timeline = timeline; return this.timeline; } /** Remove a timeline */ detachTimeline(): void { this.timeline = null; } /** Wait until a render completes */ waitForRender(): Promise { this.setNeedsRedraw('waitForRender'); if (!this._nextFramePromise) { this._nextFramePromise = new Promise(resolve => { this._resolveNextFrame = resolve; }); } return this._nextFramePromise; } /** TODO - should use device.deviceContext */ async toDataURL(): Promise { this.setNeedsRedraw('toDataURL'); await this.waitForRender(); if (this.canvas instanceof HTMLCanvasElement) { return this.canvas.toDataURL(); } throw new Error('OffscreenCanvas'); } // PRIVATE METHODS _initialize(): void { 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: any): void { 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(): void { 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(): void { 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: number): void { 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: AnimationProps): void { // 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(): void { this._needsRedraw = false; } _setupFrame(): void { this._resizeViewport(); } // Initialize the object that will be passed to app callbacks _initializeAnimationProps(): void { 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(): AnimationProps { if (!this.animationProps) { throw new Error('animationProps'); } return this.animationProps; } // Update the context object that will be passed to app callbacks _updateAnimationProps(): void { 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(): void { 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(): {width: number; height: number; aspect: number} { 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(): void { // 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?: number) { 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(): void { if (!this.device) { return; } const gpuTimeMs = this.device.commandEncoder._gpuTimeMs; if (gpuTimeMs !== undefined) { this.gpuTime.addTime(gpuTimeMs); this.device.commandEncoder._gpuTimeMs = undefined; } } _updateSharedStats(): void { 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: Event) { if (event instanceof MouseEvent) { this._getAnimationProps()._mousePosition = [event.offsetX, event.offsetY]; } } _onMouseleave(event: Event) { this._getAnimationProps()._mousePosition = null; } }