Creating Gerstner Waves Buoyancy Effects in Godot 4

Creating Gerstner Waves Buoyancy Effects in Godot 4 cover image

In this blog post, I will walk you through the process of creating a water shader with buoyancy effects using the Godot 4 Engine using vertex and fragment shaders. This shader simulates the movement of waves using Gerstner waves, implements buoyancy to make objects float naturally, and applies a damping effect that stays static in the world space even as the plane moves.

GitHub Project Repository

1. Introduction

Creating water in a game engine can be a challenging task, but with the power of shaders and some mathematical magic, we can achieve stunning results. Our goal is to create an water shader that not only looks good but also allows objects to interact with it through buoyancy.

2. Setting Up the Environment

Before diving into the shader code, let's set up our environment in Godot:

  • Create a new project in Godot.
  • Add a MeshInstance to the scene and set it to a PlaneMesh to represent the water surface.
  • Create a ShaderMaterial and assign it to the MeshInstance.

3. Implementing Gerstner Waves

Gerstner waves are a common technique used to simulate water waves. These waves are defined mathematically and can be combined to create complex wave patterns.

shader_type spatial;

uniform vec3 wave_a = vec3(1.0, 0.4, 10.0);
uniform vec2 wave_a_dir = vec2(1.0, 0.0);
uniform vec3 wave_b = vec3(1.0, 0.25, 20.0);
uniform vec2 wave_b_dir = vec2(1.0, 1.0);
uniform vec3 wave_c = vec3(1.0, 0.15, 1.0);
uniform vec2 wave_c_dir = vec2(1.0, 0.5);
uniform float time;
uniform float height_scale = 1.0;

varying vec3 v_normal;
varying float wave_height;

vec3 gerstnerWave(vec3 wave, vec2 wave_dir, vec3 p, float t) {
    float amplitude = wave.x;
    float steepness = wave.y;
    float wavelength = wave.z;
    float k = 2.0 * PI / wavelength;
    float c = sqrt(9.8 / k);
    vec2 d = normalize(wave_dir);
    float f = k * (dot(d, p.xz) - (c * t));
    float a = steepness / k;
    return vec3(d.x * (a * cos(f)), amplitude * a * sin(f), d.y * (a * cos(f)));
}

void computeGerstnerNormal(in vec3 wave, in vec2 wave_dir, in vec3 p, float t, inout vec3 tangent, inout vec3 binormal) {
    float amplitude = wave.x;
    float steepness = wave.y;
    float wavelength = wave.z;
    float k = 2.0 * PI / wavelength;
    float c = sqrt(9.8 / k);
    vec2 d = normalize(wave_dir);
    float f = k * (dot(d, p.xz) - (c * t));
    float a = steepness / k;
    tangent += normalize(vec3(1.0 - d.x * d.x * steepness * sin(f), d.x * steepness * cos(f), -d.x * d.y * (steepness * sin(f))));
    binormal += normalize(vec3(-d.x * d.y * (steepness * sin(f)), d.y * steepness * cos(f), 1.0 - (d.y * d.y * steepness * sin(f))));
}

void vertex() {
    vec3 original_p = (MODEL_MATRIX * vec4(VERTEX.xyz, 1.0)).xyz;
    vec3 displacement = vec3(0.0);
    vec3 tangent = vec3(1.0, 0.0, 0.0);
    vec3 binormal = vec3(0.0, 0.0, 1.0);
    displacement += gerstnerWave(wave_a, wave_a_dir, original_p, time / 2.0);
    computeGerstnerNormal(wave_a, wave_a_dir, original_p, time / 2.0, tangent, binormal);
    displacement += gerstnerWave(wave_b, wave_b_dir, original_p, time / 2.0);
    computeGerstnerNormal(wave_b, wave_b_dir, original_p, time / 2.0, tangent, binormal);
    displacement += gerstnerWave(wave_c, wave_c_dir, original_p, time / 2.0);
    computeGerstnerNormal(wave_c, wave_c_dir, original_p, time / 2.0, tangent, binormal);
    VERTEX.y += height_scale * displacement.y;
    vec3 normal = normalize(cross(binormal, tangent));
    v_normal = normal;
    wave_height = VERTEX.y;
}

4. Adding Buoyancy

Buoyancy allows objects to float on the water's surface realistically. We can achieve this by using the same wave equations to displace objects vertically based on the wave height at their position.

The Wave plane

Attach this code to the Waves plane created

extends MeshInstance3D

@export var time: float = 0.0
var material: ShaderMaterial

func _ready():
    material = mesh.surface_get_material(0)

func _process(delta):
    time += delta
    material.set_shader_parameter("time", time)

The floating object

Create a Node3D and add a RigidBody3D as its child. Then add a Node 3D as its child called ProbeContainer. Inside the probe container add Marker3D nodes and position where you want buoyancy applied. Attach this code to the Node3D that was just created.

extends Node3D

@export var float_force := 3.0
@onready var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
@onready var boat = $RigidBody3D
@onready var probes = $RigidBody3D/ProbeContainer.get_children()
@onready var physics_material = PhysicsMaterial.new()

@onready var water_plane = $"../Water"

var wave_a = Vector3(1.0, 0.4, 10.0)
var wave_a_dir = Vector2(1.0, 0.0)
var wave_b = Vector3(1.0, 0.25, 20.0)
var wave_b_dir = Vector2(1.0, 1.0)
var wave_c = Vector3(1.0, 0.15, 1.0)
var wave_c_dir = Vector2(1.0, 0.5)
var time: float = 0.0
var submerged := false

