Skip to content

Commit f4d41dd

Browse files
gyngclaude
andcommitted
Kuwahara + Anisotropic Diffusion + Pixelate + Fractal: WebGL2 ports
Kuwahara samples its four (r+1)² quadrants directly in the shader, with a static max-r=16 loop gated by the actual uniform radius. The SAT-based JS path stays on CPU as fallback. Anisotropic Diffusion ping-pongs between two RGBA8 FBOs for N ≤ 50 iterations — byte intermediate storage costs a little precision vs JS Float32 but keeps the shader cheap. Pixelate is a pull-model downsample → nearest sample at cell centre (GL path gates on identity palette + no linearize; JS path otherwise). Fractal renders Mandelbrot/Julia with smooth escape colouring, sampling the source image by escape-time wrap or painting an HSL rainbow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent acceb70 commit f4d41dd

8 files changed

Lines changed: 520 additions & 13 deletions

File tree

src/filters/anisotropicDiffusion.ts

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

68
const CONDUCTANCE_EXP = "EXP";
79
const CONDUCTANCE_QUADRATIC = "QUADRATIC";
@@ -30,15 +32,34 @@ export const defaults = {
3032
palette: { ...optionTypes.palette.default, options: { levels: 256 } }
3133
};
3234

33-
const anisotropicDiffusion = (input: any, options = defaults) => {
35+
type AnisotropicDiffusionOptions = typeof defaults & { _webglAcceleration?: boolean };
36+
37+
const anisotropicDiffusion = (input: any, options: AnisotropicDiffusionOptions = defaults) => {
3438
const { iterations, kappa, lambda, conductance, palette } = options;
39+
const W = input.width;
40+
const H = input.height;
41+
42+
if (options._webglAcceleration !== false && anisotropicDiffusionGLAvailable()) {
43+
const rendered = renderAnisotropicDiffusionGL(
44+
input, W, H,
45+
iterations, kappa, lambda,
46+
conductance === CONDUCTANCE_EXP,
47+
);
48+
if (rendered) {
49+
const identity = paletteIsIdentity(palette);
50+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
51+
if (out) {
52+
logFilterBackend("Anisotropic diffusion", "WebGL2", `iter=${iterations} kappa=${kappa}${identity ? "" : "+palettePass"}`);
53+
return out;
54+
}
55+
}
56+
}
57+
3558
const output = cloneCanvas(input, false);
3659
const inputCtx = input.getContext("2d");
3760
const outputCtx = output.getContext("2d");
3861
if (!inputCtx || !outputCtx) return input;
3962

40-
const W = input.width;
41-
const H = input.height;
4263
const buf = inputCtx.getImageData(0, 0, W, H).data;
4364

4465
// Work in Float32 for precision
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// Perona–Malik anisotropic diffusion: N iterations of 4-neighbour
8+
// gradient + edge-stopping diffusion. We ping-pong between two RGBA8
9+
// FBO textures. Intermediate storage is byte-quantised (vs the JS
10+
// reference's Float32), which costs a little precision at high
11+
// iteration counts but keeps the shader cheap.
12+
const DIFFUSE_FS = `#version 300 es
13+
precision highp float;
14+
in vec2 v_uv;
15+
out vec4 fragColor;
16+
uniform sampler2D u_input;
17+
uniform vec2 u_res;
18+
uniform float u_kappa;
19+
uniform float u_lambda;
20+
uniform int u_conductance; // 0 = exp, 1 = quadratic
21+
22+
float c(float grad) {
23+
float t = grad / u_kappa;
24+
return u_conductance == 0 ? exp(-(t * t)) : 1.0 / (1.0 + t * t);
25+
}
26+
27+
vec3 sampleAt(vec2 uv) {
28+
uv = clamp(uv, vec2(0.5) / u_res, vec2(1.0) - vec2(0.5) / u_res);
29+
return texture(u_input, uv).rgb * 255.0;
30+
}
31+
32+
void main() {
33+
vec3 v = texture(u_input, v_uv).rgb * 255.0;
34+
vec3 n = sampleAt(v_uv + vec2(0.0, 1.0 / u_res.y));
35+
vec3 s = sampleAt(v_uv + vec2(0.0, -1.0 / u_res.y));
36+
vec3 w = sampleAt(v_uv + vec2(-1.0 / u_res.x, 0.0));
37+
vec3 e = sampleAt(v_uv + vec2( 1.0 / u_res.x, 0.0));
38+
vec3 dN = n - v;
39+
vec3 dS = s - v;
40+
vec3 dW = w - v;
41+
vec3 dE = e - v;
42+
43+
vec3 cN = vec3(c(dN.r), c(dN.g), c(dN.b));
44+
vec3 cS = vec3(c(dS.r), c(dS.g), c(dS.b));
45+
vec3 cW = vec3(c(dW.r), c(dW.g), c(dW.b));
46+
vec3 cE = vec3(c(dE.r), c(dE.g), c(dE.b));
47+
48+
vec3 next = v + u_lambda * (cN * dN + cS * dS + cW * dW + cE * dE);
49+
next = clamp(next, 0.0, 255.0);
50+
fragColor = vec4(next / 255.0, 1.0);
51+
}
52+
`;
53+
54+
type Cache = { prog: Program };
55+
let _cache: Cache | null = null;
56+
const initCache = (gl: WebGL2RenderingContext): Cache => {
57+
if (_cache) return _cache;
58+
_cache = { prog: linkProgram(gl, DIFFUSE_FS, [
59+
"u_input", "u_res", "u_kappa", "u_lambda", "u_conductance",
60+
] as const) };
61+
return _cache;
62+
};
63+
64+
export const anisotropicDiffusionGLAvailable = (): boolean => glAvailable();
65+
66+
export const renderAnisotropicDiffusionGL = (
67+
source: HTMLCanvasElement | OffscreenCanvas,
68+
width: number, height: number,
69+
iterations: number, kappa: number, lambda: number,
70+
conductanceIsExp: boolean,
71+
): HTMLCanvasElement | OffscreenCanvas | null => {
72+
const ctx = getGLCtx();
73+
if (!ctx) return null;
74+
const { gl, canvas } = ctx;
75+
const cache = initCache(gl);
76+
const vao = getQuadVAO(gl);
77+
resizeGLCanvas(canvas, width, height);
78+
79+
const src = ensureTexture(gl, "anisotropicDiffusion:src", width, height);
80+
uploadSourceTexture(gl, src, source);
81+
82+
const pingA = ensureTexture(gl, "anisotropicDiffusion:A", width, height);
83+
const pingB = ensureTexture(gl, "anisotropicDiffusion:B", width, height);
84+
85+
let readTex = src.tex;
86+
let writeTarget = pingA;
87+
let other = pingB;
88+
89+
const runIter = (target: ReturnType<typeof ensureTexture>) => {
90+
drawPass(gl, target, width, height, cache.prog, () => {
91+
gl.activeTexture(gl.TEXTURE0);
92+
gl.bindTexture(gl.TEXTURE_2D, readTex);
93+
gl.uniform1i(cache.prog.uniforms.u_input, 0);
94+
gl.uniform2f(cache.prog.uniforms.u_res, width, height);
95+
gl.uniform1f(cache.prog.uniforms.u_kappa, kappa);
96+
gl.uniform1f(cache.prog.uniforms.u_lambda, lambda);
97+
gl.uniform1i(cache.prog.uniforms.u_conductance, conductanceIsExp ? 0 : 1);
98+
}, vao);
99+
};
100+
101+
const iters = Math.max(1, Math.min(50, Math.round(iterations)));
102+
for (let i = 0; i < iters - 1; i++) {
103+
runIter(writeTarget);
104+
readTex = writeTarget.tex;
105+
const swap = writeTarget;
106+
writeTarget = other;
107+
other = swap;
108+
}
109+
110+
// Final pass writes to default framebuffer.
111+
drawPass(gl, null, width, height, cache.prog, () => {
112+
gl.activeTexture(gl.TEXTURE0);
113+
gl.bindTexture(gl.TEXTURE_2D, readTex);
114+
gl.uniform1i(cache.prog.uniforms.u_input, 0);
115+
gl.uniform2f(cache.prog.uniforms.u_res, width, height);
116+
gl.uniform1f(cache.prog.uniforms.u_kappa, kappa);
117+
gl.uniform1f(cache.prog.uniforms.u_lambda, lambda);
118+
gl.uniform1i(cache.prog.uniforms.u_conductance, conductanceIsExp ? 0 : 1);
119+
}, vao);
120+
121+
return readoutToCanvas(canvas, width, height);
122+
};

src/filters/fractal.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 { fractalGLAvailable, renderFractalGL } from "./fractalGL";
1114

1215
const FRACTAL_TYPE = {
1316
MANDELBROT: "MANDELBROT",
@@ -59,16 +62,35 @@ export const defaults = {
5962
palette: { ...optionTypes.palette.default, options: { levels: 256 } }
6063
};
6164

62-
const fractalFilter = (input: any, options = defaults) => {
65+
type FractalOptions = typeof defaults & { _webglAcceleration?: boolean };
66+
67+
const fractalFilter = (input: any, options: FractalOptions = defaults) => {
6368
const { type, zoom, centerX, centerY, iterations, juliaR, juliaI, colorSource, palette } = options;
69+
const W = input.width;
70+
const H = input.height;
71+
72+
if (options._webglAcceleration !== false && fractalGLAvailable()) {
73+
const rendered = renderFractalGL(
74+
input, W, H,
75+
type === FRACTAL_TYPE.JULIA,
76+
colorSource === COLOR_SOURCE.IMAGE,
77+
zoom, centerX, centerY, iterations, juliaR, juliaI,
78+
);
79+
if (rendered) {
80+
const identity = paletteIsIdentity(palette);
81+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
82+
if (out) {
83+
logFilterBackend("Fractal", "WebGL2", `type=${type} iter=${iterations}${identity ? "" : "+palettePass"}`);
84+
return out;
85+
}
86+
}
87+
}
6488

6589
const output = cloneCanvas(input, false);
6690
const inputCtx = input.getContext("2d");
6791
const outputCtx = output.getContext("2d");
6892
if (!inputCtx || !outputCtx) return input;
6993

70-
const W = input.width;
71-
const H = input.height;
7294
const buf = inputCtx.getImageData(0, 0, W, H).data;
7395
const outBuf = new Uint8ClampedArray(buf.length);
7496

src/filters/fractalGL.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// Mandelbrot / Julia iterator with smooth escape colouring. Colour
8+
// source can be the image (sampled by escape-time wrap) or an HSL
9+
// rainbow. Matches the JS reference's per-pixel maths.
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 int u_type; // 0 = Mandelbrot, 1 = Julia
17+
uniform int u_colorSource; // 0 = image, 1 = palette hue
18+
uniform float u_zoom;
19+
uniform vec2 u_centre;
20+
uniform int u_iterations;
21+
uniform vec2 u_julia;
22+
23+
vec3 hslToRgb(float hue, float sat, float lit) {
24+
float c = (1.0 - abs(2.0 * lit - 1.0)) * sat;
25+
float hh = mod(mod(hue, 360.0) + 360.0, 360.0);
26+
float xc = c * (1.0 - abs(mod(hh / 60.0, 2.0) - 1.0));
27+
float m = lit - c * 0.5;
28+
vec3 rgb;
29+
if (hh < 60.0) rgb = vec3(c, xc, 0.0);
30+
else if (hh < 120.0) rgb = vec3(xc, c, 0.0);
31+
else if (hh < 180.0) rgb = vec3(0.0, c, xc);
32+
else if (hh < 240.0) rgb = vec3(0.0, xc, c);
33+
else if (hh < 300.0) rgb = vec3(xc, 0.0, c);
34+
else rgb = vec3(c, 0.0, xc);
35+
return floor((rgb + vec3(m)) * 255.0 + 0.5);
36+
}
37+
38+
void main() {
39+
vec2 px = v_uv * u_res;
40+
float jsX = floor(px.x);
41+
float jsY = u_res.y - 1.0 - floor(px.y);
42+
43+
float aspect = u_res.x / u_res.y;
44+
float rangeX = 3.0 / u_zoom;
45+
float rangeY = rangeX / aspect;
46+
47+
float x0 = u_centre.x + (jsX / u_res.x - 0.5) * rangeX;
48+
float y0 = u_centre.y + (jsY / u_res.y - 0.5) * rangeY;
49+
50+
float zr, zi, cr, ci;
51+
if (u_type == 1) {
52+
zr = x0; zi = y0;
53+
cr = u_julia.x; ci = u_julia.y;
54+
} else {
55+
zr = 0.0; zi = 0.0;
56+
cr = x0; ci = y0;
57+
}
58+
59+
int iter = 0;
60+
for (int i = 0; i < 500; i++) {
61+
if (i >= u_iterations) break;
62+
if (zr * zr + zi * zi >= 4.0) break;
63+
float tmp = zr * zr - zi * zi + cr;
64+
zi = 2.0 * zr * zi + ci;
65+
zr = tmp;
66+
iter = i + 1;
67+
}
68+
69+
vec3 outRgb;
70+
if (iter == u_iterations) {
71+
outRgb = vec3(0.0);
72+
} else {
73+
float mag = sqrt(zr * zr + zi * zi);
74+
float t = (float(iter) + 1.0 - log2(log2(mag))) / float(u_iterations);
75+
if (u_colorSource == 0) {
76+
float srcX = mod(floor(t * u_res.x), u_res.x);
77+
float srcY = mod(floor(t * u_res.y), u_res.y);
78+
outRgb = texture(u_source, vec2((srcX + 0.5) / u_res.x, 1.0 - (srcY + 0.5) / u_res.y)).rgb * 255.0;
79+
} else {
80+
outRgb = hslToRgb(t * 360.0 * 3.0, 0.9, 0.5);
81+
}
82+
}
83+
84+
fragColor = vec4(clamp(outRgb, 0.0, 255.0) / 255.0, 1.0);
85+
}
86+
`;
87+
88+
type Cache = { prog: Program };
89+
let _cache: Cache | null = null;
90+
const initCache = (gl: WebGL2RenderingContext): Cache => {
91+
if (_cache) return _cache;
92+
_cache = { prog: linkProgram(gl, FS, [
93+
"u_source", "u_res", "u_type", "u_colorSource",
94+
"u_zoom", "u_centre", "u_iterations", "u_julia",
95+
] as const) };
96+
return _cache;
97+
};
98+
99+
export const fractalGLAvailable = (): boolean => glAvailable();
100+
101+
export const renderFractalGL = (
102+
source: HTMLCanvasElement | OffscreenCanvas,
103+
width: number, height: number,
104+
typeIsJulia: boolean, colorFromImage: boolean,
105+
zoom: number, centreX: number, centreY: number,
106+
iterations: number, juliaR: number, juliaI: number,
107+
): HTMLCanvasElement | OffscreenCanvas | null => {
108+
const ctx = getGLCtx();
109+
if (!ctx) return null;
110+
const { gl, canvas } = ctx;
111+
const cache = initCache(gl);
112+
const vao = getQuadVAO(gl);
113+
resizeGLCanvas(canvas, width, height);
114+
const sourceTex = ensureTexture(gl, "fractal:source", width, height);
115+
uploadSourceTexture(gl, sourceTex, source);
116+
drawPass(gl, null, width, height, cache.prog, () => {
117+
gl.activeTexture(gl.TEXTURE0);
118+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
119+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
120+
gl.uniform2f(cache.prog.uniforms.u_res, width, height);
121+
gl.uniform1i(cache.prog.uniforms.u_type, typeIsJulia ? 1 : 0);
122+
gl.uniform1i(cache.prog.uniforms.u_colorSource, colorFromImage ? 0 : 1);
123+
gl.uniform1f(cache.prog.uniforms.u_zoom, zoom);
124+
gl.uniform2f(cache.prog.uniforms.u_centre, centreX, centreY);
125+
gl.uniform1i(cache.prog.uniforms.u_iterations, Math.max(1, Math.min(500, Math.round(iterations))));
126+
gl.uniform2f(cache.prog.uniforms.u_julia, juliaR, juliaI);
127+
}, vao);
128+
return readoutToCanvas(canvas, width, height);
129+
};

src/filters/kuwahara.ts

Lines changed: 21 additions & 4 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, srgbPaletteGetColor } from "utils";
3+
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, srgbPaletteGetColor, logFilterBackend } from "utils";
44
import { defineFilter } from "filters/types";
5+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
6+
import { kuwaharaGLAvailable, renderKuwaharaGL } from "./kuwaharaGL";
57

68
export const optionTypes = {
79
radius: { type: RANGE, range: [1, 16], step: 1, default: 3, desc: "Filter kernel radius — larger = more painterly" },
@@ -60,15 +62,30 @@ const buildKuwaharaSats = (buf: Uint8ClampedArray, W: number, H: number) => {
6062
return { stride, satR, satG, satB, satR2, satG2, satB2 };
6163
};
6264

63-
const kuwahara = (input: any, options = defaults) => {
65+
type KuwaharaOptions = typeof defaults & { _webglAcceleration?: boolean };
66+
67+
const kuwahara = (input: any, options: KuwaharaOptions = defaults) => {
6468
const { radius, palette } = options;
69+
const W = input.width;
70+
const H = input.height;
71+
72+
if (options._webglAcceleration !== false && kuwaharaGLAvailable()) {
73+
const rendered = renderKuwaharaGL(input, W, H, radius);
74+
if (rendered) {
75+
const identity = paletteIsIdentity(palette);
76+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
77+
if (out) {
78+
logFilterBackend("Kuwahara", "WebGL2", `radius=${radius}${identity ? "" : "+palettePass"}`);
79+
return out;
80+
}
81+
}
82+
}
83+
6584
const output = cloneCanvas(input, false);
6685
const inputCtx = input.getContext("2d");
6786
const outputCtx = output.getContext("2d");
6887
if (!inputCtx || !outputCtx) return input;
6988

70-
const W = input.width;
71-
const H = input.height;
7289
const buf = inputCtx.getImageData(0, 0, W, H).data;
7390
const r = Math.max(1, Math.round(radius));
7491

0 commit comments

Comments
 (0)