UNPKG

@motion-core/motion-gpu

Version:

Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.

640 lines (575 loc) 19 kB
import type { CurrentReadable, CurrentWritable } from './current-value.js'; import { resolveMaterial, type FragMaterial, type ResolvedMaterial } from './material.js'; import { toMotionGPUErrorReport, type MotionGPUErrorPhase, type MotionGPUErrorReport } from './error-report.js'; import { createRenderer } from './renderer.js'; import { buildRendererPipelineSignature } from './recompile-policy.js'; import { assertUniformValueForType } from './uniforms.js'; import type { FrameRegistry } from './frame-registry.js'; import type { AnyPass, ColorPipelineOptions, FrameInvalidationToken, PendingStorageWrite, Renderer, RenderTargetDefinitionMap, StorageBufferDefinitionMap, TextureMap, TextureValue, UniformType, UniformValue } from './types.js'; export interface MotionGPURuntimeLoopOptions { canvas: HTMLCanvasElement; registry: FrameRegistry; size: CurrentWritable<{ width: number; height: number }>; dpr: CurrentReadable<number>; maxDelta: CurrentReadable<number>; getMaterial: () => FragMaterial; getRenderTargets: () => RenderTargetDefinitionMap; getPasses: () => AnyPass[]; getClearColor: () => [number, number, number, number]; getColor?: () => ColorPipelineOptions | undefined; getAdapterOptions: () => GPURequestAdapterOptions | undefined; getDeviceDescriptor: () => GPUDeviceDescriptor | undefined; getOnError: () => ((report: MotionGPUErrorReport) => void) | undefined; reportError: (report: MotionGPUErrorReport | null) => void; getErrorHistoryLimit?: () => number | undefined; getOnErrorHistory?: () => ((history: MotionGPUErrorReport[]) => void) | undefined; reportErrorHistory?: (history: MotionGPUErrorReport[]) => void; } export interface MotionGPURuntimeLoop { requestFrame: () => void; invalidate: (token?: FrameInvalidationToken) => void; advance: () => void; destroy: () => void; } function getRendererRetryDelayMs(attempt: number): number { return Math.min(8000, 250 * 2 ** Math.max(0, attempt - 1)); } const ERROR_CLEAR_GRACE_MS = 750; export function createMotionGPURuntimeLoop( options: MotionGPURuntimeLoopOptions ): MotionGPURuntimeLoop { const { canvas: canvasElement, registry, size } = options; let frameId: number | null = null; let renderer: Renderer | null = null; let isDisposed = false; // Observed CSS dimensions provided by ResizeObserver. // -1 means no observation has been received yet — the render loop falls // back to getBoundingClientRect() until the first callback fires. let observedCssWidth = -1; let observedCssHeight = -1; let resizeObserver: ResizeObserver | null = null; try { // Wrapped in try/catch so a ReferenceError in environments without // ResizeObserver (bare Node.js) is handled gracefully. Tests can stub // this via vi.stubGlobal('ResizeObserver', mock). resizeObserver = new ResizeObserver((entries) => { const entry = entries[entries.length - 1]; if (!entry) { return; } const boxSize = entry.contentBoxSize?.[0]; if (boxSize) { observedCssWidth = Math.max(0, Math.floor(boxSize.inlineSize)); observedCssHeight = Math.max(0, Math.floor(boxSize.blockSize)); } else { // Fallback for browsers without contentBoxSize support. observedCssWidth = Math.max(0, Math.floor(entry.contentRect.width)); observedCssHeight = Math.max(0, Math.floor(entry.contentRect.height)); } if (!isDisposed) { scheduleFrame(); } }); resizeObserver.observe(canvasElement); } catch { // ResizeObserver may not support the canvas element in certain environments. resizeObserver = null; } let previousTime = performance.now() / 1000; let activeRendererSignature = ''; let failedRendererSignature: string | null = null; let failedRendererAttempts = 0; let nextRendererRetryAt = 0; let rendererRebuildPromise: Promise<void> | null = null; const runtimeUniforms: Record<string, UniformValue> = {}; const runtimeTextures: TextureMap = {}; let activeUniforms: Record<string, UniformValue> = {}; let activeTextures: Record<string, { source?: TextureValue }> = {}; let uniformKeys: string[] = []; let uniformKeySet = new Set<string>(); let uniformTypes = new Map<string, UniformType>(); let textureKeys: string[] = []; let textureKeySet = new Set<string>(); let activeMaterialSignature = ''; let currentCssWidth = -1; let currentCssHeight = -1; const renderUniforms: Record<string, UniformValue> = {}; const renderTextures: TextureMap = {}; const canvasSize = { width: 0, height: 0 }; let storageBufferKeys: string[] = []; let storageBufferKeySet = new Set<string>(); let storageBufferDefinitions: StorageBufferDefinitionMap = {}; const pendingStorageWrites: PendingStorageWrite[] = []; let shouldContinueAfterFrame = false; let activeErrorKey: string | null = null; let errorHistory: MotionGPUErrorReport[] = []; let errorClearReadyAtMs = 0; let lastFrameTimestampMs = performance.now(); const resolveNowMs = (nowMs?: number): number => { if (typeof nowMs === 'number' && Number.isFinite(nowMs)) { return nowMs; } return lastFrameTimestampMs; }; const getHistoryLimit = (): number => { const value = options.getErrorHistoryLimit?.() ?? 0; if (!Number.isFinite(value) || value <= 0) { return 0; } return Math.floor(value); }; const publishErrorHistory = (): void => { options.reportErrorHistory?.(errorHistory); const onErrorHistory = options.getOnErrorHistory?.(); if (!onErrorHistory) { return; } try { onErrorHistory(errorHistory); } catch { // User-provided error history handlers must not break runtime error recovery. } }; const syncErrorHistory = (): void => { const limit = getHistoryLimit(); if (limit <= 0) { if (errorHistory.length === 0) { return; } errorHistory = []; publishErrorHistory(); return; } if (errorHistory.length <= limit) { return; } errorHistory.splice(0, errorHistory.length - limit); publishErrorHistory(); }; const setError = (error: unknown, phase: MotionGPUErrorPhase, nowMs?: number): void => { const report = toMotionGPUErrorReport(error, phase); errorClearReadyAtMs = resolveNowMs(nowMs) + ERROR_CLEAR_GRACE_MS; const reportKey = JSON.stringify({ phase: report.phase, title: report.title, message: report.message, rawMessage: report.rawMessage }); if (activeErrorKey === reportKey) { return; } activeErrorKey = reportKey; const historyLimit = getHistoryLimit(); if (historyLimit > 0) { errorHistory.push(report); if (errorHistory.length > historyLimit) { errorHistory.splice(0, errorHistory.length - historyLimit); } publishErrorHistory(); } options.reportError(report); const onError = options.getOnError(); if (!onError) { return; } try { onError(report); } catch { // User-provided error handlers must not break runtime error recovery. } }; const maybeClearError = (nowMs?: number): void => { if (activeErrorKey === null) { return; } if (resolveNowMs(nowMs) < errorClearReadyAtMs) { return; } activeErrorKey = null; errorClearReadyAtMs = 0; options.reportError(null); }; const shouldRecreateRendererAfterError = (error: unknown): boolean => { return toMotionGPUErrorReport(error, 'render').code === 'WEBGPU_DEVICE_LOST'; }; const scheduleFrame = (): void => { if (isDisposed || frameId !== null) { return; } frameId = requestAnimationFrame(renderFrame); }; const requestFrame = (): void => { scheduleFrame(); }; const invalidate = (token?: FrameInvalidationToken): void => { registry.invalidate(token); requestFrame(); }; const advance = (): void => { registry.advance(); requestFrame(); }; const resetRuntimeMaps = (): void => { for (const key of Object.keys(runtimeUniforms)) { if (!uniformKeySet.has(key)) { delete runtimeUniforms[key]; } } for (const key of Object.keys(runtimeTextures)) { if (!textureKeySet.has(key)) { delete runtimeTextures[key]; } } }; const resetRenderPayloadMaps = (): void => { for (const key of Object.keys(renderUniforms)) { if (!uniformKeySet.has(key)) { delete renderUniforms[key]; } } for (const key of Object.keys(renderTextures)) { if (!textureKeySet.has(key)) { delete renderTextures[key]; } } }; const syncMaterialRuntimeState = (materialState: ResolvedMaterial): void => { const signatureChanged = activeMaterialSignature !== materialState.signature; const defaultsChanged = activeUniforms !== materialState.uniforms || activeTextures !== materialState.textures; if (!signatureChanged && !defaultsChanged) { return; } activeUniforms = materialState.uniforms; activeTextures = materialState.textures; if (!signatureChanged) { return; } // Build uniformKeys and uniformTypes in one pass to avoid iterating entries twice. const layoutEntries = materialState.uniformLayout.entries; const nextUniformKeys: string[] = []; const nextUniformTypes = new Map<string, UniformType>(); for (const entry of layoutEntries) { nextUniformKeys.push(entry.name); nextUniformTypes.set(entry.name, entry.type); } uniformKeys = nextUniformKeys; uniformTypes = nextUniformTypes; textureKeys = materialState.textureKeys; uniformKeySet = new Set(uniformKeys); textureKeySet = new Set(textureKeys); storageBufferKeys = materialState.storageBufferKeys; storageBufferKeySet = new Set(storageBufferKeys); storageBufferDefinitions = (options.getMaterial().storageBuffers ?? {}) as StorageBufferDefinitionMap; resetRuntimeMaps(); resetRenderPayloadMaps(); activeMaterialSignature = materialState.signature; }; const resolveActiveMaterial = (): ResolvedMaterial => { return resolveMaterial(options.getMaterial()); }; const setUniform = (name: string, value: UniformValue): void => { if (!uniformKeySet.has(name)) { throw new Error(`Unknown uniform "${name}". Declare it in material.uniforms first.`); } const expectedType = uniformTypes.get(name); if (!expectedType) { throw new Error(`Unknown uniform type for "${name}"`); } assertUniformValueForType(expectedType, value); runtimeUniforms[name] = value; }; const setTexture = (name: string, value: TextureValue): void => { if (!textureKeySet.has(name)) { throw new Error(`Unknown texture "${name}". Declare it in material.textures first.`); } runtimeTextures[name] = value; }; const writeStorageBuffer = ( name: string, data: ArrayBufferView, writeOptions?: { offset?: number } ): void => { if (!storageBufferKeySet.has(name)) { throw new Error( `Unknown storage buffer "${name}". Declare it in material.storageBuffers first.` ); } const definition = storageBufferDefinitions[name]; if (!definition) { throw new Error(`Missing definition for storage buffer "${name}".`); } const offset = writeOptions?.offset ?? 0; if (offset < 0 || offset + data.byteLength > definition.size) { throw new Error( `Storage buffer "${name}" write out of bounds: offset=${offset}, dataSize=${data.byteLength}, bufferSize=${definition.size}.` ); } pendingStorageWrites.push({ name, data, offset }); }; const readStorageBuffer = (name: string): Promise<ArrayBuffer> => { if (!storageBufferKeySet.has(name)) { throw new Error( `Unknown storage buffer "${name}". Declare it in material.storageBuffers first.` ); } if (!renderer) { return Promise.reject( new Error(`Cannot read storage buffer "${name}": renderer not initialized.`) ); } const gpuBuffer = renderer.getStorageBuffer?.(name); if (!gpuBuffer) { return Promise.reject(new Error(`Storage buffer "${name}" not allocated on GPU.`)); } const device = renderer.getDevice?.(); if (!device) { return Promise.reject(new Error('Cannot read storage buffer: GPU device unavailable.')); } const definition = storageBufferDefinitions[name]; if (!definition) { return Promise.reject(new Error(`Missing definition for storage buffer "${name}".`)); } const stagingBuffer = device.createBuffer({ size: definition.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); const commandEncoder = device.createCommandEncoder(); commandEncoder.copyBufferToBuffer(gpuBuffer, 0, stagingBuffer, 0, definition.size); device.queue.submit([commandEncoder.finish()]); return stagingBuffer.mapAsync(GPUMapMode.READ).then( () => { try { return stagingBuffer.getMappedRange().slice(0); } finally { stagingBuffer.unmap(); stagingBuffer.destroy(); } }, (error) => { stagingBuffer.destroy(); throw error; } ); }; const renderFrame = (timestamp: number): void => { frameId = null; if (isDisposed) { return; } lastFrameTimestampMs = timestamp; syncErrorHistory(); let materialState: ResolvedMaterial; try { materialState = resolveActiveMaterial(); } catch (error) { setError(error, 'initialization', timestamp); scheduleFrame(); return; } shouldContinueAfterFrame = false; const color = options.getColor?.(); const rendererSignature = buildRendererPipelineSignature({ materialSignature: materialState.signature, ...(color !== undefined ? { color } : {}) }); syncMaterialRuntimeState(materialState); if (failedRendererSignature && failedRendererSignature !== rendererSignature) { failedRendererSignature = null; failedRendererAttempts = 0; nextRendererRetryAt = 0; } if (!renderer || activeRendererSignature !== rendererSignature) { if ( failedRendererSignature === rendererSignature && performance.now() < nextRendererRetryAt ) { scheduleFrame(); return; } if (!rendererRebuildPromise) { rendererRebuildPromise = (async () => { try { const nextRenderer = await createRenderer({ canvas: canvasElement, fragmentWgsl: materialState.fragmentWgsl, fragmentLineMap: materialState.fragmentLineMap, fragmentSource: materialState.fragmentSource, includeSources: materialState.includeSources, defineBlockSource: materialState.defineBlockSource, materialSource: materialState.source, materialSignature: materialState.signature, uniformLayout: materialState.uniformLayout, textureKeys: materialState.textureKeys, textureDefinitions: materialState.textures, storageBufferKeys: materialState.storageBufferKeys, storageBufferDefinitions, storageTextureKeys: materialState.storageTextureKeys, getRenderTargets: options.getRenderTargets, getPasses: options.getPasses, ...(color !== undefined ? { color } : {}), getClearColor: options.getClearColor, getDpr: () => options.dpr.current, adapterOptions: options.getAdapterOptions(), deviceDescriptor: options.getDeviceDescriptor(), requestRender: scheduleFrame }); if (isDisposed) { nextRenderer.destroy(); return; } renderer?.destroy(); renderer = nextRenderer; activeRendererSignature = rendererSignature; failedRendererSignature = null; failedRendererAttempts = 0; nextRendererRetryAt = 0; maybeClearError(performance.now()); } catch (error) { failedRendererSignature = rendererSignature; failedRendererAttempts += 1; const retryDelayMs = getRendererRetryDelayMs(failedRendererAttempts); nextRendererRetryAt = performance.now() + retryDelayMs; setError(error, 'initialization'); } finally { rendererRebuildPromise = null; scheduleFrame(); } })(); } return; } const time = timestamp / 1000; const rawDelta = Math.max(0, time - previousTime); const delta = Math.min(rawDelta, options.maxDelta.current); previousTime = time; // Use ResizeObserver-supplied dimensions when available; otherwise fall // back to getBoundingClientRect() (e.g. before the first RO callback fires). let width: number; let height: number; if (observedCssWidth >= 0) { width = observedCssWidth; height = observedCssHeight; } else { const rect = canvasElement.getBoundingClientRect(); width = Math.max(0, Math.floor(rect.width)); height = Math.max(0, Math.floor(rect.height)); } if (width !== currentCssWidth || height !== currentCssHeight) { currentCssWidth = width; currentCssHeight = height; size.set({ width, height }); } try { registry.run({ time, delta, setUniform, setTexture, writeStorageBuffer, readStorageBuffer, invalidate, advance, renderMode: registry.getRenderMode(), autoRender: registry.getAutoRender(), canvas: canvasElement }); const shouldRenderFrame = registry.shouldRender(); shouldContinueAfterFrame = registry.getRenderMode() === 'always' || (registry.getRenderMode() === 'on-demand' && shouldRenderFrame); if (shouldRenderFrame) { for (const key of uniformKeys) { const runtimeValue = runtimeUniforms[key]; renderUniforms[key] = runtimeValue === undefined ? (activeUniforms[key] as UniformValue) : runtimeValue; } for (const key of textureKeys) { const runtimeValue = runtimeTextures[key]; renderTextures[key] = runtimeValue === undefined ? (activeTextures[key]?.source ?? null) : runtimeValue; } canvasSize.width = width; canvasSize.height = height; renderer.render({ time, delta, renderMode: registry.getRenderMode(), uniforms: renderUniforms, textures: renderTextures, canvasSize, pendingStorageWrites: pendingStorageWrites.length > 0 ? pendingStorageWrites : undefined }); // Clear in-place after synchronous render() completes — avoids // the splice(0) copy and eliminates the conditional spread object. if (pendingStorageWrites.length > 0) { pendingStorageWrites.length = 0; } } else if (pendingStorageWrites.length > 0) { renderer.flushStorageWrites(pendingStorageWrites); pendingStorageWrites.length = 0; } maybeClearError(timestamp); } catch (error) { setError(error, 'render', timestamp); if (renderer && shouldRecreateRendererAfterError(error)) { renderer.destroy(); renderer = null; activeRendererSignature = ''; failedRendererSignature = null; failedRendererAttempts = 0; nextRendererRetryAt = 0; shouldContinueAfterFrame = true; } } finally { registry.endFrame(); } if (shouldContinueAfterFrame) { scheduleFrame(); } }; (async () => { try { const initialMaterial = resolveActiveMaterial(); syncMaterialRuntimeState(initialMaterial); activeRendererSignature = ''; scheduleFrame(); } catch (error) { setError(error, 'initialization'); scheduleFrame(); } })(); return { requestFrame, invalidate, advance, destroy: () => { isDisposed = true; resizeObserver?.disconnect(); resizeObserver = null; if (frameId !== null) { cancelAnimationFrame(frameId); frameId = null; } renderer?.destroy(); registry.clear(); } }; }