nodes/shape.py

Part of Procedural Planets.

  1"""Shape generator: vectorised numpy port of Lague's ShapeGenerator + noise filters.
  2
  3Two filter types match upstream E07 exactly:
  4  - Simple: multi-octave Perlin FBM, value = sum of (noise + 1) * 0.5 * amplitude.
  5  - Ridge: multi-octave (1 - |perlin|)^2 with weighted accumulation.
  6
  7`SimplexNoise3D` wraps SimVX's `FastNoiseLite` so each filter has its own
  8permutation table (matches upstream's "new Noise()" per filter).
  9"""
 10
 11from __future__ import annotations
 12
 13from dataclasses import dataclass, field
 14
 15import numpy as np
 16
 17from simvx.core.noise import FastNoiseLite, NoiseType
 18
 19# ---------------------------------------------------------------------------
 20# Settings dataclasses
 21# ---------------------------------------------------------------------------
 22
 23
 24@dataclass
 25class SimpleNoiseSettings:
 26    """Multi-octave Perlin FBM settings: matches Unity SimpleNoiseSettings."""
 27
 28    strength: float = 1.0
 29    num_layers: int = 1
 30    base_roughness: float = 1.0
 31    roughness: float = 2.0
 32    persistence: float = 0.5
 33    centre: tuple[float, float, float] = (0.0, 0.0, 0.0)
 34    min_value: float = 0.0
 35
 36
 37@dataclass
 38class RidgeNoiseSettings(SimpleNoiseSettings):
 39    """Ridge filter settings: extends SimpleNoiseSettings with weight multiplier."""
 40
 41    weight_multiplier: float = 0.8
 42
 43
 44@dataclass
 45class NoiseLayer:
 46    enabled: bool = True
 47    use_first_layer_as_mask: bool = False
 48    filter_type: str = "simple"  # "simple" or "ridge"
 49    simple: SimpleNoiseSettings = field(default_factory=SimpleNoiseSettings)
 50    ridge: RidgeNoiseSettings = field(default_factory=RidgeNoiseSettings)
 51
 52
 53@dataclass
 54class ShapeSettings:
 55    planet_radius: float = 1.5
 56    layers: list[NoiseLayer] = field(default_factory=list)
 57
 58
 59# ---------------------------------------------------------------------------
 60# Noise wrapper
 61# ---------------------------------------------------------------------------
 62
 63
 64class _SimplexNoise3D:
 65    """Per-filter permutation. SimVX FastNoiseLite is pure-numpy vectorised."""
 66
 67    __slots__ = ("_noise",)
 68
 69    def __init__(self, seed: int = 0) -> None:
 70        # frequency=1.0: we scale coords manually inside the filter loop
 71        self._noise = FastNoiseLite(seed=seed, noise_type=NoiseType.PERLIN, frequency=1.0)
 72
 73    def evaluate_array(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray) -> np.ndarray:
 74        """Evaluate at parallel point arrays. Returns float32 ~ [-1, 1]."""
 75        return self._noise.get_noise_3d_array(xs, ys, zs)
 76
 77
 78# ---------------------------------------------------------------------------
 79# Filters: operate on (N, 3) arrays of unit-sphere points
 80# ---------------------------------------------------------------------------
 81
 82
 83def _eval_simple(noise: _SimplexNoise3D, points: np.ndarray, s: SimpleNoiseSettings) -> np.ndarray:
 84    """Evaluate multi-octave Perlin FBM at every point. Returns (N,) elevation."""
 85    out = np.zeros(points.shape[0], dtype=np.float32)
 86    frequency = s.base_roughness
 87    amplitude = 1.0
 88    cx, cy, cz = s.centre
 89    for _ in range(max(1, s.num_layers)):
 90        xs = points[:, 0] * frequency + cx
 91        ys = points[:, 1] * frequency + cy
 92        zs = points[:, 2] * frequency + cz
 93        v = noise.evaluate_array(xs, ys, zs)  # ~[-1, 1]
 94        out += (v + 1.0) * 0.5 * amplitude
 95        frequency *= s.roughness
 96        amplitude *= s.persistence
 97    out = out - s.min_value
 98    return out * s.strength
 99
