Tidewater

an FFT sea meeting a mirror-calm river under golden-hour rain.

▶ Run in browser

Tags: 3d ocean water river rain sky reflections showcase

The render-modernization flagship. One cohesive coastal scene draws every new rendering feature at once, each doing a real job rather than sitting in a checklist:

  • An open FFT ocean (OceanSurface3D, design D11) fills the horizon: a Tessendorf spectrum inverse-transformed each frame into rolling swell, choppy crests and wind-laced foam that curls around a rock stack out at sea.

  • A mirror-calm river (WaterSurface3D + PlanarReflection3D, design D16 + RM-B4) 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 and track the world every frame, rippled by the small Gerstner swell and Fresnel-blended over the refraction below.

  • A dynamic procedural sky (sky_mode="procedural", design D12) 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 (Rain3D, RM-E3) falls wind-slanted across the view, and the WorldEnvironment wetness/ripple channels turn every wetness_affected rock dark and glossy with an animated puddle ripple on its up-faces. As they turn wet the stones drop roughness, so the procedural-sky image-based lighting mirrors the golden sky back off the slick banks.

  • Global illumination grounds the shore with real indirect bounce: a baked IrradianceVolume3D (design D10) over the whole near shore lifts the shadowed undersides of the wet boulders with soft SH-L1 warmth from the rock and golden sky around them, while SSGI (WorldEnvironment.ssgi_*, design D8) adds the near-field, view-dependent colour bleed the screen already shows. Baked GI + screen-space GI, SSR and the planar mirror all run in the same frame.

A slow cinematic camera sways over the river mouth, looking downstream at the sun-glinting sea. Press SPACE to pause the motion, ESC to quit.

SSR/SSGI NOTE: screen-space reflections (WorldEnvironment.ssr_*, design D7) AND screen-space GI (ssgi_*, design D8) are both ON here and coexist with the PlanarReflection3D river in the same frame (the RM-BUG1 frame-order fix that previously crashed this exact combination). SSR gives the wet shelf and boulders crisp near-field reflections of the real scene geometry that IBL alone cannot, composited over the golden-sky IBL specular without double-counting (design D8fix); SSGI adds the soft indirect diffuse bounce; the planar-mirror river remains the hero reflection. All three activate/read the thin G-buffer, which now also lives on the offscreen RenderView target so the shared forward pipelines stay render-pass-compatible.

WEB PARITY: the whole scene is now cross-backend. The FFT ocean (design D11) runs on web too via its WebGPU Stockham-compute twin (RM-E7w / RM-G1), so the open sea is an OceanSurface3D on both desktop and web. SSR + SSGI + the PlanarReflection3D river also coexist on the web renderer (RM-G14, the web analogue of the desktop RM-BUG1 fix): when SSR/SSGI activate the thin G-buffer’s second forward attachment, the built-in water + ocean transparent pipelines pick up a matching second, write-masked colour target so they stay render-pass-compatible with the 2-attachment forward pass. The single-attachment path is untouched when the G-buffer is off.

Usage: uv run python examples/demos/tidewater.py uv run python examples/demos/tidewater.py –test # headless self-check

Controls: SPACE - Pause / resume the camera motion ESC - Quit

Source

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