web: print the grain as clumps, not as static
GRAIN was one value hash per cell, sampled straight off the pixel grid: a square lattice at the picture's own axes, the same field in every session and in every photo, and — measured on a flat gray frame, where every deviation IS grain — a spread that was flat rather than emulsion-like (neighbour correlation rho(1) = 0.183, so half the noise was one pixel wide). The noise is now an emulsion. Three uncorrelated hashes are averaged into a density (a bell, the way an emulsion's density swings, instead of the flat spread of a single hash), that density is read as value noise with a smoothstep cell, and the result is three octaves of it — the cell, then 2x and 4x that cell — over a lattice turned 20 degrees off the picture's axes, so no grid shows through. Only coarser octaves: a finer one (tried 2.043x, 0.72px) goes sub-pixel and rho(1) falls to 0, i.e. back to static. The whole domain is offset by a seed rolled once per page load, so two visitors never print the same clumps while the preview, the compare copy and the file of one session still print the same roll. Same flat 3000x2000 frame, preview render 1600x1067, GRAIN 10: sigma 52.53 -> 50.15 (the 2.95 gain keeps the spread the AMOUNT knob was tuned against, since the repo notes the slider was calibrated on the old field), rho(1) 0.183 -> 0.275, and the three channels still move together — the 6.9 of sigma 50 that is left over is the Overlay blend meeting the frame's own tint, the plane itself is one gray value in all three. Re-rendering the same frame draws the same clumps byte for byte; a reload rolls a new seed and a new field at the same strength.
This commit is contained in:
@@ -109,6 +109,11 @@ function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
||||
disposeAll([item]);
|
||||
}
|
||||
|
||||
// The grain's roll of film: one seed per page load, hashed into the noise's
|
||||
// domain so two runs never print the same clumps, while every render inside one
|
||||
// session — the preview, the compare copy and the file — prints the same one.
|
||||
const GRAIN_SEED = [Math.random() * 61.7, Math.random() * 43.9] as const;
|
||||
|
||||
function createSurface(width: number, height: number) {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
@@ -458,6 +463,11 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
}
|
||||
|
||||
// 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
|
||||
// Analog in character, not digital: clumps rather than one pixel of static,
|
||||
// a bell distribution rather than a flat one, a turned lattice, and this
|
||||
// session's own seed. Cell = width / 1080, so its size stays the same
|
||||
// RELATIVE to the picture as in the preview that tuned it, and a small
|
||||
// frame clamps to the single pixel it can still resolve.
|
||||
const grainAmount = Math.min(
|
||||
10,
|
||||
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
||||
@@ -468,22 +478,36 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
grainPaint.setAlphaf(grainAmount / 20);
|
||||
const noiseEffect = own(Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
uniform vec2 seed;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
// 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;
|
||||
}
|
||||
float grainNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
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);
|
||||
}
|
||||
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, 1.0)) + seed;
|
||||
// The grain, then the clumps of grain above it (2x and 4x the cell —
|
||||
// sub-cells would alias instead of clumping). One cell alone reads as
|
||||
// static; three scales read as an emulsion.
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
`));
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080, GRAIN_SEED[0], GRAIN_SEED[1]])) : null;
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
||||
|
||||
Reference in New Issue
Block a user