@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
1,254 lines (1,131 loc) • 32 kB
text/typescript
import { createCurrentWritable, type CurrentWritable, type Subscribable } from './current-value.js';
import type { FrameInvalidationToken, FrameState, RenderMode } from './types.js';
/**
* Per-frame callback executed by the frame scheduler.
*/
export type FrameCallback = (state: FrameState) => void;
/**
* Stable key type used to identify frame tasks and stages.
*/
export type FrameKey = string | symbol;
/**
* Public metadata describing a registered frame task.
*/
export interface FrameTask {
key: FrameKey;
stage: FrameKey;
}
/**
* Public metadata describing a frame stage.
*/
export interface FrameStage {
key: FrameKey;
}
/**
* Stage callback allowing custom orchestration around task execution.
*/
export type FrameStageCallback = (state: FrameState, runTasks: () => void) => void;
/**
* Options controlling task registration and scheduling behavior.
*/
export interface UseFrameOptions {
/**
* Whether task starts in active state.
*
* @default true
*/
autoStart?: boolean;
/**
* Whether task execution invalidates frame automatically.
*
* @default true
*/
autoInvalidate?: boolean;
/**
* Explicit task invalidation policy.
*/
invalidation?: FrameTaskInvalidation;
/**
* Stage to register task in.
*
* If omitted, main stage is used unless inferred from task dependencies.
*/
stage?: FrameKey | FrameStage;
/**
* Task dependencies that should run after this task.
*/
before?: (FrameKey | FrameTask) | (FrameKey | FrameTask)[];
/**
* Task dependencies that should run before this task.
*/
after?: (FrameKey | FrameTask) | (FrameKey | FrameTask)[];
/**
* Dynamic predicate controlling whether the task is currently active.
*/
running?: () => boolean;
}
/**
* Invalidation token value or resolver.
*/
export type FrameTaskInvalidationToken =
| FrameInvalidationToken
| (() => FrameInvalidationToken | null | undefined);
/**
* Explicit task invalidation policy.
*/
export type FrameTaskInvalidation =
| 'never'
| 'always'
| {
mode?: 'never' | 'always';
token?: FrameTaskInvalidationToken;
}
| {
mode: 'on-change';
token: FrameTaskInvalidationToken;
};
/**
* Handle returned by `useFrame` registration.
*/
export interface UseFrameResult {
/**
* Registered task metadata.
*/
task: FrameTask;
/**
* Starts task execution.
*/
start: () => void;
/**
* Stops task execution.
*/
stop: () => void;
/**
* Readable flag representing effective running state.
*/
started: Subscribable<boolean>;
}
/**
* Snapshot of the resolved stage/task execution order.
*/
export interface FrameScheduleSnapshot {
stages: Array<{
key: string;
tasks: string[];
}>;
}
/**
* Optional scheduler diagnostics payload captured for the last run.
*/
export interface FrameRunTimings {
total: number;
stages: Record<
string,
{
duration: number;
tasks: Record<string, number>;
}
>;
}
/**
* Aggregated timing statistics for stage/task profiling.
*/
export interface FrameTimingStats {
last: number;
avg: number;
min: number;
max: number;
count: number;
}
/**
* Profiling snapshot aggregated from the configured history window.
*/
export interface FrameProfilingSnapshot {
window: number;
frameCount: number;
lastFrame: FrameRunTimings | null;
total: FrameTimingStats;
stages: Record<
string,
{
timings: FrameTimingStats;
tasks: Record<string, FrameTimingStats>;
}
>;
}
/**
* Internal registration payload including unsubscribe callback.
*/
interface RegisteredFrameTask extends UseFrameResult {
unsubscribe: () => void;
}
/**
* Internal mutable task descriptor used by scheduler runtime.
*/
interface InternalTask {
task: FrameTask;
/** Pre-computed string form of `task.key` — avoids Symbol.toString() on every profiling frame. */
keyString: string;
callback: FrameCallback;
order: number;
started: boolean;
lastRunning: boolean;
startedStoreSet: (value: boolean) => void;
startedStore: Subscribable<boolean>;
before: Set<FrameKey>;
after: Set<FrameKey>;
invalidation: {
mode: 'never' | 'always' | 'on-change';
token?: FrameTaskInvalidationToken;
lastToken: FrameInvalidationToken | null;
hasToken: boolean;
};
running?: () => boolean;
}
/**
* Internal mutable stage descriptor used by scheduler runtime.
*/
interface InternalStage {
key: FrameKey;
order: number;
started: boolean;
before: Set<FrameKey>;
after: Set<FrameKey>;
callback: FrameStageCallback;
tasks: Map<FrameKey, InternalTask>;
}
/**
* Default stage key used when task stage is not explicitly specified.
*/
const MAIN_STAGE_KEY = Symbol('motiongpu-main-stage');
const RENDER_MODE_INVALIDATION_TOKEN = Symbol('motiongpu-render-mode-change');
/**
* Default stage callback that runs tasks immediately.
*/
const DEFAULT_STAGE_CALLBACK: FrameStageCallback = (_state, runTasks) => runTasks();
/**
* Normalizes scalar-or-array options to array form.
*/
function asArray<T>(value: T | T[] | undefined): T[] {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
/**
* Normalizes frame keys to readable string labels.
*/
function frameKeyToString(key: FrameKey): string {
return typeof key === 'symbol' ? key.toString() : key;
}
/**
* Extracts task key from either direct key or task reference.
*/
function toTaskKey(reference: FrameKey | FrameTask): FrameKey {
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: FrameKey | FrameStage): FrameKey {
if (typeof reference === 'string' || typeof reference === 'symbol') {
return reference;
}
return reference.key;
}
/**
* Resolves invalidation token from static value or resolver callback.
*/
function resolveInvalidationToken(
token: FrameTaskInvalidationToken | undefined
): FrameInvalidationToken | null {
if (token === undefined) {
return null;
}
// Fast path: most tokens are static (string | symbol) — skip the typeof
// check and return directly. Function tokens are rare (dynamic token resolvers).
if (typeof token !== 'function') {
return token;
}
const resolved = token();
if (resolved === null || resolved === undefined) {
return null;
}
return resolved;
}
/**
* Normalizes task invalidation options to runtime representation.
*/
function normalizeTaskInvalidation(
key: FrameKey,
options: UseFrameOptions
): InternalTask['invalidation'] {
const explicit = options.invalidation;
if (explicit === undefined) {
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 === undefined) {
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: token as FrameTaskInvalidationToken,
lastToken: null,
hasToken: false
};
}
return {
mode,
token: token ?? key,
lastToken: null,
hasToken: false
};
}
/**
* Computes aggregate timing stats from sampled durations.
*/
function buildTimingStats(samples: number[], last: number): FrameTimingStats {
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
};
}
/**
* Dependency graph sorting options used for diagnostics labels.
*/
interface SortDependenciesOptions<T extends { key: FrameKey; order: number }> {
graphName: string;
getItemLabel: (item: T) => string;
isKnownExternalDependency?: (key: FrameKey) => boolean;
}
/**
* Deterministically sorts dependency keys for stable traversal and diagnostics.
*/
function sortDependencyKeys(keys: Iterable<FrameKey>): FrameKey[] {
return Array.from(keys).sort((a, b) => frameKeyToString(a).localeCompare(frameKeyToString(b)));
}
/**
* Finds one deterministic cycle path in the directed dependency graph.
*/
function findDependencyCycle<T extends { key: FrameKey; order: number }>(
items: T[],
edges: ReadonlyMap<FrameKey, ReadonlySet<FrameKey>>
): FrameKey[] | null {
const visitState = new Map<FrameKey, 0 | 1 | 2>();
const stack: FrameKey[] = [];
let cycle: FrameKey[] | null = null;
const sortedItems = [...items].sort((a, b) => a.order - b.order);
const visit = (key: FrameKey): boolean => {
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);
const cyclePath = cycleStartIndex === -1 ? [childKey] : stack.slice(cycleStartIndex);
cycle = [...cyclePath, 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<T extends { key: FrameKey; order: number }>(
items: T[],
getBefore: (item: T) => Iterable<FrameKey>,
getAfter: (item: T) => Iterable<FrameKey>,
options: SortDependenciesOptions<T>
): T[] {
const itemsByKey = new Map<FrameKey, T>();
for (const item of items) {
itemsByKey.set(item.key, item);
}
const indegree = new Map<FrameKey, number>();
const edges = new Map<FrameKey, Set<FrameKey>>();
for (const item of items) {
indegree.set(item.key, 0);
edges.set(item.key, 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: T[] = [];
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;
}
/**
* Runtime registry that stores frame tasks/stages and drives render scheduling.
*/
export interface FrameRegistry {
/**
* Registers a frame callback in the scheduler.
*/
register: (
keyOrCallback: FrameKey | FrameCallback,
callbackOrOptions?: FrameCallback | UseFrameOptions,
maybeOptions?: UseFrameOptions
) => RegisteredFrameTask;
/**
* Executes one scheduler run.
*/
run: (state: FrameState) => void;
/**
* Marks frame as invalidated for `on-demand` mode.
*/
invalidate: (token?: FrameInvalidationToken) => void;
/**
* Requests a single render in `manual` mode.
*/
advance: () => void;
/**
* Returns whether renderer should submit a frame now.
*/
shouldRender: () => boolean;
/**
* Resets one-frame invalidation/advance flags.
*/
endFrame: () => void;
/**
* Sets render scheduling mode.
*/
setRenderMode: (mode: RenderMode) => void;
/**
* Enables or disables automatic rendering entirely.
*/
setAutoRender: (enabled: boolean) => void;
/**
* Sets maximum allowed delta passed to frame tasks.
*/
setMaxDelta: (value: number) => void;
/**
* Enables/disables frame profiling.
*/
setProfilingEnabled: (enabled: boolean) => void;
/**
* Sets profiling history window (in frames).
*/
setProfilingWindow: (window: number) => void;
/**
* Clears collected profiling samples.
*/
resetProfiling: () => void;
/**
* Enables/disables diagnostics capture.
*/
setDiagnosticsEnabled: (enabled: boolean) => void;
/**
* Returns current render mode.
*/
getRenderMode: () => RenderMode;
/**
* Returns whether automatic rendering is enabled.
*/
getAutoRender: () => boolean;
/**
* Returns current max delta clamp.
*/
getMaxDelta: () => number;
/**
* Returns profiling toggle state.
*/
getProfilingEnabled: () => boolean;
/**
* Returns active profiling history window (in frames).
*/
getProfilingWindow: () => number;
/**
* Returns aggregated profiling snapshot.
*/
getProfilingSnapshot: () => FrameProfilingSnapshot | null;
/**
* Returns diagnostics toggle state.
*/
getDiagnosticsEnabled: () => boolean;
/**
* Returns last run timings snapshot when diagnostics are enabled.
*/
getLastRunTimings: () => FrameRunTimings | null;
/**
* Returns dependency-sorted schedule snapshot.
*/
getSchedule: () => FrameScheduleSnapshot;
/**
* Creates or updates a stage.
*/
createStage: (
key: FrameKey,
options?: {
before?: (FrameKey | FrameStage) | (FrameKey | FrameStage)[];
after?: (FrameKey | FrameStage) | (FrameKey | FrameStage)[];
callback?: FrameStageCallback | null;
}
) => FrameStage;
/**
* Reads stage metadata by key.
*/
getStage: (key: FrameKey) => FrameStage | undefined;
/**
* Removes all tasks from all stages.
*/
clear: () => void;
}
/**
* Creates a frame registry used by `FragCanvas` and `useFrame`.
*
* @param options - Initial scheduler options.
* @returns Mutable frame registry instance.
*/
export function createFrameRegistry(options?: {
renderMode?: RenderMode;
autoRender?: boolean;
maxDelta?: number;
profilingEnabled?: boolean;
profilingWindow?: number;
diagnosticsEnabled?: boolean;
}): FrameRegistry {
let renderMode: RenderMode = options?.renderMode ?? 'always';
let autoRender = options?.autoRender ?? true;
let maxDelta = options?.maxDelta ?? 0.1;
let profilingEnabled = options?.profilingEnabled ?? options?.diagnosticsEnabled ?? false;
let profilingWindow = options?.profilingWindow ?? 120;
let lastRunTimings: FrameRunTimings | null = null;
// Ring buffer for profiling history. Replaces the Array.shift()-based
// approach (O(n)) with an O(1) head-advance on every push past capacity.
let ringBuffer: FrameRunTimings[] = new Array(profilingWindow) as FrameRunTimings[];
let ringHead = 0; // Index of the oldest valid entry.
let ringCount = 0; // Number of valid entries (≤ profilingWindow).
let hasUntokenizedInvalidation = true;
const invalidationTokens = new Set<FrameInvalidationToken>();
let shouldAdvance = false;
let orderCounter = 0;
// Pre-allocated object for the clamped-delta frame state, mutated in-place
// each frame instead of allocating a new spread object when maxDelta fires.
// Initialised lazily on the first clamped frame — until then `null` signals
// that the original state should be passed through unchanged.
let clampedFrameState: FrameState | null = null;
const assertMaxDelta = (value: number): number => {
if (!Number.isFinite(value) || value <= 0) {
throw new Error('maxDelta must be a finite number greater than 0');
}
return value;
};
const assertProfilingWindow = (value: number): number => {
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 = new Map<FrameKey, InternalStage>();
let scheduleDirty = true;
let sortedStages: InternalStage[] = [];
const sortedTasksByStage = new Map<FrameKey, InternalTask[]>();
let scheduleSnapshot: FrameScheduleSnapshot = { stages: [] };
const markScheduleDirty = (): void => {
scheduleDirty = true;
};
const syncSchedule = (): void => {
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 = new Map<FrameKey, InternalTask[]>();
const globalTaskKeys = new Set<FrameKey>();
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: FrameRunTimings): void => {
if (ringCount < profilingWindow) {
// Buffer not yet full: write at the next free slot.
ringBuffer[(ringHead + ringCount) % profilingWindow] = timings;
ringCount += 1;
} else {
// Buffer full: overwrite the oldest slot and advance the head. O(1).
ringBuffer[ringHead] = timings;
ringHead = (ringHead + 1) % profilingWindow;
}
};
const clearProfiling = (): void => {
ringHead = 0;
ringCount = 0;
lastRunTimings = null;
};
const buildProfilingSnapshot = (): FrameProfilingSnapshot | null => {
if (!profilingEnabled) {
return null;
}
const stageBuckets = new Map<
string,
{
durations: number[];
taskDurations: Map<string, number[]>;
}
>();
const totalDurations: number[] = [];
for (let ri = 0; ri < ringCount; ri++) {
const frame = ringBuffer[(ringHead + ri) % profilingWindow] as FrameRunTimings;
totalDurations.push(frame.total);
for (const [stageKey, stageTiming] of Object.entries(frame.stages)) {
const stageBucket = stageBuckets.get(stageKey) ?? {
durations: [],
taskDurations: new Map<string, number[]>()
};
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: FrameProfilingSnapshot['stages'] = {};
for (const [stageKey, stageBucket] of stageBuckets) {
const lastStageDuration = lastRunTimings?.stages[stageKey]?.duration ?? 0;
const taskSnapshot: Record<string, FrameTimingStats> = {};
for (const [taskKey, taskDurations] of stageBucket.taskDurations) {
const lastTaskDuration = lastRunTimings?.stages[stageKey]?.tasks[taskKey] ?? 0;
taskSnapshot[taskKey] = buildTimingStats(taskDurations, lastTaskDuration);
}
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: FrameKey | FrameStage,
stageOptions?: {
before?: (FrameKey | FrameStage)[];
after?: (FrameKey | FrameStage)[];
callback?: FrameStageCallback | null;
}
): InternalStage => {
const stageKey = toStageKey(stageReference);
const existing = stages.get(stageKey);
if (existing) {
if (stageOptions?.before !== undefined) {
existing.before = new Set(stageOptions.before.map((entry) => toStageKey(entry)));
markScheduleDirty();
}
if (stageOptions?.after !== undefined) {
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: InternalStage = {
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: new Map()
};
stages.set(stageKey, stage);
markScheduleDirty();
return stage;
};
ensureStage(MAIN_STAGE_KEY);
const resolveEffectiveRunning = (task: InternalTask): boolean => {
const running = task.started && (task.running?.() ?? true);
if (task.lastRunning !== running) {
task.lastRunning = running;
task.startedStoreSet(running);
}
return running;
};
const hasPendingInvalidation = (): boolean => {
return hasUntokenizedInvalidation || invalidationTokens.size > 0;
};
const invalidateWithToken = (token?: FrameInvalidationToken): void => {
if (token === undefined) {
hasUntokenizedInvalidation = true;
return;
}
invalidationTokens.add(token);
};
const applyTaskInvalidation = (task: InternalTask): void => {
const config = task.invalidation;
if (config.mode === 'never') {
return;
}
if (config.mode === 'always') {
const token = resolveInvalidationToken(config.token);
invalidateWithToken(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') as FrameKey)
: (keyOrCallback as FrameKey);
const callback =
typeof keyOrCallback === 'function' ? keyOrCallback : (callbackOrOptions as FrameCallback);
const taskOptions =
typeof keyOrCallback === 'function'
? ((callbackOrOptions as UseFrameOptions | undefined) ?? {})
: (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
) as FrameTask | undefined;
const stageKey = taskOptions.stage
? toStageKey(taskOptions.stage)
: (inferredStage?.stage ?? MAIN_STAGE_KEY);
const stage = ensureStage(stageKey);
const startedWritable: CurrentWritable<boolean> = createCurrentWritable(
taskOptions.autoStart ?? true
);
const internalTask: 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: () => {
const current = stage.tasks.get(key);
if (current === internalTask && stage.tasks.delete(key)) {
markScheduleDirty();
}
}
};
},
run(state) {
const clampedDelta = Math.min(state.delta, maxDelta);
let frameState: FrameState;
if (clampedDelta === state.delta) {
frameState = state;
} else {
// Reuse the pre-allocated object — update only the fields that can
// change between frames (delta and fields derived from `state`).
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: FrameRunTimings['stages'] = {};
for (const stage of sortedStages) {
if (!stage.started) {
continue;
}
const stageStart = profilingEnabled ? performance.now() : 0;
const taskTimings: Record<string, number> = {};
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;
}
// Drain the ring into a flat ordered array (oldest → newest).
const keep = Math.min(ringCount, newWindow);
const startOffset = ringCount - keep;
const newBuffer: FrameRunTimings[] = new Array(newWindow) as FrameRunTimings[];
for (let i = 0; i < keep; i++) {
newBuffer[i] = ringBuffer[
(ringHead + startOffset + i) % profilingWindow
] as FrameRunTimings;
}
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) {
const stageOptions: Parameters<typeof ensureStage>[1] | undefined = 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 }
: {})
}
: undefined;
const stage = ensureStage(key, stageOptions);
return { key: stage.key };
},
getStage(key) {
const stage = stages.get(key);
if (!stage) {
return undefined;
}
return { key: stage.key };
},
clear() {
for (const stage of stages.values()) {
stage.tasks.clear();
}
markScheduleDirty();
}
};
}