Tidewater

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

▶ Run in browser

Tags: 3d ocean water river rain sky reflections

One coastal scene that combines most of the 3D renderer’s features, each doing real work in the frame:

  • Open sea. OceanSurface3D is a large FFT-ocean plane: rolling swell, choppy crests and foam, styled by a WaterMaterial. It starts at the river mouth and runs to the horizon, one step below the river.

  • Mirror river. A WaterSurface3D with a gentle WaterMaterial runs between two rocky banks. Its reflection is a PlanarReflection3D placed on the same plane, which re-renders the scene mirrored every frame, so the banks and the sky invert in the water.

  • Procedural sky. sky_mode="procedural" on the WorldEnvironment builds the sky from the low DirectionalLight3D, and that sky also lights the scene.

  • Rain and weather. Rain3D is a GPU rain emitter that follows the camera and slants with the environment’s wind_direction and wind_strength. The environment’s wetness, rain_intensity and ripple_strength darken and gloss every material created with wetness_affected=True and ripple its upward faces.

  • Global illumination. An IrradianceVolume3D bakes spherical-harmonic probes over the shore so the rocks pick up bounced light, while ssgi_* adds screen-space bounce, ssr_enabled screen-space reflections and bloom_* a glow on the sun’s glint.

The camera sways slowly over the river, driven by tree.now. Pressing SPACE, clicking or tapping sets tree.paused, which stops the scene clock that the camera sway, the water and the rain all run on, so the whole scene freezes. The HUD that toggles the pause is created with update_mode=UpdateMode.ALWAYS, since a paused node hears no input; the mode is inherited, so it goes on the HUD and never on the scene root.

