Files
RecipesCam/docker/frontend/shared/utils/grainShader.ts
T
3dtours d37671c359 web: print the grain zone by mixing two lattices, not by warping one cell
The coating's patches were drawn by varying the clump CELL with position:
cell = u * (1 + (grainZone(p * ZONE_FREQ) - 0.5) * ZONE_SWING), the same slow
value noise that picks the patch. A lattice whose cell varies with position
smears instead of resizing: its phase accumulates as
d(phase)/ds = 1/cell - s*cell'/cell^2, and c' is read along the radius from the
picture's own origin, so the second term grows with the distance s from it and
the clumps are drawn out wherever the patch's own cell runs. Measured on one
classic-neg paint (1024px, cell 1.09, the app's own Overlay at alpha 0.5, 64
tiles): with the swing on, the tiles' lag-1 correlation of the raw frame spans
-0.065..0.747 — clumps stretched into smooth blotches beside grain. The design's
+-20% swing cannot do that: the SAME field with the swing forced to 0 spans
-0.057..0.045 across its tiles, and the two-lattice field spans -0.055..0.052
(leica: -0.049..0.523 with the swing on, -0.068..0.024 at swing 0).

The zone now MIXES two FIXED lattices, 0.8x and 1.2x the stock's own cell,
weighted by that same patch noise. A fixed lattice's phase is linear in the
picture, so a patch can only choose how much of each is printed, never how
either is shaped — and the two lattices' beat falls at
1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm cell: the pixel scale, not
a line the eye reads. The mix is renormalised by sqrt(w^2 + (1-w)^2), the share
of one field's spread a two-field blend carries, so neither the mean nor the
spread follows the patch: the same classic-neg field reads sd 24.85 against
24.91 and leica 23.74 against 23.74, tile by tile.

The zone still reads what it is for. At preview scale (1600px, cell 1.70, 8x8
tiles of 200px) the mixed field's tiles span rho1 0.082..0.265, ratio 3.233,
against 0.169..0.193, ratio 1.141, with the swing forced to 0 — classic-neg;
leica 0.026..0.188, ratio 7.361, against 0.085..0.105, ratio 1.237. A coarser
patch still prints coarser clumps; it just never prints a stretched one.

