scripts/gen-dither.js
Rama main · réplica de lectura del repositorio original.
// Generate a 256x256 zero-mean noise PNG for gradient dithering.
//
// Strategy
// --------
// White noise dithering kills banding mathematically but the human eye reads
// low-frequency clumps in white noise as "digital noise". For a *premium*
// feel we want the grain to look like silver halide film grain — fine,
// dense, no clumps. The fix is to high-pass-filter the white noise:
// subtract a blurred copy of the noise from itself, which suppresses
// low-frequency content and leaves only the high-frequency scatter. The
// result is a cheap approximation of blue noise, which is what dithering
// algorithms in printers/photo software use precisely because it looks
// like grain rather than static.
//
// Amplitude is set so soft-light blend yields ~±14/255 perturbation in the
// player's dark gradient zones — enough to break visible bands even for a
// trained eye, while the high-pass character keeps the grain organic.
//
// The PNG is 256x256: large enough that the tile boundary isn't visible
// on a phone screen (~4×9 tiles on a 1080×2400 device) and small enough
// to bundle/decode cheaply.
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const SIZE = 256;
const CENTER = 128;
const RAW_AMPLITUDE = 64; // pre-filter amplitude (white noise span)
const SEED = 0xc01dface;
const BLUR_RADIUS = 1; // 3x3 box blur for low-frequency removal
const HIGHPASS_STRENGTH = 0.92; // how much of the blur to subtract back out
const mulberry32 = (seed) => {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const rand = mulberry32(SEED);
// Step 1: white noise (signed, centered on 0)
const raw = new Float32Array(SIZE * SIZE);
for (let i = 0; i < raw.length; i++) {
raw[i] = (rand() - 0.5) * 2 * RAW_AMPLITUDE;
}
// Step 2: box-blur the noise to extract low frequencies. Toroidal wrap so
// the resulting noise is tileable (no seams at tile edges).
const blurred = new Float32Array(SIZE * SIZE);
const diameter = BLUR_RADIUS * 2 + 1;
const kernelArea = diameter * diameter;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
let sum = 0;
for (let dy = -BLUR_RADIUS; dy <= BLUR_RADIUS; dy++) {
const ny = (y + dy + SIZE) % SIZE;
for (let dx = -BLUR_RADIUS; dx <= BLUR_RADIUS; dx++) {
const nx = (x + dx + SIZE) % SIZE;
sum += raw[ny * SIZE + nx];
}
}
blurred[y * SIZE + x] = sum / kernelArea;
}
}
// Step 3: high-pass = raw - α * blurred. This kills the patchy
// low-frequency clumps and leaves the fine-grain detail. Compute the
// actual amplitude so we can normalize to the target perturbation range.
const filtered = new Float32Array(SIZE * SIZE);
let maxAbs = 0;
for (let i = 0; i < raw.length; i++) {
filtered[i] = raw[i] - HIGHPASS_STRENGTH * blurred[i];
const a = Math.abs(filtered[i]);
if (a > maxAbs) maxAbs = a;
}
// Step 4: scale to a target peak amplitude. With the native Paint
// dithering now active in react-native-linear-gradient (patched), the JS
// overlay only needs to add a subtle film-grain character on top of the
// already-clean gradient. A target peak of ±18 in 0..255 yields ~±9/255
// of perturbation in dark areas — enough to read as organic grain but
// well short of "noisy". Bump higher if the native dither path turns out
// to be a no-op on your device and bands still poke through.
const TARGET_PEAK = 18;
const scale = TARGET_PEAK / maxAbs;
const png = new PNG({ colorType: 6, height: SIZE, width: SIZE });
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const v = Math.max(0, Math.min(255, Math.round(CENTER + filtered[y * SIZE + x] * scale)));
const idx = (y * SIZE + x) * 4;
png.data[idx + 0] = v;
png.data[idx + 1] = v;
png.data[idx + 2] = v;
png.data[idx + 3] = 255;
}
}
const outPath = path.resolve(process.argv[2]);
png.pack()
.pipe(fs.createWriteStream(outPath))
.on('finish', () => {
console.log(
`wrote ${outPath} (${SIZE}x${SIZE}, high-pass blue noise, peak ±${TARGET_PEAK} around ${CENTER})`,
);
});