A nostalgic camera filter becomes easier to tune when “the look” is a set of numbers you can inspect. Grain, a warm cast, soft detail and bright highlights do different jobs; putting them behind one intensity slider makes it harder to understand the result.
This walkthrough uses the approach in Y2K Photo Editor, a browser-based photo tool I’m working on. Its digicam renderer uses Canvas 2D and local pixel processing. The examples below explain that implementation; they are an aesthetic approximation, not a calibrated simulation of a particular CCD sensor.
1. Make a preset explicit
Here is the current 2002 Café Warmth starting point (its internal preset ID is warm-digicam). Numeric controls use a 0–100 scale, but their neutral values differ: warmth, tint, highlights and shadows center on 50, while grain and bloom start at zero.
const warmDigicam = {
grain: 28,
softness: 18,
contrast: 54,
saturation: 58,
warmth: 66,
tint: 50,
highlights: 48,
shadows: 52,
vignette: 12,
aberration: 8,
fade: 8,
bloom: 10,
dateStamp: false,
};
A preset should load these values into the controls, so someone can see and adjust the recipe. Keep the date stamp separate: it is a decorative overlay, not a date extracted from the photo.
2. Size the working canvas before reading pixels
The renderer limits both width and total pixel count. This matters for portrait images: a width limit alone can still leave a very tall frame to process.
const scale = Math.min(
1,
maxWidth / sourceWidth,
Math.sqrt(maxPixels / (sourceWidth * sourceHeight)),
);
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
Set the canvas to those dimensions, draw the source, and read its ImageData. The site applies softness during drawing, using a blur radius of (softness / 100) * 1.8 pixels on the working canvas. Scaling first makes that radius consistent at the chosen preview size.
3. Seed the grain
Calling Math.random() on every render makes an unchanged preset produce a different texture. A seeded generator makes comparisons less distracting:
function seededNoise(seed) {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
const rand = seededNoise(
(width * 2654435761 + height * 97 + grain * 733) >>> 0,
);
// Inside the pixel loop, after color adjustments:
const n = (rand() - 0.5) * grain * 0.48;
r += n;
g += n;
b += n * 1.08;
For the same dimensions, grain setting and traversal order, this yields the same noise sequence. Changing the output size changes the seed and texture. That is useful reproducibility within a render size, not a promise of identical pixels across every export size.
4. Adjust color and highlights independently
The production pipeline separates saturation, contrast, warmth, tint, highlight/shadow lifts, fade and bloom. These are excerpts from the digicam branch, in its processing order:
// r, g and b are the channels after saturation and contrast.
const warm = (warmth - 50) * 0.56;
const cast = (tint - 50) * 0.42;
r += warm + cast;
g -= cast * 1.55;
b -= warm - cast;
// lum was calculated earlier, before these color adjustments.
const glow = Math.max(0, (lum - 128) / 127) * (bloom / 100) * 34;
r += glow;
g += glow * 0.94;
b += glow * 0.82;
The bloom control here is a luminance-based highlight lift. It does not blur a bright-pass layer into surrounding pixels, so it should not be described as a physically accurate optical bloom. That distinction helps set expectations when comparing it with a more elaborate renderer.
Keep an immutable copy of the sampled pixels if you also offset red and blue channels for chromatic aberration. Otherwise later pixels can accidentally sample values already modified earlier in the loop.
5. Use a bounded vignette and preserve alpha
const nx = (x - width / 2) / Math.max(1, width / 2);
const ny = (y - height / 2) / Math.max(1, height / 2);
const falloff = Math.min(
0.42,
Math.max(0, nx * nx + ny * ny - 0.3) * (vignette / 100) * 0.38,
);
r *= 1 - falloff;
g *= 1 - falloff;
b *= 1 - falloff;
After adding grain, clamp RGB channels to 0–255 and write them back. Leave the alpha channel untouched. Keep vignette adjustable: heavy dark corners can obscure detail in a night portrait even when they suit a brighter scene.
Try the parameters on different scenes
Test the same recipe on a low-light portrait, a daylight portrait and a landscape. Look for retained shadow detail, skin color and grain in smooth sky areas. A useful preset is a starting point that can be adjusted for the source image.
The Digital Camera Filter Lab exposes these controls and a before/after comparison. The site’s example scenes are generated demo images, not real CCD-camera captures or customer photographs.
Photos are processed in the browser without an account or photo upload. Current free photo exports have no watermark, are up to 1080 pixels wide and share an allowance of five downloads per UTC day. Current exports are for personal use.
Disclosure: I’m working on Y2K Photo Editor. This article was prepared with AI assistance and checked against the project’s current renderer and preset definitions.
Top comments (0)