Code/Water/HullWaterExclusionVolume.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace RedSnail.WaterTool;
/// <summary>
/// Excludes the water surface inside a mesh hull rather than an approximated box volume.
/// Place on the same GameObject as the ModelRenderer. The physics collision mesh is extracted
/// once and uploaded to the GPU as a triangle list; only the WorldToLocal matrix is updated
/// each frame as the object moves or rotates.
/// </summary>
[Title("Hull Water Exclusion Volume"), Group("Water"), Icon("sailing")]
public sealed class HullWaterExclusionVolume : Component, Component.ExecuteInEditor
{
/// <summary>Triangle vertices in model LOCAL space, flat (v0,v1,v2, v0,v1,v2 …).</summary>
public Vector3[] LocalTriangles { get; private set; } = Array.Empty<Vector3>();
/// <summary>AABB of all local triangles, used for early GPU rejection.</summary>
public BBox LocalAABB { get; private set; }
private Model _lastModel;
protected override void OnEnabled()
{
RebuildMesh();
WaterManager.Current?.Register(this);
}
protected override void OnDisabled()
{
WaterManager.Current?.Unregister(this);
}
protected override void OnUpdate()
{
var model = GetComponent<ModelRenderer>()?.Model;
if (model != _lastModel)
RebuildMesh();
}
private void RebuildMesh()
{
var model = GetComponent<ModelRenderer>()?.Model;
if (model == null)
{
LocalTriangles = Array.Empty<Vector3>();
LocalAABB = default;
_lastModel = null;
Log.Warning($"{nameof(HullWaterExclusionVolume)}: No ModelRenderer or Model found.");
return;
}
_lastModel = model;
var tris = new List<Vector3>();
var aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
// Prefer the physics collision mesh — it's already simplified and watertight.
var physics = model.Physics;
if (physics != null)
{
foreach (var part in physics.Parts)
{
foreach (var meshPart in part.Meshes)
{
foreach (var tri in meshPart.GetTriangles())
{
tris.Add(tri.A);
tris.Add(tri.B);
tris.Add(tri.C);
aabbMin = Vector3.Min(aabbMin, Vector3.Min(tri.A, Vector3.Min(tri.B, tri.C)));
aabbMax = Vector3.Max(aabbMax, Vector3.Max(tri.A, Vector3.Max(tri.B, tri.C)));
}
}
// Convex hull shapes have no MeshParts — triangulate each hull instead.
foreach (var hullPart in part.Hulls)
{
var pts = hullPart.GetPoints()?.ToArray();
if (pts == null || pts.Length < 4) continue;
TriangulateConvexHull(pts, tris, ref aabbMin, ref aabbMax);
}
}
}
// Fallback: render mesh (may have more triangles, less ideal for GPU iteration)
if (tris.Count == 0)
{
var vertices = model.GetVertices();
var indices = model.GetIndices();
if (vertices != null && indices != null)
{
for (int i = 0; i + 2 < indices.Length; i += 3)
{
Vector3 v0 = vertices[indices[i + 0]].Position;
Vector3 v1 = vertices[indices[i + 1]].Position;
Vector3 v2 = vertices[indices[i + 2]].Position;
tris.Add(v0);
tris.Add(v1);
tris.Add(v2);
aabbMin = Vector3.Min(aabbMin, Vector3.Min(v0, Vector3.Min(v1, v2)));
aabbMax = Vector3.Max(aabbMax, Vector3.Max(v0, Vector3.Max(v1, v2)));
}
}
}
LocalTriangles = tris.ToArray();
LocalAABB = tris.Count > 0 ? new BBox(aabbMin, aabbMax) : default;
}
// N³ convex hull triangulation.
// Finds each hull face by collecting ALL coplanar vertices, then fan-triangulates once per face.
// Without this, rectangular faces (4 coplanar verts) emit C(4,3)=4 overlapping triangles,
// flipping the ray parity and incorrectly marking exterior points as inside.
private static void TriangulateConvexHull(Vector3[] verts, List<Vector3> result, ref Vector3 aabbMin, ref Vector3 aabbMax)
{
int n = verts.Length;
if (n < 4) return;
var centroid = Vector3.Zero;
foreach (var v in verts) centroid += v;
centroid /= n;
var processedFaces = new HashSet<string>();
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
for (int k = j + 1; k < n; k++)
{
Vector3 A = verts[i], B = verts[j], C = verts[k];
Vector3 rawNormal = Vector3.Cross(B - A, C - A);
if (rawNormal.LengthSquared < 1e-8f) continue;
Vector3 normal = rawNormal.Normal; // normalize so d = actual distance in units
bool pos = false, neg = false;
var faceIndices = new List<int> { i, j, k };
for (int m = 0; m < n; m++)
{
if (m == i || m == j || m == k) continue;
float d = Vector3.Dot(normal, verts[m] - A);
if (MathF.Abs(d) < 0.01f)
faceIndices.Add(m); // coplanar — part of this face
else if (d > 0f) pos = true;
else neg = true;
}
if (pos && neg) continue; // interior edge, not a hull face
if (!pos && !neg) continue; // degenerate — no non-coplanar vertices
// Canonical key: sorted vertex indices — each face processed exactly once.
faceIndices.Sort();
string key = string.Join(",", faceIndices);
if (!processedFaces.Add(key)) continue;
// Collect face vertices and sort by angle around the face centroid.
var faceVerts = faceIndices.Select(idx => verts[idx]).ToList();
var fc = Vector3.Zero;
foreach (var fv in faceVerts) fc += fv;
fc /= faceVerts.Count;
// Build a 2D frame in the face plane for angle sorting.
var outward = (Vector3.Dot(normal, centroid - A) < 0f) ? normal : -normal;
var tan = faceVerts.Select(fv => fv - fc).FirstOrDefault(d => d.LengthSquared > 1e-8f);
tan = tan.Normal;
var bitan = Vector3.Cross(outward.Normal, tan);
faceVerts.Sort((p, q) =>
{
float ap = MathF.Atan2(Vector3.Dot(p - fc, bitan), Vector3.Dot(p - fc, tan));
float aq = MathF.Atan2(Vector3.Dot(q - fc, bitan), Vector3.Dot(q - fc, tan));
return ap.CompareTo(aq);
});
// Fan triangulate the face.
for (int t = 1; t < faceVerts.Count - 1; t++)
{
var ta = faceVerts[0]; var tb = faceVerts[t]; var tc = faceVerts[t + 1];
result.Add(ta); result.Add(tb); result.Add(tc);
aabbMin = Vector3.Min(aabbMin, Vector3.Min(ta, Vector3.Min(tb, tc)));
aabbMax = Vector3.Max(aabbMax, Vector3.Max(ta, Vector3.Max(tb, tc)));
}
}
}
/// <summary>
/// Fills the 4 rows of the WorldToLocal matrix (row-major, for mul(M, float4(worldPos,1)) in HLSL).
/// </summary>
/// <summary>
/// Matches WorldTransform.PointToLocal = Rotation.Inverse * (worldPt - Position) / Scale.
/// In s&box: Forward=(1,0,0)=localX, Left=-Right=(0,1,0)=localY, Up=(0,0,1)=localZ.
/// </summary>
public void GetWorldToLocalRows(out Vector4 r0, out Vector4 r1, out Vector4 r2, out Vector4 r3)
{
Vector3 fwd = WorldRotation.Forward; // world-space local X axis
Vector3 left = -WorldRotation.Right; // world-space local Y axis (Right = -Y in s&box)
Vector3 up = WorldRotation.Up; // world-space local Z axis
Vector3 pos = WorldPosition;
Vector3 scale = WorldScale;
float isx = MathF.Abs(scale.x) > 1e-6f ? 1f / scale.x : 0f;
float isy = MathF.Abs(scale.y) > 1e-6f ? 1f / scale.y : 0f;
float isz = MathF.Abs(scale.z) > 1e-6f ? 1f / scale.z : 0f;
r0 = new Vector4(fwd.x * isx, fwd.y * isx, fwd.z * isx, -Vector3.Dot(fwd, pos) * isx);
r1 = new Vector4(left.x * isy, left.y * isy, left.z * isy, -Vector3.Dot(left, pos) * isy);
r2 = new Vector4(up.x * isz, up.y * isz, up.z * isz, -Vector3.Dot(up, pos) * isz);
r3 = new Vector4(0f, 0f, 0f, 1f);
}
protected override void DrawGizmos()
{
if (!Gizmo.IsSelected || LocalTriangles == null || LocalTriangles.Length == 0)
return;
Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.5f);
Gizmo.Draw.LineBBox(LocalAABB);
}
}