OceanFoam.cs
using System;
using Sandbox.Rendering;

namespace OceanLib;

/// <summary>
/// Persistent foam for the sea an <see cref="Ocean"/> simulates: a window of foam centred on the camera, carried over
/// from frame to frame in a compute shader. Each frame the foam already there fades and blurs into its neighbours -
/// dispersing - while folding crests (at the sea state's <see cref="OceanSea.CrestFoam"/> rate) and every
/// <see cref="OceanFoamSource"/> (hulls, rocks) add new foam, so whitecaps linger after the wave has passed and a
/// moving ship leaves a wake. The window steps with the camera by whole texels, so the foam stays put on the sea.
/// <see cref="OceanSurface"/> draws it; beyond the window, the surface falls back on the crests' own foam.
/// </summary>
[Title( "Ocean Foam" ), Category( "Ocean" ), Icon( "bubble_chart" )]
public sealed class OceanFoam : Component
{
	/// <summary>The sea to foam. Empty takes the one on this object, else the scene's.</summary>
	[Property] public Ocean Ocean { get; set; }

	/// <summary>Texels per side of the window.</summary>
	[Property, Group( "Window" )] public int Resolution { get; set; } = 1024;

	/// <summary>Metres the window covers.</summary>
	[Property, Group( "Window" ), Range( 50, 2000 )] public float Extent { get; set; } = 512.0f;

	/// <summary>Seconds for foam to fade to a third.</summary>
	[Property, Group( "Behaviour" ), Range( 0.2f, 30 )] public float Lifetime { get; set; } = 4.0f;

	/// <summary>How fast foam spreads into its neighbours: soft, dispersing edges.</summary>
	[Property, Group( "Behaviour" ), Range( 0, 1 )] public float Spread { get; set; } = 0.15f;

	/// <summary>The Jacobian below which a crest counts as breaking.</summary>
	[Property, Group( "Behaviour" ), Range( -1, 1 )] public float CrestThreshold { get; set; } = 0.45f;

	/// <summary>
	/// The foam drawn this frame, one channel from 0 to 1: the window the last frame's pass wrote, since this frame's
	/// pass runs after the surface has drawn.
	/// </summary>
	public Texture Current { get; private set; }

	/// <summary>World xy of the corner of the window <see cref="Current"/> holds.</summary>
	public Vector2 Origin { get; private set; }

	/// <summary>World units the window covers.</summary>
	public float WorldExtent => m_Texel * m_Size;

	private const int MaxSources = 32;

	// Ping-pong: each pass reads one and writes the other; m_Flip says which was written last.
	private Texture m_A, m_B;
	private bool m_Flip;
	private int m_Size;
	private float m_Texel;
	private Vector2 m_LastOrigin;
	private bool m_HasLast;
	private ComputeShader m_Shader;
	private CameraCommands m_Commands;
	private GpuBuffer<Vector4> m_Sources;
	private readonly Vector4[] m_SourceData = new Vector4[MaxSources * 3];

	private static readonly ProfilingSampler s_Profile = new( "Ocean Foam" );

	private Ocean Sea => Ocean.IsValid() ? Ocean : Components.Get<Ocean>() ?? Ocean.Current;

	protected override void OnEnabled()
	{
		m_Shader = new ComputeShader( "ocean/shaders/ocean_foam_cs.shader" );
		// After the FFT, which is on the same stage at order 0.
		m_Commands = new CameraCommands( "Ocean Foam", Stage.AfterUI, 1 );
		m_Sources = new GpuBuffer<Vector4>( MaxSources * 3, GpuBuffer.UsageFlags.Structured, "Ocean Foam Sources" );
		Allocate();
	}

	protected override void OnDisabled()
	{
		m_Commands?.Dispose();
		m_Commands = null;
		m_Sources?.Dispose();
		m_Sources = null;
	}

	private void Allocate()
	{
		m_Size = Math.Clamp( Resolution / 8 * 8, 64, 4096 );
		m_A = Texture.Create( m_Size, m_Size, ImageFormat.R32F ).WithUAVBinding().WithName( "Ocean Foam A" ).Finish();
		m_B = Texture.Create( m_Size, m_Size, ImageFormat.R32F ).WithUAVBinding().WithName( "Ocean Foam B" ).Finish();
		m_HasLast = false;
	}

