diff --git a/packages/gl/Dockerfile b/packages/gl/Dockerfile index c800a75..9ba47a1 100644 --- a/packages/gl/Dockerfile +++ b/packages/gl/Dockerfile @@ -1,18 +1,17 @@ FROM mcr.microsoft.com/playwright:v1.49.1 -RUN npm install -g corepack@latest -RUN corepack enable +RUN npm install -g pnpm@10 WORKDIR /app -COPY pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --from=workspace pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages/gl/package.json ./packages/gl/ -COPY packages/helpers/package.json ./packages/helpers/ +COPY package.json ./packages/gl/ +COPY --from=helpers package.json ./packages/helpers/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install -COPY packages/gl ./packages/gl -COPY packages/helpers ./packages/helpers +COPY . ./packages/gl +COPY --from=helpers . ./packages/helpers WORKDIR /app/packages/gl diff --git a/packages/gl/compose.yml b/packages/gl/compose.yml index 2baef45..b2ed7e8 100644 --- a/packages/gl/compose.yml +++ b/packages/gl/compose.yml @@ -1,8 +1,11 @@ services: test: build: - context: ../.. - dockerfile: packages/gl/Dockerfile + context: . + dockerfile: Dockerfile + additional_contexts: + workspace: ../.. + helpers: ../helpers ports: - "9323:9323" volumes: diff --git a/packages/gl/playground/src/pages/core/offscreencanvas.astro b/packages/gl/playground/src/pages/core/offscreencanvas.astro new file mode 100644 index 0000000..793bd0f --- /dev/null +++ b/packages/gl/playground/src/pages/core/offscreencanvas.astro @@ -0,0 +1,33 @@ +--- +import Layout from "../../layouts/Layout.astro"; +--- + + + + + + + + diff --git a/packages/gl/playground/src/pages/textures/texture.astro b/packages/gl/playground/src/pages/textures/texture.astro index 33bc656..91aca61 100644 --- a/packages/gl/playground/src/pages/textures/texture.astro +++ b/packages/gl/playground/src/pages/textures/texture.astro @@ -5,26 +5,9 @@ import Layout from "../../layouts/Layout.astro"; diff --git a/packages/gl/playground/src/workers/offscreenGradient.worker.ts b/packages/gl/playground/src/workers/offscreenGradient.worker.ts new file mode 100644 index 0000000..e3295ad --- /dev/null +++ b/packages/gl/playground/src/workers/offscreenGradient.worker.ts @@ -0,0 +1,77 @@ +import { glCanvas, loadTexture, type GLCanvas } from "@radiance/gl"; + +type InitMessage = { + type: "init"; + canvas: OffscreenCanvas; +}; + +type ResizeMessage = { + type: "resize"; + size: { + width: number; + height: number; + }; +}; + +type WorkerMessage = InitMessage | ResizeMessage; + +addEventListener("message", (event: MessageEvent) => { + switch (event.data.type) { + case "init": { + init(event.data); + break; + } + case "resize": { + resize(event.data); + break; + } + } +}); + +let scene: GLCanvas; + +function init(message: InitMessage) { + scene = glCanvas({ + canvas: message.canvas, + fragment: /* glsl */ ` + in vec2 vUv; + uniform sampler2D uTexture; + uniform vec2 uResolution; + out vec4 fragColor; + + #define CONTAIN 1 + #define COVER 2 + #define OBJECT_FIT CONTAIN + + void main() { + vec2 textureResolution = vec2(textureSize(uTexture, 0)); + float canvasRatio = uResolution.x / uResolution.y; + float textureRatio = textureResolution.x / textureResolution.y; + + vec2 uv = vUv - 0.5; + if (OBJECT_FIT == CONTAIN ? canvasRatio > textureRatio : canvasRatio < textureRatio) { + uv.x *= canvasRatio / textureRatio; + } else { + uv.y *= textureRatio / canvasRatio; + } + uv += 0.5; + + vec3 color = texture(uTexture, uv).rgb; + color *= step(0., uv.x) * (1. - step(1., uv.x)); + color *= step(0., uv.y) * (1. - step(1., uv.y)); + + fragColor = vec4(color, 1.); + } + `, + }); + + scene.onAfterRender(() => { + postMessage({ type: "rendered" }); + }); + + scene.uniforms.uTexture = loadTexture("https://picsum.photos/id/669/600/400"); +} + +function resize(message: ResizeMessage) { + scene?.setSize(message.size); +} diff --git a/packages/gl/playwright.config.ts b/packages/gl/playwright.config.ts index 8557b61..39a5b79 100644 --- a/packages/gl/playwright.config.ts +++ b/packages/gl/playwright.config.ts @@ -52,7 +52,9 @@ export default defineConfig({ { name: "safari", use: { ...devices["Desktop Safari"], viewport: desktopViewport }, - grepInvert: /video/, // the video does not work in the docker image used for the CI, maybe due to a codec issue + // - the video does not work in the docker image used for the CI, maybe due to a codec issue + // - issues with offscreen canvas and WebGL in Safari < 17 + grepInvert: /video|offscreencanvas/, }, { name: "android", @@ -67,7 +69,9 @@ export default defineConfig({ { name: "iphone", use: { ...devices["iPhone 12"], viewport: mobileViewport }, - grepInvert: /video/, // the video does not work in the docker image used for the CI, maybe due to a codec issue + // - the video does not work in the docker image used for the CI, maybe due to a codec issue + // - issues with offscreen canvas and WebGL in Safari < 17 + grepInvert: /video|offscreencanvas/, }, ], webServer: { diff --git a/packages/gl/src/core/texture.ts b/packages/gl/src/core/texture.ts index 9096316..680699c 100644 --- a/packages/gl/src/core/texture.ts +++ b/packages/gl/src/core/texture.ts @@ -1,3 +1,5 @@ +import { isHTMLImageTexture, isHTMLVideoTexture } from "../internal/typeGuards"; + const minFilterMap: Record = { linear: WebGL2RenderingContext.LINEAR, nearest: WebGL2RenderingContext.NEAREST, @@ -19,10 +21,9 @@ const wrapMap: Record = { /** * 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",