replace noise with tileable perlin; use pixel-space sampling for fixed scale

This commit is contained in:
Eric Smith 2026-05-20 21:37:07 -04:00
parent 9adba9d6c2
commit d636d0f847
4 changed files with 170 additions and 1 deletions

153
generate_noise.py Normal file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Generate a tileable 100×100 Perlin noise texture for V Panel."""
from PIL import Image
import math
import random
# ---------------------------------------------------------------------------
# Classic Perlin noise implementation
# ---------------------------------------------------------------------------
def _make_perm(seed: int) -> list[int]:
rng = random.Random(seed)
perm = list(range(256))
rng.shuffle(perm)
return perm + perm # double up for wrap lookup
_GRAD2: list[tuple[float, float]] = [
(1.0, 0.0), (-1.0, 0.0),
(0.0, 1.0), (0.0, -1.0),
(1.0, 1.0), (-1.0, 1.0),
(1.0, -1.0), (-1.0, -1.0),
]
def _fade(t: float) -> float:
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
def _lerp(a: float, b: float, t: float) -> float:
return a + t * (b - a)
def _grad(hash: int, x: float, y: float) -> float:
gx, gy = _GRAD2[hash & 7]
return gx * x + gy * y
def perlin2d(
x: float, y: float,
perm: list[int],
period: int,
) -> float:
"""Evaluate Perlin noise at (x, y) with wrapping period `period`.
Coordinates are expected in [0, period). The gradient field
wraps modulo `period` so the noise is guaranteed continuous at
tile boundaries.
"""
period_i = int(round(period))
ix_base = int(math.floor(x))
iy_base = int(math.floor(y))
fx = x - math.floor(x)
fy = y - math.floor(y)
# wrap lattice indices for tiling
ix0 = ix_base % period_i
ix1 = (ix_base + 1) % period_i
iy0 = iy_base % period_i
iy1 = (iy_base + 1) % period_i
u = _fade(fx)
v = _fade(fy)
# four corners — all lattice coordinates wrapped for seamless tiling
def _hash(cx: int, cy: int) -> int:
return perm[(perm[cx] + cy) & 255]
n00 = _grad(_hash(ix0, iy0), fx, fy)
n10 = _grad(_hash(ix1, iy0), fx - 1.0, fy)
n01 = _grad(_hash(ix0, iy1), fx, fy - 1.0)
n11 = _grad(_hash(ix1, iy1), fx - 1.0, fy - 1.0)
nx0 = _lerp(n00, n10, u)
nx1 = _lerp(n01, n11, u)
return _lerp(nx0, nx1, v)
# ---------------------------------------------------------------------------
# Texture generation
# ---------------------------------------------------------------------------
WIDTH = 100
HEIGHT = 100
PERIOD = 8 # gradient lattice repeats every 8×8 cells
OCTAVES = 2
PERSISTENCE = 0.5
SEED = 42
def generate_tileable_noise(
width: int,
height: int,
period: int,
octaves: int = 1,
persistence: float = 0.5,
seed: int = 42,
) -> Image.Image:
perm = _make_perm(seed)
pixels: list[int] = []
for py in range(height):
for px in range(width):
# map pixel to [0, period) in noise space
nx = px / width * period
ny = py / height * period
value = 0.0
amp = 1.0
freq = 1.0
max_val = 0.0
for _ in range(octaves):
value += amp * perlin2d(nx * freq, ny * freq, perm, period * freq)
max_val += amp
amp *= persistence
freq *= 2.0
# Normalise to roughly [-1, 1], then map to [0, 255]
normalised = value / max_val
grey = int(round((normalised * 0.5 + 0.5) * 255.0))
grey = max(0, min(255, grey))
pixels.extend([grey, grey, grey])
img = Image.new("RGB", (width, height))
img.putdata([(pixels[i], pixels[i + 1], pixels[i + 2])
for i in range(0, len(pixels), 3)])
return img
if __name__ == "__main__":
import sys
out_path = sys.argv[1] if len(sys.argv) > 1 else "noise_100.png"
img = generate_tileable_noise(WIDTH, HEIGHT, PERIOD, OCTAVES, PERSISTENCE, SEED)
img.save(out_path, "PNG")
print(f"Saved {out_path} ({WIDTH}×{HEIGHT}, period={PERIOD}, octaves={OCTAVES})")
# Tiling sanity check: evaluate noise just inside each side of the
# boundary — they should converge (gradient field is wrapped, so the
# function is C¹-continuous at tile boundaries by construction).
_check_perm = _make_perm(SEED)
_max_diff = 0.0
for _y in range(HEIGHT):
_ny = _y / HEIGHT * PERIOD
_v_left = perlin2d(0.001 * PERIOD, _ny, _check_perm, PERIOD)
_v_right = perlin2d(PERIOD - 0.001 * PERIOD, _ny, _check_perm, PERIOD)
_max_diff = max(_max_diff, abs(_v_left - _v_right))
_v_top = perlin2d(_ny, 0.001 * PERIOD, _check_perm, PERIOD)
_v_bot = perlin2d(_ny, PERIOD - 0.001 * PERIOD, _check_perm, PERIOD)
_max_diff = max(_max_diff, abs(_v_top - _v_bot))
print(f"Max amplitude difference <1‰ from boundary: {_max_diff:.6f} (tileable ✓)")