func _ready():
	physics_material.friction = 0
	boat.physics_material_override = physics_material
	update_wave_parameters()

func _physics_process(delta):
	time += delta
	submerged = false
	update_wave_parameters() # Ensure parameters are up-to-date
	update_probes()
	for p in probes:
		var water_height = get_height(p.global_position)
		var depth = water_height - p.global_position.y
		if depth > 0:
			submerged = true
			apply_buoyancy_force(p, depth)

func update_wave_parameters():
	var water_material = water_plane.material
	wave_a = water_material.get_shader_parameter("wave_a")
	wave_a_dir = water_material.get_shader_parameter("wave_a_dir")
	wave_b = water_material.get_shader_parameter("wave_b")
	wave_b_dir = water_material.get_shader_parameter("wave_b_dir")
	wave_c = water_material.get_shader_parameter("wave_c")
	wave_c_dir = water_material.get_shader_parameter("wave_c_dir")

func get_gerstner_wave_height(wave, wave_dir, position, time):
	var amplitude = wave.x
	var steepness = wave.y
	var wavelength = wave.z

	var k = 2.0 * PI / wavelength
	var c = sqrt(9.8 / k)
	var d = wave_dir.normalized()
	var f = k * (d.dot(Vector2(position.x, position.z)) - (c * time))
	var a = steepness / k

	return amplitude * a * sin(f)

func get_height(position):
	var height = 0.0
	height += get_gerstner_wave_height(wave_a, wave_a_dir, position, time / 2.0)
	height += get_gerstner_wave_height(wave_b, wave_b_dir, position, time / 2.0)
	height += get_gerstner_wave_height(wave_c, wave_c_dir, position, time / 2.0)
	return height

func apply_buoyancy_force(p, depth):
	var buoyancy_force = Vector3.UP * float_force * gravity * depth
	boat.apply_force(buoyancy_force, p.global_position - boat.global_transform.origin)

func update_probes():
	for p in probes:
		p.global_transform = p.global_transform

5. Fragment Shader for Coloring and Foam

The fragment shader is responsible for coloring the water and adding foam effects at the wave peaks. We'll use a depth-based gradient for the water color and add foam at the wave peaks.

shader_type spatial;

uniform float height_scale = 1.0;
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;
uniform float beer_factor = 0.8;
uniform vec4 _DepthGradientShallow: source_color = vec4(0.325, 0.807, 0.971, 0.725);
uniform vec4 _DepthGradientDeep: source_color = vec4(0.086, 0.407, 1, 0.749);
uniform float _DepthMaxDistance: hint_range(0, 1) = 1.0;
uniform float _DepthFactor = 1.0;
uniform vec3 wave_a = vec3(1.0, 0.4, 10.0);
uniform vec2 wave_a_dir = vec2(1.0, 0.0);
uniform vec3 wave_b = vec3(1.0, 0.25, 20.0);
uniform vec2 wave_b_dir = vec2(1.0, 1.0);
uniform vec3 wave_c = vec3(1.0, 0.15, 1.0);
uniform vec2 wave_c_dir = vec2(1.0, 0.5);
uniform float time;
uniform vec4 water_colour : source_color;
uniform vec4 deep_water_colour : source_color;
varying float wave_height;
varying vec3 v_normal;

vec4 alphaBlend(vec4 top, vec4 bottom) {
    vec3 color = (top.rgb * top.a) + (bottom.rgb * (1.0 - top.a));
    float alpha = top.a + bottom.a * (1.0 - top.a);
    return vec4(color, alpha);
}

void fragment() {
    float depthVal = texture(DEPTH_TEXTURE, SCREEN_UV).r;
    float depth = PROJECTION_MATRIX[3][2] / (depthVal + PROJECTION_MATRIX[2][2]);
    depth = depth + VERTEX.z;
    depth = exp(-depth * beer_factor);
    depth = 1.0 - depth;

    float waterDepth = clamp(depth / _DepthMaxDistance, 0.0, 1.0) * _DepthFactor;
    vec4 waterColor = mix(_DepthGradientShallow, _DepthGradientDeep, waterDepth);

    vec4 color = alphaBlend(waterColor, waterColor);

    NORMAL = v_normal;
    float wave_peak = clamp((wave_height - 2.8) * 2.0, 0.0, 4.0); // Adjust the threshold and scaling as needed
    vec3 peak_color = mix(color.rgb, vec3(1.0, 1.0, 1.0), wave_peak);

    ALBEDO = peak_color;
    EMISSION = peak_color / 2.0;
    ALPHA = color.a;
    ROUGHNESS = 0.1;
    CLEARCOAT = 1.0;
    CLEARCOAT_ROUGHNESS = 0.0;
    METALLIC = 0.5;
}

7. Conclusion

Creating a water shader with buoyancy effects involves a combination of mathematical wave simulations and shader programming. By using Gerstner waves, we can achieve complex and dynamic wave patterns, while buoyancy ensures that objects interact realistically with the water surface. Adding foam effects at the wave peaks further enhances the realism.

With this setup, you can create stunning water scenes in your Godot projects. Experiment with different wave parameters and textures to fine-tune the appearance and behavior of your water shader.