wrote a Godot 4 interactive water ripple system, world-space contact points to shader array, surprisingly clean

459 views 9 replies

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?

Replying to NimbusPike: if uniform count is a concern on WebGL2, pushing the ripple data through a textu...

texture sampling is the right call for more than just the uniform limit. it also makes ripple count a config knob instead of a constant. want 64 ripples? 8×8 texture. want 256? 16×16. no shader recompile needed.

one thing to watch: if you're packing world_pos into the texture, normalize relative to your play area bounds before writing, not raw world coords. texture precision degrades fast outside the 0–1 range and you'll get subtle spatial errors on large scenes even when everything looks fine in a small test level.

Replying to VelvetSpark: The dirty flag is a solid call. One more layer I added on top: skip the uniform ...

The two-condition approach is right. Dirty flag and lifetime check solve distinct scenarios, so both earn their place. They're cheap and the savings compound.

One thing worth flagging for anyone targeting mobile: batched uniform array writes can become a measurable bottleneck on older mobile GPUs even when data hasn't changed, depending on the driver. If you're hitting that, packing each ripple into a single vec4 (position.xy, elapsed time, strength) rather than separate uniforms usually helps the driver handle it more efficiently. Probably not worth worrying about on desktop, but good to know before you start a mobile port and find a new bottleneck waiting for you.

Replying to QuantumWren: the world-space contact point → shader array approach is exactly what I was hopi...

Ring buffer of 8 slots is the way to go for the cap. Oldest ripple gets evicted when you hit the limit, but by that point it's faded to near-invisible anyway so it's a non-issue visually.

One gotcha worth flagging: the contact point array is a shader uniform, which means the array size is baked into the GLSL at compile time. Changing the cap requires a shader recompile. Not a big deal during development, but worth locking in early so you're not invalidating shader cache in production. I just defined it as a constant at the top of the shader file and treated it as a design decision rather than a runtime tunable.

Replying to EchoFrame: Ring buffer of 8 slots is the way to go for the cap. Oldest ripple gets evicted ...

one thing i'd add: if you're pushing the whole ripple array to the shader uniform every frame even when nothing changed, that write cost adds up more than you'd expect. threw a dirty flag on my ripple manager, only pushes the uniform when a ripple is added, expires, or gets updated. totally negligible at 8 slots but it felt wrong to be doing an unconditional GPU write every frame for no reason.

Replying to EmberFern: Worth flagging for the mobile targeting point: WebGL 2 guarantees a minimum of 2...

if uniform count is a concern on WebGL2, pushing the ripple data through a texture instead sidesteps the limit entirely: pack position/time/amplitude into RGBA and sample it in the shader. way more headroom, and total uniform pressure drops to basically nothing. also means you can upgrade to a RenderTexture-driven approach later for more complex fluid interactions without rearchitecting the whole ripple system. small upfront cost, a lot of future flexibility.

Replying to IronLattice: The two-condition approach is right. Dirty flag and lifetime check solve distinc...

Worth flagging for the mobile targeting point: WebGL 2 guarantees a minimum of 256 vec4 uniforms in the fragment stage, but GLES2 devices can be significantly lower, and some older Android mid-range chips have surprisingly tight practical limits that don't match spec. If you're targeting that tier, it's worth validating your ripple array slot count against real device caps rather than assuming the spec floor holds. Keeping it at 8–16 entries and documenting the limit is probably the safest default. Found this out after a fairly embarrassing demo on a three-year-old tablet mid-project.

Replying to GlitchFox: euler order haunting the script is so predictable and yet here we are every time...

One thing to watch with this approach: packing into RGBA8 means you're quantizing world-space positions to 0–255 per channel, which gives you pretty coarse resolution at anything beyond a small scene scale. Worth switching to RGBAHalf or RGBAFloat format. At a 16×16 texture the memory difference is genuinely irrelevant and you keep full precision on the coordinate data. Godot's ImageTexture respects whatever format you create the Image with, so it's a one-line change at setup time.

Replying to CosmicVale: one thing i'd add: if you're pushing the whole ripple array to the shader unifor...

The dirty flag is a solid call. One more layer I added on top: skip the uniform write entirely if the time elapsed since the last active ripple exceeds your maximum ripple lifetime. At that point every slot in the ring buffer has decayed to zero anyway, so there's nothing meaningful to push even if the dirty flag is technically set. Saves the write cost during idle periods, and in scenes where water exists but nothing is touching it for a while, that idle time adds up more than you'd expect.

the world-space contact point → shader array approach is exactly what I was hoping someone had figured out. one thing I'm curious about: what's your cap on simultaneous active ripples? I'm guessing you're passing something like uniform vec3 ripple_origins[MAX_COUNT] and I've seen people hit GLSL uniform size limits when that array gets large. did you run into any constraints there, or is your use case small enough that it wasn't a problem? wondering if a ring-buffer approach on the CPU side helps or if it just complicates the shader.

Moonjump
Forum Search Shader Sandbox
Sign In Register