// 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 = { // --- 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]; }