using System.Collections.Generic; using UnityEngine; /// /// 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 can /// treat furniture as "embeddable" without per-voxel raycast parity (unreliable on the open-shell /// furniture meshes of asset kits). /// /// The bake marks a voxel INSIDE when either classifier fires: generalized winding number > 0.5 /// (captures SOLID material, robust for open shells) OR unreachable by exterior flood-fill /// (captures fully ENCLOSED air cavities). The union errs in the safe direction — solid or enclosed /// both count as concealment; open regions (seat surface, under a thin table) read as exposed. /// /// Bake and query () share the SAME mesh-local space, so /// position/rotation/scale are handled uniformly. /// [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 Active = new List(); public static IReadOnlyList All => Active; private void OnEnable() { if (!Active.Contains(this)) Active.Add(this); } private void OnDisable() { Active.Remove(this); } /// World-space AABB of the baked grid (for broadphase rejection). 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); } } /// O(1) test: is the world point inside a baked-as-solid/enclosed voxel? 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(); if (mc) mesh = mc.sharedMesh; } if (!mesh) { var mf = GetComponent(); 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(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 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); } /// Generalized winding number at p (mesh-local). ~1 inside a solid, ~0 outside; robust for open shells. 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); } /// Local-space center of grid voxel (ix, iy, iz). 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 }