Been putting off interactive water for too long because every approach I looked at was either render-texture-based (overkill for my scale) or a single static ripple point. Finally sat down and figured out a clean multi-point version.
The core idea: store up to 8 ripple origins and their ages in GDScript, flush them to the shader as packed arrays every frame. In the shader each ripple propagates as a ring front, sin((dist - age * wave_speed) * frequency), multiplied by a double-exponential envelope that kills both distant and old waves simultaneously.
shader_type spatial;
render_mode blend_mix, cull_back;
uniform vec3 ripple_origins[8];
uniform float ripple_ages[8];
uniform int ripple_count : hint_range(0, 8) = 0;
uniform float wave_speed : hint_range(0.5, 10.0) = 3.0;
uniform float wave_frequency : hint_range(1.0, 30.0) = 12.0;
uniform float wave_amplitude : hint_range(0.0, 0.2) = 0.04;
void vertex() {
vec3 world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
float disp = 0.0;
for (int i = 0; i < ripple_count; i++) {
float dist = length(world_pos.xz - ripple_origins[i].xz);
float age = ripple_ages[i];
float radius = age * wave_speed;
float envelope = exp(-dist * 1.5) * exp(-age * 0.8);
disp += sin((dist - radius) * wave_frequency) * envelope * wave_amplitude;
}
VERTEX.y += disp;
}
GDScript side ages each ripple every frame and culls expired ones:
@export var water_material: ShaderMaterial
var _ripples: Array = []
const MAX_RIPPLES := 8
func add_ripple(world_pos: Vector3) -> void:
if _ripples.size() >= MAX_RIPPLES:
_ripples.pop_front()
_ripples.append({ "pos": world_pos, "age": 0.0 })
func _process(delta: float) -> void:
for r in _ripples:
r["age"] += delta
_ripples = _ripples.filter(func(r): return r["age"] < 4.0)
_flush_to_shader()
func _flush_to_shader() -> void:
var origins := PackedVector3Array()
var ages := PackedFloat32Array()
for r in _ripples:
origins.append(r["pos"])
ages.append(r["age"])
while origins.size() < MAX_RIPPLES:
origins.append(Vector3.ZERO)
ages.append(99.0)
water_material.set_shader_parameter("ripple_origins", origins)
water_material.set_shader_parameter("ripple_ages", ages)
water_material.set_shader_parameter("ripple_count", _ripples.size())
Anything that touches the water calls water_system.add_ripple(contact_position). Works for footsteps, projectiles, dropped items, anything with a world-space contact point.
Two things that tripped me up: Godot 4 requires you to pad array uniforms to their full declared size before passing them, or the shader reads garbage from uninitialized slots. That's what the padding loop is for. And ripple_count must stay accurate. If it drifts out of sync, stale entries fire phantom waves from origin.
Current problem I haven't solved: vertex displacement gets noticeably faceted on anything less than a fairly dense mesh. Considering a camera-distance blend, full vertex displacement close in, normal perturbation only past some threshold. Has anyone done a hybrid like this for water, and how did you handle the blend transition?