@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
text/typescript
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;
}