@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
296 lines (253 loc) • 7.67 kB
text/typescript
import { assertUniformName } from './uniforms.js';
import type {
MaterialDefineValue,
MaterialDefines,
MaterialIncludes,
TypedMaterialDefineValue
} from './material.js';
const INCLUDE_DIRECTIVE_PATTERN = /^\s*#include\s+<([A-Za-z_][A-Za-z0-9_]*)>\s*$/;
/**
* Source location metadata for one generated fragment line.
*/
export interface MaterialSourceLocation {
/**
* Origin category for this generated line.
*/
kind: 'fragment' | 'include' | 'define';
/**
* 1-based line in the origin source.
*/
line: number;
/**
* Include chunk identifier when `kind === "include"`.
*/
include?: string;
/**
* Define identifier when `kind === "define"`.
*/
define?: string;
}
/**
* 1-based line map from generated fragment WGSL to user source locations.
*/
export type MaterialLineMap = Array<MaterialSourceLocation | null>;
/**
* Preprocess output used by material resolution and diagnostics mapping.
*/
export interface PreprocessedMaterialFragment {
/**
* Final fragment source after defines/include expansion.
*/
fragment: string;
/**
* 1-based generated-line source map.
*/
lineMap: MaterialLineMap;
/**
* Deterministic WGSL define block used to build the final fragment source.
*/
defineBlockSource: string;
}
function normalizeTypedDefine(
name: string,
define: TypedMaterialDefineValue
): TypedMaterialDefineValue {
const value = define.value;
if (define.type === 'bool') {
if (typeof value !== 'boolean') {
throw new Error(`Invalid define value for "${name}". bool define requires boolean value.`);
}
return {
type: 'bool',
value
};
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`Invalid define value for "${name}". Numeric define must be finite.`);
}
if ((define.type === 'i32' || define.type === 'u32') && !Number.isInteger(value)) {
throw new Error(`Invalid define value for "${name}". ${define.type} define requires integer.`);
}
if (define.type === 'u32' && value < 0) {
throw new Error(`Invalid define value for "${name}". u32 define must be >= 0.`);
}
return {
type: define.type,
value
};
}
/**
* Validates and normalizes define entries.
*/
export function normalizeDefines<TDefineKey extends string>(
defines: MaterialDefines<TDefineKey> | undefined
): MaterialDefines<TDefineKey> {
const resolved: MaterialDefines<TDefineKey> = {} as MaterialDefines<TDefineKey>;
for (const [name, value] of Object.entries(defines ?? {}) as Array<
[TDefineKey, MaterialDefineValue]
>) {
assertUniformName(name);
if (typeof value === 'boolean') {
resolved[name] = value;
continue;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new Error(`Invalid define value for "${name}". Define numbers must be finite.`);
}
resolved[name] = value;
continue;
}
const normalized = normalizeTypedDefine(name, value);
resolved[name] = Object.freeze(normalized);
}
return resolved;
}
/**
* Validates include map identifiers and source chunks.
*/
export function normalizeIncludes<TIncludeKey extends string>(
includes: MaterialIncludes<TIncludeKey> | undefined
): MaterialIncludes<TIncludeKey> {
const resolved: MaterialIncludes<TIncludeKey> = {} as MaterialIncludes<TIncludeKey>;
for (const [name, source] of Object.entries(includes ?? {}) as Array<[TIncludeKey, string]>) {
assertUniformName(name);
if (typeof source !== 'string' || source.trim().length === 0) {
throw new Error(`Invalid include "${name}". Include source must be a non-empty WGSL string.`);
}
resolved[name] = source;
}
return resolved;
}
/**
* Converts one define declaration to WGSL `const`.
*/
export function toDefineLine(key: string, value: MaterialDefineValue): string {
if (typeof value === 'boolean') {
return `const ${key}: bool = ${value ? 'true' : 'false'};`;
}
if (typeof value === 'number') {
const valueLiteral = Number.isInteger(value) ? `${value}.0` : `${value}`;
return `const ${key}: f32 = ${valueLiteral};`;
}
if (value.type === 'bool') {
return `const ${key}: bool = ${value.value ? 'true' : 'false'};`;
}
if (value.type === 'f32') {
const numberValue = value.value as number;
const valueLiteral = Number.isInteger(numberValue) ? `${numberValue}.0` : `${numberValue}`;
return `const ${key}: f32 = ${valueLiteral};`;
}
if (value.type === 'i32') {
return `const ${key}: i32 = ${value.value};`;
}
return `const ${key}: u32 = ${value.value}u;`;
}
function expandChunk(
source: string,
kind: 'fragment' | 'include',
includeName: string | undefined,
includes: Record<string, string>,
stack: string[],
expandedIncludes: Set<string>
): { lines: string[]; mapEntries: MaterialSourceLocation[] } {
const sourceLines = source.split('\n');
const lines: string[] = [];
const mapEntries: MaterialSourceLocation[] = [];
for (let index = 0; index < sourceLines.length; index += 1) {
const sourceLine = sourceLines[index];
if (sourceLine === undefined) {
continue;
}
const includeMatch = sourceLine.match(INCLUDE_DIRECTIVE_PATTERN);
if (!includeMatch) {
lines.push(sourceLine);
mapEntries.push({
kind,
line: index + 1,
...(kind === 'include' && includeName ? { include: includeName } : {})
});
continue;
}
const includeKey = includeMatch[1];
if (!includeKey) {
throw new Error('Invalid include directive in fragment shader.');
}
assertUniformName(includeKey);
const includeSource = includes[includeKey];
if (!includeSource) {
throw new Error(`Unknown include "${includeKey}" referenced in fragment shader.`);
}
if (stack.includes(includeKey)) {
throw new Error(
`Circular include detected for "${includeKey}". Include stack: ${[...stack, includeKey].join(' -> ')}.`
);
}
if (expandedIncludes.has(includeKey)) {
continue;
}
expandedIncludes.add(includeKey);
const nested = expandChunk(
includeSource,
'include',
includeKey,
includes,
[...stack, includeKey],
expandedIncludes
);
lines.push(...nested.lines);
mapEntries.push(...nested.mapEntries);
}
return { lines, mapEntries };
}
/**
* Preprocesses material fragment with deterministic define/include expansion and line mapping.
*/
export function preprocessMaterialFragment<
TDefineKey extends string,
TIncludeKey extends string
>(input: {
fragment: string;
defines?: MaterialDefines<TDefineKey>;
includes?: MaterialIncludes<TIncludeKey>;
}): PreprocessedMaterialFragment {
const normalizedDefines = normalizeDefines(input.defines);
const normalizedIncludes = normalizeIncludes(input.includes);
const fragmentExpanded = expandChunk(
input.fragment,
'fragment',
undefined,
normalizedIncludes,
[],
new Set()
);
const defineEntries = (
Object.entries(normalizedDefines) as Array<[TDefineKey, MaterialDefineValue]>
).sort(([a], [b]) => a.localeCompare(b));
const lines: string[] = [];
const defineLines: string[] = [];
const mapEntries: Array<MaterialSourceLocation | null> = [];
for (let index = 0; index < defineEntries.length; index += 1) {
const entry = defineEntries[index];
if (!entry) {
continue;
}
const [name, value] = entry;
const defineLine = toDefineLine(name, value);
lines.push(defineLine);
defineLines.push(defineLine);
mapEntries.push({ kind: 'define', line: index + 1, define: name });
}
if (defineEntries.length > 0) {
lines.push('');
mapEntries.push(null);
}
lines.push(...fragmentExpanded.lines);
mapEntries.push(...fragmentExpanded.mapEntries);
const lineMap: MaterialLineMap = [null, ...mapEntries];
return {
fragment: lines.join('\n'),
lineMap,
defineBlockSource: defineLines.join('\n')
};
}