// MONOCHROME GRAIN — one roll of film, shared by the live camera preview, the // library preview, the web port and the file the phone writes. // // The seed is rolled ONCE per app launch and hashed into the noise's domain, so // every render inside one session — the preview, the compare copy, the export — // prints the same clumps, while the next launch (or the next visitor, on the // web) prints a different roll. Film, not a fixed pattern. export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math.random() * 43.9]; // Grain is sized against the PICTURE, never against the screen: one noise cell // per 1/1080 of the picture's width, the size the GRAIN knob was tuned at in the // first place. GRAIN_SKSL's 2.95 gain is what holds the AMOUNT slider on the // 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. // // Emulsion is not ONE grain size across the frame: the coating settles unevenly, // so the clumps run coarser in patches and tighter in others. The same hash read // as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ // cells, turned off the axes and smoothed, because a step at a patch border // would print as a seam — and that weight MIXES two lattices, ZONE_SWING/2 // either side of the design cell, instead of warping one. A cell that varies // with position is what the eye reads as a smear: the phase of a lattice built // on cell(pos) accumulates as d(phase)/ds = 1/cell - s*cell'/cell^2, and that // second term grows with the distance s from the picture's own origin, so the // clumps are stretched wherever the patch's cell runs — measured on one // classic-neg paint (1024px, cell 1.09, 64 tiles): swing on, the tiles' lag-1 // correlation spans -0.065..0.747, clumps drawn out into smooth blotches; // swing forced to 0, the SAME field's tiles span -0.057..0.045. Two FIXED // lattices cannot do that — their phase is linear in the picture, so a patch can // only change how much of each is printed, never how either is shaped, and // their beat falls at 1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm // cell: the pixel scale, not a line. Neither the mean nor the spread moves: // both lattices carry grainClump's own 0.5685 and the mix is renormalised by // sqrt(w^2+(1-w)^2), so a coarser patch prints bigger clumps — the tiles span // 3.2x in lag-1 correlation at the preview's cell 1.70, against 1.14x with the // swing off — not a brighter or a harder one, which is why the panel can read // out one number while the frame carries a range. export const ZONE_FREQ = 1 / 96; export const ZONE_SWING = 0.4; // ponytail: the field is evaluated once per lattice, so the grain pass costs // 1.94x what the single warped lattice did (700px field, software CanvasKit, // measured). One evaluation cannot hold two cell sizes, so this is the price of // the mix; revisit only if a preview budget asks for the pass back. export const GRAIN_SKSL = ` uniform float u; uniform float mincell; uniform vec2 seed; float grainHash(vec2 q) { 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); } // 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; 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); } // The patches of grain size: the same hash read slowly, smoothed so a border // between two patches is a slope and not a step. float grainZone(vec2 p) { vec2 i = floor(p); vec2 f = p - i; f = f * f * (3.0 - 2.0 * f); return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x), mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y); } // 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. q is in CELLS, so one text serves both lattices below. float grainField(vec2 q) { return 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; } vec4 main(vec2 pos) { vec2 p = pos.xy / max(u, 0.0001) + seed; // This patch's weight, 0..1: the coarse lattice where the coating settled // heavy, the fine one where it settled tight. float w = grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)}); // The two lattices the patch mixes, never under the floor — under it the // clumps are sub-pixel and print as static, which is aliasing, not a finer // emulsion. float fine = max(u * ${(1 - ZONE_SWING / 2).toFixed(2)}, mincell); float coarse = max(u * ${(1 + ZONE_SWING / 2).toFixed(2)}, mincell); float a = grainField(pos.xy / fine + seed); float b = grainField(pos.xy / coarse + seed); // 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. The two lattices // are independent, so the blend carries sqrt(w^2+(1-w)^2) of one field's // spread and that is taken back out with it — patch size must not read as // patch contrast. float n = (w * (a - 0.5685) + (1.0 - w) * (b - 0.5685)) * inversesqrt(w * w + (1.0 - w) * (1.0 - w)) * 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); } `; // The cell GRAIN_SKSL must be handed for a render of a picture occupying // `pictureWidth` canvas px: the camera's composition region, the library // photo's window on screen, or the full width of the file being exported. // // `minCell` is the floor under which the target cannot resolve a cell at all. // An export passes one output pixel (the default): a 900px file's 0.83px cell // prints as one pixel of static, so the cell stops at the pixel it can still // print. A PREVIEW must pass one DEVICE pixel — 1 / PixelRatio.get() — because // canvas units are dp: on a 3x phone the old floor of a whole dp pinned a // 390dp-wide preview to a 3 device-pixel cell, 2.8x coarser than the file's // grain, which is the preview-vs-file mismatch all over again, only that way. // // `sizePct` is the SIZE knob: a percentage of the cell above, which is the // phone's own classic-chrome cell (see GRAIN_SKSL) — the web port scales its // stock's own cell by the same number. It is handed to the shader as a floor // too, so neither lattice the patches mix can fall under it. export const grainCell = (pictureWidth: number, minCell = 1, sizePct = 100) => Math.max((pictureWidth / GRAIN_REF) * (sizePct / 100), minCell); // The clump count the panel READS OUT: how many clumps the design puts across an // inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about // the cell and the SIZE knob, never about one patch of the frame — the field // mixes two lattices ±ZONE_SWING/2 either side of it, patch by patch — and // never about the screen, so the same cell reads the same number in the // preview and in the file. export const GRAIN_DPI = 300; export const grainPerInch = (sizePct = 100) => Math.round(GRAIN_DPI / (sizePct / 100));