@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
text/typescript
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();
}
};
}