Files
RecipesCam/docker/frontend/shared/utils/grainShader.ts
T
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00

338 lines
14 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 swing 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
// swings ±ZONE_SWING/2 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 each patch swings its own cell size by half of
// ZONE_SWING either way. Nothing here moves the field's mean: a coarser patch
// prints bigger clumps, not a brighter 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;
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);
}
vec4 main(vec2 pos) {
vec2 p = pos.xy / max(u, 0.0001) + seed;
// This patch's own cell: the design cell swung by its zone, never under the
// floor — under it the clumps are sub-pixel and print as static, which is
// aliasing, not a finer emulsion.
float cell = max(u * (1.0 + (grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)}) - 0.5) * ${ZONE_SWING}), mincell);
vec2 q = pos.xy / cell + 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, mincell,
// seed, mix, spread. `minCell` is the floor grainCell() applied, so the field's
// own patch swing cannot take a cell 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];
}