Code/Gameplay/HullWaterExclusionVolume.cs

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.

File Access
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);
	}
}