UNPKG

@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
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