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])