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;
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);
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);
}
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;
}
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);
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);
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);
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:
- Chromatic aberration
- Softness
- Bloom
- Contrast, highlights, shadows
- Warmth, tint, saturation
- Fade, vignette
- Grain
- 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,
});
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;
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)