Deep Sea Aquarium

Bioluminescent underwater world with boids and bloom.

▶ Run in browser

Tags: 3d particles bloom audio boids

A visually immersive 3D demo showcasing PBR rendering, bloom, particles, audio, and interaction. Dark ocean depths with self-illuminating creatures creating stunning visuals through emissive materials and bloom.

Controls: Mouse drag : Orbit camera Scroll : Zoom in/out Space : Toggle auto-orbit Click : Interact with creature (zoom, highlight, chime) Escape : Quit

Source

  1"""Deep Sea Aquarium: Bioluminescent underwater world with boids and bloom.
  2
  3# /// simvx
  4# tags = ["3d", "particles", "bloom", "audio", "boids"]
  5# web = { width = 1920, height = 1080 }
  6# ///
  7
  8A visually immersive 3D demo showcasing PBR rendering, bloom, particles,
  9audio, and interaction. Dark ocean depths with self-illuminating creatures
 10creating stunning visuals through emissive materials and bloom.
 11
 12Controls:
 13    Mouse drag : Orbit camera
 14    Scroll     : Zoom in/out
 15    Space      : Toggle auto-orbit
 16    Click      : Interact with creature (zoom, highlight, chime)
 17    Escape     : Quit
 18"""
 19
 20
 21import math
 22import sys
 23from pathlib import Path
 24
 25import numpy as np
 26
 27from simvx.core import (
 28    Camera3D,
 29    Input,
 30    Key,
 31    MouseButton,
 32    Node,
 33    ParticleEmitter,
 34    Text2D,
 35    Vec3,
 36    WorldEnvironment,
 37)
 38
 39sys.path.insert(0, str(Path(__file__).resolve().parent))
 40
 41from creatures import Anemone, CoralFormation, FishSchool, Jellyfish
 42from environment import Kelp, SeaFloor
 43from music import AmbientMusicController
 44
 45
 46class AquariumScene(Node):
 47    """Root scene for the deep sea aquarium."""
 48
 49    def __init__(self, **kw):
 50        super().__init__(name="Aquarium", **kw)
 51        self._cam: Camera3D | None = None
 52        self._time = 0.0
 53        self._hud: Text2D | None = None
 54        self._hud_timer = 3.0
 55        self._creature_label: Text2D | None = None
 56        self._creature_label_timer = 0.0
 57        self._music: AmbientMusicController | None = None
 58
 59        # Camera orbit state
 60        self._yaw = 30.0
 61        self._pitch = 12.0
 62        self._distance = 22.0
 63        self._target = Vec3(0, 0, 0)
 64        self._auto_orbit = True
 65        self._dragging = False
 66        self._last_mouse = (0.0, 0.0)
 67
 68        # Zoom-to-creature state
 69        self._zoom_active = False
 70        self._zoom_target_pos = Vec3(0, 0, 0)
 71        self._zoom_target_dist = 5.0
 72        self._zoom_timer = 0.0
 73        self._zoom_return_yaw = 0.0
 74        self._zoom_return_pitch = 0.0
 75        self._zoom_return_dist = 0.0
 76        self._zoom_return_target = Vec3(0, 0, 0)
 77
 78        # Startup camera animation
 79        self._intro_timer = 3.0
 80
 81    def on_ready(self):
 82        # Camera
 83        self._cam = Camera3D(name="Camera", fov=55.0, near=0.1, far=200.0)
 84        self.add_child(self._cam)
 85
 86        # Note: forward.frag uses hardcoded lighting, point/directional lights are cosmetic only
 87        # Floor lighting achieved via emissive gradient in concentric ring materials
 88
 89        # Central floor spotlight: pool of light on the ground like a real tank feature light
 90        from simvx.core import PointLight3D
 91        # Centre floor light (cosmetic: shader doesn't use it, but kept for future PBR)
 92        centre_light = PointLight3D(name="CentreFloorLight", position=Vec3(0, -3.5, 0))
 93        centre_light.colour = (0.1, 0.12, 0.22)
 94        centre_light.intensity = 5.0
 95        centre_light.range = 15.0
 96        self.add_child(centre_light)
 97
 98        # Sea floor: very dark, just enough to ground the scene
 99        self.add_child(SeaFloor())
