Rain puddles with ripples and reflections

Post-processing shader that adds rain puddles on all horizontal surfaces and has screen space reflections with ripples. This shader includes parts of the SSR implementation created by marcelb – link and the rain ripple effect created by Zavie – link.

This shader is a futher improvement on the puddles shader I shared recently – link.

To complete the effect, you’ll probably want to add the raindrops themselves, for example, using GPU particles.

Important! Like any other post-processing shader for Godot, it requires full screen mesh to work. I already shared the scene setup in the puddles shader, you can see it in the link above. I will share another method that worked well for me, using single triangle. I got roughly 20% FPS boost by using single triangle for this shader instead of a quad.

Single triangle scene setup:

Create MeshInstance3D node and set it as a child of your main camera. Attach the following script to it:

@tool
extends MeshInstance3D
class_name FullscreenMesh

@export_tool_button("Set up") var setup_tool = setup

func setup():
	gi_mode = GeometryInstance3D.GI_MODE_DISABLED
	cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	extra_cull_margin = 16384.0
	
	if mesh != null:
		mesh = null
		
	mesh = ArrayMesh.new()
	
	var verts = PackedVector3Array()
	verts.append(Vector3(-1.0, -1.0, 0.0))
	verts.append(Vector3(3.0, -1.0, 0.0))
	verts.append(Vector3(-1.0, 3.0, 0.0))

	var mesh_array = []
	mesh_array.resize(Mesh.ARRAY_MAX)
	mesh_array[Mesh.ARRAY_VERTEX] = verts

	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, mesh_array)

Click the node in editor and press the newly created button “Set up” in the inspector. It will create a fullscreen triangle mesh and set all necessary parameters. Then you can attach the shader material to it in “Material Override”.

Shader code
// Rain puddles with ripples using SSR
// CC0, shadecore_dev, 2025.
// Includes parts of the SSR implementation created by marcelb: https://godotshaders.com/shader/transparent-water-shader-supporting-ssr/
// Includes the rain ripple effect created by Zavie: https://www.shadertoy.com/view/ldfyzl

shader_type spatial;
render_mode unshaded, fog_disabled;

uniform sampler2D depth_texture : hint_depth_texture;
uniform sampler2D screen_texture : hint_screen_texture, filter_nearest;

group_uniforms PuddleEffect;

/** Fallback color for puddles when SSR reflection is absent. */
uniform vec4 puddle_color : source_color = vec4(0.01, 0.3, 0.28, 0.7);

/** Puddle mask multiplier. Affected by "Puddle Mask Threshold" value. */
uniform float puddle_reflectivness = 2.0;

/** Scale for perlin noise #1 used to create a mask for puddles. */
uniform float puddle_micro_mask_size = 5.0;

/** Scale for perlin noise #2 used to create a mask for puddles. */
uniform float puddle_macro_mask_size = 25.0;

/** The threshold value used to form a mask from two noises. */
uniform float puddle_mask_threshold = 0.33;

group_uniforms ScreenSpaceReflection;

/**
* The distance the ray-marching algorithm moves per step. Smaller values are better but slower.
* [color=yellow]Moderate performance impact.[/color]
**/
uniform float ssr_resolution : hint_range(0.1, 10.0, 0.1) = 1.0;

/** Max SSR travel distance. 0 deactivates SSR. [color=red]High performance impact.[/color] */
uniform float ssr_max_travel : hint_range(0.0, 200.0, 0.1)  = 30.0;

/**
* The maximum diff from geometry that is counted as a ray-march hit.
* Low values might miss geometry, high values might create false positives.
* No performance impact. Aim for good looks.
**/
uniform float ssr_max_diff : hint_range(0.1, 10.0, 0.1) = 4.0;

/** Strength of fade-out effect on reflections close to the screen borders */
uniform float ssr_screen_border_fadeout : hint_range(0.0, 1.0, 0.01) = 0.3;

group_uniforms RippleEffect;

/** Max ripple travel distance. Integer. [color=red]High performance impact.[/color] */
uniform float ripple_max_radius : hint_range(0.0, 5.0, 1.0) = 2.0;

/** Ripple scale modifier. */
uniform float ripple_scale : hint_range(0.1, 10.0, 0.1) = 1.0;

/** Ripple travel speed. */
uniform float ripple_speed : hint_range(0.1, 2.0, 0.01) = 0.5;

const float HASHSCALE1 = 0.1031; // Seed #1 for ripple effect generation.
const vec3 HASHSCALE3 = vec3(0.1031, 0.1030, 0.0973); // Seed #2 for ripple effect generation.

const float EPSILON = 1e-5; // Don't change.

float hash12(vec2 p) {
 vec3 p3  = fract(vec3(p.xyx) * HASHSCALE1);
 p3 += dot(p3, p3.yzx + 19.19);
 return fract((p3.x + p3.y) * p3.z);
}

