Tidewater¶
an FFT sea meeting a mirror-calm river under golden-hour rain.
â–¶ Run in browserTags: 3d ocean water river rain sky reflections
A single coastal scene that exercises most of the renderer’s 3D feature set at once, each feature doing real work in the frame:
An open FFT ocean (
OceanSurface3D) fills the horizon: a Tessendorf spectrum inverse-transformed each frame into rolling swell, choppy crests and wind-laced foam that curls around the rock stacks out at sea.A mirror-calm river (
WaterSurface3D+PlanarReflection3D) runs between two wet rocky banks toward the sea. Its surface is a true planar mirror: the banks, the rain-slicked stones and the golden sky invert in it every frame, rippled by a small Gerstner swell and Fresnel-blended over the refraction below.A procedural sky (
sky_mode="procedural") is synthesized from the low golden-hour sun with a hazy turbidity, and drives both the sky background and the image-based lighting the whole scene reflects.Rain and weather (
Rain3Dplus theWorldEnvironmentwetness and ripple channels): wind-slanted rain falls across the view while everywetness_affectedrock darkens, drops roughness and gains an animated puddle ripple, so the slick banks mirror the golden sky back at you.Global illumination from both ends: a baked
IrradianceVolume3Dover the near shore lifts the shadowed undersides of the wet boulders with soft SH-L1 bounce, while screen-space GI (ssgi_*) adds the near-field, view-dependent colour bleed. Baked GI, SSGI, screen-space reflections (ssr_*) and the planar-mirror river all run in the same frame.
The whole scene is cross-backend: the FFT ocean, the planar-mirror river, SSR and SSGI all run on the web renderer too (the ocean via its WebGPU compute twin).
A slow cinematic camera sways over the river mouth, looking downstream at the sun-glinting sea. Press SPACE (or click / tap) to pause the whole scene: camera, ocean, river and rain all freeze together. ESC quits.
Usage: uv run python examples/demos/tidewater.py uv run python examples/demos/tidewater.py –test # headless self-check
Controls: SPACE / click / tap - Pause / resume the whole scene ESC - Quit
Source¶
1"""Tidewater: an FFT sea meeting a mirror-calm river under golden-hour rain.
2
3A single coastal scene that exercises most of the renderer's 3D feature set at
4once, each feature doing real work in the frame:
5
6* An open **FFT ocean** (``OceanSurface3D``) fills the horizon: a Tessendorf
7 spectrum inverse-transformed each frame into rolling swell, choppy crests and
8 wind-laced foam that curls around the rock stacks out at sea.
9* A **mirror-calm river** (``WaterSurface3D`` + ``PlanarReflection3D``) runs
10 between two wet rocky banks toward the sea. Its surface is a true planar
11 mirror: the banks, the rain-slicked stones and the golden sky invert in it
12 every frame, rippled by a small Gerstner swell and Fresnel-blended over the
13 refraction below.
14* A **procedural sky** (``sky_mode="procedural"``) is synthesized from the low
15 golden-hour sun with a hazy turbidity, and drives both the sky background and
16 the image-based lighting the whole scene reflects.
17* **Rain and weather** (``Rain3D`` plus the ``WorldEnvironment`` wetness and
18 ripple channels): wind-slanted rain falls across the view while every
19 ``wetness_affected`` rock darkens, drops roughness and gains an animated
20 puddle ripple, so the slick banks mirror the golden sky back at you.
21* **Global illumination** from both ends: a baked ``IrradianceVolume3D`` over
22 the near shore lifts the shadowed undersides of the wet boulders with soft
23 SH-L1 bounce, while screen-space GI (``ssgi_*``) adds the near-field,
24 view-dependent colour bleed. Baked GI, SSGI, screen-space reflections
25 (``ssr_*``) and the planar-mirror river all run in the same frame.
26
27The whole scene is cross-backend: the FFT ocean, the planar-mirror river, SSR
28and SSGI all run on the web renderer too (the ocean via its WebGPU compute
29twin).
30
31A slow cinematic camera sways over the river mouth, looking downstream at the
32sun-glinting sea. Press SPACE (or click / tap) to pause the whole scene:
33camera, ocean, river and rain all freeze together. ESC quits.
34
35# /// simvx
36# tags = ["3d", "ocean", "water", "river", "rain", "sky", "reflections"]
37# screenshot_frame = 54
38# ///
39
40Usage:
41 uv run python examples/demos/tidewater.py
42 uv run python examples/demos/tidewater.py --test # headless self-check
43
44Controls:
45 SPACE / click / tap - Pause / resume the whole scene
46 ESC - Quit
47"""
48
49from __future__ import annotations
50
51import math
52import sys
53
54from simvx.core import (
55 Camera3D,
56 DirectionalLight3D,
57 InputMap,
58 IrradianceVolume3D,
59 Key,
60 Material,
61 Mesh,
62 MeshInstance3D,
63 MouseButton,
64 Node,
65 OceanSurface3D,
66 PlanarReflection3D,
67 Rain3D,
68 Text2D,
69 WaterMaterial,
70 WaterSurface3D,
71 WorldEnvironment,
72 on_input,
73)
74from simvx.graphics import App
75
76WIDTH, HEIGHT = 1280, 720
77
78# World layout. A calm river channel runs down the middle (surface at
79# ``RIVER_LEVEL``) between two raised rocky banks, and spills over a low rock lip
80# at ``SHELF_EDGE_Z`` into the open sea, whose surface sits a step lower at
81# ``SEA_LEVEL``. The sea plane begins AT the shelf edge (never underlaps the
82# foreground), so the two water bodies read as a river pouring into the sea with
83# no z-fighting and no near-field sea foam poking through the banks.
84RIVER_LEVEL = 0.5
85SEA_LEVEL = -0.5
86RIVER_HALF_WIDTH = 9.0
87SHELF_EDGE_Z = -20.0 # where the banks end and the open sea begins
88
89# Bank rock: base albedo kept readable so the wetness darkening (x0.55) still
90# leaves a grey-brown wet stone rather than crushing it to black.
91ROCK = (0.44, 0.41, 0.37, 1.0)
92ROCK_MOSS = (0.38, 0.42, 0.32, 1.0)
93ROCK_PALE = (0.54, 0.51, 0.47, 1.0)
94RIVERBED = (0.16, 0.17, 0.15, 1.0) # dark bed so the deep river reads rich, not muddy
95
96
97class TidewaterScene(Node):
98 def on_ready(self):
99 InputMap.add_action("quit", [Key.ESCAPE])
100 # Left-click / tap also pauses, so the demo is fully operable with a
101 # pointer alone (touch arrives as MouseButton.LEFT on web).
102 InputMap.add_action("pause", [Key.SPACE, MouseButton.LEFT])
103
104 self._build_environment()
105 self._build_sun()
106 self._build_camera()
107 self._build_shelf_and_banks()
108 self._build_gi()
109 self._build_river()
110 self._build_sea()
111 self._build_rain()
112 self._build_hud()
113
114 # -- scene construction ------------------------------------------------
115 def _build_environment(self):
116 """One WorldEnvironment ties the whole showcase together.
117
118 It enables the HDR chain the water refraction and SSR read, publishes the
119 wind that drives both the ocean spectrum and the rain slant, sets the
120 weather channels the wet rocks consume, and selects the procedural sky.
121 """
122 env = self.add_child(WorldEnvironment())
123 # Wind: drives the FFT/Gerstner swell direction and the rain slant.
124 env.wind_direction = (0.65, 0.42)
125 env.wind_strength = 0.7
126 # Weather: rain-slicked rock + subtle puddle ripples on up-faces. Kept
127 # moderate so the wet banks gloss and darken without the ripple normal
128 # tiling into a hard grid across the large surfaces.
129 env.wetness = 0.6
130 env.rain_intensity = 0.8
131 env.ripple_strength = 0.25
132 # Dynamic Preetham sky: a hazy golden-hour atmosphere driving IBL.
133 env.sky_mode = "procedural"
134 env.sky_turbidity = 3.6
135 env.sky_ground_albedo = (0.18, 0.15, 0.12, 1.0)
136 # SSR: screen-space reflections mirror the real scene geometry in the
137 # rain-slicked shelf and boulders, which IBL alone cannot do, composited
138 # over the golden-sky IBL specular without double-counting.
139 env.ssr_enabled = True
140 # SSGI: screen-space global illumination gathers one soft bounce of
141 # indirect diffuse from the on-screen scene, so the wet shore and the
142 # tumble of boulders pick up a subtle colour bleed from the warm banks and
143 # the golden water beside them instead of sitting on flat ambient. Kept
144 # tasteful (short range, gentle intensity) so it reads as ambient warmth
145 # rather than a glow.
146 env.ssgi_enabled = True
147 env.ssgi_intensity = 0.55
148 env.ssgi_max_distance = 10.0
149 # A touch of bloom so the low sun's glint on the sea blooms warmly.
150 env.bloom_enabled = True
151 env.bloom_threshold = 1.15
152 env.bloom_intensity = 0.45
153 self._env = env
154
155 def _build_sun(self):
156 """A low, warm golden-hour sun. The procedural sky is built from it."""
157 sun = self.add_child(DirectionalLight3D(intensity=3.4))
158 sun.colour = (1.0, 0.78, 0.52)
159 # Low over the sea (toward -Z), just off-centre: a long glint path down the
160 # river and across the swell, and a warm golden-hour Preetham horizon. The
161 # shallow elevation keeps the sun near the horizon for the golden mood.
162 sun_to = (0.14, 0.11, -0.98)
163 sun.direction = (-sun_to[0], -sun_to[1], -sun_to[2])
164 self._sun = sun
165
166 def _build_camera(self):
167 # Elevated, at the head of the river channel, looking downstream (toward
168 # -Z) at the sun-glinting sea. The height lets the calm river read as a
169 # mirror ribbon leading the eye to the horizon.
170 self._look_target = (0.0, 0.4, -30.0)
171 self.camera = self.add_child(
172 Camera3D(position=(0.0, 6.5, 24.0), look_at=self._look_target, up=(0, 1, 0), fov=55, near=0.1, far=900.0)
173 )
174
175 def _build_shelf_and_banks(self):
176 """The two raised rocky banks framing the river, plus its dark bed.
177
178 The banks are the wet-rock showcase surface: a solid ridge on each side
179 with a scatter of boulders spilling toward the waterline, all
180 ``wetness_affected`` so they darken, gloss (roughness drop) and pick up
181 the animated ripple normal + the golden-sky IBL reflection under the rain.
182 """
183 hw = RIVER_HALF_WIDTH
184
185 # A dark riverbed well below the surface, giving the river real depth for
186 # the refraction + depth-fade (shallow->deep, transparent->opaque) to read
187 # as calm clear water rather than a painted sheet.
188 self.add_child(
189 MeshInstance3D(
190 mesh=Mesh.cube(1.0),
191 material=Material(colour=RIVERBED, roughness=0.9),
192 position=(0.0, SEA_LEVEL - 1.5, 4.0),
193 scale=(2 * hw + 2.0, 3.0, 54.0),
194 )
195 )
196
197 # The two bank ridges: a solid rock mass on each side of the channel,
198 # top above the water, sloping out of frame. They occlude the sea plane
199 # laterally so no near-field sea foam leaks past the channel.
200 for side in (-1.0, 1.0):
201 self.add_child(
202 MeshInstance3D(
203 mesh=Mesh.cube(1.0),
204 material=Material(colour=ROCK, roughness=0.8, wetness_affected=True),
205 position=(side * (hw + 11.0), 0.2, 6.0),
206 scale=(24.0, 5.4, 52.0),
207 )
208 )
209 # Break the flat slab top with a run of large boulders so each bank
210 # reads as a natural rock mass, not a box. Deterministic placement.
211 for i, (dz, r, colour) in enumerate(
212 [(-14.0, 3.4, ROCK_PALE), (-4.0, 4.2, ROCK), (5.0, 3.0, ROCK_MOSS), (14.0, 3.8, ROCK_PALE)]
213 ):
214 ox = side * (hw + 8.0 + 2.2 * (i % 2))
215 self.add_child(
216 MeshInstance3D(
217 mesh=Mesh.sphere(r, rings=16, segments=24),
218 material=Material(colour=colour, roughness=0.78, wetness_affected=True),
219 position=(ox, 2.9 + 0.25 * r, dz),
220 )
221 )
222
223 # Boulders spilling down each bank toward the waterline: varied stone,
224 # size and roughness so the banks read as a natural tumble of wet rock.
225 # Deterministic layout (no RNG) so the golden frame is stable.
226 boulders = [
227 (-hw - 0.5, 14.0, 2.4, ROCK_MOSS, 0.7),
228 (-hw + 0.4, 6.0, 1.7, ROCK_PALE, 0.55),
229 (-hw - 1.5, -2.0, 3.0, ROCK, 0.75),
230 (-hw + 0.2, -11.0, 1.9, ROCK_MOSS, 0.6),
231 (-hw - 0.8, -17.5, 2.3, ROCK_PALE, 0.5),
232 (hw + 0.6, 12.0, 2.1, ROCK_PALE, 0.5),
233 (hw - 0.3, 4.0, 1.6, ROCK, 0.7),
234 (hw + 1.4, -4.0, 2.8, ROCK_MOSS, 0.75),
235 (hw - 0.2, -12.0, 2.0, ROCK_PALE, 0.55),
236 (hw + 0.9, -17.5, 2.5, ROCK, 0.6),
237 ]
238 for x, z, r, colour, rough in boulders:
239 self.add_child(
240 MeshInstance3D(
241 mesh=Mesh.sphere(r, rings=16, segments=24),
242 material=Material(colour=colour, roughness=rough, wetness_affected=True),
243 position=(x, RIVER_LEVEL + r * 0.45, z),
244 )
245 )
246
247 # A low rock lip across the river mouth where it spills to the sea,
248 # hiding the seam between the river plane and the (lower) sea plane.
249 for x in range(-int(hw), int(hw) + 2, 3):
250 self.add_child(
251 MeshInstance3D(
252 mesh=Mesh.sphere(1.7, rings=12, segments=18),
253 material=Material(colour=ROCK, roughness=0.65, wetness_affected=True),
254 position=(float(x) + 0.5, RIVER_LEVEL - 0.5, SHELF_EDGE_Z + 0.8),
255 )
256 )
257
258 def _build_gi(self):
259 """Baked diffuse global illumination over the shore.
260
261 An ``IrradianceVolume3D`` spans the whole near shore, so the boulders and
262 bank ridges carry baked SH-L1 indirect light from the warm rock and golden
263 sky around them: the shadowed undersides of the wet stones lift with soft
264 bounced warmth instead of reading as flat ambient. This is the low-frequency
265 static GI partner to the screen-space SSGI (which adds the near-field,
266 view-dependent bleed); together they give the shore a grounded, lit-from-
267 the-scene feel. Baked once over the first frames (``bake_mode="once"``),
268 with a generous per-frame budget so it settles well before the golden frame.
269 """
270 vol = self.add_child(
271 IrradianceVolume3D(name="ShoreGI", extents=(30.0, 7.0, 26.0), spacing=7.0, position=(0.0, 2.5, 0.0))
272 )
273 # A fragment inside the volume takes its ambient from the baked SH probes
274 # instead of the golden-sky IBL. The probes carry directional warmth from the
275 # rock and sky, but their diffuse capture is dimmer than the very bright
276 # low-sun IBL, so the intensity is lifted so the boulders keep their midtone
277 # (warm lit rock rather than crushed-black) while gaining the directional
278 # bounce on their shadowed undersides.
279 vol.intensity = 3.0
280 vol.update_budget = 32 # ~216 probes -> fully baked in ~7 frames
281 self._gi = vol
282
283 def _build_river(self):
284 """The calm river with a true planar-mirror reflection."""
285 # The mirror on the river plane: re-renders the scene above it each frame.
286 self.river_mirror = self.add_child(PlanarReflection3D(position=(0.0, RIVER_LEVEL, 4.0)))
287
288 river = self.add_child(
289 WaterSurface3D(position=(0.0, RIVER_LEVEL, 4.0), size=(2 * RIVER_HALF_WIDTH, 54.0), subdivisions=112)
290 )
291 # Mirror-calm: tiny slow swell + gentle detail ripples so the planar
292 # reflection stays crisp, and a long depth-fade over the deep bed so the
293 # channel darkens from a clear teal edge to near-black centre.
294 river.material = WaterMaterial(
295 shallow_colour=(0.12, 0.30, 0.34),
296 deep_colour=(0.02, 0.07, 0.11),
297 depth_fade_distance=3.2,
298 wave_amplitude=0.03,
299 wave_length=4.5,
300 wave_steepness=0.22,
301 foam_amount=0.12,
302 refraction_strength=0.16,
303 fresnel_power=5.0,
304 normal_strength=0.25,
305 opacity=0.9,
306 )
307 # A true mirror instead of the cubemap: the banks + sky invert in the river.
308 river.reflection = self.river_mirror
309 self.river = river
310
311 def _build_sea(self):
312 """The open sea: an FFT ocean, the same on desktop and web.
313
314 The sea plane begins at the shelf edge and extends to the horizon, one
315 step below the river, so the river visibly spills into it and the plane
316 never underlaps the foreground channel.
317 """
318 # Centre the plane so its NEAR edge lands at the shelf lip: no near-field
319 # sea surface pokes past the banks into the foreground.
320 sea_size = 640.0
321 sea_z = SHELF_EDGE_Z - sea_size * 0.5
322 sea_mat = WaterMaterial(
323 shallow_colour=(0.05, 0.24, 0.30),
324 deep_colour=(0.01, 0.05, 0.10),
325 depth_fade_distance=10.0,
326 wave_amplitude=1.2,
327 wave_steepness=0.72,
328 foam_colour=(0.92, 0.96, 1.0),
329 foam_amount=0.7,
330 refraction_strength=0.35,
331 )
332 self.sea = self.add_child(
333 OceanSurface3D(position=(0.0, SEA_LEVEL, sea_z), size=(sea_size, sea_size), subdivisions=320)
334 )
335 self.sea.material = sea_mat
336
337 # A couple of rock stacks out at sea near the river mouth: they break the
338 # horizon in the low sun and give the ocean foam something to curl around.
339 self.add_child(
340 MeshInstance3D(
341 mesh=Mesh.sphere(6.5, rings=18, segments=28),
342 material=Material(colour=(0.30, 0.28, 0.26, 1.0), roughness=0.85),
343 position=(-26.0, SEA_LEVEL - 2.0, -55.0),
344 )
345 )
346 self.add_child(
347 MeshInstance3D(
348 mesh=Mesh.sphere(4.5, rings=14, segments=22),
349 material=Material(colour=(0.33, 0.31, 0.28, 1.0), roughness=0.8),
350 position=(30.0, SEA_LEVEL - 1.0, -78.0),
351 )
352 )
353
354 def _build_rain(self):
355 # Rain particles are round camera-facing sprites, so smaller and denser
356 # drops read as rain rather than as near-camera bokeh.
357 self.add_child(
358 Rain3D(
359 radius=24.0,
360 height=15.0,
361 fall_speed=28.0,
362 amount=7000,
363 wind_response=1.2,
364 start_scale=0.028,
365 end_scale=0.028,
366 )
367 )
368
369 def _build_hud(self):
370 self.add_child(Text2D(text="TIDEWATER", position=(24, 20), font_scale=2.0))
371 # Feature-list subtitle. Drawn in a rect spanning the live window width with
372 # ``fit_to_width`` so the trailing entries never clip off the right edge on a
373 # narrower window; the rect is re-fitted each frame in ``on_update``.
374 self._subtitle = self.add_child(
375 Text2D(
376 text="FFT ocean + planar-mirror river + procedural sky + rain + wetness + GI",
377 rect=(24, 58, WIDTH - 48, 22),
378 fit_to_width=True,
379 font_scale=1.1,
380 )
381 )
382 # Bottom controls hint. Pinned to the live window bottom (see ``on_update``)
383 # so it stays on screen at any window size, not just the launch resolution.
384 self._controls = self.add_child(
385 Text2D(text="SPACE / CLICK:Pause ESC:Quit", position=(24, HEIGHT - 34), font_scale=1.1)
386 )
387 # Empty while running (keeps the golden unchanged); shows PAUSED on toggle.
388 self._paused_label = self.add_child(Text2D(text="", position=(24, 92), font_scale=1.3))
389
390 # -- input -------------------------------------------------------------
391 # Pause and quit run through @on_input, not on_update polling: input
392 # handlers fire regardless of ``tree.paused``, so the pause action still
393 # resumes a frozen scene (a paused tree stops on_update on every PAUSABLE node).
394 @on_input(action="quit")
395 def _on_quit(self, event):
396 self.app.quit()
397 return True
398
399 @on_input(action="pause")
400 def _on_pause(self, event):
401 # Freeze the whole scene, not just the camera: ``tree.paused`` halts the
402 # scene clock (``tree.now``), which is what the FFT ocean, mirror river
403 # and rain animate from, so pausing stops the water and rain too.
404 tree = self.tree
405 tree.paused = not tree.paused
406 self._paused_label.text = "PAUSED" if tree.paused else ""
407 return True
408
409 # -- per-frame ---------------------------------------------------------
410 def on_update(self, dt):
411 # Keep the HUD glued to the live window: fit the subtitle to the current
412 # width and pin the controls hint to the current bottom, so both stay on
413 # screen and unclipped at any window size, not just the launch resolution.
414 win_w, win_h = int(self.app.width), int(self.app.height)
415 self._subtitle.rect = (24, 58, win_w - 48, 22)
416 self._controls.position = (24, win_h - 34)
417
418 # Slow cinematic sway + gentle dolly down the channel, always looking
419 # downstream at the sun-glinting sea. Driven by the scene clock so it is
420 # deterministic under the fixed headless clock and freezes with the rest
421 # of the scene when ``tree.paused`` halts ``tree.now``.
422 t = self.tree.now
423 cam_x = 3.0 * math.sin(t * 0.13)
424 cam_y = 6.5 + 0.5 * math.sin(t * 0.11)
425 cam_z = 24.0 - 2.5 * math.sin(t * 0.09)
426 self.camera.position = (cam_x, cam_y, cam_z)
427 # Aim just ahead down the channel, easing toward the sea so the river
428 # leads the eye to the horizon.
429 self.camera.look_at((cam_x * 0.25, 0.4, -30.0))
430
431
432def _selftest() -> bool:
433 """Headless self-check: render the flagship, assert it is non-blank and that
434 the sea, river and rain animate frame to frame (the money shot is alive)."""
435 import numpy as np
436
437 from simvx.graphics.testing import assert_not_blank, save_png
438
439 app = App(width=WIDTH, height=HEIGHT, title="Tidewater", visible=False, backend="glfw")
440 scene = TidewaterScene(name="TidewaterScene")
441
442 # Capture two settled frames a few frames apart and one full filmstrip stride
443 # so we can prove motion (water/rain) and hand a filmstrip to the reviewer.
444 strip = [10, 25, 40, 54, 70]
445 frames = app.run_headless(scene, frames=72, capture_frames=strip)
446 assert len(frames) == len(strip), f"expected {len(strip)} captures, got {len(frames)}"
447
448 money = frames[3] # frame 54 == the declared golden / screenshot frame
449 assert_not_blank(money)
450 save_png(money, "/tmp/tidewater_money.png")
451 for fi, px in zip(strip, frames, strict=True):
452 save_png(px, f"/tmp/tidewater_{fi:02d}.png")
453
454 # Inter-frame delta over the whole frame proves the combined scene animates
455 # (FFT/Gerstner swell + river + rain). Two well-separated frames.
456 a = frames[1].astype(np.float32)
457 b = frames[4].astype(np.float32)
458 mean_delta = float(np.abs(a - b).mean())
459
460 # A representative lower band (the water region) must be clearly non-flat
461 # colour-wise (not a dead blue wash): count distinct-ish colours.
462 lower = money[money.shape[0] // 2 :, :, :3]
463 colours = np.unique(lower.reshape(-1, 3) // 8, axis=0).shape[0]
464
465 checks = {
466 "money frame non-blank": True, # assert_not_blank above would have raised
467 "scene animates frame-to-frame (water + rain)": mean_delta > 0.5,
468 "water band has rich colour variation": colours > 400,
469 }
470 print(f"mean_delta={mean_delta:.3f} water_colours={colours}")
471 print("filmstrip: /tmp/tidewater_{10,25,40,54,70}.png money: /tmp/tidewater_money.png")
472 for name, ok in checks.items():
473 print(f" [{'PASS' if ok else 'FAIL'}] {name}")
474 passed = all(checks.values())
475 print("SELFTEST:", "PASS" if passed else "FAIL")
476 return passed
477
478
479def main():
480 if "--test" in sys.argv:
481 sys.exit(0 if _selftest() else 1)
482 App(width=WIDTH, height=HEIGHT, title="SimVX - Tidewater").run(TidewaterScene())
483
484
485if __name__ == "__main__":
486 main()