Rain and Puddle Reflections — A Complete GLSL Shader Breakdown for Game Developers

395 views 0 replies
Live Shader
Loading versions...

Rain is one of those atmospheric effects that can instantly elevate a game's visual mood. From the gentle patter of a drizzle to a torrential downpour with puddles reflecting neon city lights, getting rain right is a rite of passage for graphics programmers. In this post, we'll build a complete rain and puddle reflection shader from scratch in GLSL, breaking down every technique so you can adapt it to your own engine.

The shader below produces an animated rainy scene featuring multiple layers of falling rain streaks with parallax depth, a ground plane with procedural puddles, concentric ripple animations on puddle surfaces, and approximate reflections of the sky in the wet ground. Everything is procedural — no textures required.

Complete Standalone Shader

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

// -------------------------------------------------------
// Hash and noise utilities for procedural generation
// -------------------------------------------------------

// Simple 1D hash
float hash(float n) {
    return fract(sin(n) * 43758.5453123);
}

// 2D hash returning a single float
float hash12(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * 0.1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
}

// 2D hash returning a vec2
vec2 hash22(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx + p3.yz) * p3.zy);
}

// 2D value noise
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    // Cubic Hermite interpolation for smooth results
    vec2 u = f * f * (3.0 - 2.0 * f);

    return mix(
        mix(hash12(i + vec2(0.0, 0.0)), hash12(i + vec2(1.0, 0.0)), u.x),
        mix(hash12(i + vec2(0.0, 1.0)), hash12(i + vec2(1.0, 1.0)), u.x),
        u.y
    );
}

// Fractal Brownian Motion for cloud/sky texture
float fbm(vec2 p) {
    float value = 0.0;
    float amplitude = 0.5;
    for (int i = 0; i < 5; i++) {
        value += amplitude * noise(p);
        p *= 2.0;
        amplitude *= 0.5;
    }
    return value;
}

// -------------------------------------------------------
// Sky rendering — overcast rainy atmosphere
// -------------------------------------------------------
vec3 renderSky(vec2 uv) {
    // Dark overcast base color
    vec3 darkCloud = vec3(0.12, 0.13, 0.18);
    vec3 lightCloud = vec3(0.3, 0.32, 0.38);

    // Layered cloud noise, slowly drifting
    float clouds = fbm(uv * 3.0 + vec2(iTime * 0.02, 0.0));
    clouds = smoothstep(0.3, 0.7, clouds);

    vec3 sky = mix(darkCloud, lightCloud, clouds);

    // Subtle lightning flash (occasional)
    float flash = smoothstep(0.98, 1.0, sin(iTime * 1.7 + 3.0) * sin(iTime * 2.3));
    sky += vec3(0.4, 0.45, 0.55) * flash * 0.5;

    return sky;
}

// -------------------------------------------------------
// Rain layer — streaks falling with slight wind
// -------------------------------------------------------
float rainLayer(vec2 uv, float layerScale, float speed, float density) {
    // Scale and offset for this rain layer
    vec2 st = uv * vec2(layerScale * 1.5, layerScale * 0.15);
    // Identify which column/row cell we are in
    vec2 cellId = floor(st);
    vec2 cellUv = fract(st);

    // Randomize per-cell: horizontal offset and time offset
    float cellRand = hash12(cellId);
    float xOffset = (cellRand - 0.5) * 0.6;

    // Only some cells have rain drops (density control)
    if (cellRand > density) return 0.0;

    // Vertical motion: fast falling, wrapping via fract
    float timeOffset = hash(cellId.x * 31.7 + cellId.y * 17.3);
    float fall = fract((cellUv.y + iTime * speed + timeOffset) * 1.0);

    // Rain streak shape: thin vertically, centered horizontally
    float xDist = abs(cellUv.x - 0.5 + xOffset * 0.3);
    float streak = smoothstep(0.015, 0.0, xDist);

    // Streak is brightest near head, fading in a tail
    float head = smoothstep(0.0, 0.04, fall) * smoothstep(0.25, 0.0, fall);

    return streak * head;
}

// Composite multiple rain layers for depth/parallax
float rainEffect(vec2 uv) {
    float rain = 0.0;
    // Far layer: smaller, slower, more transparent
    rain += rainLayer(uv, 80.0, 1.8, 0.4) * 0.25;
    // Mid layer
    rain += rainLayer(uv + 0.1, 50.0, 2.5, 0.5) * 0.45;
    // Near layer: larger, faster, brighter
    rain += rainLayer(uv + 0.3, 30.0, 3.2, 0.55) * 0.7;
    // Extra near drops
    rain += rainLayer(uv + 0.7, 18.0, 3.8, 0.35) * 0.9;
    return clamp(rain, 0.0, 1.0);
}

