UNPKG

@motion-core/motion-gpu

Version:

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

361 lines (320 loc) 10.4 kB
import { storageTextureSampleScalarType } from '../core/compute-shader.js'; import { preprocessMaterialFragment, type MaterialLineMap } from '../core/material-preprocess.js'; import type { MaterialDefines, MaterialIncludes } from '../core/material.js'; import { assertUniformName } from '../core/uniforms.js'; const FRAGMENT_FUNCTION_SIGNATURE_PATTERN = /\bfn\s+frag\s*\(\s*([^)]*?)\s*\)\s*->\s*([A-Za-z_][A-Za-z0-9_<>\s]*)\s*(?:\{|$)/m; const FRAGMENT_FUNCTION_NAME_PATTERN = /\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; export interface PingPongShaderPassOptions< TDefineKey extends string = string, TIncludeKey extends string = string > { /** * Fragment WGSL source containing `fn frag(uv: vec2f) -> vec4f`. * * The generated shader exposes `motiongpuPreviousSampler` and * `motiongpuPrevious` for reading the previous ping-pong state. */ fragment: string; /** * Material texture key that will receive the latest ping-pong output. */ target: string; /** * Explicit simulation texture width. If omitted, derived from canvas width * scale. */ width?: number; /** * Explicit simulation texture height. If omitted, derived from canvas height * scale. */ height?: number; /** * Canvas-relative scale for implicit dimensions. */ scale?: number; /** * Ping-pong render texture format. Default: `rgba16float`. */ format?: GPUTextureFormat; /** * Previous-state sampler filter. Default: `linear`. */ filter?: GPUFilterMode; /** * Previous-state sampler U address mode. Default: `clamp-to-edge`. */ addressModeU?: GPUAddressMode; /** * Previous-state sampler V address mode. Default: `clamp-to-edge`. */ addressModeV?: GPUAddressMode; /** * Number of fragment iterations per frame. Default: 1. */ iterations?: number; /** * Color used to initialize/reset both ping-pong textures. Default: transparent black. */ clearColor?: [number, number, number, number]; /** * Optional compile-time define constants injected before the fragment. */ defines?: MaterialDefines<TDefineKey>; /** * Optional WGSL include chunks used by `#include <name>` directives. */ includes?: MaterialIncludes<TIncludeKey>; /** * Enables/disables this pass. */ enabled?: boolean; } function normalizeSignaturePart(value: string): string { return value.replace(/\s+/g, ' ').trim(); } function listFunctionNames(fragment: string): string[] { const names = new Set<string>(); for (const match of fragment.matchAll(FRAGMENT_FUNCTION_NAME_PATTERN)) { const name = match[1]; if (name) { names.add(name); } } return Array.from(names); } function assertFragmentContract(fragment: string): void { if (typeof fragment !== 'string' || fragment.trim().length === 0) { throw new Error('PingPongShaderPass fragment must be a non-empty WGSL string.'); } const signature = fragment.match(FRAGMENT_FUNCTION_SIGNATURE_PATTERN); if (!signature) { const discoveredFunctions = listFunctionNames(fragment).slice(0, 4); const discoveredLabel = discoveredFunctions.length > 0 ? `Found: ${discoveredFunctions.map((name) => `\`${name}(...)\``).join(', ')}.` : 'No WGSL function declarations were found.'; throw new Error( `PingPongShaderPass fragment contract mismatch: missing entrypoint \`fn frag(uv: vec2f) -> vec4f\`. ${discoveredLabel}` ); } const params = normalizeSignaturePart(signature[1] ?? ''); const returnType = normalizeSignaturePart(signature[2] ?? ''); if (params !== 'uv: vec2f') { throw new Error( `PingPongShaderPass fragment contract mismatch for \`frag\`: expected parameter list \`(uv: vec2f)\`, received \`(${params || '...'})\`.` ); } if (returnType !== 'vec4f') { throw new Error( `PingPongShaderPass fragment contract mismatch for \`frag\`: expected return type \`vec4f\`, received \`${returnType}\`.` ); } } function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`${name} must be a finite number greater than 0`); } } function assertIterations(count: number): number { if (!Number.isFinite(count) || count < 1 || !Number.isInteger(count)) { throw new Error(`PingPongShaderPass iterations must be a positive integer >= 1, got ${count}`); } return count; } function assertFloatSampledFormat(format: GPUTextureFormat): GPUTextureFormat { if (storageTextureSampleScalarType(format) !== 'f32') { throw new Error( `PingPongShaderPass format "${format}" must be float-sampled so fragment shaders can read the previous state.` ); } return format; } function cloneColor(color: [number, number, number, number]): [number, number, number, number] { return [color[0], color[1], color[2], color[3]]; } function resolveDimension( explicitValue: number | undefined, canvasDimension: number, scale: number ): number { if (explicitValue !== undefined) { assertPositiveFinite('PingPongShaderPass dimension', explicitValue); return Math.max(1, Math.floor(explicitValue)); } return Math.max(1, Math.floor(canvasDimension * scale)); } /** * Fragment-shader feedback pass for iterative GPU simulations. * * The renderer owns two render textures, alternates read/write direction per * iteration, then exposes the latest texture view through the declared material * texture slot. */ export class PingPongShaderPass { /** * Enables/disables this pass without removing it from graph. */ enabled: boolean; /** * Discriminant flag for render graph to identify fragment feedback passes. */ readonly isPingPongShader = true as const; private fragment: string; private fragmentLineMap: MaterialLineMap; private target: string; private width: number | undefined; private height: number | undefined; private scale: number; private format: GPUTextureFormat; private filter: GPUFilterMode; private addressModeU: GPUAddressMode; private addressModeV: GPUAddressMode; private iterations: number; private clearColor: [number, number, number, number]; private totalIterations = 0; private resetPending = true; constructor(options: PingPongShaderPassOptions) { assertUniformName(options.target); const preprocessed = preprocessMaterialFragment({ fragment: options.fragment, ...(options.defines !== undefined ? { defines: options.defines } : {}), ...(options.includes !== undefined ? { includes: options.includes } : {}) }); assertFragmentContract(preprocessed.fragment); this.fragment = preprocessed.fragment; this.fragmentLineMap = preprocessed.lineMap; this.target = options.target; this.width = options.width; this.height = options.height; this.scale = options.scale ?? 1; assertPositiveFinite('PingPongShaderPass scale', this.scale); if (this.width !== undefined) { assertPositiveFinite('PingPongShaderPass width', this.width); } if (this.height !== undefined) { assertPositiveFinite('PingPongShaderPass height', this.height); } this.format = assertFloatSampledFormat(options.format ?? 'rgba16float'); this.filter = options.filter ?? 'linear'; this.addressModeU = options.addressModeU ?? 'clamp-to-edge'; this.addressModeV = options.addressModeV ?? 'clamp-to-edge'; this.iterations = assertIterations(options.iterations ?? 1); this.clearColor = cloneColor(options.clearColor ?? [0, 0, 0, 0]); this.enabled = options.enabled ?? true; } /** * Replaces fragment source and updates define/include preprocessing. */ setFragment( fragment: string, options?: { defines?: MaterialDefines; includes?: MaterialIncludes; } ): void { const preprocessed = preprocessMaterialFragment({ fragment, ...(options?.defines !== undefined ? { defines: options.defines } : {}), ...(options?.includes !== undefined ? { includes: options.includes } : {}) }); assertFragmentContract(preprocessed.fragment); this.fragment = preprocessed.fragment; this.fragmentLineMap = preprocessed.lineMap; } /** * Updates iteration count. */ setIterations(count: number): void { this.iterations = assertIterations(count); } /** * Updates explicit dimensions. Passing `undefined` returns that axis to scale-based sizing. */ setDimensions(width?: number, height?: number): void { if (width !== undefined) { assertPositiveFinite('PingPongShaderPass width', width); } if (height !== undefined) { assertPositiveFinite('PingPongShaderPass height', height); } this.width = width; this.height = height; this.reset(); } /** * Updates canvas-relative scale used for implicit dimensions. */ setScale(scale: number): void { assertPositiveFinite('PingPongShaderPass scale', scale); this.scale = scale; this.reset(); } /** * Requests both ping-pong textures to be cleared before the next iteration. */ reset(clearColor?: [number, number, number, number]): void { if (clearColor) { this.clearColor = cloneColor(clearColor); } this.totalIterations = 0; this.resetPending = true; } /** * Returns and clears the pending reset color for renderer use. */ consumeResetColor(): [number, number, number, number] | null { if (!this.resetPending) { return null; } this.resetPending = false; return cloneColor(this.clearColor); } /** * Returns the texture key holding the latest result. */ getCurrentOutput(): string { return this.totalIterations % 2 === 0 ? `${this.target}A` : `${this.target}B`; } /** * Advances the iteration accumulator by the current iteration count. */ advanceFrame(): void { this.totalIterations += this.iterations; } resolveSize(canvasSize: { width: number; height: number }): { width: number; height: number } { return { width: resolveDimension(this.width, canvasSize.width, this.scale), height: resolveDimension(this.height, canvasSize.height, this.scale) }; } getTarget(): string { return this.target; } getFragment(): string { return this.fragment; } getFragmentLineMap(): MaterialLineMap { return [...this.fragmentLineMap]; } getIterations(): number { return this.iterations; } getFormat(): GPUTextureFormat { return this.format; } getFilter(): GPUFilterMode { return this.filter; } getAddressModeU(): GPUAddressMode { return this.addressModeU; } getAddressModeV(): GPUAddressMode { return this.addressModeV; } getClearColor(): [number, number, number, number] { return cloneColor(this.clearColor); } dispose(): void { // No-op: GPU resources are managed by the renderer. } }