Transparent Water Shader supporting SSR and Refraction

This intends to solve the problem that the SSR (Screen Space Reflection) implementation of Godot 4.1 is limited to surfaces that are not transparent. For a water shader it is often necessary to support both transparency and reflections. In a lot of cases this can be worked around with reflection probes, but the need for SSR stands, especially because SSR works quiet well for water. The only solution is to write a custom SSR shader for that matter.

This is a full water shader implementation supporting:

  • Screen Space Reflection
  • Transparency
  • 3D waves
  • Edge detection
  • Normal textures for a varied surface
  • Fake Refraction (might not work for specific geometry, I think of finding a better solution)

What is missing:

  • Over- to underwater transition
  • More advanced 3D wave physics
  • Buoyancy API
  • Objects creating wave ripples while going through the water

Performance

Should run well in most cases. I’ve tested it at 60fps on the Steam Deck without any lowering of SSR resolution or any other optimizing and the demo is out of the box rock steady at 60fps at 50% GPU with 2.3 watts.

Parameters

Parameter

Description

Albedo

The base color of the water surface.

Transparency

The transparency of the water.

Metallic

The metallic property of the water.

Roughness

The roughness property of the water.

Surface Normals Move Direction A

The 2D vector of the Surface Normals A texture. For details on the water surface,

Surface Normals Move Direction B

The 2D vector of the Surface Normals B texture. This is merged with the details from A.

Surface Texture Roughness

How rough the detail normals will look.

Surface Texture Scale

Scale of the texture interpretation. Higher means smaller details.

Surface Texture Time Scale

The speed the details move across the surface.

SSR Resolution

The distance the ray-marching algorithm moves per step. Smaller values are better but slower.

SSR Max Travel

The maximum distance the ray will travel before giving up. Longer is better but slower.

SSR Max Diff

The maximum diff from geometry that is counted as a ray-march hit. No performance impact. Low values might miss geometry, high values might create false positives. Aim for good looks.

SSR Mix Strength

How visible the reflections are on the surface of the water.

Wave Noise Scale

Scale of the 3D waves interpretation. Higher means wider waves.

Wave Height Scale

The y-height of waves interpreted from the wave textures.

Wave Time Scale

The waves speed.

Wave Normal Flatness

The flatness of the new calculated normals. Higher values are smoother.

Border Color

The border detection color. This is the foam around objects in the water.

Border Scale

The size of the border.

Border Near

Distance when the border stops shrinking.

Border Far

Distance when the border starts growing.

Refraction Intensity

The intensity of the refraction effect. 0 is no refraction at all.

Max Visible Depth

The max depth that will be visible from the surface. After that it gets opaque.

Color Deep

The opaque color of depths not visible.

Wave A

The height texture for the wave layer A. Should be seamless and black and white, can be noise.

Wave B

The height texture for the wave layer B. Should be seamless and black and white, can be noise.

Surface Normals A

The normals texture with the surface normals for A. Should be seamless, can be noise.

Surface Normals B

The normals texture with the surface normals for A. Should be seamless, can be noise.

Shader code
shader_type spatial;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_nearest;
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_nearest;

uniform vec3 albedo : source_color = vec3(0.02, 0.45, 0.8);
uniform float transparency : hint_range(0.0, 1.0, 0.01) = 0.4;
uniform float metallic : hint_range(0.0, 1.0, 0.01) = 0;
uniform float roughness : hint_range(0.0, 1.0, 0.01) = 0.5;

uniform vec2 surface_normals_move_direction_a = vec2(-1.0, 0.0);
uniform vec2 surface_normals_move_direction_b = vec2(0.0, 1.0);
uniform float surface_texture_roughness : hint_range(0.0, 1.0, 0.01) = 0.6;
uniform float surface_texture_scale : hint_range(0.001, 2.0, 0.001) = 0.3;
uniform float surface_texture_time_scale : hint_range(0.001, 2.0, 0.001) = 0.06;

uniform float ssr_resolution   : hint_range(0.1, 10.0, 0.1)		= 2.0;
uniform float ssr_max_travel   : hint_range(0.1, 200.0, 0.1) 	= 30.0;
uniform float ssr_max_diff     : hint_range(0.1, 10.0, 0.1) 	= 4.0;
uniform float ssr_mix_strength : hint_range(0.0, 1.0, 0.01) 	= 0.7;

uniform float wave_noise_scale = 15.0;
uniform float wave_height_scale = 0.25;
uniform float wave_time_scale = 0.025;
uniform float wave_normal_flatness : hint_range(0.1, 100.0, 0.1) = 30.0;

uniform vec3  border_color : source_color = vec3(1.0);
uniform float border_scale : hint_range(0.0, 5.0, 0.01) = 2.0;
uniform float border_near = 0.5;
uniform float border_far = 300.0;

uniform float refraction_intensity : hint_range(0.0, 1.0, 0.01) = 0.4;

uniform float max_visible_depth : hint_range(0.0, 100.0, 0.1) = 10.0;
uniform vec3 color_deep : source_color = vec3(0.0, 0.1, 0.4);

uniform sampler2D wave_a;
uniform sampler2D wave_b;

uniform sampler2D surface_normals_a;
uniform sampler2D surface_normals_b;

varying vec2 vertex_uv;

