Procedural Planets¶
Lague’s quad-sphere tutorial, multi-octave noise, biome ramp, IBL.
▶ Run in browserUpstream: https://github.com/SebLague/Procedural-Planets
Tags: port tier-2
Procedural Planets: SimVX Port¶
A SimVX port of SebLague/Procedural-Planets (E07 final episode). Builds a quad-sphere planet from 6 cube faces, displaces vertices with a stack of multi-octave Perlin (Simple FBM) and ridge noise filters, and shades the surface with a per-biome elevation ramp texture.
Run¶
Interactive (Vulkan window, slider UI):
uv run python ported_games/procedural_planets/simvx_port/main.py
Headless screenshot capture (no GPU window: saves 8 staged screenshots
under screenshots/):
uv run python ported_games/procedural_planets/simvx_port/main.py --test
CPU-only smoke test (no Vulkan, no window: exercises the noise and mesh build pipeline + sanity-checks performance budgets):
uv run python ported_games/procedural_planets/simvx_port/harness.py
Web export (WebGPU 3D pipeline, single standalone HTML):
uv run simvx export web ported_games/procedural_planets/simvx_port/main.py \
-o ported_games/procedural_planets/simvx_port/web/index.html
Controls¶
A/DorLeft/Right: orbit camera horizontallyW/SorUp/Down: zoom in/outMouse wheel: zoom in/out
Right-click drag: orbit camera (yaw + pitch)
R: force planet regenerateQ/Esc: quitSlider panel (bottom-left): drag to deform the planet in real time
Files¶
main.py:PlanetRootscene +--testheadless modenodes/shape.py:ShapeGenerator(vectorised numpy port of upstream’s noise filters)nodes/terrain_face.py: single quad-sphere face mesh buildernodes/colour.py: biome ramp texture + biome% samplernodes/planet.py:Planet(Node3D)root with 6 face children +regenerate()nodes/controls.py:SliderPanelbottom-left UIharness.py: CPU-only smoke / perf testassets/sky.hdr(optional): equirectangular HDR skybox; falls back to a procedural blue gradient if absentscreenshots/:--testoutput
See ../NOTES.md for the engine-friction log and design deviations.
Source¶
1"""Procedural Planets: Lague's quad-sphere tutorial, multi-octave noise, biome ramp, IBL.
2
3# /// simvx
4# tags = ["port", "tier-2"]
5# upstream = "https://github.com/SebLague/Procedural-Planets"
6# web = { width = 1280, height = 720, responsive = true }
7# ///
8
9Run:
10 uv run python ported_games/procedural_planets/simvx_port/main.py # interactive
11 uv run python ported_games/procedural_planets/simvx_port/main.py --test # headless capture
12"""
13
14from __future__ import annotations
15
16import math
17import sys
18from pathlib import Path
19
20_PORT_DIR = Path(__file__).parent
21if str(_PORT_DIR) not in sys.path:
22 sys.path.insert(0, str(_PORT_DIR))
23
24from nodes.controls import SliderPanel, apply_slider_value # noqa: E402
25from nodes.planet import Planet # noqa: E402
26
27from simvx.core import ( # noqa: E402
28 Camera3D,
29 DirectionalLight3D,
30 Input,
31 InputMap,
32 Key,
33 MouseButton,
34 Node,
35 Vec3,
36 WorldEnvironment,
37)
38from simvx.graphics import App # noqa: E402
39
40WIDTH = 1280
41HEIGHT = 720
42
43
44# ---------------------------------------------------------------------------
45# HDR skybox helper: falls back to procedural blue if no .hdr is present
46# ---------------------------------------------------------------------------
47
48def _make_environment_map() -> dict:
49 """Return the environment_map spec for WorldEnvironment.
50
51 If `assets/sky.hdr` exists, load it as an equirectangular HDR cubemap;
52 otherwise return a procedural gradient (deep blue) that the renderer
53 converts to a cubemap on first install.
54 """
55 hdr_path = _PORT_DIR / "assets" / "sky.hdr"
56 if hdr_path.exists():
57 return {"path": str(hdr_path)}
58 return {"colour": (0.05, 0.07, 0.15)}
59
60
61# ---------------------------------------------------------------------------
62# Root scene
63# ---------------------------------------------------------------------------
64
65class PlanetRoot(Node):
66 """Root: WorldEnvironment + Camera3D + DirectionalLight3D + Planet + UI."""
67
68 def on_ready(self) -> None:
69 # Input actions live in root.on_ready (web exporter skips main()).
70 InputMap.add_action("orbit", [MouseButton.LEFT])
71 InputMap.add_action("orbit_left", [Key.A, Key.LEFT])
72 InputMap.add_action("orbit_right", [Key.D, Key.RIGHT])
73 InputMap.add_action("zoom_in", [Key.W, Key.UP])
74 InputMap.add_action("zoom_out", [Key.S, Key.DOWN])
75 InputMap.add_action("regen", [Key.R])
76 InputMap.add_action("quit", [Key.ESCAPE, Key.Q])
77
78 # Environment + IBL: gradient skybox unless an HDR is sitting in
79 # assets/sky.hdr. Bloom helps the specular highlight pop.
80 env = WorldEnvironment(
81 environment_map=_make_environment_map(),
82 )
83 env.bloom_enabled = True
84 env.bloom_threshold = 1.05
85 env.bloom_intensity = 0.5
86 env.tonemap_exposure = 1.05
87 self.add_child(env)
88
89 # Camera orbit state.
90 self._yaw_deg = 25.0
91 self._pitch_deg = 18.0
92 self._distance = 5.5
93 self._cam = Camera3D(name="Camera", fov=55.0, near=0.05, far=200.0)
94 self.add_child(self._cam)
95
96 # Sun.
97 sun = DirectionalLight3D(name="Sun", intensity=1.6)
98 sun.colour = (1.0, 0.97, 0.92)
99 sun.position = Vec3(4.0, 3.0, 2.5)
100 sun.look_at(Vec3(0.0, 0.0, 0.0))
101 self.add_child(sun)
102
103 # Planet.
104 self.planet = Planet(resolution=64)
105 self.add_child(self.planet)
106
107 # Slider panel: shown in interactive mode AND when explicitly
108 # requested for a headless capture stage. When `_show_panel=False`,
109 # the panel widget tree is skipped so headless capture is faster.
110 self._panel: SliderPanel | None = None
111 if getattr(self, "_show_panel", not self._headless):
112 self._panel = SliderPanel(self.planet.shape_settings, self._on_slider)
113 self.add_child(self._panel)
114
115 # Mouse drag state (right-click drag) → pan camera.
116 self._dragging_camera = False
117 self._drag_last: tuple[float, float] | None = None
118 # Coalesce slider changes: regenerate at most once every K frames.
119 self._regen_pending = False
120 self._regen_cooldown = 0
121 self._regen_pending_resolution: int | None = None
122
123 self._update_camera()
124
125 @property
126 def _headless(self) -> bool:
127 return getattr(self, "_is_headless", False)
128
129 # ------------------------------------------------------------------
130 # Input + per-frame update
131 # ------------------------------------------------------------------
132
133 def on_process(self, dt: float) -> None:
134 if Input.is_action_just_pressed("quit"):
135 self.app.quit()
136 return
137
138 # Keyboard orbit.
139 if Input.is_action_pressed("orbit_left"):
140 self._yaw_deg += 80.0 * dt
141 if Input.is_action_pressed("orbit_right"):
142 self._yaw_deg -= 80.0 * dt
143 if Input.is_action_pressed("zoom_in"):
144 self._distance = max(2.4, self._distance - 4.0 * dt)
145 if Input.is_action_pressed("zoom_out"):
146 self._distance = min(20.0, self._distance + 4.0 * dt)
147 if Input.is_action_just_pressed("regen"):
148 self.planet.regenerate()
149
150 # Mouse-wheel zoom.
151 wheel = getattr(Input, "mouse_scroll_y", 0.0) or 0.0
152 if wheel:
153 self._distance = max(2.4, min(20.0, self._distance - wheel * 0.5))
154
155 # Idle planet rotation (slow, like upstream demo).
156 self.planet.rotate_y(0.05 * dt)
157
158 # Coalesced regen.
159 if self._regen_cooldown > 0:
160 self._regen_cooldown -= 1
161 if self._regen_pending and self._regen_cooldown <= 0:
162 self._regen_pending = False
163 self._regen_cooldown = 4
164 if self._regen_pending_resolution is not None:
165 self.planet.set_resolution(self._regen_pending_resolution)
166 self._regen_pending_resolution = None
167 else:
168 self.planet.regenerate()
169
170 self._update_camera()
171
172 def on_input(self, event) -> None:
173 # Only the right mouse button orbits the camera. Left-click is
174 # reserved for slider drag (handled by the UI router).
175 button = getattr(event, "button", None)
176 if button == 2: # right
177 if event.pressed:
178 self._dragging_camera = True
179 pos = event.position
180 self._drag_last = (float(pos.x), float(pos.y))
181 else:
182 self._dragging_camera = False
183 self._drag_last = None
184 elif self._dragging_camera and event.position is not None and self._drag_last is not None:
185 x, y = float(event.position.x), float(event.position.y)
186 dx = x - self._drag_last[0]
187 dy = y - self._drag_last[1]
188 self._drag_last = (x, y)
189 self._yaw_deg -= dx * 0.4
190 self._pitch_deg = max(-85.0, min(85.0, self._pitch_deg + dy * 0.3))
191
192 def _update_camera(self) -> None:
193 yaw = math.radians(self._yaw_deg)
194 pitch = math.radians(self._pitch_deg)
195 cp = math.cos(pitch)
196 x = self._distance * cp * math.sin(yaw)
197 y = self._distance * math.sin(pitch)
198 z = self._distance * cp * math.cos(yaw)
199 self._cam.position = Vec3(x, y, z)
200 self._cam.look_at(Vec3(0.0, 0.0, 0.0))
201
202 # ------------------------------------------------------------------
203 # Slider handling
204 # ------------------------------------------------------------------
205
206 def _on_slider(self, key: str, value: float) -> None:
207 if key == "resolution":
208 new_res = max(2, int(round(value)))
209 if new_res != self.planet.resolution:
210 # Resolution changes are heavier: defer with a 4-frame cooldown.
211 self._regen_pending_resolution = new_res
212 self._regen_pending = True
213 return
214 apply_slider_value(self.planet.shape_settings, key, value)
215 self._regen_pending = True
216
217
218# ---------------------------------------------------------------------------
219# Headless capture mode
220# ---------------------------------------------------------------------------
221
222def _run_headless() -> None:
223 """Capture 8 staged screenshots in a single run_headless invocation.
224
225 Strategy: schedule each stage's mutator at a specific frame index. The
226 mutator runs in `on_frame` (BEFORE the engine ticks/renders that frame),
227 so by the time `capture_frame()` fires at the *end* of that frame, the
228 scene reflects the new shape. We use 4 frames per stage so the
229 mesh-rebuild cost amortises and we don't capture a half-built planet.
230 """
231 from simvx.graphics import save_png
232
233 from nodes.controls import apply_slider_value as _apply
234 from nodes.shape import default_shape_settings
235
236 out_dir = _PORT_DIR / "screenshots"
237 out_dir.mkdir(exist_ok=True)
238
239 stages: list[tuple[str, callable]] = []
240
241 def stage(name, fn):
242 stages.append((name, fn))
243
244 stage("01_boot.png", lambda root: None)
245
246 def smooth(root):
247 s = default_shape_settings()
248 s.layers[1].enabled = False
249 root.planet.update_shape(s)
250 stage("02_smooth.png", smooth)
251
252 def ridged(root):
253 s = default_shape_settings()
254 _apply(s, "ridge_strength", 0.85)
255 _apply(s, "base_strength", 0.25)
256 root.planet.update_shape(s)
257 stage("03_ridged.png", ridged)
258
259 def low_elev(root):
260 s = default_shape_settings()
261 _apply(s, "base_strength", 0.05)
262 _apply(s, "ridge_strength", 0.02)
263 root.planet.update_shape(s)
264 stage("04_low_elev.png", low_elev)
265
266 def high_elev(root):
267 s = default_shape_settings()
268 _apply(s, "base_strength", 0.95)
269 _apply(s, "ridge_strength", 0.85)
270 root.planet.update_shape(s)
271 stage("05_high_elev.png", high_elev)
272
273 def mid_drag(root):
274 # Mid-drag stage: slider panel is included so this screenshot doubles
275 # as proof the UI renders (sized + anchored).
276 s = default_shape_settings()
277 _apply(s, "base_strength", 0.55)
278 _apply(s, "ridge_strength", 0.45)
279 _apply(s, "base_freq", 1.6)
280 root.planet.update_shape(s)
281 root.planet.set_resolution(48)
282 # Inject the panel: only this stage shows it; the others stay clean
283 # for the planet-only beauty shots.
284 if root._panel is None:
285 from nodes.controls import SliderPanel as _SP
286 root._panel = _SP(root.planet.shape_settings, root._on_slider)
287 root.add_child(root._panel)
288 # Force a layout pass so the panel is positioned this frame.
289 root._panel.queue_redraw()
290 stage("06_dragging.png", mid_drag)
291
292 def high_res(root):
293 # Final high-detail render with moderately exaggerated terrain so the
294 # extra resolution is visually obvious at framing distance.
295 s = default_shape_settings()
296 _apply(s, "base_strength", 0.45)
297 _apply(s, "ridge_strength", 0.7)
298 _apply(s, "base_freq", 1.4)
299 root.planet.update_shape(s)
300 root.planet.set_resolution(160)
301 stage("07_high_res.png", high_res)
302
303 def cinematic(root):
304 root._yaw_deg = 90.0
305 root._pitch_deg = 28.0
306 root._distance = 5.0
307 root._update_camera()
308 stage("08_orbit.png", cinematic)
309
310 # Per-stage budget: 4 frames (mutate, settle, render, capture).
311 settle_frames = 4
312 capture_indices: list[int] = []
313 boot_frames = 4 # let the planet build before the first capture
314 capture_indices.append(boot_frames - 1)
315 next_frame = boot_frames
316 schedule: dict[int, callable] = {}
317 for _name, mutator in stages[1:]:
318 schedule[next_frame] = mutator
319 capture_indices.append(next_frame + settle_frames - 1)
320 next_frame += settle_frames
321 total_frames = next_frame + 1
322
323 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (test)", visible=False)
324 root = PlanetRoot()
325 root._is_headless = True
326
327 def on_frame(idx, _t):
328 if idx in schedule:
329 try:
330 schedule[idx](root)
331 except Exception as e: # don't crash the capture sweep
332 print(f"[--test] stage at frame {idx} failed: {e!r}")
333 return None
334
335 captured = app.run_headless(
336 root,
337 frames=total_frames,
338 capture_frames=capture_indices,
339 on_frame=on_frame,
340 )
341
342 for (name, _), img in zip(stages, captured, strict=False):
343 save_png(out_dir / name, img)
344 print(f"saved {out_dir / name}")
345
346
347def main() -> None:
348 if "--test" in sys.argv:
349 _run_headless()
350 return
351 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (SimVX)")
352 app.run(PlanetRoot())
353
354
355if __name__ == "__main__":
356 main()