// -------------------------------------------------------
// Puddle ripple — concentric rings expanding outward
// -------------------------------------------------------
float ripple(vec2 uv, vec2 center, float birthTime) {
    float dist = length(uv - center);
    float age = iTime - birthTime;
    // Ripple expands outward over time
    float radius = age * 0.2;
    // Ring shape: narrow band at the wavefront
    float ring = smoothstep(0.008, 0.0, abs(dist - radius)) * 2.0;
    // Additional secondary ring
    float ring2 = smoothstep(0.006, 0.0, abs(dist - radius * 0.7)) * 1.0;
    // Fade out as the ripple ages
    float fade = exp(-age * 1.5) * smoothstep(0.0, 0.3, age);
    return (ring + ring2) * fade;
}

// Generate multiple ripples across the puddle surface
float puddleRipples(vec2 uv) {
    float totalRipple = 0.0;
    // We spawn ripples in a grid with randomized positions and timings
    for (int i = 0; i < 12; i++) {
        float fi = float(i);
        // Random position within puddle area
        vec2 pos = hash22(vec2(fi * 7.13, fi * 13.71));
        pos = pos * 1.6 - 0.3; // spread across puddle

        // Ripple repeats periodically with per-ripple offset
        float period = 2.5 + hash(fi * 3.3) * 2.0;
        float birthTime = floor(iTime / period) * period + hash(fi * 5.7) * period;
        // Make sure birthTime is not in the future
        if (birthTime > iTime) birthTime -= period;

        totalRipple += ripple(uv, pos, birthTime);
    }
    return totalRipple;
}

// -------------------------------------------------------
// Puddle shape — organic blobby shapes on ground
// -------------------------------------------------------
float puddleMask(vec2 uv) {
    // Use noise to define irregular puddle shapes
    float n = noise(uv * 4.0 + 0.5);
    float n2 = noise(uv * 8.0 + 10.0) * 0.3;
    float puddle = smoothstep(0.42, 0.55, n + n2);
    return puddle;
}

// -------------------------------------------------------
// Ground rendering with wet surfaces and puddles
// -------------------------------------------------------
vec3 renderGround(vec2 uv, vec2 screenUv) {
    // Base ground: dark wet asphalt color
    vec3 dryGround = vec3(0.08, 0.08, 0.09);
    vec3 wetGround = vec3(0.05, 0.055, 0.07);

    // Ground texture variation
    float groundNoise = noise(uv * 20.0) * 0.15 + 0.85;
    vec3 groundColor = mix(dryGround, wetGround, 0.7) * groundNoise;

    // Puddle coverage
    float puddle = puddleMask(uv);

    // Ripples distort the reflection UVs
    float ripples = puddleRipples(uv);

    // Reflected sky: flip vertical and add ripple distortion
    vec2 reflUv = screenUv;
    reflUv.y = 1.0 - reflUv.y; // flip for reflection
    reflUv += ripples * 0.04;  // ripple distortion
    vec3 reflectedSky = renderSky(reflUv * 1.5);

    // Make reflections slightly brighter to simulate specular wetness
    reflectedSky *= 1.3;

    // Add subtle color tint for the water
    vec3 waterTint = vec3(0.06, 0.08, 0.14);
    vec3 puddleColor = reflectedSky + waterTint;

    // Ripple highlights — bright caustic-like rings
    puddleColor += vec3(0.3, 0.35, 0.45) * ripples;

    // Blend ground and puddle
    vec3 finalGround = mix(groundColor, puddleColor, puddle);

    // Wet ground outside puddles still has subtle reflectivity
    float wetness = 0.15;
    vec3 subtleReflection = renderSky(reflUv * 1.2) * wetness;
    finalGround = mix(finalGround, finalGround + subtleReflection, 1.0 - puddle);

    return finalGround;
}

