@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
1,617 lines (1,490 loc) • 82.4 kB
text/typescript
import { buildRenderTargetSignature, resolveRenderTargetDefinitions } from './render-targets.js';
import { planRenderGraph, type RenderGraphPlan } from './render-graph.js';
import {
buildShaderSourceWithMap,
formatShaderSourceLocation,
type ShaderLineMap
} from './shader.js';
import {
attachShaderCompilationDiagnostics,
type ShaderCompilationDiagnostic,
type ShaderCompilationRuntimeContext
} from './error-diagnostics.js';
import {
getTextureMipLevelCount,
normalizeTextureDefinitions,
resolveTextureUpdateMode,
resolveTextureSize,
toTextureData
} from './textures.js';
import { packUniformsIntoFast } from './uniforms.js';
import {
buildComputeShaderSourceWithMap,
buildPingPongComputeShaderSourceWithMap,
extractWorkgroupSize,
storageTextureSampleScalarType
} from './compute-shader.js';
import { createComputeStorageBindGroupCache } from './compute-bindgroup-cache.js';
import { normalizeStorageBufferDefinition } from './storage-buffers.js';
import {
buildCanvasConfiguration,
buildPresentationShader,
resolveColorPipeline,
shouldConvertLinearToSrgb,
type EffectiveDynamicRange
} from './color-pipeline.js';
import type {
AnyPass,
RenderPass,
RenderPassInputSlot,
RenderPassOutputSlot,
RenderMode,
RenderTarget,
Renderer,
RendererOptions,
StorageBufferAccess,
StorageBufferType,
TextureSource,
TextureUpdateMode,
TextureValue
} from './types.js';
/**
* Binding index for frame uniforms (`time`, `delta`, `resolution`).
*/
const FRAME_BINDING = 0;
/**
* Binding index for material uniform buffer.
*/
const UNIFORM_BINDING = 1;
/**
* First binding index used for texture sampler/texture pairs.
*/
const FIRST_TEXTURE_BINDING = 2;
/**
* Runtime texture binding state associated with a single texture key.
*/
interface RuntimeTextureBinding {
key: string;
samplerBinding: number;
textureBinding: number;
fragmentVisible: boolean;
sampler: GPUSampler;
fallbackTexture: GPUTexture;
fallbackView: GPUTextureView;
texture: GPUTexture | null;
view: GPUTextureView;
source: TextureSource | null;
width: number | undefined;
height: number | undefined;
mipLevelCount: number;
format: GPUTextureFormat;
colorSpace: 'srgb' | 'linear';
defaultColorSpace: 'srgb' | 'linear';
flipY: boolean;
defaultFlipY: boolean;
generateMipmaps: boolean;
defaultGenerateMipmaps: boolean;
premultipliedAlpha: boolean;
defaultPremultipliedAlpha: boolean;
update: TextureUpdateMode;
defaultUpdate?: TextureUpdateMode;
lastToken: TextureValue;
}
/**
* Runtime render target allocation metadata.
*/
interface RuntimeRenderTarget {
texture: GPUTexture;
view: GPUTextureView;
width: number;
height: number;
format: GPUTextureFormat;
}
/**
* Runtime ping-pong storage textures for a single logical target key.
*/
interface PingPongTexturePair {
target: string;
format: GPUTextureFormat;
width: number;
height: number;
textureA: GPUTexture;
viewA: GPUTextureView;
textureB: GPUTexture;
viewB: GPUTextureView;
bindGroupLayout: GPUBindGroupLayout;
readAWriteBBindGroup: GPUBindGroup | null;
readBWriteABindGroup: GPUBindGroup | null;
}
/**
* Cached pass properties used to validate render-graph cache correctness.
*/
interface RenderGraphPassSnapshot {
pass: AnyPass;
enabled: RenderPass['enabled'];
needsSwap: RenderPass['needsSwap'];
input: RenderPass['input'];
output: RenderPass['output'];
clear: RenderPass['clear'];
preserve: RenderPass['preserve'];
hasClearColor: boolean;
clearColor0: number;
clearColor1: number;
clearColor2: number;
clearColor3: number;
}
/**
* Internal shape implemented by renderer-managed compute pass classes.
*/
interface RuntimeComputePass {
isCompute?: boolean;
getCompute?: () => string;
resolveDispatch?: (ctx: {
width: number;
height: number;
time: number;
delta: number;
workgroupSize: [number, number, number];
}) => [number, number, number];
getWorkgroupSize?: () => [number, number, number];
isPingPong?: boolean;
getTarget?: () => string;
getCurrentOutput?: () => string;
getIterations?: () => number;
advanceFrame?: () => void;
}
/**
* Returns sampler/texture binding slots for a texture index.
*/
function getTextureBindings(index: number): {
samplerBinding: number;
textureBinding: number;
} {
const samplerBinding = FIRST_TEXTURE_BINDING + index * 2;
return {
samplerBinding,
textureBinding: samplerBinding + 1
};
}
/**
* Maps WGSL scalar texture type to WebGPU sampled texture bind-group sample type.
*/
function toGpuTextureSampleType(type: 'f32' | 'u32' | 'i32'): GPUTextureSampleType {
if (type === 'u32') {
return 'uint';
}
if (type === 'i32') {
return 'sint';
}
return 'float';
}
/**
* Resizes canvas backing store to match client size and DPR.
*/
function resizeCanvas(
canvas: HTMLCanvasElement,
dprInput: number,
cssSize?: { width: number; height: number }
): { width: number; height: number } {
const dpr = Number.isFinite(dprInput) && dprInput > 0 ? dprInput : 1;
const rect = cssSize ? null : canvas.getBoundingClientRect();
const cssWidth = Math.max(0, cssSize?.width ?? rect?.width ?? 0);
const cssHeight = Math.max(0, cssSize?.height ?? rect?.height ?? 0);
const width = Math.max(1, Math.floor((cssWidth || 1) * dpr));
const height = Math.max(1, Math.floor((cssHeight || 1) * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
return { width, height };
}
/**
* Throws when a shader module contains WGSL compilation errors.
*/
async function assertCompilation(
module: GPUShaderModule,
options?: {
lineMap?: ShaderLineMap;
fragmentSource?: string;
computeSource?: string;
includeSources?: Record<string, string>;
defineBlockSource?: string;
materialSource?: {
component?: string;
file?: string;
line?: number;
column?: number;
functionName?: string;
} | null;
runtimeContext?: ShaderCompilationRuntimeContext;
errorPrefix?: string;
shaderStage?: 'fragment' | 'compute';
}
): Promise<void> {
const info = await module.getCompilationInfo();
const errors = info.messages.filter((message: GPUCompilationMessage) => message.type === 'error');
if (errors.length === 0) {
return;
}
const diagnostics = errors.map((message: GPUCompilationMessage) => ({
generatedLine: message.lineNum,
message: message.message,
linePos: message.linePos,
lineLength: message.length,
sourceLocation: options?.lineMap?.[message.lineNum] ?? null
}));
const summary = diagnostics
.map((diagnostic) => {
const sourceLabel = formatShaderSourceLocation(diagnostic.sourceLocation);
const generatedLineLabel =
diagnostic.generatedLine > 0 ? `generated WGSL line ${diagnostic.generatedLine}` : null;
const contextLabel = [sourceLabel, generatedLineLabel].filter((value) => Boolean(value));
if (contextLabel.length === 0) {
return diagnostic.message;
}
return `[${contextLabel.join(' | ')}] ${diagnostic.message}`;
})
.join('\n');
const prefix = options?.errorPrefix ?? 'WGSL compilation failed';
const error = new Error(`${prefix}:\n${summary}`);
throw attachShaderCompilationDiagnostics(error, {
kind: 'shader-compilation',
...(options?.shaderStage !== undefined ? { shaderStage: options.shaderStage } : {}),
diagnostics,
fragmentSource: options?.fragmentSource ?? '',
...(options?.computeSource !== undefined ? { computeSource: options.computeSource } : {}),
includeSources: options?.includeSources ?? {},
...(options?.defineBlockSource !== undefined
? { defineBlockSource: options.defineBlockSource }
: {}),
materialSource: options?.materialSource ?? null,
...(options?.runtimeContext !== undefined ? { runtimeContext: options.runtimeContext } : {})
});
}
function toSortedUniqueStrings(values: string[]): string[] {
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}
/**
* Best-effort line extraction from a raw GPU error/exception message.
*
* Used only as a fallback when WebGPU's structured `getCompilationInfo()` and
* `popErrorScope()` channels have no per-message line metadata — primarily to
* keep test mocks that throw synchronously from `createComputePipeline()`
* reproducible against the structured-diagnostics contract.
*/
function extractGeneratedLineFromComputeError(message: string): number | null {
const lineMatch = message.match(/\bline\s+(\d+)\b/i);
if (lineMatch) {
const parsed = Number.parseInt(lineMatch[1] ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
const colonMatch = message.match(/:(\d+):\d+/);
if (colonMatch) {
const parsed = Number.parseInt(colonMatch[1] ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
/**
* Builds a compute compilation Error with structured diagnostics attached.
*/
function buildComputeCompilationError(input: {
diagnostics: ShaderCompilationDiagnostic[];
computeSource: string;
runtimeContext: ShaderCompilationRuntimeContext;
}): Error {
const summary = input.diagnostics
.map((diagnostic) => {
const sourceLabel = formatShaderSourceLocation(diagnostic.sourceLocation);
const generatedLineLabel =
diagnostic.generatedLine > 0 ? `generated WGSL line ${diagnostic.generatedLine}` : null;
const contextLabel = [sourceLabel, generatedLineLabel].filter((value) => Boolean(value));
if (contextLabel.length === 0) {
return diagnostic.message;
}
return `[${contextLabel.join(' | ')}] ${diagnostic.message}`;
})
.join('\n');
const error = new Error(`Compute shader compilation failed:\n${summary}`);
return attachShaderCompilationDiagnostics(error, {
kind: 'shader-compilation',
shaderStage: 'compute',
diagnostics: input.diagnostics,
fragmentSource: '',
computeSource: input.computeSource,
includeSources: {},
materialSource: null,
runtimeContext: input.runtimeContext
});
}
/**
* Fallback compute-compilation error builder used when the synchronous
* `createShaderModule` / `createComputePipeline` path itself throws — there is
* no compilation info or popped scope to inspect, so we extract whatever line
* hint we can from the raw exception message.
*/
function toComputeCompilationError(input: {
error: unknown;
lineMap: ShaderLineMap;
computeSource: string;
runtimeContext: ShaderCompilationRuntimeContext;
}): Error {
const baseError =
input.error instanceof Error ? input.error : new Error(String(input.error ?? 'Unknown error'));
const generatedLine = extractGeneratedLineFromComputeError(baseError.message) ?? 0;
const sourceLocation = generatedLine > 0 ? (input.lineMap[generatedLine] ?? null) : null;
return buildComputeCompilationError({
diagnostics: [
{
generatedLine,
message: baseError.message,
sourceLocation
}
],
computeSource: input.computeSource,
runtimeContext: input.runtimeContext
});
}
/**
* Awaits the async outputs of a compute shader module + pipeline creation
* sequence (compilation info + popped validation scope) and, if either reveals
* an error, returns a fully-attributed compute compilation Error. Returns
* `null` when both channels are clean.
*/
async function assertComputeCompilationAsync(input: {
module: GPUShaderModule;
validationScope: Promise<GPUError | null>;
lineMap: ShaderLineMap;
computeSource: string;
runtimeContext: ShaderCompilationRuntimeContext;
}): Promise<Error | null> {
let compilationMessages: GPUCompilationMessage[] = [];
try {
const info = await input.module.getCompilationInfo();
compilationMessages = info.messages.filter(
(message: GPUCompilationMessage) => message.type === 'error'
);
} catch {
// If the runtime cannot report compilation info, fall through to
// validation scope or treat as clean.
}
const validationError = await input.validationScope.catch(() => null);
if (compilationMessages.length === 0 && !validationError) {
return null;
}
const diagnostics =
compilationMessages.length > 0
? compilationMessages.map((message: GPUCompilationMessage) => ({
generatedLine: message.lineNum,
message: message.message,
linePos: message.linePos,
lineLength: message.length,
sourceLocation: input.lineMap[message.lineNum] ?? null
}))
: [
{
generatedLine: 0,
message: validationError!.message,
sourceLocation: null
}
];
return buildComputeCompilationError({
diagnostics,
computeSource: input.computeSource,
runtimeContext: input.runtimeContext
});
}
function buildPassGraphSnapshot(
passes: AnyPass[] | undefined
): NonNullable<ShaderCompilationRuntimeContext['passGraph']> {
const declaredPasses = passes ?? [];
let enabledPassCount = 0;
const inputs: string[] = [];
const outputs: string[] = [];
for (const pass of declaredPasses) {
if (pass.enabled === false) {
continue;
}
enabledPassCount += 1;
if ('isCompute' in pass && (pass as { isCompute?: boolean }).isCompute === true) {
continue;
}
const rp = pass as RenderPass;
const needsSwap = rp.needsSwap ?? true;
const input = rp.input ?? 'source';
const output = rp.output ?? (needsSwap ? 'target' : 'source');
inputs.push(input);
outputs.push(output);
}
return {
passCount: declaredPasses.length,
enabledPassCount,
inputs: toSortedUniqueStrings(inputs),
outputs: toSortedUniqueStrings(outputs)
};
}
function buildShaderCompilationRuntimeContext(
options: RendererOptions
): ShaderCompilationRuntimeContext {
const passList = options.getPasses?.() ?? options.passes;
const renderTargetMap = options.getRenderTargets?.() ?? options.renderTargets;
return {
...(options.materialSignature ? { materialSignature: options.materialSignature } : {}),
passGraph: buildPassGraphSnapshot(passList),
activeRenderTargets: Object.keys(renderTargetMap ?? {}).sort((a, b) => a.localeCompare(b))
};
}
/**
* Creates a 1x1 white fallback texture used before user textures become available.
*/
function createFallbackTexture(device: GPUDevice, format: GPUTextureFormat): GPUTexture {
const texture = device.createTexture({
size: { width: 1, height: 1, depthOrArrayLayers: 1 },
format,
usage:
GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});
const pixel = new Uint8Array([255, 255, 255, 255]);
device.queue.writeTexture(
{ texture },
pixel,
{ offset: 0, bytesPerRow: 4, rowsPerImage: 1 },
{ width: 1, height: 1, depthOrArrayLayers: 1 }
);
return texture;
}
/**
* Creates an offscreen canvas used for CPU mipmap generation.
*/
function createMipmapCanvas(width: number, height: number): OffscreenCanvas | HTMLCanvasElement {
if (typeof OffscreenCanvas !== 'undefined') {
return new OffscreenCanvas(width, height);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
/**
* Creates typed descriptor for `copyExternalImageToTexture`.
*/
function createExternalCopySource(
source: CanvasImageSource,
options: { flipY?: boolean; premultipliedAlpha?: boolean }
): GPUCopyExternalImageSourceInfo {
const descriptor = {
source,
...(options.flipY ? { flipY: true } : {}),
...(options.premultipliedAlpha ? { premultipliedAlpha: true } : {})
};
return descriptor as GPUCopyExternalImageSourceInfo;
}
/**
* Uploads source content to a GPU texture and optionally generates mip chain on CPU.
*/
function uploadTexture(
device: GPUDevice,
texture: GPUTexture,
binding: Pick<RuntimeTextureBinding, 'flipY' | 'premultipliedAlpha' | 'generateMipmaps'>,
source: TextureSource,
width: number,
height: number,
mipLevelCount: number
): void {
device.queue.copyExternalImageToTexture(
createExternalCopySource(source, {
flipY: binding.flipY,
premultipliedAlpha: binding.premultipliedAlpha
}),
{ texture, mipLevel: 0 },
{ width, height, depthOrArrayLayers: 1 }
);
if (!binding.generateMipmaps || mipLevelCount <= 1) {
return;
}
let previousSource: CanvasImageSource = source;
let previousWidth = width;
let previousHeight = height;
for (let level = 1; level < mipLevelCount; level += 1) {
const nextWidth = Math.max(1, Math.floor(previousWidth / 2));
const nextHeight = Math.max(1, Math.floor(previousHeight / 2));
const canvas = createMipmapCanvas(nextWidth, nextHeight);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Unable to create 2D context for mipmap generation');
}
context.drawImage(
previousSource,
0,
0,
previousWidth,
previousHeight,
0,
0,
nextWidth,
nextHeight
);
device.queue.copyExternalImageToTexture(
createExternalCopySource(canvas, {
premultipliedAlpha: binding.premultipliedAlpha
}),
{ texture, mipLevel: level },
{ width: nextWidth, height: nextHeight, depthOrArrayLayers: 1 }
);
previousSource = canvas;
previousWidth = nextWidth;
previousHeight = nextHeight;
}
}
/**
* Creates bind group layout entries for frame/uniform buffers plus texture bindings.
*/
function createBindGroupLayoutEntries(
textureBindings: RuntimeTextureBinding[]
): GPUBindGroupLayoutEntry[] {
const entries: GPUBindGroupLayoutEntry[] = [
{
binding: FRAME_BINDING,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform', minBindingSize: 16 }
},
{
binding: UNIFORM_BINDING,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}
];
for (const binding of textureBindings) {
entries.push({
binding: binding.samplerBinding,
visibility: GPUShaderStage.FRAGMENT,
sampler: { type: 'filtering' }
});
entries.push({
binding: binding.textureBinding,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
viewDimension: '2d',
multisampled: false
}
});
}
return entries;
}
/**
* Maximum gap (in floats) between two dirty ranges that triggers merge.
*
* Set to 4 (16 bytes) which covers one vec4f alignment slot.
*/
const DIRTY_RANGE_MERGE_GAP = 4;
/**
* Shared empty result returned when no float values differ between snapshots.
*
* Avoids allocating a new `[]` on every clean frame (the common steady-state
* case). Callers must not mutate this reference.
*/
const EMPTY_DIRTY_RANGES: ReadonlyArray<{ start: number; count: number }> = [];
/**
* Computes dirty float ranges between two uniform snapshots.
*
* Adjacent dirty ranges separated by a gap smaller than or equal to
* {@link DIRTY_RANGE_MERGE_GAP} are merged to reduce `writeBuffer` calls.
*
* Returns a shared empty array reference when the buffers are identical —
* callers must not mutate the returned array.
*/
export function findDirtyFloatRanges(
previous: Float32Array,
next: Float32Array,
mergeGapThreshold = DIRTY_RANGE_MERGE_GAP
): ReadonlyArray<{ start: number; count: number }> {
let start = -1;
let rangeCount = 0;
const ranges: Array<{ start: number; count: number }> = [];
for (let index = 0; index < next.length; index += 1) {
if (previous[index] !== next[index]) {
if (start === -1) {
start = index;
}
continue;
}
if (start !== -1) {
ranges.push({ start, count: index - start });
rangeCount += 1;
start = -1;
}
}
if (start !== -1) {
ranges.push({ start, count: next.length - start });
rangeCount += 1;
}
if (rangeCount === 0) {
// Most common case in steady-state animations: no dirty ranges.
// Return the shared sentinel to avoid a per-frame heap allocation.
return EMPTY_DIRTY_RANGES;
}
if (rangeCount <= 1) {
return ranges;
}
const merged: Array<{ start: number; count: number }> = [ranges[0]!];
for (let index = 1; index < rangeCount; index += 1) {
const prev = merged[merged.length - 1]!;
const curr = ranges[index]!;
const gap = curr.start - (prev.start + prev.count);
if (gap <= mergeGapThreshold) {
prev.count = curr.start + curr.count - prev.start;
} else {
merged.push(curr);
}
}
return merged;
}
/**
* Allocates a render target texture with usage flags suitable for passes/blits.
*/
function createRenderTexture(
device: GPUDevice,
width: number,
height: number,
format: GPUTextureFormat
): RuntimeRenderTarget {
const texture = device.createTexture({
size: { width, height, depthOrArrayLayers: 1 },
format,
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.COPY_SRC
});
return {
texture,
view: texture.createView(),
width,
height,
format
};
}
/**
* Destroys a render target texture if present.
*/
function destroyRenderTexture(target: RuntimeRenderTarget | null): void {
target?.texture.destroy();
}
/**
* Creates the WebGPU renderer used by `FragCanvas`.
*
* @param options - Renderer creation options resolved from material/context state.
* @returns Renderer instance with `render` and `destroy`.
* @throws {Error} On WebGPU unavailability, shader compilation issues, or runtime setup failures.
*/
export async function createRenderer(options: RendererOptions): Promise<Renderer> {
if (!navigator.gpu) {
throw new Error('WebGPU is not available in this browser');
}
const context = options.canvas.getContext('webgpu') as GPUCanvasContext | null;
if (!context) {
throw new Error('Canvas does not support webgpu context');
}
const preferredCanvasFormat = navigator.gpu.getPreferredCanvasFormat();
const colorPipeline = resolveColorPipeline({
color: options.color,
preferredCanvasFormat
});
const workingFormat = colorPipeline.workingFormat;
const scenePipelineFormat = colorPipeline.requiresPresentationPass
? workingFormat
: colorPipeline.canvasFormat;
let effectiveCanvasFormat = colorPipeline.canvasFormat;
let effectiveDynamicRange: EffectiveDynamicRange =
colorPipeline.dynamicRange === 'auto' ? 'hdr' : colorPipeline.dynamicRange;
const adapter = await navigator.gpu.requestAdapter(options.adapterOptions);
if (!adapter) {
throw new Error('Unable to acquire WebGPU adapter');
}
const device = await adapter.requestDevice(options.deviceDescriptor);
let isDestroyed = false;
let deviceLostMessage: string | null = null;
const uncapturedErrorMessages: string[] = [];
const initializationCleanups: Array<() => void> = [];
let acceptInitializationCleanups = true;
const MAX_UNCAPTURED_ERROR_MESSAGES = 12;
const isDerivativeUncapturedMessage = (message: string): boolean => {
const normalized = message.toLowerCase();
// "is invalid due to a previous error" is the canonical Dawn/WebGPU
// cascade marker emitted from setPipeline / commandEncoder.finish /
// queue.submit when a prior shader/pipeline failed validation. The
// authoritative error already lives in our compute-pipeline error cache
// (or in another uncaptured message), so suppress these from the user
// channel — they only add noise like "[Invalid CommandBuffer] is
// invalid due to a previous error".
return (
normalized.includes('is invalid due to a previous error') ||
normalized.includes('too many warnings, no more warnings will be reported')
);
};
const consumeUncapturedErrorMessage = (): string | null => {
if (uncapturedErrorMessages.length === 0) {
return null;
}
const uniqueMessages: string[] = [];
for (const message of uncapturedErrorMessages) {
if (!uniqueMessages.includes(message)) {
uniqueMessages.push(message);
}
}
uncapturedErrorMessages.length = 0;
const primaryIndex = uniqueMessages.findIndex(
(message) => !isDerivativeUncapturedMessage(message)
);
// When every queued message is derivative cascade noise we have nothing
// of substance to surface — return null so the host can fall through to
// the structured diagnostics path (e.g. a cached compute compilation
// error) instead of throwing an unhelpful "[Invalid X] is invalid due
// to a previous error".
if (primaryIndex === -1) {
return null;
}
const primaryMessage = uniqueMessages[primaryIndex];
if (!primaryMessage) {
return null;
}
const relatedMessages = uniqueMessages.filter((_, index) => index !== primaryIndex);
if (relatedMessages.length === 0) {
return `WebGPU uncaptured error: ${primaryMessage}`;
}
return [
`WebGPU uncaptured error: ${primaryMessage}`,
`Additional uncaptured WebGPU errors (${relatedMessages.length}):`,
...relatedMessages.map((message, index) => `[${index + 1}] ${message}`)
].join('\n');
};
const registerInitializationCleanup = (cleanup: () => void): void => {
if (!acceptInitializationCleanups) {
return;
}
options.__onInitializationCleanupRegistered?.();
initializationCleanups.push(cleanup);
};
const runInitializationCleanups = (): void => {
for (let index = initializationCleanups.length - 1; index >= 0; index -= 1) {
try {
initializationCleanups[index]?.();
} catch {
// Best-effort cleanup on failed renderer initialization.
}
}
initializationCleanups.length = 0;
};
void device.lost.then((info) => {
if (isDestroyed) {
return;
}
const reason = info.reason ? ` (${info.reason})` : '';
const details = info.message?.trim();
deviceLostMessage = details
? `WebGPU device lost: ${details}${reason}`
: `WebGPU device lost${reason}`;
});
const handleUncapturedError = (event: GPUUncapturedErrorEvent): void => {
if (isDestroyed) {
return;
}
const message =
event.error instanceof Error
? event.error.message
: String((event.error as { message?: string })?.message ?? event.error);
const trimmedMessage = message.trim();
const normalizedMessage =
trimmedMessage.length > 0 ? trimmedMessage : 'Unknown GPU validation error';
const lastMessage = uncapturedErrorMessages[uncapturedErrorMessages.length - 1];
if (lastMessage === normalizedMessage) {
return;
}
uncapturedErrorMessages.push(normalizedMessage);
if (uncapturedErrorMessages.length > MAX_UNCAPTURED_ERROR_MESSAGES) {
uncapturedErrorMessages.splice(
0,
uncapturedErrorMessages.length - MAX_UNCAPTURED_ERROR_MESSAGES
);
}
};
device.addEventListener('uncapturederror', handleUncapturedError);
try {
const runtimeContext = buildShaderCompilationRuntimeContext(options);
const convertLinearToSrgb =
!colorPipeline.requiresPresentationPass &&
shouldConvertLinearToSrgb(colorPipeline.outputEncoding, colorPipeline.canvasFormat, 'sdr');
const fragmentTextureKeys = options.textureKeys.filter(
(key) => options.textureDefinitions[key]?.fragmentVisible !== false
);
const builtShader = buildShaderSourceWithMap(
options.fragmentWgsl,
options.uniformLayout,
fragmentTextureKeys,
{
convertLinearToSrgb,
fragmentLineMap: options.fragmentLineMap,
...(options.storageBufferKeys !== undefined
? { storageBufferKeys: options.storageBufferKeys }
: {}),
...(options.storageBufferDefinitions !== undefined
? { storageBufferDefinitions: options.storageBufferDefinitions }
: {})
}
);
const shaderModule = device.createShaderModule({ code: builtShader.code });
await assertCompilation(shaderModule, {
lineMap: builtShader.lineMap,
fragmentSource: options.fragmentSource,
includeSources: options.includeSources,
...(options.defineBlockSource !== undefined
? { defineBlockSource: options.defineBlockSource }
: {}),
materialSource: options.materialSource ?? null,
runtimeContext
});
const normalizedTextureDefinitions = normalizeTextureDefinitions(
options.textureDefinitions,
options.textureKeys
);
const storageBufferKeys = options.storageBufferKeys ?? [];
const storageBufferDefinitions = options.storageBufferDefinitions ?? {};
const storageTextureKeys = options.storageTextureKeys ?? [];
const storageTextureKeySet = new Set(storageTextureKeys);
const fragmentTextureIndexByKey = new Map(
fragmentTextureKeys.map((key, index) => [key, index] as const)
);
const textureBindings = options.textureKeys.map((key): RuntimeTextureBinding => {
const config = normalizedTextureDefinitions[key];
if (!config) {
throw new Error(`Missing texture definition for "${key}"`);
}
const fragmentTextureIndex = fragmentTextureIndexByKey.get(key);
const fragmentVisible = fragmentTextureIndex !== undefined;
const { samplerBinding, textureBinding } = getTextureBindings(fragmentTextureIndex ?? 0);
const sampler = device.createSampler({
magFilter: config.filter,
minFilter: config.filter,
mipmapFilter: config.generateMipmaps ? config.filter : 'nearest',
addressModeU: config.addressModeU,
addressModeV: config.addressModeV,
maxAnisotropy: config.filter === 'linear' ? config.anisotropy : 1
});
// Storage textures use a safe fallback format — the fallback is never
// sampled because storage textures are eagerly allocated with their
// real format/dimensions. Non-storage textures use their own format.
const fallbackFormat = config.storage ? 'rgba8unorm' : config.format;
const fallbackTexture = createFallbackTexture(device, fallbackFormat);
registerInitializationCleanup(() => {
fallbackTexture.destroy();
});
const fallbackView = fallbackTexture.createView();
const runtimeBinding: RuntimeTextureBinding = {
key,
samplerBinding,
textureBinding,
fragmentVisible,
sampler,
fallbackTexture,
fallbackView,
texture: null,
view: fallbackView,
source: null,
width: undefined,
height: undefined,
mipLevelCount: 1,
format: config.format,
colorSpace: config.colorSpace,
defaultColorSpace: config.colorSpace,
flipY: config.flipY,
defaultFlipY: config.flipY,
generateMipmaps: config.generateMipmaps,
defaultGenerateMipmaps: config.generateMipmaps,
premultipliedAlpha: config.premultipliedAlpha,
defaultPremultipliedAlpha: config.premultipliedAlpha,
update: config.update ?? 'once',
lastToken: null
};
if (config.update !== undefined) {
runtimeBinding.defaultUpdate = config.update;
}
// Storage textures: eagerly create GPU texture with explicit dimensions
if (config.storage && config.width && config.height) {
const storageUsage =
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.COPY_DST;
const storageTexture = device.createTexture({
size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
format: config.format,
usage: storageUsage
});
registerInitializationCleanup(() => {
storageTexture.destroy();
});
runtimeBinding.texture = storageTexture as unknown as GPUTexture;
runtimeBinding.view = storageTexture.createView();
runtimeBinding.width = config.width;
runtimeBinding.height = config.height;
}
return runtimeBinding;
});
const textureBindingByKey = new Map(textureBindings.map((binding) => [binding.key, binding]));
const fragmentTextureBindings = textureBindings.filter((binding) => binding.fragmentVisible);
const computeStorageBufferLayoutEntries: GPUBindGroupLayoutEntry[] = storageBufferKeys.map(
(key, index) => {
const def = storageBufferDefinitions[key];
const access = def?.access ?? 'read-write';
const bufferType: GPUBufferBindingType =
access === 'read' ? 'read-only-storage' : 'storage';
return {
binding: index,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: bufferType }
};
}
);
const computeStorageBufferTopologyKey = storageBufferKeys
.map((key) => `${key}:${storageBufferDefinitions[key]?.access ?? 'read-write'}`)
.join('|');
const computeStorageTextureLayoutEntries: GPUBindGroupLayoutEntry[] = storageTextureKeys.map(
(key, index) => {
const config = normalizedTextureDefinitions[key];
return {
binding: index,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: 'write-only' as GPUStorageTextureAccess,
format: (config?.format ?? 'rgba8unorm') as GPUTextureFormat,
viewDimension: '2d'
}
};
}
);
const computeStorageTextureTopologyKey = storageTextureKeys
.map((key) => `${key}:${normalizedTextureDefinitions[key]?.format ?? 'rgba8unorm'}`)
.join('|');
const computeStorageBufferBindGroupCache = createComputeStorageBindGroupCache(device);
const computeStorageTextureBindGroupCache = createComputeStorageBindGroupCache(device);
const bindGroupLayout = device.createBindGroupLayout({
entries: createBindGroupLayoutEntries(fragmentTextureBindings)
});
const fragmentStorageBindGroupLayout =
storageBufferKeys.length > 0
? device.createBindGroupLayout({
entries: storageBufferKeys.map((_, index) => ({
binding: index,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: 'read-only-storage' as GPUBufferBindingType }
}))
})
: null;
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: fragmentStorageBindGroupLayout
? [bindGroupLayout, fragmentStorageBindGroupLayout]
: [bindGroupLayout]
});
const pipeline = device.createRenderPipeline({
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: 'motiongpuVertex'
},
fragment: {
module: shaderModule,
entryPoint: 'motiongpuFragment',
targets: [{ format: scenePipelineFormat }]
},
primitive: {
topology: 'triangle-list'
}
});
const presentationBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
sampler: { type: 'filtering' }
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
viewDimension: '2d',
multisampled: false
}
}
]
});
const presentationPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [presentationBindGroupLayout]
});
const presentationPipelines = new Map<string, GPURenderPipeline>();
const buildPresentationPipelineKey = (
canvasFormat: GPUTextureFormat,
dynamicRange: EffectiveDynamicRange,
applyFinalTransform: boolean
): string => {
return `${canvasFormat}|${dynamicRange}|${applyFinalTransform}`;
};
const createPresentationPipeline = async (
canvasFormat: GPUTextureFormat,
dynamicRange: EffectiveDynamicRange,
applyFinalTransform: boolean
): Promise<void> => {
const key = buildPresentationPipelineKey(canvasFormat, dynamicRange, applyFinalTransform);
if (presentationPipelines.has(key)) {
return;
}
const convertPresentationLinearToSrgb =
applyFinalTransform &&
shouldConvertLinearToSrgb(colorPipeline.outputEncoding, canvasFormat, dynamicRange);
const presentationShaderModule = device.createShaderModule({
code: buildPresentationShader({
toneMapping: applyFinalTransform ? colorPipeline.toneMapping : 'none',
convertLinearToSrgb: convertPresentationLinearToSrgb,
dynamicRange
})
});
await assertCompilation(presentationShaderModule);
presentationPipelines.set(
key,
device.createRenderPipeline({
layout: presentationPipelineLayout,
vertex: {
module: presentationShaderModule,
entryPoint: 'motiongpuPresentationVertex'
},
fragment: {
module: presentationShaderModule,
entryPoint: 'motiongpuPresentationFragment',
targets: [{ format: canvasFormat }]
},
primitive: {
topology: 'triangle-list'
}
})
);
};
await createPresentationPipeline(
colorPipeline.canvasFormat,
colorPipeline.dynamicRange === 'auto' ? 'hdr' : colorPipeline.dynamicRange,
colorPipeline.requiresPresentationPass
);
if (colorPipeline.dynamicRange === 'auto') {
await createPresentationPipeline(
colorPipeline.fallbackCanvasFormat,
'sdr',
colorPipeline.requiresPresentationPass
);
}
const presentationSampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge'
});
let presentationBindGroupByView = new WeakMap<GPUTextureView, GPUBindGroup>();
// ── Storage buffer allocation ────────────────────────────────────────
const storageBufferMap = new Map<string, GPUBuffer>();
const pingPongTexturePairs = new Map<string, PingPongTexturePair>();
for (const key of storageBufferKeys) {
const definition = storageBufferDefinitions[key];
if (!definition) {
continue;
}
const normalized = normalizeStorageBufferDefinition(definition);
const buffer = device.createBuffer({
size: normalized.size,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
});
registerInitializationCleanup(() => {
buffer.destroy();
});
if (definition.initialData) {
const data = definition.initialData;
device.queue.writeBuffer(
buffer,
0,
data.buffer as ArrayBuffer,
data.byteOffset,
data.byteLength
);
}
storageBufferMap.set(key, buffer);
}
const fragmentStorageBindGroup =
fragmentStorageBindGroupLayout && storageBufferKeys.length > 0
? device.createBindGroup({
layout: fragmentStorageBindGroupLayout,
entries: storageBufferKeys.map((key, index) => {
const buffer = storageBufferMap.get(key);
if (!buffer) {
throw new Error(`Storage buffer "${key}" not allocated.`);
}
return { binding: index, resource: { buffer } };
})
})
: null;
const ensurePingPongTexturePair = (target: string): PingPongTexturePair => {
const existing = pingPongTexturePairs.get(target);
if (existing) {
return existing;
}
const config = normalizedTextureDefinitions[target];
if (!config || !config.storage) {
throw new Error(
`PingPongComputePass target "${target}" must reference a texture declared with storage:true.`
);
}
if (!config.width || !config.height) {
throw new Error(
`PingPongComputePass target "${target}" requires explicit texture width and height.`
);
}
const usage =
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.COPY_DST;
const textureA = device.createTexture({
size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
format: config.format,
usage
});
const textureB = device.createTexture({
size: { width: config.width, height: config.height, depthOrArrayLayers: 1 },
format: config.format,
usage
});
registerInitializationCleanup(() => {
textureA.destroy();
});
registerInitializationCleanup(() => {
textureB.destroy();
});
const sampleScalarType = storageTextureSampleScalarType(config.format);
const sampleType = toGpuTextureSampleType(sampleScalarType);
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType,
viewDimension: '2d',
multisampled: false
}
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: 'write-only' as GPUStorageTextureAccess,
format: config.format as GPUTextureFormat,
viewDimension: '2d'
}
}
]
});
const pair: PingPongTexturePair = {
target,
format: config.format as GPUTextureFormat,
width: config.width,
height: config.height,
textureA,
viewA: textureA.createView(),
textureB,
viewB: textureB.createView(),
bindGroupLayout,
readAWriteBBindGroup: null,
readBWriteABindGroup: null
};
pingPongTexturePairs.set(target, pair);
return pair;
};
// ── Compute pipeline setup ──────────────────────────────────────────
interface ComputePipelineEntry {
pipeline: GPUComputePipeline;
bindGroup: GPUBindGroup;
workgroupSize: [number, number, number];
computeSource: string;
}
// Per-source cache state. The renderer resolves the compute source for
// each pass once per frame and looks it up here. The state machine
// preserves the synchronous render contract while still surfacing the
// rich asynchronously-discovered diagnostics from getCompilationInfo()
// and the validation error scope.
//
// State transitions:
// (miss) → pending → ready (compilation succeeded)
// → error (compilation failed)
//
// `pending` carries the optimistically-built entry so the first frame
// after a source change can still dispatch (matching the prior
// synchronous behaviour). If validation later reports an error the
// cache is upgraded and the next render() call surfaces a fully
// attributed Error from the compute-pass loop instead of letting the
// derivative "[Invalid CommandBuffer] is invalid due to a previous
// error" cascade reach the user.
type ComputePipelineCacheState =
| { kind: 'pending'; entry: ComputePipelineEntry; validation: Promise<void> }
| { kind: 'ready'; entry: ComputePipelineEntry }
| { kind: 'error'; error: Error };
const computePipelineCache = new Map<string, ComputePipelineCacheState>();
let nextComputePipelineLabelIndex = 0;
const requestRender = options.requestRender;
const computeBuildResult = (
cacheKey: string,
buildOptions: {
computeSource: string;
pingPongTarget?: string;
pingPongFormat?: GPUTextureFormat;
}
): ComputePipelineCacheState => {
const storageBufferDefs: Record<
string,
{ type: StorageBufferType; access: StorageBufferAccess }
> = {};
for (const key of storageBufferKeys) {
const def = storageBufferDefinitions[key];
if (def) {
const norm = normalizeStorageBufferDefinition(def);
storageBufferDefs[key] = { type: norm.type, access: norm.access };
}
}
const storageTextureDefs: Record<string, { format: GPUTextureFormat }> = {};
for (const key of storageTextureKeys) {
const texDef = options.textureDefinitions[key];
if (texDef?.format) {
storageTextureDefs[key] = { format: texDef.format };
}
}
const isPingPongPipeline = Boolean(
buildOptions.pingPongTarget && buildOptions.pingPongFormat
);
const builtComputeShader = isPingPongPipeline
? buildPingPongComputeShaderSourceWithMap({
compute: buildOptions.computeSource,
uniformLayout: options.uniformLayout,
storageBufferKeys,
storageBufferDefinitions: storageBufferDefs,
target: buildOptions.pingPongTarget!,
targetFormat: buildOptions.pingPongFormat!
})
: buildComputeShaderSourceWithMap({
compute: buildOptions.computeSource,
uniformLayout: options.uniformLayout,
storageBufferKeys,
storageBufferDefinitions: storageBufferDefs,
storageTextureKeys,
storageTextureDefinitions: storageTextureDefs
});
const labelIndex = (nextComputePipelineLabelIndex += 1);
const labelBase = isPingPongPipeline
? `compute-pingpong[${buildOptions.pingPongTarget}/${buildOptions.pingPongFormat}]#${labelIndex}`
: `compute#${labelIndex}`;
const moduleLabel = `${labelBase}:module`;
const pipelineLabel = `${labelBase}:pipeline`;
const workgroupSize = extractWorkgroupSize(buildOptions.computeSource);
// Compute bind group layout: group(0)=uniforms, group(1)=storage buffers, group(2)=storage textures
const computeUniformBGL = device.createBindGroupLayout({
label: `${labelBase}:bgl-uniforms`,
entries: [
{
binding: FRAME_BINDING,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'uniform', minBindingSize: 16 }
},
{
binding: UNIFORM_BINDING,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: 'uniform' }
}
]
});
const storageBGL =
computeStorageBufferLayoutEntries.length > 0
? device.createBindGroupLayout({
label: `${labelBase}:bgl-storage`,
entries: computeStorageBufferLayoutEntries
})
: null;
const storageTextureBGLEntries: GPUBindGroupLayoutEntry[] = isPingPongPipeline
? [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: toGpuTextureSampleType(
storageTextureSampleScalarType(buildOptions.pingPongFormat!)
),
viewDimension: '2d',
multisampled: false
}
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: 'write-only' as GPUStorageTextureAccess,
format: buildOptions.pingPongFormat!,
viewDimension: '2d'
}
}
]
: computeStorageTextureLayoutEntries;
const storageTextureBGL =
storageTextureBGLEntries.length > 0
? device.createBindGroupLayout({
label: `${labelBase}:bgl-storage-textures`,
entries: storageTextureBGLEntries
})
: null;
// Bind group layout indices must match shader @group() indices:
// group(0) = uniforms, group(1) = storage buffers, group(2) = storage textures.
// When a group is unused, insert an empty placeholder to keep indices aligned.
const bindGroupLayouts: GPUBindGroupLayout[] = [computeUniformBGL];
if (storageBGL || storageTextureBGL) {
bindGroupLayouts.push(
storageBGL ??
device.createBindGroupLayout({
label: `${labelBase}:bgl-storage-empty`,
entries: []
})
);
}
if (storageTextureBGL) {
bindGroupLayouts.push(storageTextureBGL);
}
const computePipelineLayout = device.createPipelineLayout({
label: `${labelBase}:layout`,
bindGroupLayouts
});
// Wrap the validation-prone calls in an error scope so the parser
// error and "invalid module/pipeline" cascade are captured here
// instead of leaking to `uncapturederror`. The popped scope is
// awaited together with `getCompilationInfo()` below.
device.pushErrorScope('validation');
let computeShaderModule: GPUShaderModule;
let pipeline: GPUComputePipeline;
try {
computeShaderModule = device.createShaderModule({
label: moduleLabel,
code: builtComputeShader.code
});
pipeline = device.createComputePipeline({
label: pipelineLabel,
layout: computePipelineLayout,
compute: {
module: computeShaderModule,
entryPoint: 'compute'
}
});
} catch (jsError) {
// Always pop the scope even when the synchronous call threw,
// otherwise the scope would leak. Real WebGPU implementations
// rarely throw synchronously for shader compilation issues —
// this branch primarily serves test mocks that simulate a
// thrown `createComputePipeline`.
void device.popErrorScope().catch(() => {
// Discard popped error in the synchronous-throw branch —
// we already have the JS exception with full context.
});
const error = toComputeCompilationError({
error: jsError,
lineMap: builtComputeShader.lineMap,
computeSource: buildOptions.computeSource,
runtimeContext
});
return { kind: 'error', error };
}
const validationScope = device.popErrorScope();
// Build uniform bind group for compute (group 0)
const computeUniformBindGroup = device.createBindGroup({
label: `${labelBase}:bg-uniforms`,
layout: computeUniformBGL,
entries: [
{ binding: FRAME_BINDING, resource: { buffer: frameBuffer } },
{ binding: UNIFORM_BINDING, resource: { buffer: uniformBuffer } }
]
});
const entry: ComputePipelineEntry = {
pipeline,
bindGroup: computeUniformBindGroup,
workgroupSize,
computeSource: buildOptions.computeSource
};
const validation = (async () => {
const compilationError = await assertComputeCompilationAsync({
module: computeShaderModule,
validationScope,
lineMap: builtComputeShader.lineMap,
computeSource: buildOptions.computeSource,
runtimeContext
});
if (isDestroyed) {
return;
}
// Only upgrade state if no later cache-miss has already replaced
// us (defensive — the cache is keyed by source so this should
// be a no-op in practice, but it guards against in-flight
// stragglers when the user edits the same source rapidly).
const current = computePipelineCache.get(cacheKey);
if (current && current.kind !== 'pending') {
return;
}
if (compilationError) {
computePipelineCache.set(cacheKey, { kind: 'error', error: compilationError });
// Drain any derivative-cascade noise queued by the
// optimistic dispatch so the next render() call doesn't
// throw "[Invalid CommandBuffer] is invalid due to a
// previous error" before our rich diagnostic surfaces.
uncapturedErrorMessages.length = 0;
requestRender?.();
} else {
computePipelineCache.set(cacheKey, { kind: 'ready', entry });
}
})();
return { kind: 'pending', entry, validation };
};
const buildComputePipelineEntry = (buildOptions: {
computeSource: string;
pingPongTarget?: string;
pingPongFormat?: GPUTextureFormat;
}): ComputePipelineEntry => {
const cacheKey =
buildOptions.pingPongTarget && buildOptions.pingPongFormat
? `pingpong:${buildOptions.pingPongTarget}:${buildOptions.pingPongFormat}:${buildOptions.computeSource}`
: `compute:${buildOptions.computeSource}`;
const cached = computePipelineCache.get(cacheKey);
if (cached) {
if (cached.kind === 'error') {
// Drain any derivative cascade messages that may have
// arrived between frames so consumeUncapturedErrorMessage
// in the next render() call doesn't surface them.
uncapturedErrorMessages.length = 0;
throw cached.error;
}
return cached.entry;
}
const state = computeBuildResult(cacheKey, buildOptions);
computePipelineCache.set(cacheKey, state);
if (state.kind === 'error') {
uncapturedErrorMessages.length = 0;
throw state.error;
}
return state.entry;
};
// Helper to get the storage bind group for dispatch
const getComputeStorageBindGroup = (): GPUBindGroup | null => {
if (computeStorageBufferLayoutEntries.length === 0) {
return null;
}
const res