Tidewater¶

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

â–¶ Run in browser

Tags: 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 (Rain3D plus the WorldEnvironment wetness and ripple channels): wind-slanted rain falls across the view while every wetness_affected rock 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 IrradianceVolume3D over 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()