From 410f9808b5fff231196a74278590f3522da589c7 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 1 Jul 2026 16:06:36 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ConcealmentColliderBaker.cs | 175 +++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs b/Assets/Scripts/ConcealmentColliderBaker.cs index 828eb87..3301877 100644 --- a/Assets/Scripts/ConcealmentColliderBaker.cs +++ b/Assets/Scripts/ConcealmentColliderBaker.cs @@ -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>(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) ---- + + /// + /// Recursively halves a slab cluster along its larger in-plane axis until its hull footprint is + /// at least 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). + /// + private void RefineCluster(List cluster, List wv, List triA, List triB, List triC, int depth, List> 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(); + var right = new List(); + 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); + } + + /// + /// 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). + /// + private static float ClusterFill(List cluster, List wv, List triA, List triB, List triC, out Vector3 splitAxis) + { + var pts = new List(cluster.Count * 3); + var seen = new HashSet(); + 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); + + /// Marks every grid cell whose center falls inside the 2D triangle (edge-on triangles paint their thin bbox strip). + 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; + } + } + + /// PCA orthonormal frame of a point cloud: u = major axis, v = mid, n = minor (slab normal). Eigenvectors sorted by variance desc. + private static void PcaFrame(List 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; + } + /// Cyclic Jacobi eigendecomposition of a symmetric 3x3 matrix (eigenvectors in columns of v). private static void Jacobi3(double[,] a, out double[,] v, out double[] d) {