88cff5ca87bc7edc68b65404f61990ff742ec106
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d37671c359 |
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.
|
||
|
|
0e9f78bd5e |
web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).
Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.
A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.
SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.
Measured:
grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
522/INCH (66.56: under the preview's pixel floor what is printed is static,
not finer grain); the region claim on an 8x8 grid of the flat frame gives
tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
patch is not a brighter one.
grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
module itself: 200% doubles the cell, 50% halves it, the output pixel still
floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
like every other caller.
_grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
_grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
swing forced to 0, peak/median 32.3 against 27.6.
_grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
selectors moved to the strip).
tsc: web clean; the phone's scoped config reports exactly the pre-change
baseline (Viewfinder.tsx's own errors, none new).
ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
|
||
|
|
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. |
||
|
|
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. |