vec2 hash22(vec2 p) {
 vec3 p3 = fract(vec3(p.xyx) * HASHSCALE3);
 p3 += dot(p3, p3.yzx+19.19);
 return fract((p3.xx+p3.yz)*p3.zy);
}

vec2 get_ripple_offset(vec2 input_uv) {
	float resolution = 10.0 * exp2(-3.0);
	vec2 uv = input_uv / ripple_scale * resolution;
	vec2 p0 = floor(uv);

	vec2 circles = vec2(0.0);
	for (float j = -ripple_max_radius; j <= ripple_max_radius; ++j)
	{
		for (float i = -ripple_max_radius; i <= ripple_max_radius; ++i)
		{
			vec2 pi = p0 + vec2(i, j);

			vec2 hsh = hash22(pi);

			vec2 p = pi + hash22(hsh);

			float t = fract(ripple_speed * TIME + hash12(hsh));
			vec2 v = p - uv;
			float d = length(v) - (float(ripple_max_radius) + 1.)*t;

			float h = 1e-3;
			float d1 = d - h;
			float d2 = d + h;
			float p1 = sin(31.*d1) * smoothstep(-0.6, -0.3, d1) * smoothstep(0., -0.3, d1);
			float p2 = sin(31.*d2) * smoothstep(-0.6, -0.3, d2) * smoothstep(0., -0.3, d2);
			circles += 0.5 * normalize(v) * ((p2 - p1) / (2. * h) * (1. - t) * (1. - t));
		}
	}
	
	circles /= float((ripple_max_radius * 2.0 + 1.0) * (ripple_max_radius * 2.0 + 1.0));

	float intensity = mix(0.01, 0.15, smoothstep(0.1, 0.6, abs(fract(0.05 * TIME + 0.5) * 2.0 -1.0)));
	vec3 n = vec3(circles, sqrt(1.0 - dot(circles, circles)));
	return (intensity * n.xy) + 5.0 * pow(clamp(dot(n, normalize(vec3(1.0, 0.7, 0.5))), 0.0, 1.0), 6.0);
}

vec2 get_uv_from_view_position(vec3 position_view_space, mat4 proj_m)
{
	vec4 position_clip_space = proj_m * vec4(position_view_space.xyz, 1.0);
	vec2 position_ndc = position_clip_space.xy / position_clip_space.w;
	return position_ndc.xy * 0.5 + 0.5;
}

vec3 get_view_position_from_uv(vec2 uv, float depth, mat4 inv_proj_m)
{
	vec4 position_ndc = vec4((uv * 2.0) - 1.0, depth, 1.0);
	vec4 view_position = inv_proj_m * position_ndc;
	return view_position.xyz /= view_position.w;
}

bool is_within_screen_boundaries(vec2 position) 
{
	return position.x > 0.0 && position.x < 1.0 && position.y > 0.0 && position.y < 1.0;
}

bool is_zero(float value) 
{
    return abs(value) < EPSILON;
}

vec2 random(vec2 uv){
    uv = vec2( dot(uv, vec2(127.1,311.7) ),
               dot(uv, vec2(269.5,183.3) ) );
    return -1.0 + 2.0 * fract(sin(uv) * 43758.5453123);
}

float noise(vec2 uv) {
    vec2 uv_index = floor(uv);
    vec2 uv_fract = fract(uv);

    vec2 blur = smoothstep(0.0, 1.0, uv_fract);

    return mix( mix( dot( random(uv_index + vec2(0.0,0.0) ), uv_fract - vec2(0.0,0.0) ),
                     dot( random(uv_index + vec2(1.0,0.0) ), uv_fract - vec2(1.0,0.0) ), blur.x),
                mix( dot( random(uv_index + vec2(0.0,1.0) ), uv_fract - vec2(0.0,1.0) ),
                     dot( random(uv_index + vec2(1.0,1.0) ), uv_fract - vec2(1.0,1.0) ), blur.x), blur.y) + 0.5;
}


float get_screen_border_alpha(vec2 screen_position)
{
    vec2 shifted_screen_position = 4.0 * screen_position * (1.0 - screen_position);
	float mask = shifted_screen_position.x * shifted_screen_position.y;
	
	float offset = mix(0.0, 0.5, (clamp(ssr_screen_border_fadeout, 0.75, 1.0)-0.75) / 0.25);
	float alpha = clamp(smoothstep(0.0, 2.0 * ssr_screen_border_fadeout, mask) - offset, 0.0, 1.0);

	return is_zero(ssr_screen_border_fadeout) ? 1.0 : alpha;
}

