Component that extracts a model's hull triangles and bounding box for use as a water exclusion volume. It reads the model physics meshes or render mesh, triangulates convex hulls, stores local triangle list and AABB, and exposes a world-to-local matrix and editor gizmo drawing.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace RedSnail.WaterTool2;
/// <summary>
/// Cuts the water's surface away inside a model's hull, exactly, rather than inside a box: the inside of a boat stays
/// dry whatever its shape. Put it on the GameObject with the ModelRenderer. The hull's collision mesh is read once and
/// handed to the GPU as a list of triangles; only where it is in the world changes from frame to frame.
/// </summary>
[Title("Hull Water Exclusion Volume")]
[Category("Water Tool 2")]
[Icon("sailing")]
public sealed class HullWaterExclusionVolume : Component, Component.ExecuteInEditor
{
/// <summary>
/// The hull's triangles in model space, three points each.
/// </summary>
public Vector3[] LocalTriangles { get; private set; } = Array.Empty<Vector3>();
/// <summary>
/// The box round all of them, which lets the GPU skip the hull quickly.
/// </summary>
public BBox LocalAABB { get; private set; }
/// <summary>
/// The model whose hull is used; the ModelRenderer's when empty.
/// </summary>
[Property] public Model CustomModel { get; set; }
private Model m_LastModel;
protected override void OnEnabled()
{
RebuildMesh();
}
protected override void OnUpdate()
{
if (ResolveModel() != m_LastModel)
RebuildMesh();
}
private Model ResolveModel()
{
return CustomModel.IsValid() ? CustomModel : GetComponent<ModelRenderer>()?.Model;
}
private void RebuildMesh()
{
var model = ResolveModel();
m_LastModel = model;
if (model is null)
{
LocalTriangles = Array.Empty<Vector3>();
LocalAABB = default;
return;
}
var triangles = new List<Vector3>();
var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var max = new Vector3(float.MinValue, float.MinValue, float.MinValue);
// The collision mesh first: already simplified, and closed.
var physics = model.Physics;
if (physics is not null)
{
foreach (var part in physics.Parts)
{
foreach (var mesh in part.Meshes)
{
foreach (var triangle in mesh.GetTriangles())
AddTriangle(triangles, triangle.A, triangle.B, triangle.C, ref min, ref max);
}
// Convex hulls carry no triangles of their own.
foreach (var hull in part.Hulls)
{
var points = hull.GetPoints()?.ToArray();
if (points is not null && points.Length >= 4)
TriangulateConvexHull(points, triangles, ref min, ref max);
}
}
}
// Failing that, the render mesh.
if (triangles.Count == 0)
{
var vertices = model.GetVertices();
var indices = model.GetIndices();
if (vertices is not null && indices is not null)
{
for (int i = 0; i + 2 < indices.Length; i += 3)
AddTriangle(triangles, vertices[indices[i]].Position, vertices[indices[i + 1]].Position, vertices[indices[i + 2]].Position, ref min, ref max);
}
}
LocalTriangles = triangles.ToArray();
LocalAABB = triangles.Count > 0 ? new BBox(min, max) : default;
}
private static void AddTriangle(List<Vector3> _Triangles, Vector3 _A, Vector3 _B, Vector3 _C, ref Vector3 _Min, ref Vector3 _Max)
{
_Triangles.Add(_A);
_Triangles.Add(_B);
_Triangles.Add(_C);
_Min = Vector3.Min(_Min, Vector3.Min(_A, Vector3.Min(_B, _C)));
_Max = Vector3.Max(_Max, Vector3.Max(_A, Vector3.Max(_B, _C)));
}
/// <summary>
/// Every face of a convex hull, once: all the points lying on a face are gathered and the face is fanned from its
/// middle. Emitting a triangle per triple of coplanar points instead would overlap them, and the inside test (an odd
/// number of crossings) would then say outside where it is inside.
/// </summary>
private static void TriangulateConvexHull(Vector3[] _Points, List<Vector3> _Result, ref Vector3 _Min, ref Vector3 _Max)
{
int count = _Points.Length;
var centroid = Vector3.Zero;
foreach (var point in _Points)
centroid += point;
centroid /= count;
var faces = new HashSet<string>();
for (int i = 0; i < count; i++)
{
for (int j = i + 1; j < count; j++)
{
for (int k = j + 1; k < count; k++)
{
Vector3 a = _Points[i];
Vector3 raw = Vector3.Cross(_Points[j] - a, _Points[k] - a);
if (raw.LengthSquared < 1e-8f)
continue;
// Normalised, so the coplanar test below is a distance in units.
Vector3 normal = raw.Normal;
bool positive = false;
bool negative = false;
var face = new List<int> { i, j, k };
for (int m = 0; m < count; m++)
{
if (m == i || m == j || m == k)
continue;
float distance = Vector3.Dot(normal, _Points[m] - a);
if (MathF.Abs(distance) < 0.01f)
face.Add(m);
else if (distance > 0.0f)
positive = true;
else
negative = true;
}
if (positive == negative)
continue;
face.Sort();
if (!faces.Add(string.Join(",", face)))
continue;
var facePoints = face.Select(index => _Points[index]).ToList();
var middle = Vector3.Zero;
foreach (var point in facePoints)
middle += point;
middle /= facePoints.Count;
var outward = Vector3.Dot(normal, centroid - a) < 0.0f ? normal : -normal;
var tangent = facePoints.Select(point => point - middle).FirstOrDefault(offset => offset.LengthSquared > 1e-8f).Normal;
var bitangent = Vector3.Cross(outward, tangent);
facePoints.Sort((p, q) =>
{
float angleP = MathF.Atan2(Vector3.Dot(p - middle, bitangent), Vector3.Dot(p - middle, tangent));
float angleQ = MathF.Atan2(Vector3.Dot(q - middle, bitangent), Vector3.Dot(q - middle, tangent));
return angleP.CompareTo(angleQ);
});
for (int t = 1; t < facePoints.Count - 1; t++)
AddTriangle(_Result, facePoints[0], facePoints[t], facePoints[t + 1], ref _Min, ref _Max);
}
}
}
}
/// <summary>
/// The four rows of the world-to-model matrix, row-major, for mul(M, float4(position, 1)) in HLSL. Matches
/// WorldTransform.PointToLocal (forward is model X, left model Y, up model Z).
/// </summary>
public void GetWorldToLocalRows(out Vector4 _R0, out Vector4 _R1, out Vector4 _R2, out Vector4 _R3)
{
Vector3 forward = WorldRotation.Forward;
Vector3 left = -WorldRotation.Right;
Vector3 up = WorldRotation.Up;
Vector3 position = WorldPosition;
Vector3 scale = WorldScale;
float sx = MathF.Abs(scale.x) > 1e-6f ? 1.0f / scale.x : 0.0f;
float sy = MathF.Abs(scale.y) > 1e-6f ? 1.0f / scale.y : 0.0f;
float sz = MathF.Abs(scale.z) > 1e-6f ? 1.0f / scale.z : 0.0f;
_R0 = new Vector4(forward.x * sx, forward.y * sx, forward.z * sx, -Vector3.Dot(forward, position) * sx);
_R1 = new Vector4(left.x * sy, left.y * sy, left.z * sy, -Vector3.Dot(left, position) * sy);
_R2 = new Vector4(up.x * sz, up.y * sz, up.z * sz, -Vector3.Dot(up, position) * sz);
_R3 = new Vector4(0.0f, 0.0f, 0.0f, 1.0f);
}
protected override void DrawGizmos()
{
if (!Gizmo.IsSelected || LocalTriangles.Length == 0)
return;
Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.5f);
Gizmo.Draw.LineBBox(LocalAABB);
}
}