Sweep over 48 files (Editor/ excluded): remove comments that restate the code, condense class summaries, fix three stale doc statements (HsvColorPicker alpha bar, PaintHud layoutPrefab, MasterVolume FallbackDefault). Inline two single-use wrappers (IsPointerOverUi, BuildModes), drop an unused using, replace a magic literal with the existing ParallaxBase constant, and convert ~10 UnityEngine.Object null checks to the implicit-bool convention. No serialized fields, signatures, RPC/NetworkVariable names, defaults, or #if blocks touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260 lines
11 KiB
C#
260 lines
11 KiB
C#
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 (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 (<see cref="ContainsWorld"/>) share the SAME mesh-local space, 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
|
|
}
|