Planet Explorer¶

an endless flight over procedural terrain, weather and a day/night cycle.

â–¶ Run in browser

Tags: 3d procedural camera terrain particles

A delta-wing craft flies over land that never runs out. Terrain and clouds are built in chunks around the ship as it moves, a two-minute day repaints the sky, moves the sun and moon and brings out stars and aurora curtains at night, and a slower storm cycle greys the clouds, thickens the fog and turns rare lightning into a strike every few seconds. Now and then a meteor streaks in, becomes a fireball and flashes where it hits the ground.

Streaming terrain. ChunkStreamer keeps a square of chunks built around the ship: when the ship crosses into a new cell it drops the chunks beyond keep_radius and queues the missing ones nearest first, then builds two a frame so no single frame pays for the whole ring. A terrain chunk is a heightfield sampled from FastNoiseLite.get_noise_2d_array and built with NumPy alone: np.gradient gives the normals, one index array gives two triangles per grid square, and np.digitize sorts the triangles into biome bands (sand, grass, forest, rock, snow), one Mesh and shared Material per band. Vertices are local to the chunk’s centre, which becomes the MeshInstance3D position, so each chunk is culled by its own bounds. Clouds are the same streamer over a coarser grid, rebuilt every eight seconds by a repeating Timer with the noise shifted so they drift.

Day, night and weather. PlanetExplorer advances time_of_day (0 is midnight, 0.5 noon) and reads every sky value from two keyframe tables sharing the times in DAY_TIMES, smoothstepped between keys by day_at(): DAY_COLOURS (sky top and bottom, ambient light, sun colour) and DAY_LEVELS (sun strength, exposure, bloom threshold and intensity). It writes them to a WorldEnvironment (colour sky, exponential fog, bloom, ACES tonemapping, film grain and chromatic aberration) and to the DirectionalLight3D. The storm level blends grey into the sky and clouds and adds fog; lightning is a short spike in tonemap_exposure, sometimes doubled. Sky carries the sun and moon discs on one pivot rotated with the time of day, a MultiMesh dome of unlit stars and eight emissive aurora curtains textured from NumPy arrays, and follows the camera so none of it can be flown past.

The ship and the camera. The hull is built with MeshBuilder, each vertex given a top-down planar UV, and painted by hull_texture(), a NumPy array of panel grooves, canopy and engine strip passed straight to the material’s albedo_map. Ship turns by yaw only and banks its hull child, so the ChaseCamera trailing it (offset behind and above, a look_offset above the ship so the ship sits low in the frame) never rolls. A meteor is a small state machine over a Phase StrEnum, carrying a ParticleEmitter trail and a PointLight3D that lights the ground below it, and destroys itself when its flash fades.

Input. fly is one Axis2D action (arrow keys or the left stick): left and right turn, up and down change speed. Holding the pointer and dragging does the same, horizontal to turn and vertical for speed. auto_fly (Space, or A on a pad) hands over to gentle S-curves and time_speed (T, or Y) steps the day through 1x, 4x and 16x. Both are also action Button controls in the corner, so a tap presses the same key and a press on a button never steers.

Source¶

  1# /// simvx
  2# tags = ["3d", "procedural", "camera", "terrain", "particles"]
  3# web = { width = 1280, height = 720 }
  4# ///
  5"""Planet Explorer: an endless flight over procedural terrain, weather and a day/night cycle.
  6
  7A delta-wing craft flies over land that never runs out. Terrain and clouds are
  8built in chunks around the ship as it moves, a two-minute day repaints the sky,
  9moves the sun and moon and brings out stars and aurora curtains at night, and a
 10slower storm cycle greys the clouds, thickens the fog and turns rare lightning
 11into a strike every few seconds. Now and then a meteor streaks in, becomes a
 12fireball and flashes where it hits the ground.
 13
 14**Streaming terrain.** ``ChunkStreamer`` keeps a square of chunks built around
 15the ship: when the ship crosses into a new cell it drops the chunks beyond
 16``keep_radius`` and queues the missing ones nearest first, then builds two a
 17frame so no single frame pays for the whole ring. A terrain chunk is a
 18heightfield sampled from ``FastNoiseLite.get_noise_2d_array`` and built with
 19NumPy alone: ``np.gradient`` gives the normals, one index array gives two
 20triangles per grid square, and ``np.digitize`` sorts the triangles into biome
 21bands (sand, grass, forest, rock, snow), one ``Mesh`` and shared ``Material``
 22per band. Vertices are local to the chunk's centre, which becomes the
 23``MeshInstance3D`` position, so each chunk is culled by its own bounds. Clouds
 24are the same streamer over a coarser grid, rebuilt every eight seconds by a
 25repeating ``Timer`` with the noise shifted so they drift.
 26
 27**Day, night and weather.** ``PlanetExplorer`` advances ``time_of_day`` (0 is
 28midnight, 0.5 noon) and reads every sky value from two keyframe tables sharing
 29the times in ``DAY_TIMES``, smoothstepped between keys by ``day_at()``:
 30``DAY_COLOURS`` (sky top and bottom, ambient light, sun colour) and
 31``DAY_LEVELS`` (sun strength, exposure, bloom threshold and intensity). It
 32writes them to a ``WorldEnvironment`` (colour sky, exponential fog, bloom, ACES
 33tonemapping, film grain and chromatic aberration) and to the
 34``DirectionalLight3D``. The storm level blends grey into
 35the sky and clouds and adds fog; lightning is a short spike in
 36``tonemap_exposure``, sometimes doubled. ``Sky`` carries the sun and moon
 37discs on one pivot rotated with the time of day, a ``MultiMesh`` dome of unlit
 38stars and eight emissive aurora curtains textured from NumPy arrays, and
 39follows the camera so none of it can be flown past.
 40
 41**The ship and the camera.** The hull is built with ``MeshBuilder``, each
 42vertex given a top-down planar UV, and painted by ``hull_texture()``, a NumPy
 43array of panel grooves, canopy and engine strip passed straight to the
 44material's ``albedo_map``. ``Ship`` turns by yaw only and banks its ``hull``
 45child, so the ``ChaseCamera`` trailing it (``offset`` behind and above, a
 46``look_offset`` above the ship so the ship sits low in the frame) never rolls.
 47A meteor is a small state machine over a ``Phase`` ``StrEnum``, carrying a
 48``ParticleEmitter`` trail and a ``PointLight3D`` that lights the ground below
 49it, and destroys itself when its flash fades.
 50
 51**Input.** ``fly`` is one ``Axis2D`` action (arrow keys or the left stick):
 52left and right turn, up and down change speed. Holding the pointer and dragging
 53does the same, horizontal to turn and vertical for speed. ``auto_fly`` (Space,
 54or A on a pad) hands over to gentle S-curves and ``time_speed`` (T, or Y)
 55steps the day through 1x, 4x and 16x. Both are also action ``Button`` controls
 56in the corner, so a tap presses the same key and a press on a button never
 57steers.
 58"""
 59
 60import math
 61import random
 62from collections import deque
 63from enum import StrEnum
 64
 65import numpy as np
 66
 67from simvx.core import (
 68    Anchor,
 69    Axis2D,
 70    Button,
 71    CanvasLayer,
 72    ChaseCamera,
 73    Controller,
 74    DirectionalLight3D,
 75    FogMode,
 76    Input,
 77    Key,
 78    Label,
 79    Material,
 80    Mesh,
 81    MeshBuilder,
 82    MeshInstance3D,
 83    MultiMesh,
 84    MultiMeshInstance3D,
 85    Node3D,
 86    ParticleEmitter,
 87    Pointer,
 88    PointLight3D,
 89    Quat,
 90    Timer,
 91    TonemapMode,
 92    Vec3,
 93    WorldEnvironment,
 94    on_input,
 95)
 96from simvx.core.noise import FastNoiseLite, FractalType, NoiseType
 97from simvx.graphics import App
 98
 99FLY_HEIGHT = 45.0
100HEIGHT_SCALE = 40.0
101WATER_LEVEL = 0.0
102CLOUD_HEIGHT = 65.0
103DAY_SECONDS = 120.0
104TIME_SPEEDS = (1, 4, 16)
105SUN_AXIS = Vec3(1.0, 0.0, 0.3) / math.hypot(1.0, 0.3)  # where the sun sits at sunrise, on a unit sphere
106
107TERRAIN_NOISE = FastNoiseLite(seed=42, noise_type=NoiseType.SIMPLEX, frequency=0.008)
108TERRAIN_NOISE.fractal_type = FractalType.FBM
109TERRAIN_NOISE.fractal_octaves = 5
110CLOUD_NOISE = FastNoiseLite(seed=137, noise_type=NoiseType.SIMPLEX, frequency=0.012)
111CLOUD_NOISE.fractal_type = FractalType.FBM
112CLOUD_NOISE.fractal_octaves = 3
113
114# Biome bands by triangle height: each starts where the previous one ends.
115BIOME_TOPS = (3.0, 10.0, 18.0, 25.0)
116BIOMES = [
117    Material(colour=(0.82, 0.72, 0.42), roughness=0.85),  # sand
118    Material(colour=(0.22, 0.62, 0.15), roughness=0.90),  # grass
119    Material(colour=(0.10, 0.40, 0.10), roughness=0.88),  # forest
120    Material(colour=(0.50, 0.42, 0.35), roughness=0.75),  # rock
121    Material(colour=(0.95, 0.95, 0.98), roughness=0.60),  # snow
122]
123
124# The day as keyframes, one row per time of day, eased between rows by day_at().
125DAY_TIMES = np.array([0.0, 0.2, 0.25, 0.3, 0.5, 0.7, 0.75, 0.8, 1.0])
126DAY_COLOURS = np.array([
127    # sky top            sky bottom           ambient              sun colour
128    [(0.02, 0.02, 0.10), (0.01, 0.01, 0.05), (0.02, 0.02, 0.06), (1.00, 0.40, 0.15)],  # midnight
129    [(0.02, 0.02, 0.10), (0.01, 0.01, 0.05), (0.02, 0.02, 0.06), (1.00, 0.40, 0.15)],
130    [(0.45, 0.22, 0.10), (0.60, 0.30, 0.10), (0.06, 0.04, 0.03), (1.00, 0.40, 0.15)],  # sunrise
131    [(0.18, 0.35, 0.72), (0.35, 0.50, 0.72), (0.08, 0.07, 0.06), (1.00, 0.90, 0.80)],
132    [(0.18, 0.35, 0.72), (0.42, 0.55, 0.78), (0.10, 0.09, 0.08), (1.00, 0.98, 0.95)],  # noon
133    [(0.18, 0.35, 0.72), (0.35, 0.50, 0.72), (0.08, 0.07, 0.06), (1.00, 0.90, 0.80)],
134    [(0.65, 0.28, 0.08), (0.65, 0.35, 0.12), (0.06, 0.04, 0.03), (1.00, 0.40, 0.15)],  # sunset
135    [(0.02, 0.02, 0.10), (0.01, 0.01, 0.05), (0.02, 0.02, 0.06), (1.00, 0.40, 0.15)],
136    [(0.02, 0.02, 0.10), (0.01, 0.01, 0.05), (0.02, 0.02, 0.06), (1.00, 0.40, 0.15)],  # midnight
137])  # fmt: skip
138DAY_LEVELS = np.array([
139    # sun  exposure  bloom threshold  bloom intensity
140    [0.0,  0.50,     0.8,             0.7],
141    [0.0,  0.50,     0.8,             0.7],
142    [0.3,  0.80,     0.5,             1.0],
143    [0.9,  0.75,     1.0,             0.5],
144    [1.1,  0.70,     1.0,             0.5],
145    [0.9,  0.75,     1.0,             0.5],
146    [0.3,  0.90,     0.5,             1.0],
147    [0.0,  0.50,     0.8,             0.7],
148    [0.0,  0.50,     0.8,             0.7],
149])  # fmt: skip
150STORM_GREY = np.array((0.25, 0.27, 0.3))
151
152
153def day_at(table: np.ndarray, t: float) -> np.ndarray:
154    """The row of ``table`` for time of day ``t``, smoothstepped between the two nearest keys."""
155    i = min(int(np.searchsorted(DAY_TIMES, t, side="right")) - 1, len(DAY_TIMES) - 2)
156    f = (t - DAY_TIMES[i]) / (DAY_TIMES[i + 1] - DAY_TIMES[i])
157    return mix(table[i], table[i + 1], f * f * (3.0 - 2.0 * f))
158
159
160def rgb(colour: np.ndarray) -> tuple[float, ...]:
161    return tuple(colour.tolist())
162
163
164def mix(a, b, t):
165    return a + (b - a) * t
166
167
168def clamp01(x: float) -> float:
169    return min(1.0, max(0.0, x))
170
171
172def terrain_height(x, z):
173    """Ground height at world x/z; takes scalars or arrays of any shape."""
174    x, z = np.asarray(x, dtype=np.float64), np.asarray(z, dtype=np.float64)
175    return TERRAIN_NOISE.get_noise_2d_array(x.ravel(), z.ravel()).reshape(x.shape) * HEIGHT_SCALE
176
177
178def chunk_grid(cx: int, cz: int, size: float, res: int):
179    """World x and z of a chunk's ``res`` x ``res`` sample points, and the chunk's centre."""
180    x0, z0 = cx * size, cz * size
181    xs, zs = np.meshgrid(np.linspace(x0, x0 + size, res), np.linspace(z0, z0 + size, res), indexing="ij")
182    return xs, zs, Vec3(x0 + size / 2, 0.0, z0 + size / 2)
183
184
185def grid_triangles(res: int) -> np.ndarray:
186    """Vertex indices of a ``res`` x ``res`` grid, one row of two triangles per square."""
187    rows, cols = np.meshgrid(np.arange(res - 1), np.arange(res - 1), indexing="ij")
188    a = (rows * res + cols).ravel()
189    b = a + res
190    return np.column_stack([a, a + 1, b, a + 1, b + 1, b]).astype(np.uint32)
191
192
193def local_positions(xs, ys, zs, centre: Vec3) -> np.ndarray:
194    return np.column_stack([(xs - centre.x).ravel(), np.ravel(ys), (zs - centre.z).ravel()]).astype(np.float32)
195
196
197class ChunkStreamer(Node3D):
198    """Keeps the chunks within ``radius`` of a moving target built, nearest first and two per frame."""
199
200    chunk_size = 64.0
201    radius = 4
202    keep_radius = 6  # wider than radius, so a chunk just passed does not vanish and pop straight back
203
204    def __init__(self, target: Node3D, **kwargs):
205        super().__init__(**kwargs)
206        self.target = target
207        self.chunks: dict[tuple[int, int], list[Node3D]] = {}
208        self.queue: deque[tuple[int, int]] = deque()
209        self.cell: tuple[int, int] | None = None
210
211    def build(self, cx: int, cz: int) -> list[Node3D]:
212        raise NotImplementedError
213
214    def distance(self, key: tuple[int, int]) -> int:
215        return max(abs(key[0] - self.cell[0]), abs(key[1] - self.cell[1]))
216
217    def in_range(self) -> list[tuple[int, int]]:
218        """Every chunk within ``radius`` of the current cell, nearest first."""
219        (cx, cz), r = self.cell, range(-self.radius, self.radius + 1)
220        return sorted(((cx + dx, cz + dz) for dx in r for dz in r), key=lambda k: (k[0] - cx) ** 2 + (k[1] - cz) ** 2)
221
222    def rebuild(self):
223        """Queue every chunk in range to be built again, replacing the old one."""
224        self.queue = deque(self.in_range())
225
226    def drop(self, key: tuple[int, int]):
227        for node in self.chunks.pop(key, ()):
228            node.destroy()
229
230    def on_update(self, dt: float):
231        pos = self.target.position
232        cell = (math.floor(pos.x / self.chunk_size), math.floor(pos.z / self.chunk_size))
233        if cell != self.cell:
234            self.cell = cell
235            for key in [k for k in self.chunks if self.distance(k) > self.keep_radius]:
236                self.drop(key)
237            self.queue = deque(k for k in self.in_range() if k not in self.chunks)
238        for _ in range(min(2, len(self.queue))):
239            key = self.queue.popleft()
240            if self.distance(key) <= self.radius:
241                self.drop(key)
242                self.chunks[key] = [self.add_child(node) for node in self.build(*key)]
243
244
245class Terrain(ChunkStreamer):
246    """Biome-coloured heightfield chunks, with a sea plane that stays under the ship."""
247
248    resolution = 24
249
250    def __init__(self, target: Node3D, **kwargs):
251        super().__init__(target, **kwargs)
252        sea_size = (2 * self.keep_radius + 1) * self.chunk_size
253        self.sea = self.add_child(
254            MeshInstance3D(
255                mesh=Mesh.plane(size=sea_size),
256                material=Material(colour=(0.08, 0.25, 0.55, 0.65), blend="alpha", metallic=0.3, roughness=0.2),
257            )
258        )
259
260    def on_update(self, dt: float):
261        super().on_update(dt)
262        self.sea.position = Vec3(self.target.position.x, WATER_LEVEL, self.target.position.z)
263
264    def build(self, cx: int, cz: int) -> list[Node3D]:
265        xs, zs, centre = chunk_grid(cx, cz, self.chunk_size, self.resolution)
266        heights = terrain_height(xs, zs)
267        dx, dz = np.gradient(heights, self.chunk_size / (self.resolution - 1))
268        normals = np.column_stack([-dx.ravel(), np.ones(dx.size), -dz.ravel()])
269        normals = (normals / np.linalg.norm(normals, axis=1, keepdims=True)).astype(np.float32)
270        positions = local_positions(xs, heights, zs, centre)
271
272        triangles = grid_triangles(self.resolution).reshape(-1, 3)
273        band = np.digitize(positions[triangles, 1].mean(axis=1), BIOME_TOPS)
274        return [
275            MeshInstance3D(
276                mesh=Mesh(positions, triangles[band == i].ravel(), normals), material=material, position=centre
277            )
278            for i, material in enumerate(BIOMES)
279            if (band == i).any()
280        ]
281
282
283class Clouds(ChunkStreamer):
284    """Translucent cloud sheets where the cloud noise is dense, rebuilt every few seconds so they drift."""
285
286    chunk_size = 128.0
287    radius = keep_radius = 3
288    resolution = 12
289
290    def __init__(self, target: Node3D, **kwargs):
291        super().__init__(target, **kwargs)
292        self.drift = 0.0
293        self.material = Material(colour=(1.0, 1.0, 1.0, 0.4), blend="alpha", double_sided=True)
294        self.add_child(Timer(duration=8.0, one_shot=False, autostart=True)).timeout.connect(self.rebuild)
295
296    def on_update(self, dt: float):
297        self.drift += 3.0 * dt
298        super().on_update(dt)
299
300    def build(self, cx: int, cz: int) -> list[Node3D]:
301        xs, zs, centre = chunk_grid(cx, cz, self.chunk_size, self.resolution)
302        density = np.clip((CLOUD_NOISE.get_noise_2d_array((xs + self.drift).ravel(), zs.ravel()) + 1.0) / 2, 0, 1)
303        squares = grid_triangles(self.resolution)
304        squares = squares[(density[squares] > 0.3).any(axis=1)]  # only squares with a cloudy corner
305        if not len(squares):
306            return []
307        positions = local_positions(xs, CLOUD_HEIGHT + density * 5.0, zs, centre)
308        normals = np.tile(np.float32((0, 1, 0)), (len(positions), 1))
309        return [MeshInstance3D(mesh=Mesh(positions, squares.ravel(), normals), material=self.material, position=centre)]
310
311
312# Delta-wing hull, nose toward -Z: named corners, and the hull's triangles as three names each.
313SHIP_POINTS = {
314    "nose": (0, 0.05, -3.5), "left_tip": (-2.2, -0.05, 1.5), "right_tip": (2.2, -0.05, 1.5),
315    "spine": (0, 0.55, 0.3), "spine_rear": (0, 0.4, 1.2), "tail_l": (-0.45, 0.1, 1.8), "tail_r": (0.45, 0.1, 1.8),
316    "mid_l": (-1.2, 0.15, -0.3), "mid_r": (1.2, 0.15, -0.3), "keel_f": (0, -0.15, -2.5), "keel_r": (0, -0.1, 1.0),
317    "canopy_f": (0, 0.35, -2.0), "canopy_r": (0, 0.45, -0.8),
318    "canopy_l": (-0.3, 0.25, -1.4), "canopy_r2": (0.3, 0.25, -1.4),
319}  # fmt: skip
320SHIP_FACES = """
321    nose mid_l spine    mid_l left_tip spine    spine left_tip spine_rear
322    nose spine mid_r    mid_r spine right_tip    spine spine_rear right_tip
323    nose canopy_l canopy_f    nose canopy_f canopy_r2    canopy_f canopy_l canopy_r
324    canopy_f canopy_r canopy_r2    canopy_l spine canopy_r    canopy_r2 canopy_r spine
325    nose keel_f mid_l    keel_f keel_r mid_l    mid_l keel_r left_tip
326    nose mid_r keel_f    keel_f mid_r keel_r    mid_r right_tip keel_r
327    spine_rear left_tip tail_l    spine_rear tail_r right_tip    spine_rear tail_l tail_r
328    keel_r tail_l left_tip    keel_r right_tip tail_r    keel_r tail_r tail_l
329""".split()
330
331
332def hull_texture(width: int = 512, height: int = 256) -> np.ndarray:
333    """The hull's paint as an RGBA array: u runs wingtip to wingtip, v nose to tail."""
334    u = np.linspace(0.0, 1.0, width, dtype=np.float32)[None, :]
335    v = np.linspace(0.0, 1.0, height, dtype=np.float32)[:, None]
336    rgb = np.empty((height, width, 3), dtype=np.float32)
337    rgb[...] = np.float32((170, 180, 210)) - v[..., None] * np.float32((40, 35, 25))  # silver-blue, darker aft
338    rgb += (np.clip(1.0 - np.abs(u - 0.5) / 0.05, 0, 1) * 60.0)[..., None]  # spine highlight
339    rgb[np.arange(height) % 32 < 2] -= 80.0  # panel grooves across the hull
340    rgb[:, np.arange(width) % 64 < 2] -= 70.0  # and along it
341
342    def tent(centre_u: float, half_u: float, centre_v: float, half_v: float) -> np.ndarray:
343        return (np.clip(1 - np.abs(u - centre_u) / half_u, 0, 1) * np.clip(1 - np.abs(v - centre_v) / half_v, 0, 1))[
344            ..., None
345        ]
346
347    canopy = tent(0.5, 0.1, 0.15, 0.1)
348    rgb = rgb * (1 - canopy) + np.float32((200, 240, 255)) * canopy  # teal canopy near the nose
349    rgb += tent(0.5, 0.15, 0.9, 0.05) * np.float32((80, 130, 200))  # engine strip at the tail
350    image = np.full((height, width, 4), 255, dtype=np.uint8)
351    image[..., :3] = np.clip(rgb, 0, 255)
352    return image
353
354
355def ship_mesh() -> Mesh:
356    builder = MeshBuilder()
357    builder.begin()
358    for name in SHIP_FACES:
359        x, _, z = SHIP_POINTS[name]
360        builder.set_uv(((x + 2.2) / 4.4, (z + 3.5) / 5.3))  # planar top-down: wingtips span u, nose to tail v
361        builder.add_vertex(SHIP_POINTS[name])
362    builder.generate_normals()
363    return builder.commit()
364
365
366class Ship(Node3D):
367    """The player's craft: flies level at FLY_HEIGHT, turning by yaw and banking its hull into turns."""
368
369    MIN_SPEED, MAX_SPEED = 15.0, 80.0
370    TURN_RATE = math.radians(25)
371
372    def __init__(self, **kwargs):
373        super().__init__(position=Vec3(0, FLY_HEIGHT, 0), **kwargs)
374        self.yaw = 0.0
375        self.speed = self.target_speed = 30.0
376        self.auto_fly = False
377        self.auto_fly_time = 0.0
378        self.bank = 0.0
379        self.hull = self.add_child(Node3D(name="Hull"))
380        self.hull.add_child(
381            MeshInstance3D(
382                mesh=ship_mesh(),
383                material=Material(
384                    colour=(1.5, 1.5, 1.5),  # brightens the texture, whose own values are mid-toned
385                    albedo_map=hull_texture(),
386                    emissive_colour=(0.05, 0.08, 0.15, 0.3),
387                    metallic=0.15,
388                    roughness=0.4,
389                ),
390                scale=Vec3(3),
391            )
392        )
393        engine = Vec3(0, 0.4, 5.6)
394        self.engine_glow = Material(colour=(0.05, 0.1, 0.2), emissive_colour=(0.4, 0.6, 1.2, 1.5), roughness=1.0)
395        self.hull.add_child(
396            MeshInstance3D(
397                mesh=Mesh.sphere(radius=0.35, rings=6, segments=6), material=self.engine_glow, position=engine
398            )
399        )
400        self.engine_light = self.hull.add_child(
401            PointLight3D(colour=(0.4, 0.6, 1.0), intensity=2.0, range=12.0, position=engine)
402        )
403
404    def toggle_auto_fly(self):
405        self.auto_fly = not self.auto_fly
406        self.auto_fly_time = 0.0
407
408    def on_update(self, dt: float):
409        fly = Input.value("fly")
410        turn = -fly.x
411        self.target_speed -= fly.y * 30.0 * dt  # up is -y
412        if Input.pressed("steer"):
413            drag = Input.mouse_delta
414            turn = turn or max(-1.0, min(1.0, -drag.x / 2.0))
415            if abs(drag.y) > 1.0:
416                self.target_speed -= drag.y * 0.25
417        self.target_speed = min(self.MAX_SPEED, max(self.MIN_SPEED, self.target_speed))
418        if self.auto_fly:
419            self.auto_fly_time += dt
420            turn = math.sin(self.auto_fly_time * 0.3) * 0.6
421
422        self.speed = mix(self.speed, self.target_speed, min(1.0, 3.0 * dt))
423        self.yaw += turn * self.TURN_RATE * dt
424        self.rotation = Quat.from_euler(0, self.yaw, 0)
425        self.position += self.rotation * Vec3(0, 0, -self.speed * dt)
426        self.bank = mix(self.bank, turn * math.radians(15), min(1.0, 5.0 * dt))
427        self.hull.rotation = Quat.from_euler(0, 0, self.bank)
428
429        thrust = (self.speed - self.MIN_SPEED) / (self.MAX_SPEED - self.MIN_SPEED)
430        self.engine_glow.emissive_colour = (0.3, 0.5, 1.0, 0.3 + 2.0 * thrust)
431        self.engine_light.intensity = 1.0 + 4.0 * thrust
432
433
434AURORA_TINTS = [(0.2, 0.94, 0.47), (0.31, 0.63, 1.0), (0.94, 0.31, 0.55), (0.24, 0.86, 0.78)]
435
436
437def aurora_texture(seed: int, tint: tuple[float, float, float], size: int = 256) -> np.ndarray:
438    """An RGBA curtain of vertical rays: whitish at the base, the tint above, fading out at the top and bottom."""
439    rng = np.random.default_rng(seed)
440    n = rng.integers(15, 26)
441    centres, widths, brightness = rng.uniform(0, 1, n), rng.uniform(0.01, 0.06, n), rng.uniform(0.4, 1.0, n)
442    u = (np.arange(size) / size)[:, None]
443    d = np.abs((u - centres + 0.5) % 1.0 - 0.5)  # distance to each ray, wrapping so the texture tiles
444    rays = np.minimum(1.0, np.where(d < widths, brightness * (1 - (d / widths) ** 2), 0.0).sum(axis=1))
445
446    v = 1.0 - np.arange(size) / size  # 1 at the top row, falling toward the base
447    fade = np.minimum(1.0, v * 4) * np.maximum(0.0, 1 - (v - 0.6) * 2.5)
448    tint = np.array(tint)
449    base = mix(np.array((0.78, 0.86, 0.86)), tint, np.minimum(1.0, v / 0.3)[:, None])
450    colour = base * mix(1.0, np.array((0.3, 0.2, 0.4)), np.clip((v - 0.7) / 0.3, 0, 1)[:, None])
451
452    alpha = fade[:, None] * rays[None, :]
453    image = np.empty((size, size, 4), dtype=np.uint8)
454    image[..., :3] = np.clip(colour[:, None, :] * alpha[..., None] * 255, 0, 255)
455    image[..., 3] = np.clip(alpha * 200, 0, 255)
456    return image
457
458
459class Aurora(Node3D):
460    """Curtains of light around the horizon, each fading in, shimmering and swaying on its own clock."""
461
462    COUNT = 8
463
464    def __init__(self, **kwargs):
465        super().__init__(**kwargs)
466        self.night = 0.0  # 0 by day, 1 in full night
467        self.time = 0.0
468        mesh = Mesh.plane(size=(100.0, 40.0), subdivisions=6)
469        self.curtains = []
470        for i in range(self.COUNT):
471            tint = AURORA_TINTS[i % len(AURORA_TINTS)]
472            texture = aurora_texture(200 + i, tint)
473            material = Material(
474                colour=(1.0, 1.0, 1.0, 0.0),
475                albedo_map=texture,
476                emissive_colour=(*tint, 1.0),
477                emissive_map=texture,
478                blend="alpha",
479                unlit=True,
480                double_sided=True,
481            )
482            self.curtains.append(self.add_child(MeshInstance3D(mesh=mesh, material=material)))
483
484    def on_update(self, dt: float):
485        self.time += dt
486        self.visible = self.night > 0
487        t = self.time
488        for i, curtain in enumerate(self.curtains):
489            fade = max(0.0, 0.6 * math.sin(0.12 * t + 1.7 * i) + 0.4 * math.sin(0.07 * t + 2.3 * i))
490            curtain.visible = fade >= 0.05
491            shimmer = 0.5 + 0.3 * math.sin(2.5 * t + 2.1 * i) + 0.2 * math.sin(4.0 * t + 1.3 * i)
492            glow = shimmer * fade * self.night
493            r, g, b = AURORA_TINTS[i % len(AURORA_TINTS)]
494            curtain.material.emissive_colour = (r * glow, g * glow, b * glow, glow)
495            curtain.material.colour = (r * 0.5, g * 0.5, b * 0.5, self.night * fade * 0.12)
496
497            angle = math.tau * i / self.COUNT + 0.08 * math.sin(0.1 * t + 0.9 * i)
498            distance = 65.0 + (i % 3) * 8
499            curtain.position = Vec3(math.cos(angle) * distance, 15.0 + (i % 3) * 3, math.sin(angle) * distance)
500            curtain.rotation = Quat.from_euler(math.pi / 2, angle + math.pi, 0)
501
502
503def star_dome(count: int = 200, radius: float = 180.0) -> MultiMesh:
504    rng = np.random.default_rng(99)
505    polar, around = rng.uniform(0.1, 0.45 * math.pi, count), rng.uniform(0, math.tau, count)
506    transforms = np.tile(np.eye(4, dtype=np.float32), (count, 1, 1))
507    transforms[:, :3, 3] = radius * np.column_stack(
508        [np.sin(polar) * np.cos(around), np.cos(polar), np.sin(polar) * np.sin(around)]
509    )
510    stars = MultiMesh(mesh=Mesh.sphere(radius=0.15, rings=4, segments=4), instance_count=count)
511    stars.set_all_transforms(transforms)
512    return stars
513
514
515class Sky(Node3D):
516    """Sun, moon, stars and aurora, centred on the camera every frame so they can never be flown past."""
517
518    def __init__(self, camera: Node3D, **kwargs):
519        super().__init__(**kwargs)
520        self.camera = camera
521        self.orbit = self.add_child(Node3D(name="Orbit"))  # rolls with the time of day; the moon sits opposite the sun
522        self.sun_glow = Material(colour=(1.0, 0.9, 0.5), emissive_colour=(8.0, 6.0, 2.0, 4.0))
523        self.moon_glow = Material(colour=(0.8, 0.85, 0.9), emissive_colour=(2.0, 2.2, 2.5, 2.0))
524        for radius, glow, direction in ((3.0, self.sun_glow, SUN_AXIS), (1.5, self.moon_glow, -SUN_AXIS)):
525            disc = Mesh.sphere(radius=radius, rings=12, segments=12)
526            self.orbit.add_child(MeshInstance3D(mesh=disc, material=glow, position=direction * 200.0))
527        self.star_glow = Material(colour=(2.0, 2.0, 2.5, 1.0), unlit=True)
528        self.add_child(MultiMeshInstance3D(multi_mesh=star_dome(), material=self.star_glow, name="Stars"))
529        self.aurora = self.add_child(Aurora(name="Aurora"))
530
531    def set_sun_angle(self, angle: float):
532        """Place the sun ``angle`` radians past sunrise, and fade the discs, stars and aurora to match."""
533        self.orbit.rotation = Quat.from_euler(0, 0, angle)
534        height = math.sin(angle)
535        sun, moon, stars = clamp01(height * 5 + 0.5), clamp01(0.5 - height * 5), clamp01(-height * 5)
536        self.sun_glow.emissive_colour = (8.0 * sun, 6.0 * sun, 2.0 * sun, 4.0 * sun)
537        self.moon_glow.emissive_colour = (2.0 * moon, 2.2 * moon, 2.5 * moon, 2.0 * moon)
538        self.star_glow.colour = (2.0 * stars, 2.0 * stars, 2.5 * stars, stars)
539        self.aurora.night = clamp01((-height - 0.05) * 8)
540
541    def on_update(self, dt: float):
542        self.position = self.camera.world_position
543
544
545class Phase(StrEnum):
546    SHOOTING_STAR = "shooting star"
547    FIREBALL = "fireball"
548    IMPACT = "impact"
549
550
551class Meteor(Node3D):
552    """Streaks in high and fast, slows into a growing fireball, and flashes where it meets the ground."""
553
554    def __init__(self, drift: Vec3, **kwargs):
555        super().__init__(**kwargs)
556        self.drift = drift  # sideways heading, x and z
557        self.phase = Phase.SHOOTING_STAR
558        self.age = 0.0
559        self.glow = Material(colour=(1.0, 1.0, 1.0), emissive_colour=(6.0, 5.0, 2.0, 3.0))
560        self.core = self.add_child(
561            MeshInstance3D(mesh=Mesh.sphere(radius=0.3, rings=6, segments=6), material=self.glow)
562        )
563        self.trail = self.add_child(
564            ParticleEmitter(
565                amount=30,
566                lifetime=0.6,
567                emission_rate=25.0,
568                initial_velocity=(0.0, 0.0, 0.0),
569                velocity_spread=0.5,
570                gravity=(0.0, -2.0, 0.0),
571                start_colour=(1.0, 0.9, 0.5, 1.0),
572                end_colour=(1.0, 0.3, 0.0, 0.0),
573                start_scale=0.5,
574                end_scale=0.0,
575            )
576        )
577        self.light = self.add_child(PointLight3D(colour=(1.0, 0.9, 0.6), intensity=3.0, range=40.0))
578
579    def enter(self, phase: Phase):
580        self.phase, self.age = phase, 0.0
581
582    def fly(self, speed: float, sink: float, dt: float):
583        self.position += Vec3(self.drift.x * speed, -sink, self.drift.z * speed) * dt
584
585    def on_update(self, dt: float):
586        self.age += dt
587        match self.phase:
588            case Phase.SHOOTING_STAR:
589                self.fly(120.0, 40.0, dt)
590                if self.age > 1.5 or self.position.y < 80.0:
591                    self.enter(Phase.FIREBALL)
592                    self.trail.start_colour, self.trail.end_colour = (1.0, 0.5, 0.1, 1.0), (0.5, 0.5, 0.5, 0.0)
593            case Phase.FIREBALL:
594                self.fly(60.0, 30.0, dt)
595                t = min(1.0, self.age / 2.0)
596                self.core.scale = Vec3(1.0 + 0.8 * t)
597                self.glow.emissive_colour = (4.0, 1.5 - 0.5 * t, 0.3, 3.0 + 2.0 * t)
598                self.light.colour = (1.0, 0.6 - 0.2 * t, 0.2)
599                self.light.intensity, self.light.range = 4.0 + 4.0 * t, 50.0 + 30.0 * t
600                ground = max(float(terrain_height(self.position.x, self.position.z)), WATER_LEVEL)
601                if self.position.y <= ground + 2.0 or self.age > 3.0:
602                    self.enter(Phase.IMPACT)
603                    self.trail.emitting = False
604            case Phase.IMPACT:
605                flash = max(0.0, 1.0 - 2.0 * self.age)
606                self.core.scale = Vec3(1.8 + 3.0 * self.age)
607                self.glow.emissive_colour = (8.0 * flash, 4.0 * flash, 1.0 * flash, 5.0 * flash)
608                self.light.colour = (1.0, 0.7 * flash, 0.3 * flash)
609                self.light.intensity, self.light.range = 12.0 * flash, 80.0 * flash
610                if self.age > 1.0:
611                    self.destroy()
612
613
614class PlanetExplorer(Node3D):
615    """Runs the clock and the weather, and paints the sky, light and fog from them."""
616
617    input_context = {
618        "fly": [Axis2D.ARROWS, Controller.LEFT_STICK],
619        "steer": [Pointer.PRIMARY],
620        "auto_fly": [Key.SPACE, Controller.A],
621        "time_speed": [Key.T, Controller.Y],
622    }
623
624    def __init__(self, **kwargs):
625        super().__init__(**kwargs)
626        self.time_of_day = 0.22  # just before dawn
627        self.time_speed = TIME_SPEEDS[0]
628        self.storm_phase = 0.0
629        self.storm = 0.0  # 0 clear, 1 heavy storm
630        self.flash = 0.0  # seconds left of the current lightning flash
631        self.second_flash = False
632
633        self.env = self.add_child(
634            WorldEnvironment(
635                sky_mode="colour",
636                fog_enabled=True,
637                fog_mode=FogMode.EXPONENTIAL,
638                bloom_enabled=True,
639                tonemap_mode=TonemapMode.ACES,
640                film_grain_enabled=True,
641                film_grain_intensity=0.02,
642                chromatic_aberration_enabled=True,
643                chromatic_aberration_intensity=0.002,
644            )
645        )
646        self.sun = self.add_child(DirectionalLight3D(name="Sun"))
647        # Tree order is update order: the camera follows the ship's new pose, and the sky the camera's.
648        self.ship = self.add_child(Ship(name="Ship"))
649        camera = self.add_child(
650            ChaseCamera(
651                target=self.ship, offset=Vec3(0, 8, 15), look_offset=Vec3(0, 4, 0), half_life=0.17, fov=65, far=1200.0
652            )
653        )
654        self.add_child(Terrain(self.ship, name="Terrain"))
655        self.clouds = self.add_child(Clouds(self.ship, name="Clouds"))
656        self.sky = self.add_child(Sky(camera, name="Sky"))
657
658        self.meteors = self.add_child(Node3D(name="Meteors"))
659        self.meteor_timer = self.add_child(Timer(duration=random.uniform(10, 20), one_shot=False, autostart=True))
660        self.meteor_timer.timeout.connect(self.spawn_meteor)
661        self.lightning_timer = self.add_child(Timer(duration=random.uniform(20, 40), one_shot=False, autostart=True))
662        self.lightning_timer.timeout.connect(self.lightning)
663
664        hud = self.add_child(CanvasLayer(name="HUD"))
665        self.status = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=20))
666        self.auto_button = hud.add_child(
667            Button("Auto", action="auto_fly", anchor=Anchor.BOTTOM_LEFT, inset=16, size=(150, 42))
668        )
669        self.time_button = hud.add_child(
670            Button("Time 1x", action="time_speed", anchor=Anchor.BOTTOM_LEFT, inset=(182, 16), size=(150, 42))
671        )
672        hud.add_child(help_label())
673
674    @on_input("auto_fly")
675    def toggle_auto_fly(self, _event):
676        self.ship.toggle_auto_fly()
677        self.auto_button.checked = self.ship.auto_fly
678
679    @on_input("time_speed")
680    def cycle_time_speed(self, _event):
681        self.time_speed = TIME_SPEEDS[(TIME_SPEEDS.index(self.time_speed) + 1) % len(TIME_SPEEDS)]
682        self.time_button.text = f"Time {self.time_speed}x"
683
684    def spawn_meteor(self):
685        self.meteor_timer.duration = random.uniform(15, 30)
686        if len(self.meteors.children) >= 2:
687            return
688        heading = self.ship.yaw + random.uniform(-0.5, 0.5)
689        ahead = Quat.from_euler(0, heading, 0) * Vec3(0, 0, -random.uniform(200, 400))
690        start = Vec3(self.ship.position.x + ahead.x, random.uniform(150, 180), self.ship.position.z + ahead.z)
691        drift = Vec3(random.uniform(-0.3, 0.3), 0, random.uniform(-0.3, 0.3))
692        self.meteors.add_child(Meteor(drift, position=start))
693
694    def lightning(self):
695        """Strike (or, in clear weather, rarely strike) and schedule the next: every 20-40 s clear, 3-8 s in a storm."""
696        self.lightning_timer.duration = random.uniform(mix(20, 3, self.storm), mix(40, 8, self.storm))
697        if self.storm > 0.1 or random.random() < 0.15:
698            self.flash = 0.15
699            self.second_flash = random.random() > 0.4
700
701    def on_update(self, dt: float):
702        self.time_of_day = (self.time_of_day + dt * self.time_speed / DAY_SECONDS) % 1.0
703        self.storm_phase = (self.storm_phase + 0.04 * dt) % math.tau
704        self.storm = max(0.0, math.sin(self.storm_phase)) ** 1.5  # the positive half of the wave, sharpened
705        if self.flash > 0:
706            self.flash -= dt
707            if self.flash <= 0 and self.second_flash:
708                self.second_flash, self.flash = False, 0.15
709        self.paint_sky()
710        self.status.text = f"Speed {self.ship.speed:.0f}   {self.day_phase()}   {self.weather()}"
711
712    def paint_sky(self):
713        t, storm, env = self.time_of_day, self.storm, self.env
714        sky_top, sky_bottom, ambient, sun_colour = day_at(DAY_COLOURS, t)
715        sun, exposure, bloom_threshold, bloom_intensity = day_at(DAY_LEVELS, t)
716        angle = (t - 0.25) * math.tau  # 0 at sunrise, pi/2 at noon
717        self.sky.set_sun_angle(angle)
718        self.sun.direction = -(Quat.from_euler(0, 0, angle) * SUN_AXIS)
719        self.sun.colour = rgb(sun_colour)
720        # Below the horizon the sun would light the land from underneath.
721        self.sun.intensity = sun * (1 - 0.7 * storm) if math.sin(angle) > -0.05 else 0.0
722
723        env.sky_colour_top = rgb(mix(sky_top, STORM_GREY, 0.6 * storm))
724        env.sky_colour_bottom = env.fog_colour = rgb(mix(sky_bottom, STORM_GREY, 0.5 * storm))
725        env.ambient_light_colour = rgb(ambient)
726        env.fog_density = 0.003 + 0.006 * storm
727        lightning = max(0.0, self.flash) / 0.15 * (2 + 3 * storm)
728        env.tonemap_exposure = exposure * (1 - 0.25 * storm) + lightning
729        env.bloom_threshold, env.bloom_intensity = bloom_threshold, bloom_intensity
730
731        cloud = mix(0.7 + 0.3 * sky_top, np.array((0.25, 0.25, 0.28)), storm)  # white to a threatening grey
732        self.clouds.material.colour = (*rgb(cloud), mix(0.4, 0.85, storm))
733
734    def day_phase(self) -> str:
735        for end, name in ((0.2, "Night"), (0.3, "Sunrise"), (0.7, "Day"), (0.8, "Sunset")):
736            if self.time_of_day < end:
737                return name
738        return "Night"
739
740    def weather(self) -> str:
741        for level, name in ((0.6, "Storm"), (0.3, "Overcast"), (0.05, "Cloudy")):
742            if self.storm > level:
743                return name
744        return "Clear"
745
746
747# Example help overlay: not part of the technique shown.
748def help_label() -> Label:
749    hint = Label("Arrows, stick or drag to fly", anchor=Anchor.TOP_LEFT, inset=(12, 40), font_size=14)
750    hint.text_colour = (0.6, 0.6, 0.6, 1.0)
751    return hint
752
753
754if __name__ == "__main__":
755    App(title="Planet Explorer", width=1280, height=720).run(PlanetExplorer(name="PlanetExplorer"))