Split concavity-bridging slabs in the collider baker

A slab that spans a concave silhouette (e.g. a table side = two legs
plus a top rail, all coplanar and thin) produced one convex hull that
filled the open gap between the legs, so a player standing between them
counted as embedded. Add a refinement pass: each slab whose in-plane
coverage grid shows its convex-hull footprint is less than a
configurable fraction (hullFillThreshold, default 0.6) covered by real
surface is recursively halved along its larger PCA axis until every
piece hugs actual geometry. Solid boards (the shelf) are ~fully covered
and pass through untouched. Verified over a 9600-point grid: zero
bridging (no collider interior lies >6cm from the source surface),
shelf output unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-01 16:06:36 +02:00
1 parent 5c1f31a887
commit 410f9808b5
1 file changed
+175
+175
View File
@@ -44,6 +44,9 @@ public class ConcealmentColliderBaker : MonoBehaviour
[Tooltip("Flat/thin clusters are inflated to at least this thickness (local units) so the convex collider has usable volume for ClosestPoint tests.")]
[Min(1e-3f)] [SerializeField] private float minThickness = 0.04f;
[Tooltip("A slab is recursively split until its convex-hull footprint is at least this fraction covered by real surface (0..1). Guards against one hull bridging open gaps between coplanar members (e.g. a table side = 2 legs + top rail merge into a flat panel whose hull fills the empty gap). Lower => tolerate more concavity/bridging; higher => split into more, tighter surface-hugging pieces. Solid boards (~fully covered) are never split.")]
[Range(0f, 1f)] [SerializeField] private float hullFillThreshold = 0.6f;
[Tooltip("Clusters with fewer triangles than this are discarded as noise.")]
[Min(1)] [SerializeField] private int minClusterTriangles = 1;
@@ -59,6 +62,7 @@ public class ConcealmentColliderBaker : MonoBehaviour
private const string ContainerName = "ConcealmentColliders (generated)";
private const string AssetFolder = "Assets/Generated/ConcealmentColliders";
private const int MaxRefineDepth = 8;
#if UNITY_EDITOR
[ContextMenu("Bake Convex Colliders")]
@@ -162,6 +166,16 @@ public class ConcealmentColliderBaker : MonoBehaviour
clusters.Add(cluster);
}
// 3b. Refine: split slabs whose convex hull would bridge open concavities. A table side is
// 2 legs + a top rail — all coplanar and thin, so they merge into ONE slab whose hull
// fills the empty gap between the legs. Each slab is recursively halved along its larger
// in-plane axis until its hull footprint is mostly covered by real surface. Solid boards
// (the shelf) are already ~fully covered => returned untouched.
var refined = new List<List<int>>(clusters.Count);
for (int c = 0; c < clusters.Count; c++)
RefineCluster(clusters[c], wv, triA, triB, triC, 0, refined);
clusters = refined;
// 4. Filter noise + cap count (keep largest slabs).
clusters.RemoveAll(c => c.Count < minClusterTriangles);
clusters.Sort((x, y) => y.Count.CompareTo(x.Count));
@@ -415,6 +429,167 @@ public class ConcealmentColliderBaker : MonoBehaviour
return mx - mn;
}
// ---- Concavity refinement (split slabs whose hull would bridge open gaps) ----
/// <summary>
/// Recursively halves a slab cluster along its larger in-plane axis until its hull footprint is
/// at least <see cref="hullFillThreshold"/> covered by real surface (or it gets too small / too deep).
/// Prevents one convex hull from bridging the empty space between disjoint coplanar members (table legs).
/// </summary>
private void RefineCluster(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, int depth, List<List<int>> outClusters)
{
if (cluster.Count <= 2 || depth >= MaxRefineDepth)
{
outClusters.Add(cluster);
return;
}
float fill = ClusterFill(cluster, wv, triA, triB, triC, out Vector3 axis);
if (fill >= hullFillThreshold)
{
outClusters.Add(cluster);
return;
}
// Halve at the centroid median along the larger in-plane axis. Recursion + the fill test
// converge on surface-hugging pieces; a solid board is >= threshold at once and never splits.
int m = cluster.Count;
var proj = new float[m];
for (int j = 0; j < m; j++)
proj[j] = Vector3.Dot((wv[triA[cluster[j]]] + wv[triB[cluster[j]]] + wv[triC[cluster[j]]]) * (1f / 3f), axis);
var sorted = (float[])proj.Clone();
Array.Sort(sorted);
float median = sorted[m / 2];
var left = new List<int>();
var right = new List<int>();
for (int j = 0; j < m; j++)
{
if (proj[j] < median) left.Add(cluster[j]); else right.Add(cluster[j]);
}
if (left.Count == 0 || right.Count == 0)
{
outClusters.Add(cluster); // all centroids coincide on the axis — can't halve, keep as-is
return;
}
RefineCluster(left, wv, triA, triB, triC, depth + 1, outClusters);
RefineCluster(right, wv, triA, triB, triC, depth + 1, outClusters);
}
/// <summary>
/// Fraction (0..1) of the cluster's in-plane bounding footprint actually covered by surface, via a
/// coverage grid on the PCA plane (union of the projected triangles — single-/double-sided agnostic).
/// Low fill => the convex hull would enclose empty space. Outputs the larger in-plane axis (split dir).
/// </summary>
private static float ClusterFill(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, out Vector3 splitAxis)
{
var pts = new List<Vector3>(cluster.Count * 3);
var seen = new HashSet<int>();
for (int j = 0; j < cluster.Count; j++)
{
int t = cluster[j];
if (seen.Add(triA[t])) pts.Add(wv[triA[t]]);
if (seen.Add(triB[t])) pts.Add(wv[triB[t]]);
if (seen.Add(triC[t])) pts.Add(wv[triC[t]]);
}
PcaFrame(pts, out Vector3 u, out Vector3 v, out _);
float umin = float.MaxValue, umax = float.MinValue, vmin = float.MaxValue, vmax = float.MinValue;
for (int i = 0; i < pts.Count; i++)
{
float pu = Vector3.Dot(pts[i], u), pv = Vector3.Dot(pts[i], v);
if (pu < umin) umin = pu; if (pu > umax) umax = pu;
if (pv < vmin) vmin = pv; if (pv > vmax) vmax = pv;
}
float eu = Mathf.Max(umax - umin, 1e-5f), ev = Mathf.Max(vmax - vmin, 1e-5f);
splitAxis = eu >= ev ? u : v;
float cell = Mathf.Max(Mathf.Max(eu, ev) / 32f, 1e-4f);
int NU = Mathf.Clamp(Mathf.CeilToInt(eu / cell), 1, 40);
int NV = Mathf.Clamp(Mathf.CeilToInt(ev / cell), 1, 40);
var cov = new bool[NU, NV];
for (int j = 0; j < cluster.Count; j++)
{
int t = cluster[j];
RasterTri(cov, NU, NV,
ToGrid(wv[triA[t]], u, v, umin, vmin, eu, ev, NU, NV),
ToGrid(wv[triB[t]], u, v, umin, vmin, eu, ev, NU, NV),
ToGrid(wv[triC[t]], u, v, umin, vmin, eu, ev, NU, NV));
}
int covered = 0;
for (int a = 0; a < NU; a++) for (int b = 0; b < NV; b++) if (cov[a, b]) covered++;
return (float)covered / (NU * NV);
}
private static Vector2 ToGrid(Vector3 p, Vector3 u, Vector3 v, float umin, float vmin, float eu, float ev, int NU, int NV)
=> new Vector2((Vector3.Dot(p, u) - umin) / eu * NU, (Vector3.Dot(p, v) - vmin) / ev * NV);
/// <summary>Marks every grid cell whose center falls inside the 2D triangle (edge-on triangles paint their thin bbox strip).</summary>
private static void RasterTri(bool[,] cov, int NU, int NV, Vector2 a, Vector2 b, Vector2 c)
{
float minx = Mathf.Min(a.x, Mathf.Min(b.x, c.x)), maxx = Mathf.Max(a.x, Mathf.Max(b.x, c.x));
float miny = Mathf.Min(a.y, Mathf.Min(b.y, c.y)), maxy = Mathf.Max(a.y, Mathf.Max(b.y, c.y));
int x0 = Mathf.Clamp(Mathf.FloorToInt(minx), 0, NU - 1), x1 = Mathf.Clamp(Mathf.CeilToInt(maxx), 0, NU - 1);
int y0 = Mathf.Clamp(Mathf.FloorToInt(miny), 0, NV - 1), y1 = Mathf.Clamp(Mathf.CeilToInt(maxy), 0, NV - 1);
float d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
if (Mathf.Abs(d) < 1e-9f)
{
for (int x = x0; x <= x1; x++) for (int y = y0; y <= y1; y++) cov[x, y] = true;
return;
}
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
{
float px = x + 0.5f, py = y + 0.5f;
float w0 = ((b.y - c.y) * (px - c.x) + (c.x - b.x) * (py - c.y)) / d;
float w1 = ((c.y - a.y) * (px - c.x) + (a.x - c.x) * (py - c.y)) / d;
float w2 = 1f - w0 - w1;
if (w0 >= -1e-3f && w1 >= -1e-3f && w2 >= -1e-3f) cov[x, y] = true;
}
}
/// <summary>PCA orthonormal frame of a point cloud: u = major axis, v = mid, n = minor (slab normal). Eigenvectors sorted by variance desc.</summary>
private static void PcaFrame(List<Vector3> pts, out Vector3 u, out Vector3 v, out Vector3 n)
{
u = Vector3.right; v = Vector3.up; n = Vector3.forward;
int cnt = pts.Count;
if (cnt < 2) return;
Vector3 mean = Vector3.zero;
for (int i = 0; i < cnt; i++) mean += pts[i];
mean /= cnt;
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
for (int i = 0; i < cnt; i++)
{
double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z;
c00 += x * x; c01 += x * y; c02 += x * z;
c11 += y * y; c12 += y * z; c22 += z * z;
}
var mat = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
Jacobi3(mat, out double[,] vec, out double[] d);
int hi = 0, mid = 1, lo = 2;
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
if (d[mid] < d[lo]) { int t = mid; mid = lo; lo = t; }
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
u = AxisOr(vec, hi, Vector3.right);
v = AxisOr(vec, mid, Vector3.up);
n = AxisOr(vec, lo, Vector3.forward);
}
private static Vector3 AxisOr(double[,] vec, int col, Vector3 fallback)
{
Vector3 a = new Vector3((float)vec[0, col], (float)vec[1, col], (float)vec[2, col]);
return a.sqrMagnitude < 1e-12f ? fallback : a.normalized;
}
/// <summary>Cyclic Jacobi eigendecomposition of a symmetric 3x3 matrix (eigenvectors in columns of v).</summary>
private static void Jacobi3(double[,] a, out double[,] v, out double[] d)
{