vec4 get_ssr_color(vec3 surface_view_position, vec3 normal_view_space, vec3 view_view_space, mat4 proj_m, mat4 inv_proj_m)
{
	if (ssr_max_travel < EPSILON)
	{
		return vec4(0);
	}
	
	vec3 current_position_view_space = surface_view_position;
	vec3 view_direction_view_space = view_view_space * -1.0;
	vec3 reflect_vector_view_space = normalize(reflect(view_direction_view_space.xyz, normal_view_space.xyz));
	
	vec2 current_screen_position = vec2(0.0);
	
	vec3 resulting_color = vec3(-1.0);
	for(float travel=0.0; resulting_color.x < 0.0 && travel < ssr_max_travel; travel = travel + ssr_resolution)
	{
		current_position_view_space += reflect_vector_view_space * ssr_resolution;
		current_screen_position = get_uv_from_view_position(current_position_view_space, proj_m);

		float depth_texture_probe_raw = texture(depth_texture, current_screen_position).x;
		vec3 depth_texture_probe_view_position = get_view_position_from_uv(current_screen_position, depth_texture_probe_raw, inv_proj_m);
		
		float depth_diff = depth_texture_probe_view_position.z - current_position_view_space.z;
		
		vec3 ssr_screen_color = texture(screen_texture, current_screen_position.xy).rgb;
		resulting_color = (is_within_screen_boundaries(current_screen_position) && depth_diff >= 0.0 && depth_diff < ssr_max_diff) ? ssr_screen_color : vec3(-1.0);
	}

	float alpha = get_screen_border_alpha(current_screen_position);
	return vec4(resulting_color,alpha);
}

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

vec3 reconstruct_world_position(vec2 uv, float depth, mat4 inv_proj_matrix, mat4 inv_view_matrix) {
	#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
	vec3 ndc = vec3(uv, depth) * 2.0 - 1.0;
	#else
	vec3 ndc = vec3(uv * 2.0 - 1.0, depth);
	#endif

	vec4 view = inv_proj_matrix * vec4(ndc, 1.0);
	view.xyz /= view.w;

	vec4 world = inv_view_matrix * inv_proj_matrix * vec4(ndc, 1.0);
	return world.xyz / world.w;
}

void fragment() {
	vec2 uv_center = SCREEN_UV;
	float depth_center = texture(depth_texture, uv_center).x;
	
	if (depth_center <= EPSILON)
		discard;

	vec2 uv_right = SCREEN_UV + vec2(1, 0) / VIEWPORT_SIZE;
	vec2 uv_top = SCREEN_UV + vec2(0, 1) / VIEWPORT_SIZE;

	float depth_right = texture(depth_texture, uv_right).x;
	float depth_top = texture(depth_texture, uv_top).x;

	vec3 pixel_world_position_center = reconstruct_world_position(uv_center, depth_center, INV_PROJECTION_MATRIX, INV_VIEW_MATRIX);
	vec3 pixel_world_position_right = reconstruct_world_position(uv_right, depth_right, INV_PROJECTION_MATRIX, INV_VIEW_MATRIX);
	vec3 pixel_world_position_top = reconstruct_world_position(uv_top, depth_top, INV_PROJECTION_MATRIX, INV_VIEW_MATRIX);

	vec3 normal = normalize(
		cross(
			pixel_world_position_top - pixel_world_position_center,
			pixel_world_position_right - pixel_world_position_center
		)
	);
	
	vec2 ripple_offset = get_ripple_offset(pixel_world_position_center.xz);

	vec3 ripple_vector = vec3(ripple_offset.x * CAMERA_DIRECTION_WORLD.x, 0.0, ripple_offset.y * CAMERA_DIRECTION_WORLD.z);
	
	normal -= ripple_vector;
	
	vec3 normal_view_space = (VIEW_MATRIX * vec4(normal, 0.0)).xyz;
	
	vec3 view_view_space = normalize(-get_view_position_from_uv(SCREEN_UV, 1.0, INV_PROJECTION_MATRIX));
	
	vec3 surface_view_position = get_view_position_from_uv(SCREEN_UV, depth_center, INV_PROJECTION_MATRIX);
	
	float puddle_mask_micro = noise(pixel_world_position_center.xz / puddle_micro_mask_size);
	float puddle_mask_macro = noise(pixel_world_position_center.xz / puddle_macro_mask_size);
	
	float final_puddle_mask = clamp(puddle_reflectivness * puddle_mask_micro * clamp(puddle_mask_macro, 0.0, 1.0) * normal.y - puddle_mask_threshold, 0.0, 1.0);

	vec4 ssr_color = get_ssr_color(surface_view_position, normal_view_space, view_view_space, PROJECTION_MATRIX, INV_PROJECTION_MATRIX);

	vec3 surface_color_ssr_mix = (ssr_max_travel > EPSILON) ? mix(vec3(0.0), ssr_color.rgb, ssr_color.a) : vec3(0);

	ALBEDO = mix(
		puddle_color.rgb,
		ssr_color.x < 0.0 ? puddle_color.rgb : ssr_color.rgb,
		ssr_color.a
	);

	ALPHA = mix(
		0.0,
		ssr_color.x < 0.0 ? puddle_color.a : max(ssr_color.a, puddle_color.a), 
		final_puddle_mask
	);
}
Live Preview
Tags
3d, postprocess, SSR
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from shadecore_dev

Related shaders

guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments