Procedural Planets

Lague’s quad-sphere tutorial, multi-octave noise, biome ramp, IBL.

▶ Run in browser

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

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

  • Mouse wheel: zoom in/out

  • Right-click drag: orbit camera (yaw + pitch)

  • 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

  • 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

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