Tidewater¶
an FFT sea meeting a mirror-calm river under golden-hour rain.
▶ Run in browserTags: 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 theWorldEnvironmentwetness/ripple channels turn everywetness_affectedrock 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()