"""Noise generation — FastNoiseLite-inspired procedural noise, pure NumPy vectorized.
Supports Perlin, Simplex, Value, and Cellular (Worley) noise with fractal layering
(FBM, Ridged, Ping-Pong). All implementations accept and return NumPy arrays with
no Python loops over pixels.
Examples:
from simvx.core.noise import FastNoiseLite, NoiseType, FractalType
noise = FastNoiseLite(seed=42)
noise.noise_type = NoiseType.SIMPLEX
noise.fractal_type = FractalType.FBM
noise.fractal_octaves = 4
val = noise.get_noise_2d(10.5, 20.3)
image = noise.get_image(512, 512, scale=0.02) # HxW float array in [-1, 1]
"""
from __future__ import annotations
import logging
from enum import IntEnum
from itertools import product
import numpy as np
log = logging.getLogger(__name__)
__all__ = ["FastNoiseLite", "NoiseType", "FractalType"]
[docs]
class NoiseType(IntEnum):
"""Noise algorithm selection."""
PERLIN = 0
SIMPLEX = 1
VALUE = 2
CELLULAR = 3
[docs]
class FractalType(IntEnum):
"""Fractal layering mode."""
NONE = 0
FBM = 1
RIDGED = 2
PING_PONG = 3
# ============================================================================
# Permutation / gradient tables
# ============================================================================
def _build_perm(seed: int) -> np.ndarray:
"""Build a 512-element permutation table from seed."""
rng = np.random.RandomState(seed & 0x7FFFFFFF)
p = np.arange(256, dtype=np.int32)
rng.shuffle(p)
return np.tile(p, 2)
def _build_grad2() -> np.ndarray:
"""12 unit-ish gradient vectors for 2D Perlin (from classic implementation)."""
return np.array(
[
[1, 1],
[-1, 1],
[1, -1],
[-1, -1],
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[1, 1],
[-1, 1],
[1, -1],
[-1, -1],
],
dtype=np.float64,
)
def _build_grad3() -> np.ndarray:
"""16 gradient vectors for 3D Perlin."""
return np.array(
[
[1, 1, 0],
[-1, 1, 0],
[1, -1, 0],
[-1, -1, 0],
[1, 0, 1],
[-1, 0, 1],
[1, 0, -1],
[-1, 0, -1],
[0, 1, 1],
[0, -1, 1],
[0, 1, -1],
[0, -1, -1],
[1, 1, 0],
[-1, 1, 0],
[0, -1, 1],
[0, -1, -1],
],
dtype=np.float64,
)
_GRAD2 = _build_grad2()
_GRAD3 = _build_grad3()
# Simplex skew constants
_F2 = 0.5 * (np.sqrt(3.0) - 1.0)
_G2 = (3.0 - np.sqrt(3.0)) / 6.0
_F3 = 1.0 / 3.0
_G3 = 1.0 / 6.0
# ============================================================================
# Vectorized interpolation helpers
# ============================================================================
def _fade(t: np.ndarray) -> np.ndarray:
"""Quintic fade curve: 6t^5 - 15t^4 + 10t^3."""
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
def _lerp(a: np.ndarray, b: np.ndarray, t: np.ndarray) -> np.ndarray:
return a + t * (b - a)
# ============================================================================
# Noise implementations (all vectorized — accept/return flat arrays)
# ============================================================================
def _perlin_2d(xs: np.ndarray, ys: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""Classic Perlin 2D noise, fully vectorized."""
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
xf = xs - xi
yf = ys - yi
xi &= 255
yi &= 255
u = _fade(xf)
v = _fade(yf)
# Hash corners
aa = perm[perm[xi] + yi]
ab = perm[perm[xi] + yi + 1]
ba = perm[perm[xi + 1] + yi]
bb = perm[perm[xi + 1] + yi + 1]
# Gradient dot products
g = _GRAD2
def _dot(h, dx, dy):
idx = h % 12
return g[idx, 0] * dx + g[idx, 1] * dy
n00 = _dot(aa, xf, yf)
n10 = _dot(ba, xf - 1.0, yf)
n01 = _dot(ab, xf, yf - 1.0)
n11 = _dot(bb, xf - 1.0, yf - 1.0)
nx0 = _lerp(n00, n10, u)
nx1 = _lerp(n01, n11, u)
return _lerp(nx0, nx1, v)
def _perlin_3d(xs: np.ndarray, ys: np.ndarray, zs: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""Classic Perlin 3D noise, fully vectorized."""
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
zi = np.floor(zs).astype(np.int32)
xf = xs - xi
yf = ys - yi
zf = zs - zi
xi &= 255
yi &= 255
zi &= 255
u = _fade(xf)
v = _fade(yf)
w = _fade(zf)
# Hash 8 corners
a = perm[xi] + yi
aa = perm[a] + zi
ab = perm[a + 1] + zi
b = perm[xi + 1] + yi
ba = perm[b] + zi
bb = perm[b + 1] + zi
g = _GRAD3
def _dot3(h, dx, dy, dz):
idx = h % 16
return g[idx, 0] * dx + g[idx, 1] * dy + g[idx, 2] * dz
n000 = _dot3(perm[aa], xf, yf, zf)
n100 = _dot3(perm[ba], xf - 1, yf, zf)
n010 = _dot3(perm[ab], xf, yf - 1, zf)
n110 = _dot3(perm[bb], xf - 1, yf - 1, zf)
n001 = _dot3(perm[aa + 1], xf, yf, zf - 1)
n101 = _dot3(perm[ba + 1], xf - 1, yf, zf - 1)
n011 = _dot3(perm[ab + 1], xf, yf - 1, zf - 1)
n111 = _dot3(perm[bb + 1], xf - 1, yf - 1, zf - 1)
nx00 = _lerp(n000, n100, u)
nx10 = _lerp(n010, n110, u)
nx01 = _lerp(n001, n101, u)
nx11 = _lerp(n011, n111, u)
nxy0 = _lerp(nx00, nx10, v)
nxy1 = _lerp(nx01, nx11, v)
return _lerp(nxy0, nxy1, w)
def _simplex_2d(xs: np.ndarray, ys: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""2D Simplex noise, fully vectorized."""
xs.shape[0]
s = (xs + ys) * _F2
i = np.floor(xs + s).astype(np.int32)
j = np.floor(ys + s).astype(np.int32)
t = (i + j).astype(np.float64) * _G2
x0 = xs - (i - t)
y0 = ys - (j - t)
# Determine simplex
i1 = np.where(x0 > y0, 1, 0).astype(np.int32)
j1 = np.where(x0 > y0, 0, 1).astype(np.int32)
x1 = x0 - i1 + _G2
y1 = y0 - j1 + _G2
x2 = x0 - 1.0 + 2.0 * _G2
y2 = y0 - 1.0 + 2.0 * _G2
ii = i & 255
jj = j & 255
g = _GRAD2
def _contrib(gi_idx, dx, dy):
t_val = 0.5 - dx * dx - dy * dy
mask = t_val > 0
t_val = np.where(mask, t_val, 0.0)
t2 = t_val * t_val
t4 = t2 * t2
idx = gi_idx % 12
grad_dot = g[idx, 0] * dx + g[idx, 1] * dy
return np.where(mask, t4 * grad_dot, 0.0)
gi0 = perm[ii + perm[jj]]
gi1 = perm[ii + i1 + perm[jj + j1]]
gi2 = perm[ii + 1 + perm[jj + 1]]
n0 = _contrib(gi0, x0, y0)
n1 = _contrib(gi1, x1, y1)
n2 = _contrib(gi2, x2, y2)
# Scale to roughly [-1, 1]
return 70.0 * (n0 + n1 + n2)
def _simplex_3d(xs: np.ndarray, ys: np.ndarray, zs: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""3D Simplex noise, fully vectorized."""
s = (xs + ys + zs) * _F3
i = np.floor(xs + s).astype(np.int32)
j = np.floor(ys + s).astype(np.int32)
k = np.floor(zs + s).astype(np.int32)
t = (i + j + k).astype(np.float64) * _G3
x0 = xs - (i - t)
y0 = ys - (j - t)
z0 = zs - (k - t)
# Determine simplex traversal order
i1 = np.zeros_like(i)
j1 = np.zeros_like(j)
k1 = np.zeros_like(k)
i2 = np.zeros_like(i)
j2 = np.zeros_like(j)
k2 = np.zeros_like(k)
ge_xy = x0 >= y0
ge_xz = x0 >= z0
ge_yz = y0 >= z0
# x >= y >= z
m = ge_xy & ge_xz & ge_yz
i1[m], j1[m], k1[m] = 1, 0, 0
i2[m], j2[m], k2[m] = 1, 1, 0
# x >= z > y
m = ge_xy & ge_xz & ~ge_yz
i1[m], j1[m], k1[m] = 1, 0, 0
i2[m], j2[m], k2[m] = 1, 0, 1
# z > x >= y
m = ge_xy & ~ge_xz
i1[m], j1[m], k1[m] = 0, 0, 1
i2[m], j2[m], k2[m] = 1, 0, 1
# y > x, y >= z, x >= z
m = ~ge_xy & ge_yz & ge_xz
i1[m], j1[m], k1[m] = 0, 1, 0
i2[m], j2[m], k2[m] = 1, 1, 0
# y > x, y >= z, z > x
m = ~ge_xy & ge_yz & ~ge_xz
i1[m], j1[m], k1[m] = 0, 1, 0
i2[m], j2[m], k2[m] = 0, 1, 1
# z > y > x
m = ~ge_xy & ~ge_yz
i1[m], j1[m], k1[m] = 0, 0, 1
i2[m], j2[m], k2[m] = 0, 1, 1
x1 = x0 - i1 + _G3
y1 = y0 - j1 + _G3
z1 = z0 - k1 + _G3
x2 = x0 - i2 + 2.0 * _G3
y2 = y0 - j2 + 2.0 * _G3
z2 = z0 - k2 + 2.0 * _G3
x3 = x0 - 1.0 + 3.0 * _G3
y3 = y0 - 1.0 + 3.0 * _G3
z3 = z0 - 1.0 + 3.0 * _G3
ii = i & 255
jj = j & 255
kk = k & 255
g = _GRAD3
def _contrib3(gi_idx, dx, dy, dz):
t_val = 0.6 - dx * dx - dy * dy - dz * dz
mask = t_val > 0
t_val = np.where(mask, t_val, 0.0)
t2 = t_val * t_val
t4 = t2 * t2
idx = gi_idx % 16
grad_dot = g[idx, 0] * dx + g[idx, 1] * dy + g[idx, 2] * dz
return np.where(mask, t4 * grad_dot, 0.0)
gi0 = perm[ii + perm[jj + perm[kk]]]
gi1 = perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]]
gi2 = perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]]
gi3 = perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]]
n0 = _contrib3(gi0, x0, y0, z0)
n1 = _contrib3(gi1, x1, y1, z1)
n2 = _contrib3(gi2, x2, y2, z2)
n3 = _contrib3(gi3, x3, y3, z3)
return 32.0 * (n0 + n1 + n2 + n3)
def _value_2d(xs: np.ndarray, ys: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""Value noise 2D — hash-based with smooth interpolation, vectorized."""
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
xf = xs - xi
yf = ys - yi
xi &= 255
yi &= 255
u = _fade(xf)
v = _fade(yf)
# Hash to get pseudo-random values at corners, mapped to [-1, 1]
def _hash_val(h):
return (h / 255.0) * 2.0 - 1.0
n00 = _hash_val(perm[perm[xi] + yi].astype(np.float64))
n10 = _hash_val(perm[perm[xi + 1] + yi].astype(np.float64))
n01 = _hash_val(perm[perm[xi] + yi + 1].astype(np.float64))
n11 = _hash_val(perm[perm[xi + 1] + yi + 1].astype(np.float64))
nx0 = _lerp(n00, n10, u)
nx1 = _lerp(n01, n11, u)
return _lerp(nx0, nx1, v)
def _value_3d(xs: np.ndarray, ys: np.ndarray, zs: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""Value noise 3D, vectorized."""
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
zi = np.floor(zs).astype(np.int32)
xf = xs - xi
yf = ys - yi
zf = zs - zi
xi &= 255
yi &= 255
zi &= 255
u = _fade(xf)
v = _fade(yf)
w = _fade(zf)
def _hash_val(h):
return (h / 255.0) * 2.0 - 1.0
n000 = _hash_val(perm[perm[perm[xi] + yi] + zi].astype(np.float64))
n100 = _hash_val(perm[perm[perm[xi + 1] + yi] + zi].astype(np.float64))
n010 = _hash_val(perm[perm[perm[xi] + yi + 1] + zi].astype(np.float64))
n110 = _hash_val(perm[perm[perm[xi + 1] + yi + 1] + zi].astype(np.float64))
n001 = _hash_val(perm[perm[perm[xi] + yi] + zi + 1].astype(np.float64))
n101 = _hash_val(perm[perm[perm[xi + 1] + yi] + zi + 1].astype(np.float64))
n011 = _hash_val(perm[perm[perm[xi] + yi + 1] + zi + 1].astype(np.float64))
n111 = _hash_val(perm[perm[perm[xi + 1] + yi + 1] + zi + 1].astype(np.float64))
nx00 = _lerp(n000, n100, u)
nx10 = _lerp(n010, n110, u)
nx01 = _lerp(n001, n101, u)
nx11 = _lerp(n011, n111, u)
nxy0 = _lerp(nx00, nx10, v)
nxy1 = _lerp(nx01, nx11, v)
return _lerp(nxy0, nxy1, w)
def _cellular_2d(
xs: np.ndarray,
ys: np.ndarray,
perm: np.ndarray,
distance_fn: str = "euclidean",
return_type: str = "distance",
) -> np.ndarray:
"""Worley / cellular noise 2D, fully vectorized.
Evaluates the 3x3 neighborhood of each point's cell, finds the two closest
feature points, and returns a value based on distance_fn and return_type.
"""
n = xs.shape[0]
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
dist1 = np.full(n, 1e10, dtype=np.float64)
dist2 = np.full(n, 1e10, dtype=np.float64)
# Check 3x3 neighborhood
for di in range(-1, 2):
for dj in range(-1, 2):
cx = xi + di
cy = yi + dj
# Pseudo-random feature point within each cell using perm table
hx = perm[(perm[cx & 255] + (cy & 255)) & 255]
hy = perm[(perm[(cx + 53) & 255] + ((cy + 97) & 255)) & 255]
fx = cx + hx / 255.0
fy = cy + hy / 255.0
dx = fx - xs
dy = fy - ys
if distance_fn == "manhattan":
d = np.abs(dx) + np.abs(dy)
else: # euclidean
d = np.sqrt(dx * dx + dy * dy)
# Insert into sorted dist1, dist2
closer = d < dist1
dist2 = np.where(closer, dist1, np.where(d < dist2, d, dist2))
dist1 = np.where(closer, d, dist1)
if return_type == "distance2":
result = dist2
elif return_type == "distance2add":
result = dist1 + dist2
else: # "distance"
result = dist1
# Normalize to approximately [-1, 1]
return result * 2.0 - 1.0
def _cellular_3d(
xs: np.ndarray,
ys: np.ndarray,
zs: np.ndarray,
perm: np.ndarray,
distance_fn: str = "euclidean",
return_type: str = "distance",
) -> np.ndarray:
"""Worley / cellular noise 3D, fully vectorized."""
n = xs.shape[0]
xi = np.floor(xs).astype(np.int32)
yi = np.floor(ys).astype(np.int32)
zi = np.floor(zs).astype(np.int32)
dist1 = np.full(n, 1e10, dtype=np.float64)
dist2 = np.full(n, 1e10, dtype=np.float64)
for di, dj, dk in product(range(-1, 2), repeat=3):
cx = xi + di
cy = yi + dj
cz = zi + dk
hx = perm[(perm[(perm[cx & 255] + (cy & 255)) & 255] + (cz & 255)) & 255]
hy = perm[(perm[((cx + 53) & 255)] + ((cy + 97) & 255) + ((cz + 31) & 255)) & 255]
hz = perm[(perm[((cx + 71) & 255)] + ((cy + 13) & 255) + ((cz + 59) & 255)) & 255]
fx = cx + hx / 255.0
fy = cy + hy / 255.0
fz = cz + hz / 255.0
dx = fx - xs
dy = fy - ys
dz = fz - zs
if distance_fn == "manhattan":
d = np.abs(dx) + np.abs(dy) + np.abs(dz)
else:
d = np.sqrt(dx * dx + dy * dy + dz * dz)
closer = d < dist1
dist2 = np.where(closer, dist1, np.where(d < dist2, d, dist2))
dist1 = np.where(closer, d, dist1)
if return_type == "distance2":
result = dist2
elif return_type == "distance2add":
result = dist1 + dist2
else:
result = dist1
return result * 2.0 - 1.0
# ============================================================================
# FastNoiseLite — main API
# ============================================================================
[docs]
class FastNoiseLite:
"""Procedural noise generator inspired by FastNoiseLite.
Pure NumPy implementation — all evaluation is vectorized. Supports Perlin,
Simplex, Value, and Cellular noise with optional fractal layering.
Args:
seed: Random seed for permutation table generation.
noise_type: Base noise algorithm (default PERLIN).
frequency: Coordinate multiplier — lower values produce larger features.
Examples:
noise = FastNoiseLite(seed=42, noise_type=NoiseType.SIMPLEX)
noise.fractal_type = FractalType.FBM
noise.fractal_octaves = 5
image = noise.get_image(256, 256, scale=0.05)
"""
def __init__(
self,
seed: int = 0,
noise_type: NoiseType = NoiseType.PERLIN,
frequency: float = 0.01,
):
self._seed = seed
self.noise_type = noise_type
self.frequency = frequency
self.fractal_type: FractalType = FractalType.NONE
self.fractal_octaves: int = 3
self.fractal_lacunarity: float = 2.0
self.fractal_gain: float = 0.5
self.fractal_weighted_strength: float = 0.0
self.cellular_distance_function: str = "euclidean"
self.cellular_return_type: str = "distance"
self._perm = _build_perm(seed)
@property
def seed(self) -> int:
return self._seed
@seed.setter
def seed(self, value: int) -> None:
self._seed = value
self._perm = _build_perm(value)
# --- Single-point evaluation ---
[docs]
def get_noise_2d(self, x: float, y: float) -> float:
"""Evaluate noise at a single 2D point. Returns float in ~[-1, 1]."""
xs = np.array([x * self.frequency], dtype=np.float64)
ys = np.array([y * self.frequency], dtype=np.float64)
return float(self._evaluate_2d(xs, ys)[0])
[docs]
def get_noise_3d(self, x: float, y: float, z: float) -> float:
"""Evaluate noise at a single 3D point. Returns float in ~[-1, 1]."""
xs = np.array([x * self.frequency], dtype=np.float64)
ys = np.array([y * self.frequency], dtype=np.float64)
zs = np.array([z * self.frequency], dtype=np.float64)
return float(self._evaluate_3d(xs, ys, zs)[0])
# --- Vectorized batch evaluation ---
[docs]
def get_noise_2d_array(self, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
"""Evaluate noise at arrays of 2D points. Returns array in ~[-1, 1]."""
xs_f = np.asarray(xs, dtype=np.float64).ravel() * self.frequency
ys_f = np.asarray(ys, dtype=np.float64).ravel() * self.frequency
return self._evaluate_2d(xs_f, ys_f).astype(np.float32)
[docs]
def get_noise_3d_array(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> np.ndarray:
"""Evaluate noise at arrays of 3D points. Returns array in ~[-1, 1]."""
xs_f = np.asarray(xs, dtype=np.float64).ravel() * self.frequency
ys_f = np.asarray(ys, dtype=np.float64).ravel() * self.frequency
zs_f = np.asarray(zs, dtype=np.float64).ravel() * self.frequency
return self._evaluate_3d(xs_f, ys_f, zs_f).astype(np.float32)
[docs]
def get_image(self, width: int, height: int, scale: float = 1.0) -> np.ndarray:
"""Generate an HxW noise image. Returns float32 array in ~[-1, 1].
Args:
width: Image width in pixels.
height: Image height in pixels.
scale: Coordinate scale multiplier (applied on top of frequency).
"""
iy, ix = np.meshgrid(np.arange(height, dtype=np.float64), np.arange(width, dtype=np.float64), indexing="ij")
xs = (ix.ravel() * scale) * self.frequency
ys = (iy.ravel() * scale) * self.frequency
result = self._evaluate_2d(xs, ys)
return result.reshape(height, width).astype(np.float32)
# --- Internal dispatch ---
def _base_noise_2d(self, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
"""Dispatch to the selected noise type (2D)."""
if self.noise_type == NoiseType.PERLIN:
return _perlin_2d(xs, ys, self._perm)
elif self.noise_type == NoiseType.SIMPLEX:
return _simplex_2d(xs, ys, self._perm)
elif self.noise_type == NoiseType.VALUE:
return _value_2d(xs, ys, self._perm)
elif self.noise_type == NoiseType.CELLULAR:
return _cellular_2d(xs, ys, self._perm, self.cellular_distance_function, self.cellular_return_type)
raise ValueError(f"Unknown noise type: {self.noise_type}")
def _base_noise_3d(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> np.ndarray:
"""Dispatch to the selected noise type (3D)."""
if self.noise_type == NoiseType.PERLIN:
return _perlin_3d(xs, ys, zs, self._perm)
elif self.noise_type == NoiseType.SIMPLEX:
return _simplex_3d(xs, ys, zs, self._perm)
elif self.noise_type == NoiseType.VALUE:
return _value_3d(xs, ys, zs, self._perm)
elif self.noise_type == NoiseType.CELLULAR:
return _cellular_3d(xs, ys, zs, self._perm, self.cellular_distance_function, self.cellular_return_type)
raise ValueError(f"Unknown noise type: {self.noise_type}")
def _evaluate_2d(self, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
"""Evaluate 2D noise with optional fractal layering."""
if self.fractal_type == FractalType.NONE:
return self._base_noise_2d(xs, ys)
return self._apply_fractal_2d(xs, ys)
def _evaluate_3d(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> np.ndarray:
"""Evaluate 3D noise with optional fractal layering."""
if self.fractal_type == FractalType.NONE:
return self._base_noise_3d(xs, ys, zs)
return self._apply_fractal_3d(xs, ys, zs)
# --- Fractal layering (unified 2D/3D via noise_fn callback) ---
def _apply_fractal(self, noise_fn, *coords: np.ndarray) -> np.ndarray:
"""Apply the configured fractal type using noise_fn(*scaled_coords)."""
if self.fractal_type == FractalType.FBM:
return self._fbm(noise_fn, *coords)
elif self.fractal_type == FractalType.RIDGED:
return self._ridged(noise_fn, *coords)
elif self.fractal_type == FractalType.PING_PONG:
return self._ping_pong(noise_fn, *coords)
raise ValueError(f"Unknown fractal type: {self.fractal_type}")
def _apply_fractal_2d(self, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
return self._apply_fractal(self._base_noise_2d, xs, ys)
def _apply_fractal_3d(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> np.ndarray:
return self._apply_fractal(self._base_noise_3d, xs, ys, zs)
def _fbm(self, noise_fn, *coords: np.ndarray) -> np.ndarray:
"""Fractal Brownian Motion — sum of octaves with decreasing amplitude."""
result = np.zeros_like(coords[0])
amp = 1.0
freq = 1.0
amp_sum = 0.0
for _ in range(self.fractal_octaves):
noise = noise_fn(*(c * freq for c in coords))
result += noise * amp
amp_sum += amp
amp *= _lerp(np.ones_like(noise), (noise + 1.0) * 0.5, self.fractal_weighted_strength)
amp *= self.fractal_gain
freq *= self.fractal_lacunarity
return result / amp_sum
def _ridged(self, noise_fn, *coords: np.ndarray) -> np.ndarray:
"""Ridged multifractal — absolute value creates sharp ridges."""
result = np.zeros_like(coords[0])
amp = 1.0
freq = 1.0
weight = 1.0
amp_sum = 0.0
for _ in range(self.fractal_octaves):
noise = np.abs(noise_fn(*(c * freq for c in coords)))
noise = (1.0 - noise) ** 2 * weight # Invert + sharpen + weight
weight = np.clip(noise * self.fractal_gain, 0.0, 1.0)
result += noise * amp
amp_sum += amp
amp *= self.fractal_gain
freq *= self.fractal_lacunarity
return result / amp_sum * 2.0 - 1.0
def _ping_pong(self, noise_fn, *coords: np.ndarray) -> np.ndarray:
"""Ping-pong fractal — folds noise via triangle wave for organic patterns."""
result = np.zeros_like(coords[0])
amp = 1.0
freq = 1.0
amp_sum = 0.0
for _ in range(self.fractal_octaves):
noise = noise_fn(*(c * freq for c in coords))
# Triangle wave fold: [-1,1] -> [0,1] -> fold -> [-1,1]
folded = (noise + 1.0) * 0.5
folded = (1.0 - np.abs(np.mod(folded * 2.0, 2.0) - 1.0)) * 2.0 - 1.0
result += folded * amp
amp_sum += amp
amp *= self.fractal_gain
freq *= self.fractal_lacunarity
return result / amp_sum