CPU-side representation of a water mesh and related helpers. Defines SurfacePoint struct, SeaHole for cutting sea surface polygons/disks, and WaterMeshData which stores vertex data, builds spatial buckets, computes outline sea holes, and samples interpolated surface state at a world point.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.WaterTool2;
/// <summary>
/// The water at a point of a piece, at rest: the surface's height and the water's state there.
/// </summary>
internal struct SurfacePoint
{
public float Height;
public Vector2 Velocity;
public float Waves;
public float Foam;
/// <summary>
/// How far the surface has handed over to the sea: 0 is the piece's own water, 1 is the sea's surface exactly.
/// </summary>
public float SeaBlend;
}
/// <summary>
/// A patch of the sea's surface a piece of water covers wherever it lies near sea level: the sea is cut away inside
/// it, so the two are never drawn over each other. A polygon in plan, or a disk.
/// </summary>
internal sealed class SeaHole
{
/// <summary>
/// The polygon, or null for a disk.
/// </summary>
public Vector2[] Points;
public Vector2 Centre;
public float Radius;
/// <summary>
/// The lowest water surface the hole covers: only holes near sea level cut the sea.
/// </summary>
public float Lowest;
public Vector2 Min;
public Vector2 Max;
public static SeaHole Polygon(Vector2[] _Points, float _Lowest)
{
var hole = new SeaHole { Points = _Points, Lowest = _Lowest };
hole.UpdateBounds();
return hole;
}
public static SeaHole Disk(Vector2 _Centre, float _Radius, float _Lowest)
{
var hole = new SeaHole { Centre = _Centre, Radius = _Radius, Lowest = _Lowest };
hole.UpdateBounds();
return hole;
}
public void UpdateBounds()
{
if (Points is null)
{
Min = Centre - new Vector2(Radius, Radius);
Max = Centre + new Vector2(Radius, Radius);
return;
}
Min = new Vector2(float.MaxValue, float.MaxValue);
Max = new Vector2(float.MinValue, float.MinValue);
foreach (var point in Points)
{
Min = new Vector2(MathF.Min(Min.x, point.x), MathF.Min(Min.y, point.y));
Max = new Vector2(MathF.Max(Max.x, point.x), MathF.Max(Max.y, point.y));
}
}
/// <summary>
/// Moves the hole from network space into the world (its height too).
/// </summary>
public void Transform(in Transform _Transform)
{
if (Points is null)
{
Vector3 centre = _Transform.PointToWorld(new Vector3(Centre.x, Centre.y, Lowest));
Centre = new Vector2(centre.x, centre.y);
Lowest = centre.z;
}
else
{
for (int i = 0; i < Points.Length; i++)
{
Vector3 point = _Transform.PointToWorld(new Vector3(Points[i].x, Points[i].y, 0.0f));
Points[i] = new Vector2(point.x, point.y);
}
Lowest = _Transform.PointToWorld(new Vector3(0.0f, 0.0f, Lowest)).z;
}
UpdateBounds();
}
}
/// <summary>
/// One built piece of water surface on the CPU, in world space and at rest: where every vertex is, and the state of
/// the water there (its flow, wave strength, foam and how far it has handed over to the sea). The GPU draws it;
/// gameplay asks it where the surface is under a point, with the same interpolation the GPU does between the same
/// vertices.
/// </summary>
internal sealed class WaterMeshData
{
public Vector3[] Positions;
public Vector2[] Velocities;
public float[] Waves;
public float[] Foams;
public float[] SeaBlend;
public int[] Indices;
/// <summary>
/// Where the sea is cut away under this piece, when it lies near sea level.
/// </summary>
public readonly List<SeaHole> SeaHoles = new();
/// <summary>
/// The surface's world bounds, at rest.
/// </summary>
public BBox Bounds;
public int VertexCount => Positions?.Length ?? 0;
public int TriangleCount => (Indices?.Length ?? 0) / 3;
private float m_BucketSize;
private float m_MinX;
private float m_MinY;
private int m_Columns;
private int m_Rows;
private int[] m_BucketStarts;
private int[] m_BucketTriangles;
public WaterMeshData(int _VertexCount)
{
Positions = new Vector3[_VertexCount];
Velocities = new Vector2[_VertexCount];
Waves = new float[_VertexCount];
Foams = new float[_VertexCount];
SeaBlend = new float[_VertexCount];
}
/// <summary>
/// Moves the piece from network space into the world: positions through the whole transform, the flow turned with
/// it, the sea holes with them. Then works out its bounds and its lookup grid; with <paramref name="_OutlineHoles"/>,
/// the piece's own outline becomes its sea hole.
/// </summary>
public void Finish(in Transform _Transform, IList<int> _Indices, bool _OutlineHoles = false)
{
Indices = new int[_Indices.Count];
_Indices.CopyTo(Indices, 0);
Rotation rotation = _Transform.Rotation;
for (int i = 0; i < Positions.Length; i++)
{
Positions[i] = _Transform.PointToWorld(Positions[i]);
Vector3 flow = rotation * new Vector3(Velocities[i].x, Velocities[i].y, 0.0f);
Velocities[i] = new Vector2(flow.x, flow.y);
}
foreach (var hole in SeaHoles)
hole.Transform(_Transform);
if (Positions.Length > 0)
{
var bounds = new BBox(Positions[0], Positions[0]);
foreach (var position in Positions)
bounds = bounds.AddPoint(position);
Bounds = bounds;
}
if (_OutlineHoles)
AddOutlineHoles();
BuildLookup();
}
/// <summary>
/// Every loop of edges only one triangle uses (the piece's outline) as a sea hole.
/// </summary>
private void AddOutlineHoles()
{
var used = new Dictionary<long, int>();
for (int t = 0; t + 2 < Indices.Length; t += 3)
{
for (int k = 0; k < 3; k++)
{
int a = Indices[t + k];
int b = Indices[t + (k + 1) % 3];
long key = a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a;
used[key] = used.GetValueOrDefault(key) + 1;
}
}
// The outline's edges, each from the vertex it leaves to the one it reaches.
var next = new Dictionary<int, int>();
for (int t = 0; t + 2 < Indices.Length; t += 3)
{
for (int k = 0; k < 3; k++)
{
int a = Indices[t + k];
int b = Indices[t + (k + 1) % 3];
long key = a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a;
if (used[key] == 1)
next[a] = b;
}
}
var visited = new HashSet<int>();
foreach (int start in next.Keys)
{
if (visited.Contains(start))
continue;
var loop = new List<Vector2>();
float lowest = float.MaxValue;
int current = start;
while (visited.Add(current) && next.TryGetValue(current, out int following))
{
loop.Add(new Vector2(Positions[current].x, Positions[current].y));
lowest = MathF.Min(lowest, Positions[current].z);
current = following;
}
if (loop.Count >= 3)
SeaHoles.Add(SeaHole.Polygon(loop.ToArray(), lowest));
}
}
/// <summary>
/// Buckets the triangles on a grid over the piece, so a point is found among a handful of them.
/// </summary>
private void BuildLookup()
{
int triangles = TriangleCount;
if (triangles == 0)
return;
float width = MathF.Max(1.0f, Bounds.Size.x);
float height = MathF.Max(1.0f, Bounds.Size.y);
// Roughly a couple of triangles per bucket.
m_BucketSize = MathF.Max(8.0f, MathF.Sqrt(width * height / MathF.Max(1, triangles)) * 1.5f);
m_MinX = Bounds.Mins.x;
m_MinY = Bounds.Mins.y;
m_Columns = Math.Clamp((int)MathF.Ceiling(width / m_BucketSize), 1, 4096);
m_Rows = Math.Clamp((int)MathF.Ceiling(height / m_BucketSize), 1, 4096);
var counts = new int[m_Columns * m_Rows + 1];
for (int pass = 0; pass < 2; pass++)
{
for (int t = 0; t < triangles; t++)
{
TriangleBuckets(t, 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 bucket = y * m_Columns + x;
if (pass == 0)
counts[bucket + 1]++;
else
m_BucketTriangles[m_BucketStarts[bucket] + counts[bucket]++] = t;
}
}
}
if (pass == 0)
{
for (int i = 1; i < counts.Length; i++)
counts[i] += counts[i - 1];
m_BucketStarts = counts;
m_BucketTriangles = new int[counts[^1]];
counts = new int[m_Columns * m_Rows + 1];
}
}
}
private void TriangleBuckets(int _Triangle, out int _X0, out int _Y0, out int _X1, out int _Y1)
{
Vector3 a = Positions[Indices[_Triangle * 3]];
Vector3 b = Positions[Indices[_Triangle * 3 + 1]];
Vector3 c = Positions[Indices[_Triangle * 3 + 2]];
_X0 = Bucket(MathF.Min(a.x, MathF.Min(b.x, c.x)) - m_MinX, m_Columns);
_X1 = Bucket(MathF.Max(a.x, MathF.Max(b.x, c.x)) - m_MinX, m_Columns);
_Y0 = Bucket(MathF.Min(a.y, MathF.Min(b.y, c.y)) - m_MinY, m_Rows);
_Y1 = Bucket(MathF.Max(a.y, MathF.Max(b.y, c.y)) - m_MinY, m_Rows);
}
private int Bucket(float _Offset, int _Count)
{
return Math.Clamp((int)MathF.Floor(_Offset / m_BucketSize), 0, _Count - 1);
}
/// <summary>
/// The surface at rest over a world point, and the water's state there, interpolated across the triangle the point
/// falls in exactly as the GPU interpolates it. False when the point is not over this piece.
/// </summary>
public bool TrySample(Vector2 _Point, out SurfacePoint _Surface)
{
_Surface = default;
if (m_BucketStarts is null)
return false;
if (_Point.x < Bounds.Mins.x || _Point.x > Bounds.Maxs.x || _Point.y < Bounds.Mins.y || _Point.y > Bounds.Maxs.y)
return false;
int bucket = Bucket(_Point.y - m_MinY, m_Rows) * m_Columns + Bucket(_Point.x - m_MinX, m_Columns);
for (int i = m_BucketStarts[bucket]; i < m_BucketStarts[bucket + 1]; i++)
{
int t = m_BucketTriangles[i];
int ia = Indices[t * 3];
int ib = Indices[t * 3 + 1];
int ic = Indices[t * 3 + 2];
Vector3 a = Positions[ia];
Vector3 b = Positions[ib];
Vector3 c = Positions[ic];
float area = Cross(b.x - a.x, b.y - a.y, c.x - a.x, c.y - a.y);
if (MathF.Abs(area) < 1e-6f)
continue;
float wa = Cross(b.x - _Point.x, b.y - _Point.y, c.x - _Point.x, c.y - _Point.y) / area;
float wb = Cross(c.x - _Point.x, c.y - _Point.y, a.x - _Point.x, a.y - _Point.y) / area;
float wc = 1.0f - wa - wb;
const float tolerance = -1e-4f;
if (wa < tolerance || wb < tolerance || wc < tolerance)
continue;
_Surface = new SurfacePoint
{
Height = a.z * wa + b.z * wb + c.z * wc,
Velocity = Velocities[ia] * wa + Velocities[ib] * wb + Velocities[ic] * wc,
Waves = Waves[ia] * wa + Waves[ib] * wb + Waves[ic] * wc,
Foam = Foams[ia] * wa + Foams[ib] * wb + Foams[ic] * wc,
SeaBlend = SeaBlend[ia] * wa + SeaBlend[ib] * wb + SeaBlend[ic] * wc
};
return true;
}
return false;
}
private static float Cross(float _Ax, float _Ay, float _Bx, float _By)
{
return _Ax * _By - _Ay * _Bx;
}
}