Rain and wetness¶
a GPU downpour over a rain-slicked courtyard.
▶ Run in browserTags: 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()