Network/WaterNetwork.Sea.cs

Part of the WaterNetwork component that implements the infinite sea surface and related data for rendering and sampling. It builds ring meshes centred on the camera, uploads ring and sea-hole buffers to the GPU, computes hole grids for efficient hole testing, applies render attributes, and provides sampling of sea displacement and water samples.

Native InteropFile Access
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Rendering;

namespace RedSnail.WaterTool2;

/// <summary>
/// The sea: one infinite surface at sea level, drawn round the camera out to the horizon. Round the land it gives way
/// to the coast zone (see WaterNetwork.Coast), which it is masked out over, along with the land itself.
/// </summary>
public sealed partial class WaterNetwork
{
	/// <summary>
	/// Pieces of water whose surface comes this close above sea level cut the sea away under them, so the two never
	/// draw over each other.
	/// </summary>
	private const float SeaHoleMargin = 64.0f;

	private const int MaxSeaRings = 12;

	/// <summary>
	/// The most cells the grid over the sea holes has along either side.
	/// </summary>
	private const int MaxSeaHoleGrid = 128;

	/// <summary>
	/// An infinite sea at <see cref="SeaLevel"/>, drawn in rings round the camera, each twice as coarse as the one
	/// inside it, out to the horizon.
	/// </summary>
	[Property(Title = "Enabled"), Group("Sea"), Order(5)]
	public bool SeaEnabled
	{
		get;
		set
		{
			field = value;
			MarkDirty();
		}
	}

	/// <summary>
	/// The sea's level, in network space. The coast lies at it, and the rivers opening into the sea end at it (the next
	/// reconcile moves them). The coast is found at this level: build it again after moving it.
	/// </summary>
	[Property(Title = "Sea Level"), Group("Sea"), Order(5)]
	public float SeaLevel
	{
		get;
		set
		{
			field = value;
			MarkDirty();
		}
	}

	/// <summary>
	/// The sea's waves: long swell and chop of their own, apart from the rivers' and lakes'. Ocean waves when empty.
	/// </summary>
	[Property(Title = "Wave Profile"), Group("Sea"), Order(5)] public WaterDefinition SeaProfile { get; set; }

	/// <summary>
	/// Scales the sea's waves.
	/// </summary>
	[Property(Title = "Waves"), Group("Sea"), Order(5), Range(0.0f, 2.0f)] public float SeaWaves { get; set { field = value.Clamp(0.0f, 10.0f); } } = 1.0f;

	/// <summary>
	/// How deep the sea is, for swimming, drowning and buoyancy.
	/// </summary>
	[Property(Title = "Depth"), Group("Sea"), Order(5)] public float SeaDepth { get; set { field = value.Clamp(1.0f, 1000000.0f); } } = 5000.0f;

	/// <summary>
	/// The size of the finest ring's cells, round the camera; each ring further out doubles it. The coast zone is built
	/// on the same cells, so it is the sea's own surface point for point.
	/// </summary>
	[Property(Title = "Cell Size"), Group("Sea"), Order(5), Range(16.0f, 256.0f)]
	public float SeaCellSize
	{
		get;
		set
		{
			field = value.Clamp(4.0f, 4096.0f);
			MarkDirty();
		}
	} = 32.0f;

	/// <summary>
	/// How many rings the sea is drawn in: each one reaches twice as far as the one inside it.
	/// </summary>
	[Property(Title = "Rings"), Group("Sea"), Order(5), Range(2, 12)] public int SeaRings { get; set { field = value.Clamp(1, MaxSeaRings); } } = 7;

	/// <summary>
	/// How many cells across each ring is.
	/// </summary>
	[Property(Title = "Ring Cells"), Group("Sea"), Order(5), Range(32, 256)] public int SeaRingCells { get; set { field = Math.Max(16, value.Clamp(16, 512) / 8 * 8); } } = 128;

	/// <summary>
	/// The sea's wave profile: its own, or ocean waves.
	/// </summary>
	public WaterDefinition ResolvedSeaProfile => SeaProfile.IsValid() ? SeaProfile : WaterDefinition.SeaFallback;

	/// <summary>
	/// The sea's level in the world.
	/// </summary>
	public float WorldSeaLevel => WorldTransform.PointToWorld(new Vector3(0.0f, 0.0f, SeaLevel)).z;

