Network/WaterNetwork.Ripples.cs

Part of a WaterNetwork component that manages interactive ripple effects. It stores ripples, updates their lifetime, uploads ripple data to a GPU buffer for the shader, exposes render attributes, and computes a CPU-side ripple height at a point to match the shader.

Native InteropNetworking
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;
	}
}