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.
This commit is contained in:
2026-09-23 13:00:14 +07:00
parent c2a4740a3f
commit 64f1598076
2 changed files with 81 additions and 39 deletions
+38 -18
View File
@@ -204,35 +204,55 @@ export function halationSigma(pictureWidth: number, stock: GrainStock): number {
// structure. The three 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. `spread` holds the AMOUNT knob on the spread it was tuned with.
//
// The field is JITTERED CLUMPS, not value noise. Value noise prints the density
// of the cell's four corners, so every clump sits on a knot of one square grid,
// and that grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
// reads as diagonal lines. Here a clump lands at a random spot inside its cell
// instead: no two clumps share a grid, and the printed frame's off-origin
// autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The
// hash behind the jitter is sin-free for the same reason: a float sinus whose
// argument grows with the picture folds back on itself, a lattice of its own.
export const GRAIN_SKSL = `
uniform float u;
uniform vec2 seed;
uniform vec3 mixw;
uniform float spread;
float grainHash(vec2 q) {
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// Three uncorrelated hashes averaged: a bell, the way an emulsion's density
// swings, in place of the flat spread of one hash.
float grainDensity(vec2 q) {
return (grainHash(q) + grainHash(q + vec2(19.19, 7.77)) + grainHash(q + vec2(3.33, 41.71))) * 0.3333;
}
// Value noise: the density of the cell's four corners, smoothed. The bilinear
// over smoothed corners is what makes a clump instead of one pixel of static.
float grainNoise(vec2 p) {
// One clump per cell, at a random spot inside it; what is printed is the
// distance to the nearest, so the clump is a smooth mound, not one pixel of
// static.
float grainClump(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * (3.0 - 2.0 * f);
return mix(mix(grainDensity(i), grainDensity(i + vec2(1.0, 0.0)), f.x),
mix(grainDensity(i + vec2(0.0, 1.0)), grainDensity(i + vec2(1.0, 1.0)), f.x), f.y);
float near = 4.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 c = g + vec2(grainHash(i + g), grainHash(i + g + vec2(19.19, 7.77))) - f;
near = min(near, dot(c, c));
}
}
return 1.0 - min(sqrt(near), 1.0);
}
vec4 main(vec2 pos) {
// 20 degrees off the axes: no lattice shows through the picture.
vec2 p = mat2(0.9397, -0.3420, 0.3420, 0.9397) * (pos.xy / max(u, 0.0001)) + seed;
// The stock's own field: the base octave, then its clumps at 2x and 4x.
float n = grainNoise(p) * mixw.x
+ grainNoise(p * 0.5 + vec2(13.7, 7.3)) * mixw.y
+ grainNoise(p * 0.25 + vec2(4.1, 27.9)) * mixw.z;
vec2 q = pos.xy / max(u, 0.0001) + 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;
// 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;
return vec4(vec3(clamp((n - 0.5) * spread + 0.5, 0.0, 1.0)), 1.0);
}
`;
+43 -21
View File
@@ -13,36 +13,58 @@ export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math
// spread it was calibrated against — move one and the other has to move too.
export const GRAIN_REF = 1080;
// The grain is JITTERED CLUMPS, not value noise. Value noise prints the density
// of the cell's four corners, so every clump sits on a knot of one square grid,
// and that grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
// reads as diagonal lines. Here a clump lands at a random spot inside its cell
// instead: no two clumps share a grid, and the printed frame's off-origin
// autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The
// hash behind the jitter is sin-free for the same reason: a float sinus whose
// argument grows with the picture folds back on itself, a lattice of its own.
//
// The web port (docker/frontend/shared/utils/grainShader.ts) carries the same
// field with the stock's weights, cell and gain as uniforms; the 0.55/0.30/0.15
// and 2.95 below are its classic-chrome row, so the two print the same texture.
export const GRAIN_SKSL = `
uniform float u;
uniform vec2 seed;
float grainHash(vec2 q) {
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// Three uncorrelated hashes averaged: a bell, the way an emulsion's density
// swings, in place of the flat spread of one hash.
float grainDensity(vec2 q) {
return (grainHash(q) + grainHash(q + vec2(19.19, 7.77)) + grainHash(q + vec2(3.33, 41.71))) * 0.3333;
}
// Value noise: the density of the cell's four corners, smoothed. The bilinear
// over smoothed corners is what makes a clump instead of one pixel of static,
// which is all a cell smaller than the target's own pixel can be.
float grainNoise(vec2 p) {
// One clump per cell, at a random spot inside it; what is printed is the
// distance to the nearest, so the clump is a smooth mound, not one pixel of
// static, which is all a cell smaller than the target's own pixel could be.
float grainClump(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * (3.0 - 2.0 * f);
return mix(mix(grainDensity(i), grainDensity(i + vec2(1.0, 0.0)), f.x),
mix(grainDensity(i + vec2(0.0, 1.0)), grainDensity(i + vec2(1.0, 1.0)), f.x), f.y);
float near = 4.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 c = g + vec2(grainHash(i + g), grainHash(i + g + vec2(19.19, 7.77))) - f;
near = min(near, dot(c, c));
}
}
return 1.0 - min(sqrt(near), 1.0);
}
vec4 main(vec2 pos) {
// 20 degrees off the axes: no lattice shows through the picture.
vec2 p = mat2(0.9397, -0.3420, 0.3420, 0.9397) * (pos.xy / max(u, 0.0001)) + seed;
// The grain, then the clumps of grain above it at 2x and 4x 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.
float n = grainNoise(p) * 0.55
+ grainNoise(p * 0.5 + vec2(13.7, 7.3)) * 0.30
+ grainNoise(p * 0.25 + vec2(4.1, 27.9)) * 0.15;
vec2 q = pos.xy / max(u, 0.0001) + 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;
// 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;
// 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);
}