	protected override void OnUpdate()
	{
		var ocean = Sea;

		if ( m_Commands is null || !ocean.IsValid() || ocean.Cascades.Length == 0 )
			return;

		if ( m_Size != Math.Clamp( Resolution / 8 * 8, 64, 4096 ) )
			Allocate();

		m_Commands.Follow( Scene );

		// What the last pass wrote is what this frame shows.
		Current = m_Flip ? m_B : m_A;
		Origin = m_LastOrigin;

		// The next window, centred on the camera and snapped to whole texels.
		m_Texel = Extent * Ocean.UnitsPerMetre / m_Size;
		var camera = m_Commands.Camera;
		var eye = camera.IsValid() ? camera.WorldPosition : WorldPosition;
		float half = m_Size * 0.5f * m_Texel;
		var origin = new Vector2( MathF.Round( (eye.x - half) / m_Texel ) * m_Texel, MathF.Round( (eye.y - half) / m_Texel ) * m_Texel );

		var shift = m_HasLast
			? new Vector2( MathF.Round( (origin.x - m_LastOrigin.x) / m_Texel ), MathF.Round( (origin.y - m_LastOrigin.y) / m_Texel ) )
			: new Vector2( m_Size, m_Size );

		m_LastOrigin = origin;
		m_HasLast = true;

		int count = GatherSources();
		Record( ocean, origin, shift, count );
	}

	private int GatherSources()
	{
		int count = 0;

		foreach ( var source in Scene.GetAllComponents<OceanFoamSource>() )
		{
			if ( count >= MaxSources )
				break;

			var forward = source.WorldRotation.Forward.WithZ( 0 );
			forward = forward.Length > 1e-3f ? forward.Normal : Vector3.Forward;

			m_SourceData[count * 3 + 0] = new Vector4( source.WaterlineCenter, source.Rate );
			m_SourceData[count * 3 + 1] = new Vector4( forward.x, forward.y, source.WorldHalfSize.x, source.WorldHalfSize.y );
			m_SourceData[count * 3 + 2] = new Vector4( source.HeightRange.x, source.HeightRange.y, source.BandWidth * Ocean.UnitsPerMetre, 0 );
			count++;
		}

		if ( count > 0 )
			m_Sources.SetData( m_SourceData.AsSpan( 0, count * 3 ), 0 );

		return count;
	}

	private void Record( Ocean ocean, Vector2 origin, Vector2 shift, int sources )
	{
		var cl = m_Commands.List;
		cl.Reset();

		var previous = m_Flip ? m_B : m_A;
		m_Flip = !m_Flip;
		var next = m_Flip ? m_B : m_A;

		using ( cl.ProfileScope( s_Profile ) )
		{
			ocean.Bind( cl );

			cl.Attributes.Set( "FoamPrevious", previous );
			cl.Attributes.Set( "FoamNext", next );
			cl.Attributes.Set( "FoamSize", m_Size );
			cl.Attributes.Set( "FoamOrigin", origin );
			cl.Attributes.Set( "FoamTexel", m_Texel );
			cl.Attributes.Set( "FoamShift", shift );

			float delta = Time.Delta * ocean.TimeScale;
			cl.Attributes.Set( "FoamDecay", MathF.Exp( -delta / MathF.Max( Lifetime, 0.01f ) ) );
			cl.Attributes.Set( "FoamBlur", MathF.Min( Spread * delta * 30.0f, 0.9f ) );
			cl.Attributes.Set( "FoamThreshold", CrestThreshold );
			cl.Attributes.Set( "FoamCrestRate", ocean.Sea.CrestFoam );
			cl.Attributes.Set( "FoamDelta", delta );
			cl.Attributes.Set( "FoamSourceCount", sources );
			cl.Attributes.Set( "FoamSources", m_Sources );

			cl.DispatchCompute( m_Shader, m_Size, m_Size, 1 );
			cl.UavBarrier( next );
		}
	}

	/// <summary>Hands the foam window to anything drawn with the ocean surface shader.</summary>
	public void Bind( RenderAttributes attributes )
	{
		if ( Current is null )
			return;

		attributes.Set( "OceanFoamMap", Current );
		attributes.Set( "OceanFoamOrigin", Origin );
		attributes.Set( "OceanFoamExtent", WorldExtent );
	}
}