Both copies carry it: docker/frontend/shared/utils/grainShader.ts and
src/utils/grainShader.ts (the phone's, which the root web harness imports too).
The field is evaluated once per lattice now, so the grain pass costs 1.94x what
one lattice did — the ratio, not the absolute.

Measured:
  _grain-zone2.cjs — the 64-tile lag-1 correlation spread above, three modules
    on one paint and one seed: swing 0.4 vs swing 0 vs the mix.
  _grain-zone-ck.cjs — the zone's own contribution at preview scale, zone on
    against the same module with the swing forced to 0, nothing else differing:
    classic-neg tile sd 24.21..24.86 (ratio 1.027), hf 0.769..0.937 (1.219),
    rho1 0.082..0.265 (3.233) against sd 29.04..32.95 (1.135), hf 0.834..0.858
    (1.028), rho1 0.169..0.193 (1.141); leica rho1 0.026..0.188 (7.361) against
    0.085..0.105 (1.237). The swing-off row's higher sd is the renormalisation
    of a blend with itself (a and b are one field at swing 0), not a contrast
    change in the shipped field.
  _grain-fft.cjs — same paint, same seeds, three rolls, 1024px: the mix's top
    spectral peak sits at 2.6px (classic-neg, cell 1.09) and 2.8..2.9px (leica,
    cell 1.00) against the warped field's 3.0/4.3/6.2px and 2.7/3.8/4.5px — both
    within a pixel of the clump cell, neither a coarse lattice.
  _grain-bench.cjs — 12.68s per 700px field (one lattice) against 24.60s (two),
    1.94x on software CanvasKit.
  grain-size-test.cjs 17/0 — the SIZE rule and the readout on the module, both
    lattices floored at the target's own pixel, file rho1 0.673, preview rho1
    0.003.
  grain-stock-test.cjs 53/0 on the deployed build — the stock table, the
    halation chain and its ordering, no page errors.
  grain-controls-test.cjs 20/0 on the deployed build — the patch claim still
    holds there: tile sd 56.58..65.48 (mean 61.9, max/min 1.157), tile mean
    spread 0.65, so a coarser patch is still not a brighter one.
  _grain-spectrum.cjs (app, deployed, 1600px) — residual autocorrelation peak
    0.020..0.021, top peaks at 2.0px@20/110 and 2.5px@51.
  tsc: web `--noEmit` clean (the docker build runs it); the phone's scoped
    config reports its pre-change baseline, nothing in grainShader.ts.

One honest number: the app-level spectral peak/median rises 4.6..6.1 to 10.2
(classic-neg, sim-classic-neg-g6/g10) because two fixed lattices beat where one
warped lattice spread. It is 20x below the value-noise field this work replaced
(29..35, tiling) and 5x below a lattice (50+), and it sits at 2px, the cell
itself.

ponytail: the field is evaluated once per lattice, so the grain pass costs
1.94x. One evaluation cannot hold two cell sizes; revisit only if a preview
budget asks for the pass back. The 0.8/1.2 rungs (ZONE_SWING/2 either side) are
one working set, not a search.

Verified: `grain-stock-test.cjs` 53/0, `grain-controls-test.cjs` 20/0 and
`_grain-spectrum.cjs` against the deployed build at localhost:8090;
`grain-size-test.cjs` 17/0; `_grain-zone2.cjs`, `_grain-zone-ck.cjs`,
`_grain-fft.cjs`, `_grain-bench.cjs` against the module built from HEAD,
`inversesqrt` still the one call the shader needed to renormalise; web
`tsc --noEmit` clean, phone scoped tsc down to its pre-existing errors.
2026-09-23 16:47:19 +07:00

368 lines
16 KiB
TypeScript

// 35mm & 120 FILM GRAIN — the structures the marketing card promises, per
// stock: "authentic grain structures plus halation bloom, tuned per stock
// rather than one global overlay."
//
// The phone's grainShader.ts rolls the film and sizes the cell; this is the
// same film pulled through the lab's stock table. One grain field is still
// printed for the whole picture, but WHICH field — how coarse, how clumpy, and
// how much the highlights bleed back — is the emulsion's and its format's, not
// a constant.
//
// FORMAT. A 35mm frame is 24x36mm; a 120 frame is 6x6 or bigger — two to four
// times the linear area for the same print. So at one picture width the 35mm
// negative's grain prints COARSER and a 120 negative's FINER and smoother: the
// same emulsion is two different textures. `cell` is the format's share of the
// 1080-wide reference the GRAIN knob was tuned at — 1.0 is the 35mm baseline
// that ships today, so an existing 35mm recipe keeps the grain it has.
//
// STOCK. Within a format the emulsion sets the rest: how hard the grain clumps
// (`spread` and the octave `mix`), and how far the highlights bleed back as
// halation. Halation is RED because red is the light that passes the emulsion,
// bounces off the anti-halation backing and returns: the colour negative, whose
// red-sensitive layer sits deepest, halates hardest. Slides halate less. B&W has
// no colour layer to bleed, so it gets a weak, barely-warm grey. A sensor has no
// backing at all — LEICA's two looks are digital, so they carry none.
//
// The GRAIN knob stays the only control, exactly as it is today: its amount
// scales BOTH the grain's alpha and the halation's, so GRAIN OFF is still a
// clean frame and the OFF/WEAK/STRONG chips still mean 0/3/6. Nothing here
// moves a stock's look until grain is dialled in.
import type { BaseFilter } from '../types';
export type FilmFormat = '35mm' | '120';
export interface GrainStock {
format: FilmFormat;
/** The emulsion this entry stands for — the record behind the numbers. */
film: string;
/** Grain cell, as a share of the 1080-wide reference (see GRAIN_REF). */
cell: number;
/** Contrast of the noise, i.e. how hard the grain clumps. */
spread: number;
/** Octave weights of the grain field, fine → coarse. */
mix: [number, number, number];
/** Halation strength at GRAIN 10, 0..1. */
halation: number;
/** Halo radius in 1080-reference units (picture-relative, see grainCell). */
halo: number;
/** What the halo is made of: the highlight, tinted. */
haloRGB: [number, number, number];
}
// The baseline field the GRAIN knob was calibrated against, and the one the
// phone still prints: a fine octave carrying most of the structure, then its
// clumps at 2x and 4x. Coarser and clumpier (35mm), or the base octave opened
// up and the clumps pulled back (120's finer, smoother texture).
const CLUMPY: [number, number, number] = [0.55, 0.3, 0.15];
const SMOOTH: [number, number, number] = [0.62, 0.26, 0.12];
// Digital: a sensor has no emulsion and no backing — the grain the knob prints
// is the user's own choice, so it keeps the neutral baseline field and no
// halation. Both LEICA looks land here.
const SENSOR: GrainStock = {
format: '35mm',
film: 'digital sensor',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0,
halo: 0,
haloRGB: [1, 1, 1],
};
export const GRAIN_STOCKS: Record<BaseFilter, GrainStock> = {
// --- 35mm: the consumer, press and cinema stocks ---
'classic-neg': {
format: '35mm',
film: 'colour negative, 35mm',
cell: 1.15,
spread: 3.1,
mix: CLUMPY,
halation: 0.45,
halo: 7,
haloRGB: [1, 0.18, 0.06],
},
'classic-chrome': {
format: '35mm',
film: 'colour slide, 35mm',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0.25,
halo: 6,
haloRGB: [1, 0.22, 0.08],
},
'classic-vivid': {
format: '35mm',
film: 'colour slide, vivid, 35mm',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0.3,
halo: 6,
haloRGB: [1, 0.2, 0.07],
},
eterna: {
format: '35mm',
film: 'motion-picture negative, 35mm',
cell: 1.1,
spread: 3.2,
mix: CLUMPY,
halation: 0.38,
halo: 6.5,
haloRGB: [1, 0.2, 0.07],
},
'mono-high-contrast': {
format: '35mm',
film: 'B&W, high contrast, 35mm',
cell: 1.25,
spread: 3.4,
mix: CLUMPY,
halation: 0.15,
halo: 4.5,
haloRGB: [1, 0.72, 0.55],
},
// --- 120: the pro emulsions whose reputation is the big negative ---
provia: {
format: '120',
film: 'Fuji Provia 100F, 120',
cell: 0.62,
spread: 2.5,
mix: SMOOTH,
halation: 0.18,
halo: 5.5,
haloRGB: [1, 0.24, 0.09],
},
velvia: {
format: '120',
film: 'Fuji Velvia 50, 120',
cell: 0.55,
spread: 2.35,
mix: SMOOTH,
halation: 0.3,
halo: 6,
haloRGB: [1, 0.2, 0.07],
},
astia: {
format: '120',
film: 'Fuji Astia 100F, 120',
cell: 0.62,
spread: 2.5,
mix: SMOOTH,
halation: 0.16,
halo: 5.5,
haloRGB: [1, 0.26, 0.1],
},
monochrome: {
format: '120',
film: 'Fuji Acros 100, 120',
cell: 0.72,
spread: 2.7,
mix: SMOOTH,
halation: 0.12,
halo: 4,
haloRGB: [1, 0.78, 0.62],
},
// --- digital ---
leica: SENSOR,
'leica-vivid': SENSOR,
none: SENSOR,
};
// The stock a base filter prints with. Every id is in the table, so the fallback
// is only there for a recipe that names a filter this build does not know.
export function grainStockFor(base: BaseFilter | string | null | undefined): GrainStock {
return (base && GRAIN_STOCKS[base as BaseFilter]) || SENSOR;
}
// The roll of film: one seed per page load, hashed into the noise's domain, so
// two visitors never print the same clumps while every render inside one
// session — the preview, the compare copy and the file — prints the same one.
export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math.random() * 43.9];
// Grain is sized against the PICTURE, never against the screen: one noise cell
// per 1/1080 of the picture's width, the size the GRAIN knob was tuned at. A
// stock scales that cell by its format and its emulsion (see GRAIN_STOCKS).
export const GRAIN_REF = 1080;
// The cell GRAIN_SKSL is handed for a picture of `pictureWidth` px: the stock's
// share of the reference, scaled by the SIZE knob (a percentage of the stock's
// own cell), and floored at one pixel because a cell smaller than the target's
// own pixel cannot be resolved — it prints as static instead of grain, which is
// aliasing, not a finer emulsion. The floor is handed to the shader too, so the
// field's own patch-to-patch mix cannot cross it.
export function grainCell(pictureWidth: number, stock: GrainStock, sizePct = 100, minCell = 1): number {
return Math.max((pictureWidth / GRAIN_REF) * stock.cell * (sizePct / 100), minCell);
}
// The clump count the panel READS OUT: how many clumps the design puts across an
// inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about
// the stock and the SIZE knob, never about one patch of the frame — the field
// mixes two lattices ±ZONE_SWING/2 either side of it, patch by patch (see
// GRAIN_SKSL) — and never about the screen, so the same stock reads the same
// number in the preview and in the file.
export const GRAIN_DPI = 300;
export function grainPerInch(stock: GrainStock, sizePct = 100): number {
return Math.round(GRAIN_DPI / (stock.cell * (sizePct / 100)));
}
// The halo radius in px for the same picture: measured in reference units too,
// so a 120 stock's tighter grain cannot leave it a 35mm-sized halo.
export function halationSigma(pictureWidth: number, stock: GrainStock): number {
return Math.max((pictureWidth / GRAIN_REF) * stock.halo, 0.6);
}
// `u` is the cell, `seed` this session's roll, `mix`/`spread` the stock's own
// structure. The three octaves go COARSER only: a finer one lands under the
// pixel, the clumping is lost (rho(1) 0.275 -> 0, measured) and the field is
// static again. `spread` holds the AMOUNT knob on the spread it was tuned with.
//
// The field is JITTERED CLUMPS, not value noise. Value noise prints the density
// of the cell's four corners, so every clump sits on a knot of one square grid,
// and that grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
// reads as diagonal lines. Here a clump lands at a random spot inside its cell
// instead: no two clumps share a grid, and the printed frame's off-origin
// autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The
// hash behind the jitter is sin-free for the same reason: a float sinus whose
// argument grows with the picture folds back on itself, a lattice of its own.
//
// Emulsion is not ONE grain size across the frame: the coating settles unevenly,
// so the clumps run coarser in patches and tighter in others. The same hash read
// as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ
// cells, turned off the axes and smoothed, because a step at a patch border
// would print as a seam — and that weight MIXES two lattices, ZONE_SWING/2
// either side of the design cell, instead of warping one. A cell that varies
// with position is what the eye reads as a smear: the phase of a lattice built
// on cell(pos) accumulates as d(phase)/ds = 1/cell - s*cell'/cell^2, and that
// second term grows with the distance s from the picture's own origin, so the
// clumps are stretched wherever the patch's cell runs — measured on one
// classic-neg paint (1024px, cell 1.09, 64 tiles): swing on, the tiles' lag-1
// correlation spans -0.065..0.747, clumps drawn out into smooth blotches;
// swing forced to 0, the SAME field's tiles span -0.057..0.045. Two FIXED
// lattices cannot do that — their phase is linear in the picture, so a patch can
// only change how much of each is printed, never how either is shaped, and
// their beat falls at 1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm
// cell: the pixel scale, not a line. Neither the mean nor the spread moves:
// both lattices carry grainClump's own 0.5685 and the mix is renormalised by
// sqrt(w^2+(1-w)^2), so a coarser patch prints bigger clumps — the tiles span
// 3.2x in lag-1 correlation at the preview's cell 1.70, against 1.14x with the
// swing off — not a brighter or a harder one, which is why the panel can read
// out one number while the frame carries a range.
export const ZONE_FREQ = 1 / 96;
export const ZONE_SWING = 0.4;
// ponytail: the field is evaluated once per lattice, so the grain pass costs
// 1.94x what the single warped lattice did (700px field, software CanvasKit,
// measured). One evaluation cannot hold two cell sizes, so this is the price of
// the mix; revisit only if a preview budget asks for the pass back.
export const GRAIN_SKSL = `
uniform float u;
uniform float mincell;
uniform vec2 seed;
uniform vec3 mixw;
uniform float spread;
float grainHash(vec2 q) {
vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// One clump per cell, at a random spot inside it; what is printed is the
// distance to the nearest, so the clump is a smooth mound, not one pixel of
// static.
float grainClump(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
float near = 4.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 c = g + vec2(grainHash(i + g), grainHash(i + g + vec2(19.19, 7.77))) - f;
near = min(near, dot(c, c));
}
}
return 1.0 - min(sqrt(near), 1.0);
}
// The patches of grain size: the same hash read slowly, smoothed so a border
// between two patches is a slope and not a step.
float grainZone(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * (3.0 - 2.0 * f);
return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x),
mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y);
}
// The stock's own field: three octaves, no two of them on the same grid.
// Each is turned to its own angle — 20, 47, 73 degrees — and sits on its own
// rung of the ladder, 1 / 0.53 / 0.29, off the dyadic 1 / 0.5 / 0.25 where
// the coarse octaves' cells land back on the fine one's and stack. q is in
// CELLS, so one text serves both lattices below.
float grainField(vec2 q) {
return grainClump(mat2(0.9397, -0.3420, 0.3420, 0.9397) * q) * mixw.x
+ grainClump(mat2(0.6820, -0.7314, 0.7314, 0.6820) * q * 0.53 + vec2(13.7, 7.3)) * mixw.y
+ grainClump(mat2(0.2924, -0.9563, 0.9563, 0.2924) * q * 0.29 + vec2(4.1, 27.9)) * mixw.z;
}
vec4 main(vec2 pos) {
vec2 p = pos.xy / max(u, 0.0001) + seed;
// This patch's weight, 0..1: the coarse lattice where the coating settled
// heavy, the fine one where it settled tight.
float w = grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)});
// The two lattices the patch mixes, never under the floor — under it the
// clumps are sub-pixel and print as static, which is aliasing, not a finer
// emulsion.
float fine = max(u * ${(1 - ZONE_SWING / 2).toFixed(2)}, mincell);
float coarse = max(u * ${(1 + ZONE_SWING / 2).toFixed(2)}, mincell);
float a = grainField(pos.xy / fine + seed);
float b = grainField(pos.xy / coarse + seed);
// Back onto the field the AMOUNT knob was calibrated on: clumps sit higher
// and tighter than the value noise they replace (mean 0.569 against 0.500,
// sigma 0.123 against 0.081, measured), so the sum is put back on that mean
// and that spread before the knob's own gain is applied. The two lattices
// are independent, so the blend carries sqrt(w^2+(1-w)^2) of one field's
// spread and that is taken back out with it — patch size must not read as
// patch contrast.
float n = (w * (a - 0.5685) + (1.0 - w) * (b - 0.5685))
* inversesqrt(w * w + (1.0 - w) * (1.0 - w)) * 0.52 + 0.5;
return vec4(vec3(clamp((n - 0.5) * spread + 0.5, 0.0, 1.0)), 1.0);
}
`;
// Flat uniform buffer for makeShader — declaration order above: u, mincell,
// seed, mix, spread. `minCell` is the floor grainCell() applied, so the field's
// own patch mix cannot take a lattice under it.
export function grainUniformArray(cell: number, stock: GrainStock, minCell = 1): number[] {
return [cell, minCell, GRAIN_SEED[0], GRAIN_SEED[1], stock.mix[0], stock.mix[1], stock.mix[2], stock.spread];
}
// HALATION: the highlight bleed the backing returns. Threshold first — a
// mid-tone never reaches the emulsion's scatter ceiling, so only what is going
// white bleeds — then the highlight is TINTED (the stock's halo colour, red for
// every colour emulsion) before the caller blurs it wide and screens it back.
export const HALATION_SKSL = `
uniform shader src;
uniform vec3 tint;
uniform float t0;
uniform float t1;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
return vec4(clamp(c.rgb * tint, 0.0, 1.0) * smoothstep(t0, t1, luma), c.a);
}
`;
// The print has to be near white before the backing sees anything: a touch
// higher than the HDF bloom's threshold, so a bright sky does not smear the
// frame the way a specular highlight does.
export const HALATION_T0 = 0.62;
export const HALATION_T1 = 0.92;
// What the halo is worth at GRAIN 10, over the stock's own strength: a screen
// blend of a blurred highlight is a strong move, so a halating stock lands
// under half alpha at full grain.
export const HALATION_MAX = 0.6;
export function halationUniformArray(stock: GrainStock): number[] {
return [...stock.haloRGB, HALATION_T0, HALATION_T1];
}