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); }