98 lines
3.4 KiB
Python
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
|