@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
426 lines (425 loc) • 15.1 kB
JavaScript
import { assertUniformValueForType } from "./uniforms.js";
import { resolveMaterial } from "./material.js";
import { toMotionGPUErrorReport } from "./error-report.js";
import { createRenderer } from "./renderer.js";
import { buildRendererPipelineSignature } from "./recompile-policy.js";
//#region src/lib/core/runtime-loop.ts
function getRendererRetryDelayMs(attempt) {
return Math.min(8e3, 250 * 2 ** Math.max(0, attempt - 1));
}
var ERROR_CLEAR_GRACE_MS = 750;
function createMotionGPURuntimeLoop(options) {
const { canvas: canvasElement, registry, size } = options;
let frameId = null;
let renderer = null;
let isDisposed = false;
let observedCssWidth = -1;
let observedCssHeight = -1;
let resizeObserver = null;
try {
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 {
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 = null;
}
let previousTime = performance.now() / 1e3;
let activeRendererSignature = "";
let failedRendererSignature = null;
let failedRendererAttempts = 0;
let nextRendererRetryAt = 0;
let rendererRebuildPromise = null;
const runtimeUniforms = {};
const runtimeTextures = {};
let activeUniforms = {};
let activeTextures = {};
let uniformKeys = [];
let uniformKeySet = /* @__PURE__ */ new Set();
let uniformTypes = /* @__PURE__ */ new Map();
let textureKeys = [];
let textureKeySet = /* @__PURE__ */ new Set();
let activeMaterialSignature = "";
let currentCssWidth = -1;
let currentCssHeight = -1;
const renderUniforms = {};
const renderTextures = {};
const canvasSize = {
width: 0,
height: 0
};
let storageBufferKeys = [];
let storageBufferKeySet = /* @__PURE__ */ new Set();
let storageBufferDefinitions = {};
const pendingStorageWrites = [];
let shouldContinueAfterFrame = false;
let activeErrorKey = null;
let errorHistory = [];
let errorClearReadyAtMs = 0;
let lastFrameTimestampMs = performance.now();
const resolveNowMs = (nowMs) => {
if (typeof nowMs === "number" && Number.isFinite(nowMs)) return nowMs;
return lastFrameTimestampMs;
};
const getHistoryLimit = () => {
const value = options.getErrorHistoryLimit?.() ?? 0;
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.floor(value);
};
const publishErrorHistory = () => {
options.reportErrorHistory?.(errorHistory);
const onErrorHistory = options.getOnErrorHistory?.();
if (!onErrorHistory) return;
try {
onErrorHistory(errorHistory);
} catch {}
};
const syncErrorHistory = () => {
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, phase, nowMs) => {
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 {}
};
const maybeClearError = (nowMs) => {
if (activeErrorKey === null) return;
if (resolveNowMs(nowMs) < errorClearReadyAtMs) return;
activeErrorKey = null;
errorClearReadyAtMs = 0;
options.reportError(null);
};
const shouldRecreateRendererAfterError = (error) => {
return toMotionGPUErrorReport(error, "render").code === "WEBGPU_DEVICE_LOST";
};
const scheduleFrame = () => {
if (isDisposed || frameId !== null) return;
frameId = requestAnimationFrame(renderFrame);
};
const requestFrame = () => {
scheduleFrame();
};
const invalidate = (token) => {
registry.invalidate(token);
requestFrame();
};
const advance = () => {
registry.advance();
requestFrame();
};
const resetRuntimeMaps = () => {
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 = () => {
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) => {
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;
const layoutEntries = materialState.uniformLayout.entries;
const nextUniformKeys = [];
const nextUniformTypes = /* @__PURE__ */ new Map();
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 ?? {};
resetRuntimeMaps();
resetRenderPayloadMaps();
activeMaterialSignature = materialState.signature;
};
const resolveActiveMaterial = () => {
return resolveMaterial(options.getMaterial());
};
const setUniform = (name, value) => {
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, value) => {
if (!textureKeySet.has(name)) throw new Error(`Unknown texture "${name}". Declare it in material.textures first.`);
runtimeTextures[name] = value;
};
const writeStorageBuffer = (name, data, writeOptions) => {
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) => {
if (!storageBufferKeySet.has(name)) throw new Error(`Unknown storage buffer "${name}". Declare it in material.storageBuffers first.`);
if (!renderer) return Promise.reject(/* @__PURE__ */ new Error(`Cannot read storage buffer "${name}": renderer not initialized.`));
const gpuBuffer = renderer.getStorageBuffer?.(name);
if (!gpuBuffer) return Promise.reject(/* @__PURE__ */ new Error(`Storage buffer "${name}" not allocated on GPU.`));
const device = renderer.getDevice?.();
if (!device) return Promise.reject(/* @__PURE__ */ new Error("Cannot read storage buffer: GPU device unavailable."));
const definition = storageBufferDefinitions[name];
if (!definition) return Promise.reject(/* @__PURE__ */ 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) => {
frameId = null;
if (isDisposed) return;
lastFrameTimestampMs = timestamp;
syncErrorHistory();
let materialState;
try {
materialState = resolveActiveMaterial();
} catch (error) {
setError(error, "initialization", timestamp);
scheduleFrame();
return;
}
shouldContinueAfterFrame = false;
const color = options.getColor?.();
const rendererSignature = buildRendererPipelineSignature({
materialSignature: materialState.signature,
...color !== void 0 ? { 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 !== void 0 ? { 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 / 1e3;
const rawDelta = Math.max(0, time - previousTime);
const delta = Math.min(rawDelta, options.maxDelta.current);
previousTime = time;
let width;
let height;
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 === void 0 ? activeUniforms[key] : runtimeValue;
}
for (const key of textureKeys) {
const runtimeValue = runtimeTextures[key];
renderTextures[key] = runtimeValue === void 0 ? 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 : void 0
});
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 {
syncMaterialRuntimeState(resolveActiveMaterial());
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();
}
};
}
//#endregion
export { createMotionGPURuntimeLoop };
//# sourceMappingURL=runtime-loop.js.map