UNPKG

@motion-core/motion-gpu

Version:

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

351 lines (350 loc) 14.1 kB
import { assertUniformName, assertUniformValueForType, inferUniformType, resolveUniformLayout } from "./uniforms.js"; import { normalizeTextureDefinition } from "./textures.js"; import { normalizeDefines, normalizeIncludes, preprocessMaterialFragment, toDefineLine } from "./material-preprocess.js"; import { assertStorageBufferDefinition, assertStorageTextureFormat } from "./storage-buffers.js"; //#region src/lib/core/material.ts /** * Strict fragment contract used by MotionGPU. */ var FRAGMENT_FUNCTION_SIGNATURE_PATTERN = /\bfn\s+frag\s*\(\s*([^)]*?)\s*\)\s*->\s*([A-Za-z_][A-Za-z0-9_<>\s]*)\s*(?:\{|$)/m; var FRAGMENT_FUNCTION_NAME_PATTERN = /\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; var resolvedMaterialCache = /* @__PURE__ */ new WeakMap(); var preprocessedFragmentCache = /* @__PURE__ */ new WeakMap(); var materialSourceMetadataCache = /* @__PURE__ */ new WeakMap(); function getCachedResolvedMaterial(material) { const cached = resolvedMaterialCache.get(material); if (!cached) return null; return cached; } var STACK_TRACE_CHROME_PATTERN = /^\s*at\s+(?:(.*?)\s+\()?(.+?):(\d+):(\d+)\)?$/; var STACK_TRACE_FIREFOX_PATTERN = /^(.*?)@(.+?):(\d+):(\d+)$/; function getPathBasename(path) { const parts = (path.split(/[?#]/)[0] ?? path).split(/[\\/]/); const last = parts[parts.length - 1]; return last && last.length > 0 ? last : path; } function normalizeSignaturePart(value) { return value.replace(/\s+/g, " ").trim(); } function listFunctionNames(fragment) { const names = /* @__PURE__ */ new Set(); 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() { const stack = (/* @__PURE__ */ 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] ?? void 0; 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) { 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 === void 0 && file === void 0 && line === void 0 && column === void 0 && functionName === void 0) return null; return { ...component !== void 0 ? { component } : {}, ...file !== void 0 ? { file } : {}, ...line !== void 0 ? { line } : {}, ...column !== void 0 ? { column } : {}, ...functionName !== void 0 ? { functionName } : {} }; } /** * Asserts that material has been normalized by {@link defineMaterial}. */ function assertDefinedMaterial(material) { 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) { if (typeof value === "number") return value; if (Array.isArray(value)) return Object.freeze([...value]); if (typeof value === "object" && value !== null && "type" in value && "value" in value) { const typed = value; const typedValue = typed.value; 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 }); } return value; } /** * Clones optional texture value payload. */ function cloneTextureValue(value) { if (value === void 0 || value === null) return null; if (typeof value === "object" && "source" in value) { const data = value; return { source: data.source, ...data.width !== void 0 ? { width: data.width } : {}, ...data.height !== void 0 ? { height: data.height } : {}, ...data.colorSpace !== void 0 ? { colorSpace: data.colorSpace } : {}, ...data.flipY !== void 0 ? { flipY: data.flipY } : {}, ...data.premultipliedAlpha !== void 0 ? { premultipliedAlpha: data.premultipliedAlpha } : {}, ...data.generateMipmaps !== void 0 ? { generateMipmaps: data.generateMipmaps } : {}, ...data.update !== void 0 ? { update: data.update } : {} }; } return value; } /** * Clones and validates fragment source contract. */ function resolveFragment(fragment) { 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(uniforms) { const resolved = {}; for (const [name, value] of Object.entries(uniforms ?? {})) { assertUniformName(name); const clonedValue = cloneUniformValue(value); assertUniformValueForType(inferUniformType(clonedValue), clonedValue); resolved[name] = clonedValue; } resolveUniformLayout(resolved); return resolved; } /** * Clones and validates texture declarations. */ function resolveTextures(textures) { const resolved = {}; for (const [name, definition] of Object.entries(textures ?? {})) { assertUniformName(name); const source = definition?.source; const normalizedSource = cloneTextureValue(source); const clonedDefinition = { ...definition ?? {}, ...source !== void 0 ? { source: normalizedSource } : {} }; resolved[name] = Object.freeze(clonedDefinition); } return resolved; } /** * Clones and validates define declarations. */ function resolveDefines(defines) { return normalizeDefines(defines); } /** * Clones and validates include declarations. */ function resolveIncludes(includes) { 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(textures, textureKeys) { const signature = {}; 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, field, value) { 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, definition) { 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 !== void 0) 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. */ function buildDefinesBlock(defines) { 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. */ function applyMaterialDefines(fragment, defines) { 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. */ function defineMaterial(input) { 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(void 0)); const rawStorageBuffers = input.storageBuffers ?? {}; for (const [name, definition] of Object.entries(rawStorageBuffers)) assertStorageBufferDefinition(name, definition); const storageBuffers = Object.freeze(Object.fromEntries(Object.entries(rawStorageBuffers).map(([name, definition]) => { const def = definition; const cloned = { size: def.size, type: def.type, ...def.access !== void 0 ? { access: def.access } : {}, ...def.initialData !== void 0 ? { initialData: def.initialData.slice() } : {} }; return [name, Object.freeze(cloned)]; }))); for (const [name, definition] of Object.entries(textures)) if (definition?.storage) assertStorageTextureDefinition(name, definition); const preprocessed = preprocessMaterialFragment({ fragment, defines, includes }); const material = 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. */ function resolveMaterial(material) { const cached = getCachedResolvedMaterial(material); if (cached) return cached; assertDefinedMaterial(material); const uniforms = material.uniforms; const textures = material.textures; const uniformLayout = resolveUniformLayout(uniforms); const textureKeys = Object.keys(textures).sort(); 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(); const storageTextureKeys = textureKeys.filter((key) => textures[key]?.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[key]; return `${key}:${def?.type ?? "?"}:${def?.size ?? 0}:${def?.access ?? "read-write"}`; }), storageTextureKeys }); const resolved = { fragmentWgsl, fragmentLineMap: preprocessed.lineMap, uniforms, textures, uniformLayout, textureKeys, signature, fragmentSource: material.fragment, includeSources: material.includes, defineBlockSource: preprocessed.defineBlockSource, source: materialSourceMetadataCache.get(material) ?? null, storageBufferKeys, storageTextureKeys }; resolvedMaterialCache.set(material, resolved); return resolved; } //#endregion export { applyMaterialDefines, buildDefinesBlock, defineMaterial, resolveMaterial }; //# sourceMappingURL=material.js.map