DEV Community

hb lai
hb lai

Posted on

Twelve retro photo effects in one WebGL fragment shader (and why the photo never leaves the browser)

Every "make my photo look like film" tool I looked at wanted me to upload the photo first. That is a server bill, a privacy promise I would have to keep, and a round trip before the user sees anything.

None of it is necessary. Grain, bloom, chromatic aberration, color grading: these are per-pixel math. The GPU in the phone that took the photo can do all of it, in real time, while the file stays in memory.

Here is how the shader is built, including several things I got wrong and had to correct.

The setup: one pass

No framebuffer ping-pong, no multi-pass pipeline. One fragment shader with twelve effect uniforms, each mapped to a slider, plus the image, resolution, and time:

uniform sampler2D u_image;
uniform vec2 u_resolution;
uniform float u_grain;
uniform float u_softness;
uniform float u_contrast;
// ... u_saturation, u_warmth, u_tint, u_highlights,
//     u_shadows, u_vignette, u_aberration, u_fade, u_bloom
uniform float u_time;
Enter fullscreen mode Exit fullscreen mode

Worth being precise about what "single pass" means here: the texture-sampling effects (aberration, softness, bloom) all read from u_image, the original. Everything after that is arithmetic on an accumulated color variable, applied in sequence. So it is not "every effect reads the original" and it is not a clean chain either. It is both, split at the point where sampling stops.

Chromatic aberration: displace radially

The first version shifted the red and blue channels by a fixed pixel offset. It looked like a 3D movie poster.

For the lateral fringing a real lens produces, the offset needs to grow with distance from the image center and point outward. Center stays corrected, corners get the color separation:

float aberrationAmount = u_aberration * 0.003;
vec2 center = uv - 0.5;
float dist = length(center);
vec2 dir = normalize(center);

float r = texture2D(u_image, uv + dir * dist * aberrationAmount).r;
float g = texture2D(u_image, uv).g;
float b = texture2D(u_image, uv - dir * dist * aberrationAmount).b;
color = vec3(r, g, b);
Enter fullscreen mode Exit fullscreen mode

Green is the unshifted reference. Note the direction carefully, because it is the opposite of what it looks like: sampling red at uv + offset pulls in red from further out, so red features end up displaced inward, and blue outward.

One cleanup I have not shipped yet: normalize(center) is undefined exactly at the center pixel. Since dir * dist is just center, the safe equivalent is vec2 offset = center * aberrationAmount;.

This models lateral chromatic aberration specifically. Longitudinal CA is a different phenomenon, and a fixed offset is still a perfectly good stylistic choice if that is the look you want.

Bloom: gate on luminance, ramp on the excess

Bloom is what makes an image read as "old sensor." Bright areas bleed outward instead of clipping flat. Blurring the whole frame and mixing it back just produces mud.

Gate it on luminance instead, and let the mix strength grow with how far past the threshold a pixel sits:

vec3 blurred = blur(u_image, uv, u_bloom * 5.0);
float luminance = dot(blurred, vec3(0.299, 0.587, 0.114));
if (luminance > 0.5) {
  color = mix(color, blurred, u_bloom * 0.4 * (luminance - 0.5) * 2.0);
}
Enter fullscreen mode Exit fullscreen mode

Two details worth stealing.

The luminance comes from blurred, the softened sampling of the original, not from the sharp pixel. That is what lets the glow spread past the actual edge of a highlight. Measure luminance on the sharp image and the glow stops dead at the boundary.

(luminance - 0.5) * 2.0 remaps 0.5..1.0 back to 0..1, so a pixel barely over the threshold gets almost nothing and a blown-out one gets the maximum. There is still a hard branch at 0.5, but the weight rises continuously from zero, so no visible seam.

Grain: hash noise, animated by time

A widely circulated sine-dot pseudo-random hash, plus u_time so the pattern changes per frame instead of freezing into a static overlay:

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

float grain(vec2 uv, float intensity) {
  float noise = hash(uv * u_resolution + u_time);
  return (noise - 0.5) * intensity;
}
Enter fullscreen mode Exit fullscreen mode

Multiplying uv by u_resolution before hashing is the part people miss. It makes the noise vary at roughly one sample per output pixel. Hash the raw normalized UV instead and the pattern's feature count stays tied to normalized coordinates, so its apparent size in pixels changes with the output resolution: enormous blobs on a large canvas, fine dust on a small one.

Saturation: HSL because the control is legible

