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