diff --git a/Assets/Scripts/CharacterTeleport.cs b/Assets/Scripts/CharacterTeleport.cs
new file mode 100644
index 0000000..2c686ff
--- /dev/null
+++ b/Assets/Scripts/CharacterTeleport.cs
@@ -0,0 +1,21 @@
+using UnityEngine;
+
+/// Disables a CharacterController around a transform write so it cannot snap the transform back.
+public static class CharacterTeleport
+{
+ public static void Move(CharacterController cc, Transform t, Vector3 position)
+ {
+ bool reenable = cc && cc.enabled;
+ if (cc) cc.enabled = false;
+ t.position = position;
+ if (cc) cc.enabled = reenable;
+ }
+
+ public static void Move(CharacterController cc, Transform t, Vector3 position, Quaternion rotation)
+ {
+ bool reenable = cc && cc.enabled;
+ if (cc) cc.enabled = false;
+ t.SetPositionAndRotation(position, rotation);
+ if (cc) cc.enabled = reenable;
+ }
+}
diff --git a/Assets/Scripts/CharacterTeleport.cs.meta b/Assets/Scripts/CharacterTeleport.cs.meta
new file mode 100644
index 0000000..546e61a
--- /dev/null
+++ b/Assets/Scripts/CharacterTeleport.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 52a1fdeaf96581840bc09373ff2c7ad0
\ No newline at end of file
diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs b/Assets/Scripts/ConcealmentColliderBaker.cs
index 93663b6..ae05d1a 100644
--- a/Assets/Scripts/ConcealmentColliderBaker.cs
+++ b/Assets/Scripts/ConcealmentColliderBaker.cs
@@ -198,16 +198,7 @@ public class ConcealmentColliderBaker : MonoBehaviour
int made = 0;
for (int ci = 0; ci < clusters.Count; ci++)
{
- var pset = new HashSet();
- var pts = new List();
- List cl = clusters[ci];
- for (int j = 0; j < cl.Count; j++)
- {
- int t = cl[j];
- if (pset.Add(triA[t])) pts.Add(wv[triA[t]]);
- if (pset.Add(triB[t])) pts.Add(wv[triB[t]]);
- if (pset.Add(triC[t])) pts.Add(wv[triC[t]]);
- }
+ List pts = CollectClusterPoints(clusters[ci], wv, triA, triB, triC);
float thick = SlabThickness(pts, out Vector3 normal);
List hullPts;
@@ -389,13 +380,25 @@ public class ConcealmentColliderBaker : MonoBehaviour
if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); }
}
- /// Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.
- private static float SlabThickness(List pts, out Vector3 normal)
+ /// Unique welded vertex positions of a triangle cluster.
+ private static List CollectClusterPoints(List cluster, List wv, List triA, List triB, List triC)
{
- normal = Vector3.up;
- int n = pts.Count;
- if (n < 2) return 0f;
+ var pts = new List(cluster.Count * 3);
+ var seen = new HashSet();
+ for (int j = 0; j < cluster.Count; j++)
+ {
+ int t = cluster[j];
+ if (seen.Add(triA[t])) pts.Add(wv[triA[t]]);
+ if (seen.Add(triB[t])) pts.Add(wv[triB[t]]);
+ if (seen.Add(triC[t])) pts.Add(wv[triC[t]]);
+ }
+ return pts;
+ }
+ /// Mean-centered 3x3 covariance of the point cloud, eigendecomposed via .
+ private static void CovarianceEigen(List pts, out double[,] vec, out double[] d)
+ {
+ int n = pts.Count;
Vector3 mean = Vector3.zero;
for (int i = 0; i < n; i++) mean += pts[i];
mean /= n;
@@ -409,7 +412,17 @@ public class ConcealmentColliderBaker : MonoBehaviour
}
var a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
- Jacobi3(a, out double[,] v, out double[] d);
+ Jacobi3(a, out vec, out d);
+ }
+
+ /// Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.
+ private static float SlabThickness(List pts, out Vector3 normal)
+ {
+ normal = Vector3.up;
+ int n = pts.Count;
+ if (n < 2) return 0f;
+
+ CovarianceEigen(pts, out double[,] v, out double[] d);
int mi = 0;
if (d[1] < d[mi]) mi = 1;
@@ -483,15 +496,7 @@ public class ConcealmentColliderBaker : MonoBehaviour
///
private static float ClusterFill(List cluster, List wv, List triA, List triB, List triC, out Vector3 splitAxis)
{
- var pts = new List(cluster.Count * 3);
- var seen = new HashSet();
- for (int j = 0; j < cluster.Count; j++)
- {
- int t = cluster[j];
- if (seen.Add(triA[t])) pts.Add(wv[triA[t]]);
- if (seen.Add(triB[t])) pts.Add(wv[triB[t]]);
- if (seen.Add(triC[t])) pts.Add(wv[triC[t]]);
- }
+ List pts = CollectClusterPoints(cluster, wv, triA, triB, triC);
PcaFrame(pts, out Vector3 u, out Vector3 v, out _);
@@ -555,23 +560,9 @@ public class ConcealmentColliderBaker : MonoBehaviour
private static void PcaFrame(List pts, out Vector3 u, out Vector3 v, out Vector3 n)
{
u = Vector3.right; v = Vector3.up; n = Vector3.forward;
- int cnt = pts.Count;
- if (cnt < 2) return;
+ if (pts.Count < 2) return;
- Vector3 mean = Vector3.zero;
- for (int i = 0; i < cnt; i++) mean += pts[i];
- mean /= cnt;
-
- double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
- for (int i = 0; i < cnt; i++)
- {
- double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z;
- c00 += x * x; c01 += x * y; c02 += x * z;
- c11 += y * y; c12 += y * z; c22 += z * z;
- }
-
- var mat = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
- Jacobi3(mat, out double[,] vec, out double[] d);
+ CovarianceEigen(pts, out double[,] vec, out double[] d);
int hi = 0, mid = 1, lo = 2;
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
diff --git a/Assets/Scripts/ConcealmentMeter.cs b/Assets/Scripts/ConcealmentMeter.cs
index 70f53e4..efe6339 100644
--- a/Assets/Scripts/ConcealmentMeter.cs
+++ b/Assets/Scripts/ConcealmentMeter.cs
@@ -88,7 +88,6 @@ public class ConcealmentMeter : MonoBehaviour
private readonly List _concave = new List();
private readonly List _volumes = new List();
- // Gizmo cache.
private readonly List _gizPos = new List();
private readonly List _gizEmbedded = new List();
private float _gizVoxelWorld;
@@ -132,9 +131,7 @@ public class ConcealmentMeter : MonoBehaviour
// 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.
- Vector3 ls = targetRenderer.transform.lossyScale;
- if (ls.x != 1f || ls.y != 1f || ls.z != 1f)
- RescaleBakedToUnitScale(ls);
+ GeometryUtil.RescaleToUnitScale(_baked, targetRenderer.transform.lossyScale, _vbuf);
_verts = _baked.vertices;
_tris = _baked.triangles;
@@ -156,7 +153,7 @@ public class ConcealmentMeter : MonoBehaviour
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);
+ Bounds worldBounds = GeometryUtil.TransformBounds(l2w, lb);
BuildEnvironmentCandidates(worldBounds);
bool anyEnv = _convex.Count > 0 ||
@@ -213,24 +210,6 @@ public class ConcealmentMeter : MonoBehaviour
HasResult = true;
}
- /// Divides the player's world scale back out of the freshly baked mesh so it is true unit-scale
- /// local geometry (the space this class raises to world with). Reuses — no per-eval alloc.
- private void RescaleBakedToUnitScale(Vector3 lossyScale)
- {
- float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f;
- float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f;
- float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f;
- _baked.GetVertices(_vbuf);
- for (int i = 0; i < _vbuf.Count; i++)
- {
- Vector3 v = _vbuf[i];
- v.x *= ix; v.y *= iy; v.z *= iz;
- _vbuf[i] = v;
- }
- _baked.SetVertices(_vbuf);
- _baked.RecalculateBounds();
- }
-
///
/// 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).
@@ -389,22 +368,6 @@ public class ConcealmentMeter : MonoBehaviour
return (hits & 1) == 1;
}
- /// World AABB enclosing a local AABB after an affine transform (encapsulates the 8 transformed corners).
- 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;
diff --git a/Assets/Scripts/ConcealmentVolume.cs b/Assets/Scripts/ConcealmentVolume.cs
index 624697d..4a95df8 100644
--- a/Assets/Scripts/ConcealmentVolume.cs
+++ b/Assets/Scripts/ConcealmentVolume.cs
@@ -60,16 +60,9 @@ public class ConcealmentVolume : MonoBehaviour
{
Vector3 mn = origin;
Vector3 mx = origin + new Vector3(nx * voxelSize, ny * voxelSize, nz * voxelSize);
- Matrix4x4 m = transform.localToWorldMatrix;
- Bounds b = new Bounds(m.MultiplyPoint3x4(mn), 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;
+ var local = new Bounds();
+ local.SetMinMax(mn, mx);
+ return GeometryUtil.TransformBounds(transform.localToWorldMatrix, local);
}
}
@@ -184,13 +177,7 @@ public class ConcealmentVolume : MonoBehaviour
bool enclosed = useFlood && !outside[idx];
bool solid = false;
if (useWinding)
- {
- Vector3 cWorldLocal = new Vector3(
- origin.x + (ix + 0.5f) * vs,
- origin.y + (iy + 0.5f) * vs,
- origin.z + (iz + 0.5f) * vs);
- solid = WindingNumber(cWorldLocal, verts, tris) > 0.5;
- }
+ 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);
@@ -253,6 +240,10 @@ public class ConcealmentVolume : MonoBehaviour
return sum / (4.0 * System.Math.PI);
}
+ /// Local-space center of grid voxel (ix, iy, iz).
+ 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;
@@ -265,11 +256,7 @@ public class ConcealmentVolume : MonoBehaviour
{
int idx = ix + nx * (iy + ny * iz);
if ((bits[idx >> 5] & (1u << (idx & 31))) == 0) continue;
- Vector3 c = new Vector3(
- origin.x + (ix + 0.5f) * voxelSize,
- origin.y + (iy + 0.5f) * voxelSize,
- origin.z + (iz + 0.5f) * voxelSize);
- Gizmos.DrawCube(c, cube);
+ Gizmos.DrawCube(VoxelCenter(origin, ix, iy, iz, voxelSize), cube);
}
}
#endif
diff --git a/Assets/Scripts/FingerIK.cs b/Assets/Scripts/FingerIK.cs
index 3906e6f..7273366 100644
--- a/Assets/Scripts/FingerIK.cs
+++ b/Assets/Scripts/FingerIK.cs
@@ -306,11 +306,8 @@ public class FingerIK : MonoBehaviour
// World hinge axis (invariant under the joint's own twist about local X).
Vector3 axisW = (j.rotation * HingeAxis(i)).normalized;
- Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
- Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
- if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) continue;
+ if (!TryInPlaneAngle(toTip, toTarget, axisW, out float hingeAng)) continue;
- float hingeAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
j.rotation = Quaternion.AngleAxis(hingeAng, axisW) * j.rotation;
ConstrainToHinge(i);
@@ -367,16 +364,25 @@ public class FingerIK : MonoBehaviour
Vector3 toTip = tip.position - b.position;
Vector3 toTarget = target.position - b.position;
- float latAng = 0f;
- Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
- Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
- if (tipPlane.sqrMagnitude > 1e-10f && tgtPlane.sqrMagnitude > 1e-10f)
- latAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
+ TryInPlaneAngle(toTip, toTarget, axisW, out float latAng); // degenerate -> 0 (spread bias still applies)
latAng = Mathf.Clamp(latAng + spread, spreadLimits.x, spreadLimits.y);
_solvedLocal[0] = _solvedLocal[0] * Quaternion.AngleAxis(latAng, SpreadAxis);
}
+ // Signed angle from toTip to toTarget projected onto the plane orthogonal to axis; false when
+ // either projection is degenerate (angle is then 0 and must not be applied as a solve step).
+ private static bool TryInPlaneAngle(Vector3 toTip, Vector3 toTarget, Vector3 axis, out float angle)
+ {
+ angle = 0f;
+ Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axis);
+ Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axis);
+ if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) return false;
+
+ angle = Vector3.SignedAngle(tipPlane, tgtPlane, axis);
+ return true;
+ }
+
// Base-joint spread/opposition about local Z, composed AFTER curl so the two DOFs don't
// fight. Applied on the final base rotation when IK isn't driving the base laterally.
private void ApplySpread()
diff --git a/Assets/Scripts/FirstPersonController.cs b/Assets/Scripts/FirstPersonController.cs
index 935826c..7ec7d27 100644
--- a/Assets/Scripts/FirstPersonController.cs
+++ b/Assets/Scripts/FirstPersonController.cs
@@ -53,12 +53,7 @@ public class FirstPersonController : MonoBehaviour
CinemachineCamera _vcam;
PlayerCharacterController _player; // body whose yaw we pin to the camera while active
PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset
- float _yaw; // look target (raw input accumulates here)
- float _pitch;
- float _displayYaw; // smoothed value actually applied to the transform
- float _displayPitch;
- float _yawSmoothRef;
- float _pitchSmoothRef;
+ SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with the freecam
bool _resolved;
void Awake()
@@ -89,7 +84,7 @@ public class FirstPersonController : MonoBehaviour
SyncToCurrentView();
InputUtil.SetCursorLocked(true);
ApplyTransform(); // place at the eye on the same frame so the blend starts from there
- if (_player) _player.FaceYaw = _displayYaw; // body faces the camera from frame one
+ if (_player) _player.FaceYaw = _look.DisplayYaw; // body faces the camera from frame one
}
else
{
@@ -105,32 +100,18 @@ public class FirstPersonController : MonoBehaviour
// LateUpdate so the player has already moved the CameraTarget this frame.
LookTick();
ApplyTransform();
- if (_player) _player.FaceYaw = _displayYaw; // keep the body's yaw locked to the camera
+ if (_player) _player.FaceYaw = _look.DisplayYaw; // keep the body's yaw locked to the camera
}
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
- _yaw += delta.x * horizontalSensitivity;
- float vs = verticalSensitivity * (invertY ? 1f : -1f);
- _pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch);
-
- if (lookSmoothTime > 0f)
- {
- // SmoothDampAngle is frame-rate-independent and wraps yaw correctly.
- _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime);
- _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime);
- }
- else
- {
- _displayYaw = _yaw;
- _displayPitch = _pitch;
- }
+ _look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
}
void ApplyTransform()
{
- Quaternion rot = Quaternion.Euler(_displayPitch, _displayYaw, 0f);
+ Quaternion rot = _look.Rotation;
if (cameraTarget)
{
// Sit at the first-person eye anchor, then raise/lower the eye by the active pose's first-person
@@ -151,18 +132,9 @@ public class FirstPersonController : MonoBehaviour
{
var cam = Camera.main;
if (cam)
- {
- Vector3 e = cam.transform.rotation.eulerAngles;
- _yaw = e.y;
- float p = e.x > 180f ? e.x - 360f : e.x;
- _pitch = Mathf.Clamp(p, -maxPitch, maxPitch);
- }
-
- // Snap the smoothed state to the fresh pose so entering never lags/jumps to catch up.
- _displayYaw = _yaw;
- _displayPitch = _pitch;
- _yawSmoothRef = 0f;
- _pitchSmoothRef = 0f;
+ _look.SyncTo(cam.transform.rotation, maxPitch);
+ else
+ _look.Snap(); // no camera: keep the current angles but still snap the smoothed state
}
void SetMeshHidden(bool hidden)
diff --git a/Assets/Scripts/FirstPersonMode.cs b/Assets/Scripts/FirstPersonMode.cs
index b86bf98..4fe1855 100644
--- a/Assets/Scripts/FirstPersonMode.cs
+++ b/Assets/Scripts/FirstPersonMode.cs
@@ -14,18 +14,9 @@ public class FirstPersonMode : GameMode
public override void Enter()
{
// Player stays fully controllable; only the view changes.
- if (Manager.Player)
- {
- Manager.Player.enabled = true;
- Manager.Player.ControlEnabled = true;
- }
-
- if (Manager.Painter)
- Manager.Painter.PaintingEnabled = false;
-
- // The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame.
- if (Manager.CameraInput)
- Manager.CameraInput.enabled = false;
+ SetPlayerControl(true);
+ SetPainting(false);
+ SetOrbitInputEnabled(false);
if (Manager.FirstPerson)
Manager.FirstPerson.SetActive(true);
@@ -38,9 +29,9 @@ public class FirstPersonMode : GameMode
if (Manager.FirstPerson)
Manager.FirstPerson.SetActive(false);
+ SetOrbitInputEnabled(true);
if (Manager.CameraInput)
{
- Manager.CameraInput.enabled = true;
// Carry the first-person facing into the orbit camera so the next orbit mode (Paint / Player
// Control) keeps the current view angle instead of snapping back to the orbit's last-used angle.
// Camera.main still shows the first-person view this frame (the brain hasn't blended yet).
@@ -94,7 +85,7 @@ public class FirstPersonMode : GameMode
for (int i = 0; i < count; i++)
{
var netObj = _hits[i].collider.GetComponentInParent();
- if (netObj && self && netObj == self) continue; // ignore our own collider
+ if (netObj && self && netObj == self) continue;
if (!netObj || !netObj.IsPlayerObject) return; // first solid hit is world geometry -> blocked
var setup = netObj.GetComponent();
diff --git a/Assets/Scripts/FreeCamController.cs b/Assets/Scripts/FreeCamController.cs
index 1670e03..4e7e694 100644
--- a/Assets/Scripts/FreeCamController.cs
+++ b/Assets/Scripts/FreeCamController.cs
@@ -57,12 +57,7 @@ public class FreeCamController : MonoBehaviour
CharacterController _controller;
CinemachineCamera _vcam;
- float _yaw; // look target (raw input accumulates here)
- float _pitch;
- float _displayYaw; // smoothed value actually applied to the transform
- float _displayPitch;
- float _yawSmoothRef;
- float _pitchSmoothRef;
+ SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with first person
Vector3 _moveVelocity; // smoothed world-space flight velocity
Vector3 _moveVelocityRef;
bool _ignoredPlayer;
@@ -110,24 +105,10 @@ public class FreeCamController : MonoBehaviour
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
- _yaw += delta.x * horizontalSensitivity;
- float vs = verticalSensitivity * (invertY ? 1f : -1f);
- _pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch);
-
- if (lookSmoothTime > 0f)
- {
- // SmoothDampAngle is frame-rate-independent and wraps yaw correctly.
- _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime);
- _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime);
- }
- else
- {
- _displayYaw = _yaw;
- _displayPitch = _pitch;
- }
+ _look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
// Rotation is unconstrained by the CharacterController; only position is.
- transform.rotation = Quaternion.Euler(_displayPitch, _displayYaw, 0f);
+ transform.rotation = _look.Rotation;
}
void MoveTick()
@@ -153,22 +134,9 @@ public class FreeCamController : MonoBehaviour
var cam = Camera.main;
if (!cam) return;
- // CharacterController forbids direct transform writes while enabled, so toggle it for the teleport.
- bool wasEnabled = _controller.enabled;
- _controller.enabled = false;
- transform.SetPositionAndRotation(cam.transform.position, cam.transform.rotation);
- _controller.enabled = wasEnabled;
+ CharacterTeleport.Move(_controller, transform, cam.transform.position, cam.transform.rotation);
- Vector3 e = transform.rotation.eulerAngles;
- _yaw = e.y;
- float p = e.x > 180f ? e.x - 360f : e.x;
- _pitch = Mathf.Clamp(p, -maxPitch, maxPitch);
-
- // Snap the smoothed state to the fresh pose so entering freecam never lags/jumps to catch up.
- _displayYaw = _yaw;
- _displayPitch = _pitch;
- _yawSmoothRef = 0f;
- _pitchSmoothRef = 0f;
+ _look.SyncTo(transform.rotation, maxPitch);
_moveVelocity = Vector3.zero;
_moveVelocityRef = Vector3.zero;
}
diff --git a/Assets/Scripts/FreeCamMode.cs b/Assets/Scripts/FreeCamMode.cs
index 7d2b62f..8294c94 100644
--- a/Assets/Scripts/FreeCamMode.cs
+++ b/Assets/Scripts/FreeCamMode.cs
@@ -11,18 +11,9 @@ public class FreeCamMode : GameMode
public override void Enter()
{
// Keep the player component alive (gravity/grounding) but reject input, like Paint mode.
- if (Manager.Player)
- {
- Manager.Player.enabled = true;
- Manager.Player.ControlEnabled = false;
- }
-
- if (Manager.Painter)
- Manager.Painter.PaintingEnabled = false;
-
- // The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame.
- if (Manager.CameraInput)
- Manager.CameraInput.enabled = false;
+ SetPlayerControl(false);
+ SetPainting(false);
+ SetOrbitInputEnabled(false);
if (Manager.FreeCam)
Manager.FreeCam.SetActive(true);
@@ -35,7 +26,6 @@ public class FreeCamMode : GameMode
if (Manager.FreeCam)
Manager.FreeCam.SetActive(false);
- if (Manager.CameraInput)
- Manager.CameraInput.enabled = true;
+ SetOrbitInputEnabled(true);
}
}
diff --git a/Assets/Scripts/GameMode.cs b/Assets/Scripts/GameMode.cs
index 62f8d19..08effe0 100644
--- a/Assets/Scripts/GameMode.cs
+++ b/Assets/Scripts/GameMode.cs
@@ -21,4 +21,29 @@ public abstract class GameMode
/// Called every frame while this mode is active.
public virtual void Tick() { }
+
+ /// Enables the player component and sets whether it accepts movement input.
+ protected void SetPlayerControl(bool controlEnabled)
+ {
+ if (Manager.Player)
+ {
+ Manager.Player.enabled = true;
+ Manager.Player.ControlEnabled = controlEnabled;
+ }
+ }
+
+ /// Toggles painting on the mesh painter.
+ protected void SetPainting(bool on)
+ {
+ if (Manager.Painter)
+ Manager.Painter.PaintingEnabled = on;
+ }
+
+ /// Enables/disables the orbit camera input component. Off while a dedicated camera rig is
+ /// live: the orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame.
+ protected void SetOrbitInputEnabled(bool on)
+ {
+ if (Manager.CameraInput)
+ Manager.CameraInput.enabled = on;
+ }
}
diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs
index 06d8d80..4e83f5c 100644
--- a/Assets/Scripts/GameModeManager.cs
+++ b/Assets/Scripts/GameModeManager.cs
@@ -145,14 +145,25 @@ public class GameModeManager : MonoBehaviour
_paused = false;
_resumeIndex = -1;
+ ClearPlayerBindings();
+ TearDownActiveMode();
+ BuildSpectatorModes();
+ SwitchTo(0);
+ }
+
+ /// Drops the player/painter bindings and notifies painter-following UI.
+ private void ClearPlayerBindings()
+ {
player = null;
painter = null;
PainterChanged?.Invoke(null);
+ }
+ /// Exits the active mode and marks no mode active.
+ private void TearDownActiveMode()
+ {
ActiveMode?.Exit();
_activeIndex = -1;
- BuildSpectatorModes();
- SwitchTo(0);
}
///
@@ -197,8 +208,7 @@ public class GameModeManager : MonoBehaviour
firstPerson.SetPlayer(localPlayer);
// Tear down the pre-spawn (player-less) mode and rebuild scoped to this avatar's role.
- ActiveMode?.Exit();
- _activeIndex = -1;
+ TearDownActiveMode();
// Lobby avatar -> full set; match variant -> its role's modes. Scope is baked per prefab
// variant, so it follows the spawned avatar with no replicated-phase timing race.
@@ -224,12 +234,9 @@ public class GameModeManager : MonoBehaviour
_paused = false;
_resumeIndex = -1;
- ActiveMode?.Exit();
- _activeIndex = -1;
+ TearDownActiveMode();
_modes.Clear();
- player = null;
- painter = null;
- PainterChanged?.Invoke(null);
+ ClearPlayerBindings();
// The cycle is empty (no SwitchTo runs), so notify mode-following UI explicitly - otherwise the
// paint HUD (hides only via ModeChanged) would stay open after a shutdown during PaintMode.
@@ -308,7 +315,7 @@ public class GameModeManager : MonoBehaviour
else if (painter) painter.PaintingEnabled = false; // degenerate paint-only set: at least stop painting
}
- if (WasSwitchPressed())
+ if (InputUtil.GameplayKeyPressed(switchKey))
{
int next = (_activeIndex + 1) % _modes.Count;
if (suppressPaint && _modes[next] is PaintMode)
@@ -355,14 +362,4 @@ public class GameModeManager : MonoBehaviour
Debug.Log($"[GameModeManager] Mode -> {ActiveMode.DisplayName}", this);
ModeChanged?.Invoke(ActiveMode);
}
-
- private bool WasSwitchPressed()
- {
- if (TypingGuard.IsTyping) return false;
-#if ENABLE_INPUT_SYSTEM
- return Keyboard.current != null && Keyboard.current[switchKey].wasPressedThisFrame;
-#else
- return Input.GetKeyDown(switchKey);
-#endif
- }
}
diff --git a/Assets/Scripts/GeometryUtil.cs b/Assets/Scripts/GeometryUtil.cs
new file mode 100644
index 0000000..6ef3fd1
--- /dev/null
+++ b/Assets/Scripts/GeometryUtil.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+/// Shared mesh/bounds geometry helpers.
+public static class GeometryUtil
+{
+ /// Divides a world scale back out of a freshly baked mesh so it is true unit-scale local
+ /// geometry (BakeMesh freezes the transform's lossyScale into the vertices). No-op at scale 1;
+ /// is reused so nothing is allocated per bake.
+ public static void RescaleToUnitScale(Mesh baked, Vector3 lossyScale, List scratch)
+ {
+ if (lossyScale.x == 1f && lossyScale.y == 1f && lossyScale.z == 1f) return;
+
+ float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f;
+ float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f;
+ float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f;
+ baked.GetVertices(scratch);
+ for (int i = 0; i < scratch.Count; i++)
+ {
+ Vector3 v = scratch[i];
+ v.x *= ix; v.y *= iy; v.z *= iz;
+ scratch[i] = v;
+ }
+ baked.SetVertices(scratch);
+ baked.RecalculateBounds();
+ }
+
+ /// World AABB enclosing a local AABB after an affine transform (encapsulates the 8 transformed corners).
+ public 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;
+ }
+}
diff --git a/Assets/Scripts/GeometryUtil.cs.meta b/Assets/Scripts/GeometryUtil.cs.meta
new file mode 100644
index 0000000..62229be
--- /dev/null
+++ b/Assets/Scripts/GeometryUtil.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: deab7e342bb598049b9972454a8399e5
\ No newline at end of file
diff --git a/Assets/Scripts/HsvColorPicker.cs b/Assets/Scripts/HsvColorPicker.cs
index 8bd68ff..ac4104e 100644
--- a/Assets/Scripts/HsvColorPicker.cs
+++ b/Assets/Scripts/HsvColorPicker.cs
@@ -110,11 +110,7 @@ public class HsvColorPicker : MonoBehaviour
_svHandle = sv.Find("Handle") as RectTransform;
- if (sv.TryGetComponent(out UiPointerSurface svPointer))
- {
- svPointer.Picked -= OnSvPicked;
- svPointer.Picked += OnSvPicked;
- }
+ Rehook(sv, OnSvPicked);
}
var hue = transform.Find("Hue") as RectTransform;
@@ -123,11 +119,7 @@ public class HsvColorPicker : MonoBehaviour
if (hue.TryGetComponent(out Image hueImg)) hueImg.sprite = PaintHud.HueSprite();
_hueHandle = hue.Find("Handle") as RectTransform;
- if (hue.TryGetComponent(out UiPointerSurface huePointer))
- {
- huePointer.Picked -= OnHuePicked;
- huePointer.Picked += OnHuePicked;
- }
+ Rehook(hue, OnHuePicked);
}
// Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a
@@ -141,6 +133,16 @@ public class HsvColorPicker : MonoBehaviour
Refresh();
}
+ // Idempotently hooks the handler to the surface's drag relay (unsubscribe-then-subscribe).
+ private static void Rehook(Component surface, Action handler)
+ {
+ if (surface.TryGetComponent(out UiPointerSurface pointer))
+ {
+ pointer.Picked -= handler;
+ pointer.Picked += handler;
+ }
+ }
+
private void OnSvPicked(Vector2 p)
{
_s = p.x;
diff --git a/Assets/Scripts/InputUtil.cs b/Assets/Scripts/InputUtil.cs
index d770f95..ad720f3 100644
--- a/Assets/Scripts/InputUtil.cs
+++ b/Assets/Scripts/InputUtil.cs
@@ -104,4 +104,21 @@ public static class InputUtil
return Input.GetMouseButtonDown(0);
#endif
}
+
+ /// Gameplay key pressed on this exact frame (edge), suppressed while a UI text field is
+ /// focused so typing never triggers gameplay bindings. The parameter type follows the active input
+ /// backend, matching how the callers' serialized key fields are declared.
+#if ENABLE_INPUT_SYSTEM
+ public static bool GameplayKeyPressed(Key key)
+ {
+ if (TypingGuard.IsTyping) return false;
+ return Keyboard.current != null && Keyboard.current[key].wasPressedThisFrame;
+ }
+#else
+ public static bool GameplayKeyPressed(KeyCode key)
+ {
+ if (TypingGuard.IsTyping) return false;
+ return Input.GetKeyDown(key);
+ }
+#endif
}
diff --git a/Assets/Scripts/MatchHud.cs b/Assets/Scripts/MatchHud.cs
index ec1bc9b..ed748ce 100644
--- a/Assets/Scripts/MatchHud.cs
+++ b/Assets/Scripts/MatchHud.cs
@@ -82,25 +82,19 @@ public class MatchHud : MonoBehaviour
private void EnsureStyles()
{
- if (_phaseStyle == null)
+ if (_phaseStyle == null) _phaseStyle = MakeCenteredBoldLabel(22);
+ if (_timeStyle == null) _timeStyle = MakeCenteredBoldLabel(28);
+ }
+
+ private static GUIStyle MakeCenteredBoldLabel(int fontSize)
+ {
+ var style = new GUIStyle(GUI.skin.label)
{
- _phaseStyle = new GUIStyle(GUI.skin.label)
- {
- alignment = TextAnchor.MiddleCenter,
- fontSize = 22,
- fontStyle = FontStyle.Bold,
- };
- _phaseStyle.normal.textColor = Color.white;
- }
- if (_timeStyle == null)
- {
- _timeStyle = new GUIStyle(GUI.skin.label)
- {
- alignment = TextAnchor.MiddleCenter,
- fontSize = 28,
- fontStyle = FontStyle.Bold,
- };
- _timeStyle.normal.textColor = Color.white;
- }
+ alignment = TextAnchor.MiddleCenter,
+ fontSize = fontSize,
+ fontStyle = FontStyle.Bold,
+ };
+ style.normal.textColor = Color.white;
+ return style;
}
}
diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs
index 7a5f5d5..5cce81a 100644
--- a/Assets/Scripts/MatchManager.cs
+++ b/Assets/Scripts/MatchManager.cs
@@ -233,20 +233,26 @@ public class MatchManager : MonoBehaviour
_net.OnClientStarted -= OnClientStarted;
_net.OnServerStopped -= OnNetStopped;
_net.OnClientStopped -= OnNetStopped;
- _net.OnClientConnectedCallback -= OnClientConnected;
- _net.OnClientDisconnectCallback -= OnClientDisconnected;
- if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
- if (_net.CustomMessagingManager != null)
- {
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
- }
+ UnhookSessionCallbacks();
}
if (Instance == this) Instance = null;
}
+ // Unhooks the per-session callbacks and named-message handlers (shared by OnDisable and OnNetStopped).
+ private void UnhookSessionCallbacks()
+ {
+ _net.OnClientConnectedCallback -= OnClientConnected;
+ _net.OnClientDisconnectCallback -= OnClientDisconnected;
+ if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
+ if (_net.CustomMessagingManager != null)
+ {
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
+ _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
+ }
+ }
+
private void OnServerStarted()
{
_cleanedUp = false;
@@ -313,19 +319,7 @@ public class MatchManager : MonoBehaviour
Phase = MatchPhase.Lobby;
ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode);
_pendingHiders.Clear();
- if (_net)
- {
- _net.OnClientConnectedCallback -= OnClientConnected;
- _net.OnClientDisconnectCallback -= OnClientDisconnected;
- if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent;
- if (_net.CustomMessagingManager != null)
- {
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
- _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
- }
- }
+ if (_net) UnhookSessionCallbacks();
ReturnToMainOnly();
}
@@ -463,15 +457,7 @@ public class MatchManager : MonoBehaviour
private IEnumerator MatchSequence(HashSet hunterIds)
{
- var sm = _net.SceneManager;
-
- // Strictly serialized: NGO allows only one scene op in flight at a time.
- Scene lobby = SceneManager.GetSceneByName(lobbySceneName);
- if (lobby.IsValid() && lobby.isLoaded)
- yield return SceneOp(() => sm.UnloadScene(lobby), SceneEventType.UnloadEventCompleted, lobbySceneName);
-
- yield return SceneOp(() => sm.LoadScene(matchSceneName, LoadSceneMode.Additive),
- SceneEventType.LoadEventCompleted, matchSceneName);
+ yield return SwapScenes(lobbySceneName, matchSceneName);
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
@@ -500,7 +486,7 @@ public class MatchManager : MonoBehaviour
yield return WaitPhase(resolveDuration, null);
_phaseRoutine = null;
- EndMatch(); // swaps the map back to the lobby and respawns everyone as the neutral Player
+ EndMatch();
}
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
@@ -691,15 +677,7 @@ public class MatchManager : MonoBehaviour
private IEnumerator EndMatchSequence()
{
- var sm = _net.SceneManager;
-
- // Strictly serialized: NGO allows only one scene op in flight at a time.
- Scene map = SceneManager.GetSceneByName(matchSceneName);
- if (map.IsValid() && map.isLoaded)
- yield return SceneOp(() => sm.UnloadScene(map), SceneEventType.UnloadEventCompleted, matchSceneName);
-
- yield return SceneOp(() => sm.LoadScene(lobbySceneName, LoadSceneMode.Additive),
- SceneEventType.LoadEventCompleted, lobbySceneName);
+ yield return SwapScenes(matchSceneName, lobbySceneName);
RespawnAll(_ => _playerPrefab, lobbySpawnCenter);
@@ -707,6 +685,20 @@ public class MatchManager : MonoBehaviour
Debug.Log("[MatchManager] Back in lobby.", this);
}
+ // Unloads one gameplay scene (if loaded), then additively loads the next (shared by the match
+ // start/end sequences).
+ private IEnumerator SwapScenes(string unloadSceneName, string loadSceneName)
+ {
+ var sm = _net.SceneManager;
+
+ Scene old = SceneManager.GetSceneByName(unloadSceneName);
+ if (old.IsValid() && old.isLoaded)
+ yield return SceneOp(() => sm.UnloadScene(old), SceneEventType.UnloadEventCompleted, unloadSceneName);
+
+ yield return SceneOp(() => sm.LoadScene(loadSceneName, LoadSceneMode.Additive),
+ SceneEventType.LoadEventCompleted, loadSceneName);
+ }
+
// Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one
// is in flight, so callers must await this before issuing the next.
private IEnumerator SceneOp(System.Func op, SceneEventType completed, string sceneName)
@@ -823,18 +815,22 @@ public class MatchManager : MonoBehaviour
public bool IsLocalSceneLobby => SceneManager.GetActiveScene().name == lobbySceneName;
public bool IsLocalSceneMap => SceneManager.GetActiveScene().name == matchSceneName;
- // Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
- private void SendAuthorityGrant(ulong clientId)
+ // Sends a named message whose payload carries no information (a named message still needs a buffer,
+ // so a single dummy byte is written).
+ private void SendEmptyNamedMessage(string messageName, ulong targetClientId)
{
var cmm = _net != null ? _net.CustomMessagingManager : null;
if (cmm == null) return;
using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
{
- writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer
- cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer);
+ writer.WriteByteSafe(1);
+ cmm.SendNamedMessage(messageName, targetClientId, writer);
}
}
+ // Server -> client: tell the designated client it is the match authority (so it reads Esc locally).
+ private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId);
+
// Client handler: this machine has been granted match authority.
private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader)
{
@@ -844,16 +840,7 @@ public class MatchManager : MonoBehaviour
// Client -> server: ask the server to end the match. Only honored from the authority client (and only
// while a match is running - EndMatch guards the phase).
- private void RequestEndMatchFromServer()
- {
- var cmm = _net != null ? _net.CustomMessagingManager : null;
- if (cmm == null) return;
- using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp))
- {
- writer.WriteByteSafe(1);
- cmm.SendNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId, writer);
- }
- }
+ private void RequestEndMatchFromServer() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId);
// Server handler: an authority client asked to end the match.
private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader)
diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs
index 0915bca..d259c48 100644
--- a/Assets/Scripts/MeshPainter.Backend.cs
+++ b/Assets/Scripts/MeshPainter.Backend.cs
@@ -26,11 +26,7 @@ public partial class MeshPainter
var canonical = new Color32[MAP_COUNT][];
for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
- if (_gpuActive) TeardownGpu();
- else TeardownCpu();
-
- SetupBackend(toGpu, canonical);
- _hasLastScreen = false;
+ RebuildBackend(toGpu, canonical);
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
@@ -40,14 +36,44 @@ public partial class MeshPainter
/// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for
/// the new content. The input arrays are never mutated (SetupBackend clones its seed).
///
- private void ReseedCanonical(Color32[][] canonical)
+ private void ReseedCanonical(Color32[][] canonical) => RebuildBackend(useGpu, canonical);
+
+ /// Tears down the active backend and sets up the requested one on the given maps.
+ private void RebuildBackend(bool gpu, Color32[][] canonical)
{
if (_gpuActive) TeardownGpu();
else TeardownCpu();
- SetupBackend(useGpu, canonical);
+ SetupBackend(gpu, canonical);
_hasLastScreen = false;
}
+ /// Seed source for a map: the supplied canonical data when valid, else the material seed.
+ private Color32[] ResolveSeed(int mi, Color32[][] canonical)
+ {
+ return (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
+ ? canonical[mi] : _seedCanonical[mi];
+ }
+
+ // Backend-agnostic stroke dispatch (GPU vs CPU), shared by local painting and replayed net stamps.
+
+ private void BeginBackendStroke()
+ {
+ if (_gpuActive) BeginGpuStroke();
+ else ResetCpuDirty();
+ }
+
+ private void DrawBackendStamp(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
+ {
+ if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, n);
+ else PaintStamp3D(pLocal, radiusLocal, u, v, n);
+ }
+
+ private void EndBackendStroke()
+ {
+ if (_gpuActive) EndGpuStroke();
+ else EndCpuStroke();
+ }
+
/// Reads a map from the active backend into canonical (material) layout.
private Color32[] ReadCanonical(int mi)
{
diff --git a/Assets/Scripts/MeshPainter.Cpu.cs b/Assets/Scripts/MeshPainter.Cpu.cs
index 24ccfb6..b0c3480 100644
--- a/Assets/Scripts/MeshPainter.Cpu.cs
+++ b/Assets/Scripts/MeshPainter.Cpu.cs
@@ -34,8 +34,7 @@ public partial class MeshPainter
/// CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.
private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
{
- var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
- ? canonical[mi] : _seedCanonical[mi];
+ var src = ResolveSeed(mi, canonical);
var px = (Color32[])src.Clone();
if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness.
for (int i = 0; i < px.Length; i++) px[i].a = 255;
diff --git a/Assets/Scripts/MeshPainter.Gpu.cs b/Assets/Scripts/MeshPainter.Gpu.cs
index f4fcd74..98474f1 100644
--- a/Assets/Scripts/MeshPainter.Gpu.cs
+++ b/Assets/Scripts/MeshPainter.Gpu.cs
@@ -71,8 +71,7 @@ public partial class MeshPainter
/// Seeds a GPU map's display RT from canonical data (coverage alpha = UV islands), then packs MS to final.
private void SeedGpuMap(int mi, Color32[][] canonical)
{
- var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
- ? canonical[mi] : _seedCanonical[mi];
+ var src = ResolveSeed(mi, canonical);
var gm = _gpu[mi];
if (mi == MAP_MSO)
@@ -108,8 +107,7 @@ public partial class MeshPainter
Graphics.SetRenderTarget(rt);
if (_brushMat.SetPass(1))
{
- int sub = _bakedMesh.subMeshCount;
- for (int i = 0; i < sub; i++) Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
+ DrawBakedMeshNow();
}
else
{
@@ -258,6 +256,12 @@ public partial class MeshPainter
private void DrawStamp()
{
if (!_brushMat.SetPass(0)) return;
+ DrawBakedMeshNow();
+ }
+
+ /// Draws the baked mesh once per sub-mesh with the pass the caller just set.
+ private void DrawBakedMeshNow()
+ {
int sub = _bakedMesh.subMeshCount;
for (int i = 0; i < sub; i++)
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs
index 102a23d..31c7ab5 100644
--- a/Assets/Scripts/MeshPainter.Net.cs
+++ b/Assets/Scripts/MeshPainter.Net.cs
@@ -139,18 +139,9 @@ public partial class MeshPainter
if (spray) BuildSprayStampMaskSeeded(s.SpraySeed);
else { ResolveActiveMask(); UseResolvedStampMask(); }
- if (_gpuActive)
- {
- BeginGpuStroke();
- DrawBrushGpu(s.P, s.R, s.U, s.V, s.N);
- EndGpuStroke();
- }
- else
- {
- ResetCpuDirty();
- PaintStamp3D(s.P, s.R, s.U, s.V, s.N);
- EndCpuStroke();
- }
+ BeginBackendStroke();
+ DrawBackendStamp(s.P, s.R, s.U, s.V, s.N);
+ EndBackendStroke();
// Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same
// history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner).
@@ -189,21 +180,7 @@ public partial class MeshPainter
w.Write(textureSize);
w.Write(MAP_COUNT);
- var bytes = new byte[n * 4];
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- Color32[] px = ReadCanonical(mi);
- int count = Mathf.Min(n, px.Length);
- for (int i = 0; i < count; i++)
- {
- int o = i * 4;
- bytes[o] = px[i].r;
- bytes[o + 1] = px[i].g;
- bytes[o + 2] = px[i].b;
- bytes[o + 3] = px[i].a;
- }
- w.Write(bytes, 0, count * 4);
- }
+ WriteCanonicalMaps(w);
}
compressed = Deflate(raw.ToArray());
}
@@ -217,19 +194,8 @@ public partial class MeshPainter
///
public void ApplyPaintSnapshot(byte[] compressed, int baselineId)
{
- if (!_setupValid || compressed == null || compressed.Length == 0) return;
+ if (!TryDecodeSnapshot(compressed, "Paint", out Color32[][] canonical)) return;
- Color32[][] canonical;
- try { canonical = DecodeSnapshot(compressed); }
- catch (System.Exception e)
- {
- Debug.LogWarning($"[MeshPainter] Paint snapshot decode failed: {e.Message}", this);
- return;
- }
- if (canonical == null) return;
-
- // Re-seed exactly like SwitchBackend, but with externally supplied maps, so the island/dilation
- // seeding path runs and the edge padding is rebuilt correctly for the loaded content.
ReseedCanonical(canonical);
SetHistoryBaseline(canonical, baselineId);
}
@@ -242,22 +208,28 @@ public partial class MeshPainter
///
public bool LoadPaintFromSnapshot(byte[] compressed)
{
- if (!_setupValid || compressed == null || compressed.Length == 0) return false;
-
- Color32[][] canonical;
- try { canonical = DecodeSnapshot(compressed); }
- catch (System.Exception e)
- {
- Debug.LogWarning($"[MeshPainter] Carry snapshot decode failed: {e.Message}", this);
- return false;
- }
- if (canonical == null) return false;
+ if (!TryDecodeSnapshot(compressed, "Carry", out Color32[][] canonical)) return false;
ReseedCanonical(canonical);
ResetHistoryToCurrent(canonical);
return true;
}
+ /// Guard + decode for an incoming snapshot blob; false = not ready / empty / undecodable.
+ private bool TryDecodeSnapshot(byte[] compressed, string context, out Color32[][] canonical)
+ {
+ canonical = null;
+ if (!_setupValid || compressed == null || compressed.Length == 0) return false;
+
+ try { canonical = DecodeSnapshot(compressed); }
+ catch (System.Exception e)
+ {
+ Debug.LogWarning($"[MeshPainter] {context} snapshot decode failed: {e.Message}", this);
+ return false;
+ }
+ return canonical != null;
+ }
+
private Color32[][] DecodeSnapshot(byte[] compressed)
{
byte[] raw = Inflate(compressed);
@@ -272,22 +244,7 @@ public partial class MeshPainter
// Resolution / version mismatch: ignore rather than smear; the painter keeps its state.
if (ver != SnapshotVersion || size != textureSize) return null;
- int n = textureSize * textureSize;
- var result = new Color32[MAP_COUNT][];
- int load = Mathf.Min(maps, MAP_COUNT);
- for (int mi = 0; mi < load; mi++)
- {
- byte[] b = r.ReadBytes(n * 4);
- if (b.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
- var px = new Color32[n];
- for (int i = 0; i < n; i++)
- {
- int o = i * 4;
- px[i] = new Color32(b[o], b[o + 1], b[o + 2], b[o + 3]);
- }
- result[mi] = px;
- }
- return result;
+ return ReadCanonicalMaps(r, maps);
}
}
diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs
index dba769c..7f1c154 100644
--- a/Assets/Scripts/MeshPainter.Persistence.cs
+++ b/Assets/Scripts/MeshPainter.Persistence.cs
@@ -36,13 +36,16 @@ public partial class MeshPainter
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
- private static string SanitizeKey(string s)
+ /// Filesystem-safe key: invalid filename characters and spaces become underscores.
+ internal static string SanitizeFileKey(string s)
{
- if (string.IsNullOrEmpty(s)) return "unnamed";
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
+ private static string SanitizeKey(string s) =>
+ string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s);
+
/// Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.
private static string PathForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
@@ -86,7 +89,6 @@ public partial class MeshPainter
try
{
Directory.CreateDirectory(PaintLibraryDir);
- int n = textureSize * textureSize;
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
using (var w = new BinaryWriter(fs))
@@ -111,21 +113,7 @@ public partial class MeshPainter
w.Write(HeightNormalIntensity);
w.Write(ParallaxIntensity);
- var bytes = new byte[n * 4];
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- Color32[] px = ReadCanonical(mi);
- int count = Mathf.Min(n, px.Length);
- for (int i = 0; i < count; i++)
- {
- int o = i * 4;
- bytes[o] = px[i].r;
- bytes[o + 1] = px[i].g;
- bytes[o + 2] = px[i].b;
- bytes[o + 3] = px[i].a;
- }
- w.Write(bytes, 0, count * 4);
- }
+ WriteCanonicalMaps(w);
}
return true;
}
@@ -147,8 +135,6 @@ public partial class MeshPainter
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
if (canonical == null) return false;
- // Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path
- // runs and the edge padding is rebuilt correctly for the loaded content.
ReseedCanonical(canonical);
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
@@ -202,9 +188,7 @@ public partial class MeshPainter
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
- if (r.ReadUInt32() != PaintFileMagic) return null;
- int ver = r.ReadInt32();
- if (ver < 2 || ver > PaintFileVersion) return null; // list legacy v2..v3 slots + current
+ if (!TryReadPaintVersion(r, out _)) return null;
return r.ReadString();
}
}
@@ -228,9 +212,7 @@ public partial class MeshPainter
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
- if (r.ReadUInt32() != PaintFileMagic) return null;
- int ver = r.ReadInt32();
- if (ver < 2 || ver > PaintFileVersion) return null; // read legacy v2..v3 + current
+ if (!TryReadPaintVersion(r, out int ver)) return null;
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
@@ -264,23 +246,7 @@ public partial class MeshPainter
return null;
}
- int n = textureSize * textureSize;
- var result = new Color32[MAP_COUNT][];
- int load = Mathf.Min(maps, MAP_COUNT);
- for (int mi = 0; mi < load; mi++)
- {
- byte[] raw = r.ReadBytes(n * 4);
- if (raw.Length != n * 4) break; // truncated file -> remaining maps stay seeded
-
- var px = new Color32[n];
- for (int i = 0; i < n; i++)
- {
- int o = i * 4;
- px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
- }
- result[mi] = px;
- }
- return result;
+ return ReadCanonicalMaps(r, maps);
}
}
catch (System.Exception e)
@@ -289,4 +255,55 @@ public partial class MeshPainter
return null;
}
}
+
+ /// Validates the paint-file magic and version range (legacy v2 .. current).
+ private static bool TryReadPaintVersion(BinaryReader r, out int ver)
+ {
+ if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; }
+ ver = r.ReadInt32();
+ return ver >= 2 && ver <= PaintFileVersion;
+ }
+
+ /// Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot).
+ private void WriteCanonicalMaps(BinaryWriter w)
+ {
+ int n = textureSize * textureSize;
+ var bytes = new byte[n * 4];
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ Color32[] px = ReadCanonical(mi);
+ int count = Mathf.Min(n, px.Length);
+ for (int i = 0; i < count; i++)
+ {
+ int o = i * 4;
+ bytes[o] = px[i].r;
+ bytes[o + 1] = px[i].g;
+ bytes[o + 2] = px[i].b;
+ bytes[o + 3] = px[i].a;
+ }
+ w.Write(bytes, 0, count * 4);
+ }
+ }
+
+ /// Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded).
+ private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile)
+ {
+ int n = textureSize * textureSize;
+ var result = new Color32[MAP_COUNT][];
+ int load = Mathf.Min(mapsInFile, MAP_COUNT);
+ for (int mi = 0; mi < load; mi++)
+ {
+ byte[] raw = r.ReadBytes(n * 4);
+ if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
+
+ var px = new Color32[n];
+ for (int i = 0; i < n; i++)
+ {
+ int o = i * 4;
+ px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
+ }
+ result[mi] = px;
+ }
+ return result;
+ }
}
diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs
index 1f7dbeb..34976aa 100644
--- a/Assets/Scripts/MeshPainter.Pick.cs
+++ b/Assets/Scripts/MeshPainter.Pick.cs
@@ -95,6 +95,14 @@ public partial class MeshPainter
Picked?.Invoke();
}
+ /// Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha).
+ private void AdoptAlbedo(Color c) =>
+ brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a);
+
+ /// Zeroes the emission colour, preserving its paint opacity (alpha).
+ private void ClearEmissionRgb() =>
+ emissionColor = new Color(0f, 0f, 0f, emissionColor.a);
+
///
/// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic).
///
@@ -149,7 +157,7 @@ public partial class MeshPainter
{
// No camera (headless) -> keep the material's raw albedo rather than failing.
if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit))
- brushColor = new Color(Mathf.Clamp01(lit.r), Mathf.Clamp01(lit.g), Mathf.Clamp01(lit.b), brushColor.a);
+ AdoptAlbedo(lit);
return true;
}
@@ -204,7 +212,7 @@ public partial class MeshPainter
Color albedo = baseCol;
if (hasUv && baseTex)
albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol;
- brushColor = new Color(Mathf.Clamp01(albedo.r), Mathf.Clamp01(albedo.g), Mathf.Clamp01(albedo.b), brushColor.a);
+ AdoptAlbedo(albedo);
// --- Metallic / Smoothness: MetallicGloss map (linear) ---
// URP/Lit: metallic = map.r ; smoothness = map.a * _Smoothness (or _Metallic/_Glossiness scalars).
@@ -307,10 +315,10 @@ public partial class MeshPainter
if (weightSum <= 1e-4f) return false;
float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway
- brushColor = new Color(Mathf.Clamp01(albedo.r * inv), Mathf.Clamp01(albedo.g * inv), Mathf.Clamp01(albedo.b * inv), brushColor.a);
+ AdoptAlbedo(albedo * inv);
metallic = Mathf.Clamp01(metallicVal * inv);
smoothness = Mathf.Clamp01(smoothVal * inv);
- emissionColor = new Color(0f, 0f, 0f, emissionColor.a); // occlusion left untouched (now painted height)
+ ClearEmissionRgb(); // occlusion left untouched (now painted height)
return true;
}
@@ -344,10 +352,10 @@ public partial class MeshPainter
{
if (!TrySampleScreenColor(screenPos, out Color c)) return false;
- brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a);
+ AdoptAlbedo(c);
metallic = 0f;
smoothness = 0f;
- emissionColor = new Color(0f, 0f, 0f, emissionColor.a); // occlusion left untouched (now painted height)
+ ClearEmissionRgb(); // occlusion left untouched (now painted height)
return true;
}
diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs
index 545ada1..32fc2ae 100644
--- a/Assets/Scripts/MeshPainter.Setup.cs
+++ b/Assets/Scripts/MeshPainter.Setup.cs
@@ -176,8 +176,6 @@ public partial class MeshPainter
/// Builds the albedo base-color texels from the existing material texture or baseColor.
private Color32[] BuildBaseColors()
{
- var px = new Color32[textureSize * textureSize];
-
Texture sourceTex = null;
var shared = _renderer.sharedMaterial;
if (shared)
@@ -197,9 +195,7 @@ public partial class MeshPainter
catch { /* not readable -> fall back to baseColor */ }
}
- Color32 c = baseColor;
- for (int i = 0; i < px.Length; i++) px[i] = c;
- return px;
+ return FillCanonical(baseColor);
}
/// Enables the URP keywords for the painted PBR maps and seeds the material scalars.
diff --git a/Assets/Scripts/MeshPainter.Spray.cs b/Assets/Scripts/MeshPainter.Spray.cs
index 7f2df3f..eac70ed 100644
--- a/Assets/Scripts/MeshPainter.Spray.cs
+++ b/Assets/Scripts/MeshPainter.Spray.cs
@@ -143,13 +143,7 @@ public partial class MeshPainter
{
if (_sprayTex) return;
- _sprayTex = new Texture2D(SprayRes, SprayRes, TextureFormat.RGBA32, false, true) // linear: raw mask values
- {
- name = "MeshPainter_SprayMask",
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear,
- hideFlags = HideFlags.HideAndDontSave
- };
+ _sprayTex = NewMaskTexture(SprayRes, "MeshPainter_SprayMask");
_sprayPixels = new Color32[SprayRes * SprayRes];
_sprayOpacity = new float[SprayRes * SprayRes];
}
diff --git a/Assets/Scripts/MeshPainter.Undo.cs b/Assets/Scripts/MeshPainter.Undo.cs
index 6006e8b..df91cb9 100644
--- a/Assets/Scripts/MeshPainter.Undo.cs
+++ b/Assets/Scripts/MeshPainter.Undo.cs
@@ -209,10 +209,17 @@ public partial class MeshPainter
private void RestoreCurrentState()
{
- ReseedCanonical(_undoStates[_undoIndex]);
+ RestoreEntry(_undoIndex);
+ OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id
+ }
+
+ /// Shows history entry : re-seeds the backend and clears any in-progress stroke.
+ private void RestoreEntry(int idx)
+ {
+ _undoIndex = idx;
+ ReseedCanonical(_undoStates[idx]);
_strokeActive = false;
_strokeDirtyMaps = 0;
- OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id
}
///
@@ -225,10 +232,7 @@ public partial class MeshPainter
int idx = _undoIds.IndexOf(id);
if (idx < 0) return false;
- _undoIndex = idx;
- ReseedCanonical(_undoStates[idx]);
- _strokeActive = false;
- _strokeDirtyMaps = 0;
+ RestoreEntry(idx);
return true;
}
diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index e5cc201..80c931f 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -105,6 +105,20 @@ public partial class MeshPainter : MonoBehaviour
set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase);
}
+ /// The three camo knobs as one vector (x = half-Lambert, y = its brightness, z = specular dampen).
+ public Vector3 CamoKnobs
+ {
+ get => new(CamoHalfLambert, CamoHalfLambertBrightness, CamoSpecularDampen);
+ set { CamoHalfLambert = value.x; CamoHalfLambertBrightness = value.y; CamoSpecularDampen = value.z; }
+ }
+
+ /// The three painted-height knobs as one vector (x = occlusion threshold, y = relief normal intensity, z = parallax intensity).
+ public Vector3 HeightKnobs
+ {
+ get => new(OcclusionThreshold, HeightNormalIntensity, ParallaxIntensity);
+ set { OcclusionThreshold = value.x; HeightNormalIntensity = value.y; ParallaxIntensity = value.z; }
+ }
+
/// Drives the see-through-walls X-ray overlay: sets the TARGET for the alpha of the authored
/// _XRayColor (the rgb glow colour is preserved); Update eases the material alpha toward it
/// over seconds. A per-avatar runtime reveal, NOT a painted channel
@@ -438,13 +452,7 @@ public partial class MeshPainter : MonoBehaviour
if (_proceduralMask) return;
const int s = 256;
- _proceduralMask = new Texture2D(s, s, TextureFormat.RGBA32, false, true) // linear: raw mask values
- {
- name = "MeshPainter_CircleMask",
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear,
- hideFlags = HideFlags.HideAndDontSave
- };
+ _proceduralMask = NewMaskTexture(s, "MeshPainter_CircleMask");
var px = new Color32[s * s];
_proceduralOpacity = new float[s * s];
@@ -468,6 +476,18 @@ public partial class MeshPainter : MonoBehaviour
_procH = s;
}
+ /// Creates a square RGBA32 brush-mask texture (linear: raw mask values, not colors).
+ private static Texture2D NewMaskTexture(int size, string texName)
+ {
+ return new Texture2D(size, size, TextureFormat.RGBA32, false, true)
+ {
+ name = texName,
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear,
+ hideFlags = HideFlags.HideAndDontSave
+ };
+ }
+
/// Picks the active mask (list entry or procedural circle) and refreshes the CPU opacity cache on change.
private void ResolveActiveMask()
{
@@ -802,8 +822,7 @@ public partial class MeshPainter : MonoBehaviour
if (stamp)
{
- if (_gpuActive) BeginGpuStroke();
- else ResetCpuDirty();
+ BeginBackendStroke();
if (!firstPress && paintInterval <= 0f)
{
@@ -819,8 +838,7 @@ public partial class MeshPainter : MonoBehaviour
PaintAtScreen(screenPos);
}
- if (_gpuActive) EndGpuStroke();
- else EndCpuStroke();
+ EndBackendStroke();
}
_lastScreen = screenPos;
@@ -850,8 +868,7 @@ public partial class MeshPainter : MonoBehaviour
}
else UseResolvedStampMask();
- if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
- else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
+ DrawBackendStamp(pLocal, radiusLocal, u, v, nLocal);
EmitStamp(pLocal, radiusLocal, u, v, nLocal);
MarkStrokePainted(); // a stamp landed -> this stroke is undoable
diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs
index 9dd3171..06d1d84 100644
--- a/Assets/Scripts/NetworkPlayerSetup.cs
+++ b/Assets/Scripts/NetworkPlayerSetup.cs
@@ -145,9 +145,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
// Remote avatar: NetworkTransform owns the transform here, so disable the local
// simulation (input/movement) and the CharacterController's own depenetration.
- if (controller) controller.enabled = false;
- var cc = GetComponent();
- if (cc) cc.enabled = false;
+ DisableLocalSimulation();
// The concealment meter (per-frame bake + voxelization + overlap) only feeds the LOCAL player;
// a remote's concealment is computed on its own owner's machine, so here it is wasted CPU.
@@ -206,16 +204,22 @@ public class NetworkPlayerSetup : NetworkBehaviour
if (IsOwner)
{
- var controller = GetComponent();
- if (controller) controller.enabled = false;
- var cc = GetComponent();
- if (cc) cc.enabled = false;
+ DisableLocalSimulation();
var manager = FindFirstObjectByType(FindObjectsInactive.Include);
if (manager) manager.BindSpectator();
}
}
+ // Stops this avatar simulating locally: movement input and the CharacterController's own depenetration.
+ private void DisableLocalSimulation()
+ {
+ var controller = GetComponent();
+ if (controller) controller.enabled = false;
+ var cc = GetComponent();
+ if (cc) cc.enabled = false;
+ }
+
private void Update()
{
UpdateVisibility();
@@ -352,11 +356,9 @@ public class NetworkPlayerSetup : NetworkBehaviour
// Buried-hider reveal: already gated to hider + running match on the owner before it is published.
if (_embeddedReveal.Value) return true;
- // Resolution reveals every remaining (un-found) hider through walls, on every client.
- var match = MatchManager.Instance;
- return match && match.MatchActive &&
- match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution &&
- role == PlayerRole.Hider && !_isFound.Value;
+ // Resolution reveals every remaining (un-found) hider through walls, on every client -
+ // exactly the still-in-play-hider-during-Resolution predicate the freeze uses.
+ return ComputeResolutionFreeze();
}
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
@@ -392,10 +394,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
private void ApplyOwnerPosition(Vector3 worldPos)
{
var cc = GetComponent();
- bool reenable = cc && cc.enabled;
- if (cc) cc.enabled = false;
- transform.position = worldPos;
- if (cc) cc.enabled = reenable;
+ CharacterTeleport.Move(cc, transform, worldPos);
}
// Resolves the spawn-area collider for this avatar: the Role-specific area (Hunter/Hider) takes priority,
@@ -446,12 +445,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
}
Vector3 target = new Vector3(pick.x, b.min.y + spawnAreaGroundLift, pick.z);
-
- var cc = GetComponent();
- bool reenable = cc && cc.enabled;
- if (cc) cc.enabled = false;
- transform.position = target;
- if (cc) cc.enabled = reenable;
+ ApplyOwnerPosition(target);
}
// Collider.ClosestPoint only constrains to the real shape for primitives and CONVEX mesh colliders;
@@ -481,11 +475,6 @@ public class NetworkPlayerSetup : NetworkBehaviour
// Half-slot stagger per ring so outer slots don't line up radially with inner ones.
float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f;
Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius;
-
- var cc = GetComponent();
- bool reenable = cc && cc.enabled;
- if (cc) cc.enabled = false;
- transform.position += offset;
- if (cc) cc.enabled = reenable;
+ ApplyOwnerPosition(transform.position + offset);
}
}
diff --git a/Assets/Scripts/OnlineSessionManager.cs b/Assets/Scripts/OnlineSessionManager.cs
index 5d80884..650d575 100644
--- a/Assets/Scripts/OnlineSessionManager.cs
+++ b/Assets/Scripts/OnlineSessionManager.cs
@@ -149,14 +149,30 @@ public class OnlineSessionManager : MonoBehaviour
}
}
- private async void HostAsync()
+ // Shared busy/sign-in/error skeleton around one session operation; the body carries the
+ // op-specific statuses and state.
+ private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, Func body)
{
- if (_busy || IsConnected) return;
SetBusy(true);
try
{
SetStatus("Signing in…");
await EnsureSignedInAsync();
+ await body();
+ }
+ catch (Exception e)
+ {
+ SetStatus($"{failLabel} failed: {e.Message}");
+ Debug.LogException(e);
+ }
+ finally { SetBusy(false); }
+ }
+
+ private async void HostAsync()
+ {
+ if (_busy || IsConnected) return;
+ await RunSessionOpAsync("Host", async () =>
+ {
SetStatus("Creating session…");
var options = new SessionOptions
{
@@ -165,55 +181,31 @@ public class OnlineSessionManager : MonoBehaviour
}.WithRelayNetwork();
_session = await MultiplayerService.Instance.CreateSessionAsync(options);
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
- }
- catch (Exception e)
- {
- SetStatus($"Host failed: {e.Message}");
- Debug.LogException(e);
- }
- finally { SetBusy(false); }
+ });
}
private async void RefreshAsync()
{
if (_busy) return;
- SetBusy(true);
- try
+ await RunSessionOpAsync("Refresh", async () =>
{
- SetStatus("Signing in…");
- await EnsureSignedInAsync();
SetStatus("Querying sessions…");
var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions());
_sessions = new List(results.Sessions);
SessionsChanged?.Invoke();
SetStatus($"{_sessions.Count} session(s) found");
- }
- catch (Exception e)
- {
- SetStatus($"Refresh failed: {e.Message}");
- Debug.LogException(e);
- }
- finally { SetBusy(false); }
+ });
}
private async void JoinAsync(string sessionId)
{
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
- SetBusy(true);
- try
+ await RunSessionOpAsync("Join", async () =>
{
- SetStatus("Signing in…");
- await EnsureSignedInAsync();
SetStatus("Joining session…");
_session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
SetStatus($"Joined \"{_session.Name}\"");
- }
- catch (Exception e)
- {
- SetStatus($"Join failed: {e.Message}");
- Debug.LogException(e);
- }
- finally { SetBusy(false); }
+ });
}
private async void DisconnectAsync()
diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs
index 18b9ed8..2d3f175 100644
--- a/Assets/Scripts/PaintHud.Persistence.cs
+++ b/Assets/Scripts/PaintHud.Persistence.cs
@@ -73,11 +73,7 @@ public partial class PaintHud
return Path.Combine(HudSaveDir, key + ".hudlayout");
}
- private static string SanitizeHudKey(string s)
- {
- foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
- return s.Replace(' ', '_');
- }
+ private static string SanitizeHudKey(string s) => MeshPainter.SanitizeFileKey(s);
[Serializable]
private class HudState
@@ -224,27 +220,18 @@ public partial class PaintHud
// it - once per HUD, so a reset always returns to the genuine material default.
if (!_hasDefaultCamo)
{
- _defaults.camoHalfLambert = painter.CamoHalfLambert;
- _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
- _defaults.camoSpecularDampen = painter.CamoSpecularDampen;
- _defaults.occlusionThreshold = painter.OcclusionThreshold;
- _defaults.heightNormalIntensity = painter.HeightNormalIntensity;
- _defaults.parallaxIntensity = painter.ParallaxIntensity;
+ CaptureCamoDefaults();
_hasDefaultCamo = true;
}
if (_hasPendingCamo)
{
- painter.CamoHalfLambert = _pendingCamo.x;
- painter.CamoHalfLambertBrightness = _pendingCamo.y;
- painter.CamoSpecularDampen = _pendingCamo.z;
+ painter.CamoKnobs = _pendingCamo;
_hasPendingCamo = false;
}
if (_hasPendingHeight)
{
- painter.OcclusionThreshold = _pendingHeight.x;
- painter.HeightNormalIntensity = _pendingHeight.y;
- painter.ParallaxIntensity = _pendingHeight.z;
+ painter.HeightKnobs = _pendingHeight;
_hasPendingHeight = false;
}
SyncCamoSliders();
@@ -471,6 +458,17 @@ public partial class PaintHud
_hasDefaults = true;
}
+ /// Snapshots the live camo + height knobs into the reset defaults (caller decides _hasDefaultCamo).
+ private void CaptureCamoDefaults()
+ {
+ _defaults.camoHalfLambert = painter.CamoHalfLambert;
+ _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
+ _defaults.camoSpecularDampen = painter.CamoSpecularDampen;
+ _defaults.occlusionThreshold = painter.OcclusionThreshold;
+ _defaults.heightNormalIntensity = painter.HeightNormalIntensity;
+ _defaults.parallaxIntensity = painter.ParallaxIntensity;
+ }
+
///
/// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the
/// panels jump back to their prefab positions and every brush value returns to the painter's prefab /
@@ -494,14 +492,7 @@ public partial class PaintHud
// Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than
// slamming it to the not-yet-filled 0 (which would flatten the material's lighting).
if (!_hasDefaultCamo && painter.IsReady)
- {
- _defaults.camoHalfLambert = painter.CamoHalfLambert;
- _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
- _defaults.camoSpecularDampen = painter.CamoSpecularDampen;
- _defaults.occlusionThreshold = painter.OcclusionThreshold;
- _defaults.heightNormalIntensity = painter.HeightNormalIntensity;
- _defaults.parallaxIntensity = painter.ParallaxIntensity;
- }
+ CaptureCamoDefaults();
ApplyValues(painter, _defaults);
SyncFromPainter();
diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs
index 6e988ef..e8ec493 100644
--- a/Assets/Scripts/PaintHud.cs
+++ b/Assets/Scripts/PaintHud.cs
@@ -155,11 +155,7 @@ public partial class PaintHud : MonoBehaviour
{
if (!newPainter || newPainter == painter || !_panel) return;
- if (painter)
- {
- painter.Picked -= SyncFromPainter;
- painter.BrushSizeChanged -= SyncBrushSize;
- }
+ UnhookPainterEvents();
painter = newPainter;
_camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready
@@ -184,13 +180,13 @@ public partial class PaintHud : MonoBehaviour
UpdateButtonStates(); // enable Save now that a painter is bound
}
- private void OnDestroy()
+ private void OnDestroy() => UnhookPainterEvents();
+
+ private void UnhookPainterEvents()
{
- if (painter)
- {
- painter.Picked -= SyncFromPainter;
- painter.BrushSizeChanged -= SyncBrushSize;
- }
+ if (!painter) return;
+ painter.Picked -= SyncFromPainter;
+ painter.BrushSizeChanged -= SyncBrushSize;
}
private void OnModeChanged(GameMode mode)
@@ -383,8 +379,7 @@ public partial class PaintHud : MonoBehaviour
private static void SetSlider(Transform panel, string name, float value)
{
- var cell = FindDeep(panel, name);
- var slider = cell ? cell.GetComponentInChildren() : null;
+ var slider = FindSlider(panel, name, out _);
if (!slider) return;
var curve = slider.GetComponent();
if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener
@@ -470,8 +465,7 @@ public partial class PaintHud : MonoBehaviour
{
if (!tex) return null;
if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached;
- var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
- s.hideFlags = HideFlags.HideAndDontSave;
+ var s = ToSprite(tex);
_maskSprites[tex] = s;
return s;
}
@@ -525,11 +519,16 @@ public partial class PaintHud : MonoBehaviour
toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f);
}
+ /// Finds a slider cell by name and the Slider inside it (null when either is missing).
+ private static Slider FindSlider(Transform panel, string name, out Transform cell)
+ {
+ cell = FindDeep(panel, name);
+ return cell ? cell.GetComponentInChildren() : null;
+ }
+
private static void WireSlider(Transform panel, string name, float value, Action onChanged)
{
- var cell = FindDeep(panel, name);
- if (!cell) return;
- var slider = cell.GetComponentInChildren();
+ var slider = FindSlider(panel, name, out Transform cell);
if (!slider) return;
var curve = slider.GetComponent(); // optional non-linear response on the same object
var valueRt = cell.Find("Value");
diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs
index b03fd26..a76000a 100644
--- a/Assets/Scripts/PaintMode.cs
+++ b/Assets/Scripts/PaintMode.cs
@@ -9,11 +9,7 @@ public class PaintMode : GameMode
public override void Enter()
{
- if (Manager.Player)
- {
- Manager.Player.enabled = true;
- Manager.Player.ControlEnabled = false;
- }
+ SetPlayerControl(false);
if (Manager.CameraInput)
{
@@ -21,8 +17,7 @@ public class PaintMode : GameMode
Manager.CameraInput.PanEnabled = true;
}
- if (Manager.Painter)
- Manager.Painter.PaintingEnabled = true;
+ SetPainting(true);
}
public override void Exit()
diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs
index 76fae74..dc66f80 100644
--- a/Assets/Scripts/PlayerCharacterController.cs
+++ b/Assets/Scripts/PlayerCharacterController.cs
@@ -486,17 +486,12 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum. The
- /// CharacterController overrides direct transform writes, so it is toggled around the move (same
- /// pattern as ). Owner-only; the authoritative
- /// NetworkTransform replicates the new position to remotes.
+ /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum.
+ /// Owner-only; the authoritative NetworkTransform replicates the new position to remotes.
///
private void RescueToSpawn()
{
- bool reenable = _controller && _controller.enabled;
- if (_controller) _controller.enabled = false;
- transform.position = _spawnPosition;
- if (_controller) _controller.enabled = reenable;
+ CharacterTeleport.Move(_controller, transform, _spawnPosition);
_verticalVelocity = 0f;
_horizontalVelocity = Vector3.zero;
@@ -800,12 +795,12 @@ public class PlayerCharacterController : MonoBehaviour
}
else if (_climbLeftGround && vEff > 0f)
{
- TopOutDetach(); // topped out upward onto walkable ground: hop over the lip (holding the up key carries on)
+ TopOutDetach();
return;
}
else if (_climbLeftGround || vEff < 0f)
{
- ExitWallClimb(); // descended to the floor, or pulling down/away at the base: let go, no hop
+ ExitWallClimb();
return;
}
@@ -989,34 +984,16 @@ public class PlayerCharacterController : MonoBehaviour
///
private bool ProbeWall(out Vector3 normal, out float gap)
{
- normal = _wallNormal;
- gap = 0f;
-
Vector3 origin = WorldCapsuleCenter;
Vector3 dir = -_wallNormal;
float extent = CapsuleExtent(dir);
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
- int count = Physics.RaycastNonAlloc(
- origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
+ if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
+ return true;
- float best = float.MaxValue;
- bool found = false;
- for (int i = 0; i < count; i++)
- {
- if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) // skip self, other player avatars, and no-climb surfaces
- continue;
- if (_wallProbe[i].distance < best)
- {
- best = _wallProbe[i].distance;
- normal = _wallProbe[i].normal;
- found = true;
- }
- }
-
- if (found)
- gap = Mathf.Max(0f, best - extent);
- return found;
+ normal = _wallNormal;
+ return false;
}
///
@@ -1035,15 +1012,32 @@ public class PlayerCharacterController : MonoBehaviour
///
private bool ProbeCeiling(out Vector3 normal, out float gap)
{
- normal = Vector3.down;
- gap = 0f;
-
Vector3 origin = WorldCapsuleCenter;
float half = WorldHalfHeight;
float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale;
+ if (NearestClimbHit(origin, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap))
+ return true;
+
+ normal = Vector3.down;
+ return false;
+ }
+
+ ///
+ /// Raycasts along and returns the nearest hit that a climb probe accepts
+ /// (player avatars and no-climb surfaces never count; additionally
+ /// rejects non-ceiling normals). is the clearance beyond the capsule's
+ /// support along the probe direction. False = no valid hit
+ /// (/ are then zero; callers supply their fallback).
+ ///
+ private bool NearestClimbHit(Vector3 origin, Vector3 dir, float maxDist, float extent,
+ bool ceilingOnly, out Vector3 normal, out float gap)
+ {
+ normal = Vector3.zero;
+ gap = 0f;
+
int count = Physics.RaycastNonAlloc(
- origin, Vector3.up, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
+ origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
float best = float.MaxValue;
bool found = false;
@@ -1051,7 +1045,7 @@ public class PlayerCharacterController : MonoBehaviour
{
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
- if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling
+ if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling
continue;
if (_wallProbe[i].distance < best)
{
@@ -1062,7 +1056,7 @@ public class PlayerCharacterController : MonoBehaviour
}
if (found)
- gap = Mathf.Max(0f, best - half);
+ gap = Mathf.Max(0f, best - extent);
return found;
}
diff --git a/Assets/Scripts/PlayerControlMode.cs b/Assets/Scripts/PlayerControlMode.cs
index 71ffaf3..f252485 100644
--- a/Assets/Scripts/PlayerControlMode.cs
+++ b/Assets/Scripts/PlayerControlMode.cs
@@ -8,16 +8,11 @@ public class PlayerControlMode : GameMode
public override void Enter()
{
- if (Manager.Player)
- {
- Manager.Player.enabled = true;
- Manager.Player.ControlEnabled = true;
- }
+ SetPlayerControl(true);
if (Manager.CameraInput)
Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive;
- if (Manager.Painter)
- Manager.Painter.PaintingEnabled = false;
+ SetPainting(false);
}
}
diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs
index 932d03c..9af2d54 100644
--- a/Assets/Scripts/PlayerPaintSync.cs
+++ b/Assets/Scripts/PlayerPaintSync.cs
@@ -233,20 +233,14 @@ public class PlayerPaintSync : NetworkBehaviour
// its own material via HUD + persistence).
if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady)
{
- Vector3 c = _camo.Value;
- _painter.CamoHalfLambert = c.x;
- _painter.CamoHalfLambertBrightness = c.y;
- _painter.CamoSpecularDampen = c.z;
+ _painter.CamoKnobs = _camo.Value;
_camoApplyPending = false;
}
// Remote replica: apply the owner's painted-height knobs once the material exists / on change.
if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady)
{
- Vector3 h = _height.Value;
- _painter.OcclusionThreshold = h.x;
- _painter.HeightNormalIntensity = h.y;
- _painter.ParallaxIntensity = h.z;
+ _painter.HeightKnobs = _height.Value;
_heightApplyPending = false;
}
@@ -274,11 +268,11 @@ public class PlayerPaintSync : NetworkBehaviour
// published (it would briefly flatten remotes' lighting until the real value lands).
if (_painter && _painter.IsReady)
{
- var camo = new Vector3(_painter.CamoHalfLambert, _painter.CamoHalfLambertBrightness, _painter.CamoSpecularDampen);
+ Vector3 camo = _painter.CamoKnobs;
if (_camo.Value != camo)
_camo.Value = camo;
- var height = new Vector3(_painter.OcclusionThreshold, _painter.HeightNormalIntensity, _painter.ParallaxIntensity);
+ Vector3 height = _painter.HeightKnobs;
if (_height.Value != height)
_height.Value = height;
}
@@ -365,20 +359,31 @@ public class PlayerPaintSync : NetworkBehaviour
[ServerRpc]
private void SubmitRestoreServerRpc(int seq, int id) => ApplyRestoreClientRpc(seq, id);
- [ClientRpc]
- private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
+ ///
+ /// Common gate for the replicated commands: the owner already applied them locally, so only remotes
+ /// (incl. the host's replica) proceed; during a snapshot transfer the event is buffered instead.
+ /// Returns true when the caller should apply the event live.
+ ///
+ private bool GateRemoteEvent(NetEvent ev)
{
- // The owner already painted these locally; only remotes (incl. the host's replica) replay them.
if (IsOwner || !_painter)
- return;
+ return false;
- _seqHorizon = seq; // server replica tracks this as the snapshot cutoff source
+ _seqHorizon = ev.seq; // server replica tracks this as the snapshot cutoff source
if (_awaitingSnapshot)
{
- _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps });
- return;
+ _buffered.Add(ev);
+ return false;
}
+ return true;
+ }
+
+ [ClientRpc]
+ private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
+ {
+ if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps }))
+ return;
for (int i = 0; i < stamps.Length; i++)
_painter.ApplyNetworkStamp(stamps[i]);
@@ -387,17 +392,9 @@ public class PlayerPaintSync : NetworkBehaviour
[ClientRpc]
private void ApplyCheckpointClientRpc(int seq, int id)
{
- if (IsOwner || !_painter)
+ if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id }))
return;
- _seqHorizon = seq;
-
- if (_awaitingSnapshot)
- {
- _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id });
- return;
- }
-
_painter.RemoteCommitCheckpoint(id);
LogSync($"committed checkpoint {id} (seq {seq})");
}
@@ -405,17 +402,9 @@ public class PlayerPaintSync : NetworkBehaviour
[ClientRpc]
private void ApplyRestoreClientRpc(int seq, int id)
{
- if (IsOwner || !_painter)
+ if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Restore, id = id }))
return;
- _seqHorizon = seq;
-
- if (_awaitingSnapshot)
- {
- _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Restore, id = id });
- return;
- }
-
// Reproduce the undo/redo from this peer's own history; if it doesn't hold that checkpoint (an
// undo past this peer's join point), fetch a fresh snapshot of the resulting state instead.
if (_painter.RemoteRestoreTo(id))
@@ -520,14 +509,20 @@ public class PlayerPaintSync : NetworkBehaviour
int xferId = ++_xferOut;
BeginSnapshotClientRpc(xferId, data.Length, capturedSeq, baselineId, send);
+ yield return SendChunked(data, (off, chunk) => SnapshotChunkClientRpc(xferId, off, chunk, send));
+ EndSnapshotClientRpc(xferId, send);
+ }
+ /// Feeds a blob chunk-wise into sendChunk(offset, chunk), throttled to snapshotChunksPerFrame.
+ private IEnumerator SendChunked(byte[] data, System.Action sendChunk)
+ {
int sentThisFrame = 0;
for (int off = 0; off < data.Length; off += snapshotChunkBytes)
{
int len = Mathf.Min(snapshotChunkBytes, data.Length - off);
var chunk = new byte[len];
System.Array.Copy(data, off, chunk, 0, len);
- SnapshotChunkClientRpc(xferId, off, chunk, send);
+ sendChunk(off, chunk);
if (++sentThisFrame >= snapshotChunksPerFrame)
{
@@ -535,8 +530,6 @@ public class PlayerPaintSync : NetworkBehaviour
yield return null; // spread the burst so the reliable window does not overflow
}
}
-
- EndSnapshotClientRpc(xferId, send);
}
[ClientRpc]
@@ -564,8 +557,14 @@ public class PlayerPaintSync : NetworkBehaviour
private void SnapshotChunkClientRpc(int xferId, int offset, byte[] chunk, ClientRpcParams p = default)
{
if (xferId != _activeXferId || _snapBuffer == null) return; // stale chunk from a superseded transfer
- if (offset >= 0 && offset + chunk.Length <= _snapBuffer.Length)
- System.Array.Copy(chunk, 0, _snapBuffer, offset, chunk.Length);
+ CopyChunkInto(_snapBuffer, offset, chunk);
+ }
+
+ /// Copies a received chunk into the reassembly buffer, ignoring out-of-bounds offsets.
+ private static void CopyChunkInto(byte[] buffer, int offset, byte[] chunk)
+ {
+ if (offset >= 0 && offset + chunk.Length <= buffer.Length)
+ System.Array.Copy(chunk, 0, buffer, offset, chunk.Length);
}
[ClientRpc]
@@ -611,22 +610,7 @@ public class PlayerPaintSync : NetworkBehaviour
private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target)
{
BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target);
-
- int sentThisFrame = 0;
- for (int off = 0; off < data.Length; off += snapshotChunkBytes)
- {
- int len = Mathf.Min(snapshotChunkBytes, data.Length - off);
- var chunk = new byte[len];
- System.Array.Copy(data, off, chunk, 0, len);
- UploadChunkServerRpc(off, chunk);
-
- if (++sentThisFrame >= snapshotChunksPerFrame)
- {
- sentThisFrame = 0;
- yield return null; // spread the burst so the reliable window does not overflow
- }
- }
-
+ yield return SendChunked(data, (off, chunk) => UploadChunkServerRpc(off, chunk));
EndUploadServerRpc();
}
@@ -644,8 +628,7 @@ public class PlayerPaintSync : NetworkBehaviour
private void UploadChunkServerRpc(int offset, byte[] chunk)
{
if (_uploadBuffer == null) return;
- if (offset >= 0 && offset + chunk.Length <= _uploadBuffer.Length)
- System.Array.Copy(chunk, 0, _uploadBuffer, offset, chunk.Length);
+ CopyChunkInto(_uploadBuffer, offset, chunk);
}
[ServerRpc]
diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs
index e500065..25ae156 100644
--- a/Assets/Scripts/PlayerPoseController.cs
+++ b/Assets/Scripts/PlayerPoseController.cs
@@ -179,7 +179,7 @@ public class PlayerPoseController : MonoBehaviour
// Local control only - a remote's pose index is pushed in via SetNetworkState.
if (!_networkControlled)
{
- if (CycleKeyPressed())
+ if (InputUtil.GameplayKeyPressed(cycleKey))
NextPose();
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
@@ -213,17 +213,21 @@ public class PlayerPoseController : MonoBehaviour
// Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides
// the camera, routed to the ThirdPersonCamera's follow target only (never the body).
float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f;
- _cameraPoseOffset = cameraOffsetSpeed > 0f
- ? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime)
- : cameraPoseTarget;
+ _cameraPoseOffset = EaseCameraOffset(_cameraPoseOffset, cameraPoseTarget);
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
// Separate per-pose first-person eye offset - NOT routed to the shared camera target (that would
// also move the ThirdPersonCamera); the FirstPersonController reads it directly.
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
- _firstPersonCameraOffset = cameraOffsetSpeed > 0f
- ? Mathf.MoveTowards(_firstPersonCameraOffset, firstPersonTarget, cameraOffsetSpeed * Time.deltaTime)
- : firstPersonTarget;
+ _firstPersonCameraOffset = EaseCameraOffset(_firstPersonCameraOffset, firstPersonTarget);
+ }
+
+ /// Eases a camera y-offset toward its target at ; 0 snaps.
+ private float EaseCameraOffset(float current, float target)
+ {
+ return cameraOffsetSpeed > 0f
+ ? Mathf.MoveTowards(current, target, cameraOffsetSpeed * Time.deltaTime)
+ : target;
}
void OnDisable()
@@ -339,17 +343,6 @@ public class PlayerPoseController : MonoBehaviour
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
}
- private bool CycleKeyPressed()
- {
- if (TypingGuard.IsTyping) return false;
-#if ENABLE_INPUT_SYSTEM
- var keyboard = Keyboard.current;
- return keyboard != null && keyboard[cycleKey].wasPressedThisFrame;
-#else
- return Input.GetKeyDown(cycleKey);
-#endif
- }
-
///
/// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
/// clip crossfades from the current pose over (the first pose, or a
diff --git a/Assets/Scripts/SkinnedColliderBaker.cs b/Assets/Scripts/SkinnedColliderBaker.cs
index 8d0cfe8..d61d68d 100644
--- a/Assets/Scripts/SkinnedColliderBaker.cs
+++ b/Assets/Scripts/SkinnedColliderBaker.cs
@@ -73,30 +73,12 @@ public class SkinnedColliderBaker : MonoBehaviour
// renderer), and MeshPainter / ConcealmentMeter — which raise the baked mesh to world via
// localToWorldMatrix — stay correct at any player scale. No-op (and untouched) at scale 1; the
// uniform divide is rotation-independent, which the player's uniform scale guarantees.
- Vector3 ls = transform.lossyScale;
- if (ls.x != 1f || ls.y != 1f || ls.z != 1f)
- RescaleToUnitScale(ls);
+ GeometryUtil.RescaleToUnitScale(_baked, transform.lossyScale, _vbuf);
_collider.sharedMesh = null; // force a physics re-cook of the new geometry
_collider.sharedMesh = _baked;
}
- private void RescaleToUnitScale(Vector3 lossyScale)
- {
- float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f;
- float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f;
- float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f;
- _baked.GetVertices(_vbuf);
- for (int i = 0; i < _vbuf.Count; i++)
- {
- Vector3 v = _vbuf[i];
- v.x *= ix; v.y *= iy; v.z *= iz;
- _vbuf[i] = v;
- }
- _baked.SetVertices(_vbuf);
- _baked.RecalculateBounds();
- }
-
private void OnDestroy()
{
if (_baked) Destroy(_baked);
diff --git a/Assets/Scripts/SmoothedLook.cs b/Assets/Scripts/SmoothedLook.cs
new file mode 100644
index 0000000..042a984
--- /dev/null
+++ b/Assets/Scripts/SmoothedLook.cs
@@ -0,0 +1,62 @@
+using UnityEngine;
+
+///
+/// Mouse-look yaw/pitch accumulator with optional SmoothDampAngle smoothing, shared by the
+/// first-person and freecam rigs. accumulates a scaled mouse delta into the
+/// target angles and advances the smoothed display angles; seeds everything
+/// from an existing rotation so going live produces no visible jump.
+///
+public struct SmoothedLook
+{
+ float _yaw; // look target (raw input accumulates here)
+ float _pitch;
+ float _displayYaw; // smoothed value actually applied to the transform
+ float _displayPitch;
+ float _yawSmoothRef;
+ float _pitchSmoothRef;
+
+ /// Smoothed yaw shown this frame (drives the body's facing in first person).
+ public float DisplayYaw => _displayYaw;
+
+ /// Rotation composed from the smoothed display angles.
+ public Quaternion Rotation => Quaternion.Euler(_displayPitch, _displayYaw, 0f);
+
+ /// Accumulates one frame of look input and advances the smoothed display angles.
+ public void Tick(Vector2 scaledDelta, float hSens, float vSens, bool invertY, float maxPitch, float smoothTime)
+ {
+ _yaw += scaledDelta.x * hSens;
+ float vs = vSens * (invertY ? 1f : -1f);
+ _pitch = Mathf.Clamp(_pitch + scaledDelta.y * vs, -maxPitch, maxPitch);
+
+ if (smoothTime > 0f)
+ {
+ // SmoothDampAngle is frame-rate-independent and wraps yaw correctly.
+ _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, smoothTime);
+ _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, smoothTime);
+ }
+ else
+ {
+ _displayYaw = _yaw;
+ _displayPitch = _pitch;
+ }
+ }
+
+ /// Seeds the look angles from an existing rotation, then snaps the smoothed state to them.
+ public void SyncTo(Quaternion rotation, float maxPitch)
+ {
+ Vector3 e = rotation.eulerAngles;
+ _yaw = e.y;
+ float p = e.x > 180f ? e.x - 360f : e.x;
+ _pitch = Mathf.Clamp(p, -maxPitch, maxPitch);
+ Snap();
+ }
+
+ /// Snaps the smoothed state to the current targets so entering never lags/jumps to catch up.
+ public void Snap()
+ {
+ _displayYaw = _yaw;
+ _displayPitch = _pitch;
+ _yawSmoothRef = 0f;
+ _pitchSmoothRef = 0f;
+ }
+}
diff --git a/Assets/Scripts/SmoothedLook.cs.meta b/Assets/Scripts/SmoothedLook.cs.meta
new file mode 100644
index 0000000..2297928
--- /dev/null
+++ b/Assets/Scripts/SmoothedLook.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b943136bd10858b42b2790061dc1213e
\ No newline at end of file