From 120d3b6675565a919964bdbe75de5b5c05daef83 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 27 Jun 2026 10:25:05 +0200 Subject: [PATCH] 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 --- Assets/Scenes/SampleScene.unity | 20 ----- Assets/Scripts/PlayerCharacterController.cs | 85 ++++++++++++++++++--- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index 6bf83a8..af09848 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -7552,10 +7552,6 @@ PrefabInstance: propertyPath: m_IsActive value: 1 objectReference: {fileID: 0} - - target: {fileID: 5804137883901333613, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} - propertyPath: m_Enabled - value: 1 - objectReference: {fileID: 0} - target: {fileID: 5866666021909216657, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} propertyPath: m_Avatar value: @@ -7568,22 +7564,6 @@ PrefabInstance: propertyPath: m_ApplyRootMotion value: 0 objectReference: {fileID: 0} - - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} - propertyPath: m_LocalScale.x - value: 1 - objectReference: {fileID: 0} - - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} - propertyPath: m_LocalScale.y - value: 1 - objectReference: {fileID: 0} - - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} - propertyPath: m_LocalScale.z - value: 1 - objectReference: {fileID: 0} - - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3} - propertyPath: m_ConstrainProportionsScale - value: 1 - objectReference: {fileID: 0} m_RemovedComponents: [] m_RemovedGameObjects: [] m_AddedGameObjects: [] diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index 7847c45..3e8c33e 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -161,7 +161,7 @@ public partial class PlayerCharacterController : MonoBehaviour public bool IsSprinting => _sprinting; /// True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal). - public bool IsOnCeiling => _isWallClimbing && _wallNormal.y < CeilingNormalThreshold; + public bool IsOnCeiling => _isWallClimbing && _onCeiling; /// /// 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 } } + /// + /// 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. + /// + private void UpdateCeilingClass() + { + if (_onCeiling) + { + if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false; + } + else if (_wallNormal.y < CeilingEnterThreshold) + { + _onCeiling = true; + } + } + /// /// 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)