Skip to content

Commit 1a0a120

Browse files
gyngclaude
andcommitted
Sepia + Gradient Map + Infrared + Pop Art: WebGL2 ports
Per-pixel colour transforms drop off the JS hot path. Sepia, Gradient Map and Infrared are straight per-pixel math; Pop Art also does luminance- driven Ben-Day dot placement in-shader using JS-orientation pixel coords. All four keep their existing palette pass after GL readout for non-identity palettes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08f440e commit 1a0a120

8 files changed

Lines changed: 364 additions & 14 deletions

File tree

src/filters/gradientMap.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
fillBufferPixel,
77
getBufferIndex,
88
rgba,
9-
paletteGetColor
9+
paletteGetColor,
10+
logFilterBackend,
1011
} from "utils";
12+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
13+
import { gradientMapGLAvailable, renderGradientMapGL } from "./gradientMapGL";
1114

1215
export const optionTypes = {
1316
color1: { type: COLOR, default: [0, 0, 40], desc: "Shadow color (darkest tones)" },
@@ -27,16 +30,35 @@ export const defaults = {
2730

2831
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
2932

30-
const gradientMap = (input: any, options = defaults) => {
33+
type GradientMapOptions = typeof defaults & { _webglAcceleration?: boolean };
34+
35+
const gradientMap = (input: any, options: GradientMapOptions = defaults) => {
3136
const { color1, color2, color3, mix, palette } = options;
37+
const W = input.width, H = input.height;
38+
39+
if (options._webglAcceleration !== false && gradientMapGLAvailable()) {
40+
const rendered = renderGradientMapGL(
41+
input, W, H,
42+
[color1[0], color1[1], color1[2]],
43+
[color2[0], color2[1], color2[2]],
44+
[color3[0], color3[1], color3[2]],
45+
mix,
46+
);
47+
if (rendered) {
48+
const identity = paletteIsIdentity(palette);
49+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
50+
if (out) {
51+
logFilterBackend("Gradient Map", "WebGL2", identity ? "direct" : "direct+palettePass");
52+
return out;
53+
}
54+
}
55+
}
3256

3357
const output = cloneCanvas(input, false);
3458
const inputCtx = input.getContext("2d");
3559
const outputCtx = output.getContext("2d");
3660
if (!inputCtx || !outputCtx) return input;
3761

38-
const W = input.width;
39-
const H = input.height;
4062
const buf = inputCtx.getImageData(0, 0, W, H).data;
4163
const outBuf = new Uint8ClampedArray(buf.length);
4264

src/filters/gradientMapGL.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
const FS = `#version 300 es
8+
precision highp float;
9+
in vec2 v_uv;
10+
out vec4 fragColor;
11+
uniform sampler2D u_source;
12+
uniform vec3 u_color1;
13+
uniform vec3 u_color2;
14+
uniform vec3 u_color3;
15+
uniform float u_mix;
16+
void main() {
17+
vec4 c = texture(u_source, v_uv);
18+
vec3 src = c.rgb * 255.0;
19+
float lum = (0.2126 * src.r + 0.7152 * src.g + 0.0722 * src.b) / 255.0;
20+
vec3 mapped;
21+
if (lum < 0.5) {
22+
mapped = mix(u_color1, u_color2, lum * 2.0);
23+
} else {
24+
mapped = mix(u_color2, u_color3, (lum - 0.5) * 2.0);
25+
}
26+
vec3 fin = floor(mix(src, mapped, u_mix) + 0.5);
27+
fragColor = vec4(fin / 255.0, c.a);
28+
}
29+
`;
30+
31+
type Cache = { prog: Program };
32+
let _cache: Cache | null = null;
33+
const initCache = (gl: WebGL2RenderingContext): Cache => {
34+
if (_cache) return _cache;
35+
_cache = { prog: linkProgram(gl, FS, [
36+
"u_source", "u_color1", "u_color2", "u_color3", "u_mix",
37+
] as const) };
38+
return _cache;
39+
};
40+
41+
export const gradientMapGLAvailable = (): boolean => glAvailable();
42+
43+
export const renderGradientMapGL = (
44+
source: HTMLCanvasElement | OffscreenCanvas,
45+
width: number, height: number,
46+
color1: [number, number, number],
47+
color2: [number, number, number],
48+
color3: [number, number, number],
49+
mixAmount: number,
50+
): HTMLCanvasElement | OffscreenCanvas | null => {
51+
const ctx = getGLCtx();
52+
if (!ctx) return null;
53+
const { gl, canvas } = ctx;
54+
const cache = initCache(gl);
55+
const vao = getQuadVAO(gl);
56+
resizeGLCanvas(canvas, width, height);
57+
const sourceTex = ensureTexture(gl, "gradientMap:source", width, height);
58+
uploadSourceTexture(gl, sourceTex, source);
59+
drawPass(gl, null, width, height, cache.prog, () => {
60+
gl.activeTexture(gl.TEXTURE0);
61+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
62+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
63+
gl.uniform3f(cache.prog.uniforms.u_color1, color1[0], color1[1], color1[2]);
64+
gl.uniform3f(cache.prog.uniforms.u_color2, color2[0], color2[1], color2[2]);
65+
gl.uniform3f(cache.prog.uniforms.u_color3, color3[0], color3[1], color3[2]);
66+
gl.uniform1f(cache.prog.uniforms.u_mix, mixAmount);
67+
}, vao);
68+
return readoutToCanvas(canvas, width, height);
69+
};

src/filters/infrared.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { RANGE, PALETTE } from "constants/controlTypes";
22
import { nearest } from "palettes";
3-
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, paletteGetColor } from "utils";
3+
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, paletteGetColor, logFilterBackend } from "utils";
44
import { defineFilter } from "filters/types";
5+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
6+
import { infraredGLAvailable, renderInfraredGL } from "./infraredGL";
57

68
export const optionTypes = {
79
intensity: { type: RANGE, range: [0, 1], step: 0.05, default: 0.8, desc: "Infrared effect strength" },
@@ -15,14 +17,29 @@ export const defaults = {
1517
palette: { ...optionTypes.palette.default, options: { levels: 256 } }
1618
};
1719

18-
const infrared = (input: any, options = defaults) => {
20+
type InfraredOptions = typeof defaults & { _webglAcceleration?: boolean };
21+
22+
const infrared = (input: any, options: InfraredOptions = defaults) => {
1923
const { intensity, falseColor, palette } = options;
24+
const W = input.width, H = input.height;
25+
26+
if (options._webglAcceleration !== false && infraredGLAvailable()) {
27+
const rendered = renderInfraredGL(input, W, H, intensity, falseColor);
28+
if (rendered) {
29+
const identity = paletteIsIdentity(palette);
30+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
31+
if (out) {
32+
logFilterBackend("Infrared", "WebGL2", `intensity=${intensity}${identity ? "" : "+palettePass"}`);
33+
return out;
34+
}
35+
}
36+
}
37+
2038
const output = cloneCanvas(input, false);
2139
const inputCtx = input.getContext("2d");
2240
const outputCtx = output.getContext("2d");
2341
if (!inputCtx || !outputCtx) return input;
2442

25-
const W = input.width, H = input.height;
2643
const buf = inputCtx.getImageData(0, 0, W, H).data;
2744
const outBuf = new Uint8ClampedArray(buf.length);
2845

src/filters/infraredGL.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// IR film simulation — the "Wood effect":
8+
// green (foliage) goes bright, blue (sky) goes dark, red stays neutral.
9+
// Optional false-colour pass shifts to the pink/magenta look typical of
10+
// Kodak Aerochrome.
11+
const FS = `#version 300 es
12+
precision highp float;
13+
in vec2 v_uv;
14+
out vec4 fragColor;
15+
uniform sampler2D u_source;
16+
uniform float u_intensity;
17+
uniform float u_falseColor;
18+
void main() {
19+
vec4 c = texture(u_source, v_uv);
20+
vec3 src = c.rgb * 255.0;
21+
float irLum = clamp(src.r * 0.3 + src.g * 0.7 + src.b * (-0.2), 0.0, 255.0);
22+
vec3 ir;
23+
if (u_falseColor > 0.0) {
24+
ir = vec3(
25+
irLum * 0.9 + src.g * 0.3 * u_falseColor,
26+
irLum * 0.3 - src.b * 0.2 * u_falseColor,
27+
irLum * 0.5 + src.r * 0.2 * u_falseColor
28+
);
29+
} else {
30+
ir = vec3(irLum);
31+
}
32+
vec3 blended = clamp(src * (1.0 - u_intensity) + ir * u_intensity, 0.0, 255.0);
33+
fragColor = vec4(floor(blended + 0.5) / 255.0, c.a);
34+
}
35+
`;
36+
37+
type Cache = { prog: Program };
38+
let _cache: Cache | null = null;
39+
const initCache = (gl: WebGL2RenderingContext): Cache => {
40+
if (_cache) return _cache;
41+
_cache = { prog: linkProgram(gl, FS, ["u_source", "u_intensity", "u_falseColor"] as const) };
42+
return _cache;
43+
};
44+
45+
export const infraredGLAvailable = (): boolean => glAvailable();
46+
47+
export const renderInfraredGL = (
48+
source: HTMLCanvasElement | OffscreenCanvas,
49+
width: number, height: number,
50+
intensity: number,
51+
falseColor: number,
52+
): HTMLCanvasElement | OffscreenCanvas | null => {
53+
const ctx = getGLCtx();
54+
if (!ctx) return null;
55+
const { gl, canvas } = ctx;
56+
const cache = initCache(gl);
57+
const vao = getQuadVAO(gl);
58+
resizeGLCanvas(canvas, width, height);
59+
const sourceTex = ensureTexture(gl, "infrared:source", width, height);
60+
uploadSourceTexture(gl, sourceTex, source);
61+
drawPass(gl, null, width, height, cache.prog, () => {
62+
gl.activeTexture(gl.TEXTURE0);
63+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
64+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
65+
gl.uniform1f(cache.prog.uniforms.u_intensity, intensity);
66+
gl.uniform1f(cache.prog.uniforms.u_falseColor, falseColor);
67+
}, vao);
68+
return readoutToCanvas(canvas, width, height);
69+
};

src/filters/popArt.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { RANGE, PALETTE } from "constants/controlTypes";
22
import { nearest } from "palettes";
3-
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, paletteGetColor } from "utils";
3+
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, paletteGetColor, logFilterBackend } from "utils";
44
import { defineFilter } from "filters/types";
5+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
6+
import { popArtGLAvailable, renderPopArtGL } from "./popArtGL";
57

68
export const optionTypes = {
79
dotSize: { type: RANGE, range: [3, 16], step: 1, default: 6, desc: "Ben-Day dot size" },
@@ -17,14 +19,29 @@ export const defaults = {
1719
palette: { ...optionTypes.palette.default, options: { levels: 256 } }
1820
};
1921

20-
const popArt = (input: any, options = defaults) => {
22+
type PopArtOptions = typeof defaults & { _webglAcceleration?: boolean };
23+
24+
const popArt = (input: any, options: PopArtOptions = defaults) => {
2125
const { dotSize, levels, saturationBoost, palette } = options;
26+
const W = input.width, H = input.height;
27+
28+
if (options._webglAcceleration !== false && popArtGLAvailable()) {
29+
const rendered = renderPopArtGL(input, W, H, dotSize, levels, saturationBoost);
30+
if (rendered) {
31+
const identity = paletteIsIdentity(palette);
32+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
33+
if (out) {
34+
logFilterBackend("Pop Art", "WebGL2", `dotSize=${dotSize} levels=${levels}${identity ? "" : "+palettePass"}`);
35+
return out;
36+
}
37+
}
38+
}
39+
2240
const output = cloneCanvas(input, false);
2341
const inputCtx = input.getContext("2d");
2442
const outputCtx = output.getContext("2d");
2543
if (!inputCtx || !outputCtx) return input;
2644

27-
const W = input.width, H = input.height;
2845
const buf = inputCtx.getImageData(0, 0, W, H).data;
2946
const outBuf = new Uint8ClampedArray(buf.length);
3047

src/filters/popArtGL.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// Lichtenstein-style pop-art pass: saturation boost + colour posterise +
8+
// luminance-driven Ben-Day dots on a white background. JS-orientation
9+
// pixel coordinates match the reference loop so dot placement is stable.
10+
const FS = `#version 300 es
11+
precision highp float;
12+
in vec2 v_uv;
13+
out vec4 fragColor;
14+
uniform sampler2D u_source;
15+
uniform vec2 u_res;
16+
uniform float u_dotSize;
17+
uniform float u_levels;
18+
uniform float u_satBoost;
19+
20+
void main() {
21+
vec2 px = v_uv * u_res;
22+
float x = floor(px.x);
23+
float y = u_res.y - 1.0 - floor(px.y);
24+
25+
vec3 src = texture(u_source, vec2((x + 0.5) / u_res.x, 1.0 - (y + 0.5) / u_res.y)).rgb * 255.0;
26+
27+
// Saturation boost around luma.
28+
float gray = 0.2126 * src.r + 0.7152 * src.g + 0.0722 * src.b;
29+
vec3 sat = clamp(gray + (src - gray) * u_satBoost, 0.0, 255.0);
30+
sat = floor(sat + 0.5);
31+
32+
// Posterise.
33+
float step = 255.0 / (u_levels - 1.0);
34+
vec3 post = floor(floor(sat / step + 0.5) * step + 0.5);
35+
36+
// Ben-Day dots.
37+
float lum = (0.2126 * post.r + 0.7152 * post.g + 0.0722 * post.b) / 255.0;
38+
float cellX = mod(x, u_dotSize);
39+
float cellY = mod(y, u_dotSize);
40+
float cx = u_dotSize * 0.5;
41+
float dist = length(vec2(cellX - cx, cellY - cx));
42+
float dotR = cx * (1.0 - lum);
43+
44+
vec3 outCol = dist < dotR ? post / 255.0 : vec3(1.0);
45+
fragColor = vec4(outCol, 1.0);
46+
}
47+
`;
48+
49+
type Cache = { prog: Program };
50+
let _cache: Cache | null = null;
51+
const initCache = (gl: WebGL2RenderingContext): Cache => {
52+
if (_cache) return _cache;
53+
_cache = { prog: linkProgram(gl, FS, [
54+
"u_source", "u_res", "u_dotSize", "u_levels", "u_satBoost",
55+
] as const) };
56+
return _cache;
57+
};
58+
59+
export const popArtGLAvailable = (): boolean => glAvailable();
60+
61+
export const renderPopArtGL = (
62+
source: HTMLCanvasElement | OffscreenCanvas,
63+
width: number, height: number,
64+
dotSize: number, levels: number, saturationBoost: number,
65+
): HTMLCanvasElement | OffscreenCanvas | null => {
66+
const ctx = getGLCtx();
67+
if (!ctx) return null;
68+
const { gl, canvas } = ctx;
69+
const cache = initCache(gl);
70+
const vao = getQuadVAO(gl);
71+
resizeGLCanvas(canvas, width, height);
72+
const sourceTex = ensureTexture(gl, "popArt:source", width, height);
73+
uploadSourceTexture(gl, sourceTex, source);
74+
drawPass(gl, null, width, height, cache.prog, () => {
75+
gl.activeTexture(gl.TEXTURE0);
76+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
77+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
78+
gl.uniform2f(cache.prog.uniforms.u_res, width, height);
79+
gl.uniform1f(cache.prog.uniforms.u_dotSize, dotSize);
80+
gl.uniform1f(cache.prog.uniforms.u_levels, levels);
81+
gl.uniform1f(cache.prog.uniforms.u_satBoost, saturationBoost);
82+
}, vao);
83+
return readoutToCanvas(canvas, width, height);
84+
};

0 commit comments

Comments
 (0)