diff --git a/.changeset/six-birds-own.md b/.changeset/six-birds-own.md
deleted file mode 100644
index a845151c..00000000
--- a/.changeset/six-birds-own.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index eaa89c62..8f282293 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -21,7 +21,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Build gl-react packages
run: |
- for d in packages/gl-react packages/gl-react-dom packages/gl-react-headless; do
+ for d in packages/gl-react packages/gl-react-dom packages/gl-react-headless packages/gl-react-image; do
pnpm exec babel --root-mode upward --source-maps --extensions '.ts,.tsx' -d "$d/lib" "$d/src"
done
- run: xvfb-run -s "-ac -screen 0 1280x1024x24" pnpm test
diff --git a/CLAUDE.md b/CLAUDE.md
index 60948672..3db15f2c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,6 +35,7 @@ Releases are managed by [changesets](https://github.com/changesets/changesets).
- **`packages/gl-react-native/`** — React Native standalone implementation.
- **`packages/gl-react-expo/`** — React Native via Expo GLView.
- **`packages/gl-react-headless/`** — Node.js implementation using headless-gl.
+- **`packages/gl-react-image/`** — `GLImage` component implementing `resizeMode` (cover/contain/free/stretch) in OpenGL.
- **`packages/tests/`** — Shared Jest test suite using `gl-react-headless` + `react-test-renderer`.
- **`packages/cookbook/`** — Modern examples (Vite + TypeScript + Tailwind).
diff --git a/packages/cookbook/package.json b/packages/cookbook/package.json
index 28c5f987..999e6a39 100644
--- a/packages/cookbook/package.json
+++ b/packages/cookbook/package.json
@@ -17,6 +17,7 @@
"@heroicons/react": "^2.2.0",
"gl-react": "workspace:^",
"gl-react-dom": "workspace:^",
+ "gl-react-image": "workspace:^",
"gl-shader": "^4.2.1",
"gl-texture2d": "^2.1.0",
"gl-transitions": "^1.43.0",
diff --git a/packages/cookbook/src/examples/imageeffects.tsx b/packages/cookbook/src/examples/imageeffects.tsx
new file mode 100644
index 00000000..f5ed3f84
--- /dev/null
+++ b/packages/cookbook/src/examples/imageeffects.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { Surface } from "gl-react-dom";
+import GLImage from "gl-react-image";
+import { Saturate } from "./saturation";
+
+// GLImage renders a regular gl-react Node, so it composes like any other:
+// here the cover-cropped image is the texture input of the Saturate effect.
+export default function ImageEffects({
+ saturation = 1,
+ zoom = 0.6,
+ centerX = 0.5,
+ centerY = 0.5,
+}: {
+ saturation?: number;
+ zoom?: number;
+ centerX?: number;
+ centerY?: number;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/cookbook/src/examples/imageresizemodes.tsx b/packages/cookbook/src/examples/imageresizemodes.tsx
new file mode 100644
index 00000000..4393681b
--- /dev/null
+++ b/packages/cookbook/src/examples/imageresizemodes.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { Surface } from "gl-react-dom";
+import GLImage from "gl-react-image";
+
+// The surface is wider than the image ratio, so each resizeMode
+// behaves differently: cover crops, contain letterboxes, stretch distorts.
+export default function ImageResizeModes({
+ resizeMode = "cover",
+ zoom = 1,
+ centerX = 0.5,
+ centerY = 0.5,
+}: {
+ resizeMode?: "cover" | "free" | "contain" | "stretch";
+ zoom?: number;
+ centerX?: number;
+ centerY?: number;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/cookbook/src/examples/index.ts b/packages/cookbook/src/examples/index.ts
index 700de5cf..9a64089b 100644
--- a/packages/cookbook/src/examples/index.ts
+++ b/packages/cookbook/src/examples/index.ts
@@ -139,6 +139,42 @@ export const examples: ExampleEntry[] = [
category: "Composition",
Component: lazy(() => import("./diamondanim")),
},
+ {
+ id: "imageresizemodes",
+ title: "Image Resize Modes",
+ description: "gl-react-image: cover, free, contain and stretch resizeMode implemented in OpenGL",
+ category: "Composition",
+ Component: lazy(() => import("./imageresizemodes")),
+ controls: {
+ resizeMode: {
+ type: "select",
+ label: "Resize Mode",
+ options: [
+ { key: "cover", label: "cover" },
+ { key: "free", label: "free" },
+ { key: "contain", label: "contain" },
+ { key: "stretch", label: "stretch" },
+ ],
+ default: "cover",
+ },
+ zoom: { type: "float", label: "Zoom", min: 0.05, max: 1, step: 0.01, default: 1 },
+ centerX: { type: "float", label: "Center X", min: 0, max: 1, step: 0.01, default: 0.5 },
+ centerY: { type: "float", label: "Center Y", min: 0, max: 1, step: 0.01, default: 0.5 },
+ },
+ },
+ {
+ id: "imageeffects",
+ title: "Image + Effects",
+ description: "GLImage cover-crop composed with a color effect: a Node like any other",
+ category: "Composition",
+ Component: lazy(() => import("./imageeffects")),
+ controls: {
+ saturation: { type: "float", label: "Saturation", min: 0, max: 2, step: 0.01, default: 1 },
+ zoom: { type: "float", label: "Zoom", min: 0.05, max: 1, step: 0.01, default: 0.6 },
+ centerX: { type: "float", label: "Center X", min: 0, max: 1, step: 0.01, default: 0.5 },
+ centerY: { type: "float", label: "Center Y", min: 0, max: 1, step: 0.01, default: 0.5 },
+ },
+ },
// === Blur ===
{
diff --git a/packages/cookbook/vite.config.ts b/packages/cookbook/vite.config.ts
index f79f2798..55e441c2 100644
--- a/packages/cookbook/vite.config.ts
+++ b/packages/cookbook/vite.config.ts
@@ -29,6 +29,7 @@ export default defineConfig({
'@': resolve(__dirname, './src'),
'gl-react': resolve(__dirname, '../gl-react/src'),
'gl-react-dom': resolve(__dirname, '../gl-react-dom/src'),
+ 'gl-react-image': resolve(__dirname, '../gl-react-image/src'),
buffer: resolve(__dirname, './src/shims/buffer.ts'),
},
},
diff --git a/packages/gl-react-image/LICENSE b/packages/gl-react-image/LICENSE
new file mode 100755
index 00000000..6cdbf257
--- /dev/null
+++ b/packages/gl-react-image/LICENSE
@@ -0,0 +1,19 @@
+The MIT License (MIT)
+Copyright (c) 2016 - 2017
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/gl-react-image/README.md b/packages/gl-react-image/README.md
new file mode 100644
index 00000000..aecb4a72
--- /dev/null
+++ b/packages/gl-react-image/README.md
@@ -0,0 +1,27 @@
+# gl-react-image
+
+Universal [gl-react](https://github.com/gre/gl-react) image component that implements various `resizeMode` in OpenGL: **cover**, **contain**, **free** and **stretch**.
+
+```jsx
+import { Surface } from "gl-react-dom"; // or gl-react-expo / gl-react-native / gl-react-headless
+import GLImage from "gl-react-image";
+
+
+
+;
+```
+
+## Props
+
+- **`source`** *(required)*: any gl-react texture input (image URL, DOM element, child Node, Bus ref, ...).
+- **`resizeMode`**: one of:
+ - `"cover"` *(default)*: fills the area, cropping the image. `center` and `zoom` move the crop window, clamped so the image always covers the area.
+ - `"free"`: like `"cover"` but without edge clamping; areas outside the image are transparent.
+ - `"contain"`: letterboxes the image so it fits entirely in the area.
+ - `"stretch"`: distorts the image to the area size.
+- **`center`**: `[x, y]` crop window center in image coordinates (default `[0.5, 0.5]`). Only for `"cover"` and `"free"`.
+- **`zoom`**: crop window zoom level (default `1`). Only for `"cover"` and `"free"`.
+
+Any other prop is passed through to the underlying gl-react `Node`.
+
+Because GLImage renders a Node, it can be composed anywhere in a gl-react shader tree: use it as the texture input of an effect, or give it effects as `source`.
diff --git a/packages/gl-react-image/package.json b/packages/gl-react-image/package.json
new file mode 100644
index 00000000..7726c7c6
--- /dev/null
+++ b/packages/gl-react-image/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "gl-react-image",
+ "version": "6.0.0",
+ "license": "MIT",
+ "author": "Gaëtan Renaudeau ",
+ "description": "Universal gl-react image that implements various resizeMode in OpenGL (cover, contain, free, stretch)",
+ "keywords": [
+ "gl-react",
+ "image",
+ "resizeMode",
+ "crop",
+ "gl",
+ "opengl",
+ "react",
+ "react-component"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gre/gl-react",
+ "directory": "packages/gl-react-image"
+ },
+ "main": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "files": [
+ "src",
+ "lib",
+ "README.md",
+ "LICENSE"
+ ],
+ "peerDependencies": {
+ "gl-react": "*",
+ "react": ">=18"
+ },
+ "devDependencies": {
+ "gl-react": "workspace:^"
+ }
+}
diff --git a/packages/gl-react-image/src/index.tsx b/packages/gl-react-image/src/index.tsx
new file mode 100644
index 00000000..55370036
--- /dev/null
+++ b/packages/gl-react-image/src/index.tsx
@@ -0,0 +1,197 @@
+import React from "react";
+import { Shaders, Node, GLSL, LinearCopy, Uniform } from "gl-react";
+
+const shaders = Shaders.create({
+ contain: {
+ vert: GLSL`
+attribute vec2 _p;
+varying vec2 uv;
+uniform float tR;
+uniform vec2 res;
+float r;
+void main() {
+ r = res.x / res.y;
+ gl_Position = vec4(_p,0.0,1.0);
+ uv = .5+.5*_p*vec2(max(r/tR,1.),max(tR/r,1.));
+}
+ `,
+ frag: GLSL`
+precision highp float;
+varying vec2 uv;
+uniform sampler2D t;
+void main () {
+ gl_FragColor =
+ step(0.0, uv.x) *
+ step(0.0, uv.y) *
+ step(uv.x, 1.0) *
+ step(uv.y, 1.0) *
+ texture2D(t, uv);
+}
+ `,
+ },
+ free: {
+ vert: GLSL`
+attribute vec2 _p;
+varying vec2 uv;
+uniform float zoom;
+uniform vec2 center;
+uniform float tR;
+uniform vec2 res;
+float r;
+vec2 invert (vec2 p) {
+ return vec2(p.x, 1.0-p.y);
+}
+void main() {
+ r = res.x / res.y;
+ gl_Position = vec4(_p,0.0,1.0);
+ // crop with zoom & center in a cover mode. preserving image ratio
+ float maxR = max(r, tR);
+ vec2 zoomedCanvasSize = vec2(
+ (r / maxR) * zoom,
+ (tR / maxR) * zoom
+ );
+ vec4 crop = vec4(
+ center.x - zoomedCanvasSize.x / 2.,
+ center.y - zoomedCanvasSize.y / 2.,
+ zoomedCanvasSize.x,
+ zoomedCanvasSize.y
+ );
+ // apply the crop rectangle
+ uv = invert(invert(.5+.5*_p) * crop.zw + crop.xy);
+}
+ `,
+ frag: GLSL`
+precision highp float;
+varying vec2 uv;
+uniform sampler2D t;
+void main () {
+ gl_FragColor =
+ step(0.0, uv.x) *
+ step(0.0, uv.y) *
+ step(uv.x, 1.0) *
+ step(uv.y, 1.0) *
+ texture2D(t, uv);
+}
+ `,
+ },
+ cover: {
+ // NB the cover vertex code can probably be simplified. good enough for now.
+ vert: GLSL`
+attribute vec2 _p;
+varying vec2 uv;
+uniform float zoom;
+uniform vec2 center;
+uniform float tR;
+uniform vec2 res;
+float r;
+
+vec2 invert (vec2 p) {
+ return vec2(p.x, 1.0-p.y);
+}
+void main() {
+ r = res.x / res.y;
+ gl_Position = vec4(_p,0.0,1.0);
+ // crop with zoom & center in a cover mode. preserving image ratio
+ float maxR = max(r, tR);
+ vec2 zoomedCanvasSize = vec2(
+ (r / maxR) * zoom,
+ (tR / maxR) * zoom
+ );
+ vec4 crop = vec4(
+ center.x - zoomedCanvasSize.x / 2.,
+ center.y - zoomedCanvasSize.y / 2.,
+ zoomedCanvasSize.x,
+ zoomedCanvasSize.y
+ );
+ // clamp to not escape the edges
+ float w = crop[2], h = crop[3];
+ float ratio = w / h;
+ if (w > 1.) {
+ w = 1.;
+ h = w / ratio;
+ }
+ if (h > 1.) {
+ h = 1.;
+ w = h * ratio;
+ }
+ crop = vec4(
+ max(0., min(crop.x, 1.-w)),
+ max(0., min(crop.y, 1.-h)),
+ w,
+ h
+ );
+ // apply the crop rectangle
+ uv = invert(invert(.5+.5*_p) * crop.zw + crop.xy);
+}
+ `,
+ frag: GLSL`
+precision highp float;
+varying vec2 uv;
+uniform sampler2D t;
+void main () {
+ gl_FragColor = texture2D(t, uv);
+}
+ `,
+ },
+});
+
+export type GLImageResizeMode = "cover" | "free" | "contain" | "stretch";
+
+export type GLImageProps = {
+ /** any gl-react texture input (URL, image element, Node, Bus ref, ...) */
+ source: any;
+ /**
+ * - "cover": fills the area, cropping the image (default). `center`/`zoom`
+ * move the crop window, clamped so the image always covers the area.
+ * - "free": like "cover" but without clamping; out-of-image areas are
+ * transparent.
+ * - "contain": letterboxes the image to fit entirely in the area.
+ * - "stretch": distorts the image to the area size.
+ */
+ resizeMode?: GLImageResizeMode;
+ /** crop window center in image coordinates, only for "cover" and "free" */
+ center?: [number, number];
+ /** crop window zoom level, only for "cover" and "free" */
+ zoom?: number;
+} & Record;
+
+export default function GLImage({
+ source,
+ resizeMode = "cover",
+ center,
+ zoom,
+ ...rest
+}: GLImageProps) {
+ if (resizeMode === "cover" || resizeMode === "free") {
+ return (
+
+ );
+ }
+
+ if (resizeMode === "contain") {
+ return (
+
+ );
+ }
+
+ // fallback on stretch, most basic thing
+ return {source};
+}
diff --git a/packages/gl-react-image/tsconfig.json b/packages/gl-react-image/tsconfig.json
new file mode 100644
index 00000000..6593d576
--- /dev/null
+++ b/packages/gl-react-image/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": false,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "declarationDir": "lib",
+ "outDir": "lib",
+ "rootDir": "src",
+ "composite": true
+ },
+ "references": [{ "path": "../gl-react" }],
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["lib", "node_modules"]
+}
diff --git a/packages/tests/__tests__/all.js b/packages/tests/__tests__/all.js
index 4eea47dd..9c613280 100755
--- a/packages/tests/__tests__/all.js
+++ b/packages/tests/__tests__/all.js
@@ -2512,3 +2512,68 @@ test("VisitorLogger + bunch of funky extreme tests", () => {
// eslint-disable-next-line no-global-assign
console = oldConsole;
});
+
+test("gl-react-image GLImage resizeMode", async () => {
+ const GLImage = require("gl-react-image").default;
+ // yellow3x2 has a 1.5 ratio; on a square surface:
+ // - cover crops horizontally and fills everything in yellow
+ // - contain letterboxes vertically: transparent bands at top/bottom
+ // - stretch distorts and fills everything in yellow
+ const renderWithResizeMode = async (props) => {
+ const loader = createOneTextureLoader(
+ (gl) => createNDArrayTexture(gl, yellow3x2),
+ [3, 2]
+ );
+ globalRegistry.add(loader.Loader);
+ const inst = create(
+
+
+
+ );
+ const surface = inst.getInstance();
+ await loader.resolve();
+ surface.flush();
+ // copy the capture data: Node.capture() reuses a pooled buffer,
+ // so each capture call overwrites the previously returned array
+ const capture = (x, y) => new Uint8Array(surface.capture(x, y, 1, 1).data);
+ const result = {
+ center: capture(32, 32),
+ bottomLeft: capture(0, 0),
+ topLeft: capture(0, 63),
+ };
+ inst.unmount();
+ globalRegistry.remove(loader.Loader);
+ return result;
+ };
+
+ const yellow = new Uint8Array([255, 255, 0, 255]);
+ const transparent = new Uint8Array([0, 0, 0, 0]);
+
+ const cover = await renderWithResizeMode({});
+ expectToBeCloseToColorArray(cover.center, yellow);
+ expectToBeCloseToColorArray(cover.bottomLeft, yellow);
+ expectToBeCloseToColorArray(cover.topLeft, yellow);
+
+ const contain = await renderWithResizeMode({ resizeMode: "contain" });
+ expectToBeCloseToColorArray(contain.center, yellow);
+ expectToBeCloseToColorArray(contain.bottomLeft, transparent);
+ expectToBeCloseToColorArray(contain.topLeft, transparent);
+
+ const stretch = await renderWithResizeMode({ resizeMode: "stretch" });
+ expectToBeCloseToColorArray(stretch.center, yellow);
+ expectToBeCloseToColorArray(stretch.bottomLeft, yellow);
+ expectToBeCloseToColorArray(stretch.topLeft, yellow);
+
+ // free mode with a small zoom centered in a corner: part of the square
+ // escapes the image edges and stays transparent
+ const free = await renderWithResizeMode({
+ resizeMode: "free",
+ zoom: 0.5,
+ center: [0, 0],
+ });
+ expectToBeCloseToColorArray(free.topLeft, transparent);
+});
diff --git a/packages/tests/package.json b/packages/tests/package.json
index a080a6fe..9968d330 100755
--- a/packages/tests/package.json
+++ b/packages/tests/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"gl-react": "workspace:^",
"gl-react-headless": "workspace:^",
+ "gl-react-image": "workspace:^",
"gl-texture2d": "^2.0.12",
"invariant": "^2.2.4",
"jest": "^29.7.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 741b5030..44cf274b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,9 @@ importers:
gl-react-dom:
specifier: workspace:^
version: link:../gl-react-dom
+ gl-react-image:
+ specifier: workspace:^
+ version: link:../gl-react-image
gl-shader:
specifier: ^4.2.1
version: 4.3.1
@@ -388,6 +391,16 @@ importers:
specifier: workspace:^
version: link:../gl-react
+ packages/gl-react-image:
+ dependencies:
+ react:
+ specifier: '>=18'
+ version: 19.1.0
+ devDependencies:
+ gl-react:
+ specifier: workspace:^
+ version: link:../gl-react
+
packages/gl-react-native:
dependencies:
expo-gl:
@@ -421,6 +434,9 @@ importers:
gl-react-headless:
specifier: workspace:^
version: link:../gl-react-headless
+ gl-react-image:
+ specifier: workspace:^
+ version: link:../gl-react-image
gl-texture2d:
specifier: ^2.0.12
version: 2.1.0
diff --git a/tsconfig.json b/tsconfig.json
index e96f7a0e..f52426ee 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,6 +17,7 @@
"paths": {
"gl-react": ["./packages/gl-react/src"],
"gl-react-dom": ["./packages/gl-react-dom/src"],
+ "gl-react-image": ["./packages/gl-react-image/src"],
"gl-react-headless": ["./packages/gl-react-headless/src"],
"gl-react-expo": ["./packages/gl-react-expo/src"],
"gl-react-native": ["./packages/gl-react-native/src"]
@@ -25,6 +26,7 @@
"references": [
{ "path": "packages/gl-react" },
{ "path": "packages/gl-react-dom" },
+ { "path": "packages/gl-react-image" },
{ "path": "packages/gl-react-headless" },
{ "path": "packages/gl-react-expo" },
{ "path": "packages/gl-react-native" }