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¶
# the export runs in a copy made by `simvx examples copy ports_procedural_planets`
simvx examples run ports_procedural_planets # interactive
simvx examples run ports_procedural_planets --test # headless capture (8 staged screenshots in screenshots/)
simvx export web main.py \
-o 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 cardassets/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.
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. |
432 |
|
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. |
169 |
|
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. |
65 |
Source¶
1# /// simvx
2# tags = ["port", "tier-2"]
3# upstream = "https://github.com/SebLague/Procedural-Planets"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Procedural Planets: quad-sphere terrain built from layered noise and shaded by a biome ramp.
7
8A SimVX port of Sebastian Lague's Procedural Planets series. Six cube faces are
9normalised onto a sphere and displaced by a stack of multi-octave Perlin and
10ridge noise filters, evaluated a whole face at a time with NumPy on top of
11SimVX's `FastNoiseLite`. Elevation drives a per-biome colour ramp texture, and
12the planet is lit by a directional sun inside a `WorldEnvironment` sky with
13bloom. Drag the sliders to reshape the world while it turns: slider changes are
14coalesced so a drag does not queue a rebuild per pixel. Each rebuild is still a
15blocking numpy pass on the main thread, so expect a visible hitch that grows
16with mesh resolution, from a fraction of a second at the default up to a few
17seconds at the slider's maximum.
18
19The camera is an `OrbitCamera3D`, which drags, zooms and takes the right stick
20by itself: drag anywhere outside the slider panel to orbit, scroll or pinch to
21zoom. The row along the bottom shows the keys and presses them: `A`/`D` orbit,
22`W`/`S` zoom, `R` rebuilds the mesh, `Q` or `Escape` quits.
23
24Run:
25 simvx examples run ports_procedural_planets # interactive
26 simvx examples run ports_procedural_planets --test # headless capture
27"""
28
29from __future__ import annotations
30
31import math
32import sys
33from collections.abc import Callable
34from pathlib import Path
35
36_PORT_DIR = Path(__file__).parent
37
38from nodes.controls import SliderPanel, apply_slider_value # noqa: E402
39from nodes.planet import Planet # noqa: E402
40from nodes.title import TitleOverlay # noqa: E402
41
42from simvx.core import (
43 DirectionalLight3D,
44 HBoxContainer,
45 Input,
46 Key,
47 Node,
48 OrbitCamera3D,
49 PointerFilter,
50 Vec3,
51 WorldEnvironment,
52)
53from simvx.core.ui import Anchor, Button # noqa: E402
54from simvx.graphics import App # noqa: E402
55
56WIDTH = 1280
57HEIGHT = 720
58
59MIN_DISTANCE = 2.4
60MAX_DISTANCE = 20.0
61ROW_HEIGHT = 44.0
62
63CONTROL_HINTS = [
64 "DRAG to orbit",
65 "SCROLL or PINCH to zoom",
66 "A/D W/S to orbit and zoom",
67 "R to regenerate",
68 "Q to quit",
69]
70
71
72# ---------------------------------------------------------------------------
73# HDR skybox helper: falls back to procedural blue if no .hdr is present
74# ---------------------------------------------------------------------------
75
76
77def _make_environment_map() -> dict:
78 """Return the environment_map spec for WorldEnvironment.
79
80 If `assets/sky.hdr` exists, load it as an equirectangular HDR cubemap;
81 otherwise return a procedural gradient (deep blue) that the renderer
82 converts to a cubemap on first install.
83 """
84 hdr_path = _PORT_DIR / "assets" / "sky.hdr"
85 if hdr_path.exists():
86 return {"path": str(hdr_path)}
87 return {"colour": (0.05, 0.07, 0.15)}
88
89
90# ---------------------------------------------------------------------------
91# Root scene
92# ---------------------------------------------------------------------------
93
94
95class PlanetRoot(Node):
96 """Root: WorldEnvironment + OrbitCamera3D + DirectionalLight3D + Planet + UI.
97
98 Args:
99 headless: Skip the title card and the slider panel so `--test` captures
100 render the planet alone.
101 show_panel: Force the slider panel on or off; defaults to "on once the
102 title card is dismissed".
103 """
104
105 # The canonical registration path: in force while this scene is mounted,
106 # on the desktop and in the web export alike.
107 input_context = {
108 "orbit_left": [Key.A, Key.LEFT],
109 "orbit_right": [Key.D, Key.RIGHT],
110 "zoom_in": [Key.W, Key.UP],
111 "zoom_out": [Key.S, Key.DOWN],
112 "start": [Key.SPACE, Key.ENTER],
113 "regen": [Key.R],
114 "quit": [Key.ESCAPE, Key.Q],
115 }
116
117 def __init__(self, *, headless: bool = False, show_panel: bool | None = None, **kwargs) -> None:
118 super().__init__(**kwargs)
119 self._headless = headless
120 self._show_panel = (not headless) if show_panel is None else show_panel
121 self._panel: SliderPanel | None = None
122 self._menu: TitleOverlay | None = None
123 # Coalesce slider changes: regenerate at most once every K frames.
124 self._regen_pending = False
125 self._regen_cooldown = 0
126 self._regen_pending_resolution: int | None = None
127
128 def on_ready(self) -> None:
129 # Environment + IBL: gradient skybox unless an HDR is sitting in
130 # assets/sky.hdr. Bloom helps the specular highlight pop.
131 env = WorldEnvironment(
132 environment_map=_make_environment_map(),
133 )
134 env.bloom_enabled = True
135 env.bloom_threshold = 1.05
136 env.bloom_intensity = 0.5
137 env.tonemap_exposure = 1.05
138 self.add_child(env)
139
140 # OrbitCamera3D derives its position from pivot/distance/yaw/pitch, so
141 # the port never touches camera trigonometry. Angles are radians and a
142 # negative pitch places the camera above the planet, looking down.
143 self._cam = OrbitCamera3D(
144 name="Camera",
145 fov=55.0,
146 near=0.05,
147 far=200.0,
148 distance=5.5,
149 yaw=math.radians(25.0),
150 pitch=math.radians(-18.0),
151 min_distance=MIN_DISTANCE,
152 max_distance=MAX_DISTANCE,
153 interactive=not self._headless,
154 )
155 self.add_child(self._cam)
156
157 # Sun.
158 sun = DirectionalLight3D(name="Sun", intensity=1.6)
159 sun.colour = (1.0, 0.97, 0.92)
160 sun.position = Vec3(4.0, 3.0, 2.5)
161 sun.look_at(Vec3(0.0, 0.0, 0.0))
162 self.add_child(sun)
163
164 # Planet.
165 self.planet = Planet(resolution=64)
166 self.add_child(self.planet)
167
168 if self._headless:
169 if self._show_panel:
170 self.show_slider_panel()
171 return
172
173 # The keyboard controls, pressable: each button shows its key and
174 # presses it. The row ignores the pointer, so a drag beside the
175 # buttons still orbits.
176 controls = self.add_child(
177 HBoxContainer(name="Controls", anchor=Anchor.BOTTOM_WIDE, size=(0, ROW_HEIGHT - 6.0), inset=(12, 0, 0, 6))
178 )
179 controls.alignment = "centre"
180 controls.separation = 8.0
181 controls.pointer_filter = PointerFilter.IGNORE
182 for text, action in (
183 ("Orbit left", "orbit_left"),
184 ("Orbit right", "orbit_right"),
185 ("Zoom in", "zoom_in"),
186 ("Zoom out", "zoom_out"),
187 ("Regenerate", "regen"),
188 ("Quit", "quit"),
189 ):
190 controls.add_child(Button(text, action=action, name=text))
191
192 self._menu = TitleOverlay(self._start, controls=CONTROL_HINTS)
193 self.add_child(self._menu)
194
195 # ------------------------------------------------------------------
196 # Title card
197 # ------------------------------------------------------------------
198
199 def _start(self) -> None:
200 """Dismiss the title card and bring up the slider panel."""
201 if self._menu is None:
202 return
203 self.remove_child(self._menu)
204 self._menu = None
205 if self._show_panel:
206 self.show_slider_panel()
207
208 def show_slider_panel(self) -> SliderPanel:
209 """Add the noise-slider panel if it is not up already, and return it."""
210 if self._panel is None:
211 self._panel = SliderPanel(
212 self.planet.shape_settings,
213 self._on_slider,
214 bottom_margin=ROW_HEIGHT + 8.0,
215 )
216 self.add_child(self._panel)
217 return self._panel
218
219 def hide_slider_panel(self) -> None:
220 """Remove the noise-slider panel if it is up."""
221 if self._panel is not None:
222 self.remove_child(self._panel)
223 self._panel = None
224
225 # ------------------------------------------------------------------
226 # Camera
227 # ------------------------------------------------------------------
228
229 def set_view(self, yaw_deg: float, pitch_deg: float, distance: float) -> None:
230 """Frame the planet from a fixed angle (used by the capture stages)."""
231 self._cam.yaw = math.radians(yaw_deg)
232 self._cam.pitch = math.radians(pitch_deg)
233 self._cam.distance = max(MIN_DISTANCE, min(MAX_DISTANCE, distance))
234
235 # ------------------------------------------------------------------
236 # Per-frame update
237 # ------------------------------------------------------------------
238
239 def on_update(self, dt: float) -> None:
240 if Input.just_pressed("quit"):
241 self.app.quit()
242 return
243
244 # Idle planet rotation (slow, like upstream demo).
245 self.planet.rotate_y(0.05 * dt)
246
247 if self._menu is not None:
248 if Input.just_pressed("start"):
249 self._start()
250 return
251
252 # Keyboard orbit + zoom; the camera takes the pointer and the pad itself.
253 if Input.pressed("orbit_left"):
254 self._cam.orbit(math.radians(80.0 * dt), 0.0)
255 if Input.pressed("orbit_right"):
256 self._cam.orbit(math.radians(-80.0 * dt), 0.0)
257 if Input.pressed("zoom_in"):
258 self._cam.zoom(4.0 * dt)
259 if Input.pressed("zoom_out"):
260 self._cam.zoom(-4.0 * dt)
261 if Input.just_pressed("regen"):
262 self.planet.regenerate()
263
264 # Coalesced regen.
265 if self._regen_cooldown > 0:
266 self._regen_cooldown -= 1
267 if self._regen_pending and self._regen_cooldown <= 0:
268 self._regen_pending = False
269 self._regen_cooldown = 4
270 if self._regen_pending_resolution is not None:
271 self.planet.set_resolution(self._regen_pending_resolution)
272 self._regen_pending_resolution = None
273 else:
274 self.planet.regenerate()
275
276 # ------------------------------------------------------------------
277 # Slider handling
278 # ------------------------------------------------------------------
279
280 def _on_slider(self, key: str, value: float) -> None:
281 if key == "resolution":
282 new_res = max(2, int(round(value)))
283 if new_res != self.planet.resolution:
284 # Resolution changes are heavier: defer with a 4-frame cooldown.
285 self._regen_pending_resolution = new_res
286 self._regen_pending = True
287 return
288 apply_slider_value(self.planet.shape_settings, key, value)
289 self._regen_pending = True
290
291
292# ---------------------------------------------------------------------------
293# Headless capture mode
294# ---------------------------------------------------------------------------
295
296
297def _run_headless() -> None:
298 """Capture 8 staged screenshots in a single run_headless invocation.
299
300 Strategy: schedule each stage's mutator at a specific frame index. The
301 mutator runs in `on_frame` (BEFORE the engine ticks/renders that frame),
302 so by the time `capture_frame()` fires at the *end* of that frame, the
303 scene reflects the new shape. We use 4 frames per stage so the
304 mesh-rebuild cost amortises and we don't capture a half-built planet.
305 """
306 from nodes.controls import apply_slider_value as _apply
307 from nodes.shape import default_shape_settings
308
309 from simvx.graphics import save_png
310
311 out_dir = _PORT_DIR / "screenshots"
312 out_dir.mkdir(exist_ok=True)
313
314 Mutator = Callable[[PlanetRoot], None]
315 stages: list[tuple[str, Mutator]] = []
316
317 def stage(name: str, fn: Mutator) -> None:
318 stages.append((name, fn))
319
320 stage("01_boot.png", lambda root: None)
321
322 def smooth(root: PlanetRoot) -> None:
323 s = default_shape_settings()
324 s.layers[1].enabled = False
325 root.planet.update_shape(s)
326
327 stage("02_smooth.png", smooth)
328
329 def ridged(root: PlanetRoot) -> None:
330 s = default_shape_settings()
331 _apply(s, "ridge_strength", 0.85)
332 _apply(s, "base_strength", 0.25)
333 root.planet.update_shape(s)
334
335 stage("03_ridged.png", ridged)
336
337 def low_elev(root: PlanetRoot) -> None:
338 s = default_shape_settings()
339 _apply(s, "base_strength", 0.05)
340 _apply(s, "ridge_strength", 0.02)
341 root.planet.update_shape(s)
342
343 stage("04_low_elev.png", low_elev)
344
345 def high_elev(root: PlanetRoot) -> None:
346 s = default_shape_settings()
347 _apply(s, "base_strength", 0.95)
348 _apply(s, "ridge_strength", 0.85)
349 root.planet.update_shape(s)
350
351 stage("05_high_elev.png", high_elev)
352
353 def mid_drag(root: PlanetRoot) -> None:
354 # Mid-drag stage: slider panel is included so this screenshot doubles
355 # as proof the UI renders (sized + anchored).
356 s = default_shape_settings()
357 _apply(s, "base_strength", 0.55)
358 _apply(s, "ridge_strength", 0.45)
359 _apply(s, "base_freq", 1.6)
360 root.planet.update_shape(s)
361 root.planet.set_resolution(48)
362 # Only this stage shows the panel; the rest stay clean for the
363 # planet-only beauty shots. queue_redraw forces a layout pass so the
364 # panel is positioned this frame.
365 root.show_slider_panel().queue_redraw()
366
367 stage("06_dragging.png", mid_drag)
368
369 def high_res(root: PlanetRoot) -> None:
370 # Final high-detail render with moderately exaggerated terrain so the
371 # extra resolution is visually obvious at framing distance.
372 root.hide_slider_panel()
373 s = default_shape_settings()
374 _apply(s, "base_strength", 0.45)
375 _apply(s, "ridge_strength", 0.7)
376 _apply(s, "base_freq", 1.4)
377 root.planet.update_shape(s)
378 root.planet.set_resolution(160)
379
380 stage("07_high_res.png", high_res)
381
382 def cinematic(root: PlanetRoot) -> None:
383 root.set_view(yaw_deg=90.0, pitch_deg=-28.0, distance=5.0)
384
385 stage("08_orbit.png", cinematic)
386
387 # Per-stage budget: 4 frames (mutate, settle, render, capture).
388 settle_frames = 4
389 capture_indices: list[int] = []
390 boot_frames = 4 # let the planet build before the first capture
391 capture_indices.append(boot_frames - 1)
392 next_frame = boot_frames
393 schedule: dict[int, Mutator] = {}
394 for _name, mutator in stages[1:]:
395 schedule[next_frame] = mutator
396 capture_indices.append(next_frame + settle_frames - 1)
397 next_frame += settle_frames
398 total_frames = next_frame + 1
399
400 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (test)", visible=False)
401 root = PlanetRoot(headless=True)
402
403 def on_frame(idx, _t):
404 if idx in schedule:
405 try:
406 schedule[idx](root)
407 except Exception as e: # don't crash the capture sweep
408 print(f"[--test] stage at frame {idx} failed: {e!r}")
409 return None
410
411 captured = app.run_headless(
412 root,
413 frames=total_frames,
414 capture_frames=capture_indices,
415 on_frame=on_frame,
416 )
417
418 for (name, _), img in zip(stages, captured, strict=False):
419 save_png(img, out_dir / name)
420 print(f"saved {out_dir / name}")
421
422
423def main() -> None:
424 if "--test" in sys.argv:
425 _run_headless()
426 return
427 app = App(width=WIDTH, height=HEIGHT, title="Procedural Planets (SimVX)")
428 app.run(PlanetRoot())
429
430
431if __name__ == "__main__":
432 main()