153 lines
4.6 KiB
Python
153 lines
4.6 KiB
Python
#!/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 ✓)")
|