Replace unreliable raycast parity on open-shell meshes with editor-baked voxel grids. ConcealmentVolume bakes an inside-mask per object using the union of a generalized-winding-number test (solid material, robust to open shells) and an exterior flood-fill (enclosed cavities). ConcealmentMeter now prefers baked volumes via an O(1) world-space lookup through a registry, falling back to the old concave parity only for un-baked colliders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
392 lines
16 KiB
C#
392 lines
16 KiB
C#
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
|
|
/// (same convention MeshPainter uses for its collider; world = transform.localToWorldMatrix).
|
|
/// 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;
|
|
|
|
public int InteriorVoxelCount { get; private set; }
|
|
public int EmbeddedVoxelCount { get; private set; }
|
|
public bool HasResult { get; private set; }
|
|
|
|
private Mesh _baked;
|
|
private Vector3[] _verts;
|
|
private int[] _tris;
|
|
private float _timer;
|
|
|
|
// 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>();
|
|
|
|
// Gizmo cache.
|
|
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);
|
|
_verts = _baked.vertices;
|
|
_tris = _baked.triangles;
|
|
if (_verts.Length == 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 = 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();
|
|
|
|
for (int iy = 0; iy < ny; iy++)
|
|
{
|
|
float cy = origin.y + (iy + 0.5f) * voxel;
|
|
|
|
int below = 0;
|
|
for (int k = 0; k < col.Count; k++)
|
|
{
|
|
if (col[k] <= cy) below++;
|
|
else break; // sorted ascending
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
InteriorVoxelCount = interior;
|
|
EmbeddedVoxelCount = embedded;
|
|
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) n = _overlap.Length;
|
|
|
|
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 volume supersedes raycast parity for this object.
|
|
if (includeConcaveMeshColliders && !mc.GetComponentInParent<ConcealmentVolume>())
|
|
_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;
|
|
}
|
|
|
|
/// <summary>World AABB enclosing a local AABB after an affine transform (encapsulates the 8 transformed corners).</summary>
|
|
private static Bounds TransformBounds(Matrix4x4 m, Bounds local)
|
|
{
|
|
Vector3 mn = local.min, mx = local.max;
|
|
Vector3 c0 = m.MultiplyPoint3x4(mn);
|
|
Bounds b = new Bounds(c0, Vector3.zero);
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mn.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mn.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mn.y, mx.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mx.y, mn.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mx.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mx.z)));
|
|
b.Encapsulate(m.MultiplyPoint3x4(mx));
|
|
return b;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|