// luma.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import { luma } from '@luma.gl/core'; import { requestAnimationFrame, cancelAnimationFrame } from '@luma.gl/core'; import { Stats } from '@probe.gl/stats'; let statIdCounter = 0; const DEFAULT_ANIMATION_LOOP_PROPS = { device: null, onAddHTML: () => '', onInitialize: async () => { return null; }, onRender: () => { }, onFinalize: () => { }, onError: error => console.error(error), // eslint-disable-line no-console stats: luma.stats.get(`animation-loop-${statIdCounter++}`), // view parameters useDevicePixels: true, autoResizeViewport: false, autoResizeDrawingBuffer: false }; /** Convenient animation loop */ export class AnimationLoop { device = null; canvas = null; props; animationProps = null; timeline = null; stats; cpuTime; gpuTime; frameRate; display; needsRedraw = 'initialized'; _initialized = false; _running = false; _animationFrameId = null; _nextFramePromise = null; _resolveNextFrame = null; _cpuStartTime = 0; // _gpuTimeQuery: Query | null = null; /* * @param {HTMLCanvasElement} canvas - if provided, width and height will be passed to context */ constructor(props) { this.props = { ...DEFAULT_ANIMATION_LOOP_PROPS, ...props }; props = this.props; if (!props.device) { throw new Error('No device provided'); } const { useDevicePixels = true } = this.props; // state this.stats = props.stats || new Stats({ id: 'animation-loop-stats' }); this.cpuTime = this.stats.get('CPU Time'); this.gpuTime = this.stats.get('GPU Time'); this.frameRate = this.stats.get('Frame Rate'); this.setProps({ autoResizeViewport: props.autoResizeViewport, autoResizeDrawingBuffer: props.autoResizeDrawingBuffer, useDevicePixels }); // 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); } /** @deprecated Use .destroy() */ delete() { this.destroy(); } /** Flags this animation loop as needing redraw */ setNeedsRedraw(reason) { this.needsRedraw = this.needsRedraw || reason; return this; } /** TODO - move these props to CanvasContext? */ setProps(props) { if ('autoResizeViewport' in props) { this.props.autoResizeViewport = props.autoResizeViewport || false; } if ('autoResizeDrawingBuffer' in props) { this.props.autoResizeDrawingBuffer = props.autoResizeDrawingBuffer || false; } if ('useDevicePixels' in props) { this.props.useDevicePixels = props.useDevicePixels || 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(); // 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.props.onFinalize(this.animationProps); } this._cancelAnimationFrame(); this._nextFramePromise = null; this._resolveNextFrame = null; this._running = false; } return this; } /** Explicitly draw a frame */ redraw() { if (this.device?.isLost) { return this; } this._beginFrameTimers(); 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._resizeCanvasDrawingBuffer(); this._resizeViewport(); // this._gpuTimeQuery = Query.isSupported(this.gl, ['timers']) ? new Query(this.gl) : null; } _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 = requestAnimationFrame(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.cancelAnimationFrame) { // this.display.cancelAnimationFrame(this._animationFrameId); // } cancelAnimationFrame(this._animationFrameId); this._animationFrameId = null; } _animationFrame() { if (!this._running) { return; } this.redraw(); 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._resizeCanvasDrawingBuffer(); this._resizeViewport(); } // Initialize the object that will be passed to app callbacks _initializeAnimationProps() { if (!this.device) { throw new Error('loop'); } this.animationProps = { animationLoop: this, device: this.device, canvas: this.device?.canvasContext?.canvas, timeline: this.timeline, // Initial values useDevicePixels: this.props.useDevicePixels, 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.canvasContext?.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 }; } // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html const [width, height] = this.device?.canvasContext?.getPixelSize() || [1, 1]; // https://webglfundamentals.org/webgl/lessons/webgl-anti-patterns.html let aspect = 1; const canvas = this.device?.canvasContext?.canvas; // @ts-expect-error if (canvas && canvas.clientHeight) { // @ts-expect-error aspect = canvas.clientWidth / canvas.clientHeight; } else if (width > 0 && height > 0) { aspect = width / height; } return { width, height, aspect }; } /** 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); } } /** * Resize the render buffer of the canvas to match canvas client size * Optionally multiplying with devicePixel ratio */ _resizeCanvasDrawingBuffer() { if (this.props.autoResizeDrawingBuffer) { this.device?.canvasContext?.resize({ useDevicePixels: this.props.useDevicePixels }); } } _beginFrameTimers() { this.frameRate.timeEnd(); this.frameRate.timeStart(); // Check if timer for last frame has completed. // GPU timer results are never available in the same // frame they are captured. // if ( // this._gpuTimeQuery && // this._gpuTimeQuery.isResultAvailable() && // !this._gpuTimeQuery.isTimerDisjoint() // ) { // this.stats.get('GPU Time').addTime(this._gpuTimeQuery.getTimerMilliseconds()); // } // if (this._gpuTimeQuery) { // // GPU time query start // this._gpuTimeQuery.beginTimeElapsedQuery(); // } this.cpuTime.timeStart(); } _endFrameTimers() { this.cpuTime.timeEnd(); // if (this._gpuTimeQuery) { // // GPU time query end. Results will be available on next frame. // this._gpuTimeQuery.end(); // } } // 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; } }