web: give each stock its own grain, and a halo where it belongs
The landing card sells "35mm & 120 Film Grain — authentic grain structures plus halation bloom, tuned per stock rather than one global overlay", and the engine printed one field for everything: a width/1080 cell, one spread, no bleed. `shared/utils/grainShader.ts` (new, the web fork of the phone's src/utils/grainShader.ts) now carries the stock table — format, cell, spread, octave mix, halation, halo radius, halo tint — and `grainStockFor( recipe.baseFilter)` picks the one this recipe prints. FORMAT. 35mm cells are the 1.0 reference the knob was tuned at (classic-negative 1.15, B&W high contrast 1.25); the 120 emulsions sit at 0.55-0.72 and open their base octave (mix 0.55/0.30/0.15 -> 0.62/0.26/0.12), so the same knob prints a finer, smoother texture on the bigger negative. Measured on a flat 128 grey at a 3200px preview (cells 3.41px vs 1.63px), GRAIN 10, luma residual against a 17px box: 35mm CLASSIC NEGIPES r1 0.793 keeps 0.976 35mm CLASSIC CHRIPES r1 0.744 keeps 0.931 35mm B&W HIGH CONTRAST r1 0.812 keeps 1.002 120 PROVIPES r1 0.423 keeps 0.728 120 VELVIPES r1 0.313 keeps 0.672 120 ACRIPES r1 0.543 keeps 0.794 r1 is the lag-1 autocorrelation of the residual — how coarse the clumps are — and "keeps" is the residual sd after a 2x box downscale over the sd before, i.e. how much of its texture a print at half size holds on to. Every 35mm stock beats every 120 stock on both, and VELVIPES (0.55 cell) is finer than PROVIPES (0.62) inside 120, so the format is a look and not a label. Raw sd is NOT the measure: the knob drives one alpha for every stock, so a stock's amount follows its cell and mix rather than the order anyone assumed. HALATION. A new pass 6b thresholds the print (T0 0.62, T1 0.92), tints what is left the stock's halo colour — red, because red is the light the emulsion passes and the backing returns — blurs it at the stock's own radius and screens it back at `halation * grain/10 * 0.6`. Riding the GRAIN knob keeps today's contract: OFF is still a clean frame, the OFF/WEAK/STRONG chips still mean 0/3/6, and a sensor stock carries none at any amount. Measured R-B of the ring around a white block on black, GRAIN 6 minus GRAIN 0 (mean, and the ring's reddest pixel): CLASSIC NEGIPES 7.87 (peak 0 -> 14) VELVIPES 3.71 (0 -> 13) CLASSIC CHRIPES 2.91 (0 -> 10) PROVIPES 2.01 (0 -> 7) B&W HIGH CONTRAST 0.61 (0 -> 5) ACRIPES 0.24 (0 -> 3) LC STREETLIFE CLASSIC 0.09 (0 -> 0) which is the table's own halation column (0.45 > 0.30 > 0.25 > 0.18 > 0.15 > 0.12) in order: the colour negative halates hardest, the B&W emulsions barely, Acros — no colour layer to bleed — least of all, and the sensor not at all. The colour negative's own grade leaves its ring blue at GRAIN 0 (-5.96 there), so the statistic is the change and not the absolute channel; in a crop of the block the bloom itself is unmistakable at GRAIN 6 and 10 and absent at 0. GRAIN_SEED moves here from exportEngine.ts so the roll is still one per page load, and still shared by the preview, the compare copy and the file. Checked: tsc --noEmit clean; grain-stock-test 53 PASS / 0 FAIL; sims-test 31/0, fx-mono-test 15/0, grain-size-test 12/0, grain-analog-test 7/0, wm-font-test green. ponytail: halation rides the GRAIN knob instead of a control of its own, since the card promises no more than "tuned per stock". Add a HALATION chip when the phone grows one. ponytail: `grainCell`'s 1px floor is the aliasing guard, and it also hides the format ratio under a ~1600px preview. Nothing to add: the exported file is always wide enough, and the harness renders at 3200 to see it.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// 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.
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user