	/// <summary>
	/// What the sea is drawing right now, for the editor: the coast zone's chunks, the river mouths, and the cut-outs the
	/// water near sea level makes in the infinite sea (every pixel of the sea near one tests it).
	/// </summary>
	public string SeaStats
	{
		get
		{
			int chunks = 0;
			int mouths = 0;
			long chunkTriangles = 0;
			long mouthTriangles = 0;

			foreach (var piece in m_Pieces.Values)
			{
				if (piece.Key.Kind == WaterPieceKind.Coast)
				{
					chunks++;
					chunkTriangles += piece.Mesh.TriangleCount;
				}
				else if (piece.Key.Kind == WaterPieceKind.Estuary)
				{
					mouths++;
					mouthTriangles += piece.Mesh.TriangleCount;
				}
			}

			return $"Coast zone: {chunks} chunks, {Count(chunkTriangles)} triangles\nRiver mouths: {mouths}, {Count(mouthTriangles)} triangles\nSea cut-outs: {m_SeaHoleCount}";
		}
	}



	private static string Count(long _Value)
	{
		if (_Value >= 1000000)
			return $"{_Value / 1000000.0:0.0}M";

		if (_Value >= 1000)
			return $"{_Value / 1000.0:0.0}K";

		return _Value.ToString();
	}



	// ── The sea's surface ──
	// One grid of cells, drawn once per ring: ring k centred near the camera (snapped to twice its cell size, so the ring
	// inside it lands exactly on its grid), with cells twice the size of ring k - 1's and a hole where ring k - 1 is.

	private GpuBuffer<WaterVertex> m_RingVertices;
	private GpuBuffer<uint> m_RingIndices;
	private int m_RingIndexCount;
	private int m_RingMeshCells;
	private int m_RingCount;

	private GpuBuffer<Vector4> m_RingBuffer;
	private readonly Vector4[] m_RingData = new Vector4[MaxSeaRings];
	private RenderAttributes[] m_RingAttributes = Array.Empty<RenderAttributes>();

	private GpuBuffer<Vector4> m_SeaHoleBuffer;
	private int m_SeaHoleCapacity;
	private int m_SeaHoleCount;
	private Vector4 m_SeaHoleBounds;
	private GpuBuffer<int> m_SeaHoleGridBuffer;
	private int m_SeaHoleGridCapacity;
	private Vector2 m_SeaHoleGridSize = Vector2.One;
	private int m_SeaHoleRevision = -1;
	private int m_SeaHoleZoneRevision = -1;
	private float m_SeaHoleLevel = float.NaN;



	/// <summary>
	/// The rings round this frame's camera, the coast zone the sea is masked out over, and the holes the water near sea
	/// level cuts in it.
	/// </summary>
	private void UpdateSea()
	{
		if (!SeaEnabled)
		{
			m_RingCount = 0;
			return;
		}

		EnsureRingMesh();

		m_RingCount = Math.Clamp(SeaRings, 1, MaxSeaRings);

		Vector3 viewer = m_HasCullFrustum ? m_CameraPosition : WorldPosition;
		float half = SeaRingCells * 0.5f;

		for (int k = 0; k < m_RingCount; k++)
		{
			float cell = SeaCellSize * (1 << k);
			float snap = cell * 2.0f;

			m_RingData[k] = new Vector4(MathF.Round(viewer.x / snap) * snap, MathF.Round(viewer.y / snap) * snap, cell, half * cell);
		}

		if (!m_RingBuffer.IsValid())
			m_RingBuffer = new GpuBuffer<Vector4>(MaxSeaRings, GpuBuffer.UsageFlags.Structured);

		m_RingBuffer.SetData(m_RingData.AsSpan(0, MaxSeaRings));

		UploadCoastZone();
		UpdateSeaHoles();
	}