float get_wave_height(vec2 uv)
{
	float height1 = texture(wave_a, uv).y;
	float height2 = texture(wave_b, uv).y;
	return (height1 + height2) / 2.0;
}

void vertex()
{
	vertex_uv = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
	vec2 uv = vertex_uv / wave_noise_scale + TIME * wave_time_scale;
	VERTEX.y += get_wave_height(uv) * wave_height_scale;

	float normal_height_scale = wave_height_scale / wave_normal_flatness;
	vec2 e = vec2(0.01, 0.0);
	vec3 normal = normalize(vec3(
		get_wave_height(uv - e) * normal_height_scale - get_wave_height(uv + e) * normal_height_scale,
		2.0 * e.x,
		get_wave_height(uv - e.yx) * normal_height_scale - get_wave_height(uv + e.yx) * normal_height_scale
		));
	NORMAL = normal;
}

bool is_within_screen_boundaries(vec2 position) {
	return position.x > 0.0 && position.x < 1.0 && position.y > 0.0 && position.y < 1.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;
}

vec3 get_ssr_color(vec3 surface_view_position, vec3 normal_view_space, vec3 view_view_space, mat4 proj_m, mat4 inv_proj_m)
{
	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;

		resulting_color = (is_within_screen_boundaries(current_screen_position) && depth_diff >= 0.0 && depth_diff < ssr_max_diff) ? texture(SCREEN_TEXTURE, current_screen_position.xy).rgb : vec3(-1.0);
	}
	return resulting_color;
}

float border(float cur_depth)
{
	return border_near * border_far / (border_far + (2.0 * cur_depth - 1.0) * (border_near - border_far));
}

float normalize_float(float min_v, float max_v, float value) {
	float clamped_value = clamp(value, min_v, max_v);
	return  (clamped_value - min_v) / (max_v - min_v);
}

vec2 get_refracted_uv(vec2 raw_screen_uv, float screen_depth_raw, vec3 view, vec3 normal, mat4 proj_m, mat4 inv_proj_m)
{
		vec3 screen_view_position_original = get_view_position_from_uv(raw_screen_uv, screen_depth_raw, inv_proj_m);
		float screen_center_distance = clamp(abs(length(raw_screen_uv - vec2(0.5, 0.5))) * 2.0, 0.0, 1.0);
		float refraction_intensity_deglitched = mix(1.0 - refraction_intensity, 1.0, screen_center_distance);
		vec3 refraction_position_view_space = screen_view_position_original + normalize(refract(view, -normal, refraction_intensity_deglitched));
		vec2 refraction_uv = get_uv_from_view_position(refraction_position_view_space, proj_m);
		return refraction_uv;
}

void fragment() {
	float screen_depth_raw = texture(DEPTH_TEXTURE, SCREEN_UV).x;

	vec2 refraction_uv = refraction_intensity > 0.0 ? get_refracted_uv(SCREEN_UV, screen_depth_raw, VIEW, NORMAL, PROJECTION_MATRIX, INV_PROJECTION_MATRIX) : SCREEN_UV;

	float screen_depth = texture(DEPTH_TEXTURE, refraction_uv).x;
	float surface_depth = FRAGCOORD.z;

	float border_diff = border(screen_depth_raw) - border(surface_depth);

	vec2 time_vector = (TIME * surface_normals_move_direction_a) * surface_texture_time_scale;
	vec2 time_vector2 = (TIME * surface_normals_move_direction_b) * surface_texture_time_scale;

	vec3 normal_texture_blend 	= mix(texture(surface_normals_a, vertex_uv * surface_texture_scale + time_vector).xyz, texture(surface_normals_b, vertex_uv * surface_texture_scale + time_vector2).xyz, 0.5);
	vec3 normal_blend 			= mix(NORMAL, normal_texture_blend, surface_texture_roughness);

	vec3 screen_view_position 	= get_view_position_from_uv(refraction_uv, screen_depth, INV_PROJECTION_MATRIX);
	vec3 surface_view_position 	= get_view_position_from_uv(refraction_uv, surface_depth, INV_PROJECTION_MATRIX);
	float depth_opacity = 1.0 - normalize_float(0.0, max_visible_depth, length(surface_view_position - screen_view_position));

	vec3 screen_color 				= texture(SCREEN_TEXTURE, refraction_uv).rgb;
	vec3 surface_color 				= albedo;
	vec3 ssr_color 					= get_ssr_color(surface_view_position, NORMAL, VIEW, PROJECTION_MATRIX, INV_PROJECTION_MATRIX);
	vec3 color_with_transparency 	= mix(surface_color, screen_color, transparency);
	vec3 depth_color 				= mix(color_deep.rgb, color_with_transparency, depth_opacity);
	vec3 water_color 				= (ssr_color.x >= 0.0) ? mix(depth_color, ssr_color, ssr_mix_strength) : depth_color;
	vec3 final_color				= mix(border_color, water_color, step(border_scale, border_diff));

	ALBEDO.rgb = final_color;
	METALLIC = metallic;
	ROUGHNESS = roughness;
	NORMAL_MAP = normal_blend;
}
Tags
ssr water reflection transparency refraction
The shader code and all code snippets in this post are under MIT license and can be used freely. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

screen space refraction shader

3D Low Distortion Refraction (Low Poly Glass)

Stylized Water Shader

Subscribe
Notify of
guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
doradoro
9 months ago

Please, update de video link.