WaterNetwork partial class handling interactive water ripples. Manages a fixed-size list of expanding ring ripples, uploads their data to a GPU buffer for the shader, provides runtime removal, exposes attributes for rendering, and computes ripple height at a world point on CPU to match shader logic.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.WaterTool2;
public sealed partial class WaterNetwork
{
// Interactive ripples: expanding rings stamped onto the surface where something enters or moves on the water. Each
// one goes to the shader as two rows: (centre.xy, start time, strength) and (wavelength, ring width, _, _). The same
// formula runs in advancedwater.shader and in ComputeRippleHeight, so what floats bobs on the rings it sees.
private const int MaxRipples = 64;
private const int RippleRows = 2;
[Property(Title = "Amplitude"), Group("Ripples"), Order(3)] public float RippleAmplitude { get; set; } = 8.0f;
[Property(Title = "Expansion Speed"), Group("Ripples"), Order(3)] public float RippleSpeed { get; set; } = 100.0f;
/// <summary>
/// Ring spacing for ripples spawned without one of their own. Smaller makes more, tighter rings.
/// </summary>
[Property(Title = "Default Wavelength"), Group("Ripples"), Order(3)] public float RippleWavelength { get; set; } = 120.0f;
/// <summary>
/// Ring size for ripples spawned without one of their own. Larger makes a bigger, broader ripple.
/// </summary>
[Property(Title = "Default Ring Width"), Group("Ripples"), Order(3)] public float RippleWidth { get; set; } = 50.0f;
[Property(Title = "Damping"), Group("Ripples"), Order(3)] public float RippleDamping { get; set; } = 1.0f;
[Property(Title = "Lifetime"), Group("Ripples"), Order(3)] public float RippleLifetime { get; set; } = 3.0f;
private struct Ripple
{
public Vector2 Centre;
public float StartTime;
public float Strength;
public float Wavelength;
public float Width;
}
private readonly List<Ripple> m_Ripples = new();
private readonly Vector4[] m_RippleData = new Vector4[MaxRipples * RippleRows];
private GpuBuffer<Vector4> m_RippleBuffer;
private int m_UploadedRipples;
/// <summary>
/// Everywhere the uploaded ripples reach, in plan (min.xy, max.xy): the shader leaves every vertex outside it alone.
/// </summary>
private Vector4 m_RippleBounds;
/// <summary>
/// Spawns an expanding ripple on the water at a world position.
/// </summary>
/// <param name="_WorldPosition">Where the ripple starts (only X and Y count).</param>
/// <param name="_Strength">Scales its height (1 is an ordinary splash).</param>
/// <param name="_Wavelength">Ring spacing; 0 or less uses the network's default.</param>
/// <param name="_Width">Ring size; 0 or less uses the network's default.</param>
public static void AddRipple(Vector3 _WorldPosition, float _Strength = 1.0f, float _Wavelength = -1.0f, float _Width = -1.0f)
{
if (Current.IsValid())
Current.AddRippleInternal(_WorldPosition, _Strength, _Wavelength, _Width);
}
private void AddRippleInternal(Vector3 _WorldPosition, float _Strength, float _Wavelength, float _Width)
{
if (_Strength <= 0.0f)
return;
// The oldest goes when the list is full, so the freshest splashes always show.
if (m_Ripples.Count >= MaxRipples)
m_Ripples.RemoveAt(0);
m_Ripples.Add(new Ripple
{
Centre = new Vector2(_WorldPosition.x, _WorldPosition.y),
StartTime = Time.Now,
Strength = _Strength,
Wavelength = _Wavelength > 0.0f ? _Wavelength : RippleWavelength,
Width = _Width > 0.0f ? _Width : RippleWidth
});
}
/// <summary>
/// Drops the ripples that have died out. Runs everywhere: a dedicated server's buoyancy reads them too.
/// </summary>
private void UpdateRipples()
{
for (int i = m_Ripples.Count - 1; i >= 0; i--)
{
if (Time.Now - m_Ripples[i].StartTime > RippleLifetime)
m_Ripples.RemoveAt(i);
}
}
private void UploadRipples()
{
m_UploadedRipples = Math.Min(m_Ripples.Count, MaxRipples);
var min = new Vector2(float.MaxValue, float.MaxValue);
var max = new Vector2(float.MinValue, float.MinValue);
for (int i = 0; i < m_UploadedRipples; i++)
{
var ripple = m_Ripples[i];
int row = i * RippleRows;
m_RippleData[row + 0] = new Vector4(ripple.Centre.x, ripple.Centre.y, ripple.StartTime, ripple.Strength);
m_RippleData[row + 1] = new Vector4(ripple.Wavelength, ripple.Width, 0.0f, 0.0f);
// As far as its ring has spread (a moment more to spare), and the four ring widths past it the shader still
// counts. A ripple without a width reaches everywhere.
float reach = ripple.Width > 0.001f
? (MathF.Max(0.0f, Time.Now - ripple.StartTime) + 0.1f) * MathF.Abs(RippleSpeed) + ripple.Width * 4.0f
: 1e30f;
min = new Vector2(MathF.Min(min.x, ripple.Centre.x - reach), MathF.Min(min.y, ripple.Centre.y - reach));
max = new Vector2(MathF.Max(max.x, ripple.Centre.x + reach), MathF.Max(max.y, ripple.Centre.y + reach));
}
m_RippleBounds = m_UploadedRipples > 0 ? new Vector4(min.x, min.y, max.x, max.y) : Vector4.Zero;
if (!m_RippleBuffer.IsValid())
m_RippleBuffer = new GpuBuffer<Vector4>(MaxRipples * RippleRows, GpuBuffer.UsageFlags.Structured);
m_RippleBuffer.SetData(m_RippleData.AsSpan(0, m_UploadedRipples * RippleRows));
}
private void ApplyRippleAttributes(RenderAttributes _Attributes)
{
_Attributes.Set("RippleCount", m_UploadedRipples);
_Attributes.Set("RippleBounds", m_RippleBounds);
_Attributes.Set("RippleAmplitude", RippleAmplitude);
_Attributes.Set("RippleSpeed", RippleSpeed);
_Attributes.Set("RippleDamping", RippleDamping);
if (m_RippleBuffer.IsValid())
_Attributes.Set("RippleData", m_RippleBuffer);
}
private void ReleaseRipples()
{
m_RippleBuffer?.Dispose();
m_RippleBuffer = null;
}
/// <summary>
/// How far the ripples lift (or drop) the surface at a world point. Mirrors ComputeRipples() in the shader.
/// </summary>
public float ComputeRippleHeight(Vector2 _Point)
{
if (m_Ripples.Count == 0)
return 0.0f;
float height = 0.0f;
foreach (var ripple in m_Ripples)
{
float age = Time.Now - ripple.StartTime;
if (age < 0.0f || age > RippleLifetime)
continue;
float frequency = ripple.Wavelength > 0.001f ? MathF.PI * 2.0f / ripple.Wavelength : 0.0f;
float inverseWidthSquared = ripple.Width > 0.001f ? 1.0f / (ripple.Width * ripple.Width) : 0.0f;
float distance = (_Point - ripple.Centre).Length;
float offset = distance - age * RippleSpeed;
float spatial = MathF.Exp(-offset * offset * inverseWidthSquared);
float fade = MathF.Exp(-age * RippleDamping);
height += MathF.Sin(offset * frequency) * spatial * fade * RippleAmplitude * ripple.Strength;
}
return height;
}
}