	/// <summary>
	/// The grid every ring is drawn from: its vertices at whole-cell steps from its middle.
	/// </summary>
	private void EnsureRingMesh()
	{
		int cells = SeaRingCells;

		if (m_RingVertices.IsValid() && m_RingMeshCells == cells)
			return;

		m_RingVertices?.Dispose();
		m_RingIndices?.Dispose();

		int side = cells + 1;
		var vertices = new WaterVertex[side * side];

		for (int y = 0; y < side; y++)
		{
			for (int x = 0; x < side; x++)
			{
				vertices[y * side + x] = new WaterVertex
				{
					Position = new Vector3(x - cells * 0.5f, y - cells * 0.5f, 0.0f),
					Normal = Vector3.Up,
					Tangent = new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
					TexCoord = Vector2.Zero,
					Color = new Color(0.0f, 0.0f, 1.0f, 0.0f)
				};
			}
		}

		// Two triangles a cell, always split the same way: the coast zone and the river mouths rebuild the surface from
		// it, and the coast zone's cells are split the same way too.
		var indices = new uint[cells * cells * 6];
		int cursor = 0;

		for (int y = 0; y < cells; y++)
		{
			for (int x = 0; x < cells; x++)
			{
				uint i0 = (uint)(y * side + x);
				uint i1 = i0 + 1;
				uint i2 = i0 + (uint)side;
				uint i3 = i2 + 1;

				indices[cursor++] = i0;
				indices[cursor++] = i1;
				indices[cursor++] = i2;
				indices[cursor++] = i1;
				indices[cursor++] = i3;
				indices[cursor++] = i2;
			}
		}

		m_RingVertices = new GpuBuffer<WaterVertex>(vertices.Length, GpuBuffer.UsageFlags.Vertex);
		m_RingVertices.SetData(vertices);

		m_RingIndices = new GpuBuffer<uint>(indices.Length, GpuBuffer.UsageFlags.Index);
		m_RingIndices.SetData(indices);

		m_RingIndexCount = indices.Length;
		m_RingMeshCells = cells;
	}



	/// <summary>
	/// Every hole the pieces near sea level cut in the sea, packed for the shader: two rows per hole (its bounds, then
	/// whether it is a disk or a polygon, where its points start, how many there are, and a disk's radius), then every
	/// polygon's points; and a grid over them, so the sea only ever tests the holes near it. Holes inside the coast zone
	/// are left out: the sea is masked out there already.
	/// </summary>
	private void UpdateSeaHoles()
	{
		float level = WorldSeaLevel;

		if (m_SeaHoleRevision == PieceRevision && m_SeaHoleZoneRevision == m_CoastZoneRevision && m_SeaHoleLevel == level)
			return;

		m_SeaHoleRevision = PieceRevision;
		m_SeaHoleZoneRevision = m_CoastZoneRevision;
		m_SeaHoleLevel = level;

		var holes = new List<SeaHole>();
		int points = 0;

		foreach (var piece in m_Pieces.Values)
		{
			foreach (var hole in piece.Mesh.SeaHoles)
			{
				if (hole.Lowest > level + SeaHoleMargin)
					continue;

				if (m_CoastZone is { } zone && zone.IsMasked(hole.Min, m_MouthTiles) && zone.IsMasked(hole.Max, m_MouthTiles)
					&& zone.IsMasked(new Vector2(hole.Min.x, hole.Max.y), m_MouthTiles) && zone.IsMasked(new Vector2(hole.Max.x, hole.Min.y), m_MouthTiles))
					continue;

				holes.Add(hole);
				points += hole.Points?.Length ?? 0;
			}
		}

		m_SeaHoleCount = holes.Count;

		if (holes.Count == 0)
		{
			m_SeaHoleBounds = Vector4.Zero;
			return;
		}

		int rows = holes.Count * 2 + points;

		if (!m_SeaHoleBuffer.IsValid() || m_SeaHoleCapacity < rows)
		{
			m_SeaHoleBuffer?.Dispose();
			m_SeaHoleCapacity = Math.Max(rows, m_SeaHoleCapacity * 2);
			m_SeaHoleBuffer = new GpuBuffer<Vector4>(m_SeaHoleCapacity, GpuBuffer.UsageFlags.Structured);
		}

		var data = new Vector4[rows];
		int cursor = holes.Count * 2;
		var min = new Vector2(float.MaxValue, float.MaxValue);
		var max = new Vector2(float.MinValue, float.MinValue);

		for (int i = 0; i < holes.Count; i++)
		{
			var hole = holes[i];

			data[i * 2] = new Vector4(hole.Min.x, hole.Min.y, hole.Max.x, hole.Max.y);

			min = new Vector2(MathF.Min(min.x, hole.Min.x), MathF.Min(min.y, hole.Min.y));
			max = new Vector2(MathF.Max(max.x, hole.Max.x), MathF.Max(max.y, hole.Max.y));

			if (hole.Points is null)
			{
				data[i * 2 + 1] = new Vector4(0.0f, 0.0f, 0.0f, hole.Radius);
				continue;
			}

			data[i * 2 + 1] = new Vector4(1.0f, cursor, hole.Points.Length, 0.0f);

			foreach (var point in hole.Points)
				data[cursor++] = new Vector4(point.x, point.y, 0.0f, 0.0f);
		}

		m_SeaHoleBuffer.SetData(data.AsSpan());
		m_SeaHoleBounds = new Vector4(min.x, min.y, max.x, max.y);

		UpdateSeaHoleGrid(holes, min, max);
	}



