> ## Documentation Index
> Fetch the complete documentation index at: https://docs.summerengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# VFX water ripple skill for AI agents

> Water-ripple effect: animated normal-distortion ripples on a water plane or Decal, set off by impacts. A Summer Engine skill for AI agents.

`vfx-water-ripple` is a skill from the [Summer skill library](/mcp/skills): a guide your AI agent reads when the task matches, so it builds the good version on the first try. Water-ripple effect: animated normal-distortion ripples on a water plane or Decal, set off by impacts.

| | |
| - | - |
| Status | Stable |
| Domains | Visual effects, Rendering and lighting |
| Version | `1.0.2` |
| Engine version | `>=4.6` |
| Recommended set | No, installed by `--all` or by name |

## When your agent uses it

* Authoring a water-ripple effect — raindrop ripples, splash ripples, footstep in a puddle, fish jumps
* Rain, footsteps, or falling objects should disturb a water surface with spreading ripples

## Install

`summer setup` installs every skill for your agent. To add only this one:

```bash theme={null}
npx -y summer-engine@latest skills install vfx-water-ripple --agent claude-code
```

Swap `claude-code` for your agent, such as `codex` or `cursor`. See [Skills](/mcp/skills#quick-start) for every option.

## The skill

Concentric expanding rings on a water surface, normal-distorted so the ripple actually refracts the reflection. The shader keeps a small ring buffer of (origin, time, amplitude) tuples and renders all of them simultaneously over a base water plane. GDScript exposes `add_ripple(world_pos, amplitude)` so any system (rain, footsteps, projectile impacts, fish) can spawn one with a single call.

## When to use

* "Add ripples when the player walks through the puddle."
* "Rain hitting the lake should make ripples."
* "When the bullet hits the water, ripple."
* "Fish jumping leaves ripples."
* "Footsteps on the wet street should ripple."
* A water plane already exists and looks too static.

## When NOT to use

* The user wants the *whole* water surface to wave (Gerstner / FFT ocean) — that needs an ocean wave shader; this recipe adds impact ripples on top.
* The user wants a 2D top-down ripple effect on a UI map — use `canvas_item` shader, not this `spatial` one.
* The user wants splash particles flying upward on impact — pair this with `vfx-hit-spark` (recolor blue) for the droplets; this only handles the ring on the surface.
* The user wants the ripple to physically displace floating objects — this is visual only. Use `Area3D` impulses for the simulation side.

## Recipe

### 1. Files to create

```
addons/vfx/water-ripple/water_ripple.gdshader     # spatial shader on the water plane
addons/vfx/water-ripple/water_ripple_surface.gd    # MeshInstance3D controller w/ add_ripple()
addons/vfx/water-ripple/water_ripple.tscn          # reusable plane
```

### 2. Shader code

`addons/vfx/water-ripple/water_ripple.gdshader`:

```glsl theme={null}
shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_disabled;

const int MAX_RIPPLES = 16;

uniform vec4  water_color    : source_color = vec4(0.10, 0.30, 0.45, 0.85);
uniform vec4  ripple_color   : source_color = vec4(0.85, 0.95, 1.00, 1.0);
uniform float water_metallic : hint_range(0.0, 1.0) = 0.4;
uniform float water_roughness: hint_range(0.0, 1.0) = 0.15;
uniform float fresnel_power  : hint_range(0.5, 8.0) = 4.0;
uniform float scroll_speed   : hint_range(0.0, 0.5) = 0.05;
uniform sampler2D normal_map : hint_normal;

// Per-ripple data: (origin.xz, start_time, amplitude * (1 if active else 0))
uniform vec4 ripples[MAX_RIPPLES];

// Ripple ages run off the controller's own clock, pushed here every frame. They
// cannot run off TIME: shader TIME wraps at
// rendering/limits/time/time_rollover_secs (3600 by default, verified), which
// would send every live ripple to a negative age once an hour of uptime.
uniform float now = 0.0;

uniform float ripple_speed       : hint_range(0.5, 8.0)  = 2.5;   // m/s expansion
uniform float ripple_wavelength  : hint_range(0.05, 1.0) = 0.20;  // distance between crests
uniform float ripple_lifetime    : hint_range(0.5, 6.0)  = 2.5;   // seconds visible
uniform float ripple_height      : hint_range(0.0, 0.4)  = 0.08;  // visual normal kick

void vertex() {
    // Sample ripples in vertex to displace Y for proper silhouette.
    vec3 wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
    float disp = 0.0;
    for (int i = 0; i < MAX_RIPPLES; i++) {
        vec4 r = ripples[i];
        if (r.w <= 0.0) continue;
        float age = now - r.z;
        if (age < 0.0 || age > ripple_lifetime) continue;
        float dist = length(wpos.xz - r.xy);
        float front = age * ripple_speed;
        float ring = exp(-pow((dist - front) / 0.25, 2.0));
        float fade = 1.0 - smoothstep(0.0, ripple_lifetime, age);
        disp += sin((dist - front) / ripple_wavelength * 6.2831) * ring * fade * r.w;
    }
    VERTEX.y += disp * ripple_height;
}

void fragment() {
    // Base water normal scroll.
    vec2 nuv = UV + vec2(TIME * scroll_speed, TIME * scroll_speed * 0.7);
    vec3 base_n = texture(normal_map, nuv).rgb * 2.0 - 1.0;

    // Composite ripple normals on top.
    vec3 wpos = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
    vec3 ripple_n = vec3(0.0, 0.0, 1.0);
    float ripple_glow = 0.0;
    for (int i = 0; i < MAX_RIPPLES; i++) {
        vec4 r = ripples[i];
        if (r.w <= 0.0) continue;
        float age = now - r.z;
        if (age < 0.0 || age > ripple_lifetime) continue;
        vec2 d = wpos.xz - r.xy;
        float dist = length(d);
        float front = age * ripple_speed;
        float ring = exp(-pow((dist - front) / 0.25, 2.0));
        float fade = 1.0 - smoothstep(0.0, ripple_lifetime, age);
        vec2 dir = (dist > 0.0001) ? (d / dist) : vec2(0.0);
        float wave = cos((dist - front) / ripple_wavelength * 6.2831);
        ripple_n.xy += dir * wave * ring * fade * r.w * 1.5;
        ripple_glow += ring * fade * r.w * 0.5;
    }

    // NORMAL_MAP is decoded by the engine (scene_forward_clustered.glsl:1427 —
    // `normal_map.xy = normal_map.xy * 2.0 - 1.0`), so hand it the RAW [0,1] texel
    // encoding. Assigning an already-decoded +/-1 vector makes a flat surface read
    // as permanent maximum tilt.
    NORMAL_MAP = normalize(base_n + vec3(ripple_n.xy, 0.0)) * 0.5 + 0.5;
    NORMAL_MAP_DEPTH = 0.6;

    // Fresnel-tinted color.
    float fres = pow(1.0 - clamp(dot(NORMAL, VIEW), 0.0, 1.0), fresnel_power);
    ALBEDO = mix(water_color.rgb, ripple_color.rgb, ripple_glow);
    METALLIC = water_metallic;
    ROUGHNESS = water_roughness;
    ALPHA = mix(water_color.a, 1.0, fres);
    EMISSION = ripple_color.rgb * ripple_glow * 0.4;
}
```

### 3. GDScript controller

`addons/vfx/water-ripple/water_ripple_surface.gd`:

```gdscript theme={null}
@tool
class_name WaterRippleSurface
extends MeshInstance3D

const MAX_RIPPLES := 16

@export var water_color: Color = Color(0.10, 0.30, 0.45, 0.85)
@export var ripple_color: Color = Color(0.85, 0.95, 1.00)
@export_range(0.5, 8.0) var ripple_speed: float = 2.5
@export_range(0.5, 6.0) var ripple_lifetime: float = 2.5
@export_range(0.0, 0.4) var ripple_height: float = 0.08
@export var normal_texture: Texture2D

var _ripples: Array[Vector4] = []
var _next_slot: int = 0
var _mat: ShaderMaterial
var _time: float = 0.0

func _ready() -> void:
    _ripples.resize(MAX_RIPPLES)
    for i in MAX_RIPPLES:
        _ripples[i] = Vector4.ZERO
    if material_override == null or not (material_override is ShaderMaterial):
        var sm := ShaderMaterial.new()
        sm.shader = preload("res://addons/vfx/water-ripple/water_ripple.gdshader")
        material_override = sm
    _mat = material_override as ShaderMaterial
    _apply()
    set_process(true)

func _apply() -> void:
    if _mat == null: return
    _mat.set_shader_parameter("water_color", water_color)
    _mat.set_shader_parameter("ripple_color", ripple_color)
    _mat.set_shader_parameter("ripple_speed", ripple_speed)
    _mat.set_shader_parameter("ripple_lifetime", ripple_lifetime)
    _mat.set_shader_parameter("ripple_height", ripple_height)
    if normal_texture:
        _mat.set_shader_parameter("normal_map", normal_texture)

## Spawn a ripple at a world position.
##   amplitude: 0..1 (raindrop ~0.2, footstep ~0.5, body splash ~1.0)
func add_ripple(world_pos: Vector3, amplitude: float = 0.5) -> void:
    # Stamp with our own clock, not Time.get_ticks_msec(): the shader compares
    # against the same `now` uniform, so the two only line up if one source drives both.
    _ripples[_next_slot] = Vector4(world_pos.x, world_pos.z, _time, clamp(amplitude, 0.0, 1.0))
    _next_slot = (_next_slot + 1) % MAX_RIPPLES
    _push()

## True when `world_pos` is over this surface (XZ against the mesh's local AABB).
## Use this rather than a containment method on the caller — there isn't one.
func contains_point(world_pos: Vector3) -> bool:
    if mesh == null:
        return false
    var local := to_local(world_pos)
    var box := mesh.get_aabb()
    return local.x >= box.position.x and local.x <= box.end.x \
        and local.z >= box.position.z and local.z <= box.end.z

func _push() -> void:
    if _mat == null: return
    _mat.set_shader_parameter("ripples", _ripples)

func _process(delta: float) -> void:
    # Drive ripple age from this clock and push it every frame. Using the shader's
    # TIME instead would silently kill every ripple after
    # rendering/limits/time/time_rollover_secs (3600 s default) of uptime.
    _time += delta
    if _mat:
        _mat.set_shader_parameter("now", _time)
    # Re-push so editor updates pick up changes; cheap.
    if Engine.is_editor_hint():
        _push()
```

### 4. Node tree

```
MeshInstance3D ("Water") [script: water_ripple_surface.gd]
  └── mesh: PlaneMesh (size matches the puddle / lake)
        └── material_override: ShaderMaterial (shader: water_ripple.gdshader, normal_map: <noise>)
```

### 5. Wire it in (MCP calls)

Write the two files, then wire the surface with ONE `summer_run_script` call
with an explicit `scenePath` — transactional, and it builds the subdivided
PlaneMesh directly, so no `plane.tres` sidecar is needed:

```
summer_write_file(path="res://addons/vfx/water-ripple/water_ripple.gdshader", content="<section 2>", create_only=true)
summer_write_file(path="res://addons/vfx/water-ripple/water_ripple_surface.gd", content="<section 3>", create_only=true)

summer_run_script(scenePath="res://main.tscn", source="""
func run(ctx):
    var puddle = ctx.add_node("MeshInstance3D", "Puddle", ctx.find("World"))
    var plane := PlaneMesh.new()
    plane.subdivide_width = 32
    plane.subdivide_depth = 32
    puddle.mesh = plane
    puddle.position = Vector3(0, 0.01, 0)
    puddle.set_script(load("res://addons/vfx/water-ripple/water_ripple_surface.gd"))
    # normal_texture is an exported property of the controller script, not of MeshInstance3D
    puddle.set("normal_texture", load("res://addons/vfx/water-ripple/water_normals.png"))
    ctx.save_scene()
""")
```

Set the mesh BEFORE the script if the controller reads it in `_ready`; either way,
`ctx.add_node` has already owned the node, so it survives the save. Tuning one
mesh value later is one `summer_set_resource_property` call (`resourceProperty="mesh"`,
`subProperty="subdivide_width"`): it reaches into a resource, it never assigns one.

### 5a. Verify

The shader and the controller are compiled code, and the controller `preload`s the
shader — a shader that failed to write takes the script down at parse time.

```
summer_get_script_errors          # water_ripple_surface.gd parsed?
summer_play
summer_get_debugger_errors        # shader compile errors surface here at runtime
summer_stop
```

Then trigger ripples from any system. Footstep:

```gdscript theme={null}
# In your character controller's footstep callback:
if puddle and puddle.contains_point(global_position):
    puddle.add_ripple(global_position, 0.5)
```

Rain:

```gdscript theme={null}
# In a Rain node spawning N drops/sec on the water plane bounds:
puddle.add_ripple(rand_xz_inside_puddle(), 0.20)
```

Bullet impact:

```gdscript theme={null}
# In the bullet's hit handler when hit.collider == puddle:
puddle.add_ripple(hit.position, 0.8)
```

### 6. Parameters to tune

| Parameter | Range | Effect |
| - | - | - |
| `ripple_speed` | 0.5–8.0 m/s | how fast the ring expands |
| `ripple_lifetime` | 0.5–6.0 s | how long before it fades |
| `ripple_height` | 0.0–0.4 | vertex displacement amplitude (silhouette wobble) |
| `ripple_wavelength` | 0.05–1.0 m | distance between crests within one ring |
| `water_color` | Color | base tint, alpha controls transparency |
| `ripple_color` | Color | crest tint, additive glow at the ring front |
| `fresnel_power` | 0.5–8.0 | how grazing the angle has to be before the surface goes opaque |
| `scroll_speed` | 0.0–0.5 | base normal map scroll speed (subtle shimmer) |

## Cookbook — named variants

### puddle-shallow (default)

Tight, fast ripples, high contrast.

```
ripple_speed     = 3.0
ripple_lifetime  = 1.6
ripple_height    = 0.04
ripple_wavelength= 0.12
water_color      = Color(0.05, 0.07, 0.10, 0.75)
```

### lake-calm

Slow, wide, long-lived ripples.

```
ripple_speed     = 1.5
ripple_lifetime  = 4.0
ripple_height    = 0.10
ripple_wavelength= 0.30
water_color      = Color(0.10, 0.30, 0.45, 0.85)
```

### lava-glow (recolor for hot liquid)

```
water_color      = Color(0.12, 0.02, 0.0, 1.0)
ripple_color     = Color(1.0, 0.55, 0.10)
ripple_lifetime  = 1.2
ripple_height    = 0.02
emission via shader: bump fres mix toward ripple_color
```

### blood-pool

Sticky, slow, dark.

```
water_color      = Color(0.18, 0.02, 0.02, 0.95)
ripple_color     = Color(0.45, 0.05, 0.05)
ripple_speed     = 1.0
ripple_lifetime  = 3.0
ripple_wavelength= 0.40
```

## Anti-patterns

* **Spawning more than 16 ripples per surface.** The shader has a fixed array. Older ripples get overwritten — fine for rain (they were fading anyway), bad if you spawn 50 in one frame. Either raise `MAX_RIPPLES` (and the GLSL `MAX_RIPPLES`) together, or throttle spawns.
* **Ripple position in local instead of world space.** The shader expects world XZ. Always convert in the GDScript before pushing.
* **No normal map assigned.** The surface looks like flat-shaded plastic. Always assign a tileable water normals texture (any blue noise normals will do).
* **Writing a decoded ±1 vector into `NORMAL_MAP`.** The engine decodes it again (`normal_map.xy = normal_map.xy * 2.0 - 1.0`), so a flat surface reads as permanent maximum tilt. Re-encode with `* 0.5 + 0.5` before assigning, as section 2 does.
* **Timing ripples against shader `TIME`.** `TIME` wraps at `rendering/limits/time/time_rollover_secs` (3600 s default) and is not `Time.get_ticks_msec() / 1000`. Push your own accumulated clock as a uniform and compare against that; otherwise every ripple silently vanishes after an hour of uptime.
* **PlaneMesh too low-res.** The vertex displacement needs \~2–4 verts per meter or the silhouette wobble looks blocky. Use `subdivide_width = 32, subdivide_depth = 32` minimum on a 10×10 plane.
* **`depth_draw_disabled` on the water material.** You'll see the bottom of the puddle through the water at every angle. Use `depth_draw_opaque`.
* **Triggering ripples in `_process` without spacing.** Spamming 60/sec floods the buffer. Throttle to \~5 ripples/sec for rain unless tuned.

## Performance notes

* Cost is O(MAX\_RIPPLES × pixels\_drawn) in the fragment loop plus O(MAX\_RIPPLES × verts) in the vertex loop. 16 ripples on a 5×5 m puddle at 1080p is cheap — an order-of-magnitude expectation, not a measurement. Profile if you need a number.
* The vertex displacement loop runs per vertex; raising plane subdivision past 64 starts to hurt. 32×32 is plenty.
* For lakes (large planes), don't apply this shader to the entire lake — split the lake into chunks and only apply ripples to the chunk near impacts. Or use a `Decal` projection instead (see edge cases).
* Mobile: drop `MAX_RIPPLES` to 8, disable vertex displacement (set `ripple_height = 0` and skip the vertex loop), and rely on normal-only ripples.

## Edge cases

* **Ripple on a non-flat water surface (Gerstner ocean).** This shader assumes the water plane is flat. For wavy water, sample the wave height in the same shader and add the ripple displacement on top.
* **Want to use a Decal instead of overriding the water material.** Use `Decal` projecting straight down with a 256×256 ring texture animated by `albedo_mix`. Cheaper but doesn't refract reflections — only colors the surface.
* **Ripple needs to interact with floating debris.** Visual ripples don't push physics. Spawn an `Area3D` impulse alongside if you need physical effect.
* **Camera under water (POV swimming).** This shader is one-sided in concept (ring expanding outward from above). For under-water POV, render a separate shader.
* **Ripple at the edge of the plane.** It clips at the plane boundary because the plane has no geometry past it. Expand the plane 2 m beyond the gameplay area.

## Fallback (no MCP)

Section 5 is fully automatable — `summer_write_file` writes the shader and the
controller, and `summer_add_node` + `summer_set_prop` +
`summer_set_resource_property` + `summer_save_scene` build the plane including its
subdivision. Do not hand these steps to the user when the MCP tools are available.

Without the MCP connection there is no engine to drive, so the user does it
manually in Summer Engine:

1. Create `addons/vfx/water-ripple/` and write the three files above.
2. Add a `MeshInstance3D` with a high-subdivision `PlaneMesh` (32×32 minimum).
3. Attach `water_ripple_surface.gd` and assign a tileable water normals texture.
4. From your trigger code, call `$Puddle.add_ripple(impact_pos, amplitude)`.
5. Check the Errors dock — the controller `preload`s the shader, so a broken shader path fails the script too.

## Handoff

After firing this recipe, suggest:

* `vfx-hit-spark` — recolor white-blue and spawn at the impact point for a few water droplets flying up. Pair them.
* `vfx-smoke` — recolor pale gray for the steam if you're hitting hot water or lava.
* `sound-effect` — generate `single water drop in shallow puddle, splash, 200ms` and trigger on each `add_ripple`.
* For weather-driven rain ripples, scaffold a simple Rain node that calls `add_ripple` 30×/sec across the puddle bounds.

## See also

* `_building-blocks/additive-billboard-particles.md` — for paired splash droplets
* `vfx-hit-spark` — sister recipe for the upward droplets
* `vfx-smoke` — for steam over hot liquids
* `gdscript-patterns` — for the controller idioms

## From a shell

No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@latest` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.

| In this skill | Command |
| - | - |
| `summer_add_node` | `summer engine add-node` |
| `summer_create_scene` | `summer engine create-scene` |
| `summer_get_debugger_errors` | `summer engine get-debugger-errors` |
| `summer_get_script_errors` | `summer engine get-script-errors` |
| `summer_inspect_node` | `summer engine inspect-node` |
| `summer_inspect_resource` | `summer engine inspect-resource` |
| `summer_play` | `summer engine play` |
| `summer_read_file` | `summer engine read-file` |
| `summer_run_script` | `summer engine run-script` |
| `summer_save_scene` | `summer engine save-scene` |
| `summer_set_prop` | `summer engine set-prop` |
| `summer_set_resource_property` | `summer engine set-resource-property` |
| `summer_stop` | `summer engine stop` |
| `summer_write_file` | `summer engine write-file` |

## Files in this skill

* [`_building-blocks/additive-billboard-particles.md`](/skills/vfx-water-ripple/building-blocks/additive-billboard-particles)

## Related skills

* [`scene-scripting`](/skills/scene-scripting): Build scenes, levels, HUDs and wiring with one editor GDScript (summer\_run\_script) instead of call chains.
* [`sound-effect`](/skills/sound-effect): Make sound effects (swings, hits, whooshes, reloads, UI): three versions to pick from, then wire them.
* [`vfx-hit-spark`](/skills/vfx-hit-spark): Hit-spark effect: a one-shot burst of additive particles oriented to the surface, fired on impact.
* [`vfx-smoke`](/skills/vfx-smoke): Smoke effect: a rising column or puff of soft particles with a noise and density falloff shader.

***

Source: [`library/skills/vfx-water-ripple/SKILL.md`](https://github.com/SummerEngine/summer/blob/main/library/skills/vfx-water-ripple/SKILL.md), rendered from `summer-engine@4.3.0` on npm.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.