100
101def _eval_ridge(noise: _SimplexNoise3D, points: np.ndarray, s: RidgeNoiseSettings) -> np.ndarray:
102    """Evaluate multi-octave ridge noise. Returns (N,) elevation."""
103    out = np.zeros(points.shape[0], dtype=np.float32)
104    frequency = s.base_roughness
105    amplitude = 1.0
106    weight = np.ones(points.shape[0], dtype=np.float32)
107    cx, cy, cz = s.centre
108    for _ in range(max(1, s.num_layers)):
109        xs = points[:, 0] * frequency + cx
110        ys = points[:, 1] * frequency + cy
111        zs = points[:, 2] * frequency + cz
112        v = 1.0 - np.abs(noise.evaluate_array(xs, ys, zs))
113        v = v * v * weight
114        weight = np.clip(v * s.weight_multiplier, 0.0, 1.0)
115        out += v * amplitude
116        frequency *= s.roughness
117        amplitude *= s.persistence
118    out = out - s.min_value
119    return out * s.strength
120
121
122# ---------------------------------------------------------------------------
123# Top-level generator
124# ---------------------------------------------------------------------------
125
126
127class ShapeGenerator:
128    """Holds the noise filter stack and tracks elevation min/max.
129
130    Matches upstream semantics: layer 0 is always evaluated (when enabled);
131    later layers can optionally use layer 0 as a mask multiplier.
132    """
133
134    def __init__(self, settings: ShapeSettings | None = None, seed: int = 0) -> None:
135        self.settings = settings or ShapeSettings()
136        self.elevation_min = float("inf")
137        self.elevation_max = float("-inf")
138        # One independent noise generator per filter (matches upstream).
139        self._noises: list[_SimplexNoise3D] = []
140        self._seed = seed
141        self._rebuild_noises()
142
143    def _rebuild_noises(self) -> None:
144        # Re-seed deterministically so noise patterns are stable across regenerates.
145        self._noises = [_SimplexNoise3D(seed=self._seed + i) for i in range(len(self.settings.layers))]
146
147    def update_settings(self, settings: ShapeSettings) -> None:
148        if len(settings.layers) != len(self._noises):
149            self.settings = settings
150            self._rebuild_noises()
151        else:
152            self.settings = settings
153
154    def reset_elevation_bounds(self) -> None:
155        self.elevation_min = float("inf")
156        self.elevation_max = float("-inf")
157
158    def calculate_unscaled_elevation(self, points_on_unit_sphere: np.ndarray) -> np.ndarray:
159        """Vectorised: (N, 3) unit-sphere points → (N,) raw elevation, plus min/max update."""
160        layers = self.settings.layers
161        if not layers:
162            zeros = np.zeros(points_on_unit_sphere.shape[0], dtype=np.float32)
163            self.elevation_min = min(self.elevation_min, 0.0)
164            self.elevation_max = max(self.elevation_max, 0.0)
165            return zeros
166
167        # Layer 0 is always computed (used as mask source, even if disabled for elevation).
168        layer0 = layers[0]
169        if layer0.filter_type == "ridge":
170            first_layer_value = _eval_ridge(self._noises[0], points_on_unit_sphere, layer0.ridge)
171        else:
172            first_layer_value = _eval_simple(self._noises[0], points_on_unit_sphere, layer0.simple)
173
174        elevation = first_layer_value if layer0.enabled else np.zeros_like(first_layer_value)
175
176        for i in range(1, len(layers)):
177            layer = layers[i]
178            if not layer.enabled:
179                continue
180            if layer.filter_type == "ridge":
181                v = _eval_ridge(self._noises[i], points_on_unit_sphere, layer.ridge)
182            else:
183                v = _eval_simple(self._noises[i], points_on_unit_sphere, layer.simple)
184            mask = first_layer_value if layer.use_first_layer_as_mask else 1.0
185            elevation = elevation + v * mask
186
187        # MinMax tracking (numpy reductions, no Python loop).
188        cur_min = float(elevation.min())
189        cur_max = float(elevation.max())
190        if cur_min < self.elevation_min:
191            self.elevation_min = cur_min
192        if cur_max > self.elevation_max:
193            self.elevation_max = cur_max
194        return elevation
195
196    def get_scaled_elevation(self, unscaled: np.ndarray) -> np.ndarray:
197        """elev = max(0, raw); displaced = radius * (1 + elev). Vectorised."""
198        elev = np.maximum(0.0, unscaled)
199        return self.settings.planet_radius * (1.0 + elev)
200
201
202# ---------------------------------------------------------------------------
203# Default preset: recreates the upstream demo's "earth-like" planet
204# ---------------------------------------------------------------------------
205
206
207def default_shape_settings() -> ShapeSettings:
208    """Two-layer config matching Lague's tutorial result."""
209    base = NoiseLayer(
210        enabled=True,
211        use_first_layer_as_mask=False,
212        filter_type="simple",
213        simple=SimpleNoiseSettings(
214            strength=0.30,
215            num_layers=4,
216            base_roughness=1.0,
217            roughness=1.85,
218            persistence=0.5,
219            min_value=0.85,
220        ),
221    )
222    ridge = NoiseLayer(
223        enabled=True,
224        use_first_layer_as_mask=True,
225        filter_type="ridge",
226        ridge=RidgeNoiseSettings(
227            strength=0.60,
228            num_layers=5,
229            base_roughness=2.0,
230            roughness=2.15,
231            persistence=0.5,
232            min_value=1.0,
233            weight_multiplier=0.8,
234        ),
235    )
236    return ShapeSettings(planet_radius=1.5, layers=[base, ridge])