Ctrl K
ComunidadGuías…

scripts/gen-dither.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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})`,
        );
    });