Deep Sea Aquarium¶

bioluminescent tank with flocking fish and a synthesised score.

â–¶ Run in browser

Tags: 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

main.py

Deep Sea Aquarium: bioluminescent tank with flocking fish and a synthesised score.

348

__init__.py

0

boids.py

Vectorised numpy boids flocking: separation, alignment, cohesion.

73

creatures.py

Bioluminescent sea creatures: Jellyfish, Fish, FishSchool, CoralFormation, Anemone.

661

environment.py

Environment nodes: SeaFloor and Kelp.

120

meshgen.py

Procedural mesh generation: pure numpy, no external libraries.

240

music.py

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