@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
621 lines (620 loc) • 20.6 kB
JavaScript
import { createCurrentWritable } from "./current-value.js";
//#region src/lib/core/frame-registry.ts
/**
* Default stage key used when task stage is not explicitly specified.
*/
var MAIN_STAGE_KEY = Symbol("motiongpu-main-stage");
var RENDER_MODE_INVALIDATION_TOKEN = Symbol("motiongpu-render-mode-change");
/**
* Default stage callback that runs tasks immediately.
*/
var DEFAULT_STAGE_CALLBACK = (_state, runTasks) => runTasks();
/**
* Normalizes scalar-or-array options to array form.
*/
function asArray(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
/**
* Normalizes frame keys to readable string labels.
*/
function frameKeyToString(key) {
return typeof key === "symbol" ? key.toString() : key;
}
/**
* Extracts task key from either direct key or task reference.
*/
function toTaskKey(reference) {
if (typeof reference === "string" || typeof reference === "symbol") return reference;
return reference.key;
}
/**
* Extracts stage key from either direct key or stage reference.
*/
function toStageKey(reference) {
if (typeof reference === "string" || typeof reference === "symbol") return reference;
return reference.key;
}
/**
* Resolves invalidation token from static value or resolver callback.
*/
function resolveInvalidationToken(token) {
if (token === void 0) return null;
if (typeof token !== "function") return token;
const resolved = token();
if (resolved === null || resolved === void 0) return null;
return resolved;
}
/**
* Normalizes task invalidation options to runtime representation.
*/
function normalizeTaskInvalidation(key, options) {
const explicit = options.invalidation;
if (explicit === void 0) {
if (options.autoInvalidate === false) return {
mode: "never",
lastToken: null,
hasToken: false
};
return {
mode: "always",
token: key,
lastToken: null,
hasToken: false
};
}
if (explicit === "never" || explicit === "always") {
if (explicit === "never") return {
mode: explicit,
lastToken: null,
hasToken: false
};
return {
mode: explicit,
token: key,
lastToken: null,
hasToken: false
};
}
const mode = explicit.mode ?? "always";
const token = explicit.token;
if (mode === "on-change" && token === void 0) throw new Error("Task invalidation mode \"on-change\" requires a token");
if (mode === "never") return {
mode,
lastToken: null,
hasToken: false
};
if (mode === "on-change") return {
mode,
token,
lastToken: null,
hasToken: false
};
return {
mode,
token: token ?? key,
lastToken: null,
hasToken: false
};
}
/**
* Computes aggregate timing stats from sampled durations.
*/
function buildTimingStats(samples, last) {
if (samples.length === 0) return {
last,
avg: 0,
min: 0,
max: 0,
count: 0
};
let sum = 0;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const value of samples) {
sum += value;
if (value < min) min = value;
if (value > max) max = value;
}
return {
last,
avg: sum / samples.length,
min,
max,
count: samples.length
};
}
/**
* Deterministically sorts dependency keys for stable traversal and diagnostics.
*/
function sortDependencyKeys(keys) {
return Array.from(keys).sort((a, b) => frameKeyToString(a).localeCompare(frameKeyToString(b)));
}
/**
* Finds one deterministic cycle path in the directed dependency graph.
*/
function findDependencyCycle(items, edges) {
const visitState = /* @__PURE__ */ new Map();
const stack = [];
let cycle = null;
const sortedItems = [...items].sort((a, b) => a.order - b.order);
const visit = (key) => {
visitState.set(key, 1);
stack.push(key);
for (const childKey of sortDependencyKeys(edges.get(key) ?? [])) {
const state = visitState.get(childKey) ?? 0;
if (state === 0) {
if (visit(childKey)) return true;
continue;
}
if (state === 1) {
const cycleStartIndex = stack.findIndex((entry) => entry === childKey);
cycle = [...cycleStartIndex === -1 ? [childKey] : stack.slice(cycleStartIndex), childKey];
return true;
}
}
stack.pop();
visitState.set(key, 2);
return false;
};
for (const item of sortedItems) {
if ((visitState.get(item.key) ?? 0) !== 0) continue;
if (visit(item.key)) return cycle;
}
return null;
}
/**
* Topologically sorts items by `before`/`after` dependencies.
*
* Throws deterministic errors when dependencies are missing or cyclic.
*/
function sortByDependencies(items, getBefore, getAfter, options) {
const itemsByKey = /* @__PURE__ */ new Map();
for (const item of items) itemsByKey.set(item.key, item);
const indegree = /* @__PURE__ */ new Map();
const edges = /* @__PURE__ */ new Map();
for (const item of items) {
indegree.set(item.key, 0);
edges.set(item.key, /* @__PURE__ */ new Set());
}
for (const item of items) {
for (const dependencyKey of getAfter(item)) {
if (!itemsByKey.has(dependencyKey)) {
if (options.isKnownExternalDependency?.(dependencyKey)) continue;
throw new Error(`${options.graphName} dependency error: ${options.getItemLabel(item)} references missing dependency "${frameKeyToString(dependencyKey)}" in "after".`);
}
edges.get(dependencyKey)?.add(item.key);
indegree.set(item.key, (indegree.get(item.key) ?? 0) + 1);
}
for (const dependencyKey of getBefore(item)) {
if (!itemsByKey.has(dependencyKey)) {
if (options.isKnownExternalDependency?.(dependencyKey)) continue;
throw new Error(`${options.graphName} dependency error: ${options.getItemLabel(item)} references missing dependency "${frameKeyToString(dependencyKey)}" in "before".`);
}
edges.get(item.key)?.add(dependencyKey);
indegree.set(dependencyKey, (indegree.get(dependencyKey) ?? 0) + 1);
}
}
const queue = items.filter((item) => (indegree.get(item.key) ?? 0) === 0);
queue.sort((a, b) => a.order - b.order);
const ordered = [];
let head = 0;
while (head < queue.length) {
const current = queue[head];
head += 1;
if (!current) break;
ordered.push(current);
for (const childKey of edges.get(current.key) ?? []) {
const nextDegree = (indegree.get(childKey) ?? 0) - 1;
indegree.set(childKey, nextDegree);
if (nextDegree === 0) {
const child = itemsByKey.get(childKey);
if (child) {
let insertIndex = queue.length;
while (insertIndex > head && (queue[insertIndex - 1]?.order ?? 0) > child.order) insertIndex -= 1;
queue.splice(insertIndex, 0, child);
}
}
}
}
if (ordered.length !== items.length) {
const cycle = findDependencyCycle(items, edges);
if (cycle) throw new Error(`${options.graphName} dependency cycle detected: ${cycle.map((key) => frameKeyToString(key)).join(" -> ")}`);
throw new Error(`${options.graphName} dependency resolution failed.`);
}
return ordered;
}
/**
* Creates a frame registry used by `FragCanvas` and `useFrame`.
*
* @param options - Initial scheduler options.
* @returns Mutable frame registry instance.
*/
function createFrameRegistry(options) {
let renderMode = options?.renderMode ?? "always";
let autoRender = options?.autoRender ?? true;
let maxDelta = options?.maxDelta ?? .1;
let profilingEnabled = options?.profilingEnabled ?? options?.diagnosticsEnabled ?? false;
let profilingWindow = options?.profilingWindow ?? 120;
let lastRunTimings = null;
let ringBuffer = new Array(profilingWindow);
let ringHead = 0;
let ringCount = 0;
let hasUntokenizedInvalidation = true;
const invalidationTokens = /* @__PURE__ */ new Set();
let shouldAdvance = false;
let orderCounter = 0;
let clampedFrameState = null;
const assertMaxDelta = (value) => {
if (!Number.isFinite(value) || value <= 0) throw new Error("maxDelta must be a finite number greater than 0");
return value;
};
const assertProfilingWindow = (value) => {
if (!Number.isFinite(value) || value <= 0) throw new Error("profilingWindow must be a finite number greater than 0");
return Math.floor(value);
};
maxDelta = assertMaxDelta(maxDelta);
profilingWindow = assertProfilingWindow(profilingWindow);
const stages = /* @__PURE__ */ new Map();
let scheduleDirty = true;
let sortedStages = [];
const sortedTasksByStage = /* @__PURE__ */ new Map();
let scheduleSnapshot = { stages: [] };
const markScheduleDirty = () => {
scheduleDirty = true;
};
const syncSchedule = () => {
if (!scheduleDirty) return;
const stageList = sortByDependencies(Array.from(stages.values()), (stage) => stage.before, (stage) => stage.after, {
graphName: "Frame stage graph",
getItemLabel: (stage) => `stage "${frameKeyToString(stage.key)}"`
});
const nextTasksByStage = /* @__PURE__ */ new Map();
const globalTaskKeys = /* @__PURE__ */ new Set();
for (const stage of stageList) for (const task of stage.tasks.values()) globalTaskKeys.add(task.task.key);
for (const stage of stageList) {
const taskList = sortByDependencies(Array.from(stage.tasks.values()).map((task) => ({
key: task.task.key,
order: task.order,
task
})), (task) => task.task.before, (task) => task.task.after, {
graphName: `Frame task graph for stage "${frameKeyToString(stage.key)}"`,
getItemLabel: (task) => `task "${frameKeyToString(task.key)}"`,
isKnownExternalDependency: (key) => globalTaskKeys.has(key)
}).map((task) => task.task);
nextTasksByStage.set(stage.key, taskList);
}
sortedStages = stageList;
sortedTasksByStage.clear();
for (const [stageKey, taskList] of nextTasksByStage) sortedTasksByStage.set(stageKey, taskList);
scheduleSnapshot = { stages: sortedStages.map((stage) => ({
key: frameKeyToString(stage.key),
tasks: (sortedTasksByStage.get(stage.key) ?? []).map((task) => frameKeyToString(task.task.key))
})) };
scheduleDirty = false;
};
const pushProfile = (timings) => {
if (ringCount < profilingWindow) {
ringBuffer[(ringHead + ringCount) % profilingWindow] = timings;
ringCount += 1;
} else {
ringBuffer[ringHead] = timings;
ringHead = (ringHead + 1) % profilingWindow;
}
};
const clearProfiling = () => {
ringHead = 0;
ringCount = 0;
lastRunTimings = null;
};
const buildProfilingSnapshot = () => {
if (!profilingEnabled) return null;
const stageBuckets = /* @__PURE__ */ new Map();
const totalDurations = [];
for (let ri = 0; ri < ringCount; ri++) {
const frame = ringBuffer[(ringHead + ri) % profilingWindow];
totalDurations.push(frame.total);
for (const [stageKey, stageTiming] of Object.entries(frame.stages)) {
const stageBucket = stageBuckets.get(stageKey) ?? {
durations: [],
taskDurations: /* @__PURE__ */ new Map()
};
stageBucket.durations.push(stageTiming.duration);
for (const [taskKey, taskDuration] of Object.entries(stageTiming.tasks)) {
const bucket = stageBucket.taskDurations.get(taskKey) ?? [];
bucket.push(taskDuration);
stageBucket.taskDurations.set(taskKey, bucket);
}
stageBuckets.set(stageKey, stageBucket);
}
}
const stagesSnapshot = {};
for (const [stageKey, stageBucket] of stageBuckets) {
const lastStageDuration = lastRunTimings?.stages[stageKey]?.duration ?? 0;
const taskSnapshot = {};
for (const [taskKey, taskDurations] of stageBucket.taskDurations) taskSnapshot[taskKey] = buildTimingStats(taskDurations, lastRunTimings?.stages[stageKey]?.tasks[taskKey] ?? 0);
stagesSnapshot[stageKey] = {
timings: buildTimingStats(stageBucket.durations, lastStageDuration),
tasks: taskSnapshot
};
}
return {
window: profilingWindow,
frameCount: ringCount,
lastFrame: lastRunTimings,
total: buildTimingStats(totalDurations, lastRunTimings?.total ?? 0),
stages: stagesSnapshot
};
};
const ensureStage = (stageReference, stageOptions) => {
const stageKey = toStageKey(stageReference);
const existing = stages.get(stageKey);
if (existing) {
if (stageOptions?.before !== void 0) {
existing.before = new Set(stageOptions.before.map((entry) => toStageKey(entry)));
markScheduleDirty();
}
if (stageOptions?.after !== void 0) {
existing.after = new Set(stageOptions.after.map((entry) => toStageKey(entry)));
markScheduleDirty();
}
if (stageOptions && Object.prototype.hasOwnProperty.call(stageOptions, "callback")) existing.callback = stageOptions.callback ?? DEFAULT_STAGE_CALLBACK;
return existing;
}
const stage = {
key: stageKey,
order: orderCounter++,
started: true,
before: new Set((stageOptions?.before ?? []).map((entry) => toStageKey(entry))),
after: new Set((stageOptions?.after ?? []).map((entry) => toStageKey(entry))),
callback: stageOptions?.callback ?? DEFAULT_STAGE_CALLBACK,
tasks: /* @__PURE__ */ new Map()
};
stages.set(stageKey, stage);
markScheduleDirty();
return stage;
};
ensureStage(MAIN_STAGE_KEY);
const resolveEffectiveRunning = (task) => {
const running = task.started && (task.running?.() ?? true);
if (task.lastRunning !== running) {
task.lastRunning = running;
task.startedStoreSet(running);
}
return running;
};
const hasPendingInvalidation = () => {
return hasUntokenizedInvalidation || invalidationTokens.size > 0;
};
const invalidateWithToken = (token) => {
if (token === void 0) {
hasUntokenizedInvalidation = true;
return;
}
invalidationTokens.add(token);
};
const applyTaskInvalidation = (task) => {
const config = task.invalidation;
if (config.mode === "never") return;
if (config.mode === "always") {
invalidateWithToken(resolveInvalidationToken(config.token) ?? task.task.key);
return;
}
const token = resolveInvalidationToken(config.token);
if (token === null) {
config.hasToken = false;
config.lastToken = null;
return;
}
const changed = !config.hasToken || config.lastToken !== token;
config.hasToken = true;
config.lastToken = token;
if (changed) invalidateWithToken(token);
};
return {
register(keyOrCallback, callbackOrOptions, maybeOptions) {
const key = typeof keyOrCallback === "function" ? Symbol("motiongpu-task") : keyOrCallback;
const callback = typeof keyOrCallback === "function" ? keyOrCallback : callbackOrOptions;
const taskOptions = typeof keyOrCallback === "function" ? callbackOrOptions ?? {} : maybeOptions ?? {};
if (typeof callback !== "function") throw new Error("useFrame requires a callback");
const before = asArray(taskOptions.before);
const after = asArray(taskOptions.after);
const inferredStage = [...before, ...after].find((entry) => typeof entry === "object" && entry !== null && "stage" in entry);
const stage = ensureStage(taskOptions.stage ? toStageKey(taskOptions.stage) : inferredStage?.stage ?? MAIN_STAGE_KEY);
const startedWritable = createCurrentWritable(taskOptions.autoStart ?? true);
const internalTask = {
task: {
key,
stage: stage.key
},
keyString: frameKeyToString(key),
callback,
order: orderCounter++,
started: taskOptions.autoStart ?? true,
lastRunning: taskOptions.autoStart ?? true,
startedStoreSet: startedWritable.set,
startedStore: { subscribe: startedWritable.subscribe },
before: new Set(before.map((entry) => toTaskKey(entry))),
after: new Set(after.map((entry) => toTaskKey(entry))),
invalidation: normalizeTaskInvalidation(key, taskOptions)
};
if (taskOptions.running) internalTask.running = taskOptions.running;
stage.tasks.set(key, internalTask);
markScheduleDirty();
internalTask.startedStoreSet(resolveEffectiveRunning(internalTask));
const start = () => {
internalTask.started = true;
resolveEffectiveRunning(internalTask);
};
const stop = () => {
internalTask.started = false;
resolveEffectiveRunning(internalTask);
};
return {
task: internalTask.task,
start,
stop,
started: internalTask.startedStore,
unsubscribe: () => {
if (stage.tasks.get(key) === internalTask && stage.tasks.delete(key)) markScheduleDirty();
}
};
},
run(state) {
const clampedDelta = Math.min(state.delta, maxDelta);
let frameState;
if (clampedDelta === state.delta) frameState = state;
else {
if (clampedFrameState === null) clampedFrameState = {
...state,
delta: clampedDelta
};
else {
clampedFrameState.time = state.time;
clampedFrameState.delta = clampedDelta;
clampedFrameState.setUniform = state.setUniform;
clampedFrameState.setTexture = state.setTexture;
clampedFrameState.writeStorageBuffer = state.writeStorageBuffer;
clampedFrameState.readStorageBuffer = state.readStorageBuffer;
clampedFrameState.invalidate = state.invalidate;
clampedFrameState.advance = state.advance;
clampedFrameState.renderMode = state.renderMode;
clampedFrameState.autoRender = state.autoRender;
clampedFrameState.canvas = state.canvas;
}
frameState = clampedFrameState;
}
syncSchedule();
const frameStart = profilingEnabled ? performance.now() : 0;
const stageTimings = {};
for (const stage of sortedStages) {
if (!stage.started) continue;
const stageStart = profilingEnabled ? performance.now() : 0;
const taskTimings = {};
const taskList = sortedTasksByStage.get(stage.key) ?? [];
stage.callback(frameState, () => {
for (const task of taskList) {
if (!resolveEffectiveRunning(task)) continue;
const taskStart = profilingEnabled ? performance.now() : 0;
task.callback(frameState);
if (profilingEnabled) taskTimings[task.keyString] = performance.now() - taskStart;
applyTaskInvalidation(task);
}
});
if (profilingEnabled) stageTimings[frameKeyToString(stage.key)] = {
duration: performance.now() - stageStart,
tasks: taskTimings
};
}
if (profilingEnabled) {
const timings = {
total: performance.now() - frameStart,
stages: stageTimings
};
lastRunTimings = timings;
pushProfile(timings);
}
},
invalidate(token) {
invalidateWithToken(token);
},
advance() {
shouldAdvance = true;
invalidateWithToken();
},
shouldRender() {
if (!autoRender) return false;
if (renderMode === "always") return true;
if (renderMode === "on-demand") return shouldAdvance || hasPendingInvalidation();
return shouldAdvance;
},
endFrame() {
hasUntokenizedInvalidation = false;
invalidationTokens.clear();
shouldAdvance = false;
},
setRenderMode(mode) {
if (renderMode === mode) return;
renderMode = mode;
shouldAdvance = false;
if (mode === "on-demand") invalidateWithToken(RENDER_MODE_INVALIDATION_TOKEN);
},
setAutoRender(enabled) {
autoRender = enabled;
},
setMaxDelta(value) {
maxDelta = assertMaxDelta(value);
},
setProfilingEnabled(enabled) {
profilingEnabled = enabled;
if (!enabled) clearProfiling();
},
setProfilingWindow(window) {
const newWindow = assertProfilingWindow(window);
if (newWindow === profilingWindow) return;
const keep = Math.min(ringCount, newWindow);
const startOffset = ringCount - keep;
const newBuffer = new Array(newWindow);
for (let i = 0; i < keep; i++) newBuffer[i] = ringBuffer[(ringHead + startOffset + i) % profilingWindow];
profilingWindow = newWindow;
ringBuffer = newBuffer;
ringHead = 0;
ringCount = keep;
},
resetProfiling() {
clearProfiling();
},
setDiagnosticsEnabled(enabled) {
profilingEnabled = enabled;
if (!enabled) clearProfiling();
},
getRenderMode() {
return renderMode;
},
getAutoRender() {
return autoRender;
},
getMaxDelta() {
return maxDelta;
},
getProfilingEnabled() {
return profilingEnabled;
},
getProfilingWindow() {
return profilingWindow;
},
getProfilingSnapshot() {
return buildProfilingSnapshot();
},
getDiagnosticsEnabled() {
return profilingEnabled;
},
getLastRunTimings() {
return lastRunTimings;
},
getSchedule() {
syncSchedule();
return scheduleSnapshot;
},
createStage(key, options) {
return { key: ensureStage(key, options ? {
...Object.prototype.hasOwnProperty.call(options, "before") ? { before: asArray(options.before) } : {},
...Object.prototype.hasOwnProperty.call(options, "after") ? { after: asArray(options.after) } : {},
...Object.prototype.hasOwnProperty.call(options, "callback") ? { callback: options.callback ?? null } : {}
} : void 0).key };
},
getStage(key) {
const stage = stages.get(key);
if (!stage) return;
return { key: stage.key };
},
clear() {
for (const stage of stages.values()) stage.tasks.clear();
markScheduleDirty();
}
};
}
//#endregion
export { createFrameRegistry };
//# sourceMappingURL=frame-registry.js.map