Source

  1# /// simvx
  2# tags = ["3d", "ocean", "water", "river", "rain", "sky", "reflections"]
  3# screenshot_frame = 54
  4# ///
  5"""Tidewater: an FFT sea meeting a mirror-calm river under golden-hour rain.
  6
  7One coastal scene that combines most of the 3D renderer's features, each doing
  8real work in the frame:
  9
 10* **Open sea.** `OceanSurface3D` is a large FFT-ocean plane: rolling swell,
 11  choppy crests and foam, styled by a `WaterMaterial`. It starts at the river
 12  mouth and runs to the horizon, one step below the river.
 13* **Mirror river.** A `WaterSurface3D` with a gentle `WaterMaterial` runs
 14  between two rocky banks. Its `reflection` is a `PlanarReflection3D` placed on
 15  the same plane, which re-renders the scene mirrored every frame, so the banks
 16  and the sky invert in the water.
 17* **Procedural sky.** `sky_mode="procedural"` on the `WorldEnvironment` builds
 18  the sky from the low `DirectionalLight3D`, and that sky also lights the scene.
 19* **Rain and weather.** `Rain3D` is a GPU rain emitter that follows the camera
 20  and slants with the environment's `wind_direction` and `wind_strength`. The
 21  environment's `wetness`, `rain_intensity` and `ripple_strength` darken and
 22  gloss every material created with `wetness_affected=True` and ripple its
 23  upward faces.
 24* **Global illumination.** An `IrradianceVolume3D` bakes spherical-harmonic
 25  probes over the shore so the rocks pick up bounced light, while `ssgi_*`
 26  adds screen-space bounce, `ssr_enabled` screen-space reflections and
 27  `bloom_*` a glow on the sun's glint.
 28
 29The camera sways slowly over the river, driven by `tree.now`. Pressing SPACE,
 30clicking or tapping sets `tree.paused`, which stops the scene clock that the
 31camera sway, the water and the rain all run on, so the whole scene freezes.
 32The HUD that toggles the pause is created with `update_mode=UpdateMode.ALWAYS`, since a paused node
 33hears no input; the mode is inherited, so it goes on the HUD and never on the
 34scene root.
 35"""
 36
 37import math
 38
 39from simvx.core import (
 40    Anchor,
 41    Camera3D,
 42    CanvasLayer,
 43    DirectionalLight3D,
 44    IrradianceVolume3D,
 45    Key,
 46    Label,
 47    Material,
 48    Mesh,
 49    MeshInstance3D,
 50    Node,
 51    OceanSurface3D,
 52    PlanarReflection3D,
 53    Pointer,
 54    Rain3D,
 55    UpdateMode,
 56    WaterMaterial,
 57    WaterSurface3D,
 58    WorldEnvironment,
 59    on_input,
 60)
 61from simvx.graphics import App
 62
 63RIVER_LEVEL = 0.5
 64SEA_LEVEL = -0.5  # a step below the river, so the river spills into the sea
 65RIVER_HALF_WIDTH = 9.0
 66SHELF_EDGE_Z = -20.0  # where the banks end and the open sea begins
 67
 68ROCK = (0.44, 0.41, 0.37, 1.0)
 69ROCK_MOSS = (0.38, 0.42, 0.32, 1.0)
 70ROCK_PALE = (0.54, 0.51, 0.47, 1.0)
 71
 72# (x, z, radius, colour, roughness) of the boulders tumbling down to the waterline.
 73BOULDERS = [
 74    (-RIVER_HALF_WIDTH - 0.5, 14.0, 2.4, ROCK_MOSS, 0.7),
 75    (-RIVER_HALF_WIDTH + 0.4, 6.0, 1.7, ROCK_PALE, 0.55),
 76    (-RIVER_HALF_WIDTH - 1.5, -2.0, 3.0, ROCK, 0.75),
 77    (-RIVER_HALF_WIDTH + 0.2, -11.0, 1.9, ROCK_MOSS, 0.6),
 78    (-RIVER_HALF_WIDTH - 0.8, -17.5, 2.3, ROCK_PALE, 0.5),
 79    (RIVER_HALF_WIDTH + 0.6, 12.0, 2.1, ROCK_PALE, 0.5),
 80    (RIVER_HALF_WIDTH - 0.3, 4.0, 1.6, ROCK, 0.7),
 81    (RIVER_HALF_WIDTH + 1.4, -4.0, 2.8, ROCK_MOSS, 0.75),
 82    (RIVER_HALF_WIDTH - 0.2, -12.0, 2.0, ROCK_PALE, 0.55),
 83    (RIVER_HALF_WIDTH + 0.9, -17.5, 2.5, ROCK, 0.6),
 84]
 85
 86
 87def rock(position, radius, colour=ROCK, roughness=0.8, wet=True):
 88    return MeshInstance3D(
 89        mesh=Mesh.sphere(radius, rings=16, segments=24),
 90        material=Material(colour=colour, roughness=roughness, wetness_affected=wet),
 91        position=position,
 92    )
 93
 94
 95class TidewaterScene(Node):
 96    def on_ready(self):
 97        self.add_child(
 98            WorldEnvironment(
 99                wind_direction=(0.65, 0.42),
100                wind_strength=0.7,
101                wetness=0.6,
102                rain_intensity=0.8,
103                ripple_strength=0.25,
104                sky_mode="procedural",
105                sky_turbidity=3.6,
106                sky_ground_albedo=(0.18, 0.15, 0.12, 1.0),
107                ssr_enabled=True,
108                ssgi_enabled=True,
109                ssgi_intensity=0.55,
110                ssgi_max_distance=10.0,
111                bloom_enabled=True,
112                bloom_threshold=1.15,
113                bloom_intensity=0.45,
114            )
115        )
116        # A low sun just off-centre over the sea, for a long glint down the river.
117        self.add_child(DirectionalLight3D(intensity=3.4, colour=(1.0, 0.78, 0.52), direction=(-0.14, -0.11, 0.98)))
118        self.camera = self.add_child(Camera3D(position=(0.0, 6.5, 24.0), look_at=(0.0, 0.4, -30.0), fov=55, far=900.0))
119
120        self.build_banks()
121        self.build_water()
122        # The probes' capture is dimmer than the bright low-sun sky, so intensity lifts the rocks' midtones back;
123        # a generous update_budget finishes the one-off bake within the first few frames.
124        self.add_child(
125            IrradianceVolume3D(
126                extents=(30.0, 7.0, 26.0), spacing=7.0, position=(0.0, 2.5, 0.0), intensity=3.0, update_budget=32
127            )
128        )
129        # Small, dense drops read as rain; large camera-facing sprites read as bokeh.
130        self.add_child(
131            Rain3D(
132                radius=24.0,
133                height=15.0,
134                fall_speed=28.0,
135                amount=7000,
136                wind_response=1.2,
137                start_scale=0.028,
138                end_scale=0.028,
139            )
140        )
141        self.add_child(Hud(update_mode=UpdateMode.ALWAYS))
142
143    def build_banks(self):
144        hw = RIVER_HALF_WIDTH
145        # A dark bed well below the river, so the water's depth fade has depth to fade over.
146        bed = Material(colour=(0.16, 0.17, 0.15, 1.0), roughness=0.9)
147        self.add_child(
148            MeshInstance3D(mesh=Mesh.box((2 * hw + 2, 3, 54)), material=bed, position=(0, SEA_LEVEL - 1.5, 4))
149        )
150
151        bank = Material(colour=ROCK, roughness=0.8, wetness_affected=True)
152        for side in (-1, 1):
153            self.add_child(
154                MeshInstance3D(mesh=Mesh.box((24, 5.4, 52)), material=bank, position=(side * (hw + 11), 0.2, 6))
155            )
156            # Large boulders along each ridge break up the flat slab top.
157            for i, (z, radius, colour) in enumerate(
158                [(-14, 3.4, ROCK_PALE), (-4, 4.2, ROCK), (5, 3.0, ROCK_MOSS), (14, 3.8, ROCK_PALE)]
159            ):
160                self.add_child(rock((side * (hw + 8 + 2.2 * (i % 2)), 2.9 + 0.25 * radius, z), radius, colour, 0.78))
161
162        for x, z, radius, colour, roughness in BOULDERS:
163            self.add_child(rock((x, RIVER_LEVEL + radius * 0.45, z), radius, colour, roughness))
164        # A low lip of rocks across the river mouth hides the seam where the river drops to the sea.
165        for x in range(-int(hw), int(hw) + 2, 3):
166            self.add_child(rock((x + 0.5, RIVER_LEVEL - 0.5, SHELF_EDGE_Z + 0.8), 1.7, roughness=0.65))
167        # Two stacks out at sea break the horizon and give the foam something to curl around.
168        self.add_child(rock((-26, SEA_LEVEL - 2, -55), 6.5, (0.30, 0.28, 0.26, 1.0), 0.85, wet=False))
169        self.add_child(rock((30, SEA_LEVEL - 1, -78), 4.5, (0.33, 0.31, 0.28, 1.0), 0.8, wet=False))
170
171    def build_water(self):
172        mirror = self.add_child(PlanarReflection3D(position=(0.0, RIVER_LEVEL, 4.0)))
173        # A tiny slow swell keeps the mirror crisp.
174        calm = WaterMaterial(
175            shallow_colour=(0.12, 0.30, 0.34),
176            deep_colour=(0.02, 0.07, 0.11),
177            depth_fade_distance=3.2,
178            wave_amplitude=0.03,
179            wave_length=4.5,
180            wave_steepness=0.22,
181            foam_amount=0.12,
182            refraction_strength=0.16,
183            fresnel_power=5.0,
184            normal_strength=0.25,
185            opacity=0.9,
186        )
187        self.add_child(
188            WaterSurface3D(
189                position=(0.0, RIVER_LEVEL, 4.0),
190                size=(2 * RIVER_HALF_WIDTH, 54.0),
191                subdivisions=112,
192                material=calm,
193                reflection=mirror,
194            )
195        )
196
197        # The sea's near edge sits exactly on the shelf edge, so no sea shows through the banks.
198        sea_size = 640.0
199        swell = WaterMaterial(
200            shallow_colour=(0.05, 0.24, 0.30),
201            deep_colour=(0.01, 0.05, 0.10),
202            depth_fade_distance=10.0,
203            wave_amplitude=1.2,
204            wave_steepness=0.72,
205            foam_colour=(0.92, 0.96, 1.0),
206            foam_amount=0.7,
207            refraction_strength=0.35,
208        )
209        self.add_child(
210            OceanSurface3D(
211                position=(0.0, SEA_LEVEL, SHELF_EDGE_Z - sea_size / 2),
212                size=(sea_size, sea_size),
213                subdivisions=320,
214                material=swell,
215            )
216        )
217
218    def on_update(self, dt):
219        # The scene clock stops while the tree is paused, and the camera with it.
220        t = self.tree.now
221        x = 3.0 * math.sin(t * 0.13)
222        self.camera.position = (x, 6.5 + 0.5 * math.sin(t * 0.11), 24.0 - 2.5 * math.sin(t * 0.09))
223        self.camera.look_at((x * 0.25, 0.4, -30.0))
224
225
226class Hud(CanvasLayer):
227    """The title, the pause toggle and its hint, pinned to the screen and alive while paused."""
228
229    input_context = {"pause": [Key.SPACE, Pointer.PRIMARY]}
230
231    def on_ready(self):
232        self.add_child(Label("TIDEWATER", anchor=Anchor.TOP_LEFT, inset=(24, 16), font_size=32))
233        features = "FFT ocean  +  planar-mirror river  +  procedural sky  +  rain + wetness  +  GI"
234        self.add_child(
235            Label(features, anchor=Anchor.TOP_WIDE, inset=(24, 58), size=(0, 24), font_size=17, autoshrink=True)
236        )
237        self.paused_label = self.add_child(
238            Label("PAUSED", anchor=Anchor.TOP_LEFT, inset=(24, 90), font_size=21, visible=False)
239        )
240        self.add_child(Label("SPACE / click / tap: pause", anchor=Anchor.BOTTOM_LEFT, inset=24, font_size=17))
241        for label in self.find_all(Label):
242            label.text_colour = (1.0, 1.0, 1.0, 1.0)  # full white stays legible over the bright sky
243
244    @on_input("pause")
245    def toggle_pause(self, event):
246        self.tree.paused = not self.tree.paused
247        self.paused_label.visible = self.tree.paused
248
249
250if __name__ == "__main__":
251    App(width=1280, height=720, title="SimVX - Tidewater").run(TidewaterScene())