Water ripple subsystem of WaterManager. Manages up to 64 ripple emitters, packs per-ripple data into a GPU buffer for the shader, exposes AddRipple, updates/prunes ripples, applies render attributes, and computes CPU ripple height matching the shader formula.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.WaterTool;
public partial class WaterManager
{
// Interactive ripples — expanding radial wave packets stamped onto the surface
// when something enters or moves on the water. Each emitter is uploaded as two
// float4 rows: row0 = (Center.xy, StartTime, Strength), row1 = (Wavelength, Width, _, _).
// Amplitude/Speed/Damping are global; Strength, Wavelength and Width are per-ripple.
// The exact same formula runs in advancedwater.shader (VS) and in ComputeRippleHeight
// (CPU) so buoyancy bobs over the visual ripples.
private const int MAX_RIPPLES = 64;
private const int RIPPLE_ROWS = 2;
[Property(Title = "Amplitude"), Group("Ripples")] public float RippleAmplitude { get; set; } = 8.0f;
[Property(Title = "Expansion Speed"), Group("Ripples")] public float RippleSpeed { get; set; } = 100.0f;
// Default ring spacing used when a ripple is spawned without an explicit wavelength.
// Smaller = tighter, more concentric rings. Larger = fewer, broader rings.
[Property(Title = "Default Wavelength"), Group("Ripples")] public float RippleWavelength { get; set; } = 120.0f;
// Default ring size used when a ripple is spawned without an explicit width.
// Larger = bigger, broader ripple (the wave packet spans a wider radial band).
[Property(Title = "Default Ring Width"), Group("Ripples")] public float RippleWidth { get; set; } = 50.0f;
[Property(Title = "Damping"), Group("Ripples")] public float RippleDamping { get; set; } = 1.0f;
[Property(Title = "Lifetime"), Group("Ripples")] public float RippleLifetime { get; set; } = 3.0f;
private struct RippleEmitter
{
public Vector2 Center;
public float StartTime;
public float Strength;
public float Wavelength;
public float Width;
}
private readonly List<RippleEmitter> m_Ripples = [];
private GpuBuffer<Vector4> m_RippleBuffer;
private readonly Vector4[] m_RippleData = new Vector4[MAX_RIPPLES * RIPPLE_ROWS];
private int m_ActiveRippleCount;
/// <summary>
/// Spawn an expanding ripple on the water surface at the given world position.
/// </summary>
/// <param name="_WorldPosition">Where the ripple originates (only XY is used).</param>
/// <param name="_Strength">Scales the height of the ripple (1 = a normal splash).</param>
/// <param name="_Wavelength">Ring spacing — smaller = more rings. Pass <= 0 to use the manager's Default Wavelength.</param>
/// <param name="_Width">Ring size — larger = a bigger, broader ripple. Pass <= 0 to use the manager's Default Ring Width.</param>
public static void AddRipple(Vector3 _WorldPosition, float _Strength = 1.0f, float _Wavelength = -1.0f, float _Width = -1.0f)
{
Current?.AddRippleInternal(_WorldPosition, _Strength, _Wavelength, _Width);
}
private void AddRippleInternal(Vector3 _WorldPosition, float _Strength, float _Wavelength, float _Width)
{
if (_Strength <= 0.0f)
return;
// Fall back to the global defaults when no per-ripple value is given
if (_Wavelength <= 0.0f)
_Wavelength = RippleWavelength;
if (_Width <= 0.0f)
_Width = RippleWidth;
// Drop the oldest when full so the freshest splashes always survive
if (m_Ripples.Count >= MAX_RIPPLES)
m_Ripples.RemoveAt(0);
m_Ripples.Add(new RippleEmitter
{
Center = new Vector2(_WorldPosition.x, _WorldPosition.y),
StartTime = Time.Now,
Strength = _Strength,
Wavelength = _Wavelength,
Width = _Width
});
}
private void UpdateRipples()
{
// Prune expired emitters
for (int i = m_Ripples.Count - 1; i >= 0; i--)
{
if (Time.Now - m_Ripples[i].StartTime > RippleLifetime)
m_Ripples.RemoveAt(i);
}
m_ActiveRippleCount = Math.Min(m_Ripples.Count, MAX_RIPPLES);
for (int i = 0; i < m_ActiveRippleCount; i++)
{
var r = m_Ripples[i];
int row = i * RIPPLE_ROWS;
m_RippleData[row + 0] = new Vector4(r.Center.x, r.Center.y, r.StartTime, r.Strength);
m_RippleData[row + 1] = new Vector4(r.Wavelength, r.Width, 0.0f, 0.0f);
}
EnsureRippleBuffer();
m_RippleBuffer.SetData(m_RippleData.AsSpan(0, m_ActiveRippleCount * RIPPLE_ROWS));
}
private void EnsureRippleBuffer()
{
if (!m_RippleBuffer.IsValid())
m_RippleBuffer = new GpuBuffer<Vector4>(MAX_RIPPLES * RIPPLE_ROWS, GpuBuffer.UsageFlags.Structured);
}
internal void ApplyRippleAttributes(RenderAttributes _Attributes)
{
_Attributes.Set("RippleCount", m_ActiveRippleCount);
_Attributes.Set("RippleAmplitude", RippleAmplitude);
_Attributes.Set("RippleSpeed", RippleSpeed);
_Attributes.Set("RippleDamping", RippleDamping);
if (m_RippleBuffer.IsValid())
_Attributes.Set("RippleData", m_RippleBuffer);
}
/// <summary>
/// CPU evaluation of the ripple vertical displacement at a world XY position.
/// MUST mirror ComputeRipples() in advancedwater.shader so physics matches visuals.
/// </summary>
public float ComputeRippleHeight(Vector2 _WorldXY)
{
if (m_Ripples.Count == 0)
return 0.0f;
float z = 0.0f;
for (int i = 0; i < m_Ripples.Count; i++)
{
var r = m_Ripples[i];
float age = Time.Now - r.StartTime;
if (age < 0.0f || age > RippleLifetime)
continue;
float freq = r.Wavelength > 0.001f ? (MathF.PI * 2.0f / r.Wavelength) : 0.0f;
float invWidthSq = r.Width > 0.001f ? 1.0f / (r.Width * r.Width) : 0.0f;
float d = (_WorldXY - r.Center).Length;
float ring = age * RippleSpeed;
float ringDelta = d - ring;
float spatialEnv = MathF.Exp(-ringDelta * ringDelta * invWidthSq);
float timeEnv = MathF.Exp(-age * RippleDamping);
float wave = MathF.Sin(ringDelta * freq);
z += wave * spatialEnv * timeEnv * RippleAmplitude * r.Strength;
}
return z;
}
}