// 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, 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. export function grainCell(pictureWidth: number, stock: GrainStock, minCell = 1): number { return Math.max((pictureWidth / GRAIN_REF) * stock.cell, minCell); } // 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. export const GRAIN_SKSL = ` uniform float u; uniform vec2 seed; uniform vec3 mixw; uniform float spread; float grainHash(vec2 q) { return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); } // Three uncorrelated hashes averaged: a bell, the way an emulsion's density // swings, in place of the flat spread of one hash. float grainDensity(vec2 q) { return (grainHash(q) + grainHash(q + vec2(19.19, 7.77)) + grainHash(q + vec2(3.33, 41.71))) * 0.3333; } // Value noise: the density of the cell's four corners, smoothed. The bilinear // over smoothed corners is what makes a clump instead of one pixel of static. float grainNoise(vec2 p) { vec2 i = floor(p); vec2 f = p - i; f = f * f * (3.0 - 2.0 * f); return mix(mix(grainDensity(i), grainDensity(i + vec2(1.0, 0.0)), f.x), mix(grainDensity(i + vec2(0.0, 1.0)), grainDensity(i + vec2(1.0, 1.0)), f.x), f.y); } vec4 main(vec2 pos) { // 20 degrees off the axes: no lattice shows through the picture. vec2 p = mat2(0.9397, -0.3420, 0.3420, 0.9397) * (pos.xy / max(u, 0.0001)) + seed; // The stock's own field: the base octave, then its clumps at 2x and 4x. float n = grainNoise(p) * mixw.x + grainNoise(p * 0.5 + vec2(13.7, 7.3)) * mixw.y + grainNoise(p * 0.25 + vec2(4.1, 27.9)) * mixw.z; 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, seed, mix, // spread. export function grainUniformArray(cell: number, stock: GrainStock): number[] { return [cell, 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]; }