The cheap alternative, which this shader does not use, is to lerp away from luminance:

// not repo code, shown for contrast
color = mix(vec3(lum), color, 1.0 + u_saturation);
Enter fullscreen mode Exit fullscreen mode

I originally wanted to claim this method drags hues toward the primaries. That is not right: scaling RGB distance from neutral gray preserves RGB hue right up until values clip.

The actual reason I went with an HSL round trip is more boring: saturation is an explicit component, so the slider maps to one number and the code says what it does.

vec3 hsl = rgb2hsl(color);
hsl.y *= (1.0 + u_saturation);
color = hsl2rgb(hsl);
Enter fullscreen mode Exit fullscreen mode

Neither approach guarantees perceptually exact hue once out-of-gamut values get clamped, and this shader does not clamp hsl.y before converting back. On a single full-screen pass the round trip has not been a bottleneck on anything I have tested, including a five-year-old midrange Android.

Fade: just add a constant

The washed, lifted-blacks look of old prints is the least sophisticated line in the shader:

color = mix(color, color + vec3(0.08), u_fade);
Enter fullscreen mode Exit fullscreen mode

Raise the floor so nothing is truly black. I spent an embarrassing amount of time on tone curves before landing here. It reads correctly because lifted blacks are exactly what faded prints and cheap film scans do.

Order

The sequence runs optical, then tonal, then color, then texture:

  1. Chromatic aberration
  2. Softness
  3. Bloom
  4. Contrast, highlights, shadows
  5. Warmth, tint, saturation
  6. Fade, vignette
  7. Grain
  8. Clamp

The useful property is that vignette and grain land on the final graded color rather than being pushed through the color stage. Move grain before softness and the softness mix attenuates it. It would not get convolved away, since blur() always samples u_image and never the accumulated color, which is a subtlety of the single-pass design that took me a re-read of my own shader to state correctly.

The export bug that cost me an afternoon

Everything looked right on screen. canvas.toDataURL() returned blank.

WebGL is allowed to discard the drawing buffer after compositing. If you serialize synchronously right after drawing you can get away with it, but an editor that exports on a button click, some frames later, will not. The options are to rerender immediately before export, render into an offscreen framebuffer, or ask the context to keep the buffer around:

const gl = canvas.getContext('webgl', {
  preserveDrawingBuffer: true,
  premultipliedAlpha: false,
});
Enter fullscreen mode Exit fullscreen mode

I took the third. There is a cost, but for an editor that renders on slider input rather than in a 60fps loop it is invisible. Context attributes cannot be changed after creation, so this has to be decided up front.

What the blur actually is

Calling this a "5x5 box blur" undersells how approximate it is:

for (float x = -2.0; x <= 2.0; x += 1.0) {
  for (float y = -2.0; y <= 2.0; y += 1.0) {
    vec2 offset = vec2(x, y) * texelSize * radius;
    result += texture2D(tex, uv + offset).rgb;
    total += 1.0;
  }
}
return result / total;
Enter fullscreen mode Exit fullscreen mode

25 equally weighted samples on a 5x5 grid, but the spacing scales with radius. At maximum softness the taps sit 3 pixels apart and span 13 pixels; at maximum bloom, 5 apart spanning 21. That is a sparse box-style approximation, not a contiguous convolution and definitely not a gaussian. A separable or downsampled blur would cover the same radius with better quality per sample, and if you are doing anything precision-sensitive you should use one.

For glow and softness at these radii I have not found it worth the extra complexity. That is a judgment call, not a benchmark.

Where it runs

The pipeline runs on the user's device. No upload, no server-side image processing cost, and the photo is never transmitted or persisted. (Analytics and the rest of the site still need their own disclosure; local processing is not a blanket exemption from writing a privacy policy.)

JPG, PNG, and WebP go through the same path everywhere. HEIC works only where the browser decodes it natively, since there is no bundled decoder. There is a Canvas 2D fallback for contexts where WebGL is unavailable, but it is honestly a reduced one: contrast, warmth, saturation, fade, grain, and vignette only. Softness, bloom, aberration, tint, highlights, and shadows are WebGL-only.

If you want to see the output, the tool is at digicamfilter.online (free, no signup). Each look documents its exact twelve slider values, so you can read the numbers off and reproduce any of it in your own shader.

The bloom luminance-from-blurred trick and the grain resolution scaling are the two I would prioritize if you are building something similar. Happy to answer questions on any of it.

Top comments (0)