	/// <summary>
	/// The grid over the sea holes: per cell where its list of holes starts and how many it has, then the lists one after
	/// another, each hole listed in every cell its bounds reach into. A pixel of the sea tests only its own cell's holes,
	/// however many a river near sea level cuts. Cells are about the size of a hole (the middle one, so a single big lake
	/// doesn't make them all coarse), at most <see cref="MaxSeaHoleGrid"/> along either side.
	/// </summary>
	private void UpdateSeaHoleGrid(List<SeaHole> _Holes, Vector2 _Min, Vector2 _Max)
	{
		var sizes = new float[_Holes.Count];

		for (int i = 0; i < _Holes.Count; i++)
			sizes[i] = MathF.Max(_Holes[i].Max.x - _Holes[i].Min.x, _Holes[i].Max.y - _Holes[i].Min.y);

		Array.Sort(sizes);

		// The same extent the shader divides by.
		float width = MathF.Max(_Max.x - _Min.x, 0.001f);
		float height = MathF.Max(_Max.y - _Min.y, 0.001f);
		float cell = MathF.Max(MathF.Max(sizes[sizes.Length / 2], 1.0f), MathF.Max(width, height) / MaxSeaHoleGrid);
		int columns = Math.Clamp((int)MathF.Ceiling(width / cell), 1, MaxSeaHoleGrid);
		int rows = Math.Clamp((int)MathF.Ceiling(height / cell), 1, MaxSeaHoleGrid);
		int cells = columns * rows;

		// The cells a hole reaches into, taken a hair wide, so the GPU's rounding never finds a pixel of it in a cell it
		// isn't listed in.
		void Range(SeaHole _Hole, out int _X0, out int _Y0, out int _X1, out int _Y1)
		{
			_X0 = Math.Clamp((int)MathF.Floor((_Hole.Min.x - _Min.x) / width * columns - 0.01f), 0, columns - 1);
			_Y0 = Math.Clamp((int)MathF.Floor((_Hole.Min.y - _Min.y) / height * rows - 0.01f), 0, rows - 1);
			_X1 = Math.Clamp((int)MathF.Floor((_Hole.Max.x - _Min.x) / width * columns + 0.01f), 0, columns - 1);
			_Y1 = Math.Clamp((int)MathF.Floor((_Hole.Max.y - _Min.y) / height * rows + 0.01f), 0, rows - 1);
		}

		var counts = new int[cells];

		foreach (var hole in _Holes)
		{
			Range(hole, out int x0, out int y0, out int x1, out int y1);

			for (int y = y0; y <= y1; y++)
			{
				for (int x = x0; x <= x1; x++)
					counts[y * columns + x]++;
			}
		}

		int listed = 0;

		foreach (int count in counts)
			listed += count;

		var grid = new int[cells * 2 + listed];
		int cursor = 0;

		for (int c = 0; c < cells; c++)
		{
			grid[c * 2] = cursor;
			cursor += counts[c];
		}

		for (int i = 0; i < _Holes.Count; i++)
		{
			Range(_Holes[i], out int x0, out int y0, out int x1, out int y1);

			for (int y = y0; y <= y1; y++)
			{
				for (int x = x0; x <= x1; x++)
				{
					int slot = (y * columns + x) * 2;
					grid[cells * 2 + grid[slot] + grid[slot + 1]++] = i;
				}
			}
		}

		if (!m_SeaHoleGridBuffer.IsValid() || m_SeaHoleGridCapacity < grid.Length)
		{
			m_SeaHoleGridBuffer?.Dispose();
			m_SeaHoleGridCapacity = Math.Max(grid.Length, m_SeaHoleGridCapacity * 2);
			m_SeaHoleGridBuffer = new GpuBuffer<int>(m_SeaHoleGridCapacity, GpuBuffer.UsageFlags.Structured);
		}

		m_SeaHoleGridBuffer.SetData(grid.AsSpan());
		m_SeaHoleGridSize = new Vector2(columns, rows);
	}



