@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
483 lines (434 loc) • 11.6 kB
text/typescript
import type { TextureUpdateMode } from './types.js';
/**
* Options controlling bitmap decode behavior.
*/
export interface TextureDecodeOptions {
/**
* Controls color-space conversion during decode.
*/
colorSpaceConversion?: 'default' | 'none';
/**
* Controls alpha premultiplication during decode.
*/
premultiplyAlpha?: 'default' | 'none' | 'premultiply';
/**
* Controls bitmap orientation during decode.
*/
imageOrientation?: 'none' | 'flipY';
}
/**
* Options controlling URL-based texture loading and decode behavior.
*/
export interface TextureLoadOptions {
/**
* Desired texture color space.
*/
colorSpace?: 'srgb' | 'linear';
/**
* Fetch options forwarded to `fetch`.
*/
requestInit?: RequestInit;
/**
* Decode options forwarded to `createImageBitmap`.
*/
decode?: TextureDecodeOptions;
/**
* Optional cancellation signal for this request.
*/
signal?: AbortSignal;
/**
* Optional runtime update strategy metadata attached to loaded textures.
*/
update?: TextureUpdateMode;
/**
* Optional runtime flip-y metadata attached to loaded textures.
*/
flipY?: boolean;
/**
* Optional runtime premultiplied-alpha metadata attached to loaded textures.
*/
premultipliedAlpha?: boolean;
/**
* Optional runtime mipmap metadata attached to loaded textures.
*/
generateMipmaps?: boolean;
}
/**
* Loaded texture payload returned by URL loaders.
*/
export interface LoadedTexture {
/**
* Source URL.
*/
url: string;
/**
* Decoded bitmap source.
*/
source: ImageBitmap;
/**
* Bitmap width in pixels.
*/
width: number;
/**
* Bitmap height in pixels.
*/
height: number;
/**
* Effective color space.
*/
colorSpace: 'srgb' | 'linear';
/**
* Effective runtime update strategy.
*/
update?: TextureUpdateMode;
/**
* Effective runtime flip-y metadata.
*/
flipY?: boolean;
/**
* Effective runtime premultiplied-alpha metadata.
*/
premultipliedAlpha?: boolean;
/**
* Effective runtime mipmap metadata.
*/
generateMipmaps?: boolean;
/**
* Releases bitmap resources.
*/
dispose: () => void;
}
interface NormalizedTextureLoadOptions {
colorSpace: 'srgb' | 'linear';
requestInit?: RequestInit;
decode: Required<TextureDecodeOptions>;
signal?: AbortSignal;
update?: TextureUpdateMode;
flipY?: boolean;
premultipliedAlpha?: boolean;
generateMipmaps?: boolean;
}
interface TextureResourceCacheEntry {
key: string;
refs: number;
controller: AbortController;
settled: boolean;
blobPromise: Promise<Blob>;
}
const resourceCache = new Map<string, TextureResourceCacheEntry>();
function createAbortError(): Error {
try {
return new DOMException('Texture request was aborted', 'AbortError');
} catch {
const error = new Error('Texture request was aborted');
(error as Error & { name: string }).name = 'AbortError';
return error;
}
}
/**
* Checks whether error represents abort cancellation.
*/
export function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))
);
}
function toBodyFingerprint(body: BodyInit | null | undefined): string | null {
if (body == null) {
return null;
}
if (typeof body === 'string') {
return `string:${body}`;
}
if (body instanceof URLSearchParams) {
return `urlsearchparams:${body.toString()}`;
}
if (typeof FormData !== 'undefined' && body instanceof FormData) {
const entries = Array.from(body.entries()).map(([key, value]) => `${key}:${String(value)}`);
return `formdata:${entries.join('&')}`;
}
if (body instanceof Blob) {
return `blob:${body.type}:${body.size}`;
}
if (body instanceof ArrayBuffer) {
return `arraybuffer:${body.byteLength}`;
}
if (ArrayBuffer.isView(body)) {
return `view:${body.byteLength}`;
}
return `opaque:${Object.prototype.toString.call(body)}`;
}
function normalizeRequestInit(requestInit: RequestInit | undefined): Record<string, unknown> {
if (!requestInit) {
return {};
}
const headers = new Headers(requestInit.headers);
const headerEntries = Array.from(headers.entries()).sort(([a], [b]) => a.localeCompare(b));
const normalized: Record<string, unknown> = {};
normalized.method = (requestInit.method ?? 'GET').toUpperCase();
normalized.mode = requestInit.mode ?? null;
normalized.cache = requestInit.cache ?? null;
normalized.credentials = requestInit.credentials ?? null;
normalized.redirect = requestInit.redirect ?? null;
normalized.referrer = requestInit.referrer ?? null;
normalized.referrerPolicy = requestInit.referrerPolicy ?? null;
normalized.integrity = requestInit.integrity ?? null;
normalized.keepalive = requestInit.keepalive ?? false;
normalized.priority = requestInit.priority ?? null;
normalized.headers = headerEntries;
normalized.body = toBodyFingerprint(requestInit.body);
return normalized;
}
function normalizeTextureLoadOptions(options: TextureLoadOptions): NormalizedTextureLoadOptions {
const colorSpace = options.colorSpace ?? 'srgb';
const normalized: NormalizedTextureLoadOptions = {
colorSpace,
decode: {
colorSpaceConversion:
options.decode?.colorSpaceConversion ?? (colorSpace === 'linear' ? 'none' : 'default'),
premultiplyAlpha: options.decode?.premultiplyAlpha ?? 'default',
imageOrientation: options.decode?.imageOrientation ?? 'none'
}
};
if (options.requestInit !== undefined) {
normalized.requestInit = options.requestInit;
}
if (options.signal !== undefined) {
normalized.signal = options.signal;
}
if (options.update !== undefined) {
normalized.update = options.update;
}
if (options.flipY !== undefined) {
normalized.flipY = options.flipY;
}
if (options.premultipliedAlpha !== undefined) {
normalized.premultipliedAlpha = options.premultipliedAlpha;
}
if (options.generateMipmaps !== undefined) {
normalized.generateMipmaps = options.generateMipmaps;
}
return normalized;
}
/**
* Builds deterministic resource cache key from full URL IO config.
*/
export function buildTextureResourceCacheKey(
url: string,
options: TextureLoadOptions = {}
): string {
const normalized = normalizeTextureLoadOptions(options);
return JSON.stringify({
url,
colorSpace: normalized.colorSpace,
requestInit: normalizeRequestInit(normalized.requestInit),
decode: normalized.decode
});
}
/**
* Clears the internal texture resource cache.
*/
export function clearTextureBlobCache(): void {
for (const entry of resourceCache.values()) {
if (!entry.settled) {
entry.controller.abort();
}
}
resourceCache.clear();
}
function acquireTextureBlob(
url: string,
options: TextureLoadOptions
): {
entry: TextureResourceCacheEntry;
release: () => void;
} {
const key = buildTextureResourceCacheKey(url, options);
const existing = resourceCache.get(key);
if (existing) {
existing.refs += 1;
let released = false;
return {
entry: existing,
release: () => {
if (released) {
return;
}
released = true;
existing.refs = Math.max(0, existing.refs - 1);
if (existing.refs === 0) {
if (!existing.settled) {
existing.controller.abort();
}
resourceCache.delete(key);
}
}
};
}
const normalized = normalizeTextureLoadOptions(options);
const controller = new AbortController();
const requestInit = {
...(normalized.requestInit ?? {}),
signal: controller.signal
} satisfies RequestInit;
const entry: TextureResourceCacheEntry = {
key,
refs: 1,
controller,
settled: false,
blobPromise: fetch(url, requestInit)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Texture request failed (${response.status}) for ${url}`);
}
return response.blob();
})
.then((blob) => {
entry.settled = true;
return blob;
})
.catch((error) => {
resourceCache.delete(key);
throw error;
})
};
resourceCache.set(key, entry);
let released = false;
return {
entry,
release: () => {
if (released) {
return;
}
released = true;
entry.refs = Math.max(0, entry.refs - 1);
if (entry.refs === 0) {
if (!entry.settled) {
entry.controller.abort();
}
resourceCache.delete(key);
}
}
};
}
async function awaitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw createAbortError();
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(createAbortError());
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
/**
* Loads a single texture from URL and converts it to an `ImageBitmap`.
*
* @param url - Texture URL.
* @param options - Loading options.
* @returns Loaded texture object.
* @throws {Error} When runtime does not support `createImageBitmap` or request fails.
*/
export async function loadTextureFromUrl(
url: string,
options: TextureLoadOptions = {}
): Promise<LoadedTexture> {
if (typeof createImageBitmap !== 'function') {
throw new Error('createImageBitmap is not available in this runtime');
}
const normalized = normalizeTextureLoadOptions(options);
const { entry, release } = acquireTextureBlob(url, options);
let bitmap: ImageBitmap | null = null;
try {
const blob = await awaitWithAbort(entry.blobPromise, normalized.signal);
const bitmapOptions: ImageBitmapOptions = {
colorSpaceConversion: normalized.decode.colorSpaceConversion,
premultiplyAlpha: normalized.decode.premultiplyAlpha,
imageOrientation: normalized.decode.imageOrientation
};
const allDefaults =
bitmapOptions.colorSpaceConversion === 'default' &&
bitmapOptions.premultiplyAlpha === 'default' &&
bitmapOptions.imageOrientation === 'none';
bitmap = allDefaults
? await createImageBitmap(blob)
: await createImageBitmap(blob, bitmapOptions);
if (normalized.signal?.aborted) {
bitmap.close();
throw createAbortError();
}
let disposed = false;
const loaded: LoadedTexture = {
url,
source: bitmap,
width: bitmap.width,
height: bitmap.height,
colorSpace: normalized.colorSpace,
dispose: () => {
if (disposed) {
return;
}
disposed = true;
bitmap?.close();
bitmap = null;
}
};
if (normalized.update !== undefined) {
loaded.update = normalized.update;
}
if (normalized.flipY !== undefined) {
loaded.flipY = normalized.flipY;
}
if (normalized.premultipliedAlpha !== undefined) {
loaded.premultipliedAlpha = normalized.premultipliedAlpha;
}
if (normalized.generateMipmaps !== undefined) {
loaded.generateMipmaps = normalized.generateMipmaps;
}
return loaded;
} catch (error) {
if (bitmap) {
bitmap.close();
}
throw error;
} finally {
release();
}
}
/**
* Loads many textures in parallel from URLs.
*
* @param urls - Texture URLs.
* @param options - Shared loading options.
* @returns Promise resolving to loaded textures in input order.
*/
export async function loadTexturesFromUrls(
urls: string[],
options: TextureLoadOptions = {}
): Promise<LoadedTexture[]> {
const settled = await Promise.allSettled(urls.map((url) => loadTextureFromUrl(url, options)));
const loaded: LoadedTexture[] = [];
let firstError: unknown = null;
for (const entry of settled) {
if (entry.status === 'fulfilled') {
loaded.push(entry.value);
continue;
}
firstError ??= entry.reason;
}
if (firstError) {
for (const texture of loaded) {
texture.dispose();
}
throw firstError;
}
return loaded;
}