// -------------------------------------------------------
// Splash particles at ground level
// -------------------------------------------------------
float splashEffect(vec2 uv) {
    float splash = 0.0;
    for (int i = 0; i < 8; i++) {
        float fi = float(i);
        vec2 pos = hash22(vec2(fi * 11.3, fi * 7.7));
        pos = pos * 2.0 - 0.5;

        float period = 1.0 + hash(fi * 9.1) * 1.5;
        float t = mod(iTime + hash(fi * 4.4) * period, period);

        // Splash expands and fades quickly
        float radius = t * 0.08;
        float ring = smoothstep(0.004, 0.0, abs(length(uv - pos) - radius));
        float fade = exp(-t * 4.0);

        splash += ring * fade * 0.5;
    }
    return splash;
}

// -------------------------------------------------------
// Main composition
// -------------------------------------------------------
void main() {
    // Normalized coordinates
    vec2 fragCoord = gl_FragCoord.xy;
    vec2 uv = fragCoord / iResolution.xy;
    vec2 aspectUv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Define ground horizon (lower portion of screen)
    float horizonLine = 0.42;
    float isGround = 1.0 - smoothstep(horizonLine - 0.01, horizonLine + 0.01, uv.y);

    // --- Sky ---
    vec3 sky = renderSky(aspectUv);

    // Slight gradient: darker at top, lighter near horizon
    sky *= 0.7 + 0.5 * smoothstep(1.0, 0.0, uv.y);

    // Fog/mist near the horizon
    vec3 fogColor = vec3(0.18, 0.2, 0.25);
    float fogAmount = smoothstep(0.7, 0.42, uv.y) * 0.6;
    sky = mix(sky, fogColor, fogAmount);

    // --- Rain streaks over the whole scene ---
    float rain = rainEffect(aspectUv);
    // Rain color: slight blue-white, semi-transparent
    vec3 rainColor = vec3(0.6, 0.65, 0.8);

    // --- Ground with puddles ---
    // Map ground UVs with perspective foreshortening
    float groundDepth = (horizonLine - uv.y) / horizonLine;
    groundDepth = max(groundDepth, 0.001);
    vec2 groundUv = vec2(aspectUv.x / groundDepth, 1.0 / groundDepth);
    groundUv *= 0.3;
    // Slow drift to simulate camera/wind
    groundUv.x += iTime * 0.01;

    vec3 ground = renderGround(groundUv, uv);

    // Splash particles on the ground
    float splashes = splashEffect(groundUv);
    ground += vec3(0.4, 0.45, 0.55) * splashes * isGround;

    // --- Compose scene ---
    vec3 color = mix(sky, ground, isGround);

    // Add rain streaks on top
    color = mix(color, rainColor, rain * 0.5);

    // --- Post-processing ---
    // Slight vignette for atmosphere
    float vig = 1.0 - 0.4 * length(uv - 0.5);
    color *= vig;

    // Slight blue tone shift for rainy mood
    color = mix(color, color * vec3(0.85, 0.9, 1.1), 0.3);

    // Gamma correction
    color = pow(color, vec3(0.9));

    gl_FragColor = vec4(color, 1.0);
}

That is a lot of shader code, so let's break down each major system and understand the techniques at play. These same principles apply whether you are writing a Shadertoy demo or integrating rain into a deferred rendering pipeline.

Rain Streak Rendering

Rain streaks in games are most commonly rendered as either particle billboards or screen-space post-process lines. Our shader uses a cell-based approach where the screen is divided into a grid and each cell potentially contains one rain streak. This avoids the cost of tracking thousands of individual particles.

// The core idea: divide into cells, one streak per cell
vec2 st = uv * vec2(layerScale * 1.5, layerScale * 0.15);
vec2 cellId = floor(st);
vec2 cellUv = fract(st);

// Per-cell randomness determines if rain exists here
float cellRand = hash12(cellId);
if (cellRand > density) return 0.0;

// Vertical motion via fract() gives infinite looping
float fall = fract((cellUv.y + iTime * speed + timeOffset) * 1.0);

The key insight is the use of fract() for vertical motion. As time advances, the fractional part wraps from 1.0 back to 0.0, giving the illusion of infinite falling without tracking state. Each cell has a random time offset so the streaks are not synchronized.

We layer multiple rain passes at different scales and speeds to create depth. The far layer uses smaller, slower, dimmer streaks, while near layers are larger and brighter. This parallax effect sells the 3D illusion on a 2D screen.

Procedural Puddle Shapes

Rather than using a texture to define where puddles appear, we derive puddle coverage from layered value noise. This gives us organic, blobby shapes that look like real water accumulation on uneven ground.

float puddleMask(vec2 uv) {
    float n = noise(uv * 4.0 + 0.5);
    float n2 = noise(uv * 8.0 + 10.0) * 0.3;
    float puddle = smoothstep(0.42, 0.55, n + n2);
    return puddle;
}

