@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
587 lines (539 loc) • 17.2 kB
text/typescript
import {
getShaderCompilationDiagnostics,
type ShaderCompilationDiagnostic
} from './error-diagnostics.js';
import { formatShaderSourceLocation } from './shader.js';
/**
* Runtime phase in which an error occurred.
*/
export type MotionGPUErrorPhase = 'initialization' | 'render';
/**
* Stable machine-readable error category code.
*/
export type MotionGPUErrorCode =
| 'WEBGPU_UNAVAILABLE'
| 'WEBGPU_ADAPTER_UNAVAILABLE'
| 'WEBGPU_CONTEXT_UNAVAILABLE'
| 'WGSL_COMPILATION_FAILED'
| 'MATERIAL_PREPROCESS_FAILED'
| 'WEBGPU_DEVICE_LOST'
| 'WEBGPU_UNCAPTURED_ERROR'
| 'BIND_GROUP_MISMATCH'
| 'RUNTIME_RESOURCE_MISSING'
| 'UNIFORM_VALUE_INVALID'
| 'STORAGE_BUFFER_OUT_OF_BOUNDS'
| 'STORAGE_BUFFER_READ_FAILED'
| 'RENDER_GRAPH_INVALID'
| 'PINGPONG_CONFIGURATION_INVALID'
| 'TEXTURE_USAGE_INVALID'
| 'TEXTURE_REQUEST_FAILED'
| 'TEXTURE_DECODE_UNAVAILABLE'
| 'TEXTURE_REQUEST_ABORTED'
| 'COMPUTE_COMPILATION_FAILED'
| 'COMPUTE_CONTRACT_INVALID'
| 'MOTIONGPU_RUNTIME_ERROR';
/**
* Severity level for user-facing diagnostics.
*/
export type MotionGPUErrorSeverity = 'error' | 'fatal';
/**
* One source-code line displayed in diagnostics snippet.
*/
export interface MotionGPUErrorSourceLine {
number: number;
code: string;
highlight: boolean;
}
/**
* Structured source context displayed for shader compilation errors.
*/
export interface MotionGPUErrorSource {
component: string;
location: string;
line: number;
column?: number;
snippet: MotionGPUErrorSourceLine[];
}
/**
* Optional runtime context captured with diagnostics payload.
*/
export interface MotionGPUErrorContext {
materialSignature?: string;
passGraph?: {
passCount: number;
enabledPassCount: number;
inputs: string[];
outputs: string[];
};
activeRenderTargets: string[];
}
/**
* Structured error payload used by UI diagnostics.
*/
export interface MotionGPUErrorReport {
/**
* Stable machine-readable category code.
*/
code: MotionGPUErrorCode;
/**
* Severity level used by diagnostics UIs and telemetry.
*/
severity: MotionGPUErrorSeverity;
/**
* Whether runtime may recover without full renderer re-creation.
*/
recoverable: boolean;
/**
* Short category title.
*/
title: string;
/**
* Primary human-readable message.
*/
message: string;
/**
* Suggested remediation hint.
*/
hint: string;
/**
* Additional parsed details (for example WGSL line errors).
*/
details: string[];
/**
* Stack trace lines when available.
*/
stack: string[];
/**
* Original unmodified message.
*/
rawMessage: string;
/**
* Runtime phase where the error occurred.
*/
phase: MotionGPUErrorPhase;
/**
* Optional source context for shader-related diagnostics.
*/
source: MotionGPUErrorSource | null;
/**
* Optional runtime context snapshot (material/pass graph/render targets).
*/
context: MotionGPUErrorContext | null;
}
/**
* Splits multi-line values into trimmed non-empty lines.
*/
function splitLines(value: string): string[] {
return value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function toDisplayName(path: string): string {
const normalized = path.split(/[?#]/)[0] ?? path;
const chunks = normalized.split(/[\\/]/);
const last = chunks[chunks.length - 1];
return last && last.length > 0 ? last : path;
}
function toSnippet(source: string, line: number, radius = 3): MotionGPUErrorSourceLine[] {
const lines = source.replace(/\r\n?/g, '\n').split('\n');
if (lines.length === 0) {
return [];
}
const targetLine = Math.min(Math.max(1, line), lines.length);
const start = Math.max(1, targetLine - radius);
const end = Math.min(lines.length, targetLine + radius);
const snippet: MotionGPUErrorSourceLine[] = [];
for (let index = start; index <= end; index += 1) {
snippet.push({
number: index,
code: lines[index - 1] ?? '',
highlight: index === targetLine
});
}
return snippet;
}
function buildSourceFromDiagnostics(error: unknown): MotionGPUErrorSource | null {
const diagnostics = getShaderCompilationDiagnostics(error);
if (!diagnostics || diagnostics.diagnostics.length === 0) {
return null;
}
const primary = diagnostics.diagnostics.find((entry) => entry.sourceLocation !== null);
if (!primary?.sourceLocation) {
return null;
}
const location = primary.sourceLocation;
const column = primary.linePos && primary.linePos > 0 ? primary.linePos : undefined;
if (location.kind === 'fragment') {
const component =
diagnostics.materialSource?.component ??
(diagnostics.materialSource?.file
? toDisplayName(diagnostics.materialSource.file)
: 'User shader fragment');
const locationLabel = formatShaderSourceLocation(location) ?? `fragment line ${location.line}`;
return {
component,
location: `${component} (${locationLabel})`,
line: location.line,
...(column !== undefined ? { column } : {}),
snippet: toSnippet(diagnostics.fragmentSource, location.line)
};
}
if (location.kind === 'include') {
const includeName = location.include ?? 'unknown';
const includeSource = diagnostics.includeSources[includeName] ?? '';
const component = `#include <${includeName}>`;
const locationLabel = formatShaderSourceLocation(location) ?? `include <${includeName}>`;
return {
component,
location: `${component} (${locationLabel})`,
line: location.line,
...(column !== undefined ? { column } : {}),
snippet: toSnippet(includeSource, location.line)
};
}
if (location.kind === 'compute') {
const computeSource = diagnostics.computeSource ?? diagnostics.fragmentSource;
const component = 'Compute shader';
const locationLabel = formatShaderSourceLocation(location) ?? `compute line ${location.line}`;
return {
component,
location: `${component} (${locationLabel})`,
line: location.line,
...(column !== undefined ? { column } : {}),
snippet: toSnippet(computeSource, location.line)
};
}
const defineName = location.define ?? 'unknown';
const defineLine = Math.max(1, location.line);
const component = `#define ${defineName}`;
const locationLabel =
formatShaderSourceLocation(location) ?? `define "${defineName}" line ${defineLine}`;
return {
component,
location: `${component} (${locationLabel})`,
line: defineLine,
...(column !== undefined ? { column } : {}),
snippet: toSnippet(diagnostics.defineBlockSource ?? '', defineLine, 2)
};
}
function formatDiagnosticMessage(entry: ShaderCompilationDiagnostic): string {
const sourceLabel = formatShaderSourceLocation(entry.sourceLocation);
const generatedLineLabel =
entry.generatedLine > 0 ? `generated WGSL line ${entry.generatedLine}` : null;
const labels = [sourceLabel, generatedLineLabel].filter((value) => Boolean(value));
if (labels.length === 0) {
return entry.message;
}
return `[${labels.join(' | ')}] ${entry.message}`;
}
/**
* Maps known WebGPU/WGSL error patterns to a user-facing title and hint.
*/
function classifyErrorMessage(
message: string
): Pick<MotionGPUErrorReport, 'code' | 'severity' | 'recoverable' | 'title' | 'hint'> {
if (message.includes('WebGPU is not available in this browser')) {
return {
code: 'WEBGPU_UNAVAILABLE',
severity: 'fatal',
recoverable: false,
title: 'WebGPU unavailable',
hint: 'Use a browser with WebGPU enabled (latest Chrome/Edge/Safari TP) and secure context.'
};
}
if (message.includes('Unable to acquire WebGPU adapter')) {
return {
code: 'WEBGPU_ADAPTER_UNAVAILABLE',
severity: 'fatal',
recoverable: false,
title: 'WebGPU adapter unavailable',
hint: 'GPU adapter request failed. Check browser permissions, flags and device support.'
};
}
if (message.includes('Canvas does not support webgpu context')) {
return {
code: 'WEBGPU_CONTEXT_UNAVAILABLE',
severity: 'error',
recoverable: true,
title: 'Canvas cannot create WebGPU context',
hint: 'Make sure this canvas is attached to DOM and not using an unsupported context option.'
};
}
if (message.includes('WGSL compilation failed')) {
return {
code: 'WGSL_COMPILATION_FAILED',
severity: 'error',
recoverable: true,
title: 'WGSL compilation failed',
hint: 'Check WGSL line numbers below and verify struct/binding/function signatures.'
};
}
if (
message.includes('Invalid include directive in fragment shader.') ||
message.includes('Unknown include "') ||
message.includes('Circular include detected for "') ||
message.includes('Invalid define value for "') ||
message.includes('Invalid include "')
) {
return {
code: 'MATERIAL_PREPROCESS_FAILED',
severity: 'error',
recoverable: true,
title: 'Material preprocess failed',
hint: 'Validate #include keys, define values and include expansion order before retrying.'
};
}
if (message.includes('Compute shader compilation failed')) {
return {
code: 'COMPUTE_COMPILATION_FAILED',
severity: 'error',
recoverable: true,
title: 'Compute shader compilation failed',
hint: 'Check WGSL compute shader sources below and verify storage bindings.'
};
}
if (
message.includes(
'Compute shader must declare `@compute @workgroup_size(...) fn compute(...)`.'
) ||
message.includes('Compute shader must include a `@builtin(global_invocation_id)` parameter.') ||
message.includes('Could not extract @workgroup_size from compute shader source.') ||
message.includes('@workgroup_size dimensions must be integers in range') ||
message.includes('Unsupported storage buffer access mode "')
) {
return {
code: 'COMPUTE_CONTRACT_INVALID',
severity: 'error',
recoverable: true,
title: 'Compute contract is invalid',
hint: 'Ensure compute shader contract (@compute, @workgroup_size, global_invocation_id, storage access) is valid.'
};
}
if (message.includes('WebGPU device lost') || message.includes('Device Lost')) {
return {
code: 'WEBGPU_DEVICE_LOST',
severity: 'fatal',
recoverable: false,
title: 'WebGPU device lost',
hint: 'GPU device/context was lost. Recreate the renderer and check OS/GPU stability.'
};
}
if (
message.includes('Dispatch workgroup count') &&
message.includes('max compute workgroups per dimension')
) {
return {
code: 'WEBGPU_UNCAPTURED_ERROR',
severity: 'error',
recoverable: true,
title: 'Compute dispatch exceeds device limit',
hint: 'Reduce dispatch counts or split compute work into multiple dispatches/chunks.'
};
}
if (
message.includes('maximum storage buffer binding size') ||
message.includes('maxStorageBufferBindingSize')
) {
return {
code: 'WEBGPU_UNCAPTURED_ERROR',
severity: 'error',
recoverable: true,
title: 'Storage buffer exceeds binding limit',
hint: 'Keep each storage buffer binding below adapter limits or shard data across multiple buffers.'
};
}
if (message.includes('WebGPU uncaptured error')) {
return {
code: 'WEBGPU_UNCAPTURED_ERROR',
severity: 'error',
recoverable: true,
title: 'WebGPU uncaptured error',
hint: 'A GPU command failed asynchronously. Review details and validate resource/state usage.'
};
}
if (message.includes('CreateBindGroup') || message.includes('bind group layout')) {
return {
code: 'BIND_GROUP_MISMATCH',
severity: 'error',
recoverable: true,
title: 'Bind group mismatch',
hint: 'Bindings in shader and runtime resources are out of sync. Verify uniforms/textures layout.'
};
}
if (message.includes('Storage buffer "') && message.includes('write out of bounds:')) {
return {
code: 'STORAGE_BUFFER_OUT_OF_BOUNDS',
severity: 'error',
recoverable: true,
title: 'Storage buffer write out of bounds',
hint: 'Ensure offset + write byte length does not exceed declared storage buffer size.'
};
}
if (
message.includes('Cannot read storage buffer "') ||
message.includes('Cannot read storage buffer: GPU device unavailable.') ||
message.includes('not allocated on GPU.')
) {
return {
code: 'STORAGE_BUFFER_READ_FAILED',
severity: 'error',
recoverable: true,
title: 'Storage buffer read failed',
hint: 'Readbacks require an initialized renderer, allocated GPU buffer and active device.'
};
}
if (
message.includes('Unknown uniform "') ||
message.includes('Unknown uniform type for "') ||
message.includes('Unknown texture "') ||
message.includes('Unknown storage buffer "') ||
message.includes('Missing definition for storage buffer "') ||
message.includes('Missing texture definition for "') ||
(message.includes('Storage buffer "') && message.includes('" not allocated.')) ||
(message.includes('Storage texture "') && message.includes('" not allocated.'))
) {
return {
code: 'RUNTIME_RESOURCE_MISSING',
severity: 'error',
recoverable: true,
title: 'Runtime resource binding failed',
hint: 'Check material declarations and runtime keys for uniforms, textures and storage resources.'
};
}
if (message.includes('Uniform ') && message.includes(' value must')) {
return {
code: 'UNIFORM_VALUE_INVALID',
severity: 'error',
recoverable: true,
title: 'Uniform value is invalid',
hint: 'Provide finite values with tuple/matrix sizes matching the uniform type.'
};
}
if (
message.includes('Render pass #') ||
message.includes('Render graph references unknown runtime target')
) {
return {
code: 'RENDER_GRAPH_INVALID',
severity: 'error',
recoverable: true,
title: 'Render graph configuration is invalid',
hint: 'Verify pass inputs/outputs, declared render targets and execution order.'
};
}
if (message.includes('PingPongComputePass must provide a target texture key.')) {
return {
code: 'PINGPONG_CONFIGURATION_INVALID',
severity: 'error',
recoverable: true,
title: 'Ping-pong compute pass is misconfigured',
hint: 'Configure a valid target texture key for PingPongComputePass.'
};
}
if (message.includes('Destination texture needs to have CopyDst')) {
return {
code: 'TEXTURE_USAGE_INVALID',
severity: 'error',
recoverable: true,
title: 'Invalid texture usage flags',
hint: 'Texture used as upload destination must include CopyDst (and often RenderAttachment).'
};
}
if (message.includes('Texture request failed')) {
return {
code: 'TEXTURE_REQUEST_FAILED',
severity: 'error',
recoverable: true,
title: 'Texture request failed',
hint: 'Verify texture URL, CORS policy and response status before retrying.'
};
}
if (message.includes('createImageBitmap is not available in this runtime')) {
return {
code: 'TEXTURE_DECODE_UNAVAILABLE',
severity: 'fatal',
recoverable: false,
title: 'Texture decode unavailable',
hint: 'Runtime lacks createImageBitmap support. Use a browser/runtime with image bitmap decoding.'
};
}
if (message.toLowerCase().includes('texture request was aborted')) {
return {
code: 'TEXTURE_REQUEST_ABORTED',
severity: 'error',
recoverable: true,
title: 'Texture request aborted',
hint: 'Texture load was cancelled. Retry the request when source inputs stabilize.'
};
}
return {
code: 'MOTIONGPU_RUNTIME_ERROR',
severity: 'error',
recoverable: true,
title: 'MotionGPU render error',
hint: 'Review technical details below. If issue persists, isolate shader/uniform/texture changes.'
};
}
/**
* Converts unknown errors to a consistent, display-ready error report.
*
* @param error - Unknown thrown value.
* @param phase - Phase during which error occurred.
* @returns Normalized error report.
*/
export function toMotionGPUErrorReport(
error: unknown,
phase: MotionGPUErrorPhase
): MotionGPUErrorReport {
const shaderDiagnostics = getShaderCompilationDiagnostics(error);
const rawMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Unknown FragCanvas error';
const rawLines = splitLines(rawMessage);
const defaultMessage = rawLines[0] ?? rawMessage;
const defaultDetails = rawLines.slice(1);
const source = buildSourceFromDiagnostics(error);
const context = shaderDiagnostics?.runtimeContext ?? null;
const message =
shaderDiagnostics && shaderDiagnostics.diagnostics[0]
? formatDiagnosticMessage(shaderDiagnostics.diagnostics[0])
: defaultMessage;
const details = shaderDiagnostics
? shaderDiagnostics.diagnostics.slice(1).map((entry) => formatDiagnosticMessage(entry))
: defaultDetails;
const stack =
error instanceof Error && error.stack
? splitLines(error.stack).filter((line) => line !== message)
: [];
let classification = classifyErrorMessage(rawMessage);
if (
shaderDiagnostics?.shaderStage === 'compute' &&
classification.code === 'WGSL_COMPILATION_FAILED'
) {
classification = {
code: 'COMPUTE_COMPILATION_FAILED',
severity: 'error',
recoverable: true,
title: 'Compute shader compilation failed',
hint: 'Check WGSL compute shader sources below and verify storage bindings.'
};
}
return {
code: classification.code,
severity: classification.severity,
recoverable: classification.recoverable,
title: classification.title,
message,
hint: classification.hint,
details,
stack,
rawMessage,
phase,
source,
context
};
}