UNPKG

@motion-core/motion-gpu

Version:

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

1,698 lines (1,566 loc) 105 kB
import { buildRenderTargetSignature, resolveRenderTargetDefinitions } from './render-targets.js'; import { planRenderGraph, type RenderGraphPlan } from './render-graph.js'; import { buildPingPongShaderSourceWithMap, buildShaderSourceWithMap, formatShaderSourceLocation, type ShaderLineMap } from './shader.js'; import type { MaterialLineMap } from './material-preprocess.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; mipmapsDirty: boolean; feedbackViewActive: boolean; } /** * 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; } /** * Runtime fragment-feedback textures for a single pass instance. */ interface PingPongShaderTexturePair { target: string; format: GPUTextureFormat; width: number; height: number; filter: GPUFilterMode; addressModeU: GPUAddressMode; addressModeV: GPUAddressMode; textureA: GPUTexture; viewA: GPUTextureView; textureB: GPUTexture; viewB: GPUTextureView; sampler: GPUSampler; previousBindGroupLayout: GPUBindGroupLayout | null; readABindGroup: GPUBindGroup | null; readBBindGroup: GPUBindGroup | null; needsClear: boolean; } /** * 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; } /** * Internal shape implemented by renderer-managed fragment feedback pass classes. */ interface RuntimePingPongShaderPass { isPingPongShader?: boolean; getTarget?: () => string; getFragment?: () => string; getFragmentLineMap?: () => MaterialLineMap; resolveSize?: (canvasSize: { width: number; height: number }) => { width: number; height: number; }; getIterations?: () => number; getFormat?: () => GPUTextureFormat; getFilter?: () => GPUFilterMode; getAddressModeU?: () => GPUAddressMode; getAddressModeV?: () => GPUAddressMode; getClearColor?: () => [number, number, number, number]; getCurrentOutput?: () => string; advanceFrame?: () => void; consumeResetColor?: () => [number, number, number, number] | null; } const DEFAULT_MAX_COMPUTE_WORKGROUPS_PER_DIMENSION = 65_535; const COMPUTE_DISPATCH_AXES = ['x', 'y', 'z'] as const; function formatComputeDispatchValue(value: unknown): string { if (value === undefined) { return 'undefined'; } if (typeof value === 'number') { return Number.isNaN(value) ? 'NaN' : String(value); } if (typeof value === 'string') { return `"${value}"`; } try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } } function getMaxComputeWorkgroupsPerDimension(device: GPUDevice): number { const max = (device.limits as GPUSupportedLimits | undefined)?.maxComputeWorkgroupsPerDimension; if (typeof max === 'number' && Number.isFinite(max) && max > 0) { return Math.floor(max); } return DEFAULT_MAX_COMPUTE_WORKGROUPS_PER_DIMENSION; } function validateComputeDispatch( dispatch: unknown, maxWorkgroupsPerDimension: number, label: string ): [number, number, number] { if (!Array.isArray(dispatch)) { throw new Error( `${label} dispatch must resolve to an array [x, y, z], got ${formatComputeDispatchValue(dispatch)}.` ); } const resolved = [dispatch[0], dispatch[1] ?? 1, dispatch[2] ?? 1] as const; const output: [number, number, number] = [1, 1, 1]; for (let index = 0; index < COMPUTE_DISPATCH_AXES.length; index += 1) { const axis = COMPUTE_DISPATCH_AXES[index]; const value = resolved[index]; if ( typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value < 1 ) { throw new Error( `${label} dispatch ${axis} must be a positive integer, got ${formatComputeDispatchValue(value)}.` ); } if (value > maxWorkgroupsPerDimension) { throw new Error( `${label} dispatch ${axis} must be <= device.limits.maxComputeWorkgroupsPerDimension (${maxWorkgroupsPerDimension}), got ${value}.` ); } output[index] = value; } return output; } /** * 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; } if ( 'isPingPongShader' in pass && (pass as { isPingPongShader?: boolean }).isPingPongShader === 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 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 the base GPU texture level. */ function uploadTextureBaseLevel( device: GPUDevice, texture: GPUTexture, binding: Pick<RuntimeTextureBinding, 'flipY' | 'premultipliedAlpha'>, source: TextureSource, width: number, height: number ): void { device.queue.copyExternalImageToTexture( createExternalCopySource(source, { flipY: binding.flipY, premultipliedAlpha: binding.premultipliedAlpha }), { texture, mipLevel: 0 }, { width, height, depthOrArrayLayers: 1 } ); } const GPU_MIPMAP_SHADER = ` struct VertexOutput { @builtin(position) position: vec4f, @location(0) uv: vec2f }; @vertex fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { var positions = array<vec2f, 3>( vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0) ); let position = positions[vertexIndex]; var out: VertexOutput; out.position = vec4f(position, 0.0, 1.0); out.uv = position * vec2f(0.5, -0.5) + vec2f(0.5, 0.5); return out; } @group(0) @binding(0) var mipSampler: sampler; @group(0) @binding(1) var mipSource: texture_2d<f32>; @fragment fn fragmentMain(in: VertexOutput) -> @location(0) vec4f { return textureSample(mipSource, mipSampler, in.uv); } `; interface GpuMipmapGenerator { generate: (input: { commandEncoder: GPUCommandEncoder; texture: GPUTexture; format: GPUTextureFormat; mipLevelCount: number; }) => void; } function createGpuMipmapGenerator(device: GPUDevice): GpuMipmapGenerator { let sampler: GPUSampler | null = null; let shaderModule: GPUShaderModule | null = null; let bindGroupLayout: GPUBindGroupLayout | null = null; let pipelineLayout: GPUPipelineLayout | null = null; const pipelineByFormat = new Map<GPUTextureFormat, GPURenderPipeline>(); const ensureBindGroupLayout = (): GPUBindGroupLayout => { if (!bindGroupLayout) { bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } } ] }); } return bindGroupLayout; }; const ensurePipeline = (format: GPUTextureFormat): GPURenderPipeline => { const cached = pipelineByFormat.get(format); if (cached) { return cached; } const layout = ensureBindGroupLayout(); shaderModule ??= device.createShaderModule({ code: GPU_MIPMAP_SHADER }); pipelineLayout ??= device.createPipelineLayout({ bindGroupLayouts: [layout] }); const pipeline = device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: shaderModule, entryPoint: 'vertexMain' }, fragment: { module: shaderModule, entryPoint: 'fragmentMain', targets: [{ format }] }, primitive: { topology: 'triangle-list' } }); pipelineByFormat.set(format, pipeline); return pipeline; }; return { generate: ({ commandEncoder, texture, format, mipLevelCount }) => { if (mipLevelCount <= 1) { return; } sampler ??= device.createSampler({ minFilter: 'linear', magFilter: 'linear' }); const layout = ensureBindGroupLayout(); const pipeline = ensurePipeline(format); for (let level = 1; level < mipLevelCount; level += 1) { const sourceView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 }); const targetView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 }); const bindGroup = device.createBindGroup({ layout, entries: [ { binding: 0, resource: sampler }, { binding: 1, resource: sourceView } ] }); const pass = commandEncoder.beginRenderPass({ colorAttachments: [ { view: targetView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' } ] }); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.draw(3); pass.end(); } } }; } function markTextureMipmapsDirty( binding: Pick<RuntimeTextureBinding, 'generateMipmaps' | 'mipmapsDirty'>, mipLevelCount: number ): void { if (binding.generateMipmaps && mipLevelCount > 1) { binding.mipmapsDirty = true; } else { binding.mipmapsDirty = false; } } /** * 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(); } function toClearValue(color: [number, number, number, number]): GPUColorDict { return { r: color[0], g: color[1], b: color[2], a: color[3] }; } function toPremultipliedCanvasClearValue(color: [number, number, number, number]): GPUColorDict { const alpha = Math.min(Math.max(color[3], 0), 1); return { r: color[0] * alpha, g: color[1] * alpha, b: color[2] * alpha, a: alpha }; } /** * 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); const maxComputeWorkgroupsPerDimension = getMaxComputeWorkgroupsPerDimension(device); 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}`; options.requestRender?.(); }); 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 ); } options.requestRender?.(); }; 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 buildSceneShader = (premultiplyOutputAlpha: boolean) => buildShaderSourceWithMap(options.fragmentWgsl, options.uniformLayout, fragmentTextureKeys, { convertLinearToSrgb, premultiplyOutputAlpha, fragmentLineMap: options.fragmentLineMap, ...(options.storageBufferKeys !== undefined ? { storageBufferKeys: options.storageBufferKeys } : {}), ...(options.storageBufferDefinitions !== undefined ? { storageBufferDefinitions: options.storageBufferDefinitions } : {}) }); const builtShader = buildSceneShader(false); const shaderModule = device.createShaderModule({ code: builtShader.code }); const assertSceneShaderCompilation = ( module: GPUShaderModule, builtSource: typeof builtShader ) => assertCompilation(module, { lineMap: builtSource.lineMap, fragmentSource: options.fragmentSource, includeSources: options.includeSources, ...(options.defineBlockSource !== undefined ? { defineBlockSource: options.defineBlockSource } : {}), materialSource: options.materialSource ?? null, runtimeContext }); await assertSceneShaderCompilation(shaderModule, builtShader); const builtDirectCanvasShader = !colorPipeline.requiresPresentationPass ? buildSceneShader(true) : null; const directCanvasShaderModule = builtDirectCanvasShader ? device.createShaderModule({ code: builtDirectCanvasShader.code }) : null; if (directCanvasShaderModule && builtDirectCanvasShader) { await assertSceneShaderCompilation(directCanvasShaderModule, builtDirectCanvasShader); } 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 }); const fallbackFormat: GPUTextureFormat = config.format === 'rgba8unorm-srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm'; 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, mipmapsDirty: false, feedbackViewActive: false }; 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 directCanvasPipeline = directCanvasShaderModule ? device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: directCanvasShaderModule, entryPoint: 'motiongpuVertex' }, fragment: { module: directCanvasShaderModule, entryPoint: 'motiongpuFragment', targets: [{ format: colorPipeline.canvasFormat }] }, primitive: { topology: 'triangle-list' } }) : null; 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, premultiplyAlpha: boolean ): string => { return `${canvasFormat}|${dynamicRange}|${applyFinalTransform}|${premultiplyAlpha}`; }; const createPresentationPipeline = async ( canvasFormat: GPUTextureFormat, dynamicRange: EffectiveDynamicRange, applyFinalTransform: boolean, premultiplyAlpha: boolean ): Promise<void> => { const key = buildPresentationPipelineKey( canvasFormat, dynamicRange, applyFinalTransform, premultiplyAlpha ); 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, premultiplyAlpha }) }); 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, true ); if (colorPipeline.dynamicRange === 'auto') { await createPresentationPipeline( colorPipeline.fallbackCanvasFormat, 'sdr', colorPipeline.requiresPresentationPass, true ); } 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>(); const pingPongShaderTexturePairs = new Map< RuntimePingPongShaderPass, PingPongShaderTexturePair >(); 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; }; const destroyPingPongShaderTexturePair = (pair: PingPongShaderTexturePair): void => { pair.textureA.destroy(); pair.textureB.destroy(); }; const ensurePingPongShaderTexturePair = ( pass: RuntimePingPongShaderPass, options: { target: string; width: number; height: number; format: GPUTextureFormat; filter: GPUFilterMode; addressModeU: GPUAddressMode; addressModeV: GPUAddressMode; } ): PingPongShaderTexturePair => { const existing = pingPongShaderTexturePairs.get(pass); if ( existing && existing.target === options.target && existing.width === options.width && existing.height === options.height && existing.format === options.format && existing.filter === options.filter && existing.addressModeU === options.addressModeU && existing.addressModeV === options.addressModeV ) { return existing; } if (existing) { destroyPingPongShaderTexturePair(existing); } const usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST; const textureA = device.createTexture({ size: { width: options.width, height: options.height, depthOrArrayLayers: 1 }, format: options.format, usage }); const textureB = device.createTexture({ size: { width: options.width, height: options.height, depthOrArrayLayers: 1 }, format: options.format, usage }); const sampler = device.createSampler({ magFilter: options.filter, minFilter: options.filter, addressModeU: options.addressModeU, addressModeV: options.addressModeV }); const pair: PingPongShaderTexturePair = { target: options.target, format: options.format, width: options.width, height: options.height, filter: options.filter, addressModeU: options.addressModeU, addressModeV: options.addressModeV, textureA, viewA: textureA.createView(), textureB, viewB: textureB.createView(), sampler, previousBindGroupLayout: null, readABindGroup: null, readBBindGroup: null, needsClear: true }; pingPongShaderTexturePairs.set(pass, 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 succeed