	/// <summary>
	/// What the shader needs to know about the sea, on every draw: the coast zone and the river mouths rebuild the sea's
	/// surface from it, the rings draw it.
	/// </summary>
	private void ApplySeaAttributes(RenderAttributes _Attributes)
	{
		ResolvedSeaProfile.ApplyTo(_Attributes, "Sea");

		_Attributes.Set("SeaScale", SeaWaves);
		_Attributes.Set("SeaLevel", WorldSeaLevel);
		_Attributes.Set("OceanRingCount", m_RingCount);
		_Attributes.Set("OceanGridCells", SeaRingCells);
		_Attributes.Set("SeaHoleCount", m_SeaHoleCount);
		_Attributes.Set("SeaHoleBounds", m_SeaHoleBounds);
		_Attributes.Set("SeaHoleGridSize", m_SeaHoleGridSize);

		if (m_RingBuffer.IsValid())
			_Attributes.Set("OceanRings", m_RingBuffer);

		if (m_SeaHoleBuffer.IsValid())
			_Attributes.Set("SeaHoles", m_SeaHoleBuffer);

		if (m_SeaHoleGridBuffer.IsValid())
			_Attributes.Set("SeaHoleGrid", m_SeaHoleGridBuffer);

		ApplyCoastAttributes(_Attributes);
	}



	/// <summary>
	/// The attributes each ring is drawn with: everything every piece gets, plus which ring it is.
	/// </summary>
	private void UpdateRingAttributes()
	{
		if (m_RingAttributes.Length < m_RingCount)
		{
			var grown = new RenderAttributes[m_RingCount];

			for (int i = 0; i < grown.Length; i++)
				grown[i] = i < m_RingAttributes.Length ? m_RingAttributes[i] : new RenderAttributes();

			m_RingAttributes = grown;
		}

		for (int k = 0; k < m_RingCount; k++)
		{
			var attributes = m_RingAttributes[k];

			ApplySharedAttributes(attributes);

			attributes.Set("OceanRing", true);
			attributes.Set("RingIndex", k);
		}
	}



	/// <summary>
	/// Records the rings. The finest is drawn first; each ring hides the part of itself the one inside it covers.
	/// </summary>
	private void DrawSea(CommandList _CommandList, Material _Material)
	{
		if (m_RingCount == 0 || !m_RingVertices.IsValid() || !m_RingIndices.IsValid())
			return;

		for (int k = 0; k < m_RingCount; k++)
			_CommandList.DrawIndexed(m_RingVertices, m_RingIndices, _Material, 0, m_RingIndexCount, m_RingAttributes[k]);
	}



	private void ReleaseSea()
	{
		m_RingVertices?.Dispose();
		m_RingVertices = null;

		m_RingIndices?.Dispose();
		m_RingIndices = null;

		m_RingBuffer?.Dispose();
		m_RingBuffer = null;

		m_SeaHoleBuffer?.Dispose();
		m_SeaHoleBuffer = null;
		m_SeaHoleCapacity = 0;
		m_SeaHoleCount = 0;

		m_SeaHoleGridBuffer?.Dispose();
		m_SeaHoleGridBuffer = null;
		m_SeaHoleGridCapacity = 0;
		m_SeaHoleRevision = -1;
		m_SeaHoleZoneRevision = -1;

		ReleaseCoast();

		m_RingCount = 0;
	}



	/// <summary>
	/// The sea's waves at a world point, at full strength (no ripples).
	/// </summary>
	private Vector3 SeaDisplacementAt(Vector2 _Point)
	{
		return WaterWaves.Displacement(_Point, ResolvedSeaProfile, Time.Now) * SeaWaves;
	}



	/// <summary>
	/// The open sea at a position: everywhere outside the coast zone (whose own surface carries its water), at sea level,
	/// down to its depth.
	/// </summary>
	private bool SampleSea(Vector3 _Position, out WaterSample _Sample)
	{
		_Sample = default;

		if (!SeaEnabled)
			return false;

		float level = WorldSeaLevel;

		if (_Position.z < level - SeaDepth || IsInCoastZone(_Position))
			return false;

		var point = new Vector2(_Position.x, _Position.y);
		Vector3 displacement = SeaDisplacementAt(point);
		displacement.z += ComputeRippleHeight(point);

		_Sample = new WaterSample(level, displacement, Vector3.Zero, SeaWaves, 0.0f, SeaDepth, 1.0f);

		return true;
	}
}