@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
270 lines (266 loc) • 11.4 kB
JavaScript
import { textureSampleScalarType } from "./format-capabilities.js";
//#region src/lib/core/compute-shader.ts
/** Bounded locator for the single public compute entrypoint. */
var COMPUTE_ENTRY_CONTRACT = /\bfn\s+compute\s*\(/;
var GLOBAL_INVOCATION_ID_PATTERN = /\s*\(\s*global_invocation_id\s*\)/;
var WORKGROUP_DIMENSION_MIN = 1;
var WORKGROUP_DIMENSION_MAX = 65535;
var DEFAULT_UNIFORM_FIELD = "motiongpu_unused: vec4f,";
function stripWgslComments(source) {
let result = "";
let index = 0;
let blockDepth = 0;
let lineComment = false;
while (index < source.length) {
const current = source[index] ?? "";
const next = source[index + 1] ?? "";
if (lineComment) {
if (current === "\n") {
lineComment = false;
result += "\n";
} else result += " ";
index += 1;
continue;
}
if (blockDepth > 0) {
if (current === "/" && next === "*") {
blockDepth += 1;
result += " ";
index += 2;
continue;
}
if (current === "*" && next === "/") {
blockDepth -= 1;
result += " ";
index += 2;
continue;
}
result += current === "\n" ? "\n" : " ";
index += 1;
continue;
}
if (current === "/" && next === "/") {
lineComment = true;
result += " ";
index += 2;
continue;
}
if (current === "/" && next === "*") {
blockDepth = 1;
result += " ";
index += 2;
continue;
}
result += current;
index += 1;
}
return result;
}
function findComputeEntrypoint(compute) {
const entrypoint = COMPUTE_ENTRY_CONTRACT.exec(compute);
if (!entrypoint) return null;
const openParenIndex = entrypoint.index + entrypoint[0].length - 1;
const prefix = compute.slice(0, entrypoint.index);
const boundary = Math.max(prefix.lastIndexOf("}"), prefix.lastIndexOf(";"));
return {
attributes: prefix.slice(boundary + 1),
openParenIndex
};
}
function extractComputeParamList(compute, entrypoint) {
const { openParenIndex } = entrypoint;
let depth = 0;
for (let index = openParenIndex; index < compute.length; index += 1) {
const char = compute[index];
if (char === "(") depth += 1;
else if (char === ")") {
depth -= 1;
if (depth === 0) return compute.slice(openParenIndex + 1, index);
}
}
return null;
}
function splitWorkgroupSizeArguments(attributeBody) {
const arguments_ = [];
let argumentStart = 0;
let nestedParentheses = 0;
for (let index = 0; index < attributeBody.length; index += 1) {
const char = attributeBody[index];
if (char === "(") nestedParentheses += 1;
else if (char === ")") {
if (nestedParentheses === 0) throw new Error("Malformed @workgroup_size attribute: unexpected closing parenthesis.");
nestedParentheses -= 1;
} else if (char === "," && nestedParentheses === 0) {
arguments_.push(attributeBody.slice(argumentStart, index).trim());
argumentStart = index + 1;
}
}
if (nestedParentheses !== 0) throw new Error("Malformed @workgroup_size attribute: unclosed nested expression.");
arguments_.push(attributeBody.slice(argumentStart).trim());
if (arguments_.length < 1 || arguments_.length > 3 || arguments_.some((argument) => argument.length === 0)) throw new Error("Malformed @workgroup_size attribute: expected between one and three non-empty dimensions.");
return arguments_;
}
function extractWorkgroupSizeArguments(attributes) {
const markers = [...attributes.matchAll(/\b/g)];
if (markers.length !== 1) throw new Error(`Malformed attribute: expected exactly one declaration, found ${markers.length}.`);
let openParenIndex = (markers[0]?.index ?? 0) + (markers[0]?.[0].length ?? 0);
while (/\s/.test(attributes[openParenIndex] ?? "")) openParenIndex += 1;
if (attributes[openParenIndex] !== "(") throw new Error("Malformed @workgroup_size attribute: expected an opening parenthesis.");
let depth = 1;
for (let index = openParenIndex + 1; index < attributes.length; index += 1) {
const char = attributes[index];
if (char === "(") depth += 1;
else if (char === ")") {
depth -= 1;
if (depth === 0) return splitWorkgroupSizeArguments(attributes.slice(openParenIndex + 1, index));
}
}
throw new Error("Malformed @workgroup_size attribute: missing closing parenthesis.");
}
function unwrapParenthesizedExpression(expression) {
let result = expression.trim();
while (result.startsWith("(") && result.endsWith(")")) {
let depth = 0;
let wrapsEntireExpression = true;
for (let index = 0; index < result.length; index += 1) {
const char = result[index];
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (depth === 0 && index < result.length - 1) {
wrapsEntireExpression = false;
break;
}
}
if (!wrapsEntireExpression) break;
result = result.slice(1, -1).trim();
}
return result;
}
function parseIntegerLiteral(expression) {
const unwrapped = unwrapParenthesizedExpression(expression);
if (!/^[+-]?(?:\d+|0[xX][\dA-Fa-f]+)[iu]?$/.test(unwrapped)) return null;
const value = Number(unwrapped.replace(/[iu]$/, ""));
assertWorkgroupDimension(value);
return value;
}
function analyzeWorkgroupSize(compute) {
const entrypoint = findComputeEntrypoint(stripWgslComments(compute));
if (!entrypoint) throw new Error("Could not locate fn compute(...) while analyzing @workgroup_size.");
const literalDimensions = extractWorkgroupSizeArguments(entrypoint.attributes).map(parseIntegerLiteral);
if (literalDimensions.some((dimension) => dimension === null)) return { literal: null };
return { literal: [
literalDimensions[0],
literalDimensions[1] ?? 1,
literalDimensions[2] ?? 1
] };
}
function assertWorkgroupDimension(value) {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < WORKGROUP_DIMENSION_MIN || value > WORKGROUP_DIMENSION_MAX) throw new Error(` dimensions must be integers in range ${WORKGROUP_DIMENSION_MIN}-${WORKGROUP_DIMENSION_MAX}, got ${value}.`);
}
function normalizeExplicitWorkgroupSize(workgroupSize) {
if (workgroupSize.length < 1 || workgroupSize.length > 3) throw new Error("workgroupSize must contain between one and three dimensions.");
const resolved = [
workgroupSize[0],
workgroupSize[1] ?? 1,
workgroupSize[2] ?? 1
];
for (const value of resolved) assertWorkgroupDimension(value);
return resolved;
}
function assertComputeContract(compute, explicitWorkgroupSize) {
const source = stripWgslComments(compute);
const entrypoint = findComputeEntrypoint(source);
if (!entrypoint || !/\b/.test(entrypoint.attributes) || !/\s*\(/.test(entrypoint.attributes)) throw new Error("Compute shader must declare `@compute` and `@workgroup_size(...)` on `fn compute(...)`. Attribute order may vary, but the function must be named `compute`.");
const params = extractComputeParamList(source, entrypoint);
if (!params || !GLOBAL_INVOCATION_ID_PATTERN.test(params)) throw new Error("Compute shader must include a `@builtin(global_invocation_id)` parameter.");
resolveWorkgroupSize(source, explicitWorkgroupSize);
}
function extractWorkgroupSize(compute) {
const { literal } = analyzeWorkgroupSize(compute);
if (!literal) throw new Error("Could not extract @workgroup_size as a literal from compute shader source. Pass an explicit workgroupSize option when the attribute uses an override or constant expression.");
return literal;
}
function resolveWorkgroupSize(compute, explicitWorkgroupSize) {
const { literal } = analyzeWorkgroupSize(compute);
if (!explicitWorkgroupSize) {
if (!literal) throw new Error("Could not extract @workgroup_size as a literal from compute shader source. Pass an explicit workgroupSize option when the attribute uses an override or constant expression.");
return literal;
}
const explicit = normalizeExplicitWorkgroupSize(explicitWorkgroupSize);
if (literal && literal.some((value, index) => value !== explicit[index])) throw new Error(`Explicit workgroupSize ${explicit.join("x")} does not match literal ${literal.join("x")}.`);
return literal ?? explicit;
}
function buildUniformStructForCompute(layout) {
if (layout.entries.length === 0) return DEFAULT_UNIFORM_FIELD;
return layout.entries.map((entry) => `${entry.name}: ${entry.type},`).join("\n ");
}
function assertBindingOrder(resources) {
for (let index = 0; index < resources.length; index += 1) {
const resource = resources[index];
if (!resource || resource.binding !== index) throw new Error(`Resolved compute shader resources must use contiguous binding order; expected binding ${index}.`);
}
}
/**
* Emits the heterogeneous group 1 declarations from the resolver-owned order.
*/
function buildComputeResourceBindings(resources) {
assertBindingOrder(resources);
return resources.map((resource) => {
switch (resource.kind) {
case "sampled-texture": return ` var ${resource.alias}: texture_2d<${resource.scalarType}>;`;
case "storage-texture": return ` var ${resource.alias}: texture_storage_2d<${resource.format}, write>;`;
case "storage-buffer": return ` var<storage, ${resource.access === "storage-read" ? "read" : "read_write"}> ${resource.alias}: ${resource.wgslType};`;
case "sampler": return ` var ${resource.alias}: ${resource.samplerType === "comparison" ? "sampler_comparison" : "sampler"};`;
default: {
const unsupportedKind = resource.kind;
throw new Error(`Unsupported resolved compute shader resource kind: ${String(unsupportedKind)}`);
}
}
}).join("\n");
}
/** Maps storage texture format to sampled `texture_2d<T>` scalar type. */
function storageTextureSampleScalarType(format) {
const scalarType = textureSampleScalarType(format);
return scalarType === "u32" || scalarType === "i32" ? scalarType : "f32";
}
function buildComputeShaderSource(options) {
const uniformFields = buildUniformStructForCompute(options.uniformLayout);
const resourceBindings = buildComputeResourceBindings(options.resources);
return `struct MotionGPUFrame {
time: f32,
delta: f32,
resolution: vec2f,
};
struct MotionGPUUniforms {
${uniformFields}
};
var<uniform> motiongpuFrame: MotionGPUFrame;
var<uniform> motiongpuUniforms: MotionGPUUniforms;
${resourceBindings ? "\n" + resourceBindings : ""}
${options.compute}
`;
}
function buildComputeLineMap(generatedCode, userComputeSource) {
const lineCount = generatedCode.split("\n").length;
const lineMap = new Array(lineCount + 1).fill(null);
const computeStartIndex = generatedCode.indexOf(userComputeSource);
if (computeStartIndex === -1) return lineMap;
const computeStartLine = generatedCode.slice(0, computeStartIndex).split("\n").length;
const computeLineCount = userComputeSource.split("\n").length;
for (let line = 0; line < computeLineCount; line += 1) lineMap[computeStartLine + line] = {
kind: "compute",
line: line + 1
};
return lineMap;
}
/** The renderer's only compute WGSL generation path. */
function buildComputeShaderSourceWithMap(options) {
const code = buildComputeShaderSource(options);
return {
code,
lineMap: buildComputeLineMap(code, options.compute)
};
}
//#endregion
export { COMPUTE_ENTRY_CONTRACT, assertComputeContract, buildComputeResourceBindings, buildComputeShaderSource, buildComputeShaderSourceWithMap, extractWorkgroupSize, resolveWorkgroupSize, storageTextureSampleScalarType };
//# sourceMappingURL=compute-shader.js.map