UNPKG

@motion-core/motion-gpu

Version:

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

407 lines (372 loc) 12.7 kB
import type { AnyPass, RenderPass, RenderPassInputSlot, RenderPassOutputSlot } from './types.js'; import type { ResolvedComputeAccess, ResolvedComputePassResources } from './compute-resources.js'; import { createMotionGPUError } from './error-report.js'; import { assertMotionGPUPass, isManagedComputePass, isManagedFeedbackPass } from './pass-contract.js'; /** * Resolved render-pass step with defaults applied. */ export interface RenderGraphStep { /** * Step kind. 'render' for post-scene render passes, 'compute' for pre-scene * compute passes, 'feedback' for pre-scene fragment ping-pong passes. */ kind: 'render' | 'compute' | 'feedback'; /** * User pass instance. */ pass: AnyPass; /** * Resolved input slot. Ignored for compute steps. */ input: RenderPassInputSlot; /** * Resolved output slot. Ignored for compute steps. */ output: RenderPassOutputSlot; /** * Whether ping-pong swap should be performed after render. */ needsSwap: boolean; /** * Whether pass should clear output before drawing. */ clear: boolean; /** * Effective clear color. */ clearColor: [number, number, number, number]; /** * Whether output should be preserved after pass ends. */ preserve: boolean; /** Frame-snapshot resources consumed by a compute step. */ resolvedResources?: ResolvedComputePassResources; /** Stable diagnostic label assigned by the renderer. */ computeLabel?: string; } export interface ComputeRenderGraphOptions { getResolvedResources: (pass: AnyPass) => ResolvedComputePassResources | undefined; getPassLabel?: (pass: AnyPass) => string; } /** * Immutable render-graph execution plan for one frame. */ export interface RenderGraphPlan { /** * Resolved enabled steps in declaration order. */ steps: RenderGraphStep[]; /** * Enabled pre-scene steps in declaration order. */ preSceneSteps: RenderGraphStep[]; /** * Enabled compute steps. These always execute before the base scene render. */ computeSteps: RenderGraphStep[]; /** * Enabled render steps. These always execute after the base scene render. */ renderSteps: RenderGraphStep[]; /** * Output slot holding final post-scene render result before presentation. * Remains 'canvas' when there are no render steps. */ finalOutput: RenderPassOutputSlot; } /** * Creates a copy of RGBA clear color. */ function cloneClearColor( color: [number, number, number, number] ): [number, number, number, number] { return [color[0], color[1], color[2], color[3]]; } interface ComputeDependencyEdge { from: number; to: number; access: ResolvedComputeAccess; } /** * Returns the concrete resource identity used to relate logical aliases. */ function physicalResourceMapKey(access: ResolvedComputeAccess): object | string | symbol { return access.physicalId; } /** * Reports whether two accesses may touch the same texture subresource. * Non-texture and unspecified ranges overlap conservatively. */ function textureSubresourcesOverlap( left: ResolvedComputeAccess, right: ResolvedComputeAccess ): boolean { if (left.resourceKind !== 'texture' || right.resourceKind !== 'texture') { return true; } if (!left.subresource || !right.subresource) { return true; } const leftMipEnd = left.subresource.baseMipLevel + left.subresource.mipLevelCount; const rightMipEnd = right.subresource.baseMipLevel + right.subresource.mipLevelCount; const leftLayerEnd = left.subresource.baseArrayLayer + left.subresource.arrayLayerCount; const rightLayerEnd = right.subresource.baseArrayLayer + right.subresource.arrayLayerCount; return ( left.subresource.baseMipLevel < rightMipEnd && right.subresource.baseMipLevel < leftMipEnd && left.subresource.baseArrayLayer < rightLayerEnd && right.subresource.baseArrayLayer < leftLayerEnd ); } /** * Formats a logical compute resource for actionable graph diagnostics. */ function formatLogicalResource(access: ResolvedComputeAccess): string { const id = typeof access.logicalId === 'symbol' ? (access.logicalId.description ?? access.logicalId.toString()) : access.logicalId; return `${access.resourceKind} "${id}"`; } /** * Orders a compute segment by physical hazards while preserving stable source order. */ function stableTopologicalComputeSegment(segment: RenderGraphStep[]): RenderGraphStep[] { if (segment.length < 2) return segment; type Writer = { index: number; access: ResolvedComputeAccess }; const textureWriters = new Map<object | string | symbol, Writer[]>(); const bufferWriters = new Map<object | string | symbol, Writer[]>(); for (let index = 0; index < segment.length; index += 1) { const step = segment[index]; if (!step?.resolvedResources) continue; for (const access of step.resolvedResources.writes) { const writers = access.resourceKind === 'texture' ? textureWriters : bufferWriters; const physicalId = physicalResourceMapKey(access); const resourceWriters = writers.get(physicalId) ?? []; const previous = resourceWriters.find( (writer) => writer.index !== index && textureSubresourcesOverlap(writer.access, access) ); if (previous) { const previousStep = segment[previous.index]; throw createMotionGPUError( 'COMPUTE_GRAPH_MULTIPLE_WRITERS', `Compute graph has multiple writers for ${formatLogicalResource(access)}: ${previousStep?.computeLabel ?? `compute pass #${previous.index}`} and ${step.computeLabel ?? `compute pass #${index}`} (alias "${access.alias}").` ); } resourceWriters.push({ index, access }); writers.set(physicalId, resourceWriters); } } const edges: ComputeDependencyEdge[] = []; const edgeKeys = new Set<string>(); const addEdge = (from: number, to: number, access: ResolvedComputeAccess): void => { if (from === to) return; const key = `${from}:${to}`; if (edgeKeys.has(key)) return; edgeKeys.add(key); edges.push({ from, to, access }); }; for (let readerIndex = 0; readerIndex < segment.length; readerIndex += 1) { const resources = segment[readerIndex]?.resolvedResources; if (!resources) continue; for (const access of resources.reads) { const writers = access.resourceKind === 'texture' ? textureWriters : bufferWriters; const resourceWriters = writers.get(physicalResourceMapKey(access)) ?? []; for (const writer of resourceWriters) { if (!textureSubresourcesOverlap(writer.access, access)) continue; if (access.version === 'initial') { addEdge(readerIndex, writer.index, access); } else { addEdge(writer.index, readerIndex, access); } } } } const outgoing = Array.from({ length: segment.length }, () => [] as ComputeDependencyEdge[]); const indegree = new Array<number>(segment.length).fill(0); for (const edge of edges) { outgoing[edge.from]?.push(edge); indegree[edge.to] = (indegree[edge.to] ?? 0) + 1; } const ready: number[] = []; for (let index = 0; index < segment.length; index += 1) { if (indegree[index] === 0) ready.push(index); } const ordered: RenderGraphStep[] = []; while (ready.length > 0) { ready.sort((left, right) => left - right); const index = ready.shift(); if (index === undefined) break; const step = segment[index]; if (step) ordered.push(step); for (const edge of outgoing[index] ?? []) { indegree[edge.to] = (indegree[edge.to] ?? 0) - 1; if (indegree[edge.to] === 0) ready.push(edge.to); } } if (ordered.length !== segment.length) { const blocked = indegree .map((count, index) => ({ count, index })) .filter(({ count }) => count > 0) .map(({ index }) => segment[index]?.computeLabel ?? `compute pass #${index}`); const cycleEdges = edges .filter((edge) => (indegree[edge.from] ?? 0) > 0 && (indegree[edge.to] ?? 0) > 0) .map( (edge) => `${segment[edge.from]?.computeLabel ?? `compute pass #${edge.from}`} -> ${segment[edge.to]?.computeLabel ?? `compute pass #${edge.to}`} via ${formatLogicalResource(edge.access)} (alias "${edge.access.alias}")` ); throw createMotionGPUError( 'COMPUTE_GRAPH_CYCLE', `Compute dependency cycle detected among ${blocked.join(', ')}: ${cycleEdges.join('; ')}.` ); } return ordered; } /** * Reorders only contiguous compute blocks, leaving render-pass boundaries fixed. */ function planComputeSegments(preSceneSteps: RenderGraphStep[]): RenderGraphStep[] { const ordered: RenderGraphStep[] = []; let segment: RenderGraphStep[] = []; const flush = (): void => { if (segment.length === 0) return; ordered.push(...stableTopologicalComputeSegment(segment)); segment = []; }; for (const step of preSceneSteps) { if (step.kind === 'compute') { segment.push(step); } else { flush(); ordered.push(step); } } flush(); return ordered; } /** * Builds validated render graph plan from runtime pass list. * * @param passes - Runtime passes. * @param defaultClearColor - Global clear color fallback. * @returns Resolved render graph plan. */ export function planRenderGraph( passes: AnyPass[] | undefined, defaultClearColor: [number, number, number, number], renderTargetSlots?: Iterable<string>, computeOptions?: ComputeRenderGraphOptions ): RenderGraphPlan { const steps: RenderGraphStep[] = []; const preSceneSteps: RenderGraphStep[] = []; const computeSteps: RenderGraphStep[] = []; const renderSteps: RenderGraphStep[] = []; const declaredTargets = new Set(renderTargetSlots ?? []); const availableSlots = new Set<RenderPassInputSlot | RenderPassOutputSlot>(['source']); let finalOutput: RenderPassOutputSlot = 'canvas'; let enabledIndex = 0; for (const pass of passes ?? []) { assertMotionGPUPass(pass); if (pass.enabled === false) { continue; } // Compute passes don't participate in slot routing if (isManagedComputePass(pass)) { const resolvedResources = computeOptions?.getResolvedResources(pass); const step: RenderGraphStep = { kind: 'compute', pass, input: 'source', output: 'source', needsSwap: false, clear: false, clearColor: cloneClearColor(defaultClearColor), preserve: true, ...(resolvedResources ? { resolvedResources } : {}), ...(computeOptions?.getPassLabel ? { computeLabel: computeOptions.getPassLabel(pass) } : {}) }; steps.push(step); preSceneSteps.push(step); computeSteps.push(step); continue; } if (isManagedFeedbackPass(pass)) { const step: RenderGraphStep = { kind: 'feedback', pass, input: 'source', output: 'source', needsSwap: false, clear: false, clearColor: cloneClearColor(defaultClearColor), preserve: true }; steps.push(step); preSceneSteps.push(step); continue; } // After compute guard, pass is a render pass const rp = pass as RenderPass; const needsSwap = rp.needsSwap ?? true; const input: RenderPassInputSlot = rp.input ?? 'source'; const output: RenderPassOutputSlot = rp.output ?? (needsSwap ? 'target' : 'source'); if (input === 'canvas') { throw new Error(`Render pass #${enabledIndex} cannot read from "canvas".`); } const inputIsNamed = input !== 'source' && input !== 'target'; if (inputIsNamed && !declaredTargets.has(input)) { throw new Error(`Render pass #${enabledIndex} reads unknown target "${input}".`); } const outputIsNamed = output !== 'source' && output !== 'target' && output !== 'canvas'; if (outputIsNamed && !declaredTargets.has(output)) { throw new Error(`Render pass #${enabledIndex} writes unknown target "${output}".`); } if (needsSwap && (input !== 'source' || output !== 'target')) { throw new Error( `Render pass #${enabledIndex} uses needsSwap=true but does not follow source->target flow.` ); } if (!availableSlots.has(input)) { throw new Error(`Render pass #${enabledIndex} reads "${input}" before it is written.`); } const clear = rp.clear ?? false; const clearColor = cloneClearColor(rp.clearColor ?? defaultClearColor); const preserve = rp.preserve ?? true; const step: RenderGraphStep = { kind: 'render', pass, input, output, needsSwap, clear, clearColor, preserve }; steps.push(step); renderSteps.push(step); if (needsSwap) { availableSlots.add('target'); availableSlots.add('source'); finalOutput = 'source'; } else { if (output !== 'canvas') { availableSlots.add(output); } finalOutput = output; } enabledIndex += 1; } const orderedPreSceneSteps = computeOptions ? planComputeSegments(preSceneSteps) : preSceneSteps; const orderedComputeSteps = orderedPreSceneSteps.filter((step) => step.kind === 'compute'); return { steps, preSceneSteps: orderedPreSceneSteps, computeSteps: computeOptions ? orderedComputeSteps : computeSteps, renderSteps, finalOutput }; }