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¶

# 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 / 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

  • 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.

  • 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.

432

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.

169

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.

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