UNPKG

@luma.gl/engine

Version:

3D Engine Components for luma.gl

461 lines 16.7 kB
// 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