Deep Sea Aquarium¶
bioluminescent tank with flocking fish and a synthesised score.
â–¶ Run in browserTags: 3d particles bloom audio boids
An interactive 3D aquarium built entirely from code. Every mesh (jellyfish bells, fish bodies, coral, anemones, kelp, the sea floor) is generated with numpy at startup, and the ambient score is synthesised the same way: a sub-bass drone, pentatonic pads that cross-fade into each other, and crystal chimes that answer every click.
Engine features on show: - Emissive materials plus bloom for the bioluminescent glow - Boids flocking driving two schools of fish - Mouse picking through pickable CollisionShape3D: click a creature and it reacts (jellyfish flare, anemones retract, fish scatter) with a chime - ParticleEmitter for the motes drifting through the water - OrbitCamera3D for the drag/scroll/auto-orbit camera - WorldEnvironment post-processing: bloom, vignette, depth of field - Procedural audio: AudioSynth oscillators with ADSR and exponential envelopes, baked once into looping clips
Controls: Mouse drag : Orbit camera Scroll : Zoom in/out Space : Toggle auto-orbit Click : Interact with a creature Escape : Quit
The scene is split across support modules: creatures.py (jellyfish, fish schools, coral, anemones), environment.py (sea floor, kelp), meshgen.py (procedural meshes), boids.py (flocking maths) and music.py (the score).
Source files¶
File |
Summary |
Lines |
|---|---|---|
Deep Sea Aquarium: bioluminescent tank with flocking fish and a synthesised score. |
348 |
|
0 |
||
Vectorised numpy boids flocking: separation, alignment, cohesion. |
73 |
|
Bioluminescent sea creatures: Jellyfish, Fish, FishSchool, CoralFormation, Anemone. |
661 |
|
Environment nodes: SeaFloor and Kelp. |
120 |
|
Procedural mesh generation: pure numpy, no external libraries. |
240 |
|
Procedural ambient music synthesis: evolving drone, pentatonic pads, reactive chimes. |
204 |
Source¶
1"""Deep Sea Aquarium: bioluminescent tank with flocking fish and a synthesised score.
2
3# /// simvx
4# tags = ["3d", "particles", "bloom", "audio", "boids"]
5# web = { width = 1920, height = 1080 }
6# ///
7
8An interactive 3D aquarium built entirely from code. Every mesh (jellyfish
9bells, fish bodies, coral, anemones, kelp, the sea floor) is generated with
10numpy at startup, and the ambient score is synthesised the same way: a
11sub-bass drone, pentatonic pads that cross-fade into each other, and crystal
12chimes that answer every click.
13
14Engine features on show:
15 - Emissive materials plus bloom for the bioluminescent glow
16 - Boids flocking driving two schools of fish
17 - Mouse picking through pickable CollisionShape3D: click a creature and it
18 reacts (jellyfish flare, anemones retract, fish scatter) with a chime
19 - ParticleEmitter for the motes drifting through the water
20 - OrbitCamera3D for the drag/scroll/auto-orbit camera
21 - WorldEnvironment post-processing: bloom, vignette, depth of field
22 - Procedural audio: AudioSynth oscillators with ADSR and exponential
23 envelopes, baked once into looping clips
24
25Controls:
26 Mouse drag : Orbit camera
27 Scroll : Zoom in/out
28 Space : Toggle auto-orbit
29 Click : Interact with a creature
30 Escape : Quit
31
32The scene is split across support modules: creatures.py (jellyfish, fish
33schools, coral, anemones), environment.py (sea floor, kelp), meshgen.py
34(procedural meshes), boids.py (flocking maths) and music.py (the score).
35"""
36
37import math
38import sys
39from pathlib import Path
40
41import numpy as np
42
43from simvx.core import (
44 Input,
45 Key,
46 MouseButton,
47 Node,
48 OrbitCamera3D,
49 ParticleEmitter,
50 PointLight3D,
51 Text2D,
52 Vec3,
53 WorldEnvironment,
54)
55
56sys.path.insert(0, str(Path(__file__).resolve().parent))
57
58from creatures import FISH_WARM_COLOURS, Anemone, CoralFormation, FishSchool, Jellyfish
59from environment import Kelp, SeaFloor
60from music import AmbientMusicController
61
62# Camera. OrbitCamera3D works in radians and treats negative pitch as "above the
63# pivot looking down", so the tank is viewed from a raised three-quarter angle.
64DRAG_SENSITIVITY = math.radians(0.3) # radians of orbit per pixel dragged
65AUTO_ORBIT_SPEED = math.radians(3.5) # radians per second
66ZOOM_PER_NOTCH = 1.5 # world units per scroll notch
67MIN_DISTANCE, MAX_DISTANCE = 3.0, 40.0
68MIN_PITCH, MAX_PITCH = math.radians(-60.0), math.radians(30.0)
69
70# Opening pull-back: the view eases from a close framing out to the settled one.
71INTRO_DURATION = 3.0
72INTRO_DISTANCE, SETTLED_DISTANCE = 16.0, 20.0
73INTRO_PITCH, SETTLED_PITCH = math.radians(-30.0), math.radians(-28.0)
74
75# On-screen text: seconds held at full opacity, then seconds spent fading out.
76HUD_HOLD, HUD_FADE = 3.0, 1.0
77LABEL_HOLD, LABEL_FADE = 3.0, 1.0
78
79
80class AquariumScene(Node):
81 """Root scene for the deep sea aquarium."""
82
83 def __init__(self, **kw):
84 super().__init__(name="Aquarium", **kw)
85 self._cam: OrbitCamera3D | None = None
86 self._hud: Text2D | None = None
87 self._hud_timer = HUD_HOLD + HUD_FADE
88 self._creature_label: Text2D | None = None
89 self._creature_label_timer = 0.0
90 self._music: AmbientMusicController | None = None
91
92 # Camera state the orbit node doesn't own
93 self._auto_orbit = True
94 self._dragging = False
95 self._last_mouse = (0.0, 0.0)
96
97 # Opening camera move: normalised 0-1 progress, None once finished or
98 # cancelled by the first deliberate camera input.
99 self._intro_t: float | None = 0.0
100
101 def on_ready(self):
102 # Camera: orbits the middle of the tank, slightly below the waterline
103 self._cam = OrbitCamera3D(
104 name="Camera",
105 fov=55.0,
106 near=0.1,
107 far=200.0,
108 pivot=Vec3(0, -0.5, 0),
109 distance=INTRO_DISTANCE,
110 yaw=math.radians(15.0),
111 pitch=INTRO_PITCH,
112 )
113 self.add_child(self._cam)
114
115 # Central floor light: a pool of light on the ground like a real tank feature light
116 centre_light = PointLight3D(name="CentreFloorLight", position=Vec3(0, -3.5, 0))
117 centre_light.colour = (0.1, 0.12, 0.22)
118 centre_light.intensity = 5.0
119 centre_light.range = 15.0
120 self.add_child(centre_light)
121
122 # Sea floor: very dark, just enough to ground the scene
123 self.add_child(SeaFloor())
124
125 # Kelp strands: dark silhouettes rising from the floor
126 rng = np.random.default_rng(99)
127 kelp_positions = [
128 (-7, -5.0, -6),
129 (-4, -5.0, 3),
130 (2, -5.0, -7),
131 (6, -5.0, 2),
132 (-2, -5.0, -2),
133 (4, -5.0, -5),
134 (-5, -5.0, 6),
135 (1, -5.0, 5),
136 ]
137 for i, (x, y, z) in enumerate(kelp_positions):
138 phase = rng.uniform(0, math.tau)
139 kelp = Kelp(phase=phase, name=f"Kelp_{i}", position=Vec3(x, y, z))
140 self.add_child(kelp)
141
142 # Coral formations: 4 spread around the floor, scaled up to be visible
143 coral_configs = [(-5, -4.5, -4), (5, -4.5, -3), (-4, -4.5, 5), (5, -4.5, 5)]
144 for i, (x, y, z) in enumerate(coral_configs):
145 coral = CoralFormation(colour_index=i, name=f"Coral_{i}", position=Vec3(x, y, z))
146 coral.scale = Vec3(3.0, 3.0, 3.0)
147 self.add_child(coral)
148
149 # Anemones: on the floor, scaled up, vivid bioluminescent colours
150 anem_colours = [
151 (0.12, 0.65, 0.75, 4.0),
152 (0.6, 0.32, 0.1, 3.5),
153 (0.15, 0.45, 0.7, 4.0),
154 (0.5, 0.15, 0.55, 3.5),
155 ]
156 anem_positions = [(-3, -4.0, -4), (5, -4.0, -2), (-2, -4.0, 5), (6, -4.0, 5)]
157 for i, ((x, y, z), colour) in enumerate(zip(anem_positions, anem_colours, strict=True)):
158 anem = Anemone(colour=colour, name=f"Anemone_{i}", position=Vec3(x, y, z))
159 anem.scale = Vec3(2.0, 2.0, 2.0)
160 anem.creature_clicked.connect(self._on_creature_clicked)
161 self.add_child(anem)
162
163 # Jellyfish: 6 total, spread across all quadrants, varied scales for depth
164 jelly_configs = [
165 (0, Vec3(-4, 2.5, -3), 1.0), # Moon Jelly: front-left, standard
166 (1, Vec3(5, 5.5, -4), 1.15), # Sea Nettle: rear-right, high, slightly larger
167 (2, Vec3(-3, 3.5, 5), 0.9), # Crystal Jelly: front-right, medium
168 (3, Vec3(6, 1.5, 4), 1.1), # Atolla: right, low
169 (0, Vec3(2, 4.5, -7), 1.25), # Second Moon: rear-centre, high, largest
170 (2, Vec3(-7, 1.5, -1), 0.75), # Second Crystal: left, low, smallest
171 ]
172 for i, (species, pos, extra_scale) in enumerate(jelly_configs):
173 jelly = Jellyfish(species_index=species, name=f"Jellyfish_{i}", position=pos)
174 jelly.scale = Vec3(extra_scale, extra_scale, extra_scale)
175 jelly.creature_clicked.connect(self._on_creature_clicked)
176 self.add_child(jelly)
177
178 # Fish schools: spread wider
179 school1 = FishSchool(count=8, name="School_Blue")
180 school1.creature_clicked.connect(self._on_creature_clicked)
181 self.add_child(school1)
182
183 school2 = FishSchool(count=6, emissive_colours=FISH_WARM_COLOURS, name="School_Warm")
184 school2.position = Vec3(5, 0.5, 4)
185 school2.creature_clicked.connect(self._on_creature_clicked)
186 self.add_child(school2)
187
188 # Floating particulate matter: visible motes drifting in the tank
189 specks = ParticleEmitter(name="WaterSpecks", amount=150, seed=1)
190 specks.emission_shape = "box"
191 specks.emission_box = (18.0, 12.0, 18.0)
192 specks.start_colour = (0.3, 0.4, 0.6, 0.5)
193 specks.end_colour = (0.1, 0.15, 0.25, 0.0)
194 specks.start_scale = 0.05
195 specks.end_scale = 0.02
196 specks.lifetime = 14.0
197 specks.emission_rate = 10.0
198 specks.gravity = (0.0, 0.012, 0.0)
199 specks.velocity_spread = 0.15
200 specks.initial_velocity = (0.0, 0.025, 0.0)
201 self.add_child(specks)
202
203 # Music
204 self._music = AmbientMusicController()
205 self.add_child(self._music)
206
207 # HUD
208 self._hud = Text2D(
209 name="HUD",
210 text="Space: orbit | Click: interact | Scroll: zoom",
211 position=(20, 20),
212 font_scale=1.5,
213 colour=(0.5, 0.7, 0.9, 0.8),
214 )
215 self.add_child(self._hud)
216
217 self._creature_label = Text2D(
218 name="CreatureLabel",
219 text="",
220 position=(20, 50),
221 font_scale=1.8,
222 colour=(0.8, 0.9, 1.0, 0.0),
223 )
224 self.add_child(self._creature_label)
225
226 # Post-processing: museum aquarium look via WorldEnvironment
227 env = self.add_child(WorldEnvironment(name="PostFX"))
228 env.bloom_enabled = True
229 env.bloom_intensity = 0.35
230 env.bloom_threshold = 0.4
231 env.tonemap_exposure = 0.5
232 env.vignette_enabled = True
233 env.vignette_intensity = 0.4
234 env.vignette_smoothness = 0.4
235 env.dof_enabled = True
236 env.dof_focus_distance = 0.45
237 env.dof_focus_range = 0.35
238 env.film_grain_enabled = False
239 env.chromatic_aberration_enabled = False
240
241 def on_update(self, dt: float):
242 self._handle_input()
243 self._update_camera(dt)
244 self._update_overlay_text(dt)
245
246 def _handle_input(self):
247 # Quit
248 if Input.is_key_just_pressed(Key.ESCAPE):
249 self.app.quit()
250 return
251
252 # Toggle auto-orbit
253 if Input.is_key_just_pressed(Key.SPACE):
254 self._auto_orbit = not self._auto_orbit
255
256 cam = self._cam
257 if cam is None:
258 return
259
260 # Mouse drag for orbit
261 if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
262 self._dragging = True
263 self._last_mouse = tuple(Input.mouse_position)
264 if Input.is_mouse_button_just_released(MouseButton.LEFT):
265 self._dragging = False
266
267 if self._dragging:
268 mx, my = tuple(Input.mouse_position)
269 dx = mx - self._last_mouse[0]
270 dy = my - self._last_mouse[1]
271 self._last_mouse = (mx, my)
272 if dx or dy:
273 # Clamp the pitch target first so orbit() lands inside the tank's
274 # comfortable range in a single update_transform().
275 pitch = max(MIN_PITCH, min(MAX_PITCH, cam.pitch - dy * DRAG_SENSITIVITY))
276 cam.orbit(-dx * DRAG_SENSITIVITY, pitch - cam.pitch)
277 self._intro_t = None
278
279 # Scroll zoom
280 scroll = Input.scroll_delta
281 if scroll[1] != 0:
282 distance = max(MIN_DISTANCE, min(MAX_DISTANCE, cam.distance - scroll[1] * ZOOM_PER_NOTCH))
283 cam.zoom(cam.distance - distance)
284 self._intro_t = None
285
286 def _update_camera(self, dt: float):
287 cam = self._cam
288 if cam is None:
289 return
290
291 # Opening pull-back, eased so it settles rather than snapping
292 if self._intro_t is not None:
293 self._intro_t = min(1.0, self._intro_t + dt / INTRO_DURATION)
294 k = self._intro_t * self._intro_t * (3.0 - 2.0 * self._intro_t) # smoothstep
295 cam.distance = INTRO_DISTANCE + (SETTLED_DISTANCE - INTRO_DISTANCE) * k
296 cam.pitch = INTRO_PITCH + (SETTLED_PITCH - INTRO_PITCH) * k
297 cam.update_transform()
298 if self._intro_t >= 1.0:
299 self._intro_t = None
300
301 # Slow cinematic orbit
302 if self._auto_orbit and not self._dragging:
303 cam.orbit(AUTO_ORBIT_SPEED * dt, 0.0)
304
305 def _update_overlay_text(self, dt: float):
306 # Controls hint: held, then faded out
307 if self._hud_timer > 0 and self._hud:
308 self._hud_timer -= dt
309 alpha = max(0.0, min(1.0, self._hud_timer / HUD_FADE))
310 self._hud.colour = (0.5, 0.7, 0.9, 0.8 * alpha)
311
312 # Creature name: same hold-then-fade, cleared once invisible
313 if self._creature_label_timer > 0 and self._creature_label:
314 self._creature_label_timer -= dt
315 alpha = max(0.0, min(1.0, self._creature_label_timer / LABEL_FADE))
316 self._creature_label.colour = (0.8, 0.9, 1.0, 0.9 * alpha)
317 if self._creature_label_timer <= 0:
318 self._creature_label.text = ""
319
320 def _on_creature_clicked(self, creature_name: str):
321 """Name the creature on screen and answer the click with a chime."""
322 if self._creature_label:
323 self._creature_label.text = creature_name
324 self._creature_label.colour = (0.8, 0.9, 1.0, 0.9)
325 self._creature_label_timer = LABEL_HOLD + LABEL_FADE
326
327 if self._music:
328 self._music.play_chime()
329
330
331# ============================================================================
332# Entry point
333# ============================================================================
334
335
336def run(visible: bool = True, **app_kw):
337 """Launch the aquarium. Returns (app, scene) for programmatic use."""
338 from simvx.graphics import App
339
340 app = App(title="Deep Sea Aquarium", width=1920, height=1080, visible=visible, **app_kw)
341 scene = AquariumScene()
342 if visible:
343 app.run(scene)
344 return app, scene
345
346
347if __name__ == "__main__":
348 run()