Procedural Planets¶

quad-sphere terrain built from layered noise and shaded by a biome ramp.

â–¶ Run in browser

Upstream: 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 / D or Left / Right: orbit camera horizontally

  • W / S or Up / Down: zoom in/out

  • R: force planet regenerate

  • Q / Esc: quit

  • Slider panel (bottom-left): drag to deform the planet in real time

Files¶

  • main.py: PlanetRoot scene + --test headless mode

  • nodes/shape.py: ShapeGenerator (vectorised numpy port of upstream’s noise filters)

  • nodes/terrain_face.py: single quad-sphere face mesh builder

  • nodes/colour.py: biome ramp texture + biome% sampler

  • nodes/planet.py: Planet(Node3D) root with 6 face children + regenerate()

  • nodes/controls.py: SliderPanel bottom-left UI

  • nodes/title.py: TitleOverlay title card

  • harness.py: CPU-only smoke / perf test

  • assets/sky.hdr (optional): equirectangular HDR skybox; falls back to a procedural blue gradient if absent

  • screenshots/: --test output

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.py to 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

main.py

Procedural Planets: quad-sphere terrain built from layered noise and shaded by a biome ramp.

460

harness.py

Procedural Planets: CPU-only harness.

89

nodes/__init__.py

Procedural Planets: port nodes.

44

nodes/colour.py

Biome colour ramp generator: port of Lague’s ColourGenerator.

252

nodes/controls.py

SliderPanel: bottom-left anchored noise sliders.

177

nodes/planet.py

Planet: root 3D node owning 6 cube-face MeshInstance3D children.

133

nodes/shape.py

Shape generator: vectorised numpy port of Lague’s ShapeGenerator + noise filters.

236

nodes/terrain_face.py

Single quad-sphere face: vectorised port of Lague’s TerrainFace.

139

nodes/title.py

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