CoastZone is a utility that constructs and represents a rectangular tiled "coast zone" around coastline polylines. It classifies tiles as Sea, Coast or Land, computes mask values, chunk lists, outlines, and builds per-chunk water meshes using a marching-squares mesher that produces WaterMeshData for coast tiles.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.WaterTool2;
/// <summary>
/// The coast zone: the water round the land, out to a distance from the coast, as a surface of its own (a lake turned
/// inside out, the land its holes). The infinite sea is masked out over it and over the land, and takes over past it.
/// <para>
/// It is laid on the very grid the sea's finest ring is drawn on, and every cell is split the same way, so its triangles
/// subdivide every ring's; its vertices take the sea's surface exactly as the rings draw it, so it is the sea's own
/// surface point for point and the two meet without a seam anywhere. Square tiles of that grid say what is where: the
/// open sea's (drawn by the rings), the coast zone's, or land's (nothing).
/// </para>
/// </summary>
internal sealed class CoastZone
{
/// <summary>
/// Grid cells along a tile's side: the zone is made of whole tiles.
/// </summary>
public const int TileCells = 16;
/// <summary>
/// Tiles along a chunk's side: the zone is drawn a chunk at a time.
/// </summary>
public const int ChunkTiles = 4;
public const int ChunkCells = TileCells * ChunkTiles;
/// <summary>
/// Past this many tiles the zone is refused: the distance asked for is far too big for a surface of its own.
/// </summary>
private const int MaxTiles = 1 << 22;
public enum TileKind : byte
{
/// <summary>
/// The open sea: the infinite sea's own.
/// </summary>
Sea,
/// <summary>
/// Near the coast: the zone's surface covers its water.
/// </summary>
Coast,
/// <summary>
/// Inland: no water at all.
/// </summary>
Land
}
/// <summary>
/// The grid's spacing: the sea's finest ring's cell size.
/// </summary>
public float Cell { get; }
public float Tile => Cell * TileCells;
/// <summary>
/// The tile grid: the index of its first tile (tiles are counted from the world's origin) and how many tiles across.
/// </summary>
public int FirstTileX { get; }
public int FirstTileY { get; }
public int Width { get; }
public int Height { get; }
/// <summary>
/// The sea's level in the world.
/// </summary>
public float SeaLevel { get; }
private readonly TileKind[] m_Tiles;
private readonly List<(Vector2 A, Vector2 B)> m_Segments;
private readonly Dictionary<int, float[]> m_Rows;
private HashSet<(int X, int Y)> m_CoastChunks;
private CoastZone(float _Cell, int _FirstTileX, int _FirstTileY, int _Width, int _Height, float _SeaLevel, List<(Vector2 A, Vector2 B)> _Segments, Dictionary<int, float[]> _Rows)
{
Cell = _Cell;
FirstTileX = _FirstTileX;
FirstTileY = _FirstTileY;
Width = _Width;
Height = _Height;
SeaLevel = _SeaLevel;
m_Tiles = new TileKind[_Width * _Height];
m_Segments = _Segments;
m_Rows = _Rows;
}
/// <summary>
/// The zone round a set of coastlines (closed loops round land, in plan, in the world): every tile within
/// <paramref name="_Distance"/> of a coast is the zone's, every other tile on land is land. The blocks river mouths
/// are built in are not part of it: they are laid over it (see <see cref="IsMasked"/>), so a mouth moving never
/// rebuilds the zone. Null when there is no coastline, or the zone would be far too big.
/// </summary>
public static CoastZone Build(IReadOnlyList<Vector2[]> _Coasts, float _Cell, float _Distance, float _SeaLevel)
{
var segments = new List<(Vector2 A, Vector2 B)>();
var min = new Vector2(float.MaxValue, float.MaxValue);
var max = new Vector2(float.MinValue, float.MinValue);
foreach (var loop in _Coasts)
{
if (loop is null || loop.Length < 3)
continue;
for (int e = 0; e < loop.Length; e++)
{
Vector2 a = loop[e];
segments.Add((a, loop[(e + 1) % loop.Length]));
min = new Vector2(MathF.Min(min.x, a.x), MathF.Min(min.y, a.y));
max = new Vector2(MathF.Max(max.x, a.x), MathF.Max(max.y, a.y));
}
}
if (segments.Count == 0)
return null;
float cell = MathF.Max(1.0f, _Cell);
float tile = cell * TileCells;
float reach = MathF.Max(0.0f, _Distance);
int firstX = (int)MathF.Floor((min.x - reach) / tile) - 1;
int firstY = (int)MathF.Floor((min.y - reach) / tile) - 1;
int lastX = (int)MathF.Floor((max.x + reach) / tile) + 1;
int lastY = (int)MathF.Floor((max.y + reach) / tile) + 1;
long tiles = (long)(lastX - firstX + 1) * (lastY - firstY + 1);
if (tiles > MaxTiles)
return null;
// Where every grid row crosses the coastlines, for "which side of the coast is this point on".
var crossings = new Dictionary<int, List<float>>();
foreach (var (a, b) in segments)
{
if (a.y == b.y)
continue;
// The rows in [low, high): a row through a corner counts it once.
float low = MathF.Min(a.y, b.y);
float high = MathF.Max(a.y, b.y);
int first = (int)MathF.Ceiling(low / cell);
int last = (int)MathF.Ceiling(high / cell) - 1;
for (int j = first; j <= last; j++)
{
float y = j * cell;
if (!crossings.TryGetValue(j, out var row))
{
row = new List<float>();
crossings[j] = row;
}
row.Add(a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y));
}
}
var rows = new Dictionary<int, float[]>(crossings.Count);
foreach (var (j, row) in crossings)
{
row.Sort();
rows[j] = row.ToArray();
}
var zone = new CoastZone(cell, firstX, firstY, lastX - firstX + 1, lastY - firstY + 1, _SeaLevel, segments, rows);
// Near the coast: every tile within the distance of an edge of it.
foreach (var (a, b) in segments)
{
int x0 = (int)MathF.Floor((MathF.Min(a.x, b.x) - reach) / tile);
int x1 = (int)MathF.Floor((MathF.Max(a.x, b.x) + reach) / tile);
int y0 = (int)MathF.Floor((MathF.Min(a.y, b.y) - reach) / tile);
int y1 = (int)MathF.Floor((MathF.Max(a.y, b.y) + reach) / tile);
for (int y = y0; y <= y1; y++)
{
for (int x = x0; x <= x1; x++)
zone.SetTile(x, y, TileKind.Coast);
}
}
// Further inland, land: a tile is wholly on one side of the coast there, so its middle says which.
for (int y = 0; y < zone.Height; y++)
{
for (int x = 0; x < zone.Width; x++)
{
int tileX = zone.FirstTileX + x;
int tileY = zone.FirstTileY + y;
if (zone.TileAt(tileX, tileY) == TileKind.Sea && zone.IsLand(tileX * TileCells + TileCells / 2, tileY * TileCells + TileCells / 2))
zone.SetTile(tileX, tileY, TileKind.Land);
}
}
return zone;
}
public TileKind TileAt(int _X, int _Y)
{
int x = _X - FirstTileX;
int y = _Y - FirstTileY;
if (x < 0 || y < 0 || x >= Width || y >= Height)
return TileKind.Sea;
return m_Tiles[y * Width + x];
}
private void SetTile(int _X, int _Y, TileKind _Kind)
{
int x = _X - FirstTileX;
int y = _Y - FirstTileY;
if (x < 0 || y < 0 || x >= Width || y >= Height)
return;
m_Tiles[y * Width + x] = _Kind;
}
/// <summary>
/// The tile a world point (in plan) is in.
/// </summary>
public (int X, int Y) TileOf(Vector2 _World)
{
return ((int)MathF.Floor(_World.x / Tile), (int)MathF.Floor(_World.y / Tile));
}
/// <summary>
/// Whether the infinite sea is masked out at a world point (in plan): the coast zone's surface is there instead, or
/// land, or a river mouth (<paramref name="_Mouths"/>, the tiles their blocks take). Mirrors InsideCoastZone in the
/// water shader.
/// </summary>
public bool IsMasked(Vector2 _World, IReadOnlySet<(int X, int Y)> _Mouths)
{
var tile = TileOf(_World);
return TileAt(tile.X, tile.Y) != TileKind.Sea || _Mouths.Contains(tile);
}
/// <summary>
/// The mask the shader reads: one value per tile, row after row, 1 where the infinite sea is masked out (the zone,
/// the land, and the river mouths' blocks laid over them), over the zone's tiles grown to take in every mouth.
/// </summary>
public float[] MaskValues(IReadOnlySet<(int X, int Y)> _Mouths, out int _FirstX, out int _FirstY, out int _Width, out int _Height)
{
int firstX = FirstTileX;
int firstY = FirstTileY;
int lastX = FirstTileX + Width - 1;
int lastY = FirstTileY + Height - 1;
foreach (var (x, y) in _Mouths)
{
firstX = Math.Min(firstX, x);
firstY = Math.Min(firstY, y);
lastX = Math.Max(lastX, x);
lastY = Math.Max(lastY, y);
}
_FirstX = firstX;
_FirstY = firstY;
_Width = lastX - firstX + 1;
_Height = lastY - firstY + 1;
var values = new float[_Width * _Height];
for (int y = 0; y < _Height; y++)
{
for (int x = 0; x < _Width; x++)
{
var tile = (firstX + x, firstY + y);
if (TileAt(tile.Item1, tile.Item2) != TileKind.Sea || _Mouths.Contains(tile))
values[y * _Width + x] = 1.0f;
}
}
return values;
}
/// <summary>
/// The chunks that have some of the zone's water to draw. Worked out once: the zone never changes.
/// </summary>
public IReadOnlyCollection<(int X, int Y)> CoastChunks()
{
if (m_CoastChunks is not null)
return m_CoastChunks;
m_CoastChunks = new HashSet<(int X, int Y)>();
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
if (m_Tiles[y * Width + x] == TileKind.Coast)
m_CoastChunks.Add((FloorDiv(FirstTileX + x, ChunkTiles), FloorDiv(FirstTileY + y, ChunkTiles)));
}
}
return m_CoastChunks;
}
/// <summary>
/// What a chunk is made of, for its signature: the kind of each of its tiles, and which are left to river mouths.
/// </summary>
public void AddChunkToHash(ref StableHash _Hash, int _ChunkX, int _ChunkY, IReadOnlySet<(int X, int Y)> _Excluded)
{
for (int ty = 0; ty < ChunkTiles; ty++)
{
for (int tx = 0; tx < ChunkTiles; tx++)
{
int x = _ChunkX * ChunkTiles + tx;
int y = _ChunkY * ChunkTiles + ty;
_Hash.Add((int)TileAt(x, y));
_Hash.Add(_Excluded.Contains((x, y)));
}
}
}
/// <summary>
/// The outline of the masked area (coast zone and land against the open sea), as edges in the world, in plan.
/// </summary>
public IEnumerable<(Vector2 A, Vector2 B)> MaskOutline(IReadOnlySet<(int X, int Y)> _Mouths)
{
float tile = Tile;
bool Masked(int _X, int _Y)
{
return TileAt(_X, _Y) != TileKind.Sea || _Mouths.Contains((_X, _Y));
}
int firstX = FirstTileX;
int firstY = FirstTileY;
int lastX = FirstTileX + Width - 1;
int lastY = FirstTileY + Height - 1;
foreach (var (x, y) in _Mouths)
{
firstX = Math.Min(firstX, x);
firstY = Math.Min(firstY, y);
lastX = Math.Max(lastX, x);
lastY = Math.Max(lastY, y);
}
for (int y = firstY; y <= lastY; y++)
{
for (int x = firstX; x <= lastX; x++)
{
if (!Masked(x, y))
continue;
if (!Masked(x - 1, y))
yield return (new Vector2(x * tile, y * tile), new Vector2(x * tile, (y + 1) * tile));
if (!Masked(x + 1, y))
yield return (new Vector2((x + 1) * tile, y * tile), new Vector2((x + 1) * tile, (y + 1) * tile));
if (!Masked(x, y - 1))
yield return (new Vector2(x * tile, y * tile), new Vector2((x + 1) * tile, y * tile));
if (!Masked(x, y + 1))
yield return (new Vector2(x * tile, (y + 1) * tile), new Vector2((x + 1) * tile, (y + 1) * tile));
}
}
}
/// <summary>
/// Whether a grid point (counted in cells from the world's origin) is on land: inside an odd number of coastlines.
/// </summary>
public bool IsLand(int _I, int _J)
{
if (!m_Rows.TryGetValue(_J, out var row))
return false;
float x = _I * Cell;
int low = 0;
int high = row.Length;
// How many crossings lie left of the point.
while (low < high)
{
int middle = (low + high) >> 1;
if (row[middle] < x)
low = middle + 1;
else
high = middle;
}
return (low & 1) == 1;
}
/// <summary>
/// The zone's surface over one chunk, in the world: the water of its coast tiles (all but those left to river
/// mouths), cell by cell. A cell at sea is the sea's two triangles, split as the rings split theirs; a cell the coast
/// crosses keeps its part at sea, cut along the coast. Null when the chunk has no water.
/// </summary>
public WaterMeshData BuildChunk(int _ChunkX, int _ChunkY, IReadOnlySet<(int X, int Y)> _Excluded)
{
const int side = ChunkCells + 1;
int i0 = _ChunkX * ChunkCells;
int j0 = _ChunkY * ChunkCells;
float clamp = Cell * 2.0f;
// Which side of the coast every grid point of the chunk is on (sea positive), and how far from it the points near
// it are, so the coast is cut where it really runs.
var field = new float[side * side];
for (int j = 0; j < side; j++)
{
for (int i = 0; i < side; i++)
field[j * side + i] = IsLand(i0 + i, j0 + j) ? -clamp : clamp;
}
float minX = i0 * Cell - clamp;
float minY = j0 * Cell - clamp;
float maxX = (i0 + ChunkCells) * Cell + clamp;
float maxY = (j0 + ChunkCells) * Cell + clamp;
foreach (var (a, b) in m_Segments)
{
if (MathF.Max(a.x, b.x) < minX || MathF.Min(a.x, b.x) > maxX || MathF.Max(a.y, b.y) < minY || MathF.Min(a.y, b.y) > maxY)
continue;
int x0 = Math.Max(0, (int)MathF.Floor((MathF.Min(a.x, b.x) - clamp) / Cell) - i0);
int x1 = Math.Min(side - 1, (int)MathF.Ceiling((MathF.Max(a.x, b.x) + clamp) / Cell) - i0);
int y0 = Math.Max(0, (int)MathF.Floor((MathF.Min(a.y, b.y) - clamp) / Cell) - j0);
int y1 = Math.Min(side - 1, (int)MathF.Ceiling((MathF.Max(a.y, b.y) + clamp) / Cell) - j0);
for (int y = y0; y <= y1; y++)
{
for (int x = x0; x <= x1; x++)
{
int index = y * side + x;
var point = new Vector2((i0 + x) * Cell, (j0 + y) * Cell);
float distance = MathF.Max(0.001f, DistanceToSegment(point, a, b));
if (distance < MathF.Abs(field[index]))
field[index] = field[index] < 0.0f ? -distance : distance;
}
}
}
var mesher = new ChunkMesher(this, field, side, i0, j0);
for (int ty = 0; ty < ChunkTiles; ty++)
{
for (int tx = 0; tx < ChunkTiles; tx++)
{
int tileX = _ChunkX * ChunkTiles + tx;
int tileY = _ChunkY * ChunkTiles + ty;
if (TileAt(tileX, tileY) != TileKind.Coast || _Excluded.Contains((tileX, tileY)))
continue;
for (int cj = 0; cj < TileCells; cj++)
{
for (int ci = 0; ci < TileCells; ci++)
mesher.Cell(tx * TileCells + ci, ty * TileCells + cj);
}
}
}
return mesher.Finish();
}
private static float DistanceToSegment(Vector2 _Point, Vector2 _A, Vector2 _B)
{
Vector2 span = _B - _A;
float lengthSquared = span.LengthSquared;
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(_Point - _A, span) / lengthSquared, 0.0f, 1.0f) : 0.0f;
return (_A + span * t).Distance(_Point);
}
private static int FloorDiv(int _A, int _B)
{
return _A >= 0 ? _A / _B : -((-_A + _B - 1) / _B);
}
/// <summary>
/// Marching squares over one chunk's grid, sharing every corner and every crossing between the cells that meet there.
/// </summary>
private sealed class ChunkMesher
{
private readonly CoastZone m_Zone;
private readonly float[] m_Field;
private readonly int m_Side;
private readonly int m_I0;
private readonly int m_J0;
private readonly List<Vector3> m_Positions = new();
private readonly List<int> m_Indices = new();
private readonly int[] m_Corners;
private readonly Dictionary<int, int> m_Crossings = new();
private readonly List<int> m_Polygon = new(8);
public ChunkMesher(CoastZone _Zone, float[] _Field, int _Side, int _I0, int _J0)
{
m_Zone = _Zone;
m_Field = _Field;
m_Side = _Side;
m_I0 = _I0;
m_J0 = _J0;
m_Corners = new int[_Side * _Side];
for (int i = 0; i < m_Corners.Length; i++)
m_Corners[i] = -1;
}
/// <summary>
/// One cell, its corner at grid point (i, j) of the chunk.
/// </summary>
public void Cell(int _I, int _J)
{
int c00 = _J * m_Side + _I;
int c10 = c00 + 1;
int c01 = c00 + m_Side;
int c11 = c01 + 1;
bool s00 = m_Field[c00] > 0.0f;
bool s10 = m_Field[c10] > 0.0f;
bool s11 = m_Field[c11] > 0.0f;
bool s01 = m_Field[c01] > 0.0f;
if (s00 && s10 && s11 && s01)
{
// Split from its (1, 0) corner to its (0, 1) corner, as the rings split their cells.
Triangle(Corner(c00), Corner(c10), Corner(c01));
Triangle(Corner(c10), Corner(c11), Corner(c01));
return;
}
if (!s00 && !s10 && !s11 && !s01)
return;
// Round the cell counter-clockwise: the corners at sea, and where the coast crosses each side.
Span4 corners = new(c00, c10, c11, c01);
Span4 sides = new(Horizontal(c00), Vertical(c10), Horizontal(c01), Vertical(c00));
bool saddle = s00 == s11 && s10 == s01 && s00 != s10;
// A saddle whose middle is land is two separate corners of sea.
if (saddle && m_Field[c00] + m_Field[c10] + m_Field[c11] + m_Field[c01] <= 0.0f)
{
for (int k = 0; k < 4; k++)
{
if (m_Field[corners[k]] <= 0.0f)
continue;
Triangle(Corner(corners[k]), Crossing(sides[k]), Crossing(sides[(k + 3) % 4]));
}
return;
}
m_Polygon.Clear();
for (int k = 0; k < 4; k++)
{
bool here = m_Field[corners[k]] > 0.0f;
bool next = m_Field[corners[(k + 1) % 4]] > 0.0f;
if (here)
m_Polygon.Add(Corner(corners[k]));
if (here != next)
m_Polygon.Add(Crossing(sides[k]));
}
// The part of a square on one side of a line, or a saddle joined through its middle: convex either way.
for (int k = 1; k + 1 < m_Polygon.Count; k++)
Triangle(m_Polygon[0], m_Polygon[k], m_Polygon[k + 1]);
}
private void Triangle(int _A, int _B, int _C)
{
m_Indices.Add(_A);
m_Indices.Add(_B);
m_Indices.Add(_C);
}
private int Corner(int _Point)
{
if (m_Corners[_Point] >= 0)
return m_Corners[_Point];
int i = _Point % m_Side;
int j = _Point / m_Side;
m_Corners[_Point] = m_Positions.Count;
m_Positions.Add(new Vector3((m_I0 + i) * m_Zone.Cell, (m_J0 + j) * m_Zone.Cell, m_Zone.SeaLevel));
return m_Corners[_Point];
}
/// <summary>
/// A side of a cell, as the grid edge from a point to the next one along (key even) or up (key odd).
/// </summary>
private static int Horizontal(int _Point)
{
return _Point * 2;
}
private static int Vertical(int _Point)
{
return _Point * 2 + 1;
}
/// <summary>
/// Where the coast crosses a grid edge, worked out from the edge's own two ends whichever cell asks, so cells on
/// either side of it (in this chunk or the next) share the very same point.
/// </summary>
private int Crossing(int _Edge)
{
if (m_Crossings.TryGetValue(_Edge, out int index))
return index;
int from = _Edge / 2;
int to = (_Edge & 1) == 1 ? from + m_Side : from + 1;
float a = m_Field[from];
float b = m_Field[to];
float t = Math.Clamp(a / (a - b), 0.001f, 0.999f);
Vector3 start = Position(from);
Vector3 end = Position(to);
index = m_Positions.Count;
m_Positions.Add(Vector3.Lerp(start, end, t));
m_Crossings[_Edge] = index;
return index;
}
private Vector3 Position(int _Point)
{
int i = _Point % m_Side;
int j = _Point / m_Side;
return new Vector3((m_I0 + i) * m_Zone.Cell, (m_J0 + j) * m_Zone.Cell, m_Zone.SeaLevel);
}
public WaterMeshData Finish()
{
if (m_Indices.Count == 0)
return null;
var mesh = new WaterMeshData(m_Positions.Count);
for (int i = 0; i < m_Positions.Count; i++)
{
mesh.Positions[i] = m_Positions[i];
mesh.SeaBlend[i] = 1.0f;
}
// Built in the world already.
mesh.Finish(new Transform(Vector3.Zero), m_Indices);
return mesh;
}
}
/// <summary>
/// Four ints, indexable, without an allocation per cell.
/// </summary>
private readonly struct Span4
{
private readonly int m_A;
private readonly int m_B;
private readonly int m_C;
private readonly int m_D;
public Span4(int _A, int _B, int _C, int _D)
{
m_A = _A;
m_B = _B;
m_C = _C;
m_D = _D;
}
public int this[int _Index] => _Index switch
{
0 => m_A,
1 => m_B,
2 => m_C,
_ => m_D
};
}
}