Rain and wetness

a GPU downpour over a rain-slicked courtyard.

▶ Run in browser

Tags: 3d rain wetness weather particles

Two cooperating halves of the weather system, both driven by one WorldEnvironment. A Rain3D emitter (a camera-relative GPU particle volume that reuses the particle simulation) fills the view with wind-slanted droplets. The same WorldEnvironment publishes wetness / rain_intensity / ripple_strength into the FrameGlobals block, so every material flagged wetness_affected darkens, turns glossy (roughness drops), and grows an animated ripple normal on its up-facing faces. Turn the weather off (all three back to zero, no Rain3D) and the scene renders exactly as a dry one: the wet path is feature-bit + wetness gated, so it costs nothing when unused.

The wide floor and the low blocks are wetness_affected; the sun rakes across them so the wet gloss and the puddle ripples catch the light, while the drops slant with the wind.

Usage: uv run python examples/features/3d/rain.py

Source

  1"""Rain and wetness: a GPU downpour over a rain-slicked courtyard.
  2
  3Two cooperating halves of the weather system, both driven by one
  4WorldEnvironment. A Rain3D emitter (a camera-relative GPU particle volume that
  5reuses the particle simulation) fills the view with wind-slanted droplets. The
  6same WorldEnvironment publishes ``wetness`` / ``rain_intensity`` /
  7``ripple_strength`` into the FrameGlobals block, so every material flagged
  8``wetness_affected`` darkens, turns glossy (roughness drops), and grows an
  9animated ripple normal on its up-facing faces. Turn the weather off (all three
 10back to zero, no Rain3D) and the scene renders exactly as a dry one: the wet path
 11is feature-bit + wetness gated, so it costs nothing when unused.
 12
 13The wide floor and the low blocks are ``wetness_affected``; the sun rakes across
 14them so the wet gloss and the puddle ripples catch the light, while the drops
 15slant with the wind.
 16
 17# /// simvx
 18# tags = ["3d", "rain", "wetness", "weather", "particles"]
 19# screenshot_frame = 40
 20# ///
 21
 22Usage:
 23    uv run python examples/features/3d/rain.py
 24"""
 25
 26import numpy as np
 27
 28from simvx.core import (
 29    Camera3D,
 30    DirectionalLight3D,
 31    Input,
 32    InputMap,
 33    Key,
 34    Material,
 35    Mesh,
 36    MeshInstance3D,
 37    Node,
 38    Rain3D,
 39    WorldEnvironment,
 40)
 41from simvx.graphics import App
 42
 43WIDTH, HEIGHT = 1280, 720
 44
 45
 46def _flagstone_maps(size: int = 512, cells: int = 6, seed: int = 7) -> tuple[np.ndarray, np.ndarray]:
 47    """Procedural wet-flagstone albedo + matching normal map (no asset files).
 48
 49    A grid of stone tiles separated by recessed mortar grooves; each tile is
 50    tinted and shaded a little differently and carries a fine grain, so the wet
 51    floor reads as real paving rather than a flat sheet. The normal map lifts the
 52    grooves into relief so the rain gloss and ripples catch the raked sun.
 53    Deterministic (fixed seed) so the golden frame stays stable. The tile grid is
 54    an integer lattice, so tiling the texture with ``uv_scale`` stays seamless
 55    (the wrap seam lands on a mortar line). Returns ``(albedo_rgba, normal_rgb)``.
 56    """
 57    rng = np.random.default_rng(seed)
 58    # Cell coordinates: 0..cells across the texture; integer part = tile id,
 59    # fractional part = position within the tile.
 60    axis = np.linspace(0.0, cells, size, endpoint=False, dtype=np.float32)
 61    vv, uu = np.meshgrid(axis, axis, indexing="ij")
 62    ci = np.floor(uu).astype(np.int32)
 63    cj = np.floor(vv).astype(np.int32)
 64    fu, fv = uu - ci, vv - cj
 65
 66    # Mortar grooves: ramp from 0 inside the groove to 1 on the stone top.
 67    edge = np.minimum(np.minimum(fu, 1.0 - fu), np.minimum(fv, 1.0 - fv))
 68    mortar_w = 0.05
 69    stone = np.clip((edge - mortar_w) / mortar_w, 0.0, 1.0)
 70
 71    # Per-tile variation: mostly brightness with a slight warm/cool shift, so the
 72    # stones stay a natural grey rather than a saturated patchwork. Plus fine grain.
 73    bright = rng.uniform(0.72, 1.08, size=(cells, cells)).astype(np.float32)
 74    warm = rng.uniform(-0.03, 0.03, size=(cells, cells)).astype(np.float32)
 75    grain = rng.uniform(-0.045, 0.045, size=(size, size)).astype(np.float32)
 76
 77    base = np.array([0.5, 0.49, 0.48], dtype=np.float32)
 78    col = base[None, None, :] * bright[ci, cj][..., None]
 79    col[..., 0] += warm[ci, cj]  # warm tiles lean red
 80    col[..., 2] -= warm[ci, cj]  # ...and away from blue
 81    col += grain[..., None]
 82    col *= (0.5 + 0.5 * stone)[..., None]  # darken the grooves
 83    albedo = np.empty((size, size, 4), dtype=np.uint8)
 84    albedo[..., :3] = (np.clip(col, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
 85    albedo[..., 3] = 255
 86
 87    # Height = raised stone tops, recessed grooves; normal from its gradient.
 88    # Wrapped central differences keep the map seamless so no bright seam line
 89    # shows up where ``uv_scale`` tiles the texture.
 90    height = stone.astype(np.float32)
 91    gx = (np.roll(height, -1, axis=1) - np.roll(height, 1, axis=1)) * 0.5
 92    gy = (np.roll(height, -1, axis=0) - np.roll(height, 1, axis=0)) * 0.5
 93    n = np.stack([-gx * 3.5, -gy * 3.5, np.ones_like(height)], axis=-1)
 94    n /= np.linalg.norm(n, axis=-1, keepdims=True)
 95    normal = np.empty((size, size, 4), dtype=np.uint8)
 96    normal[..., :3] = ((n * 0.5 + 0.5) * 255.0 + 0.5).astype(np.uint8)
 97    normal[..., 3] = 255
 98    return albedo, normal
 99
100
101class RainScene(Node):
102    def on_ready(self):
103        InputMap.add_action("quit", [Key.ESCAPE])
104
105        # One WorldEnvironment drives both halves: it enables the HDR chain, sets
106        # the wind that slants the rain, and publishes the wetness/rain/ripple
107        # weather values into FrameGlobals that the wet materials read.
108        env = self.add_child(WorldEnvironment())
109        env.wind_direction = (0.6, 0.8)
110        env.wind_strength = 0.5
111        env.wetness = 0.9
112        env.rain_intensity = 0.85
113        env.ripple_strength = 0.7
114
115        self.add_child(Camera3D(position=(0, 3.4, 9.5), look_at=(0, 0.3, -1.0), up=(0, 1, 0)))
116        sun = self.add_child(DirectionalLight3D(intensity=3.2))
117        sun.direction = (-0.55, -0.85, -0.3)
118
119        # Rain-slicked flagstone courtyard: a procedural paving albedo + normal
120        # map give the floor real stone detail, and the wetness_affected material
121        # darkens it, turns it glossy, and rings its up-facing top with rain.
122        floor_albedo, floor_normal = _flagstone_maps()
123        self.add_child(
124            MeshInstance3D(
125                mesh=Mesh.cube(1.0),
126                material=Material(
127                    colour=(1.0, 1.0, 1.0, 1.0),
128                    roughness=0.8,
129                    albedo_map=floor_albedo,
130                    normal_map=floor_normal,
131                    uv_scale=(7.0, 6.5),
132                    wetness_affected=True,
133                ),
134                position=(0, -0.5, -4.0),
135                scale=(28.0, 1.0, 26.0),
136            )
137        )
138
139        # Low blocks scattered across the floor: also wet, so their tops gloss and
140        # ripple while their sides just darken (the ripple is up-facing only).
141        for x, z, colour in [
142            (-3.2, -2.0, (0.70, 0.30, 0.28)),
143            (2.8, -3.5, (0.30, 0.55, 0.72)),
144            (0.2, -5.5, (0.60, 0.58, 0.35)),
145            (-1.6, -6.5, (0.40, 0.62, 0.42)),
146        ]:
147            self.add_child(
148                MeshInstance3D(
149                    mesh=Mesh.cube(1.0),
150                    material=Material(colour=(*colour, 1.0), roughness=0.75, wetness_affected=True),
151                    position=(x, 0.4, z),
152                    scale=(1.6, 0.8, 1.6),
153                )
154            )
155
156        # The downpour: a camera-relative GPU particle volume, wind-slanted.
157        self.add_child(Rain3D(radius=16.0, height=10.0, fall_speed=24.0, amount=8000))
158
159    def on_update(self, dt):
160        if Input.is_action_pressed("quit"):
161            self.app.quit()
162
163
164def main():
165    App(width=WIDTH, height=HEIGHT, title="SimVX - Rain").run(RainScene())
166
167
168if __name__ == "__main__":
169    main()