Part of the WaterNetwork class implementing the infinite sea surface and its interaction with coast and near-sea water pieces. It builds GPU meshes and buffers for concentric rings around the camera, packs and grids holes (areas cut out by nearby water pieces) for a shader, updates/material attributes, samples sea displacement and exposes editor properties.
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;
}
}