64f1598076
The field was 20-degree-rotated value noise on a square lattice, three dyadic octaves at 1 / 0.5 / 0.25 and a sin hash behind it. Value noise prints the density of the cell's four corners, so every clump sat on a knot of one grid, and the grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye read as diagonal lines. Measured on the old field through the app (`grain-analog-test.cjs`, `_grain-spectrum.cjs`): off-origin autocorrelation peak 0.32-0.40, spectral peak/median 29-35, top peaks at 5.4-7.8px. The field is jittered clumps now, in both copies (docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts). A clump lands at a random spot inside its cell — Worley F1 over the 3x3 neighbourhood, `grainClump` — so no two clumps share a grid, and what is printed is the distance to the nearest: a smooth mound, not one pixel of static. The hash behind the jitter is sin-free (Hoskins' `p3 = fract(vec3 * 0.1031); p3 += dot(p3, p3.yzx + 33.33)`), because a float sinus whose argument grows with the picture folds back on itself and is a lattice of its own. The three octaves are turned to their own angles — 20, 47, 73 degrees — and sit on 0.53 and 0.29 off the dyadic 1 / 0.5 / 0.25, where a coarse octave's cells land back on the fine one's and stack. 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 spread, `n = (n - 0.5685) * 0.52 + 0.5`, before the AMOUNT knob's own gain. The web keeps its uniforms (cell, seed, per-stock weights, spread); the phone keeps 0.55/0.30/0.15 and 2.95, which are the web's classic-chrome row, so the two print the same texture. Measured on the deployed build: `grain-stock-test.cjs` 53/0 — 35mm still coarser than 120, the halation chain intact per stock, the "halation follows the stock" ordering intact, the field still clumped (r1 0.336-0.506) and still surviving a 2x downscale. `_grain-spectrum.cjs` residual autocorrelation peak 0.02 (was 0.32-0.40), spectral peak/median 7.0-12.7 (was 29-35), top peaks only at 2-3px periods, the cell scale. `_grain-ck.cjs` at the preview cell (U=1.481/1.704): rho1 0.093/0.177 against the old 0.505/0.586, acPeak 0.02/0.028 against 0.38/0.467, peak/med 10.1/11.2 against 76/46.5, top peaks 2.0-2.9px against 5.4-7.8px. `grain-size-test.cjs` 12/0. `grain-analog-test.cjs` 7/0 — with the TEMP pair on AUTO, the field's channel split measures 0 at a cast of 0, so the grain is exactly monochrome and no neighbour lag carries structure (max |rho1..8| 0.118). One honest number: the preview's apparent strength at GRAIN 10 is ~25% higher than the old field's (sigma 61.3 against 47.9 in that harness), and that is the PREVIEW, not the field. Step 9 of src/engine/exportEngine.ts sharpens the preview at alpha 0.5, and the clumps now sit at the cell (~1.7px) instead of on the old ~5px lattice, so that sharpen bites harder. The field's own composite spread is 17% UNDER the old one at the same geometry — lab sdRaw 24.9 against 30.0, and geometry-flat where the old one was ~30 everywhere. The 0.52 normalisation was kept rather than re-tuned upward to the app-visible number: the export path does not carry that sharpen, and a grain tuned to it would print too strong. ponytail: the octave angles and the 0.53/0.29 rungs are one working set, not a search. Re-tune only if a stock's cell is changed again. Verified: 53/0 + 12/0 + 7/0 grain harnesses, `_grain-spectrum.cjs` and `_grain-ck.cjs` A/B against the field built from HEAD, `sims-test.cjs` 31/0, `fx-mono-test.cjs` 15/0, no page errors.
296 lines
11 KiB
TypeScript
296 lines
11 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, 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.
|
|
//
|
|
// 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.
|
|
export const GRAIN_SKSL = `
|
|
uniform float u;
|
|
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);
|
|
}
|
|
vec4 main(vec2 pos) {
|
|
vec2 q = pos.xy / max(u, 0.0001) + seed;
|
|
// 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.
|
|
float n = 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;
|
|
// 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.
|
|
n = (n - 0.5685) * 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, 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];
|
|
}
|