@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
JavaScript
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