@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
322 lines (286 loc) • 8.67 kB
text/typescript
import type {
UniformLayout,
UniformLayoutEntry,
UniformMap,
UniformMat4Value,
UniformType,
UniformValue
} from './types.js';
/**
* Internal representation of explicitly typed uniform input.
*/
type UniformTypedInput = Extract<UniformValue, { type: UniformType; value: unknown }>;
/**
* Valid WGSL identifier pattern used for uniform and texture keys.
*/
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Rounds a value up to the nearest multiple of `alignment`.
*/
function roundUp(value: number, alignment: number): number {
return Math.ceil(value / alignment) * alignment;
}
/**
* Returns WGSL std140-like alignment and size metadata for a uniform type.
*/
function getTypeLayout(type: UniformType): { alignment: number; size: number } {
switch (type) {
case 'f32':
return { alignment: 4, size: 4 };
case 'vec2f':
return { alignment: 8, size: 8 };
case 'vec3f':
return { alignment: 16, size: 12 };
case 'vec4f':
return { alignment: 16, size: 16 };
case 'mat4x4f':
return { alignment: 16, size: 64 };
default:
throw new Error(`Unsupported uniform type: ${type satisfies never}`);
}
}
/**
* Type guard for explicitly typed uniform objects.
*/
function isTypedUniformValue(value: UniformValue): value is UniformTypedInput {
return typeof value === 'object' && value !== null && 'type' in value && 'value' in value;
}
/**
* Validates numeric tuple input with a fixed length.
*/
function isTuple(value: unknown, size: number): value is number[] {
return (
Array.isArray(value) &&
value.length === size &&
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
);
}
/**
* Type guard for accepted 4x4 matrix uniform values.
*/
function isMat4Value(value: unknown): value is UniformMat4Value {
if (value instanceof Float32Array) {
return value.length === 16;
}
return (
Array.isArray(value) &&
value.length === 16 &&
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
);
}
/**
* Asserts that a name can be safely used as a WGSL identifier.
*
* @param name - Candidate uniform/texture name.
* @throws {Error} When the identifier is invalid.
*/
export function assertUniformName(name: string): void {
if (!IDENTIFIER_PATTERN.test(name)) {
throw new Error(`Invalid uniform name: ${name}`);
}
}
/**
* Infers the WGSL type tag from a runtime uniform value.
*
* @param value - Uniform input value.
* @returns Inferred uniform type.
* @throws {Error} When the value does not match any supported shape.
*/
export function inferUniformType(value: UniformValue): UniformType {
if (isTypedUniformValue(value)) {
return value.type;
}
if (typeof value === 'number') {
return 'f32';
}
if (Array.isArray(value)) {
if (value.length === 2) {
return 'vec2f';
}
if (value.length === 3) {
return 'vec3f';
}
if (value.length === 4) {
return 'vec4f';
}
}
throw new Error('Uniform value must resolve to f32, vec2f, vec3f, vec4f or mat4x4f');
}
/**
* Validates that a uniform value matches an explicit uniform type declaration.
*
* @param type - Declared WGSL type.
* @param value - Runtime value to validate.
* @throws {Error} When the value shape is incompatible with the declared type.
*/
export function assertUniformValueForType(type: UniformType, value: UniformValue): void {
const input = isTypedUniformValue(value) ? value.value : value;
if (type === 'f32') {
if (typeof input !== 'number' || !Number.isFinite(input)) {
throw new Error('Uniform f32 value must be a finite number');
}
return;
}
if (type === 'vec2f') {
if (!isTuple(input, 2)) {
throw new Error('Uniform vec2f value must be a tuple with 2 numbers');
}
return;
}
if (type === 'vec3f') {
if (!isTuple(input, 3)) {
throw new Error('Uniform vec3f value must be a tuple with 3 numbers');
}
return;
}
if (type === 'vec4f') {
if (!isTuple(input, 4)) {
throw new Error('Uniform vec4f value must be a tuple with 4 numbers');
}
return;
}
if (!isMat4Value(input)) {
throw new Error('Uniform mat4x4f value must contain 16 numbers');
}
}
/**
* Resolves a deterministic packed uniform buffer layout from a uniform map.
*
* @param uniforms - Input uniform definitions.
* @returns Sorted layout with byte offsets and final buffer byte length.
*/
export function resolveUniformLayout(uniforms: UniformMap): UniformLayout {
const names = Object.keys(uniforms).sort();
let offset = 0;
const entries: UniformLayoutEntry[] = [];
const byName: Record<string, UniformLayoutEntry> = {};
for (const name of names) {
assertUniformName(name);
const type = inferUniformType(uniforms[name] as UniformValue);
const { alignment, size } = getTypeLayout(type);
offset = roundUp(offset, alignment);
const entry: UniformLayoutEntry = {
name,
type,
offset,
size
};
entries.push(entry);
byName[name] = entry;
offset += size;
}
const byteLength = Math.max(16, roundUp(offset, 16));
return { entries, byName, byteLength };
}
/**
* Writes one uniform value into the output float buffer without re-validating.
*
* Callers are responsible for ensuring the value has already been validated
* against `type` (e.g. via {@link assertUniformValueForType}).
*
* Uses `Float32Array.set()` for mat4x4f values backed by a Float32Array,
* which is significantly faster than an element-by-element loop.
*/
function writeUniformValueFast(
type: UniformType,
value: UniformValue,
data: Float32Array,
base: number
): void {
const input = isTypedUniformValue(value) ? value.value : value;
if (type === 'f32') {
data[base] = input as number;
return;
}
if (type === 'mat4x4f') {
const matrix = input as UniformMat4Value;
if (matrix instanceof Float32Array) {
// Single native copy — faster than a 16-iteration element loop.
data.set(matrix, base);
return;
}
const arr = matrix as number[];
for (let index = 0; index < 16; index += 1) {
data[base + index] = arr[index] ?? 0;
}
return;
}
const tuple = input as number[];
const length = type === 'vec2f' ? 2 : type === 'vec3f' ? 3 : 4;
for (let index = 0; index < length; index += 1) {
data[base + index] = tuple[index] ?? 0;
}
}
/**
* Packs uniforms into a newly allocated `Float32Array`.
*
* @param uniforms - Uniform values to pack.
* @param layout - Target layout definition.
* @returns Packed float buffer sized to `layout.byteLength`.
*/
export function packUniforms(uniforms: UniformMap, layout: UniformLayout): Float32Array {
const data = new Float32Array(layout.byteLength / 4);
packUniformsInto(uniforms, layout, data);
return data;
}
/**
* Packs uniforms into an existing output buffer and zeroes missing values.
*
* Values are validated against their declared types before being written.
* Uses an optimised fast-write path internally to avoid redundant type checks
* after validation has already been performed for each entry.
*
* @param uniforms - Uniform values to pack.
* @param layout - Target layout metadata.
* @param data - Destination float buffer.
* @throws {Error} When `data` size does not match the required layout size.
*/
export function packUniformsInto(
uniforms: UniformMap,
layout: UniformLayout,
data: Float32Array
): void {
const requiredLength = layout.byteLength / 4;
if (data.length !== requiredLength) {
throw new Error(
`Uniform output buffer size mismatch. Expected ${requiredLength}, got ${data.length}`
);
}
data.fill(0);
for (const entry of layout.entries) {
const raw = uniforms[entry.name];
if (raw === undefined) {
continue;
}
// Validate once per entry, then write via the fast (non-validating) path.
assertUniformValueForType(entry.type, raw);
const base = entry.offset / 4;
writeUniformValueFast(entry.type, raw, data, base);
}
}
/**
* Packs uniforms into an existing output buffer without per-entry validation.
*
* Intended for the renderer render loop where all values have already been
* validated at {@link setUniform} call time, making per-write re-validation
* redundant. Skips the size guard and validation to minimise hot-path overhead.
*
* @internal
* @param uniforms - Pre-validated uniform values to pack.
* @param layout - Target layout metadata.
* @param data - Destination float buffer (must match `layout.byteLength / 4`).
*/
export function packUniformsIntoFast(
uniforms: UniformMap,
layout: UniformLayout,
data: Float32Array
): void {
data.fill(0);
for (const entry of layout.entries) {
const raw = uniforms[entry.name];
if (raw === undefined) {
continue;
}
writeUniformValueFast(entry.type, raw, data, entry.offset / 4);
}
}