web: print the grain zone by mixing two lattices, not by warping one cell

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.
This commit is contained in:
2026-09-23 16:47:19 +07:00
parent 0e9f78bd5e
commit d37671c359
2 changed files with 106 additions and 46 deletions
+53 -23
View File
@@ -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));