Mi2dRPGamEng/engine/worldgen/noise.py

98 lines
3.4 KiB
Python

from __future__ import annotations
import random
import numpy as np
# The 8 compass-direction gradient vectors classic 2D Perlin noise blends between at each
# integer grid corner - simpler than the usual 12-edge-of-cube set (this is 2D, not 3D) but
# still gives every corner a distinct, non-axis-degenerate direction.
_GRADIENTS = (
(1, 1), (-1, 1), (1, -1), (-1, -1),
(1, 0), (-1, 0), (0, 1), (0, -1),
)
def _build_permutation(seed: int) -> list[int]:
"""A 512-long permutation table (256 values, duplicated) so indexing perm[i + 1] never
overflows - the standard trick from Perlin's reference implementation.
"""
rng = random.Random(seed)
table = list(range(256))
rng.shuffle(table)
return table + table
def _fade(t: float) -> float:
"""Perlin's improved (quintic) easing curve - smoother second-derivative than a plain
cubic, so the noise has no visible creases at integer grid lines.
"""
return t * t * t * (t * (t * 6 - 15) + 10)
def _lerp(a: float, b: float, t: float) -> float:
return a + t * (b - a)
def _gradient(hash_value: int, x: float, y: float) -> float:
gx, gy = _GRADIENTS[hash_value % len(_GRADIENTS)]
return gx * x + gy * y
def _perlin_at(perm: list[int], x: float, y: float) -> float:
"""Single-octave Perlin noise at one continuous (x, y) coordinate, roughly in [-1, 1]."""
xi, yi = int(np.floor(x)) & 255, int(np.floor(y)) & 255
xf, yf = x - np.floor(x), y - np.floor(y)
u, v = _fade(xf), _fade(yf)
aa = perm[perm[xi] + yi]
ab = perm[perm[xi] + yi + 1]
ba = perm[perm[xi + 1] + yi]
bb = perm[perm[xi + 1] + yi + 1]
x1 = _lerp(_gradient(aa, xf, yf), _gradient(ba, xf - 1, yf), u)
x2 = _lerp(_gradient(ab, xf, yf - 1), _gradient(bb, xf - 1, yf - 1), u)
return _lerp(x1, x2, v)
def perlin_noise_2d(
width: int,
height: int,
seed: int,
scale: float = 20.0,
octaves: int = 4,
persistence: float = 0.5,
lacunarity: float = 2.0,
x_offset: float = 0.0,
y_offset: float = 0.0,
) -> np.ndarray:
"""A (height, width) array of fractal Perlin noise, normalized to [0, 1].
`x_offset`/`y_offset` shift which region of the *same* infinite noise field this call
samples - two calls with the same seed whose offsets are `width`/`height` apart tile
together with no visible seam, which is what lets neighboring world-gen chunks (see
engine/worldgen/terrain.py) share one continuous heightmap instead of each rolling an
independent, discontinuous one at their own edges.
"""
perm = _build_permutation(seed)
result = np.zeros((height, width), dtype=np.float64)
for row in range(height):
for col in range(width):
amplitude = 1.0
frequency = 1.0
total = 0.0
max_amplitude = 0.0
for _ in range(octaves):
nx = (col + x_offset) / scale * frequency
ny = (row + y_offset) / scale * frequency
total += _perlin_at(perm, nx, ny) * amplitude
max_amplitude += amplitude
amplitude *= persistence
frequency *= lacunarity
result[row, col] = total / max_amplitude if max_amplitude > 0 else 0.0
# Perlin noise is theoretically bounded but the practical range for a few octaves is
# comfortably within [-1, 1] - normalize into [0, 1] for a heightmap/moisture-map caller.
return (result + 1.0) / 2.0