Replace ceiling Y offset with per-pose CharacterController capsule

Each PlayerPose now carries its own collider center Y, radius and height,
eased and routed to the CharacterController via a SetCapsule single-writer.
This lets crawl poses (Ball/Star) shrink the capsule to fit under furniture.
The ceiling Y offset is removed; poses without authored sizes fall back to
the base capsule, so existing poses keep their collision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 09:55:10 +02:00
1 parent 13de058819
commit 52f9ace9e0
6 files changed
+144 -59

No files matched your search

+3 -1
View File
@@ -18,5 +18,7 @@ MonoBehaviour:
walkClipSpeed: 1
runClip: {fileID: 0}
runClipSpeed: 1
ceilingYOffset: 0.84
controllerCenterY: 0.25
controllerRadius: 0.15
controllerHeight: 0.5
cameraTargetYOffset: -0.5
+3 -1
View File
@@ -18,5 +18,7 @@ MonoBehaviour:
walkClipSpeed: 1
runClip: {fileID: 0}
runClipSpeed: 1
ceilingYOffset: 1
controllerCenterY: 0.12
controllerRadius: 0.1
controllerHeight: 0.3
cameraTargetYOffset: -0.55
+3 -2
View File
@@ -4633,7 +4633,7 @@ MonoBehaviour:
wallOffsetReturnSpeed: 2
wallOffsetFollow: 12
wallExitGrace: 0.15
ceilingGrabDistance: 0.3
ceilingGrabDistance: 0.02
wallTopOutBoost: 0.3
cameraOrbit: {fileID: 0}
meshOffsetTarget: {fileID: 0}
@@ -4660,7 +4660,8 @@ MonoBehaviour:
defaultPoseIndex: 0
cycleKey: 17
poseBlendDuration: 0.2
ceilingOffsetSpeed: 4
cameraOffsetSpeed: 4
capsuleBlendSpeed: 8
applyFootIK: 0
--- !u!114 &1141039644
MonoBehaviour:
+26 -16
View File
@@ -127,7 +127,7 @@ public partial class PlayerCharacterController : MonoBehaviour
public float wallExitGrace = 0.15f;
[Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
public float ceilingGrabDistance = 0.3f;
public float ceilingGrabDistance = 0.02f;
[Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")]
public float wallTopOutBoost = 0.3f;
@@ -185,6 +185,16 @@ public partial class PlayerCharacterController : MonoBehaviour
/// </summary>
public Transform MeshOffsetTarget => meshOffsetTarget;
/// <summary>Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule
/// (driven by <see cref="PlayerPoseController"/>) eases back to this when no pose overrides it.</summary>
public float BaseCapsuleCenterY { get; private set; }
/// <summary>Authored CharacterController capsule radius, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
public float BaseCapsuleRadius { get; private set; }
/// <summary>Authored CharacterController capsule height, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
public float BaseCapsuleHeight { get; private set; }
// Below this horizontal speed (m/s, squared) the character counts as idle for the run animation.
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
@@ -194,7 +204,6 @@ public partial class PlayerCharacterController : MonoBehaviour
private bool _sprinting; // sprint key state this frame (exposed via IsSprinting)
private bool _isIdle; // snapshot: player essentially still this frame (exposed via IsIdle)
private bool _movingOnGround; // snapshot: grounded & moving above the run threshold (exposed via IsMovingOnGround)
private float _poseYOffset; // local-up model offset from the active pose (ceiling only); folded into SetMeshLean
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean
// Ground contact normal from the latest Move (for slope sliding).
@@ -234,6 +243,9 @@ public partial class PlayerCharacterController : MonoBehaviour
void Awake()
{
_controller = GetComponent<CharacterController>();
BaseCapsuleCenterY = _controller.center.y;
BaseCapsuleRadius = _controller.radius;
BaseCapsuleHeight = _controller.height;
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
@@ -720,7 +732,7 @@ public partial class PlayerCharacterController : MonoBehaviour
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos
+ transform.InverseTransformVector(worldOffset)
+ Vector3.up * (_poseYOffset + _groundOffset); // ceiling pose y-offset + ground mouse raise (both 0 unless in use)
+ Vector3.up * _groundOffset; // ground mouse raise (0 unless in use)
}
/// <summary>
@@ -773,21 +785,19 @@ public partial class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Sets a local-up offset applied to the visual model only, composed into the wall-lean's single
/// write of <see cref="meshOffsetTarget"/>. Used by <see cref="PlayerPoseController"/> to hang a
/// ceiling pose at the right height; 0 = none. Re-applies immediately so a change shows this frame.
/// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions,
/// driven by <see cref="PlayerPoseController"/> so a low pose can fit where the default standing capsule
/// is too tall. Only the collision capsule changes — the visual model and camera target are untouched.
/// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally).
/// </summary>
public void SetPoseLocalYOffset(float y)
public void SetCapsule(float centerY, float radius, float height)
{
if (_poseYOffset == y) return;
_poseYOffset = y;
if (!meshOffsetTarget) return;
// While climbing the lean is the live _wallOffset along _wallNormal; otherwise re-apply along
// whatever residual lean is easing home (both 0 when there's none, restoring the authored pose).
if (_isWallClimbing)
SetMeshLean(_wallNormal, _wallOffset);
else
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
Vector3 c = _controller.center;
if (c.y == centerY && _controller.radius == radius && _controller.height == height) return;
c.y = centerY;
_controller.center = c;
_controller.radius = radius;
_controller.height = height;
}
/// <summary>
+26 -9
View File
@@ -3,8 +3,9 @@ using UnityEngine;
/// <summary>
/// A selectable player pose: an idle <see cref="AnimationClip"/> held while the player stands still,
/// plus optional walk and run clips that the pose system plays itself (the Animator stays empty) while
/// moving on the ground, and a y-offset applied to the visual model only while stuck to the ceiling
/// (so the pose hangs at the right height). The first ScriptableObject in this project.
/// moving on the ground, its own CharacterController capsule dimensions (so a low pose like a ball can
/// crawl under furniture the default standing capsule is too tall for), and a camera-target y-offset that
/// frames the pose without moving the body. The first ScriptableObject in this project.
///
/// Named PlayerPose (not Pose) to avoid shadowing the built-in UnityEngine.Pose struct, mirroring
/// why the controller is PlayerCharacterController and not just "PlayerController".
@@ -37,14 +38,24 @@ public class PlayerPose : ScriptableObject
[Range(0.1f, 3f)]
[SerializeField] private float runClipSpeed = 1f;
[Tooltip("Local-up offset added to the visual model ONLY while stuck to the ceiling, so the pose " +
"hangs at the right height. Ignored on the ground and on walls. Meters.")]
[SerializeField] private float ceilingYOffset;
[Header("Collision")]
[Tooltip("CharacterController capsule center Y while this pose is active (meters, local space). Lower " +
"this together with Height so a short pose still rests its feet on the floor.")]
[SerializeField] private float controllerCenterY = 0.66f;
[Tooltip("CharacterController capsule radius while this pose is active (meters). Unity clamps it to " +
"half the height.")]
[Min(0.01f)]
[SerializeField] private float controllerRadius = 0.1f;
[Tooltip("CharacterController capsule height while this pose is active (meters). Lower it (with Center " +
"Y) so a ball/star pose can crawl under a table or couch the default capsule is too tall for.")]
[Min(0.01f)]
[SerializeField] private float controllerHeight = 1.3f;
[Tooltip("Local-up offset applied to the camera target (the ThirdPersonCamera's follow/look target) " +
"while this pose is active, in ANY state. Frames the pose by raising/lowering what the camera " +
"tracks, without moving the body. The ceiling offset is added on top of this while on the " +
"ceiling. Meters.")]
"tracks, without moving the body. Meters.")]
[SerializeField] private float cameraTargetYOffset;
/// <summary>Idle clip held while the player stands still in this pose (may be null).</summary>
@@ -65,8 +76,14 @@ public class PlayerPose : ScriptableObject
/// <summary>Playback speed multiplier for the run clip (1 = authored speed).</summary>
public float RunClipSpeed => runClipSpeed;
/// <summary>Local-up offset applied to the model only while on the ceiling.</summary>
public float CeilingYOffset => ceilingYOffset;
/// <summary>CharacterController capsule center Y applied while this pose is active.</summary>
public float ControllerCenterY => controllerCenterY;
/// <summary>CharacterController capsule radius applied while this pose is active.</summary>
public float ControllerRadius => controllerRadius;
/// <summary>CharacterController capsule height applied while this pose is active.</summary>
public float ControllerHeight => controllerHeight;
/// <summary>Local-up offset applied to the camera target while this pose is active (any state).</summary>
public float CameraTargetYOffset => cameraTargetYOffset;
+83 -30
View File
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Serialization;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
@@ -22,15 +23,16 @@ using UnityEngine.InputSystem;
/// <see cref="AnimationMixerPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that drives the
/// Animator directly. Switching clip — between poses, or between idle/walk/run within a pose —
/// crossfades the outgoing and incoming clips over <see cref="poseBlendDuration"/> instead of snapping.
/// The per-pose ceiling y-offset is eased toward its target and routed through the controller (the
/// single writer of the model-offset transform) so it composes with the wall-lean instead of fighting it.
/// A separate per-pose camera y-offset (plus that same ceiling offset) is likewise eased and routed to the
/// controller's camera target, framing each pose for the ThirdPersonCamera without moving the body.
/// Each pose also carries its own CharacterController capsule (center Y / radius / height): the active
/// pose's dimensions are eased toward and written through the controller so a low pose (ball, star) can
/// crawl under furniture the default standing capsule is too tall for. A per-pose camera y-offset is
/// likewise eased and routed to the controller's camera target, framing each pose for the
/// ThirdPersonCamera without moving the body.
/// </summary>
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
// Run after PlayerCharacterController (default order 0) so the ceiling / sprint state polled below is
// this frame's, and the ceiling offset resolves without a one-frame seam.
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
[DefaultExecutionOrder(100)]
public partial class PlayerPoseController : MonoBehaviour
{
@@ -56,9 +58,16 @@ public partial class PlayerPoseController : MonoBehaviour
[Min(0f)]
[SerializeField] private float poseBlendDuration = 0.2f;
[Tooltip("Approach speed (local units/sec) for easing the ceiling y-offset toward its target. 0 = instant.")]
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
"target while cycling poses. 0 = instant.")]
[Min(0f)]
[SerializeField] private float ceilingOffsetSpeed = 4f;
[FormerlySerializedAs("ceilingOffsetSpeed")]
[SerializeField] private float cameraOffsetSpeed = 4f;
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
[Min(0f)]
[SerializeField] private float capsuleBlendSpeed = 8f;
[Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " +
"authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " +
@@ -85,8 +94,16 @@ public partial class PlayerPoseController : MonoBehaviour
private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
private bool _blending;
private float _ceilingOffset; // eased local-up offset currently fed to the controller (mesh + camera)
private float _cameraPoseOffset; // eased per-pose camera-target offset; the ceiling offset is added on top when routed to the controller
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
private bool _capsuleStarted;
private float _capFromCenterY, _capFromRadius, _capFromHeight;
private float _capToCenterY, _capToRadius, _capToHeight;
private float _capCenterY, _capRadius, _capHeight;
private float _capBlend;
// Rest pose snapshot of the whole Animator subtree, taken in Awake before the Animator first
// evaluates. An AnimationClipPlayable only writes the bones its curves cover, so a clip that
@@ -94,7 +111,7 @@ public partial class PlayerPoseController : MonoBehaviour
// bone frozen on the previous animation. Restoring this before each pose start makes every pose
// define the full body. The controller's model-offset transform (meshOffsetTarget) is explicitly
// dropped from this set in CaptureRestPose — with the humanoid rig the Animator sits on that node,
// so it is the root of this subtree, and resetting it would wipe the wall-lean / ceiling offset.
// so it is the root of this subtree, and resetting it would wipe the wall-lean / ground raise.
private Transform[] _restBones;
private Vector3[] _restPos;
private Quaternion[] _restRot;
@@ -144,38 +161,37 @@ public partial class PlayerPoseController : MonoBehaviour
}
PlayPose(clip, speed);
// Ceiling y-offset: only while stuck to the ceiling. Ease toward the target instead of
// snapping, so entering and leaving the ceiling (and switching ceiling poses) glides. Routed
// through the controller so it shares the wall-lean's single write of the model transform.
float ceilingTarget = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
_ceilingOffset = ceilingOffsetSpeed > 0f
? Mathf.MoveTowards(_ceilingOffset, ceilingTarget, ceilingOffsetSpeed * Time.deltaTime)
: ceilingTarget;
_player.SetPoseLocalYOffset(_ceilingOffset);
// CharacterController capsule: each pose carries its own center Y / radius / height so a low pose
// (ball, star) can crawl where the default standing capsule is too tall. Eased toward the active
// pose's dimensions when the pose changes so the collider resizes smoothly instead of popping; a
// pose that leaves them unset (height/radius <= 0) keeps the default capsule.
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
ApplyCapsule(capCenterY, capRadius, capHeight);
// Camera target: a per-pose framing offset (applied in any state) plus the same ceiling offset,
// routed to the ThirdPersonCamera's follow target only (never the body). The pose part eases like
// the ceiling offset so cycling poses glides the camera; the ceiling part reuses the value above so
// camera and model stay in lockstep on the ceiling.
// 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 = ceilingOffsetSpeed > 0f
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, ceilingOffsetSpeed * Time.deltaTime)
_cameraPoseOffset = cameraOffsetSpeed > 0f
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime)
: cameraPoseTarget;
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset + _ceilingOffset);
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
}
void OnDisable()
{
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
// next idle frame. Also clear any ceiling offset we were holding.
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
ReleasePose();
_ceilingOffset = 0f;
_cameraPoseOffset = 0f;
if (_player)
{
_player.SetPoseLocalYOffset(0f);
_player.SetCameraTargetLocalYOffset(0f);
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
}
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
}
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
@@ -200,6 +216,43 @@ public partial class PlayerPoseController : MonoBehaviour
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
}
/// <summary>
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
/// </summary>
private void ApplyCapsule(float centerY, float radius, float height)
{
if (!_capsuleStarted)
{
_capsuleStarted = true;
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
_capFromRadius = _capToRadius = _capRadius = radius;
_capFromHeight = _capToHeight = _capHeight = height;
_capBlend = 1f;
}
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
{
// New target (pose changed): ease from wherever the capsule currently is.
_capFromCenterY = _capCenterY;
_capFromRadius = _capRadius;
_capFromHeight = _capHeight;
_capToCenterY = centerY;
_capToRadius = radius;
_capToHeight = height;
_capBlend = 0f;
}
_capBlend = capsuleBlendSpeed > 0f
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
: 1f;
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
}
private bool CycleKeyPressed()
{
#if ENABLE_INPUT_SYSTEM
@@ -354,7 +407,7 @@ public partial class PlayerPoseController : MonoBehaviour
}
// Drop the model-offset transform from the reset set so the per-frame ResetToRestPose never
// touches it: the wall-lean / ceiling offset (PlayerCharacterController.SetMeshLean) is its sole
// touches it: the wall-lean / ground raise (PlayerCharacterController.SetMeshLean) is its sole
// writer. With the humanoid rig the Animator shares this node, putting it in the captured subtree;
// resetting it each frame is exactly what was wiping the offsets. Nulling the slot makes
// ResetToRestPose's null-guard skip it. _player is resolved (and meshOffsetTarget too, since the