Planet Explorer¶
an endless flight over procedural terrain, weather and a day/night cycle.
â–¶ Run in browserTags: 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"))