UNPKG

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