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, 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

main.py

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

323

__init__.py

0

boids.py

Vectorised numpy boids flocking: separation, alignment, cohesion.

73

creatures.py

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

659

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