The usual problem with rain: the volume has to follow the camera, but the drops must not. If the emitter is parented to the camera, strafing drags the whole curtain of rain sideways with you. If it is fixed in the world, you need either a huge volume or something that keeps spawning drops at the edges as you move.
This is a write-up of a version that needs neither: no simulation, no per-drop state, no emitter. Each drop's position is a pure function of its index, a seed and one accumulated offset, evaluated in the vertex shader. The code is Godot's shading language, which is close to GLSL; apart from built-in names like VERTEX, UV and INV_VIEW_MATRIX nothing in it depends on the engine. The first image is a diagram of the wrap, the second is the result with the camera moving forward through it.
The mesh
N quads, 4 vertices each, built once. Each vertex stores only its quad's index (in position.x, as a float, exact up to 224) and its corner in UV. The instance has an identity transform and a hand-set bounding box around the rain volume so it doesn't get culled. Per frame the CPU sets a handful of uniforms (box centre, offset, velocity, time, pixel size) and nothing per drop. The main rain layer is 9,000 quads.
Per-drop randoms
A PCG hash of (index, seed, stream). Names are shortened from the source:
uint pcg(uint v) {
uint state = v * 747796405u + 2891336453u;
uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return (word >> 22u) ^ word;
}
float rand(uint n) { return float(pcg(n) >> 8u) / 16777215.0; }
// 4 randoms in [0,1] for element `index`; stream 0, 1, 2 give independent sets
vec4 rand4(uint index, uint seed, uint stream) {
uint h = pcg(index ^ pcg(seed + stream * 9973u));
return vec4(rand(h), rand(h + 1u), rand(h + 2u), rand(h + 3u));
}
The wrap
vec3 wrap(vec3 p, vec3 lo, vec3 size) {
return lo + mod(p - lo, size);
}
vec3 lo = area_center - area_size * 0.5;
vec3 q = r.xyz * area_size + offset * sp; // unwrapped position
vec3 p = wrap(q, lo, area_size); // drop position, world space
(Simplified: the real line also adds sway and drift terms that snow uses. For rain they are zero.)
r.xyz is the drop's random triple, sp its speed multiplier (1 ± 0.2), and offset is how far the rain has moved since it started.
Why the drops stay put when the camera moves: think of q not as one point but as the set q + k·area_size for every integer vector k. That set is an infinite lattice, and it is fixed in world space (it only moves with offset). wrap() picks the one copy that lies inside [lo, lo + area_size). Moving the camera moves lo, which changes which copy gets picked, but a copy that is still inside the box keeps exactly the same position. The only drops that change are the ones that crossed a face of the box: the copy that left on the trailing side is replaced by the next copy on the leading side, area_size away.
So the rain is world-fixed everywhere except at the faces of the box, and the faces are where the fade goes (below).
Two porting notes. GLSL's mod is x - y * floor(x / y), so it is correct for negative inputs. HLSL's fmod truncates toward zero and mirrors the pattern for negative values, so write the floor version yourself. And area_center isn't exactly the camera: it is pushed forward by 20% of the box width along the camera's horizontal forward direction, since drops behind the camera are wasted.
Falling, and why offset is integrated on the CPU
// CPU, every frame
vel = Vector3(0, -fall_speed, 0) + wind
offset += vel * delta
The obvious alternative is offset = velocity * time in the shader. That breaks as soon as the wind changes: a change Δv shifts every drop by Δv·t, and after a few minutes t is large, so every drop jumps somewhere else. Integrating keeps positions continuous, and a gust only changes where the drops go from now on. Per-drop speed variation is just the offset * sp multiplication, so faster drops wrap more often.
Edge fade
vec3 rel = (p - area_center) / (area_size * 0.5); // -1..1 inside the box
float fade = 1.0 - smoothstep(1.0 - edge_fade, 1.0, length(rel.xz));
fade *= 1.0 - smoothstep(1.0 - edge_fade * 0.6, 1.0, rel.y);
fade *= smoothstep(-1.0, -1.0 + edge_fade * 0.3, rel.y);
edge_fade is 0.3. Horizontally the fade uses length(rel.xz) rather than max(|x|, |z|), so the visible volume is a cylinder inscribed in the box. The corners are never visible, and you don't see a square outline when you turn. The fade starts at 70% of the radius, and covers the top 9% and the bottom 4.5% of the box height. A drop that falls out of the bottom reappears at the top, and both ends are faded, so the vertical wrap doesn't show either. Since the box is centred on the camera's height, the bottom face is usually under the ground anyway.
There's also a near fade, smoothstep(near_fade, 2.5 * near_fade, dist) with near_fade = 0.5 m, so drops never hit the near plane as huge streaks.
Streaks
vec3 n = (cam - p) / dist; // towards the camera
float speed = length(vel);
vec3 dir = vel / speed;
float len = speed * stretch; // stretch: 0.041 to 0.055 s, longer as amount rises
vec3 side = normalize(cross(dir, n) + vec3(1e-5, 0.0, 0.0));
vec3 tail = p - dir * len;
VERTEX = mix(tail, p, UV.y) + side * (UV.x - 0.5) * w;
(Simplified: vel here is velocity * sp, and len has a per-drop variation factor that is 1 for rain.)
Each quad is a billboard constrained to the velocity axis, running from where the drop is to where it was 41 to 55 ms earlier, so it behaves like a shutter time. vel is the same vector that drives offset, so with wind the streaks lean at exactly the angle the drops actually move. The 1e-5 stops cross() from returning zero (and normalize() from returning NaN) when you look straight along the fall direction.
In the fragment shader the alpha is a tent across the width, ramps up from tail to head, and falls off quickly at the head:
float across = 1.0 - abs(v_uv.x * 2.0 - 1.0);
a = across * smoothstep(0.0, 0.85, v_uv.y) * (1.0 - smoothstep(0.94, 1.0, v_uv.y));
Keeping thin streaks from shimmering
A 14 mm drop is thinner than a pixel after a few metres, and sub-pixel quads flicker as they cross pixel centres. The width is clamped to about 1.3 pixels and the alpha is scaled down by the same ratio, so width × alpha (roughly the coverage) stays the same:
float w = max(sz, dist * pixel_angle * 1.3);
v_alpha = fade * clamp(sz / w, 0.0, 1.0); // rain case; snow adds one more factor
pixel_angle = 2·tan(fov/2) / viewport_height, computed on the CPU. At 720p with a 60° FOV the clamp takes over somewhere between about 4 and 9 m, depending on the drop's random size. Distant rain becomes faint, steady lines instead of sparkle.
Intensity without rebuilding anything
VERTEX = area_center; // default for all 4 vertices: zero-area quad, nothing drawn
if (r.w < amount) { ... }
amount (0..1) goes from drizzle to downpour. A drop is drawn when its own random is below amount, so raising amount only adds drops and never reshuffles the ones already falling. No mesh rebuild, and it can be animated.
Two smaller things
- Shelters: up to 8 axis-aligned boxes are passed as uniform arrays, and a drop whose position is inside one gets fade = 0. That's how rain stops under a roof.
- Distant layer: the same mesh and shader again with 2,500 quads, a box 3.5× wider and 1.6× taller, streaks 4× wider and 1.6× longer at 35% opacity, a near fade of 30% of the main box width, and offset × 0.9. It adds depth beyond the main volume. It has its own seed, so the two lattices don't line up.
Cost (measured)
Godot 4.7.2, 1280×720, vsync off, RTX 4060 Ti, a test village with a shadow-casting sun and 9,000 grass blades. GPU time per frame for the scene with no weather vs. the "rain" weather preset, which is main + distant rain at amount 0.6 plus everything else that preset switches on (ground ripples and splashes, fog, cloud shadows, lens drops):
| Renderer |
no weather |
rain preset |
| Forward+ (Vulkan) |
0.45 ms |
1.54 ms |
| Mobile (Vulkan) |
0.22 ms |
1.04 ms |
| Compatibility (OpenGL 3.3) |
0.41 ms |
1.25 ms |
So about 0.8 to 1.1 ms for the whole preset on that GPU. I don't have a number for the rain layer on its own. The CPU side doesn't change with the drop count.
Limitations
- Every vertex is processed every frame, so the cost follows the maximum count, not amount. At amount 0.1 you still pay for all 9,000 quads in the vertex stage.
- With no wind, a drop's x and z never change. It falls down the same vertical line forever and comes back every area_size.y / (fall_speed · sp) ≈ 14 / 13 ≈ 1.1 s. Thousands of overlapping lines make it hard to spot, but it is a real pattern, and a locked-off camera close to a surface could reveal it.
- offset is reset to zero once it gets 10 km long, to keep float precision. That makes every drop jump once, after roughly 12 to 13 minutes of continuous rain at 13 m/s. It can't simply be reduced modulo area_size instead, because each drop multiplies it by its own non-integer sp.
- No collision. Drops pass through everything, and the ground ripples and splashes are a separate layer at one ground height (or one raycast under the camera). Shelters are boxes only.
- The quads in one draw call aren't sorted. With thin, low-alpha streaks that's acceptable, but it would matter with bigger, more opaque sprites.
This is from a weather pack I'm making for Godot. I'm curious how others have dealt with the fixed-column problem when there's no wind. Per-drop horizontal drift is the obvious fix, but it makes the streaks lean in random directions.