UNPKG

@motion-core/motion-gpu

Version:

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

1,366 lines (1,363 loc) 83.4 kB
import { buildComputeShaderSourceWithMap, buildPingPongComputeShaderSourceWithMap, extractWorkgroupSize, storageTextureSampleScalarType } from "./compute-shader.js"; import { packUniformsIntoFast } from "./uniforms.js"; import { getTextureMipLevelCount, normalizeTextureDefinitions, resolveTextureSize, resolveTextureUpdateMode, toTextureData } from "./textures.js"; import { normalizeStorageBufferDefinition } from "./storage-buffers.js"; import { attachShaderCompilationDiagnostics } from "./error-diagnostics.js"; import { buildPingPongShaderSourceWithMap, buildShaderSourceWithMap, formatShaderSourceLocation } from "./shader.js"; import { buildRenderTargetSignature, resolveRenderTargetDefinitions } from "./render-targets.js"; import { planRenderGraph } from "./render-graph.js"; import { createComputeStorageBindGroupCache } from "./compute-bindgroup-cache.js"; import { buildCanvasConfiguration, buildPresentationShader, resolveColorPipeline, shouldConvertLinearToSrgb } from "./color-pipeline.js"; //#region src/lib/core/renderer.ts /** * Binding index for frame uniforms (`time`, `delta`, `resolution`). */ var FRAME_BINDING = 0; /** * Binding index for material uniform buffer. */ var UNIFORM_BINDING = 1; /** * First binding index used for texture sampler/texture pairs. */ var FIRST_TEXTURE_BINDING = 2; var DEFAULT_MAX_COMPUTE_WORKGROUPS_PER_DIMENSION = 65535; var COMPUTE_DISPATCH_AXES = [ "x", "y", "z" ]; function formatComputeDispatchValue(value) { if (value === void 0) 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) { const max = device.limits?.maxComputeWorkgroupsPerDimension; if (typeof max === "number" && Number.isFinite(max) && max > 0) return Math.floor(max); return DEFAULT_MAX_COMPUTE_WORKGROUPS_PER_DIMENSION; } function validateComputeDispatch(dispatch, maxWorkgroupsPerDimension, label) { 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 ]; const output = [ 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) { 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) { 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, dprInput, cssSize) { 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, options) { const errors = (await module.getCompilationInfo()).messages.filter((message) => message.type === "error"); if (errors.length === 0) return; const diagnostics = errors.map((message) => ({ generatedLine: message.lineNum, message: message.message, linePos: message.linePos, lineLength: message.length, sourceLocation: options?.lineMap?.[message.lineNum] ?? null })); const summary = diagnostics.map((diagnostic) => { const contextLabel = [formatShaderSourceLocation(diagnostic.sourceLocation), diagnostic.generatedLine > 0 ? `generated WGSL line ${diagnostic.generatedLine}` : null].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"; throw attachShaderCompilationDiagnostics(/* @__PURE__ */ new Error(`${prefix}:\n${summary}`), { kind: "shader-compilation", ...options?.shaderStage !== void 0 ? { shaderStage: options.shaderStage } : {}, diagnostics, fragmentSource: options?.fragmentSource ?? "", ...options?.computeSource !== void 0 ? { computeSource: options.computeSource } : {}, includeSources: options?.includeSources ?? {}, ...options?.defineBlockSource !== void 0 ? { defineBlockSource: options.defineBlockSource } : {}, materialSource: options?.materialSource ?? null, ...options?.runtimeContext !== void 0 ? { runtimeContext: options.runtimeContext } : {} }); } function toSortedUniqueStrings(values) { 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) { 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) { const summary = input.diagnostics.map((diagnostic) => { const contextLabel = [formatShaderSourceLocation(diagnostic.sourceLocation), diagnostic.generatedLine > 0 ? `generated WGSL line ${diagnostic.generatedLine}` : null].filter((value) => Boolean(value)); if (contextLabel.length === 0) return diagnostic.message; return `[${contextLabel.join(" | ")}] ${diagnostic.message}`; }).join("\n"); return attachShaderCompilationDiagnostics(/* @__PURE__ */ new Error(`Compute shader compilation failed:\n${summary}`), { 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) { 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) { let compilationMessages = []; try { compilationMessages = (await input.module.getCompilationInfo()).messages.filter((message) => message.type === "error"); } catch {} const validationError = await input.validationScope.catch(() => null); if (compilationMessages.length === 0 && !validationError) return null; return buildComputeCompilationError({ diagnostics: compilationMessages.length > 0 ? compilationMessages.map((message) => ({ 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 }], computeSource: input.computeSource, runtimeContext: input.runtimeContext }); } function buildPassGraphSnapshot(passes) { const declaredPasses = passes ?? []; let enabledPassCount = 0; const inputs = []; const outputs = []; for (const pass of declaredPasses) { if (pass.enabled === false) continue; enabledPassCount += 1; if ("isCompute" in pass && pass.isCompute === true) continue; if ("isPingPongShader" in pass && pass.isPingPongShader === true) continue; const rp = pass; 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) { 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, format) { 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, options) { return { source, ...options.flipY ? { flipY: true } : {}, ...options.premultipliedAlpha ? { premultipliedAlpha: true } : {} }; } /** * Uploads source content to the base GPU texture level. */ function uploadTextureBaseLevel(device, texture, binding, source, width, height) { device.queue.copyExternalImageToTexture(createExternalCopySource(source, { flipY: binding.flipY, premultipliedAlpha: binding.premultipliedAlpha }), { texture, mipLevel: 0 }, { width, height, depthOrArrayLayers: 1 }); } var 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); } `; function createGpuMipmapGenerator(device) { let sampler = null; let shaderModule = null; let bindGroupLayout = null; let pipelineLayout = null; const pipelineByFormat = /* @__PURE__ */ new Map(); const ensureBindGroupLayout = () => { 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) => { 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, mipLevelCount) { 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) { const entries = [{ 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. */ var 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. */ var EMPTY_DIRTY_RANGES = []; /** * 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. */ function findDirtyFloatRanges(previous, next, mergeGapThreshold = DIRTY_RANGE_MERGE_GAP) { let start = -1; let rangeCount = 0; const ranges = []; 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) return EMPTY_DIRTY_RANGES; if (rangeCount <= 1) return ranges; const merged = [ranges[0]]; for (let index = 1; index < rangeCount; index += 1) { const prev = merged[merged.length - 1]; const curr = ranges[index]; if (curr.start - (prev.start + prev.count) <= 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, width, height, format) { 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) { target?.texture.destroy(); } function toClearValue(color) { return { r: color[0], g: color[1], b: color[2], a: color[3] }; } function toPremultipliedCanvasClearValue(color) { 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. */ async function createRenderer(options) { if (!navigator.gpu) throw new Error("WebGPU is not available in this browser"); const context = options.canvas.getContext("webgpu"); 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 = 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 = null; const uncapturedErrorMessages = []; const initializationCleanups = []; let acceptInitializationCleanups = true; const MAX_UNCAPTURED_ERROR_MESSAGES = 12; const isDerivativeUncapturedMessage = (message) => { const normalized = message.toLowerCase(); return normalized.includes("is invalid due to a previous error") || normalized.includes("too many warnings, no more warnings will be reported"); }; const consumeUncapturedErrorMessage = () => { if (uncapturedErrorMessages.length === 0) return null; const uniqueMessages = []; for (const message of uncapturedErrorMessages) if (!uniqueMessages.includes(message)) uniqueMessages.push(message); uncapturedErrorMessages.length = 0; const primaryIndex = uniqueMessages.findIndex((message) => !isDerivativeUncapturedMessage(message)); 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) => { if (!acceptInitializationCleanups) return; options.__onInitializationCleanupRegistered?.(); initializationCleanups.push(cleanup); }; const runInitializationCleanups = () => { for (let index = initializationCleanups.length - 1; index >= 0; index -= 1) try { initializationCleanups[index]?.(); } catch {} initializationCleanups.length = 0; }; 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) => { if (isDestroyed) return; const trimmedMessage = (event.error instanceof Error ? event.error.message : String(event.error?.message ?? event.error)).trim(); const normalizedMessage = trimmedMessage.length > 0 ? trimmedMessage : "Unknown GPU validation error"; if (uncapturedErrorMessages[uncapturedErrorMessages.length - 1] === 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) => buildShaderSourceWithMap(options.fragmentWgsl, options.uniformLayout, fragmentTextureKeys, { convertLinearToSrgb, premultiplyOutputAlpha, fragmentLineMap: options.fragmentLineMap, ...options.storageBufferKeys !== void 0 ? { storageBufferKeys: options.storageBufferKeys } : {}, ...options.storageBufferDefinitions !== void 0 ? { storageBufferDefinitions: options.storageBufferDefinitions } : {} }); const builtShader = buildSceneShader(false); const shaderModule = device.createShaderModule({ code: builtShader.code }); const assertSceneShaderCompilation = (module, builtSource) => assertCompilation(module, { lineMap: builtSource.lineMap, fragmentSource: options.fragmentSource, includeSources: options.includeSources, ...options.defineBlockSource !== void 0 ? { 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])); const textureBindings = options.textureKeys.map((key) => { const config = normalizedTextureDefinitions[key]; if (!config) throw new Error(`Missing texture definition for "${key}"`); const fragmentTextureIndex = fragmentTextureIndexByKey.get(key); const fragmentVisible = fragmentTextureIndex !== void 0; 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 fallbackTexture = createFallbackTexture(device, config.format === "rgba8unorm-srgb" ? "rgba8unorm-srgb" : "rgba8unorm"); registerInitializationCleanup(() => { fallbackTexture.destroy(); }); const fallbackView = fallbackTexture.createView(); const runtimeBinding = { key, samplerBinding, textureBinding, fragmentVisible, sampler, fallbackTexture, fallbackView, texture: null, view: fallbackView, source: null, width: void 0, height: void 0, 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 !== void 0) runtimeBinding.defaultUpdate = config.update; 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; 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 = storageBufferKeys.map((key, index) => { const bufferType = (storageBufferDefinitions[key]?.access ?? "read-write") === "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 = storageTextureKeys.map((key, index) => { const config = normalizedTextureDefinitions[key]; return { binding: index, visibility: GPUShaderStage.COMPUTE, storageTexture: { access: "write-only", format: config?.format ?? "rgba8unorm", 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" } })) }) : 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 = /* @__PURE__ */ new Map(); const buildPresentationPipelineKey = (canvasFormat, dynamicRange, applyFinalTransform, premultiplyAlpha) => { return `${canvasFormat}|${dynamicRange}|${applyFinalTransform}|${premultiplyAlpha}`; }; const createPresentationPipeline = async (canvasFormat, dynamicRange, applyFinalTransform, premultiplyAlpha) => { 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 = /* @__PURE__ */ new WeakMap(); const storageBufferMap = /* @__PURE__ */ new Map(); const pingPongTexturePairs = /* @__PURE__ */ new Map(); const pingPongShaderTexturePairs = /* @__PURE__ */ new Map(); 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, 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) => { 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 sampleType = toGpuTextureSampleType(storageTextureSampleScalarType(config.format)); 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", format: config.format, viewDimension: "2d" } }] }); const pair = { target, format: config.format, 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) => { pair.textureA.destroy(); pair.textureB.destroy(); }; const ensurePingPongShaderTexturePair = (pass, options) => { 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 = { 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; }; const MAX_COMPUTE_PIPELINE_CACHE_ENTRIES = 32; const computePipelineCache = /* @__PURE__ */ new Map(); let nextComputePipelineLabelIndex = 0; const requestRender = options.requestRender; const setComputePipelineCacheState = (cacheKey, state) => { if (computePipelineCache.has(cacheKey)) computePipelineCache.delete(cacheKey); computePipelineCache.set(cacheKey, state); while (computePipelineCache.size > MAX_COMPUTE_PIPELINE_CACHE_ENTRIES) { const oldestKey = computePipelineCache.keys().next().value; if (oldestKey === void 0) break; computePipelineCache.delete(oldestKey); } }; const touchComputePipelineCacheState = (cacheKey, state) => { computePipelineCache.delete(cacheKey); computePipelineCache.set(cacheKey, state); }; const computeBuildResult = (cacheKey, buildOptions) => { const storageBufferDefs = {}; 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 = {}; 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); 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 = 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", format: buildOptions.pingPongFormat, viewDimension: "2d" } }] : computeStorageTextureLayoutEntries; const storageTextureBGL = storageTextureBGLEntries.length > 0 ? device.createBindGroupLayout({ label: `${labelBase}:bgl-storage-textures`, entries: storageTextureBGLEntries }) : null; const bindGroupLayouts = [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 }); device.pushErrorScope("validation"); let computeShaderModule; let pipeline; try { computeShaderModule = device.createShaderModule({ label: moduleLabel, code: builtComputeShader.code }); pipeline = device.createComputePipeline({ label: pipelineLabel, layout: computePipelineLayout, compute: { module: computeShaderModule, entryPoint: "compute" } }); } catch (jsError) { device.popErrorScope().catch(() => {}); return { kind: "error", error: toComputeCompilationError({ error: jsError, lineMap: builtComputeShader.lineMap, computeSource: buildOptions.computeSource, runtimeContext }) }; } const validationScope = device.popErrorScope(); 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 = { pipeline, bindGroup: computeUniformBindGroup, workgroupSize, computeSource: buildOptions.computeSource }; return { kind: "pending", entry, validation: (async () => { const compilationError = await assertComputeCompilationAsync({ module: computeShaderModule, validationScope, lineMap: builtComputeShader.lineMap, computeSource: buildOptions.computeSource, runtimeContext }); if (isDestroyed) return; const current = computePipelineCache.get(cacheKey); if (!current || current.kind !== "pending") return; if (compilationError) { setComputePipelineCacheState(cacheKey, { kind: "error", error: compilationError }); uncapturedErrorMessages.length = 0; requestRender?.(); } else setComputePipelineCacheState(cacheKey, { kind: "ready", entry }); })() }; }; const buildComputePipelineEntry = (buildOptions) => { const cacheKey = buildOptions.pingPongTarget && buildOptions.pingPongFormat ? `pingpong:${buildOptions.pingPongTarget}:${buildOptions.pingPongFormat}:${buildOptions.computeSource}` : `compute:${buildOptions.computeSource}`; const cached = computePipelineCache.get(cacheKey); if (cached) { touchComputePipelineCacheState(cacheKey, cached); if (cached.kind === "error") { uncapturedErrorMessages.length = 0; throw cached.error; } return cached.entry; } const state = computeBuildResult(cacheKey, buildOptions); setComputePipelineCacheState(cacheKey, state); if (state.kind === "error") { uncapturedErrorMessages.length = 0; throw state.error; } return state.entry; }; const pingPongShaderPipelineCache = /* @__PURE__ */ new Map(); const getFragmentTextureBindingsForKeys = (keys) => keys.map((key, index) => { const binding = textureBindingByKey.get(key); if (!binding || !binding.fragmentVisible) throw new Error(`Missing fragment texture binding for "${key}".`); return { ...binding, ...getTextureBindings(index) }; }); const buildPingPongShaderPipelineEntry = (pass, format, target) => { const fragment = pass.getFragment?.(); if (!fragment) throw new Error("PingPongShaderPass must provide a fragment shader."); const feedbackTextureKeys = fragmentTextureKeys.filter((key) => key !== target); const cacheKey = [ format, target, feedbackTextureKeys.join(","), options.uniformLayout.entries.map((entry) => `${entry.name}:${entry.type}`).join(","), fragment ].join("|"); const cached = pingPongShaderPipelineCache.get(cacheKey); if (cached) return cached; const fragmentLineMap = pass.getFragmentLineMap?.(); const builtShader = buildPingPongShaderSourceWithMap(fragment, options.uniformLayout, feedbackTextureKeys, fragmentLineMap ? { fragmentLineMap } : {}); const shaderModule = device.createShaderModule({ code: builtShader.code }); const feedbackBindGroupLayout = device.createBindGroupLayout({ entries: createBindGroupLayoutEntries(getFragmentTextureBindingsForKeys(feedbackTextureKeys)) }); const previousBindGroupLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d", multisampled: false } }] }); const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [feedbackBindGroupLayout, previousBindGroupLayout] }); const entry = { pipeline: device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: shaderModule, entryPoint: "motiongpuPingPongVertex" }, fragment: { module: shaderModule, entryPoint: "motiongpuPingPongFragment", targets: [{ format }] }, primitive: { topology: "triangle-list" } }), bindGroupLayout: feedbackBindGroupLayout, previousBindGroupLayout, textureKeys: feedbackTextureKeys }; pingPongShaderPipelineCache.set(cacheKey, entry); return entry; }; const getComputeStorageBindGroup = () => { if (computeStorageBufferLayoutEntries.length === 0) return null; const resources = storageBufferKeys.map((key) => { const buffer = storageBufferMap.get(key); if (!buffer) throw new Error(`Storage buffer "${key}" not allocated.`); return buffer; }); const storageEntries = resources.map((buffer, index) => { return { binding: index, resource: { buffer } }; }); return computeStorageBufferBindGroupCache.getOrCreate({ topologyKey: computeStorageBufferTopologyKey, layoutEntries: computeStorageBufferLayoutEntries, bindGroupEntries: storageEntries, resourceRefs: resources }); }; const getComputeStorageTextureBindGroup = () => { if (computeStorageTextureLayoutEntries.length === 0) return null; const resources = storageTextureKeys.map((key) => { const binding = textureBindingByKey.get(key); if (!binding || !binding.texture) throw new Error(`Storage texture "${key}" not allocated.`); return binding.view; }); const bgEntries = resources.map((view, index) => { return { binding: index, resource: view }; }); return computeStorageTextureBindGroupCache.getOrCreate({ topologyKey: computeStorageTextureTopologyKey, layoutEntries: computeStorageTextureLayoutEntries, bindGroupEntries: bgEntries, resourceRefs: resources }); }; const getPingPongStorageTextureBindGroup = (target, readFromA) => { const pair = ensurePingPongTexturePair(target); if (readFromA) { if (!pair.readAWriteBBindGroup) pair.readAWriteBBindGroup = device.createBindGroup({ layout: pair.bindGroupLayout, entries: [{ binding: 0, resource: pair.viewA }, { binding: 1, resource: pair.viewB }] }); return pair.readAWriteBBindGroup; } if (!pair.readBWriteABindGroup) pair.readBWriteABindGroup = device.createBindGroup({ layout: pair.bindGroupLayout, entries: [{ binding: 0, resource: pair.viewB }, { binding: 1, resource: pair.viewA }] }); return pair.readBWriteABindGroup; }; const getPingPongShaderPreviousBindGroup = (pair, layout, readFromA) => { if (pair.previousBindGroupLayout !== layout) { pair.previousBindGroupLayout = layout; pair.readABindGroup = null; pair.readBBindGroup = null; } if (readFromA) { if (!pair.readABindGroup) pair.readABindGroup = device.createBindGroup({ layout, entries: [{ binding: 0, resource: pair.sampler }, { binding: 1, resource: pair.viewA }] }); return pair.readABindGroup; } if (!pair.readBBindGroup) pair.readBBindGroup = device.createBindGroup({ layout, entries: [{ binding: 0, resource: pair.sampler }, { binding: 1, resource: pair.viewB }] }); return pair.readBBindGroup; }; const frameBuffer = device.cr