= {
/**
* Given a WebGL2RenderingContext and a WebGLTexture, fill the texture with the given parameters.
*
- * - if no src is provided, the data parameter will be used
- * - if a src is provided:
- * - if it is an image that is loaded, or if it is a video that can start playing, they will be used to fill the texture
- * - else, the placeholder parameter will be used, or a single black pixel will be used if no placeholder is specified
+ * - if a src is provided with a loaded media, it will be uploaded directly
+ * - if the src is an HTMLImageElement or HTMLVideoElement which is not loaded yet, a single black pixel is uploaded
+ * - if no src is provided, the data parameter will be used (with default to a single black pixel)
*
* @param gl - The WebGL2 context.
* @param texture - The WebGL texture to fill.
@@ -53,7 +54,7 @@ export function fillTexture(
magFilter = "linear",
wrapS = "clamp-to-edge",
wrapT = "clamp-to-edge",
- } = (isLoadingMedia ? params.placeholder : params) || {};
+ } = params;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY);
@@ -61,7 +62,7 @@ export function fillTexture(
if (isLoadedMedia) {
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, format, type, params.src);
} else {
- const dataTexture = ((isLoadingMedia ? params.placeholder : params) || {}) as DataTextureParams;
+ const dataTexture = (params || {}) as DataTextureParams;
const { data = new Uint8Array([0, 0, 0, 255]), width = 1, height = 1 } = dataTexture;
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, width, height, 0, format, type, data);
}
@@ -89,44 +90,25 @@ export function fillTexture(
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapMap[wrapT]);
}
-export function isHTMLImageTexture(
- params: unknown,
-): params is ImageTextureParams & { src: HTMLImageElement } {
- return (params as ImageTextureParams).src instanceof HTMLImageElement;
-}
-
-export function isHTMLVideoTexture(
- params: unknown,
-): params is ImageTextureParams & { src: HTMLVideoElement } {
- return (params as ImageTextureParams).src instanceof HTMLVideoElement;
-}
-
/**
- * Loads an image from a URL and returns {@link ImageTextureParams}.
+ * Loads an image from a URL and returns a Promise resolving to {@link ImageTextureParams}.
* @param src - URL of the image.
* @param params - Additional texture parameters.
*/
-export function loadTexture>(src: string, params?: P) {
- const img = document.createElement("img");
-
- if (src.startsWith("http") && new URL(src).origin !== globalThis.location.origin) {
- img.crossOrigin = "anonymous";
+export async function loadTexture
(
+ src: string,
+ params?: P,
+): Promise> {
+ const response = await fetch(src);
+ if (!response.ok) {
+ throw new Error(`Failed to load texture: ${src}`);
}
+ const blob = await response.blob();
+ const bitmap = await createImageBitmap(blob, {
+ imageOrientation: params?.flipY === false ? "none" : "flipY",
+ });
- const onload = () => {
- img.removeEventListener("error", onerror);
- };
- const onerror = () => {
- console.error(`Failed to load texture: ${src}`);
- img.removeEventListener("load", onload);
- };
-
- img.addEventListener("load", onload, { once: true });
- img.addEventListener("error", onerror, { once: true });
-
- img.src = src;
-
- return { ...(params as P), src: img };
+ return { ...params, src: bitmap };
}
/**
@@ -134,7 +116,14 @@ export function loadTexture>(src: stri
* @param src - URL of the video.
* @param params - Additional video loading options.
*/
-export function loadVideoTexture
(src: string, params?: P) {
+export function loadVideoTexture
(
+ src: string,
+ params?: P,
+): ImageTextureParams {
+ if (typeof document === "undefined") {
+ throw new TypeError("loadVideoTexture requires a document context.");
+ }
+
const video = document.createElement("video");
video.loop = true;
@@ -176,7 +165,7 @@ export function loadVideoTexture(src: string, params?
video.src = src;
video.load();
- return { ...(params as P), src: video };
+ return { ...params, src: video };
}
/**
@@ -305,15 +294,11 @@ export type DataTextureParams = BaseTextureParams & {
/**
* Parameters for creating a texture from an external source (Image, Video, etc.).
*/
-export type ImageTextureParams = BaseTextureParams & {
+export type ImageTextureParams = BaseTextureParams & {
/**
* A source of texture: image, video, canvas, etc.
*/
- src: TexImageSource;
- /**
- * Placeholder data to use while the source is loading.
- */
- placeholder?: DataTextureParams;
+ src: S;
};
/**
@@ -324,7 +309,7 @@ export type TextureParams = DataTextureParams | ImageTextureParams;
/**
* Parameters for loading a video texture.
*/
-export interface LoadVideoParams extends Omit {
+export interface LoadVideoParams extends BaseTextureParams {
/**
* Timecode in seconds from which to start the video.
* @default 0
diff --git a/packages/gl/src/global/glCanvas.ts b/packages/gl/src/global/glCanvas.ts
index 17059c9..976edc1 100644
--- a/packages/gl/src/global/glCanvas.ts
+++ b/packages/gl/src/global/glCanvas.ts
@@ -9,8 +9,13 @@ import type { QuadPassParams } from "../passes/quadRenderPass";
import { quadRenderPass } from "../passes/quadRenderPass";
import { compositor } from "../passes/compositor";
import { findUniformName } from "../internal/findName";
-import { isHTMLImageTexture, isHTMLVideoTexture } from "../core/texture";
import { createHook } from "../internal/createHook";
+import {
+ isHTMLImageTexture,
+ isHTMLVideoTexture,
+ isOffscreen,
+ isPromiseLike,
+} from "../internal/typeGuards";
/**
* The main high-level function to manage a WebGL canvas.
@@ -26,7 +31,7 @@ export const glCanvas = (params: GLCanvasParams): GLCanva
canvas: canvasProp,
fragment,
vertex,
- dpr = window.devicePixelRatio,
+ dpr = globalThis.devicePixelRatio || 1,
postEffects = [],
immediate,
renderMode = "auto",
@@ -43,7 +48,7 @@ export const glCanvas = (params: GLCanvasParams): GLCanva
const renderPass = quadRenderPass(gl, params);
const mainCompositor = compositor(gl, renderPass, postEffects);
- // don't render before the first resize of the canvas to avoid a glitch
+ // flag to not render before the first resize of the canvas to avoid a glitch
let isCanvasResized = false;
function render() {
@@ -69,28 +74,52 @@ export const glCanvas = (params: GLCanvasParams): GLCanva
});
}
- for (const pass of mainCompositor.allPasses) {
- pass.onUpdated(requestRender);
+ if (renderMode === "auto") {
+ for (const pass of mainCompositor.allPasses) {
+ if (!("uniforms" in pass)) continue;
- if (!("uniforms" in pass) || renderMode === "manual") continue;
+ pass.onUpdated((name, value) => {
+ requestRender();
+ watchUniformValue(name, value, pass);
+ });
- // Request a render when an image is loaded or a video frame is rendered
- for (const uniform of Object.values(pass.uniforms)) {
- if (isHTMLImageTexture(uniform) && !uniform.src.complete) {
- uniform.src.addEventListener("load", requestRender, { once: true });
- } else if (isHTMLVideoTexture(uniform)) {
- uniform.src.requestVideoFrameCallback(function onFramePlayed() {
- requestRender();
- uniform.src.requestVideoFrameCallback(onFramePlayed);
- });
+ for (const [name, value] of Object.entries(pass.uniforms)) {
+ watchUniformValue(name, value, pass);
}
}
}
+ const [onCanvasReady, executeCanvasReadyCallbacks] = createHook();
+
function setSize({ width, height }: { width: number; height: number }) {
setCanvasSize(width, height);
mainCompositor.setSize({ width, height });
requestRender();
+
+ if (!isCanvasResized) {
+ executeCanvasReadyCallbacks();
+ isCanvasResized = true;
+ }
+ }
+
+ /**
+ * Watch a uniform value for changes that would require re-rendering, such as promises resolving or media loading.
+ */
+ function watchUniformValue(
+ name: string,
+ value: unknown,
+ pass: { uniforms: Record },
+ ) {
+ if (isPromiseLike(value)) {
+ value.then((resolvedValue) => (pass.uniforms[name] = resolvedValue));
+ } else if (isHTMLImageTexture(value) && !value.src.complete) {
+ value.src.addEventListener("load", requestRender, { once: true });
+ } else if (isHTMLVideoTexture(value)) {
+ value.src.requestVideoFrameCallback(function onFramePlayed() {
+ requestRender();
+ value.src.requestVideoFrameCallback(onFramePlayed);
+ });
+ }
}
const timeUniformName = findUniformName(fragment + vertex, "time");
@@ -113,29 +142,14 @@ export const glCanvas = (params: GLCanvasParams): GLCanva
let resizeObserver: ReturnType | null = null;
- const [onCanvasReady, executeCanvasReadyCallbacks] = createHook();
-
- function resizeCanvas(width: number, height: number, dpr: number) {
- setSize({ width: width * dpr, height: height * dpr });
- if (!isCanvasResized) {
- executeCanvasReadyCallbacks();
- isCanvasResized = true;
- }
- }
-
- // resize only if HTMLCanvasElement, because we can't know the size of an OffscreenCanvas
- if (canvas instanceof HTMLCanvasElement) {
- if (canvas.getAttribute("width") && canvas.getAttribute("height")) {
- resizeCanvas(canvas.width, canvas.height, 1);
- }
- // don't automatically resize if the renderMode is manual, because the call to gl.viewport() will break the canvas
- else if (renderMode === "auto") {
- resizeObserver = onResize(canvas, ({ size }) => {
- resizeCanvas(size.width, size.height, dpr);
- });
- } else {
- resizeCanvas(canvas.clientWidth, canvas.clientHeight, dpr);
- }
+ if (isOffscreen(canvas) || (canvas.getAttribute("width") && canvas.getAttribute("height"))) {
+ setSize({ width: canvas.width, height: canvas.height });
+ } else if (renderMode === "manual") {
+ setSize({ width: canvas.clientWidth * dpr, height: canvas.clientHeight * dpr });
+ } else {
+ resizeObserver = onResize(canvas, ({ size }) => {
+ setSize({ width: size.width * dpr, height: size.height * dpr });
+ });
}
return {
@@ -190,12 +204,12 @@ export interface GLCanvasParams extends LoopParams, QuadPass
/**
* The object returned by the {@link glCanvas} function.
*/
-export interface GLCanvas {
+export interface GLCanvas> {
/** The WebGL2 rendering context. */
gl: WebGL2RenderingContext;
/** Executes a single render of the entire pipeline. */
render: () => void;
- /** Register a callback to execute after the initial resizing of the canvas. */
+ /** Register a callback to execute after the first resizing of the canvas. */
onCanvasReady: (callback: () => void) => void;
/** The HTMLCanvasElement or OffscreenCanvas being used. */
canvas: HTMLCanvasElement | OffscreenCanvas;
@@ -208,7 +222,7 @@ export interface GLCanvas {
/** The Device Pixel Ratio being used. */
dpr: number;
/** Reactive proxy of the main render pass's uniforms. */
- uniforms: U;
+ uniforms: U & Record;
/** Registers a callback called whenever a uniform of the main render pass is updated. */
onUpdated: (callback: UpdatedCallback) => void;
/** Registers a callback called just before the main render pass is rendered. */
diff --git a/packages/gl/src/global/glContext.ts b/packages/gl/src/global/glContext.ts
index 91c160f..afac1bf 100644
--- a/packages/gl/src/global/glContext.ts
+++ b/packages/gl/src/global/glContext.ts
@@ -10,8 +10,15 @@ export function glContext(canvas) : canvas;
+ let canvasElement: HTMLCanvasElement | OffscreenCanvas | null = null;
+
+ if (typeof canvas === "string") {
+ if (typeof document !== "undefined") {
+ canvasElement = document.querySelector(canvas);
+ }
+ } else {
+ canvasElement = canvas;
+ }
if (canvasElement == null) {
throw new Error("Canvas element not found.");
diff --git a/packages/gl/src/internal/setupUniforms.ts b/packages/gl/src/internal/setupUniforms.ts
index fc0a9fd..7b63ed9 100644
--- a/packages/gl/src/internal/setupUniforms.ts
+++ b/packages/gl/src/internal/setupUniforms.ts
@@ -1,7 +1,7 @@
import type { DataTextureParams, ImageTextureParams } from "../core/texture";
import { fillTexture } from "../core/texture";
import type { UpdatedCallback } from "../passes/renderPass";
-import type { Uniforms, UniformValue } from "../types";
+import type { Uniforms } from "../types";
import { createHook } from "./createHook";
export function setupUniforms(uniforms: U) {
@@ -31,12 +31,11 @@ export function setupUniforms(uniforms: U) {
const uniformsProxy = new Proxy(
{ ...uniforms },
{
- set(target, uniform: string, value) {
- if (value !== target[uniform]) {
- const oldTarget = getSnapshot(target);
- target[uniform as keyof U] = value;
- const newTarget = getSnapshot(target);
- executeUpdateCallbacks(newTarget, oldTarget);
+ set(target, name: UniformName, value) {
+ if (value !== target[name]) {
+ const oldValue = target[name];
+ target[name] = value;
+ executeUpdateCallbacks(name, value, oldValue, getSnapshot(target));
}
return true;
},
@@ -52,7 +51,7 @@ export function setupUniforms(uniforms: U) {
}
}
- function setUniform(name: Uname, value: U[Uname] & UniformValue) {
+ function setUniform(name: Uname, value: U[Uname]) {
const uniformLocation = uniformsLocations.get(name) || -1;
if (uniformLocation === -1) return -1;
diff --git a/packages/gl/src/internal/typeGuards.ts b/packages/gl/src/internal/typeGuards.ts
new file mode 100644
index 0000000..0fa0d5c
--- /dev/null
+++ b/packages/gl/src/internal/typeGuards.ts
@@ -0,0 +1,31 @@
+import type { ImageTextureParams } from "../core/texture";
+
+export function isHTMLImageTexture(params: any): params is ImageTextureParams {
+ return (
+ typeof HTMLImageElement !== "undefined" &&
+ isObjectWithProperty(params, "src") &&
+ params.src instanceof HTMLImageElement
+ );
+}
+
+export function isHTMLVideoTexture(params: any): params is ImageTextureParams {
+ return (
+ typeof HTMLVideoElement !== "undefined" &&
+ isObjectWithProperty(params, "src") &&
+ params.src instanceof HTMLVideoElement
+ );
+}
+
+export function isOffscreen(
+ canvas: HTMLCanvasElement | OffscreenCanvas,
+): canvas is OffscreenCanvas {
+ return typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas;
+}
+
+export function isPromiseLike(value: any): value is PromiseLike & object {
+ return isObjectWithProperty(value, "then") && typeof value.then === "function";
+}
+
+export function isObjectWithProperty(obj: unknown, key: string) {
+ return obj != null && typeof obj === "object" && key in obj;
+}
diff --git a/packages/gl/src/passes/compositeEffectPass.ts b/packages/gl/src/passes/compositeEffectPass.ts
index 8788277..fad9989 100644
--- a/packages/gl/src/passes/compositeEffectPass.ts
+++ b/packages/gl/src/passes/compositeEffectPass.ts
@@ -33,8 +33,8 @@ export function compositeEffectPass>(
function initialize(gl: WebGL2RenderingContext) {
for (const pass of passes) {
pass.initialize(gl);
- pass.onUpdated((newUniforms, oldUniforms) => {
- executeUpdateCallbacks(newUniforms, oldUniforms);
+ pass.onUpdated((...args) => {
+ executeUpdateCallbacks(...args);
});
}
executeInitCallbacks(gl);
diff --git a/packages/gl/src/passes/renderPass.ts b/packages/gl/src/passes/renderPass.ts
index 5d91d5b..e7f2337 100644
--- a/packages/gl/src/passes/renderPass.ts
+++ b/packages/gl/src/passes/renderPass.ts
@@ -273,9 +273,15 @@ export type RenderCallback> = (
) => void;
/**
- * Callback function executed when uniforms change.
+ * Callback function executed when a uniform changes.
*/
export type UpdatedCallback> = (
+ /** The name of the uniform that changed. */
+ name: string,
+ /** The new value of the uniform. */
+ value: unknown,
+ /** The previous value of the uniform. */
+ oldValue: unknown,
+ /** A snapshot of all uniforms after the change. */
uniforms: Readonly,
- oldUniforms: Readonly,
) => void;
diff --git a/packages/gl/src/types.ts b/packages/gl/src/types.ts
index 324ce73..dbfd058 100644
--- a/packages/gl/src/types.ts
+++ b/packages/gl/src/types.ts
@@ -35,7 +35,7 @@ export type UniformValue = number | VectorUniform | MatrixUniform | Float32Array
/**
* A collection of uniform variables.
*/
-export type Uniforms = Record;
+export type Uniforms = Record>;
/**
* A TypedArray (e.g., Float32Array, Uint16Array) used for buffer data.
diff --git a/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-android.png b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-android.png
new file mode 100644
index 0000000..aca8297
Binary files /dev/null and b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-android.png differ
diff --git a/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-chromium.png b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-chromium.png
new file mode 100644
index 0000000..7fb3376
Binary files /dev/null and b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-chromium.png differ
diff --git a/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-firefox.png b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-firefox.png
new file mode 100644
index 0000000..042ced5
Binary files /dev/null and b/packages/gl/tests/__screenshots__/offscreencanvas/offscreencanvas-firefox.png differ
diff --git a/packages/gl/tests/screenshots.spec.ts b/packages/gl/tests/screenshots.spec.ts
index 4b9e055..7d7bc17 100644
--- a/packages/gl/tests/screenshots.spec.ts
+++ b/packages/gl/tests/screenshots.spec.ts
@@ -13,6 +13,7 @@ const expectedRendersByDemo = {
"boids (static)": "3",
mipmap: /[1-3]/,
texture: /1|2/,
+ offscreencanvas: "2",
sepia: /1|2/,
alpha: "2",
blending: "2",