Add concealment meter and skinned collider baker; reorganize assets

- ConcealmentMeter: standalone component measuring the percentage of the
  character volume embedded inside environment geometry (bake -> voxelize via
  column ray-stabbing parity -> classify interior voxels against box/sphere/
  capsule/convex colliders and terrain). Always-on, throttled, gizmo debug.
- SkinnedColliderBaker: sole per-frame collider bake owner for skinned rigs.
  MeshPainter now delegates baking and only borrows BakedMesh for the GPU
  raster and cache refresh, so the collider stays fresh outside paint mode.
- Move pose assets to Assets/Poses, retire unused Insect scripts to
  Assets/Unused, restore Paintman.controller; mode scripts and scene updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-26 12:37:02 +02:00
1 parent def10541ab
commit dcfd668422
29 files changed
+2160 -1656

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
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>();
// 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 ||
(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();
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)
{
if (includeConcaveMeshColliders) _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 < _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);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8baaa10e1e77f0944b3aa0b4312fc5a1
+6 -6
View File
@@ -22,8 +22,8 @@ public class GameModeManager : MonoBehaviour
[Tooltip("Mesh painter. Empty = found automatically.")]
public MeshPainter painter;
[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
public InsectController insect;
//[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
//public InsectController insect;
[Header("Switching")]
[Tooltip("Key that cycles to the next mode.")]
@@ -39,7 +39,7 @@ public class GameModeManager : MonoBehaviour
public PlayerCharacterController Player => player;
public ThirdPersonOrbitInput CameraInput => cameraInput;
public MeshPainter Painter => painter;
public InsectController Insect => insect;
//public InsectController Insect => insect;
/// <summary>Raised after a mode switch finishes (the new mode's Enter has already run).
/// Argument = the now-active mode. Used by UI (e.g. the paint HUD) to follow the mode.</summary>
@@ -57,7 +57,7 @@ public class GameModeManager : MonoBehaviour
if (!player) player = FindFirstObjectByType<PlayerCharacterController>();
if (!cameraInput) cameraInput = FindFirstObjectByType<ThirdPersonOrbitInput>();
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
if (!insect) insect = FindFirstObjectByType<InsectController>();
//if (!insect) insect = FindFirstObjectByType<InsectController>();
BuildModes();
}
@@ -82,8 +82,8 @@ public class GameModeManager : MonoBehaviour
// Insect mode only joins the Tab cycle when a surface walker is present, so a scene
// without one behaves exactly as before (no dead, player-freezing mode in the cycle).
if (insect)
_modes.Add(new InsectMode());
//if (insect)
// _modes.Add(new InsectMode());
foreach (var m in _modes)
m.Bind(this);
-335
View File
@@ -1,335 +0,0 @@
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Gravity-free "insect" surface walker: crawls along any surface (floors, ramps,
/// walls, ceilings, around convex/concave edges) regardless of slope. No
/// CharacterController, no Rigidbody — the transform is moved kinematically and
/// re-stuck to the surface every frame via sphere casts.
///
/// Each step: WASD is taken camera-relative (the camera stays world-up) and
/// projected onto the current surface tangent plane, the body advances along that
/// plane, then a small set of probes re-finds the surface to follow:
/// 1. a wall directly ahead (concave corner) becomes the new ground,
/// 2. otherwise a down-cast (into the surface) keeps the body stuck on
/// flat/ramped/concave ground,
/// 3. otherwise a forward-sweeping cast wraps the body over a convex edge.
/// The body's up is aligned to the surface normal; its forward faces the move
/// direction. Designed to coexist with <see cref="PlayerCharacterController"/> on
/// the same GameObject, toggled by the GameModeManager (only one is enabled).
/// </summary>
[DisallowMultipleComponent]
public class InsectController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Crawl speed along the surface (units/second).")]
public float moveSpeed = 4f;
[Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")]
public float sprintSpeedMultiplier = 1.8f;
[Tooltip("How snappily the body re-aligns its up to the surface normal and turns toward the move direction (1/second; higher = tighter).")]
public float alignSmoothing = 16f;
[Header("Camera")]
[Tooltip("Interpret WASD relative to the camera (projected onto the surface). Off = world X/Z axes projected onto the surface.")]
public bool cameraRelative = true;
[Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")]
public Transform cameraTransform;
[Header("Surface Sticking")]
[Tooltip("Distance kept between the transform origin and the surface it walks on (tune to the character's pivot; 0 = origin sits on the surface).")]
public float hoverHeight = 0f;
[Tooltip("Radius of the probe spheres. Roughly the body's half-width; keeps the walker off sharp edges.")]
public float probeRadius = 0.3f;
[Tooltip("How far the body can step up onto a rise or drop down a dip while staying stuck.")]
public float stepHeight = 0.4f;
[Tooltip("Surfaces the walker can stick to.")]
public LayerMask groundMask = ~0;
[Header("Edge Wrapping")]
[Tooltip("A surface ahead steeper than this (degrees from the current up) is treated as a wall to climb into (concave corner).")]
[Range(5f, 90f)]
public float wallWrapAngle = 35f;
[Tooltip("How far ahead to look for a wall to climb into.")]
public float wallProbeDistance = 0.6f;
[Tooltip("Largest angle the body wraps around a convex edge in one step (degrees). Higher = follows sharper outside corners.")]
[Range(30f, 170f)]
public float maxWrapAngle = 150f;
[Header("Animation (optional)")]
public Animator animator;
[Tooltip("Animator bool set true while crawling.")]
public string runningParameter = "Running";
/// <summary>When false, input is ignored (the body holds its surface pose).</summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True after the last step found a surface to stick to.</summary>
public bool OnSurface => _onSurface;
/// <summary>Surface normal the body is currently stuck to (its up).</summary>
public Vector3 SurfaceNormal => _surfaceNormal;
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
private const float Eps = 1e-5f;
private int _runningHash;
private bool _onSurface;
private Vector3 _surfaceNormal = Vector3.up;
private Vector3 _lastMove;
private readonly RaycastHit[] _hits = new RaycastHit[16];
void Awake()
{
_runningHash = Animator.StringToHash(runningParameter);
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (groundMask.value == 0)
groundMask = ~0;
_surfaceNormal = transform.up;
}
void Update()
{
float h = 0f, v = 0f;
bool sprinting = false;
if (ControlEnabled)
{
#if ENABLE_INPUT_SYSTEM
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.aKey.isPressed) h -= 1f;
if (keyboard.dKey.isPressed) h += 1f;
if (keyboard.sKey.isPressed) v -= 1f;
if (keyboard.wKey.isPressed) v += 1f;
sprinting = keyboard.leftShiftKey.isPressed;
}
#else
h = Input.GetAxisRaw("Horizontal");
v = Input.GetAxisRaw("Vertical");
sprinting = Input.GetKey(KeyCode.LeftShift);
#endif
}
Step(h, v, sprinting, Time.deltaTime);
}
/// <summary>
/// Advances the body one frame: projects the (h,v) input onto the surface,
/// moves along it, re-sticks to the surface and re-orients. Public so it can be
/// driven deterministically in tests (set <see cref="cameraRelative"/> false to
/// map input to world axes without a camera).
/// </summary>
public void Step(float h, float v, bool sprinting, float dt)
{
Vector3 up = transform.up;
Vector3 wish = InputToWorld(up, h, v);
float inputMag = Mathf.Clamp01(new Vector2(h, v).magnitude);
float speed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed;
Vector3 move = wish * (speed * inputMag * dt);
Vector3 tentative = transform.position + move;
_lastMove = move;
if (ProbeSurface(tentative, up, wish, out Vector3 point, out Vector3 normal)
&& normal.sqrMagnitude > Eps)
{
normal.Normalize();
transform.position = point + normal * hoverHeight;
_surfaceNormal = normal;
_onSurface = true;
OrientTo(normal, wish, dt);
}
else
{
// No surface within reach (e.g. ran off the end of geometry): keep
// crawling along the last tangent instead of falling.
transform.position = tentative;
_onSurface = false;
}
if (animator)
animator.SetBool(_runningHash, _onSurface && inputMag > 0f
&& _lastMove.sqrMagnitude / Mathf.Max(dt * dt, 1e-8f) > RunningSpeedThresholdSqr);
}
/// <summary>
/// Immediately probes straight into the current surface and places the body on
/// it (no smoothing). Used when entering insect mode so the first frame is stuck.
/// </summary>
public void SnapToSurface()
{
Vector3 up = transform.up;
if (ProbeSurface(transform.position, up, Vector3.zero, out Vector3 point, out Vector3 normal)
&& normal.sqrMagnitude > Eps)
{
normal.Normalize();
transform.position = point + normal * hoverHeight;
_surfaceNormal = normal;
_onSurface = true;
Vector3 fwd = Vector3.ProjectOnPlane(transform.forward, normal);
if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.right, normal);
if (fwd.sqrMagnitude > Eps)
transform.rotation = Quaternion.LookRotation(fwd.normalized, normal);
}
}
/// <summary>
/// Maps WASD to a world-space direction in the surface tangent plane. The camera
/// stays world-up, so its axes are projected onto the plane; if an axis is nearly
/// parallel to the surface normal (on a wall/ceiling) it is rebuilt from the other
/// axis so the mapping never collapses.
/// </summary>
private Vector3 InputToWorld(Vector3 up, float h, float v)
{
Vector3 right, fwd;
if (cameraRelative && cameraTransform)
{
right = Vector3.ProjectOnPlane(cameraTransform.right, up);
fwd = Vector3.ProjectOnPlane(cameraTransform.forward, up);
}
else
{
right = Vector3.ProjectOnPlane(Vector3.right, up);
fwd = Vector3.ProjectOnPlane(Vector3.forward, up);
}
if (fwd.sqrMagnitude < Eps) fwd = Vector3.Cross(right, up);
if (right.sqrMagnitude < Eps) right = Vector3.Cross(up, fwd);
if (fwd.sqrMagnitude < Eps || right.sqrMagnitude < Eps)
return Vector3.zero;
Vector3 dir = right.normalized * h + fwd.normalized * v;
return dir.sqrMagnitude > 1f ? dir.normalized : dir;
}
/// <summary>
/// Finds the surface the body should stick to at <paramref name="pos"/>, trying in
/// order: a wall ahead (concave corner), the ground below (flat/ramp/concave), a
/// convex edge wrap. Returns the surface contact point and normal.
/// </summary>
private bool ProbeSurface(Vector3 pos, Vector3 up, Vector3 moveDir, out Vector3 point, out Vector3 normal)
{
bool moving = moveDir.sqrMagnitude > Eps;
// Lift the sideways/edge probes clear of the surface we are already on, so the
// probe sphere never starts penetrating it (which would self-hit or, worse, make
// SphereCast report a bogus point at the world origin on an initial overlap).
Vector3 probeOrigin = pos + up * Mathf.Max(hoverHeight, probeRadius + 0.05f);
// 1) Concave corner: a wall right in front becomes the new ground to climb into.
if (moving)
{
Vector3 d = moveDir.normalized;
if (CastBest(probeOrigin, d, probeRadius + wallProbeDistance, out Vector3 wp, out Vector3 wn)
&& Vector3.Angle(wn, up) > wallWrapAngle)
{
point = wp; normal = wn; return true;
}
}
// 2) Ground follow: cast from above the body straight into the surface.
float startUp = hoverHeight + stepHeight;
float downDist = startUp + hoverHeight + stepHeight + probeRadius;
if (CastBest(pos + up * startUp, -up, downDist, out Vector3 gp, out Vector3 gn))
{
point = gp; normal = gn; return true;
}
// 3) Convex edge: the surface curved away ahead. Sweep the down-probe forward,
// pitching it over the edge until it catches the wrapped surface.
if (moving)
{
Vector3 axis = Vector3.Cross(moveDir.normalized, up);
if (axis.sqrMagnitude > Eps)
{
axis.Normalize();
float reach = hoverHeight + stepHeight + probeRadius + 0.5f;
for (float ang = 20f; ang <= maxWrapAngle; ang += 20f)
{
Vector3 d = Quaternion.AngleAxis(ang, axis) * (-up);
if (CastBest(probeOrigin, d, reach, out Vector3 ep, out Vector3 en))
{
point = ep; normal = en; return true;
}
}
}
}
point = pos; normal = up; return false;
}
/// <summary>
/// Sphere-casts and returns the nearest hit that is not our own collider and has a
/// usable (non-zero) normal. False if nothing valid was hit.
/// </summary>
private bool CastBest(Vector3 origin, Vector3 dir, float dist, out Vector3 point, out Vector3 normal)
{
point = origin;
normal = Vector3.zero;
if (dir.sqrMagnitude < Eps)
return false;
int n = Physics.SphereCastNonAlloc(
origin, probeRadius, dir.normalized, _hits, dist, groundMask, QueryTriggerInteraction.Ignore);
float best = float.MaxValue;
bool found = false;
for (int i = 0; i < n; i++)
{
RaycastHit hit = _hits[i];
if (!hit.collider || hit.collider.transform.IsChildOf(transform))
continue;
// A zero distance means the sphere started inside the collider; Unity then
// reports point=(0,0,0) and normal=-dir, which would teleport us — discard it.
if (hit.distance <= 1e-4f || hit.normal.sqrMagnitude < Eps)
continue;
if (hit.distance < best)
{
best = hit.distance;
point = hit.point;
normal = hit.normal;
found = true;
}
}
return found;
}
/// <summary>Eases the body's up toward the surface normal and its forward toward the move direction.</summary>
private void OrientTo(Vector3 normal, Vector3 wish, float dt)
{
Vector3 fwd = Vector3.ProjectOnPlane(wish, normal);
// When the move/forward is parallel to the new normal (walking straight into a
// wall), fall back to the old up projected onto the surface: that points "up the
// wall", so the body keeps climbing instead of snapping a 90-degree yaw.
if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.forward, normal);
if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.up, normal);
if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.right, normal);
if (fwd.sqrMagnitude < Eps)
return;
Quaternion target = Quaternion.LookRotation(fwd.normalized, normal);
float t = alignSmoothing > 0f ? 1f - Mathf.Exp(-alignSmoothing * dt) : 1f;
transform.rotation = Quaternion.Slerp(transform.rotation, target, t);
}
void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Vector3 c = transform.position + transform.up * hoverHeight;
Gizmos.DrawWireSphere(c, probeRadius);
Gizmos.color = Color.green;
Gizmos.DrawLine(c, c + _surfaceNormal * 0.5f);
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: bf4039bad99e6e64aae35786e6a87ba6
-38
View File
@@ -1,38 +0,0 @@
/// <summary>
/// Insect / surface-walk mode: the player crawls along any surface (walls, ceilings,
/// arbitrary slopes) via <see cref="InsectController"/>. The gravity-based
/// <see cref="PlayerCharacterController"/> is disabled so the two never fight. The
/// camera keeps its world-up orbit; painting is off.
/// </summary>
public class InsectMode : GameMode
{
public override string DisplayName => "Insect";
public override void Enter()
{
if (Manager.Player)
Manager.Player.enabled = false; // stop the gravity controller (its Update/gravity)
if (Manager.Insect)
{
Manager.Insect.enabled = true;
Manager.Insect.ControlEnabled = true;
Manager.Insect.SnapToSurface();
}
if (Manager.CameraInput)
Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive;
if (Manager.Painter)
Manager.Painter.PaintingEnabled = false;
}
public override void Exit()
{
if (Manager.Insect)
Manager.Insect.enabled = false;
if (Manager.Player)
Manager.Player.enabled = true;
}
}
-2
View File
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5e82ea3f59c643648a33cf33b32dba58
+11 -2
View File
@@ -10,13 +10,22 @@ public partial class MeshPainter
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
_bakedMesh = new Mesh { name = "MeshPainter_Baked" };
// An always-on SkinnedColliderBaker (skinned rigs only) owns the bake even outside paint
// mode; we delegate to it and only borrow its mesh. Without one we keep our own legacy bake.
_baker = _skinned ? GetComponent<SkinnedColliderBaker>() : null;
if (!_baker) _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
BakeColliderMesh();
}
private void BakeColliderMesh()
{
if (_skinned)
if (_baker)
{
// Baker already baked (negative execution order) and keeps _collider.sharedMesh fresh;
// we only track its mesh for the GPU raster path. No bake here - that's the whole point.
_bakedMesh = _baker.BakedMesh;
}
else if (_skinned)
{
// Static skinned mesh: bake into local coordinates (scale = 1).
_skinned.BakeMesh(_bakedMesh);
+4 -1
View File
@@ -195,6 +195,9 @@ public partial class MeshPainter : MonoBehaviour
private SkinnedMeshRenderer _skinned;
private MeshCollider _collider;
private Mesh _bakedMesh;
// When present, this always-on baker owns the collider bake (also outside paint mode); we
// stop baking and only borrow its mesh. Null = no baker -> we keep our own legacy bake.
private SkinnedColliderBaker _baker;
private Material _materialInstance;
// Mesh caches (in local/collider space).
@@ -854,7 +857,7 @@ public partial class MeshPainter : MonoBehaviour
// scene unload that does not raise OnApplicationQuit; the once-guard prevents a re-save.
SavePaintOnTeardown();
if (_bakedMesh) Destroy(_bakedMesh);
if (_bakedMesh && !_baker) Destroy(_bakedMesh); // _baker owns its mesh; only free our own
if (_materialInstance) Destroy(_materialInstance);
if (_proceduralMask) Destroy(_proceduralMask);
if (_previewMat) Destroy(_previewMat);
+2 -2
View File
@@ -8,8 +8,8 @@ public class PaintMode : GameMode
public override void Enter()
{
if (Manager.Insect)
Manager.Insect.enabled = false;
//if (Manager.Insect)
// Manager.Insect.enabled = false;
if (Manager.Player)
{
+2 -2
View File
@@ -8,8 +8,8 @@ public class PlayerControlMode : GameMode
public override void Enter()
{
if (Manager.Insect)
Manager.Insect.enabled = false;
//if (Manager.Insect)
// Manager.Insect.enabled = false;
if (Manager.Player)
{
+73
View File
@@ -0,0 +1,73 @@
using UnityEngine;
/// <summary>
/// Sole owner of the animated character's collider bake: keeps a non-convex MeshCollider in sync with
/// the SkinnedMeshRenderer's current pose every frame, independent of any paint/game mode. MeshPainter's
/// own per-frame bake sits behind its PaintingEnabled gate, so the collider would otherwise freeze in
/// player-control / insect mode. When this component is present MeshPainter delegates to it (it stops
/// baking and only borrows <see cref="BakedMesh"/> for its GPU raster path), so the deformed-pose
/// collider stays fresh for everyone - paint raycasts, physics, future seeker / line-of-sight queries.
///
/// Bakes in the renderer's LOCAL space (BakeMesh, scale = 1) - the same convention MeshPainter and
/// ConcealmentMeter use (world = transform.localToWorldMatrix). Runs at a negative execution order so
/// the collider is fresh before MeshPainter (and other consumers) read it in the same frame. Owns the
/// baked Mesh (creates + destroys it); MeshPainter never destroys a borrowed mesh.
/// </summary>
[DisallowMultipleComponent]
[DefaultExecutionOrder(-50)]
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class SkinnedColliderBaker : MonoBehaviour
{
[Min(0f)]
[Tooltip("Seconds between collider re-bakes. 0 = every frame (matches MeshPainter's per-frame bake). " +
"Raise it to trade collider freshness for CPU on a high-poly rig.")]
[SerializeField] private float bakeInterval = 0f;
/// <summary>The mesh baked from the current pose, in local space. Valid after the first bake.</summary>
public Mesh BakedMesh => _baked;
private SkinnedMeshRenderer _skinned;
private MeshCollider _collider;
private Mesh _baked;
private float _timer;
private void Awake()
{
_skinned = GetComponent<SkinnedMeshRenderer>();
if (!_skinned)
{
Debug.LogError("[SkinnedColliderBaker] No SkinnedMeshRenderer on this GameObject.", this);
enabled = false;
return;
}
_collider = GetComponent<MeshCollider>();
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord for paint raycasts
_baked = new Mesh { name = "SkinnedColliderBaker_Baked" };
Bake(); // valid collider on frame 0, before any consumer reads it
}
private void Update()
{
if (bakeInterval <= 0f) { Bake(); return; }
_timer += Time.deltaTime;
if (_timer < bakeInterval) return;
_timer = 0f;
Bake();
}
private void Bake()
{
if (!_skinned) return;
_skinned.BakeMesh(_baked);
_collider.sharedMesh = null; // force a physics re-cook of the new geometry
_collider.sharedMesh = _baked;
}
private void OnDestroy()
{
if (_baked) Destroy(_baked);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 161b7cf82dd54e74d92b5ca72d3be3af