From d37671c359e655cde3d5d4f9ffe7edbe65aec3e3 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 16:47:19 +0700 Subject: [PATCH] web: print the grain zone by mixing two lattices, not by warping one cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coating's patches were drawn by varying the clump CELL with position: cell = u * (1 + (grainZone(p * ZONE_FREQ) - 0.5) * ZONE_SWING), the same slow value noise that picks the patch. A lattice whose cell varies with position smears instead of resizing: its phase accumulates as d(phase)/ds = 1/cell - s*cell'/cell^2, and c' is read along the radius from the picture's own origin, so the second term grows with the distance s from it and the clumps are drawn out wherever the patch's own cell runs. Measured on one classic-neg paint (1024px, cell 1.09, the app's own Overlay at alpha 0.5, 64 tiles): with the swing on, the tiles' lag-1 correlation of the raw frame spans -0.065..0.747 — clumps stretched into smooth blotches beside grain. The design's +-20% swing cannot do that: the SAME field with the swing forced to 0 spans -0.057..0.045 across its tiles, and the two-lattice field spans -0.055..0.052 (leica: -0.049..0.523 with the swing on, -0.068..0.024 at swing 0). The zone now MIXES two FIXED lattices, 0.8x and 1.2x the stock's own cell, weighted by that same patch noise. A fixed lattice's phase is linear in the picture, so a patch can only choose how much of each is printed, never how either is shaped — and the two lattices' beat falls at 1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm cell: the pixel scale, not a line the eye reads. The mix is renormalised by sqrt(w^2 + (1-w)^2), the share of one field's spread a two-field blend carries, so neither the mean nor the spread follows the patch: the same classic-neg field reads sd 24.85 against 24.91 and leica 23.74 against 23.74, tile by tile. The zone still reads what it is for. At preview scale (1600px, cell 1.70, 8x8 tiles of 200px) the mixed field's tiles span rho1 0.082..0.265, ratio 3.233, against 0.169..0.193, ratio 1.141, with the swing forced to 0 — classic-neg; leica 0.026..0.188, ratio 7.361, against 0.085..0.105, ratio 1.237. A coarser patch still prints coarser clumps; it just never prints a stretched one. Both copies carry it: docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts (the phone's, which the root web harness imports too). The field is evaluated once per lattice now, so the grain pass costs 1.94x what one lattice did — the ratio, not the absolute. Measured: _grain-zone2.cjs — the 64-tile lag-1 correlation spread above, three modules on one paint and one seed: swing 0.4 vs swing 0 vs the mix. _grain-zone-ck.cjs — the zone's own contribution at preview scale, zone on against the same module with the swing forced to 0, nothing else differing: classic-neg tile sd 24.21..24.86 (ratio 1.027), hf 0.769..0.937 (1.219), rho1 0.082..0.265 (3.233) against sd 29.04..32.95 (1.135), hf 0.834..0.858 (1.028), rho1 0.169..0.193 (1.141); leica rho1 0.026..0.188 (7.361) against 0.085..0.105 (1.237). The swing-off row's higher sd is the renormalisation of a blend with itself (a and b are one field at swing 0), not a contrast change in the shipped field. _grain-fft.cjs — same paint, same seeds, three rolls, 1024px: the mix's top spectral peak sits at 2.6px (classic-neg, cell 1.09) and 2.8..2.9px (leica, cell 1.00) against the warped field's 3.0/4.3/6.2px and 2.7/3.8/4.5px — both within a pixel of the clump cell, neither a coarse lattice. _grain-bench.cjs — 12.68s per 700px field (one lattice) against 24.60s (two), 1.94x on software CanvasKit. grain-size-test.cjs 17/0 — the SIZE rule and the readout on the module, both lattices floored at the target's own pixel, file rho1 0.673, preview rho1 0.003. grain-stock-test.cjs 53/0 on the deployed build — the stock table, the halation chain and its ordering, no page errors. grain-controls-test.cjs 20/0 on the deployed build — the patch claim still holds there: tile sd 56.58..65.48 (mean 61.9, max/min 1.157), tile mean spread 0.65, so a coarser patch is still not a brighter one. _grain-spectrum.cjs (app, deployed, 1600px) — residual autocorrelation peak 0.020..0.021, top peaks at 2.0px@20/110 and 2.5px@51. tsc: web `--noEmit` clean (the docker build runs it); the phone's scoped config reports its pre-change baseline, nothing in grainShader.ts. One honest number: the app-level spectral peak/median rises 4.6..6.1 to 10.2 (classic-neg, sim-classic-neg-g6/g10) because two fixed lattices beat where one warped lattice spread. It is 20x below the value-noise field this work replaced (29..35, tiling) and 5x below a lattice (50+), and it sits at 2px, the cell itself. ponytail: the field is evaluated once per lattice, so the grain pass costs 1.94x. One evaluation cannot hold two cell sizes; revisit only if a preview budget asks for the pass back. The 0.8/1.2 rungs (ZONE_SWING/2 either side) are one working set, not a search. Verified: `grain-stock-test.cjs` 53/0, `grain-controls-test.cjs` 20/0 and `_grain-spectrum.cjs` against the deployed build at localhost:8090; `grain-size-test.cjs` 17/0; `_grain-zone2.cjs`, `_grain-zone-ck.cjs`, `_grain-fft.cjs`, `_grain-bench.cjs` against the module built from HEAD, `inversesqrt` still the one call the shader needed to renormalise; web `tsc --noEmit` clean, phone scoped tsc down to its pre-existing errors. --- docker/frontend/shared/utils/grainShader.ts | 76 ++++++++++++++------- src/utils/grainShader.ts | 76 ++++++++++++++------- 2 files changed, 106 insertions(+), 46 deletions(-) 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));