Procedural Planets¶
quad-sphere terrain built from layered noise and shaded by a biome ramp.
â–¶ Run in browserUpstream: https://github.com/SebLague/Procedural-Planets
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-2
Procedural Planets: SimVX Port¶
A SimVX port of SebLague/Procedural-Planets (E07, the 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¶
# from the repo root
uv run python examples/ports/procedural_planets/main.py # interactive
uv run python examples/ports/procedural_planets/main.py --test # headless capture (8 staged screenshots in screenshots/)
uv run python examples/ports/procedural_planets/harness.py # CPU-only noise + mesh smoke test (no Vulkan)
uv run simvx export web examples/ports/procedural_planets/main.py \
-o /tmp/procedural_planets.html
Controls¶
The port opens on a title card: click, or press Space / Enter, to dismiss it and
bring up the slider panel. The bottom strip then summarises the drag, scroll and key
controls while you play.
Drag anywhere off the UI (the slider panel and the bottom strip): orbit the camera (yaw + pitch)
Mouse wheel, or a two-finger pinch on touch: zoom in/out
A/DorLeft/Right: orbit camera horizontallyW/SorUp/Down: zoom in/outR: 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 UInodes/title.py:TitleOverlaytitle cardharness.py: CPU-only smoke / perf testassets/sky.hdr(optional): equirectangular HDR skybox; falls back to a procedural blue gradient if absentscreenshots/:--testoutput
Deviations from upstream¶
The noise filters are evaluated a whole cube face at a time with NumPy rather than per vertex, which is what makes editing the planet interactively viable at all. A full six-face rebuild is still far too slow to hide inside one frame in pure Python (of the order of 100 ms at the default resolution of 64, and close to a second at the slider’s 192 maximum), which is why slider drags coalesce their rebuilds. Run
harness.pyto time it on your machine.Elevation min/max is locked by a pre-flight pass over all six faces before any mesh is built, so the biome ramp lines up across face seams.
Slider drags request a regenerate rather than performing one: the request is coalesced to at most one rebuild every few frames.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Procedural Planets: quad-sphere terrain built from layered noise and shaded by a biome ramp. |
460 |
|
Procedural Planets: CPU-only harness. |
89 |
|
Procedural Planets: port nodes. |
44 |
|
Biome colour ramp generator: port of Lague’s ColourGenerator. |
252 |
|
SliderPanel: bottom-left anchored noise sliders. |
177 |
|
Planet: root 3D node owning 6 cube-face MeshInstance3D children. |
133 |
|
Shape generator: vectorised numpy port of Lague’s ShapeGenerator + noise filters. |
236 |
|
Single quad-sphere face: vectorised port of Lague’s TerrainFace. |
139 |
|
TitleOverlay: the title card the port opens on, before the planet is interactive. |
73 |
Source¶
1"""Procedural Planets: quad-sphere terrain built from layered noise and shaded by a biome ramp.
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
9A SimVX port of Sebastian Lague's Procedural Planets series. Six cube faces are
10normalised onto a sphere and displaced by a stack of multi-octave Perlin and
11ridge noise filters, evaluated a whole face at a time with NumPy on top of
12SimVX's `FastNoiseLite`. Elevation drives a per-biome colour ramp texture, and
13the planet is lit by a directional sun inside a `WorldEnvironment` sky with
14bloom. Drag the sliders to reshape the world while it turns: slider changes are
15coalesced so a drag does not queue a rebuild per pixel. Each rebuild is still a
16blocking numpy pass on the main thread, so expect a visible hitch that grows
17with mesh resolution, from a fraction of a second at the default up to a few
18seconds at the slider's maximum.
19
20Drag anywhere outside the slider panel to orbit, scroll or pinch to zoom, `R`
21rebuilds the mesh, `Q` or `Escape` quits.
22
23Run:
24 uv run python examples/ports/procedural_planets/main.py # interactive
25 uv run python examples/ports/procedural_planets/main.py --test # headless capture
26"""
27
28from __future__ import annotations
29
30import math
31import sys
32from collections.abc import Callable
33from pathlib import Path
34
35_PORT_DIR = Path(__file__).parent
36if str(_PORT_DIR) not in sys.path:
37 sys.path.insert(0, str(_PORT_DIR))
38
39from nodes.controls import SliderPanel, apply_slider_value # noqa: E402
40from nodes.planet import Planet # noqa: E402
41from nodes.title import TitleOverlay # noqa: E402
42
43from simvx.core import ( # noqa: E402
44 DirectionalLight3D,
45 GestureRecognizer,
46 Input,
47 Key,
48 MouseButton,
49 Node,
50 OrbitCamera3D,
51 Vec3,
52 WorldEnvironment,
53)
54from simvx.core.ui import BottomControlsStrip # noqa: E402
55from simvx.graphics import App # noqa: E402
56
57WIDTH = 1280
58HEIGHT = 720
59
60MIN_DISTANCE = 2.4
61MAX_DISTANCE = 20.0
62STRIP_HEIGHT = 32.0
63
64CONTROL_HINTS = [
65 "DRAG to orbit",
66 "SCROLL or PINCH to zoom",
67 "A/D W/S to orbit and zoom",
68 "R to regenerate",
69 "Q to quit",
70]
71
72
73# ---------------------------------------------------------------------------
74# HDR skybox helper: falls back to procedural blue if no .hdr is present
75# ---------------------------------------------------------------------------
76
77
78def _make_environment_map() -> dict:
79 """Return the environment_map spec for WorldEnvironment.
80
81 If `assets/sky.hdr` exists, load it as an equirectangular HDR cubemap;
82 otherwise return a procedural gradient (deep blue) that the renderer
83 converts to a cubemap on first install.
84 """
85 hdr_path = _PORT_DIR / "assets" / "sky.hdr"
86 if hdr_path.exists():
87 return {"path": str(hdr_path)}
88 return {"colour": (0.05, 0.07, 0.15)}
89
90
91# ---------------------------------------------------------------------------
92# Root scene
93# ---------------------------------------------------------------------------
94
95
96class PlanetRoot(Node):
97 """Root: WorldEnvironment + OrbitCamera3D + DirectionalLight3D + Planet + UI.
98
99 Args:
100 headless: Skip the title card and the slider panel so `--test` captures
101 render the planet alone.
102 show_panel: Force the slider panel on or off; defaults to "on once the
103 title card is dismissed".
104 """
105
106 # The canonical registration path: the scene tree consumes this at mount,
107 # which also covers the web export (it never calls `main()`).
108 input_actions = {
109 "orbit": [MouseButton.LEFT, MouseButton.RIGHT],
110 "orbit_left": [Key.A, Key.LEFT],
111 "orbit_right": [Key.D, Key.RIGHT],
112 "zoom_in": [Key.W, Key.UP],
113 "zoom_out": [Key.S, Key.DOWN],
114 "start": [Key.SPACE, Key.ENTER],
115 "regen": [Key.R],
116 "quit": [Key.ESCAPE, Key.Q],
117 }
118
119 def __init__(self, *, headless: bool = False, show_panel: bool | None = None, **kwargs) -> None:
120 super().__init__(**kwargs)
121 self._headless = headless
122 self._show_panel = (not headless) if show_panel is None else show_panel
123 self._panel: SliderPanel | None = None
124 self._strip: BottomControlsStrip | None = None
125 self._menu: TitleOverlay | None = None
126 # True while a pointer drag that began off the UI is orbiting the camera.
127 self._dragging = False
128 # Coalesce slider changes: regenerate at most once every K frames.
129 self._regen_pending = False
130 self._regen_cooldown = 0
131 self._regen_pending_resolution: int | None = None
132
133 def on_ready(self) -> None:
134 # Environment + IBL: gradient skybox unless an HDR is sitting in
135 # assets/sky.hdr. Bloom helps the specular highlight pop.
136 env = WorldEnvironment(
137 environment_map=_make_environment_map(),
138 )
139 env.bloom_enabled = True
140 env.bloom_threshold = 1.05
141 env.bloom_intensity = 0.5
142 env.tonemap_exposure = 1.05
143 self.add_child(env)
144
145 # OrbitCamera3D derives its position from pivot/distance/yaw/pitch, so
146 # the port never touches camera trigonometry. Angles are radians and a
147 # negative pitch places the camera above the planet, looking down.
148 self._cam = OrbitCamera3D(
149 name="Camera",
150 fov=55.0,
151 near=0.05,
152 far=200.0,
153 distance=5.5,
154 yaw=math.radians(25.0),
155 pitch=math.radians(-18.0),
156 )
157 self.add_child(self._cam)
158
159 # Sun.
160 sun = DirectionalLight3D(name="Sun", intensity=1.6)
161 sun.colour = (1.0, 0.97, 0.92)
162 sun.position = Vec3(4.0, 3.0, 2.5)
163 sun.look_at(Vec3(0.0, 0.0, 0.0))
164 self.add_child(sun)
165
166 # Planet.
167 self.planet = Planet(resolution=64)
168 self.add_child(self.planet)
169
170 if self._headless:
171 if self._show_panel:
172 self.show_slider_panel()
173 return
174
175 # Two-finger pinch is the touch equivalent of the scroll wheel.
176 gestures = GestureRecognizer(name="Gestures")
177 gestures.pinch.connect(self._on_pinch)
178 self.add_child(gestures)
179
180 self._strip = BottomControlsStrip(hints=CONTROL_HINTS)
181 self._strip.place_bottom_strip(STRIP_HEIGHT)
182 self.add_child(self._strip)
183
184 self._menu = TitleOverlay(self._start, controls=CONTROL_HINTS)
185 self.add_child(self._menu)
186
187 # ------------------------------------------------------------------
188 # Title card
189 # ------------------------------------------------------------------
190
191 def _start(self) -> None:
192 """Dismiss the title card and bring up the slider panel."""
193 if self._menu is None:
194 return
195 self.remove_child(self._menu)
196 self._menu = None
197 if self._show_panel:
198 self.show_slider_panel()
199
200 def show_slider_panel(self) -> SliderPanel:
201 """Add the noise-slider panel if it is not up already, and return it."""
202 if self._panel is None:
203 self._panel = SliderPanel(
204 self.planet.shape_settings,
205 self._on_slider,
206 bottom_margin=STRIP_HEIGHT + 8.0,
207 )
208 self.add_child(self._panel)
209 return self._panel
210
211 def hide_slider_panel(self) -> None:
212 """Remove the noise-slider panel if it is up."""
213 if self._panel is not None:
214 self.remove_child(self._panel)
215 self._panel = None
216
217 # ------------------------------------------------------------------
218 # Camera
219 # ------------------------------------------------------------------
220
221 def set_view(self, yaw_deg: float, pitch_deg: float, distance: float) -> None:
222 """Frame the planet from a fixed angle (used by the capture stages)."""
223 self._cam.yaw = math.radians(yaw_deg)
224 self._cam.pitch = math.radians(pitch_deg)
225 self._cam.distance = max(MIN_DISTANCE, min(MAX_DISTANCE, distance))
226 self._cam.update_transform()
227
228 def _zoom(self, delta: float) -> None:
229 """Move the camera `delta` units closer, clamped to the framing range."""
230 self._cam.distance = max(MIN_DISTANCE, min(MAX_DISTANCE, self._cam.distance - delta))
231 self._cam.update_transform()
232
233 def _on_pinch(self, scale: float, _cx: float, _cy: float) -> None:
234 self._zoom((scale - 1.0) * self._cam.distance)
235
236 def _update_drag(self) -> None:
237 """Orbit the camera while a pointer drag that began off the UI is held."""
238 if Input.is_action_just_pressed("orbit"):
239 self._dragging = not self._pointer_over_ui()
240 if Input.is_action_just_released("orbit") or len(Input.touches) >= 2:
241 # A second finger means the gesture is a pinch, not an orbit drag.
242 self._dragging = False
243 if not self._dragging:
244 return
245 delta = Input.mouse_delta
246 if delta.x or delta.y:
247 self._cam.orbit(math.radians(-delta.x * 0.4), math.radians(-delta.y * 0.3))
248
249 def _pointer_over_ui(self) -> bool:
250 """True when the cursor sits on the slider panel or the controls strip."""
251 pos = Input.mouse_position
252 return any(ctl is not None and ctl.is_point_inside(pos) for ctl in (self._panel, self._strip))
253
254 # ------------------------------------------------------------------
255 # Per-frame update
256 # ------------------------------------------------------------------
257
258 def on_update(self, dt: float) -> None:
259 if Input.is_action_just_pressed("quit"):
260 self.app.quit()
261 return
262
263 # Idle planet rotation (slow, like upstream demo).
264 self.planet.rotate_y(0.05 * dt)
265
266 if self._menu is not None:
267 # Any click or key starts, not just the button: touch users should
268 # not have to find a 260 px target.
269 if Input.is_action_just_pressed("start") or Input.is_action_just_pressed("orbit"):
270 self._start()
271 return
272
273 # Keyboard orbit + zoom.
274 if Input.is_action_pressed("orbit_left"):
275 self._cam.orbit(math.radians(80.0 * dt), 0.0)
276 if Input.is_action_pressed("orbit_right"):
277 self._cam.orbit(math.radians(-80.0 * dt), 0.0)
278 if Input.is_action_pressed("zoom_in"):
279 self._zoom(4.0 * dt)
280 if Input.is_action_pressed("zoom_out"):
281 self._zoom(-4.0 * dt)
282 if Input.is_action_just_pressed("regen"):
283 self.planet.regenerate()
284
285 self._update_drag()
286
287 # Mouse-wheel zoom.
288 wheel = Input.mouse_wheel_y
289 if wheel:
290 self._zoom(wheel * 0.5)
291
292 # Coalesced regen.
293 if self._regen_cooldown > 0:
294 self._regen_cooldown -= 1
295 if self._regen_pending and self._regen_cooldown <= 0:
296 self._regen_pending = False
297 self._regen_cooldown = 4
298 if self._regen_pending_resolution is not None:
299 self.planet.set_resolution(self._regen_pending_resolution)
300 self._regen_pending_resolution = None
301 else:
302 self.planet.regenerate()
303
304 # ------------------------------------------------------------------
305 # Slider handling
306 # ------------------------------------------------------------------
307
308 def _on_slider(self, key: str, value: float) -> None:
309 if key == "resolution":
310 new_res = max(2, int(round(value)))
311 if new_res != self.planet.resolution:
312 # Resolution changes are heavier: defer with a 4-frame cooldown.
313 self._regen_pending_resolution = new_res
314 self._regen_pending = True
315 return
316 apply_slider_value(self.planet.shape_settings, key, value)
317 self._regen_pending = True
318
319
320# ---------------------------------------------------------------------------
321# Headless capture mode
322# ---------------------------------------------------------------------------
323
324
325def _run_headless() -> None:
326 """Capture 8 staged screenshots in a single run_headless invocation.
327
328 Strategy: schedule each stage's mutator at a specific frame index. The
329 mutator runs in `on_frame` (BEFORE the engine ticks/renders that frame),
330 so by the time `capture_frame()` fires at the *end* of that frame, the
331 scene reflects the new shape. We use 4 frames per stage so the
332 mesh-rebuild cost amortises and we don't capture a half-built planet.
333 """
334 from nodes.controls import apply_slider_value as _apply
335 from nodes.shape import default_shape_settings
336
337 from simvx.graphics import save_png
338
339 out_dir = _PORT_DIR / "screenshots"
340 out_dir.mkdir(exist_ok=True)
341
342 Mutator = Callable[[PlanetRoot], None]
343 stages: list[tuple[str, Mutator]] = []
344
345 def stage(name: str, fn: Mutator) -> None:
346 stages.append((name, fn))
347
348 stage("01_boot.png", lambda root: None)
349
350 def smooth(root: PlanetRoot) -> None:
351 s = default_shape_settings()
352 s.layers[1].enabled = False
353 root.planet.update_shape(s)
354
355 stage("02_smooth.png", smooth)
356
357 def ridged(root: PlanetRoot) -> None:
358 s = default_shape_settings()
359 _apply(s, "ridge_strength", 0.85)
360 _apply(s, "base_strength", 0.25)
361 root.planet.update_shape(s)
362
363 stage("03_ridged.png", ridged)
364
365 def low_elev(root: PlanetRoot) -> None:
366 s = default_shape_settings()
367 _apply(s, "base_strength", 0.05)
368 _apply(s, "ridge_strength", 0.02)
369 root.planet.update_shape(s)
370
371 stage("04_low_elev.png", low_elev)
372
373 def high_elev(root: PlanetRoot) -> None:
374 s = default_shape_settings()
375 _apply(s, "base_strength", 0.95)
376 _apply(s, "ridge_strength", 0.85)
377 root.planet.update_shape(s)
378
379 stage("05_high_elev.png", high_elev)
380
381 def mid_drag(root: PlanetRoot) -> None:
382 # Mid-drag stage: slider panel is included so this screenshot doubles
383 # as proof the UI renders (sized + anchored).
384 s = default_shape_settings()
385 _apply(s, "base_strength", 0.55)
386 _apply(s, "ridge_strength", 0.45)
387 _apply(s, "base_freq", 1.6)
388 root.planet.update_shape(s)
389 root.planet.set_resolution(48)
390 # Only this stage shows the panel; the rest stay clean for the
391 # planet-only beauty shots. queue_redraw forces a layout pass so the
392 # panel is positioned this frame.
393 root.show_slider_panel().queue_redraw()
394
395 stage("06_dragging.png", mid_drag)
396
397 def high_res(root: PlanetRoot) -> None:
398 # Final high-detail render with moderately exaggerated terrain so the
399 # extra resolution is visually obvious at framing distance.
400 root.hide_slider_panel()
401 s = default_shape_settings()
402 _apply(s, "base_strength", 0.45)
403 _apply(s, "ridge_strength", 0.7)
404 _apply(s, "base_freq", 1.4)
405 root.planet.update_shape(s)
406 root.planet.set_resolution(160)
407
408 stage("07_high_res.png", high_res)
409
410 def cinematic(root: PlanetRoot) -> None:
411 root.set_view(yaw_deg=90.0, pitch_deg=-28.0, distance=5.0)
412
413 stage("08_orbit.png", cinematic)
414
415 # Per-stage budget: 4 frames (mutate, settle, render, capture).
416 settle_frames = 4
417 capture_indices: list[int] = []
418 boot_frames = 4 # let the planet build before the first capture
419 capture_indices.append(boot_frames - 1)
420 next_frame = boot_frames
421 schedule: dict[int, Mutator] = {}
422 for _name, mutator in stages[1:]:
423 schedule[next_frame] = mutator
424 capture_indices.append(next_frame + settle_frames - 1)
425 next_frame += settle_frames
426 total_frames = next_frame + 1
427
428 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (test)", visible=False)
429 root = PlanetRoot(headless=True)
430
431 def on_frame(idx, _t):
432 if idx in schedule:
433 try:
434 schedule[idx](root)
435 except Exception as e: # don't crash the capture sweep
436 print(f"[--test] stage at frame {idx} failed: {e!r}")
437 return None
438
439 captured = app.run_headless(
440 root,
441 frames=total_frames,
442 capture_frames=capture_indices,
443 on_frame=on_frame,
444 )
445
446 for (name, _), img in zip(stages, captured, strict=False):
447 save_png(img, out_dir / name)
448 print(f"saved {out_dir / name}")
449
450
451def main() -> None:
452 if "--test" in sys.argv:
453 _run_headless()
454 return
455 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (SimVX)")
456 app.run(PlanetRoot())
457
458
459if __name__ == "__main__":
460 main()