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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user