Deep Sea Aquarium¶
Bioluminescent underwater world with boids and bloom.
▶ Run in browserTags: 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 position=(20, 20), font_scale=1.5,
181 colour=(0.5, 0.7, 0.9, 0.8),
182 )
183 self.add_child(self._hud)
184
185 self._creature_label = Text2D(
186 name="CreatureLabel",
187 text="",
188 position=(20, 50), font_scale=1.8,
189 colour=(0.8, 0.9, 1.0, 0.0),
190 )
191 self.add_child(self._creature_label)
192
193 # Post-processing: museum aquarium look via WorldEnvironment
194 env = self.add_child(WorldEnvironment(name="PostFX"))
195 env.bloom_enabled = True
196 env.bloom_intensity = 0.35
197 env.bloom_threshold = 0.4
198 env.tonemap_exposure = 0.5
199 env.vignette_enabled = True
200 env.vignette_intensity = 0.4
201 env.vignette_smoothness = 0.4
202 env.dof_enabled = True
203 env.dof_focus_distance = 0.45
204 env.dof_focus_range = 0.35
205 env.film_grain_enabled = False
206 env.chromatic_aberration_enabled = False
207
208 # Start looking down at the scene: floor visible, background minimal
209 self._distance = 16.0
210 self._target = Vec3(0, -0.5, 0)
211 self._pitch = 30.0
212 self._yaw = 15.0
213
214 def on_update(self, dt: float):
215 self._time += dt
216
217 # Handle input
218 self._handle_input(dt)
219
220 # Intro camera pull-back
221 if self._intro_timer > 0:
222 self._intro_timer -= dt
223 if self._intro_timer <= 0:
224 self._zoom_active = False
225 self._distance = 20.0
226 self._target = Vec3(0, -0.5, 0)
227 self._pitch = 28.0
228
229 # Camera zoom-to-creature
230 if self._zoom_active:
231 self._zoom_timer -= dt
232 lerp_speed = min(1.0, dt * 2.0)
233 self._target = self._target + (self._zoom_target_pos - self._target) * lerp_speed
234 self._distance += (self._zoom_target_dist - self._distance) * lerp_speed
235 if self._zoom_timer <= 0:
236 self._zoom_active = False
237 # Return to previous orbit
238 self._target = self._zoom_return_target
239 self._distance = self._zoom_return_dist
240
241 # Auto orbit
242 if self._auto_orbit and not self._dragging:
243 self._yaw += 3.5 * dt # Slow cinematic orbit
244
245 # Update camera position
246 self._update_camera()
247
248 # Fade HUD after 5s
249 if self._hud_timer > 0:
250 self._hud_timer -= dt
251 if self._hud_timer <= 0 and self._hud:
252 self._hud.colour = (0.5, 0.7, 0.9, 0.0)
253
254 # Fade creature label
255 if self._creature_label_timer > 0:
256 self._creature_label_timer -= dt
257 if self._creature_label_timer <= 0 and self._creature_label:
258 self._creature_label.text = ""
259 self._creature_label.colour = (0.8, 0.9, 1.0, 0.0)
260
261 def _handle_input(self, dt: float):
262 # Quit
263 if Input.is_key_just_pressed(Key.ESCAPE):
264 self.app.quit()
265 return
266
267 # Toggle auto-orbit
268 if Input.is_key_just_pressed(Key.SPACE):
269 self._auto_orbit = not self._auto_orbit
270
271 # Mouse drag for orbit
272 if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
273 self._dragging = True
274 self._last_mouse = tuple(Input.mouse_position)
275 if Input.is_mouse_button_just_released(MouseButton.LEFT):
276 self._dragging = False
277
278 if self._dragging:
279 mx, my = tuple(Input.mouse_position)
280 dx = mx - self._last_mouse[0]
281 dy = my - self._last_mouse[1]
282 self._yaw -= dx * 0.3
283 self._pitch = max(-30, min(60, self._pitch + dy * 0.3))
284 self._last_mouse = (mx, my)
285
286 # Scroll zoom
287 scroll = Input.scroll_delta
288 if scroll[1] != 0:
289 self._distance = max(3.0, min(40.0, self._distance - scroll[1] * 1.5))
290
291 def _update_camera(self):
292 if not self._cam:
293 return
294 yaw_rad = math.radians(self._yaw)
295 pitch_rad = math.radians(self._pitch)
296 cp = math.cos(pitch_rad)
297 tx, ty, tz = float(self._target.x), float(self._target.y), float(self._target.z)
298 x = tx + self._distance * cp * math.sin(yaw_rad)
299 y = ty + self._distance * math.sin(pitch_rad)
300 z = tz + self._distance * cp * math.cos(yaw_rad)
301 self._cam.position = Vec3(x, y, z)
302 self._cam.look_at(self._target)
303
304 def _on_creature_clicked(self, creature_name: str):
305 """Handle creature interaction: show label, zoom, play chime."""
306 # Show creature label
307 if self._creature_label:
308 self._creature_label.text = creature_name
309 self._creature_label.colour = (0.8, 0.9, 1.0, 0.9)
310 self._creature_label_timer = 3.0
311
312 # Play chime
313 if self._music:
314 self._music.play_chime()
315
316 def _zoom_to(self, position: Vec3):
317 """Smooth camera zoom to a world position."""
318 if self._zoom_active:
319 return
320 self._zoom_active = True
321 self._zoom_return_yaw = self._yaw
322 self._zoom_return_pitch = self._pitch
323 self._zoom_return_dist = self._distance
324 self._zoom_return_target = Vec3(self._target)
325 self._zoom_target_pos = position
326 self._zoom_target_dist = 5.0
327 self._zoom_timer = 4.0
328
329 def on_input(self, event):
330 """Handle unhandled clicks for water ripple effect."""
331 if getattr(event, "type", None) == "mouse_button" and getattr(event, "pressed", False):
332 if getattr(event, "button", None) == MouseButton.LEFT:
333 # Empty water click: play low chime
334 if self._music:
335 self._music.play_chime(0)
336
337
338# ============================================================================
339# Entry point
340# ============================================================================
341
342def run(visible: bool = True, **app_kw):
343 """Launch the aquarium. Returns (app, scene) for programmatic use."""
344 from simvx.graphics import App
345
346 app = App(title="Deep Sea Aquarium", width=1920, height=1080, visible=visible, **app_kw)
347 scene = AquariumScene()
348 if visible:
349 app.run(scene)
350 return app, scene
351
352
353if __name__ == "__main__":
354 run()