From 64f159807667c274b29df008527cb974b023c732 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 13:00:14 +0700 Subject: [PATCH] web: print the grain as jittered clumps, not as value noise on a grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/shared/utils/grainShader.ts | 56 ++++++++++++------ src/utils/grainShader.ts | 64 ++++++++++++++------- 2 files changed, 81 insertions(+), 39 deletions(-) diff --git a/docker/frontend/shared/utils/grainShader.ts b/docker/frontend/shared/utils/grainShader.ts index 9fef264..f5e7330 100644 --- a/docker/frontend/shared/utils/grainShader.ts +++ b/docker/frontend/shared/utils/grainShader.ts @@ -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); } `; diff --git a/src/utils/grainShader.ts b/src/utils/grainShader.ts index 1414f32..9c916b6 100644 --- a/src/utils/grainShader.ts +++ b/src/utils/grainShader.ts @@ -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); }