Rain and wetness

a GPU downpour over a rain-slicked courtyard.

▶ Run in browser

Tags: 3d rain wetness weather particles

Weather has two halves, and the scene’s WorldEnvironment drives both.

The rain is a Rain3D node, a GPU particle emitter that keeps its volume centred on the active camera, so the downpour always surrounds the viewer wherever the camera goes. radius is how far the volume reaches around the camera, height how far above it the volume is centred, fall_speed how fast the drops fall and amount how many there are. The drops slant with the environment’s wind_direction and wind_strength:

env = WorldEnvironment(wind_direction=(0.6, 0.8), wind_strength=0.5)
self.add_child(Rain3D(radius=16.0, height=10.0, fall_speed=24.0, amount=8000))

The wet surfaces are materials built with wetness_affected=True. The environment’s wetness (0 to 1) darkens them, as water fills their pores, and makes them glossier by lowering their roughness. ripple_strength adds animated rain ripples to the faces that point up, so the tops of the blocks ripple while their sides only darken. Materials without the flag stay dry, and with wetness at zero every material renders exactly as it would in a dry scene.

The flagstone floor is generated in code: an albedo map with a slightly different shade per stone, and a normal map that sinks the mortar grooves, so the sunlight picks out the paving under the wet gloss. Material takes both as NumPy arrays.

Source

 1# /// simvx
 2# tags = ["3d", "rain", "wetness", "weather", "particles"]
 3# screenshot_frame = 40
 4# ///
 5"""Rain and wetness: a GPU downpour over a rain-slicked courtyard.
 6
 7Weather has two halves, and the scene's ``WorldEnvironment`` drives both.
 8
 9**The rain** is a ``Rain3D`` node, a GPU particle emitter that keeps its
10volume centred on the active camera, so the downpour always surrounds the
11viewer wherever the camera goes. ``radius`` is how far the volume reaches
12around the camera, ``height`` how far above it the volume is centred,
13``fall_speed`` how fast the drops fall and ``amount`` how many there are.
14The drops slant with the environment's ``wind_direction`` and
15``wind_strength``:
16
17    env = WorldEnvironment(wind_direction=(0.6, 0.8), wind_strength=0.5)
18    self.add_child(Rain3D(radius=16.0, height=10.0, fall_speed=24.0, amount=8000))
19
20**The wet surfaces** are materials built with ``wetness_affected=True``. The
21environment's ``wetness`` (0 to 1) darkens them, as water fills their pores,
22and makes them glossier by lowering their roughness. ``ripple_strength`` adds
23animated rain ripples to the faces that point up, so the tops of the blocks
24ripple while their sides only darken. Materials without the flag stay dry, and
25with ``wetness`` at zero every material renders exactly as it would in a dry
26scene.
27
28The flagstone floor is generated in code: an albedo map with a slightly
29different shade per stone, and a normal map that sinks the mortar grooves, so
30the sunlight picks out the paving under the wet gloss. ``Material`` takes both
31as NumPy arrays.
32"""
33
34import numpy as np
35
36from simvx.core import Camera3D, DirectionalLight3D, Material, Mesh, MeshInstance3D, Node, Rain3D, WorldEnvironment
37from simvx.graphics import App
38
39
40def flagstone_maps(size: int = 512, tiles: int = 6) -> tuple[np.ndarray, np.ndarray]:
41    """Return an (albedo, normal) pair of RGBA float maps for a grid of stone tiles."""
42    rng = np.random.default_rng(7)
43    cells = np.linspace(0.0, tiles, size, endpoint=False)
44    v, u = np.meshgrid(cells, cells, indexing="ij")
45    i, j = u.astype(int), v.astype(int)  # which stone each texel lies on
46    fu, fv = u % 1.0, v % 1.0
47    # 0 in the mortar groove, rising to 1 on the stone top.
48    edge = np.minimum.reduce([fu, 1.0 - fu, fv, 1.0 - fv])
49    stone = np.clip((edge - 0.05) / 0.05, 0.0, 1.0)
50
51    # Per stone: a brightness, and a slight warm or cool lean. Per texel: fine grain.
52    shade = rng.uniform(0.72, 1.08, (tiles, tiles))[i, j]
53    warm = rng.uniform(-0.03, 0.03, (tiles, tiles))[i, j]
54    grain = rng.uniform(-0.045, 0.045, (size, size))
55    albedo = np.array([0.5, 0.49, 0.48]) * shade[..., None] + grain[..., None]
56    albedo += np.stack([warm, np.zeros_like(warm), -warm], axis=-1)
57    albedo *= (0.5 + 0.5 * stone)[..., None]
58
59    # Normals from the height gradient; np.roll wraps, so the map tiles without a seam.
60    dx = np.roll(stone, -1, axis=1) - np.roll(stone, 1, axis=1)
61    dy = np.roll(stone, -1, axis=0) - np.roll(stone, 1, axis=0)
62    n = np.stack([-1.75 * dx, -1.75 * dy, np.ones_like(stone)], axis=-1)
63    n /= np.linalg.norm(n, axis=-1, keepdims=True)
64    opaque = np.ones((size, size))
65    return np.dstack([np.clip(albedo, 0.0, 1.0), opaque]), np.dstack([n * 0.5 + 0.5, opaque])
66
67
68class RainScene(Node):
69    def on_ready(self):
70        self.add_child(WorldEnvironment(wind_direction=(0.6, 0.8), wind_strength=0.5, wetness=0.9, ripple_strength=0.7))
71        self.add_child(Camera3D(position=(0, 3.4, 9.5), look_at=(0, 0.3, -1.0)))
72        self.add_child(DirectionalLight3D(intensity=3.2, direction=(-0.55, -0.85, -0.3)))
73
74        albedo, normal = flagstone_maps()
75        floor = Material(
76            roughness=0.8, albedo_map=albedo, normal_map=normal, uv_scale=(7.0, 6.5), wetness_affected=True
77        )
78        self.add_child(MeshInstance3D(mesh=Mesh.box((28.0, 1.0, 26.0)), material=floor, position=(0, -0.5, -4.0)))
79
80        for x, z, colour in [
81            (-3.2, -2.0, (0.70, 0.30, 0.28)),
82            (2.8, -3.5, (0.30, 0.55, 0.72)),
83            (0.2, -5.5, (0.60, 0.58, 0.35)),
84            (-1.6, -6.5, (0.40, 0.62, 0.42)),
85        ]:
86            block = Material(colour=colour, roughness=0.75, wetness_affected=True)
87            self.add_child(MeshInstance3D(mesh=Mesh.box((1.6, 0.8, 1.6)), material=block, position=(x, 0.4, z)))
88
89        self.add_child(Rain3D(radius=16.0, height=10.0, fall_speed=24.0, amount=8000))
90
91
92if __name__ == "__main__":
93    App(width=1280, height=720, title="Rain").run(RainScene())