using System.Collections.Generic;
using UnityEngine;
///
/// Standalone volumetric concealment meter for the painted character. Each evaluation bakes the
/// current (deformed) skinned mesh, voxelizes its SOLID volume, and reports the fraction embedded
/// inside environment colliders: is the headline value,
/// its complement.
///
/// Pipeline: BakeMesh with the player's world scale divided back out (see Evaluate), then column
/// ray-stabbing in local space — odd # of surface crossings below a voxel center marks it interior
/// (same XZ triangle rasterization MeshPainter.GetIslandMask uses) — then each interior voxel center
/// is tested in world space against broadphase-filtered environment colliders: convex via
/// ClosestPoint, Terrain via SampleHeight, optional concave MeshCollider via raycast parity.
///
/// 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.
///
[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);
/// Percentage [0..100] of the character's solid volume embedded inside environment colliders.
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 _verts = new List(); // 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 _vbuf = new List(); // 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[] _columns;
// Broadphase scratch + classified environment candidates (rebuilt each eval).
private readonly Collider[] _overlap = new Collider[64];
private readonly List _convex = new List();
private readonly List _terrains = new List();
private readonly List _concave = new List();
private readonly List _volumes = new List();
// World AABBs parallel to _convex/_concave/_volumes, fetched ONCE per eval. The per-voxel hot loop
// rejects a candidate with a cheap Bounds.Contains before the expensive ClosestPoint / raycast /
// InverseTransformPoint. A point inside a collider is always inside its world AABB, so this is a
// conservative filter -> never a false negative, only fewer expensive tests. This is what keeps cost
// ~O(voxels) instead of O(voxels x colliders) when the body overlaps many colliders (e.g. VHACD hulls).
private readonly List _convexBounds = new List();
private readonly List _concaveBounds = new List();
private readonly List _volumeBounds = new List();
private readonly List _gizPos = new List();
private readonly List _gizEmbedded = new List();
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();
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();
}
/// Bakes the current pose, voxelizes it, and recomputes the embedded percentage.
[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, and everything below re-applies that scale by raising the
// bake to world via localToWorldMatrix, so a scaled player would be measured at scale²,
// corrupting EmbeddedPercent. Divide it back out first. No-op at scale 1; the player's
// uniform scale makes the divide rotation-independent. Mirrors SkinnedColliderBaker's 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 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;
}
///
/// 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).
///
private void StabColumns(Vector3 origin, float voxel, int nx, int nz)
{
int cells = nx * nz;
if (_columns == null || _columns.Length < cells) _columns = new List[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(8))).Add(y);
}
}
}
}
/// Broadphase-overlaps the character bounds and classifies the hit colliders by test type, excluding the character itself.
private void BuildEnvironmentCandidates(Bounds wb)
{
_convex.Clear();
_terrains.Clear();
_concave.Clear();
_volumes.Clear();
_convexBounds.Clear();
_concaveBounds.Clear();
_volumeBounds.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 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;
Bounds vb = v.WorldBounds; // getter recomputes a matrix transform -> take it once, reuse as the hot-loop filter
if (vb.Intersects(wb)) { _volumes.Add(v); _volumeBounds.Add(vb); }
}
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();
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()
&& !mc.GetComponentInParent())
{
_concave.Add(mc);
_concaveBounds.Add(mc.bounds);
}
continue; // concave can't use ClosestPoint
}
_convex.Add(col); // Box / Sphere / Capsule / convex MeshCollider
_convexBounds.Add(col.bounds);
}
}
private bool IsInsideAnyEnvironment(Vector3 p)
{
for (int i = 0; i < _volumes.Count; i++)
{
// AABB reject first: skips the ContainsWorld InverseTransformPoint matrix mult for far voxels.
if (_volumeBounds[i].Contains(p) && _volumes[i].ContainsWorld(p)) return true;
}
for (int i = 0; i < _convex.Count; i++)
{
// AABB reject first: ClosestPoint (esp. on convex MeshColliders / VHACD hulls) is the dominant
// per-voxel cost; a point outside the collider's world AABB cannot be inside it.
if (_convexBounds[i].Contains(p) &&
(_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;
}
}
if (includeConcaveMeshColliders)
{
for (int i = 0; i < _concave.Count; i++)
{
// AABB reject first: InsideConcave fires a raycast loop up to 1000 units per voxel -> gate hard.
if (_concaveBounds[i].Contains(p) && InsideConcave(_concave[i], p)) return true;
}
}
return false;
}
/// Parity test for a closed concave MeshCollider: odd # of surface crossings straight up = inside.
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);
}
}
}