Commit Graph

2 Commits

Author SHA1 Message Date
3dtours 64f1598076 web: print the grain as jittered clumps, not as value noise on a grid
The field was 20-degree-rotated value noise on a square lattice, three dyadic
octaves at 1 / 0.5 / 0.25 and a sin hash behind it. Value noise prints the
density of the cell's four corners, so every clump sat on a knot of one grid,
and the grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
read as diagonal lines. Measured on the old field through the app
(`grain-analog-test.cjs`, `_grain-spectrum.cjs`): off-origin autocorrelation
peak 0.32-0.40, spectral peak/median 29-35, top peaks at 5.4-7.8px.

The field is jittered clumps now, in both copies
(docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts).
A clump lands at a random spot inside its cell — Worley F1 over the 3x3
neighbourhood, `grainClump` — so no two clumps share a grid, and what is
printed is the distance to the nearest: a smooth mound, not one pixel of
static. The hash behind the jitter is sin-free (Hoskins' `p3 = fract(vec3 *
0.1031); p3 += dot(p3, p3.yzx + 33.33)`), because a float sinus whose argument
grows with the picture folds back on itself and is a lattice of its own. The
three octaves are turned to their own angles — 20, 47, 73 degrees — and sit on
0.53 and 0.29 off the dyadic 1 / 0.5 / 0.25, where a coarse octave's cells land
back on the fine one's and stack.

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 spread, `n = (n - 0.5685) * 0.52 + 0.5`, before the AMOUNT
knob's own gain. The web keeps its uniforms (cell, seed, per-stock weights,
spread); the phone keeps 0.55/0.30/0.15 and 2.95, which are the web's
classic-chrome row, so the two print the same texture.

Measured on the deployed build: `grain-stock-test.cjs` 53/0 — 35mm still
coarser than 120, the halation chain intact per stock, the "halation follows
the stock" ordering intact, the field still clumped (r1 0.336-0.506) and still
surviving a 2x downscale. `_grain-spectrum.cjs` residual autocorrelation peak
0.02 (was 0.32-0.40), spectral peak/median 7.0-12.7 (was 29-35), top peaks
only at 2-3px periods, the cell scale. `_grain-ck.cjs` at the preview cell
(U=1.481/1.704): rho1 0.093/0.177 against the old 0.505/0.586, acPeak
0.02/0.028 against 0.38/0.467, peak/med 10.1/11.2 against 76/46.5, top peaks
2.0-2.9px against 5.4-7.8px. `grain-size-test.cjs` 12/0.
`grain-analog-test.cjs` 7/0 — with the TEMP pair on AUTO, the field's channel
split measures 0 at a cast of 0, so the grain is exactly monochrome and no
neighbour lag carries structure (max |rho1..8| 0.118).

One honest number: the preview's apparent strength at GRAIN 10 is ~25% higher
than the old field's (sigma 61.3 against 47.9 in that harness), and that is the
PREVIEW, not the field. Step 9 of src/engine/exportEngine.ts sharpens the
preview at alpha 0.5, and the clumps now sit at the cell (~1.7px) instead of on
the old ~5px lattice, so that sharpen bites harder. The field's own composite
spread is 17% UNDER the old one at the same geometry — lab sdRaw 24.9 against
30.0, and geometry-flat where the old one was ~30 everywhere. The 0.52
normalisation was kept rather than re-tuned upward to the app-visible number:
the export path does not carry that sharpen, and a grain tuned to it would
print too strong.

ponytail: the octave angles and the 0.53/0.29 rungs are one working set, not a
search. Re-tune only if a stock's cell is changed again.

Verified: 53/0 + 12/0 + 7/0 grain harnesses, `_grain-spectrum.cjs` and
`_grain-ck.cjs` A/B against the field built from HEAD, `sims-test.cjs` 31/0,
`fx-mono-test.cjs` 15/0, no page errors.
2026-09-23 13:00:14 +07:00
3dtours 4795a2a0ee 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.
2026-09-23 11:46:45 +07:00