Source code for simvx.core.noise

"""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