The smoothstep thresholding is what turns continuous noise into discrete puddle regions. Adjusting the threshold values controls puddle coverage — lower values mean more water on the ground, which is useful for ramping up the effect over time as a storm intensifies. In a game, you could feed a "wetness" uniform to animate puddles growing and shrinking.

Concentric Ripple Animation

The ripple effect simulates raindrops hitting the puddle surface. Each ripple is a ring that expands outward and fades over time. The mathematical core is simple: measure distance from the center, compare to the expanding radius, and use smoothstep to create a narrow ring.

float ring = smoothstep(0.008, 0.0, abs(dist - radius)) * 2.0;
float fade = exp(-age * 1.5) * smoothstep(0.0, 0.3, age);

The abs(dist - radius) gives us zero exactly at the wavefront and positive values elsewhere, so the smoothstep creates a thin bright line at the ring edge. The exponential decay makes older ripples fade naturally, while the smoothstep(0.0, 0.3, age) prevents the ripple from appearing at full brightness the instant it spawns.

We spawn ripples on a repeating timer using floor(iTime / period) * period, which gives us the birth time of the current cycle. Each ripple has a different period and spatial offset, so the pattern never looks repetitive.

Puddle Reflections

Real-time reflections in games typically use one of several approaches: planar reflections (re-rendering the scene from a mirrored camera), screen-space reflections (SSR), or cube map lookups. In our procedural shader, we use a simple vertical UV flip to approximate planar reflection of the sky.

// Flip UVs vertically and add ripple distortion
vec2 reflUv = screenUv;
reflUv.y = 1.0 - reflUv.y;
reflUv += ripples * 0.04;
vec3 reflectedSky = renderSky(reflUv * 1.5);

The critical detail is that ripple values are added as UV offsets to the reflection lookup. This means the ripple rings not only appear as visible bright lines but also distort the reflected image, which is exactly what real water ripples do — they perturb the surface normal, bending reflected light. In a full game engine, you would encode the ripple as a normal map perturbation and use it in your SSR or reflection probe sampling.

Perspective Ground Mapping

To give the ground surface a sense of depth, we apply a simple perspective division. The further a pixel is from the bottom of the screen, the more compressed the UV coordinates become, simulating a ground plane receding toward the horizon.

float groundDepth = (horizonLine - uv.y) / horizonLine;
groundDepth = max(groundDepth, 0.001);
vec2 groundUv = vec2(aspectUv.x / groundDepth, 1.0 / groundDepth);
groundUv *= 0.3;

This 1.0 / groundDepth mapping stretches the UVs near the horizon, making puddles appear to tile into the distance. In a real game, this foreshortening is handled naturally by the projection matrix, but for a full-screen shader effect it's a powerful trick to sell the 3D ground plane.

Atmospheric Post-Processing

The final composition applies several subtle effects that are standard in cinematic rain rendering. A blue color shift reinforces the cold, rainy mood. A vignette darkens the edges and draws focus to the center. Fog near the horizon reduces contrast and implies moisture in the air. These are the same techniques used in AAA rain sequences — the atmospheric grading is just as important as the rain particles themselves.

Adapting This for a Game Engine

If you want to bring these techniques into Unity, Unreal, or a custom engine, here are the key adaptations to consider:

Rain streaks work best as a screen-space post-process in the near field, combined with GPU particle billboards for larger drops the player can see individually. The cell-based approach in this shader maps directly to a full-screen post-process pass.

Puddle coverage in a real scene should be driven by a combination of heightmap data (water pools in low areas), an artist-painted mask, or a runtime flood-fill simulation. The noise-based approach shown here is a good fallback for procedural terrain.

Reflections should use your engine's existing reflection system. Perturb the reflection lookup by a ripple normal map, which can be a scrolling texture or computed in a shader like we did here. Screen-space reflections (SSR) with normal perturbation give excellent results for wet ground.

Ripples can be rendered into a separate buffer as a ripple normal map, then sampled in your surface shader. This decouples the ripple simulation from the lighting pass and allows ripples on any wet surface in the scene.

Rain is one of those effects where the sum of many simple parts creates something far greater than any single technique. Combine streaks, ripples, reflections, splashes, and moody lighting, and you get an atmosphere that players remember. Happy coding, and may your frame budgets be generous.

Moonjump
Forum Search Shader Sandbox
Sign In Register