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