154 lines
4.6 KiB
Python
154 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 ✓)")
|