UNPKG

@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
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