diff --git a/docker/frontend/shared/utils/grainShader.ts b/docker/frontend/shared/utils/grainShader.ts index 708cda8..d16b353 100644 --- a/docker/frontend/shared/utils/grainShader.ts +++ b/docker/frontend/shared/utils/grainShader.ts @@ -191,7 +191,7 @@ export const GRAIN_REF = 1080; // 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. +// 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); } @@ -199,9 +199,9 @@ export function grainCell(pictureWidth: number, stock: GrainStock, sizePct = 100 // 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. +// 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 { @@ -232,12 +232,30 @@ export function halationSigma(pictureWidth: number, stock: GrainStock): number { // 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. +// 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; @@ -275,32 +293,44 @@ export const GRAIN_SKSL = ` 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 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; + // 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. - n = (n - 0.5685) * 0.52 + 0.5; + // 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 swing cannot take a cell under it. +// 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]; } diff --git a/src/utils/grainShader.ts b/src/utils/grainShader.ts index d0fdbec..46dd18d 100644 --- a/src/utils/grainShader.ts +++ b/src/utils/grainShader.ts @@ -30,12 +30,30 @@ export const GRAIN_REF = 1080; // 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. +// 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; @@ -71,27 +89,38 @@ export const GRAIN_SKSL = ` 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 grain, then the clumps of grain above it at 0.53x and 0.29x the cell. + // The 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 — and each is turned to its own angle, 20 / 47 / 73 degrees, 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) * 0.55 + + grainClump(mat2(0.6820, -0.7314, 0.7314, 0.6820) * q * 0.53 + vec2(13.7, 7.3)) * 0.30 + + grainClump(mat2(0.2924, -0.9563, 0.9563, 0.2924) * q * 0.29 + vec2(4.1, 27.9)) * 0.15; + } 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 grain, then the clumps of grain above it at 0.53x and 0.29x the cell. - // The 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 — and each is turned to its own angle, 20 / 47 / 73 degrees, 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) * 0.55 - + grainClump(mat2(0.6820, -0.7314, 0.7314, 0.6820) * q * 0.53 + vec2(13.7, 7.3)) * 0.30 - + grainClump(mat2(0.2924, -0.9563, 0.9563, 0.2924) * q * 0.29 + vec2(4.1, 27.9)) * 0.15; + // 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. - n = (n - 0.5685) * 0.52 + 0.5; + // 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; // Scaled so the AMOUNT knob keeps the spread it was tuned with. return vec4(vec3(clamp((n - 0.5) * 2.95 + 0.5, 0.0, 1.0)), 1.0); } @@ -112,15 +141,16 @@ export const GRAIN_SKSL = ` // `sizePct` is the SIZE knob: a percentage of the cell above, which is the // phone's own classic-chrome cell (see GRAIN_SKSL) — the web port scales its // stock's own cell by the same number. It is handed to the shader as a floor -// too, so the field's patch-to-patch swing cannot cross it. +// too, so neither lattice the patches mix can fall under it. export const grainCell = (pictureWidth: number, minCell = 1, sizePct = 100) => Math.max((pictureWidth / GRAIN_REF) * (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 cell and the SIZE knob, never about one patch of the frame — the field -// swings ±ZONE_SWING/2 patch by patch — and never about the screen, so the same -// cell reads the same number in the preview and in the file. +// mixes two lattices ±ZONE_SWING/2 either side of it, patch by patch — and +// never about the screen, so the same cell reads the same number in the +// preview and in the file. export const GRAIN_DPI = 300; export const grainPerInch = (sizePct = 100) => Math.round(GRAIN_DPI / (sizePct / 100));