UNPKG

@motion-core/motion-gpu

Version:

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

785 lines (705 loc) 22.9 kB
import { normalizeTextureDefinition } from './textures.js'; import type { MaterialSourceMetadata } from './error-diagnostics.js'; import { assertUniformName, assertUniformValueForType, inferUniformType, resolveUniformLayout } from './uniforms.js'; import { normalizeDefines, normalizeIncludes, preprocessMaterialFragment, toDefineLine, type MaterialLineMap, type PreprocessedMaterialFragment } from './material-preprocess.js'; import { assertStorageBufferDefinition, assertStorageTextureFormat } from './storage-buffers.js'; import type { StorageBufferDefinition, StorageBufferDefinitionMap, TextureData, TextureDefinition, TextureDefinitionMap, TextureValue, TypedUniform, UniformMap, UniformValue } from './types.js'; /** * Typed compile-time define declaration. */ export type TypedMaterialDefineValue = | { /** * WGSL scalar type. */ type: 'bool'; /** * Literal value for the selected WGSL type. */ value: boolean; } | { /** * WGSL scalar type. */ type: 'f32' | 'i32' | 'u32'; /** * Literal value for the selected WGSL type. */ value: number; }; /** * Allowed value types for WGSL `const` define injection. */ export type MaterialDefineValue = boolean | number | TypedMaterialDefineValue; /** * Define map keyed by uniform-compatible identifier names. */ export type MaterialDefines<TKey extends string = string> = Record<TKey, MaterialDefineValue>; /** * Include map keyed by include identifier used in `#include <name>` directives. */ export type MaterialIncludes<TKey extends string = string> = Record<TKey, string>; /** * External material input accepted by {@link defineMaterial}. */ export interface FragMaterialInput< TUniformKey extends string = string, TTextureKey extends string = string, TDefineKey extends string = string, TIncludeKey extends string = string, TStorageBufferKey extends string = string > { /** * User WGSL source containing `frag(uv: vec2f) -> vec4f`. */ fragment: string; /** * Initial uniform values. */ uniforms?: UniformMap<TUniformKey>; /** * Texture definitions keyed by texture uniform name. */ textures?: TextureDefinitionMap<TTextureKey>; /** * Optional compile-time define constants injected into WGSL. */ defines?: MaterialDefines<TDefineKey>; /** * Optional WGSL include chunks used by `#include <name>` directives. */ includes?: MaterialIncludes<TIncludeKey>; /** * Optional storage buffer definitions for compute shaders. */ storageBuffers?: StorageBufferDefinitionMap<TStorageBufferKey>; } /** * Normalized and immutable material declaration consumed by `FragCanvas`. */ export interface FragMaterial< TUniformKey extends string = string, TTextureKey extends string = string, TDefineKey extends string = string, TIncludeKey extends string = string, TStorageBufferKey extends string = string > { /** * User WGSL source containing `frag(uv: vec2f) -> vec4f`. */ readonly fragment: string; /** * Initial uniform values. */ readonly uniforms: Readonly<UniformMap<TUniformKey>>; /** * Texture definitions keyed by texture uniform name. */ readonly textures: Readonly<TextureDefinitionMap<TTextureKey>>; /** * Optional compile-time define constants injected into WGSL. */ readonly defines: Readonly<MaterialDefines<TDefineKey>>; /** * Optional WGSL include chunks used by `#include <name>` directives. */ readonly includes: Readonly<MaterialIncludes<TIncludeKey>>; /** * Storage buffer definitions for compute shaders. Empty when not provided. */ readonly storageBuffers: Readonly<StorageBufferDefinitionMap<TStorageBufferKey>>; } /** * Fully resolved, immutable material snapshot used for renderer creation/caching. */ export interface ResolvedMaterial< TUniformKey extends string = string, TTextureKey extends string = string, TIncludeKey extends string = string, TStorageBufferKey extends string = string > { /** * Final fragment WGSL after define injection. */ fragmentWgsl: string; /** * 1-based map from generated fragment lines to user source lines. */ fragmentLineMap: MaterialLineMap; /** * Cloned uniforms. */ uniforms: UniformMap<TUniformKey>; /** * Cloned texture definitions. */ textures: TextureDefinitionMap<TTextureKey>; /** * Resolved packed uniform layout. */ uniformLayout: ReturnType<typeof resolveUniformLayout>; /** * Sorted texture keys. */ textureKeys: TTextureKey[]; /** * Deterministic JSON signature for cache invalidation. */ signature: string; /** * Original user fragment source before preprocessing. */ fragmentSource: string; /** * Normalized include sources map. */ includeSources: MaterialIncludes<TIncludeKey>; /** * Deterministic define block source used for diagnostics mapping. */ defineBlockSource: string; /** * Source metadata used for diagnostics. */ source: Readonly<MaterialSourceMetadata> | null; /** * Sorted storage buffer keys. Empty array when no storage buffers declared. */ storageBufferKeys: TStorageBufferKey[]; /** * Sorted storage texture keys (textures with storage: true). */ storageTextureKeys: TTextureKey[]; } /** * Strict fragment contract used by MotionGPU. */ 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; /** * Cache of resolved material snapshots keyed by immutable material instance. */ type AnyFragMaterial = FragMaterial<string, string, string, string, string>; type AnyResolvedMaterial = ResolvedMaterial<string, string, string, string>; const resolvedMaterialCache = new WeakMap<AnyFragMaterial, AnyResolvedMaterial>(); const preprocessedFragmentCache = new WeakMap<AnyFragMaterial, PreprocessedMaterialFragment>(); const materialSourceMetadataCache = new WeakMap<AnyFragMaterial, MaterialSourceMetadata | null>(); function getCachedResolvedMaterial< TUniformKey extends string, TTextureKey extends string, TIncludeKey extends string, TStorageBufferKey extends string >( material: FragMaterial<TUniformKey, TTextureKey, string, TIncludeKey, TStorageBufferKey> ): ResolvedMaterial<TUniformKey, TTextureKey, TIncludeKey, TStorageBufferKey> | null { const cached = resolvedMaterialCache.get(material); if (!cached) { return null; } // Invariant: the cache key is the same material object used to produce this resolved payload. return cached as ResolvedMaterial<TUniformKey, TTextureKey, TIncludeKey, TStorageBufferKey>; } const STACK_TRACE_CHROME_PATTERN = /^\s*at\s+(?:(.*?)\s+\()?(.+?):(\d+):(\d+)\)?$/; const STACK_TRACE_FIREFOX_PATTERN = /^(.*?)@(.+?):(\d+):(\d+)$/; function getPathBasename(path: string): string { const normalized = path.split(/[?#]/)[0] ?? path; const parts = normalized.split(/[\\/]/); const last = parts[parts.length - 1]; return last && last.length > 0 ? last : path; } 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) { continue; } names.add(name); } return Array.from(names); } function captureMaterialSourceFromStack(): MaterialSourceMetadata | null { const stack = new Error().stack; if (!stack) { return null; } const stackLines = stack.split('\n').slice(1); for (const rawLine of stackLines) { const line = rawLine.trim(); if (line.length === 0) { continue; } const chromeMatch = line.match(STACK_TRACE_CHROME_PATTERN); const firefoxMatch = line.match(STACK_TRACE_FIREFOX_PATTERN); const functionName = chromeMatch?.[1] ?? firefoxMatch?.[1] ?? undefined; const file = chromeMatch?.[2] ?? firefoxMatch?.[2]; const lineValue = chromeMatch?.[3] ?? firefoxMatch?.[3]; const columnValue = chromeMatch?.[4] ?? firefoxMatch?.[4]; if (!file || !lineValue || !columnValue) { continue; } if (file.includes('/core/material') || file.includes('\\core\\material')) { continue; } const parsedLine = Number.parseInt(lineValue, 10); const parsedColumn = Number.parseInt(columnValue, 10); if (!Number.isFinite(parsedLine) || !Number.isFinite(parsedColumn)) { continue; } return { component: getPathBasename(file), file, line: parsedLine, column: parsedColumn, ...(functionName ? { functionName } : {}) }; } return null; } function resolveSourceMetadata( source: MaterialSourceMetadata | undefined ): MaterialSourceMetadata | null { const captured = captureMaterialSourceFromStack(); const component = source?.component ?? captured?.component; const file = source?.file ?? captured?.file; const line = source?.line ?? captured?.line; const column = source?.column ?? captured?.column; const functionName = source?.functionName ?? captured?.functionName; if ( component === undefined && file === undefined && line === undefined && column === undefined && functionName === undefined ) { return null; } return { ...(component !== undefined ? { component } : {}), ...(file !== undefined ? { file } : {}), ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}), ...(functionName !== undefined ? { functionName } : {}) }; } /** * Asserts that material has been normalized by {@link defineMaterial}. */ function assertDefinedMaterial(material: AnyFragMaterial): void { if ( !Object.isFrozen(material) || !material.uniforms || !material.textures || !material.defines || !material.includes ) { throw new Error( 'Invalid material instance. Create materials with defineMaterial(...) before passing them to <FragCanvas>.' ); } } /** * Clones uniform value input to decouple material instances from external objects. */ function cloneUniformValue(value: UniformValue): UniformValue { if (typeof value === 'number') { return value; } if (Array.isArray(value)) { return Object.freeze([...value]) as UniformValue; } if (typeof value === 'object' && value !== null && 'type' in value && 'value' in value) { const typed = value as TypedUniform; const typedValue = typed.value as unknown; let clonedTypedValue = typedValue; if (typedValue instanceof Float32Array) { clonedTypedValue = new Float32Array(typedValue); } else if (Array.isArray(typedValue)) { clonedTypedValue = Object.freeze([...typedValue]); } return Object.freeze({ type: typed.type, value: clonedTypedValue }) as UniformValue; } return value; } /** * Clones optional texture value payload. */ function cloneTextureValue(value: TextureValue | undefined): TextureValue { if (value === undefined || value === null) { return null; } if (typeof value === 'object' && 'source' in value) { const data = value as TextureData; return { source: data.source, ...(data.width !== undefined ? { width: data.width } : {}), ...(data.height !== undefined ? { height: data.height } : {}), ...(data.colorSpace !== undefined ? { colorSpace: data.colorSpace } : {}), ...(data.flipY !== undefined ? { flipY: data.flipY } : {}), ...(data.premultipliedAlpha !== undefined ? { premultipliedAlpha: data.premultipliedAlpha } : {}), ...(data.generateMipmaps !== undefined ? { generateMipmaps: data.generateMipmaps } : {}), ...(data.update !== undefined ? { update: data.update } : {}) }; } return value; } /** * Clones and validates fragment source contract. */ function resolveFragment(fragment: string): string { if (typeof fragment !== 'string' || fragment.trim().length === 0) { throw new Error('Material fragment shader 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( `Material 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( `Material fragment contract mismatch for \`frag\`: expected parameter list \`(uv: vec2f)\`, received \`(${params || '...'})\`.` ); } if (returnType !== 'vec4f') { throw new Error( `Material fragment contract mismatch for \`frag\`: expected return type \`vec4f\`, received \`${returnType}\`.` ); } return fragment; } /** * Clones and validates uniform declarations. */ function resolveUniforms<TUniformKey extends string>( uniforms: UniformMap<TUniformKey> | undefined ): UniformMap<TUniformKey> { const resolved: UniformMap<TUniformKey> = {} as UniformMap<TUniformKey>; for (const [name, value] of Object.entries(uniforms ?? {}) as Array< [TUniformKey, UniformValue] >) { assertUniformName(name); const clonedValue = cloneUniformValue(value); const type = inferUniformType(clonedValue); assertUniformValueForType(type, clonedValue); resolved[name] = clonedValue; } resolveUniformLayout(resolved); return resolved; } /** * Clones and validates texture declarations. */ function resolveTextures<TTextureKey extends string>( textures: TextureDefinitionMap<TTextureKey> | undefined ): TextureDefinitionMap<TTextureKey> { const resolved: TextureDefinitionMap<TTextureKey> = {} as TextureDefinitionMap<TTextureKey>; for (const [name, definition] of Object.entries(textures ?? {}) as Array< [TTextureKey, TextureDefinition] >) { assertUniformName(name); const source = definition?.source; const normalizedSource = cloneTextureValue(source); const clonedDefinition: TextureDefinition = { ...(definition ?? {}), ...(source !== undefined ? { source: normalizedSource } : {}) }; resolved[name] = Object.freeze(clonedDefinition); } return resolved; } /** * Clones and validates define declarations. */ function resolveDefines<TDefineKey extends string>( defines: MaterialDefines<TDefineKey> | undefined ): MaterialDefines<TDefineKey> { return normalizeDefines(defines); } /** * Clones and validates include declarations. */ function resolveIncludes<TIncludeKey extends string>( includes: MaterialIncludes<TIncludeKey> | undefined ): MaterialIncludes<TIncludeKey> { return normalizeIncludes(includes); } /** * Builds a deterministic texture-config signature map used in material cache signatures. * * @param textures - Raw texture definitions from material input. * @param textureKeys - Sorted texture keys. * @returns Compact signature entries describing effective texture config per key. */ function buildTextureConfigSignature<TTextureKey extends string>( textures: TextureDefinitionMap<TTextureKey>, textureKeys: TTextureKey[] ): Record<TTextureKey, string> { const signature = {} as Record<TTextureKey, string>; for (const key of textureKeys) { const normalized = normalizeTextureDefinition(textures[key]); signature[key] = [ normalized.format, normalized.storage ? '1' : '0', normalized.colorSpace, normalized.flipY ? '1' : '0', normalized.generateMipmaps ? '1' : '0', normalized.premultipliedAlpha ? '1' : '0', normalized.update ?? '', normalized.anisotropy, normalized.filter, normalized.addressModeU, normalized.addressModeV, normalized.fragmentVisible ? '1' : '0', normalized.width ?? '', normalized.height ?? '' ].join(':'); } return signature; } function assertStorageTextureDimension( name: string, field: 'width' | 'height', value: unknown ): void { if ( typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || !Number.isInteger(value) ) { throw new Error( `Texture "${name}" with storage:true requires an explicit positive integer \`${field}\` field.` ); } } function assertStorageTextureDefinition(name: string, definition: TextureDefinition): void { if (!definition.format) { throw new Error(`Texture "${name}" with storage:true requires a \`format\` field.`); } assertStorageTextureFormat(name, definition.format); assertStorageTextureDimension(name, 'width', definition.width); assertStorageTextureDimension(name, 'height', definition.height); if (definition.source !== undefined) { throw new Error( `Texture "${name}" with storage:true is compute-managed and must not define a \`source\` field.` ); } } /** * Creates a stable WGSL define block from the provided map. * * @param defines - Optional material defines. * @returns Joined WGSL const declarations ordered by key. */ export function buildDefinesBlock(defines: MaterialDefines | undefined): string { const normalizedDefines = normalizeDefines(defines); if (Object.keys(normalizedDefines).length === 0) { return ''; } return Object.entries(normalizedDefines) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => { assertUniformName(key); return toDefineLine(key, value); }) .join('\n'); } /** * Prepends resolved defines to a fragment shader. * * @param fragment - Raw WGSL fragment source. * @param defines - Optional define map. * @returns Fragment source with a leading define block when defines are present. */ export function applyMaterialDefines( fragment: string, defines: MaterialDefines | undefined ): string { const defineBlock = buildDefinesBlock(defines); if (defineBlock.length === 0) { return fragment; } return `${defineBlock}\n\n${fragment}`; } /** * Creates an immutable material object with validated shader/uniform/texture contracts. * * @param input - User material declaration. * @returns Frozen material object safe to share and cache. */ export function defineMaterial< TUniformKey extends string = string, TTextureKey extends string = string, TDefineKey extends string = string, TIncludeKey extends string = string, TStorageBufferKey extends string = string >( input: FragMaterialInput<TUniformKey, TTextureKey, TDefineKey, TIncludeKey, TStorageBufferKey> ): FragMaterial<TUniformKey, TTextureKey, TDefineKey, TIncludeKey, TStorageBufferKey> { const fragment = resolveFragment(input.fragment); const uniforms = Object.freeze(resolveUniforms(input.uniforms)); const textures = Object.freeze(resolveTextures(input.textures)); const defines = Object.freeze(resolveDefines(input.defines)); const includes = Object.freeze(resolveIncludes(input.includes)); const source = Object.freeze(resolveSourceMetadata(undefined)); // Validate and freeze storage buffers const rawStorageBuffers = input.storageBuffers ?? ({} as StorageBufferDefinitionMap<TStorageBufferKey>); for (const [name, definition] of Object.entries(rawStorageBuffers) as Array< [string, StorageBufferDefinition] >) { assertStorageBufferDefinition(name, definition); } const storageBuffers = Object.freeze( Object.fromEntries( Object.entries(rawStorageBuffers).map(([name, definition]) => { const def = definition as StorageBufferDefinition; const cloned: StorageBufferDefinition = { size: def.size, type: def.type, ...(def.access !== undefined ? { access: def.access } : {}), ...(def.initialData !== undefined ? { initialData: def.initialData.slice() as typeof def.initialData } : {}) }; return [name, Object.freeze(cloned)]; }) ) ) as Readonly<StorageBufferDefinitionMap<TStorageBufferKey>>; // Validate storage textures for (const [name, definition] of Object.entries(textures) as Array<[string, TextureDefinition]>) { if (definition?.storage) { assertStorageTextureDefinition(name, definition); } } const preprocessed = preprocessMaterialFragment({ fragment, defines, includes }); const material: FragMaterial< TUniformKey, TTextureKey, TDefineKey, TIncludeKey, TStorageBufferKey > = Object.freeze({ fragment, uniforms, textures, defines, includes, storageBuffers }); preprocessedFragmentCache.set(material, preprocessed); materialSourceMetadataCache.set(material, source); return material; } /** * Resolves a material to renderer-ready data and a deterministic signature. * * @param material - Material input created via {@link defineMaterial}. * @returns Resolved material with packed uniform layout, sorted texture keys and cache signature. */ export function resolveMaterial< TUniformKey extends string = string, TTextureKey extends string = string, TDefineKey extends string = string, TIncludeKey extends string = string, TStorageBufferKey extends string = string >( material: FragMaterial<TUniformKey, TTextureKey, TDefineKey, TIncludeKey, TStorageBufferKey> ): ResolvedMaterial<TUniformKey, TTextureKey, TIncludeKey, TStorageBufferKey> { const cached = getCachedResolvedMaterial(material); if (cached) { return cached; } assertDefinedMaterial(material); const uniforms = material.uniforms as UniformMap<TUniformKey>; const textures = material.textures as TextureDefinitionMap<TTextureKey>; const uniformLayout = resolveUniformLayout(uniforms); const textureKeys = Object.keys(textures).sort() as TTextureKey[]; const preprocessed = preprocessedFragmentCache.get(material) ?? preprocessMaterialFragment({ fragment: material.fragment, defines: material.defines, includes: material.includes }); const fragmentWgsl = preprocessed.fragment; const textureConfig = buildTextureConfigSignature(textures, textureKeys); const storageBufferKeys = Object.keys( material.storageBuffers ?? {} ).sort() as TStorageBufferKey[]; const storageTextureKeys = textureKeys.filter( (key) => (textures[key] as TextureDefinition)?.storage === true ); const signature = JSON.stringify({ fragmentWgsl, uniforms: uniformLayout.entries.map((entry) => `${entry.name}:${entry.type}`), textureKeys, textureConfig, storageBufferKeys: storageBufferKeys.map((key) => { const def = (material.storageBuffers as StorageBufferDefinitionMap)[key]; return `${key}:${def?.type ?? '?'}:${def?.size ?? 0}:${def?.access ?? 'read-write'}`; }), storageTextureKeys }); const resolved: ResolvedMaterial<TUniformKey, TTextureKey, TIncludeKey, TStorageBufferKey> = { fragmentWgsl, fragmentLineMap: preprocessed.lineMap, uniforms, textures, uniformLayout, textureKeys, signature, fragmentSource: material.fragment, includeSources: material.includes as MaterialIncludes<TIncludeKey>, defineBlockSource: preprocessed.defineBlockSource, source: materialSourceMetadataCache.get(material) ?? null, storageBufferKeys, storageTextureKeys }; resolvedMaterialCache.set(material, resolved); return resolved; }