using System.Collections.Generic; using UnityEngine; /// /// 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. is the headline value, /// its complement. /// /// Pipeline: /// 1. -> 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. /// [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(); 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. 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 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(); // 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; 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(); 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); 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; } /// 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); } } }