@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
235 lines (209 loc) • 5.57 kB
text/typescript
import { onBeforeUnmount, onMounted } from 'vue';
import {
createCurrentWritable as currentWritable,
type CurrentReadable
} from '../core/current-value.js';
import {
isAbortError,
loadTexturesFromUrls,
type LoadedTexture,
type TextureLoadOptions
} from '../core/texture-loader.js';
import { toMotionGPUErrorReport, type MotionGPUErrorReport } from '../core/error-report.js';
/**
* Reactive state returned by {@link useTexture}.
*/
export interface UseTextureResult {
/**
* Loaded textures or `null` when unavailable/failed.
*/
textures: CurrentReadable<LoadedTexture[] | null>;
/**
* `true` while an active load request is running.
*/
loading: CurrentReadable<boolean>;
/**
* Last loading error.
*/
error: CurrentReadable<Error | null>;
/**
* Last loading error normalized to MotionGPU diagnostics report shape.
*/
errorReport: CurrentReadable<MotionGPUErrorReport | null>;
/**
* Reloads all textures using current URL input.
*/
reload: () => Promise<void>;
}
/**
* Supported URL input variants for `useTexture`.
*/
export type TextureUrlInput = string[] | (() => string[]);
/**
* Supported options input variants for `useTexture`.
*/
export type TextureOptionsInput = TextureLoadOptions | (() => TextureLoadOptions);
/**
* Normalizes unknown thrown values to an `Error` instance.
*/
function toError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
return new Error('Unknown texture loading error');
}
/**
* Releases GPU-side resources for a list of loaded textures.
*/
function disposeTextures(list: LoadedTexture[] | null): void {
for (const texture of list ?? []) {
texture.dispose();
}
}
interface MergedAbortSignal {
signal: AbortSignal;
dispose: () => void;
}
function mergeAbortSignals(
primary: AbortSignal,
secondary: AbortSignal | undefined
): MergedAbortSignal {
if (!secondary) {
return {
signal: primary,
dispose: () => {}
};
}
if (typeof AbortSignal.any === 'function') {
return {
signal: AbortSignal.any([primary, secondary]),
dispose: () => {}
};
}
const fallback = new AbortController();
let disposed = false;
const cleanup = (): void => {
if (disposed) {
return;
}
disposed = true;
primary.removeEventListener('abort', abort);
secondary.removeEventListener('abort', abort);
};
const abort = (): void => fallback.abort();
primary.addEventListener('abort', abort, { once: true });
secondary.addEventListener('abort', abort, { once: true });
return {
signal: fallback.signal,
dispose: cleanup
};
}
/**
* Loads textures from URLs and exposes reactive loading/error state.
*
* @param urlInput - URLs array or lazy URL provider.
* @param optionsInput - Loader options object or lazy options provider.
* @returns Reactive texture loading state with reload support.
*/
export function useTexture(
urlInput: TextureUrlInput,
optionsInput: TextureOptionsInput = {}
): UseTextureResult {
const textures = currentWritable<LoadedTexture[] | null>(null);
const loading = currentWritable(true);
const error = currentWritable<Error | null>(null);
const errorReport = currentWritable<MotionGPUErrorReport | null>(null);
let disposed = false;
let requestVersion = 0;
let activeController: AbortController | null = null;
let runningLoad: Promise<void> | null = null;
let reloadQueued = false;
const getUrls = typeof urlInput === 'function' ? urlInput : () => urlInput;
const getOptions =
typeof optionsInput === 'function'
? (optionsInput as () => TextureLoadOptions)
: () => optionsInput;
const executeLoad = async (): Promise<void> => {
if (disposed) {
return;
}
const version = ++requestVersion;
const controller = new AbortController();
activeController = controller;
loading.set(true);
error.set(null);
errorReport.set(null);
const previous = textures.current;
const options = getOptions() ?? {};
const mergedSignal = mergeAbortSignals(controller.signal, options.signal);
try {
const loaded = await loadTexturesFromUrls(getUrls(), {
...options,
signal: mergedSignal.signal
});
if (disposed || version !== requestVersion) {
disposeTextures(loaded);
return;
}
textures.set(loaded);
disposeTextures(previous);
} catch (nextError) {
if (disposed || version !== requestVersion) {
return;
}
if (isAbortError(nextError)) {
return;
}
disposeTextures(previous);
textures.set(null);
const normalizedError = toError(nextError);
error.set(normalizedError);
errorReport.set(toMotionGPUErrorReport(normalizedError, 'initialization'));
} finally {
if (!disposed && version === requestVersion) {
loading.set(false);
}
if (activeController === controller) {
activeController = null;
}
mergedSignal.dispose();
}
};
const runLoadLoop = async (): Promise<void> => {
do {
reloadQueued = false;
await executeLoad();
} while (reloadQueued && !disposed);
};
const load = (): Promise<void> => {
activeController?.abort();
if (runningLoad) {
reloadQueued = true;
return runningLoad;
}
const pending = runLoadLoop();
const trackedPending = pending.finally(() => {
if (runningLoad === trackedPending) {
runningLoad = null;
}
});
runningLoad = trackedPending;
return trackedPending;
};
onMounted(() => {
void load();
});
onBeforeUnmount(() => {
disposed = true;
requestVersion += 1;
activeController?.abort();
disposeTextures(textures.current);
});
return {
textures,
loading,
error,
errorReport,
reload: load
};
}