100
101        # Kelp strands: dark silhouettes rising from the floor
102        rng = np.random.default_rng(99)
103        kelp_positions = [
104            (-7, -5.0, -6), (-4, -5.0, 3), (2, -5.0, -7), (6, -5.0, 2),
105            (-2, -5.0, -2), (4, -5.0, -5), (-5, -5.0, 6), (1, -5.0, 5),
106        ]
107        for i, (x, y, z) in enumerate(kelp_positions):
108            phase = rng.uniform(0, math.tau)
109            kelp = Kelp(phase=phase, name=f"Kelp_{i}", position=Vec3(x, y, z))
110            self.add_child(kelp)
111
112        # Coral formations: 4 spread around the floor, scaled up to be visible
113        coral_configs = [(-5, -4.5, -4), (5, -4.5, -3), (-4, -4.5, 5), (5, -4.5, 5)]
114        for i, (x, y, z) in enumerate(coral_configs):
115            coral = CoralFormation(colour_index=i, name=f"Coral_{i}", position=Vec3(x, y, z))
116            coral.scale = Vec3(3.0, 3.0, 3.0)
117            self.add_child(coral)
118
119        # Anemones: on the floor, scaled up, vivid bioluminescent colours
120        anem_colours = [
121            (0.12, 0.65, 0.75, 4.0), (0.6, 0.32, 0.1, 3.5),
122            (0.15, 0.45, 0.7, 4.0), (0.5, 0.15, 0.55, 3.5),
123        ]
124        anem_positions = [(-3, -4.0, -4), (5, -4.0, -2), (-2, -4.0, 5), (6, -4.0, 5)]
125        for i, ((x, y, z), colour) in enumerate(zip(anem_positions, anem_colours, strict=True)):
126            anem = Anemone(colour=colour, name=f"Anemone_{i}", position=Vec3(x, y, z))
127            anem.scale = Vec3(2.0, 2.0, 2.0)
128            anem.creature_clicked.connect(self._on_creature_clicked)
129            self.add_child(anem)
130
131        # Jellyfish: 6 total, spread across all quadrants, varied scales for depth
132        jelly_configs = [
133            (0, Vec3(-4, 2.5, -3), 1.0),    # Moon Jelly: front-left, standard
134            (1, Vec3(5, 5.5, -4), 1.15),     # Sea Nettle: rear-right, high, slightly larger
135            (2, Vec3(-3, 3.5, 5), 0.9),      # Crystal Jelly: front-right, medium
136            (3, Vec3(6, 1.5, 4), 1.1),       # Atolla: right, low
137            (0, Vec3(2, 4.5, -7), 1.25),     # Second Moon: rear-centre, high, largest
138            (2, Vec3(-7, 1.5, -1), 0.75),    # Second Crystal: left, low, smallest
139        ]
140        for i, (species, pos, extra_scale) in enumerate(jelly_configs):
141            jelly = Jellyfish(species_index=species, name=f"Jellyfish_{i}", position=pos)
142            jelly.scale = Vec3(extra_scale, extra_scale, extra_scale)
143            jelly.creature_clicked.connect(self._on_creature_clicked)
144            self.add_child(jelly)
145
146        # Fish schools: spread wider
147        school1 = FishSchool(count=8, name="School_Blue")
148        school1.creature_clicked.connect(self._on_creature_clicked)
149        self.add_child(school1)
150
151        from creatures import FISH_WARM_COLOURS
152        school2 = FishSchool(count=6, emissive_colours=FISH_WARM_COLOURS, name="School_Warm")
153        school2.position = Vec3(5, 0.5, 4)
154        school2.creature_clicked.connect(self._on_creature_clicked)
155        self.add_child(school2)
156
157        # Floating particulate matter: visible motes drifting in the tank
158        specks = ParticleEmitter(name="WaterSpecks", amount=150, seed=1)
159        specks.emission_shape = "box"
160        specks.emission_box = (18.0, 12.0, 18.0)
161        specks.start_colour = (0.3, 0.4, 0.6, 0.5)
162        specks.end_colour = (0.1, 0.15, 0.25, 0.0)
163        specks.start_scale = 0.05
164        specks.end_scale = 0.02
165        specks.lifetime = 14.0
166        specks.emission_rate = 10.0
167        specks.gravity = (0.0, 0.012, 0.0)
168        specks.velocity_spread = 0.15
169        specks.initial_velocity = (0.0, 0.025, 0.0)
170        self.add_child(specks)
171
172        # Music
173        self._music = AmbientMusicController()
174        self.add_child(self._music)
175
176        # HUD
177        self._hud = Text2D(
178            name="HUD",
179            text="Space: orbit | Click: interact | Scroll: zoom",
180            x=20,
181            y=20,
182            font_scale=1.5,
183            colour=(0.5, 0.7, 0.9, 0.8),
184        )
185        self.add_child(self._hud)
186
187        self._creature_label = Text2D(
188            name="CreatureLabel",
189            text="",
190            x=20,
191            y=50,
192            font_scale=1.8,
193            colour=(0.8, 0.9, 1.0, 0.0),
194        )
195        self.add_child(self._creature_label)
196
197        # Post-processing: museum aquarium look via WorldEnvironment
198        env = self.add_child(WorldEnvironment(name="PostFX"))
199        env.bloom_enabled = True
200        env.bloom_intensity = 0.35
201        env.bloom_threshold = 0.4
202        env.tonemap_exposure = 0.5
203        env.vignette_enabled = True
204        env.vignette_intensity = 0.4
205        env.vignette_smoothness = 0.4
206        env.dof_enabled = True
207        env.dof_focus_distance = 0.45
208        env.dof_focus_range = 0.35
209        env.film_grain_enabled = False
210        env.chromatic_aberration_enabled = False
211
212        # Start looking down at the scene: floor visible, background minimal
213        self._distance = 16.0
214        self._target = Vec3(0, -0.5, 0)
215        self._pitch = 30.0
216        self._yaw = 15.0
217
218    def on_process(self, dt: float):
219        self._time += dt
220
221        # Handle input
222        self._handle_input(dt)
223
224        # Intro camera pull-back
225        if self._intro_timer > 0:
226            self._intro_timer -= dt
227            if self._intro_timer <= 0:
228                self._zoom_active = False
229                self._distance = 20.0
230                self._target = Vec3(0, -0.5, 0)
231                self._pitch = 28.0
232
233        # Camera zoom-to-creature
234        if self._zoom_active:
235            self._zoom_timer -= dt
236            lerp_speed = min(1.0, dt * 2.0)
237            self._target = self._target + (self._zoom_target_pos - self._target) * lerp_speed
238            self._distance += (self._zoom_target_dist - self._distance) * lerp_speed
239            if self._zoom_timer <= 0:
240                self._zoom_active = False
241                # Return to previous orbit
242                self._target = self._zoom_return_target
243                self._distance = self._zoom_return_dist
244
245        # Auto orbit
246        if self._auto_orbit and not self._dragging:
247            self._yaw += 3.5 * dt  # Slow cinematic orbit
248
249        # Update camera position
250        self._update_camera()
251
252        # Fade HUD after 5s
253        if self._hud_timer > 0:
254            self._hud_timer -= dt
255            if self._hud_timer <= 0 and self._hud:
256                self._hud.colour = (0.5, 0.7, 0.9, 0.0)
257
258        # Fade creature label
259        if self._creature_label_timer > 0:
260            self._creature_label_timer -= dt
261            if self._creature_label_timer <= 0 and self._creature_label:
262                self._creature_label.text = ""
263                self._creature_label.colour = (0.8, 0.9, 1.0, 0.0)
264
265    def _handle_input(self, dt: float):
266        # Quit
267        if Input.is_key_just_pressed(Key.ESCAPE):
268            self.app.quit()
269            return
270
271        # Toggle auto-orbit
272        if Input.is_key_just_pressed(Key.SPACE):
273            self._auto_orbit = not self._auto_orbit
274
275        # Mouse drag for orbit
276        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
277            self._dragging = True
278            self._last_mouse = tuple(Input.mouse_position)
279        if Input.is_mouse_button_just_released(MouseButton.LEFT):
280            self._dragging = False
281
282        if self._dragging:
283            mx, my = tuple(Input.mouse_position)
284            dx = mx - self._last_mouse[0]
285            dy = my - self._last_mouse[1]
286            self._yaw -= dx * 0.3
287            self._pitch = max(-30, min(60, self._pitch + dy * 0.3))
288            self._last_mouse = (mx, my)
289
290        # Scroll zoom
291        scroll = Input.scroll_delta
292        if scroll[1] != 0:
293            self._distance = max(3.0, min(40.0, self._distance - scroll[1] * 1.5))
294
295    def _update_camera(self):
296        if not self._cam:
297            return
298        yaw_rad = math.radians(self._yaw)
299        pitch_rad = math.radians(self._pitch)
300        cp = math.cos(pitch_rad)
301        tx, ty, tz = float(self._target.x), float(self._target.y), float(self._target.z)
302        x = tx + self._distance * cp * math.sin(yaw_rad)
303        y = ty + self._distance * math.sin(pitch_rad)
304        z = tz + self._distance * cp * math.cos(yaw_rad)
305        self._cam.position = Vec3(x, y, z)
306        self._cam.look_at(self._target)
307
308    def _on_creature_clicked(self, creature_name: str):
309        """Handle creature interaction: show label, zoom, play chime."""
310        # Show creature label
311        if self._creature_label:
312            self._creature_label.text = creature_name
313            self._creature_label.colour = (0.8, 0.9, 1.0, 0.9)
314            self._creature_label_timer = 3.0
315
316        # Play chime
317        if self._music:
318            self._music.play_chime()
319
320    def _zoom_to(self, position: Vec3):
321        """Smooth camera zoom to a world position."""
322        if self._zoom_active:
323            return
324        self._zoom_active = True
325        self._zoom_return_yaw = self._yaw
326        self._zoom_return_pitch = self._pitch
327        self._zoom_return_dist = self._distance
328        self._zoom_return_target = Vec3(self._target)
329        self._zoom_target_pos = position
330        self._zoom_target_dist = 5.0
331        self._zoom_timer = 4.0
332
333    def on_input(self, event):
334        """Handle unhandled clicks for water ripple effect."""
335        if getattr(event, "type", None) == "mouse_button" and getattr(event, "pressed", False):
336            if getattr(event, "button", None) == MouseButton.LEFT:
337                # Empty water click: play low chime
338                if self._music:
339                    self._music.play_chime(0)
340
341
342# ============================================================================
343# Entry point
344# ============================================================================
345
346def run(visible: bool = True, **app_kw):
347    """Launch the aquarium. Returns (app, scene) for programmatic use."""
348    from simvx.graphics import App
349
350    app = App(title="Deep Sea Aquarium", width=1920, height=1080, visible=visible, **app_kw)
351    scene = AquariumScene()
352    if visible:
353        app.run(scene)
354    return app, scene
355
356
357if __name__ == "__main__":
358    run()