Reorganize scripts into subfolders by responsibility

Move scripts under Assets/Scripts/ into Components, GameMode, Helper,
Network, Enum, Struct and ScriptableObject folders. Pure file moves
(plus folder .meta files); no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 15:02:15 +02:00
1 parent 382bfb36df
commit e23fae0c50
126 files changed
+112

No files matched your search

@@ -0,0 +1,773 @@
using System;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.SceneManagement;
#endif
/// <summary>
/// Editor tool that decomposes an environment mesh (typically an open-shell / non-watertight
/// furniture mesh) into a small set of CONVEX MeshColliders via surface clustering, so
/// <see cref="ConcealmentMeter"/> can treat the furniture as "embeddable" through its
/// convex-collider ClosestPoint path WITHOUT a single convex hull filling the open compartments.
///
/// Approach (no voxels, no native plugin — "approximate convex decomposition" on the surface):
/// 1. Weld coincident vertices so connectivity is detected across split-normal/UV seams.
/// 2. Grow connected triangle regions into SLABS: a region keeps absorbing edge-adjacent
/// triangles while it stays thinner than <see cref="maxSlabThickness"/> along its flattest
/// axis (PCA minor axis). This spans both faces of one board (they are within a board's
/// thickness) but refuses to jump the gap to the next board (that would thicken the region).
/// 3. Convex-hull each slab into its own convex collider. Boards become thin convex slabs;
/// the empty space between boards is covered by no collider, so it stays hollow.
///
/// The colliders are real child GameObjects referencing SAVED hull-mesh assets, so they survive
/// domain reload / play-mode / scene save (unlike an in-memory-only bake).
///
/// Runtime-inert: the component exists only so <see cref="ConcealmentMeter"/> can detect it in a
/// collider's parent and skip the furniture's own concave MeshCollider (exactly like a baked
/// <c>ConcealmentVolume</c> does) — the generated convex slabs are the authoritative embedding shape.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentColliderBaker : MonoBehaviour
{
[Tooltip("Optional. If empty, the shared mesh of a MeshFilter on this object (or a child) is used.")]
[SerializeField] private Mesh sourceMesh;
[Header("Clustering")]
[Tooltip("Vertices closer than this (local units) are welded, so shared edges are found across split-normal/UV seams. ~ a hair below the smallest real feature.")]
[Min(1e-4f)] [SerializeField] private float weldEpsilon = 1e-3f;
[Tooltip("A connected region keeps growing while it stays thinner than this (local units) along its flattest axis. ~ board thickness. Larger => fewer, chunkier colliders (risk of bridging compartments); smaller => more, thinner slabs.")]
[Min(1e-3f)] [SerializeField] private float maxSlabThickness = 0.12f;
[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;
[Tooltip("Hard cap on generated colliders. The meter's broadphase is capped at 64 colliders TOTAL across all environment in range, so keep this modest. Largest clusters win.")]
[Min(1)] [SerializeField] private int maxColliders = 24;
[Header("Output")]
[Tooltip("If true, generated colliders inherit the source object's layer (so they land on the meter's environment mask). Otherwise 'Collider Layer' is used.")]
[SerializeField] private bool useSourceLayer = true;
[SerializeField] private int colliderLayer;
[SerializeField, HideInInspector] private List<string> generatedAssetPaths = new List<string>();
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")]
public void Bake()
{
Mesh mesh = sourceMesh;
Transform meshSpace = transform;
if (!mesh)
{
MeshFilter mf = GetComponentInChildren<MeshFilter>();
if (mf && mf.sharedMesh) { mesh = mf.sharedMesh; meshSpace = mf.transform; }
}
if (!mesh)
{
Debug.LogError($"[ConcealmentColliderBaker] '{name}': no source mesh (assign one or add a MeshFilter).", this);
return;
}
Clear(); // remove previous container + assets first
string baseName = ResolveAssetBaseName();
Vector3[] rawVerts = mesh.vertices;
int[] rawTris = mesh.triangles;
// 1. Weld coincident vertices.
WeldVertices(rawVerts, weldEpsilon, out List<Vector3> wv, out int[] map);
// Welded triangles (drop degenerate).
var triA = new List<int>(); var triB = new List<int>(); var triC = new List<int>();
for (int i = 0; i + 2 < rawTris.Length; i += 3)
{
int a = map[rawTris[i]], b = map[rawTris[i + 1]], c = map[rawTris[i + 2]];
if (a == b || b == c || a == c) continue;
triA.Add(a); triB.Add(b); triC.Add(c);
}
int triCount = triA.Count;
if (triCount == 0)
{
Debug.LogWarning($"[ConcealmentColliderBaker] '{name}': mesh has no valid triangles after welding.", this);
return;
}
// 2. Edge adjacency (welded edges shared by triangles).
var edgeToTri = new Dictionary<long, int>(triCount * 3);
var neighbors = new List<int>[triCount];
for (int t = 0; t < triCount; t++) neighbors[t] = new List<int>();
for (int t = 0; t < triCount; t++)
{
LinkEdge(edgeToTri, neighbors, triA[t], triB[t], t);
LinkEdge(edgeToTri, neighbors, triB[t], triC[t], t);
LinkEdge(edgeToTri, neighbors, triC[t], triA[t], t);
}
// 3. Grow connected triangles into thickness-bounded slabs.
// Disconnected boards never link (separate components); connected assemblies split
// wherever a region would exceed maxSlabThickness.
var clusters = new List<List<int>>();
bool[] assigned = new bool[triCount];
var queue = new Queue<int>();
var vset = new HashSet<int>();
var vpos = new List<Vector3>();
var added = new List<int>();
for (int s = 0; s < triCount; s++)
{
if (assigned[s]) continue;
var cluster = new List<int> { s };
assigned[s] = true;
vset.Clear(); vpos.Clear();
AddTriVerts(vset, vpos, wv, triA[s], triB[s], triC[s], null);
queue.Clear();
for (int k = 0; k < neighbors[s].Count; k++) queue.Enqueue(neighbors[s][k]);
while (queue.Count > 0)
{
int t = queue.Dequeue();
if (assigned[t]) continue;
int before = vpos.Count;
AddTriVerts(vset, vpos, wv, triA[t], triB[t], triC[t], added);
float th = SlabThickness(vpos, out _);
if (th <= maxSlabThickness)
{
assigned[t] = true;
cluster.Add(t);
for (int k = 0; k < neighbors[t].Count; k++)
if (!assigned[neighbors[t][k]]) queue.Enqueue(neighbors[t][k]);
}
else
{
// Revert the tentatively added verts.
for (int k = 0; k < added.Count; k++) vset.Remove(added[k]);
if (vpos.Count > before) vpos.RemoveRange(before, vpos.Count - before);
}
}
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));
int dropped = 0;
if (clusters.Count > maxColliders)
{
dropped = clusters.Count - maxColliders;
clusters.RemoveRange(maxColliders, dropped);
}
// 5. Build a convex collider per slab.
EnsureFolder();
Transform container = new GameObject(ContainerName).transform;
container.SetParent(meshSpace, false);
container.localPosition = Vector3.zero;
container.localRotation = Quaternion.identity;
container.localScale = Vector3.one;
int outLayer = useSourceLayer ? gameObject.layer : colliderLayer;
int made = 0;
for (int ci = 0; ci < clusters.Count; ci++)
{
List<Vector3> pts = CollectClusterPoints(clusters[ci], wv, triA, triB, triC);
float thick = SlabThickness(pts, out Vector3 normal);
List<Vector3> hullPts;
if (thick < minThickness)
{
// Inflate a (near-)flat slab into a thin prism so the hull has volume.
float half = minThickness * 0.5f;
hullPts = new List<Vector3>(pts.Count * 2);
for (int k = 0; k < pts.Count; k++)
{
hullPts.Add(pts[k] + normal * half);
hullPts.Add(pts[k] - normal * half);
}
}
else hullPts = pts;
if (!ComputeConvexHull(hullPts.ToArray(), out List<Vector3> hv, out List<int> ht) || hv.Count < 4)
continue;
Mesh cm = new Mesh { name = $"{baseName}_{made}" };
cm.SetVertices(hv);
cm.SetTriangles(ht, 0);
cm.RecalculateBounds();
string path = $"{AssetFolder}/{baseName}_{made}.asset";
AssetDatabase.CreateAsset(cm, path);
generatedAssetPaths.Add(path);
GameObject go = new GameObject($"hull_{made}");
go.transform.SetParent(container, false);
go.layer = outLayer;
MeshCollider col = go.AddComponent<MeshCollider>();
col.sharedMesh = cm;
col.convex = true;
col.isTrigger = false;
made++;
}
AssetDatabase.SaveAssets();
EditorUtility.SetDirty(this);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
Debug.Log($"[ConcealmentColliderBaker] '{name}': baked {made} convex collider(s)" +
(dropped > 0 ? $" (+{dropped} smaller slab(s) dropped by Max Colliders={maxColliders})" : "") +
". Save the scene to persist.", this);
}
[ContextMenu("Clear Baked Colliders")]
public void Clear()
{
for (int i = 0; i < generatedAssetPaths.Count; i++)
if (!string.IsNullOrEmpty(generatedAssetPaths[i]))
AssetDatabase.DeleteAsset(generatedAssetPaths[i]);
generatedAssetPaths.Clear();
var all = GetComponentsInChildren<Transform>(true);
for (int i = 0; i < all.Length; i++)
if (all[i] && all[i] != transform && all[i].name == ContainerName)
DestroyImmediate(all[i].gameObject);
EditorUtility.SetDirty(this);
if (gameObject.scene.IsValid())
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
}
private static void EnsureFolder()
{
if (!AssetDatabase.IsValidFolder("Assets/Generated"))
AssetDatabase.CreateFolder("Assets", "Generated");
if (!AssetDatabase.IsValidFolder(AssetFolder))
AssetDatabase.CreateFolder("Assets/Generated", "ConcealmentColliders");
}
/// <summary>
/// Readable, deterministic base name for the generated collider-mesh assets:
/// - Prefab stage: "{assetPath without 'Assets/' and '.prefab'}/{path below the prefab root}"
/// - Scene: "{sceneName}/{full hierarchy path from the scene root}"
/// Sanitized to a filesystem-safe token; on collision with a DIFFERENT baker's assets, "_2", "_3", … is appended.
/// </summary>
private string ResolveAssetBaseName()
{
string raw;
PrefabStage stage = PrefabStageUtility.GetPrefabStage(gameObject);
if (stage != null)
{
string ap = stage.assetPath;
if (ap.StartsWith("Assets/")) ap = ap.Substring("Assets/".Length);
if (ap.EndsWith(".prefab")) ap = ap.Substring(0, ap.Length - ".prefab".Length);
// Path below the prefab root — the root's name already lives in the asset path.
string hier = GetHierarchyPath(transform, stage.prefabContentsRoot.transform);
raw = hier.Length == 0 ? ap : ap + "/" + hier;
}
else
{
string scene = string.IsNullOrEmpty(gameObject.scene.name) ? "Untitled" : gameObject.scene.name;
raw = scene + "/" + GetHierarchyPath(transform, null);
}
string baseName = SanitizeFileName(raw);
if (baseName.Length == 0) baseName = "Bake";
// Clear() already removed THIS baker's assets, so a hit here means another baker resolved to the
// same name -> disambiguate. The per-collider "_0" file is the presence marker of a base name.
string candidate = baseName;
for (int dup = 2; System.IO.File.Exists($"{AssetFolder}/{candidate}_0.asset"); dup++)
candidate = $"{baseName}_{dup}";
return candidate;
}
/// <summary>Slash-joined transform names from <paramref name="t"/> up to (but excluding) <paramref name="stopAt"/> (null = up to the root).</summary>
private static string GetHierarchyPath(Transform t, Transform stopAt)
{
var names = new List<string>();
for (Transform cur = t; cur && cur != stopAt; cur = cur.parent)
names.Add(cur.name);
names.Reverse();
return string.Join("/", names);
}
/// <summary>Collapses every run of non-[A-Za-z0-9-] characters to a single '_' and trims leading/trailing '_'.</summary>
private static string SanitizeFileName(string s)
{
var sb = new System.Text.StringBuilder(s.Length);
bool pendingSep = false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
if (keep)
{
if (pendingSep && sb.Length > 0) sb.Append('_');
pendingSep = false;
sb.Append(c);
}
else pendingSep = true;
}
return sb.ToString();
}
#endif
// ---- Welding & clustering helpers (runtime-safe: no editor API) ----
private static void WeldVertices(Vector3[] src, float eps, out List<Vector3> welded, out int[] map)
{
welded = new List<Vector3>(src.Length);
map = new int[src.Length];
float inv = 1f / Mathf.Max(eps, 1e-6f);
var lut = new Dictionary<long, int>(src.Length);
for (int i = 0; i < src.Length; i++)
{
Vector3 p = src[i];
int qx = Mathf.RoundToInt(p.x * inv);
int qy = Mathf.RoundToInt(p.y * inv);
int qz = Mathf.RoundToInt(p.z * inv);
long key = ((long)(qx & 0x1FFFFF) << 42) | ((long)(qy & 0x1FFFFF) << 21) | (long)(qz & 0x1FFFFF);
if (lut.TryGetValue(key, out int idx)) { map[i] = idx; }
else { idx = welded.Count; lut[key] = idx; welded.Add(p); map[i] = idx; }
}
}
private static void LinkEdge(Dictionary<long, int> edgeToTri, List<int>[] neighbors, int u, int v, int tri)
{
long key = EdgeKey(u, v);
if (edgeToTri.TryGetValue(key, out int other))
{
neighbors[tri].Add(other);
neighbors[other].Add(tri);
edgeToTri[key] = tri; // chain non-manifold edges (>2 tris) so all get linked
}
else edgeToTri[key] = tri;
}
private static void AddTriVerts(HashSet<int> set, List<Vector3> pos, List<Vector3> wv, int a, int b, int c, List<int> addedOut)
{
addedOut?.Clear();
if (set.Add(a)) { pos.Add(wv[a]); addedOut?.Add(a); }
if (set.Add(b)) { pos.Add(wv[b]); addedOut?.Add(b); }
if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); }
}
/// <summary>Unique welded vertex positions of a triangle cluster.</summary>
private static List<Vector3> CollectClusterPoints(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC)
{
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]]);
}
return pts;
}
/// <summary>Mean-centered 3x3 covariance of the point cloud, eigendecomposed via <see cref="Jacobi3"/>.</summary>
private static void CovarianceEigen(List<Vector3> pts, out double[,] vec, out double[] d)
{
int n = pts.Count;
Vector3 mean = Vector3.zero;
for (int i = 0; i < n; i++) mean += pts[i];
mean /= n;
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
for (int i = 0; i < n; 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 a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
Jacobi3(a, out vec, out d);
}
/// <summary>Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.</summary>
private static float SlabThickness(List<Vector3> pts, out Vector3 normal)
{
normal = Vector3.up;
int n = pts.Count;
if (n < 2) return 0f;
CovarianceEigen(pts, out double[,] v, out double[] d);
int mi = 0;
if (d[1] < d[mi]) mi = 1;
if (d[2] < d[mi]) mi = 2;
normal = new Vector3((float)v[0, mi], (float)v[1, mi], (float)v[2, mi]);
if (normal.sqrMagnitude < 1e-12f) { normal = Vector3.up; return 0f; }
normal.Normalize();
float mn = float.MaxValue, mx = float.MinValue;
for (int i = 0; i < n; i++)
{
float dp = Vector3.Dot(pts[i], normal);
if (dp < mn) mn = dp;
if (dp > mx) mx = dp;
}
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)
{
List<Vector3> pts = CollectClusterPoints(cluster, wv, triA, triB, triC);
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;
if (pts.Count < 2) return;
CovarianceEigen(pts, 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)
{
v = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
for (int sweep = 0; sweep < 24; sweep++)
{
double off = Math.Abs(a[0, 1]) + Math.Abs(a[0, 2]) + Math.Abs(a[1, 2]);
if (off < 1e-18) break;
JacobiRotate(a, v, 0, 1);
JacobiRotate(a, v, 0, 2);
JacobiRotate(a, v, 1, 2);
}
d = new double[] { a[0, 0], a[1, 1], a[2, 2] };
}
private static void JacobiRotate(double[,] a, double[,] v, int p, int q)
{
double apq = a[p, q];
if (Math.Abs(apq) < 1e-20) return;
double app = a[p, p], aqq = a[q, q];
double phi = 0.5 * Math.Atan2(2.0 * apq, aqq - app);
double c = Math.Cos(phi), s = Math.Sin(phi);
a[p, p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
a[q, q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
a[p, q] = 0; a[q, p] = 0;
int r = 3 - p - q;
double arp = a[r, p], arq = a[r, q];
double nrp = c * arp - s * arq;
double nrq = s * arp + c * arq;
a[r, p] = nrp; a[p, r] = nrp;
a[r, q] = nrq; a[q, r] = nrq;
for (int k = 0; k < 3; k++)
{
double vkp = v[k, p], vkq = v[k, q];
v[k, p] = c * vkp - s * vkq;
v[k, q] = s * vkp + c * vkq;
}
}
// ---- Convex hull (incremental; outputs a triangulated hull mesh) ----
private struct HFace { public int a, b, c; public Vector3 n; }
private static bool ComputeConvexHull(Vector3[] pts, out List<Vector3> hullVertsOut, out List<int> hullTrisOut)
{
hullVertsOut = new List<Vector3>();
hullTrisOut = new List<int>();
int n = pts.Length;
if (n < 4) return false;
Vector3 bmn = pts[0], bmx = pts[0];
for (int i = 1; i < n; i++) { bmn = Vector3.Min(bmn, pts[i]); bmx = Vector3.Max(bmx, pts[i]); }
float diag = (bmx - bmn).magnitude;
if (diag < 1e-6f) return false;
float eps = diag * 1e-6f;
int minX = 0, maxX = 0;
for (int i = 1; i < n; i++) { if (pts[i].x < pts[minX].x) minX = i; if (pts[i].x > pts[maxX].x) maxX = i; }
int i0 = minX, i1 = maxX;
if (i0 == i1) return false;
Vector3 lineDir = pts[i1] - pts[i0];
float lineLen = lineDir.magnitude;
if (lineLen < eps) return false;
lineDir /= lineLen;
int i2 = -1; float best = eps;
for (int i = 0; i < n; i++)
{
float dd = Vector3.Cross(pts[i] - pts[i0], lineDir).magnitude;
if (dd > best) { best = dd; i2 = i; }
}
if (i2 < 0) return false;
Vector3 nrm0 = Vector3.Cross(pts[i1] - pts[i0], pts[i2] - pts[i0]);
float nl = nrm0.magnitude;
if (nl < eps) return false;
nrm0 /= nl;
int i3 = -1; best = eps;
for (int i = 0; i < n; i++)
{
float dd = Mathf.Abs(Vector3.Dot(pts[i] - pts[i0], nrm0));
if (dd > best) { best = dd; i3 = i; }
}
if (i3 < 0) return false;
Vector3 interior = (pts[i0] + pts[i1] + pts[i2] + pts[i3]) * 0.25f;
var faces = new List<HFace>(4 + n);
AddHullFace(faces, pts, interior, i0, i1, i2);
AddHullFace(faces, pts, interior, i0, i1, i3);
AddHullFace(faces, pts, interior, i0, i2, i3);
AddHullFace(faces, pts, interior, i1, i2, i3);
var visible = new List<int>();
var edgeCount = new Dictionary<long, int>();
var horizonA = new List<int>();
var horizonB = new List<int>();
for (int pi = 0; pi < n; pi++)
{
if (pi == i0 || pi == i1 || pi == i2 || pi == i3) continue;
Vector3 p = pts[pi];
visible.Clear();
for (int f = 0; f < faces.Count; f++)
{
HFace hf = faces[f];
if (Vector3.Dot(hf.n, p - pts[hf.a]) > eps) visible.Add(f);
}
if (visible.Count == 0) continue;
edgeCount.Clear();
for (int vi = 0; vi < visible.Count; vi++)
{
HFace hf = faces[visible[vi]];
AccEdge(edgeCount, hf.a, hf.b);
AccEdge(edgeCount, hf.b, hf.c);
AccEdge(edgeCount, hf.c, hf.a);
}
horizonA.Clear(); horizonB.Clear();
for (int vi = 0; vi < visible.Count; vi++)
{
HFace hf = faces[visible[vi]];
if (EdgeIsHorizon(edgeCount, hf.a, hf.b)) { horizonA.Add(hf.a); horizonB.Add(hf.b); }
if (EdgeIsHorizon(edgeCount, hf.b, hf.c)) { horizonA.Add(hf.b); horizonB.Add(hf.c); }
if (EdgeIsHorizon(edgeCount, hf.c, hf.a)) { horizonA.Add(hf.c); horizonB.Add(hf.a); }
}
visible.Sort();
for (int vi = visible.Count - 1; vi >= 0; vi--) faces.RemoveAt(visible[vi]);
for (int h = 0; h < horizonA.Count; h++)
AddHullFace(faces, pts, interior, horizonA[h], horizonB[h], pi);
}
if (faces.Count < 4) return false;
var remap = new Dictionary<int, int>();
for (int f = 0; f < faces.Count; f++)
{
HFace hf = faces[f];
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.a));
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.b));
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.c));
}
return true;
}
private static void AddHullFace(List<HFace> faces, Vector3[] pts, Vector3 interior, int a, int b, int c)
{
Vector3 nn = Vector3.Cross(pts[b] - pts[a], pts[c] - pts[a]);
float l = nn.magnitude;
if (l < 1e-20f) return;
nn /= l;
if (Vector3.Dot(nn, pts[a] - interior) < 0f) nn = -nn;
faces.Add(new HFace { a = a, b = b, c = c, n = nn });
}
private static long EdgeKey(int u, int v)
{
int lo = u < v ? u : v, hi = u < v ? v : u;
return ((long)lo << 32) | (uint)hi;
}
private static void AccEdge(Dictionary<long, int> ec, int u, int v)
{
long k = EdgeKey(u, v);
ec.TryGetValue(k, out int c);
ec[k] = c + 1;
}
private static bool EdgeIsHorizon(Dictionary<long, int> ec, int u, int v)
{
ec.TryGetValue(EdgeKey(u, v), out int c);
return c == 1;
}
private static int RemapVert(Dictionary<int, int> remap, List<Vector3> outV, Vector3[] pts, int idx)
{
if (remap.TryGetValue(idx, out int r)) return r;
r = outV.Count; remap[idx] = r; outV.Add(pts[idx]);
return r;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 80f683e75cf2f2a41bb32d8e9de0a2f0
@@ -0,0 +1,393 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Standalone volumetric concealment meter for the painted character.
///
/// Each evaluation bakes the character's current (deformed) skinned mesh, voxelizes its
/// SOLID volume, and reports what fraction of that volume sits INSIDE environment colliders
/// (= embedded / hidden) versus exposed. <see cref="EmbeddedPercent"/> is the headline value,
/// <see cref="visiblePercent"/> its complement.
///
/// Pipeline:
/// 1. <see cref="SkinnedMeshRenderer.BakeMesh(Mesh)"/> -> mesh in the renderer's LOCAL space, then the
/// player's world scale is divided back out (BakeMesh bakes that scale IN; see PlayerScaler) so the
/// convention world = transform.localToWorldMatrix holds at ANY size — the same fixup SkinnedColliderBaker
/// applies, and the unit-scale mesh MeshPainter borrows from it.
/// 2. Column ray-stabbing in local space: each (x,z) grid column collects the Y where the mesh
/// surface crosses it; parity (odd # of crossings below a voxel center) marks interior voxels.
/// This is the same XZ triangle rasterization MeshPainter.GetIslandMask uses for UV islands.
/// 3. Each interior voxel center -> world; tested against broadphase-filtered environment
/// colliders: convex primitives / convex MeshCollider via ClosestPoint, Terrain via
/// SampleHeight, optional concave MeshCollider via raycast parity.
///
/// Surface-watertightness caveat: parity assumes a closed mesh. After the Blender merge-by-distance
/// the paintman rig is effectively watertight; small leaks only perturb the percentage slightly.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentMeter : MonoBehaviour
{
[Header("Targets")]
[Tooltip("Skinned character to measure. Auto-resolved from this GameObject / children if empty.")]
[SerializeField] private SkinnedMeshRenderer targetRenderer;
[Tooltip("Root of the character's OWN collider hierarchy, excluded from the environment test. " +
"Auto = the renderer transform's root.")]
[SerializeField] private Transform characterRoot;
[Header("Voxelization")]
[Min(2)]
[Tooltip("Voxels along the longest local bounds axis. Higher = more precise & more cost. ~24 is a good start.")]
[SerializeField] private int voxelResolution = 24;
[Header("Environment")]
[Tooltip("Layers that count as environment the body can be embedded in.")]
[SerializeField] private LayerMask environmentMask = ~0;
[Tooltip("Treat concave (non-convex) MeshColliders as solid via raycast parity. Off = ignored " +
"(ClosestPoint is unsupported for them, and flat floors are meaningless as 'inside').")]
[SerializeField] private bool includeConcaveMeshColliders;
[Tooltip("Count being below a Terrain surface as embedded.")]
[SerializeField] private bool includeTerrain = true;
[Header("Scheduling")]
[Min(0f)]
[Tooltip("Seconds between evaluations while playing. 0 = every frame.")]
[SerializeField] private float evaluateInterval = 0.1f;
[SerializeField] private bool evaluateInPlayMode = true;
[Header("Debug")]
[SerializeField] private bool drawGizmos;
[SerializeField] private Color exposedColor = new Color(0.2f, 1f, 0.3f, 0.25f);
[SerializeField] private Color embeddedColor = new Color(1f, 0.25f, 0.2f, 0.5f);
/// <summary>Percentage [0..100] of the character's solid volume embedded inside environment colliders.</summary>
public float EmbeddedPercent { get; private set; }
public float visiblePercent; // Inspector debug readout (complement of EmbeddedPercent)
public bool HasResult { get; private set; }
private Mesh _baked;
private readonly List<Vector3> _verts = new List<Vector3>(); // refilled per eval without allocating
private int[] _tris; // topology is invariant across bakes of the same renderer -> fetched once
private int _trisVertexCount = -1; // vertex count _tris was fetched for (detects a renderer/mesh swap)
private bool _overlapSaturated; // log-once guard for a saturated broadphase buffer
private float _timer;
private readonly List<Vector3> _vbuf = new List<Vector3>(); // reused scale-fixup scratch; nothing allocated per eval
// Per-column Y crossings (index = iz * nx + ix). Reused across evals; sizes grow as needed.
private List<float>[] _columns;
// Broadphase scratch + classified environment candidates (rebuilt each eval).
private readonly Collider[] _overlap = new Collider[64];
private readonly List<Collider> _convex = new List<Collider>();
private readonly List<Terrain> _terrains = new List<Terrain>();
private readonly List<MeshCollider> _concave = new List<MeshCollider>();
private readonly List<ConcealmentVolume> _volumes = new List<ConcealmentVolume>();
private readonly List<Vector3> _gizPos = new List<Vector3>();
private readonly List<bool> _gizEmbedded = new List<bool>();
private float _gizVoxelWorld;
// ClosestPoint returns the input point unchanged when it is inside a (convex) collider.
private const float InsideEpsSqr = 1e-8f;
private void Awake()
{
if (!targetRenderer) targetRenderer = GetComponentInChildren<SkinnedMeshRenderer>();
if (!characterRoot && targetRenderer) characterRoot = targetRenderer.transform.root;
_baked = new Mesh { name = "ConcealmentMeter_Baked" };
}
private void OnDestroy()
{
if (_baked) Destroy(_baked);
}
private void Update()
{
if (!evaluateInPlayMode) return;
if (evaluateInterval <= 0f) { Evaluate(); return; }
_timer += Time.deltaTime;
if (_timer < evaluateInterval) return;
_timer = 0f;
Evaluate();
}
/// <summary>Bakes the current pose, voxelizes it, and recomputes the embedded percentage.</summary>
[ContextMenu("Evaluate Now")]
public void Evaluate()
{
if (!targetRenderer) return;
targetRenderer.BakeMesh(_baked);
// BakeMesh freezes the bone deformation — which already carries the player's world scale (PlayerScaler)
// — into the mesh. Everything below treats the bake as unit-scale LOCAL geometry and raises it to world
// via targetRenderer.localToWorldMatrix, which re-applies that same scale. Divide it back out first,
// else a scaled player is measured at scale² (voxel world positions, broadphase box AND gizmos), which
// also corrupts EmbeddedPercent. No-op at scale 1; the uniform divide is rotation-independent, which the
// player's uniform scale guarantees. Mirrors SkinnedColliderBaker's identical fixup.
GeometryUtil.RescaleToUnitScale(_baked, targetRenderer.transform.lossyScale, _vbuf);
// mesh.vertices/.triangles return fresh arrays per call - at 10 evals/s that is steady GC
// pressure. Vertices go into a reused list; the (bake-invariant) index buffer is fetched only
// when the source mesh actually changed.
_baked.GetVertices(_verts);
if (_tris == null || _trisVertexCount != _baked.vertexCount)
{
_tris = _baked.triangles;
_trisVertexCount = _baked.vertexCount;
}
if (_verts.Count == 0 || _tris.Length < 3) return;
Bounds lb = _baked.bounds; // local-space AABB of the baked pose
Vector3 size = lb.size;
float maxExtent = Mathf.Max(size.x, Mathf.Max(size.y, size.z));
if (maxExtent <= 1e-6f) return;
float voxel = maxExtent / voxelResolution;
int nx = Mathf.Max(1, Mathf.CeilToInt(size.x / voxel));
int ny = Mathf.Max(1, Mathf.CeilToInt(size.y / voxel));
int nz = Mathf.Max(1, Mathf.CeilToInt(size.z / voxel));
Vector3 origin = lb.min;
StabColumns(origin, voxel, nx, nz);
Matrix4x4 l2w = targetRenderer.transform.localToWorldMatrix;
// Derive the broadphase box from the ACTUAL transformed geometry, not SkinnedMeshRenderer.bounds
// (the latter is a root-bone approximation that can be badly offset for a deformed/posed rig).
Bounds worldBounds = GeometryUtil.TransformBounds(l2w, lb);
BuildEnvironmentCandidates(worldBounds);
bool anyEnv = _convex.Count > 0 ||
_volumes.Count > 0 ||
(includeTerrain && _terrains.Count > 0) ||
(includeConcaveMeshColliders && _concave.Count > 0);
_gizPos.Clear();
_gizEmbedded.Clear();
_gizVoxelWorld = voxel * AverageScale(targetRenderer.transform);
int interior = 0, embedded = 0;
for (int iz = 0; iz < nz; iz++)
{
for (int ix = 0; ix < nx; ix++)
{
List<float> col = _columns[iz * nx + ix];
if (col == null || col.Count < 2) continue;
col.Sort();
// col is sorted and cy grows monotonically with iy, so a single running index
// replaces a from-scratch scan per voxel: O(ny + crossings) per column.
int below = 0;
for (int iy = 0; iy < ny; iy++)
{
float cy = origin.y + (iy + 0.5f) * voxel;
while (below < col.Count && col[below] <= cy) below++;
if ((below & 1) == 0) continue; // even crossings below -> outside the solid
interior++;
Vector3 localC = new Vector3(origin.x + (ix + 0.5f) * voxel, cy, origin.z + (iz + 0.5f) * voxel);
Vector3 worldC = l2w.MultiplyPoint3x4(localC);
bool inside = anyEnv && IsInsideAnyEnvironment(worldC);
if (inside) embedded++;
if (drawGizmos)
{
_gizPos.Add(worldC);
_gizEmbedded.Add(inside);
}
}
}
}
EmbeddedPercent = interior > 0 ? 100f * embedded / interior : 0f;
visiblePercent = 100 - EmbeddedPercent;
HasResult = true;
}
/// <summary>
/// Rasterizes every triangle into the XZ column grid; each column accumulates the Y values
/// where the surface crosses it (one per triangle whose XZ projection covers the column center).
/// </summary>
private void StabColumns(Vector3 origin, float voxel, int nx, int nz)
{
int cells = nx * nz;
if (_columns == null || _columns.Length < cells) _columns = new List<float>[cells];
for (int i = 0; i < cells; i++) _columns[i]?.Clear();
int triCount = _tris.Length / 3;
for (int t = 0; t < triCount; t++)
{
Vector3 a = _verts[_tris[t * 3]];
Vector3 b = _verts[_tris[t * 3 + 1]];
Vector3 c = _verts[_tris[t * 3 + 2]];
float ax = a.x, az = a.z, bx = b.x, bz = b.z, cx = c.x, cz = c.z;
float d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Mathf.Abs(d) < 1e-12f) continue; // degenerate in XZ (e.g. a vertical wall triangle)
float invD = 1f / d;
float minXf = Mathf.Min(ax, Mathf.Min(bx, cx));
float maxXf = Mathf.Max(ax, Mathf.Max(bx, cx));
float minZf = Mathf.Min(az, Mathf.Min(bz, cz));
float maxZf = Mathf.Max(az, Mathf.Max(bz, cz));
int ix0 = Mathf.Clamp(Mathf.FloorToInt((minXf - origin.x) / voxel), 0, nx - 1);
int ix1 = Mathf.Clamp(Mathf.CeilToInt((maxXf - origin.x) / voxel), 0, nx - 1);
int iz0 = Mathf.Clamp(Mathf.FloorToInt((minZf - origin.z) / voxel), 0, nz - 1);
int iz1 = Mathf.Clamp(Mathf.CeilToInt((maxZf - origin.z) / voxel), 0, nz - 1);
for (int iz = iz0; iz <= iz1; iz++)
{
float pz = origin.z + (iz + 0.5f) * voxel;
for (int ix = ix0; ix <= ix1; ix++)
{
float px = origin.x + (ix + 0.5f) * voxel;
float wa = ((bz - cz) * (px - cx) + (cx - bx) * (pz - cz)) * invD;
if (wa < 0f) continue;
float wb = ((cz - az) * (px - cx) + (ax - cx) * (pz - cz)) * invD;
if (wb < 0f) continue;
float wc = 1f - wa - wb;
if (wc < 0f) continue;
float y = wa * a.y + wb * b.y + wc * c.y;
int ci = iz * nx + ix;
(_columns[ci] ?? (_columns[ci] = new List<float>(8))).Add(y);
}
}
}
}
/// <summary>Broadphase-overlaps the character bounds and classifies the hit colliders by test type, excluding the character itself.</summary>
private void BuildEnvironmentCandidates(Bounds wb)
{
_convex.Clear();
_terrains.Clear();
_concave.Clear();
_volumes.Clear();
// Baked concealment volumes are authoritative for the (open-shell) furniture they cover;
// gathered from the registry independent of colliders so they need no specific collider type.
IReadOnlyList<ConcealmentVolume> vols = ConcealmentVolume.All;
for (int i = 0; i < vols.Count; i++)
{
ConcealmentVolume v = vols[i];
if (!v || !v.HasBake) continue;
if (characterRoot && (v.transform == characterRoot || v.transform.IsChildOf(characterRoot))) continue;
if (v.WorldBounds.Intersects(wb)) _volumes.Add(v);
}
int n = Physics.OverlapBoxNonAlloc(wb.center, wb.extents, _overlap, Quaternion.identity,
environmentMask, QueryTriggerInteraction.Ignore);
if (n == _overlap.Length && !_overlapSaturated)
{
_overlapSaturated = true; // silently dropped colliders would skew EmbeddedPercent low
Debug.LogWarning($"ConcealmentMeter: broadphase buffer saturated ({n}); environment colliders may be missed.", this);
}
for (int i = 0; i < n; i++)
{
Collider col = _overlap[i];
if (!col) continue;
if (characterRoot && (col.transform == characterRoot || col.transform.IsChildOf(characterRoot))) continue;
if (col is TerrainCollider)
{
Terrain terr = col.GetComponent<Terrain>();
if (terr) _terrains.Add(terr);
continue;
}
if (col is MeshCollider mc && !mc.convex)
{
// A baked ConcealmentVolume or ConcealmentColliderBaker supersedes raycast parity for this object.
if (includeConcaveMeshColliders && !mc.GetComponentInParent<ConcealmentVolume>()
&& !mc.GetComponentInParent<ConcealmentColliderBaker>())
_concave.Add(mc);
continue; // concave can't use ClosestPoint
}
_convex.Add(col); // Box / Sphere / Capsule / convex MeshCollider
}
}
private bool IsInsideAnyEnvironment(Vector3 p)
{
for (int i = 0; i < _volumes.Count; i++)
{
if (_volumes[i].ContainsWorld(p)) return true;
}
for (int i = 0; i < _convex.Count; i++)
{
if ((_convex[i].ClosestPoint(p) - p).sqrMagnitude <= InsideEpsSqr) return true;
}
if (includeTerrain)
{
for (int i = 0; i < _terrains.Count; i++)
{
Terrain terr = _terrains[i];
Vector3 tp = terr.transform.position;
TerrainData td = terr.terrainData;
float lx = p.x - tp.x, lz = p.z - tp.z;
if (lx < 0f || lz < 0f || lx > td.size.x || lz > td.size.z) continue;
if (p.y <= tp.y + terr.SampleHeight(p)) return true; // below the surface
}
}
if (includeConcaveMeshColliders)
{
for (int i = 0; i < _concave.Count; i++)
{
if (InsideConcave(_concave[i], p)) return true;
}
}
return false;
}
/// <summary>Parity test for a closed concave MeshCollider: odd # of surface crossings straight up = inside.</summary>
private static bool InsideConcave(MeshCollider mc, Vector3 p)
{
const float maxDist = 1000f;
const float skin = 1e-4f;
Vector3 dir = Vector3.up;
Ray ray = new Ray(p, dir);
int hits = 0;
float travelled = 0f;
while (travelled < maxDist && mc.Raycast(ray, out RaycastHit h, maxDist - travelled))
{
hits++;
travelled += h.distance + skin;
ray = new Ray(h.point + dir * skin, dir);
}
return (hits & 1) == 1;
}
private static float AverageScale(Transform t)
{
Vector3 s = t.lossyScale;
return (Mathf.Abs(s.x) + Mathf.Abs(s.y) + Mathf.Abs(s.z)) / 3f;
}
private void OnDrawGizmos()
{
if (!drawGizmos || _gizPos.Count == 0) return;
Vector3 cube = Vector3.one * (_gizVoxelWorld * 0.9f);
for (int i = 0; i < _gizPos.Count; i++)
{
Gizmos.color = _gizEmbedded[i] ? embeddedColor : exposedColor;
Gizmos.DrawCube(_gizPos[i], cube);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8baaa10e1e77f0944b3aa0b4312fc5a1
@@ -0,0 +1,262 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Editor-baked solid/enclosed voxel volume for a single (typically non-convex, non-watertight)
/// environment mesh. Runtime lookup is an O(1) grid test, so <see cref="ConcealmentMeter"/> can
/// treat furniture as "embeddable" without per-voxel raycast parity (which is unreliable on the
/// open-shell furniture meshes of asset kits).
///
/// Bake (editor only): voxelizes the source mesh in LOCAL space and marks a voxel as INSIDE when
/// either classifier fires:
/// * Generalized Winding Number &gt; 0.5 -> robustly captures SOLID material even for open shells.
/// * Exterior flood-fill cannot reach it -> captures fully ENCLOSED air cavities.
/// The union is conservative in the safe direction: an unreachable solid OR an enclosed pocket both
/// count as concealment; open regions (seat surface, under a thin table) correctly read as exposed.
///
/// Query is done via <see cref="ContainsWorld"/>; bake and query share the SAME mesh-local space
/// (verts at bake time, transform.InverseTransformPoint at query time), so position/rotation/scale
/// are handled uniformly.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentVolume : MonoBehaviour
{
public enum BakeMethod { WindingNumberOrFloodFill, WindingNumberOnly, FloodFillOnly }
[Header("Bake settings")]
[Min(2)]
[Tooltip("Voxels along the longest local-bounds axis. Higher = sharper & bigger bake. ~32 is a good start.")]
[SerializeField] private int resolution = 32;
[Tooltip("Source mesh. Auto = MeshCollider.sharedMesh, else MeshFilter.sharedMesh on this object.")]
[SerializeField] private Mesh sourceMesh;
[SerializeField] private BakeMethod method = BakeMethod.WindingNumberOrFloodFill;
[Header("Debug")]
[SerializeField] private bool drawBakedGizmos;
[SerializeField] private Color gizmoColor = new Color(0.2f, 0.6f, 1f, 0.35f);
// --- Baked data (local space) ---
[SerializeField, HideInInspector] private Vector3 origin; // local min corner of the grid
[SerializeField, HideInInspector] private float voxelSize;
[SerializeField, HideInInspector] private int nx, ny, nz;
[SerializeField, HideInInspector] private uint[] bits; // packed inside-flags, idx = ix + nx*(iy + ny*iz)
[SerializeField, HideInInspector] private int insideCount;
public bool HasBake => bits != null && bits.Length > 0 && nx > 0;
private static readonly List<ConcealmentVolume> Active = new List<ConcealmentVolume>();
public static IReadOnlyList<ConcealmentVolume> All => Active;
private void OnEnable() { if (!Active.Contains(this)) Active.Add(this); }
private void OnDisable() { Active.Remove(this); }
/// <summary>World-space AABB of the baked grid (for broadphase rejection).</summary>
public Bounds WorldBounds
{
get
{
Vector3 mn = origin;
Vector3 mx = origin + new Vector3(nx * voxelSize, ny * voxelSize, nz * voxelSize);
var local = new Bounds();
local.SetMinMax(mn, mx);
return GeometryUtil.TransformBounds(transform.localToWorldMatrix, local);
}
}
/// <summary>O(1) test: is the world point inside a baked-as-solid/enclosed voxel?</summary>
public bool ContainsWorld(Vector3 world)
{
if (!HasBake) return false;
Vector3 lp = transform.InverseTransformPoint(world);
int ix = Mathf.FloorToInt((lp.x - origin.x) / voxelSize);
if (ix < 0 || ix >= nx) return false;
int iy = Mathf.FloorToInt((lp.y - origin.y) / voxelSize);
if (iy < 0 || iy >= ny) return false;
int iz = Mathf.FloorToInt((lp.z - origin.z) / voxelSize);
if (iz < 0 || iz >= nz) return false;
int idx = ix + nx * (iy + ny * iz);
return (bits[idx >> 5] & (1u << (idx & 31))) != 0;
}
#if UNITY_EDITOR
[ContextMenu("Bake Concealment Volume")]
public void Bake()
{
Mesh mesh = sourceMesh;
if (!mesh)
{
var mc = GetComponent<MeshCollider>();
if (mc) mesh = mc.sharedMesh;
}
if (!mesh)
{
var mf = GetComponent<MeshFilter>();
if (mf) mesh = mf.sharedMesh;
}
if (!mesh) { Debug.LogError($"[ConcealmentVolume] {name}: no source mesh.", this); return; }
Vector3[] verts = mesh.vertices;
int[] tris = mesh.triangles;
if (verts.Length == 0 || tris.Length < 3) { Debug.LogError($"[ConcealmentVolume] {name}: empty mesh.", this); return; }
Bounds lb = mesh.bounds;
float maxExtent = Mathf.Max(lb.size.x, Mathf.Max(lb.size.y, lb.size.z));
if (maxExtent <= 1e-6f) { Debug.LogError($"[ConcealmentVolume] {name}: degenerate bounds.", this); return; }
float vs = maxExtent / resolution;
const int pad = 1; // empty shell so exterior flood always has a seed
nx = Mathf.CeilToInt(lb.size.x / vs) + 2 * pad;
ny = Mathf.CeilToInt(lb.size.y / vs) + 2 * pad;
nz = Mathf.CeilToInt(lb.size.z / vs) + 2 * pad;
voxelSize = vs;
origin = lb.min - new Vector3(vs, vs, vs) * pad;
int count = nx * ny * nz;
bool[] wall = new bool[count];
// --- 1) Rasterize triangles into wall voxels (barycentric supersampling, step = vs/2) ---
for (int t = 0; t < tris.Length; t += 3)
{
Vector3 a = verts[tris[t]], b = verts[tris[t + 1]], c = verts[tris[t + 2]];
float eMax = Mathf.Max((b - a).magnitude, (c - a).magnitude);
int steps = Mathf.Max(1, Mathf.CeilToInt(eMax / (vs * 0.5f)));
float inv = 1f / steps;
for (int u = 0; u <= steps; u++)
{
for (int v = 0; v <= steps - u; v++)
{
Vector3 p = a + (b - a) * (u * inv) + (c - a) * (v * inv);
int ix = Mathf.Clamp(Mathf.FloorToInt((p.x - origin.x) / vs), 0, nx - 1);
int iy = Mathf.Clamp(Mathf.FloorToInt((p.y - origin.y) / vs), 0, ny - 1);
int iz = Mathf.Clamp(Mathf.FloorToInt((p.z - origin.z) / vs), 0, nz - 1);
wall[ix + nx * (iy + ny * iz)] = true;
}
}
}
// --- 2) Exterior flood-fill (6-connected) through non-wall voxels ---
bool[] outside = new bool[count];
var stack = new Stack<int>(count / 4 + 16);
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
if (ix != 0 && ix != nx - 1 && iy != 0 && iy != ny - 1 && iz != 0 && iz != nz - 1) continue;
int idx = ix + nx * (iy + ny * iz);
if (!wall[idx] && !outside[idx]) { outside[idx] = true; stack.Push(idx); }
}
while (stack.Count > 0)
{
int idx = stack.Pop();
int ix = idx % nx;
int iy = (idx / nx) % ny;
int iz = idx / (nx * ny);
PushNeighbor(ix - 1, iy, iz, wall, outside, stack);
PushNeighbor(ix + 1, iy, iz, wall, outside, stack);
PushNeighbor(ix, iy - 1, iz, wall, outside, stack);
PushNeighbor(ix, iy + 1, iz, wall, outside, stack);
PushNeighbor(ix, iy, iz - 1, wall, outside, stack);
PushNeighbor(ix, iy, iz + 1, wall, outside, stack);
}
// --- 3) Classify each voxel; pack into bits ---
bits = new uint[(count + 31) >> 5];
insideCount = 0;
int floodOnly = 0, windingOnly = 0;
bool useWinding = method != BakeMethod.FloodFillOnly;
bool useFlood = method != BakeMethod.WindingNumberOnly;
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
int idx = ix + nx * (iy + ny * iz);
bool enclosed = useFlood && !outside[idx];
bool solid = false;
if (useWinding)
solid = WindingNumber(VoxelCenter(origin, ix, iy, iz, vs), verts, tris) > 0.5;
bool inside = enclosed || solid;
if (!inside) continue;
bits[idx >> 5] |= 1u << (idx & 31);
insideCount++;
if (enclosed && !solid) floodOnly++;
if (solid && !enclosed) windingOnly++;
}
UnityEditor.Undo.RecordObject(this, "Bake Concealment Volume");
UnityEditor.EditorUtility.SetDirty(this);
if (!Application.isPlaying)
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
Debug.Log($"[ConcealmentVolume] {name}: baked {nx}x{ny}x{nz} (vs={vs:F3}) -> inside={insideCount}/{count} " +
$"(winding-only={windingOnly}, flood-only={floodOnly}), bits={bits.Length * 4}B", this);
}
[ContextMenu("Clear Bake")]
public void ClearBake()
{
bits = null; nx = ny = nz = 0; insideCount = 0;
UnityEditor.EditorUtility.SetDirty(this);
}
private void PushNeighbor(int ix, int iy, int iz, bool[] wall, bool[] outside, Stack<int> stack)
{
if (ix < 0 || ix >= nx || iy < 0 || iy >= ny || iz < 0 || iz >= nz) return;
int idx = ix + nx * (iy + ny * iz);
if (wall[idx] || outside[idx]) return;
outside[idx] = true;
stack.Push(idx);
}
/// <summary>Generalized winding number at p (mesh-local). ~1 inside a solid, ~0 outside; robust for open shells.</summary>
private static double WindingNumber(Vector3 p, Vector3[] verts, int[] tris)
{
double sum = 0.0;
double px = p.x, py = p.y, pz = p.z;
for (int t = 0; t < tris.Length; t += 3)
{
Vector3 A = verts[tris[t]], B = verts[tris[t + 1]], C = verts[tris[t + 2]];
double ax = A.x - px, ay = A.y - py, az = A.z - pz;
double bx = B.x - px, by = B.y - py, bz = B.z - pz;
double cx = C.x - px, cy = C.y - py, cz = C.z - pz;
double la = System.Math.Sqrt(ax * ax + ay * ay + az * az);
double lb = System.Math.Sqrt(bx * bx + by * by + bz * bz);
double lc = System.Math.Sqrt(cx * cx + cy * cy + cz * cz);
if (la < 1e-12 || lb < 1e-12 || lc < 1e-12) continue;
// triple product a . (b x c)
double crx = by * cz - bz * cy;
double cry = bz * cx - bx * cz;
double crz = bx * cy - by * cx;
double numer = ax * crx + ay * cry + az * crz;
double denom = la * lb * lc
+ (ax * bx + ay * by + az * bz) * lc
+ (bx * cx + by * cy + bz * cz) * la
+ (cx * ax + cy * ay + cz * az) * lb;
sum += System.Math.Atan2(numer, denom) * 2.0;
}
return sum / (4.0 * System.Math.PI);
}
/// <summary>Local-space center of grid voxel (ix, iy, iz).</summary>
private static Vector3 VoxelCenter(Vector3 origin, int ix, int iy, int iz, float size)
=> new Vector3(origin.x + (ix + 0.5f) * size, origin.y + (iy + 0.5f) * size, origin.z + (iz + 0.5f) * size);
private void OnDrawGizmosSelected()
{
if (!drawBakedGizmos || !HasBake) return;
Gizmos.color = gizmoColor;
Gizmos.matrix = transform.localToWorldMatrix;
Vector3 cube = Vector3.one * (voxelSize * 0.9f);
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
int idx = ix + nx * (iy + ny * iz);
if ((bits[idx >> 5] & (1u << (idx & 31))) == 0) continue;
Gizmos.DrawCube(VoxelCenter(origin, ix, iy, iz, voxelSize), cube);
}
}
#endif
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a59a347a3128354ebd4f84afc97a0c5