跳到正文
DEV Community· leung steven·· 2 小時前AI 評分50

Y2K Photo Editor 如何用 Canvas 2D 構建可重現的數碼相機風格濾鏡

Building a reproducible digicam photo filter with Canvas 2D

AI 導讀

Y2K Photo Editor 的數碼相機濾鏡以 Canvas 2D 和本地像素處理實現,文章展示如何用可檢視的數值參數分別調整各種效果。渲染器先限制畫布寬度及總像素數,再以固定種子生成顆粒;在尺寸、顆粒設定和像素遍歷順序相同時,雜訊序列一致,但輸出尺寸改變會改變種子。照片在瀏覽器內處理,不需帳戶或上載;免費匯出寬度上限為 1080 像素,每個 UTC 日可下載 5 次。

正文

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.

來源:DEV Community · dev.to