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