Fix wall/ceiling corner rotation twitch by latching the climbed surface

In an inner wall/ceiling corner the body's facing flipped ~9 deg every
frame. Recorded frame data showed the wall/ceiling classification flipping
160 times: _wallNormal.y swung the full range (0.0 wall <-> -0.966 ceiling)
each frame, far past the hysteresis band, so the Schmitt trigger could not
hold it.

Root cause: _wallNormal had two competing per-frame sources.
- The contact-normal blend (Update) mixes ceiling and wall contacts in the
  corner into ~(0,-0.97,-0.26), classified as ceiling.
- ProbeWall raycasts along -_wallNormal, which tilts toward the wall, so the
  ray clips the wall and returns (0,0,-1), classified as wall.
Wall and ceiling drive different rotation targets (snap to face-wall vs.
face movement direction), producing the per-frame twitch.

Fix:
- The contact blend no longer overwrites _wallNormal during an active climb
  (it only seeds the initial grab); the probes own the normal while climbing.
- Surface-aware probing: on a ceiling, probe straight up (ProbeCeiling)
  instead of along -_wallNormal, so the angled ray can no longer clip the
  wall and demote us. Stay latched on the ceiling until it is genuinely gone
  overhead, then fall back to the wall probe.

Also retains the ceiling-class hysteresis (Schmitt trigger on the normal's
downward tilt) from the prior attempt as added stability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 10:25:05 +02:00
1 parent 52f9ace9e0
commit 120d3b6675
2 files changed
+75 -30

No files matched your search

+75 -10
View File
@@ -161,7 +161,7 @@ public partial class PlayerCharacterController : MonoBehaviour
public bool IsSprinting => _sprinting;
/// <summary>True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).</summary>
public bool IsOnCeiling => _isWallClimbing && _wallNormal.y < CeilingNormalThreshold;
public bool IsOnCeiling => _isWallClimbing && _onCeiling;
/// <summary>
/// True while grounded and actually moving above the run threshold (the ground-locomotion state).
@@ -215,6 +215,14 @@ public partial class PlayerCharacterController : MonoBehaviour
// than a wall (up/down model) once its normal points down past this threshold (~>120 deg
// from up); between that and wallMinAngle it is treated as a wall.
private const float CeilingNormalThreshold = -0.5f;
// Hysteresis band around the threshold: in the inner wall/ceiling corner the capsule touches
// both surfaces, so the per-frame contact normal mixes horizontal (wall) and downward (ceiling)
// and its y crosses -0.5 back and forth -- flipping the climb mode and snapping the body's yaw
// between "face the wall" and "face the move direction". Latch the class and only flip when the
// normal is unambiguously past one edge of the band.
private const float CeilingEnterThreshold = -0.6f; // wall -> ceiling only when clearly down-facing
private const float CeilingExitThreshold = -0.4f; // ceiling -> wall only when clearly not down-facing
private bool _onCeiling; // latched climb-surface class (hysteretic; see thresholds above)
private bool _isWallClimbing;
private Vector3 _wallNormal;
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
@@ -328,9 +336,12 @@ public partial class PlayerCharacterController : MonoBehaviour
UpdateWallClimbState(moveDir);
// Latch the wall normal for this frame, then reset the per-frame contact
// accumulators (OnControllerColliderHit refills them during Move).
if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
// Latch the wall normal from contacts ONLY while not yet climbing (the grab needs it).
// During a climb the probes own _wallNormal: the per-frame contact blend mixes ceiling
// and wall normals in an inner corner (e.g. ~(0,-0.97,-0.26)), which would fight the
// probed surface and flip the wall/ceiling classification frame to frame. Reset the
// accumulators regardless (OnControllerColliderHit refills them during Move).
if (!_isWallClimbing && _wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
_wallNormalAccum = Vector3.zero;
@@ -471,6 +482,7 @@ public partial class PlayerCharacterController : MonoBehaviour
{
_isWallClimbing = true;
_wallNormal = _wallNormalAccum.normalized;
_onCeiling = _wallNormal.y < CeilingNormalThreshold;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
@@ -506,7 +518,8 @@ public partial class PlayerCharacterController : MonoBehaviour
return;
}
bool onCeiling = _wallNormal.y < CeilingNormalThreshold;
UpdateCeilingClass();
bool onCeiling = _onCeiling;
// Jump detaches. Off a wall it pushes straight up; off a ceiling it just lets go and
// drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test.
@@ -524,16 +537,50 @@ public partial class PlayerCharacterController : MonoBehaviour
&& ceilHeadGap <= ceilingGrabDistance)
{
_wallNormal = ceilNormal;
_onCeiling = true;
onCeiling = true;
_wallOffset = 0f;
}
bool found = ProbeWall(out Vector3 probedNormal, out float gap);
if (found)
// Re-measure the surface each frame, probing the kind we're committed to. On a ceiling
// probe straight UP (ProbeCeiling), NOT along -_wallNormal: the ceiling normal tilts a
// little toward the wall in an inner corner, so the -_wallNormal ray would clip the wall
// and demote us to "wall" — then the hand-off re-promotes next frame, flipping the body's
// facing ~9 deg every frame (the corner twitch). Stay latched on the ceiling until it is
// genuinely gone overhead; only then fall back to the wall probe.
bool found;
float gap;
if (onCeiling)
{
_wallNormal = probedNormal;
_lostContactTimer = 0f;
onCeiling = _wallNormal.y < CeilingNormalThreshold;
found = ProbeCeiling(out Vector3 ceilNormal2, out float ceilGap);
if (found)
{
_wallNormal = ceilNormal2;
gap = ceilGap;
_lostContactTimer = 0f;
}
else
{
found = ProbeWall(out Vector3 wallNormal, out gap);
if (found)
{
_wallNormal = wallNormal;
_lostContactTimer = 0f;
}
UpdateCeilingClass();
onCeiling = _onCeiling;
}
}
else
{
found = ProbeWall(out Vector3 probedNormal, out gap);
if (found)
{
_wallNormal = probedNormal;
_lostContactTimer = 0f;
UpdateCeilingClass();
onCeiling = _onCeiling;
}
}
// Topped out onto a slope: the climbed face has flattened into something walkable (no
@@ -713,6 +760,23 @@ public partial class PlayerCharacterController : MonoBehaviour
}
}
/// <summary>
/// Updates the latched wall/ceiling classification with hysteresis on the surface normal's
/// downward tilt. In the inner corner the live normal jitters across the single threshold; the
/// band keeps the climb mode (and the body yaw it drives) from flipping frame to frame.
/// </summary>
private void UpdateCeilingClass()
{
if (_onCeiling)
{
if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false;
}
else if (_wallNormal.y < CeilingEnterThreshold)
{
_onCeiling = true;
}
}
/// <summary>
/// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the
/// given signed distance (+ = out from the wall, - = into it), relative to the model's authored
@@ -1009,6 +1073,7 @@ public partial class PlayerCharacterController : MonoBehaviour
private void ExitWallClimb()
{
_isWallClimbing = false;
_onCeiling = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
// Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest)