동적 콜라이더 캡슐
애니메이션이 진행되며 캐릭터의 범위가 변경되는데 collider는 변경되지 않아 예상하지 못하는 충돌이 일어나기도 한다.
이를 개선하기 위해 애니메이션에 맞춰 collider가 변경되는 시스템을 만들어 보자.
애니메이션 실행 중 바운드 계산
Collider가 적용되려면 애니메이션이 적용될 때 캐릭터의 바운드를 계산할 수 있어야 한다.
먼저 이를 시도해 보자.
가장 먼저 생각나는 방법은 skeleton을 갖고 있는 오브젝트의 바운드를 받아오면 되지 않을까 싶다.
skeleton 구조는 오브젝트의 계층 구조로 이루어져 있기 때문에 최상위 오브젝트에서 재귀적으로 자식들을 모두 트래킹 하여 저장한 뒤 포지션상 가장 낮은 값과 높은 값을 통해 Bound를 만들어주면 될 것 같다.
우선, 모든 bone을 가져와 보자.
[SerializeField] private GameObject rootBone;
private void CollectAllBones()
{
if (rootBone == null) return;
_allBones = new List<Transform>();
for (int i = 0; i < rootBone.transform.childCount; i++)
{
CollectBonesRecursively(rootBone.transform.GetChild(i));
}
}
private void CollectBonesRecursively(Transform bone)
{
_allBones.Add(bone);
for (int i = 0; i < bone.childCount; i++)
{
CollectBonesRecursively(bone.GetChild(i));
}
}
rootBone을 추가하지 않은 이유는 rootBone의 경우 지면에 닿아있는 가상의 포지션이기 때문에 이를 추가하면 y의 하한이 바뀌지 않는다.(항상 지면의 y에 고정 => 중력 작용)
이렇게 추출한 bone의 좌표를 통해 minBound와 maxBound를 계산해 보자.
private Bounds CalculateBoundsFromAllBones()
{
// 모든 본의 위치를 한번에 추출
var positions = _allBones.Select(bone => bone.position);
// Min/Max 계산
Vector3 min = new Vector3(
positions.Min(p => p.x),
positions.Min(p => p.y),
positions.Min(p => p.z)
);
Vector3 max = new Vector3(
positions.Max(p => p.x),
positions.Max(p => p.y),
positions.Max(p => p.z)
);
Vector3 center = (min + max) * 0.5f;
Vector3 size = max - min;
return new Bounds(center, size);
}
모든 bone의 position을 받아와 min값과 max값을 Linq를 통해 받아왔다.
이 중간을 Bound의 중앙으로 설정하고 size를 계산하여 Bound를 갱신한다.
이를 Update에서 갱신해주며 ChracterController의 hegith, radius값을 변경해 주면 bone에 맞는 Collider를 만들 수 있다.

지면 체크 이중 발생

점프애님에 따라 콜라이더가 올바르게 수정되지만 착지 애니메이션이 두 번 실행된다.
애니메이터를 살펴보니 ground가 껐다 켜졌다를 반복하는 문제인 것 같다.
이를 해결해보자.
로그를 찍어 확인해 본 결과 점프 상태에서 지면에 닿았다는 판정이 왔다 갔다 했다.
if (_currentParkourState == ParkourState.InAir)
{
_grounded = false;
// 왼발
{
var checkPosition = blackBoard.footTracker.leftFootPosition;
checkPosition.y -= blackBoard.playerMovementData.groundedOffset;
_grounded |= Physics.CheckSphere(checkPosition, blackBoard.playerMovementData.groundedRadius, blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore);
}
// 오른발
{
var checkPosition = blackBoard.footTracker.rightFootPosition;
checkPosition.y -= blackBoard.playerMovementData.groundedOffset;
_grounded |= Physics.CheckSphere(checkPosition, blackBoard.playerMovementData.groundedRadius, blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore);
}
}
점프 상태에서 왼발과 오른발을 기준으로 지면 체크를 했더니 발을 접었다 피었다 하는 애니메이션에 의해 문제가 발생하는 듯했다.
결국 문제의 원인을 찾아냈다...
점프 로직은 아무 문제가 없었다.
범인은 수직 속도를 일정하게 유지하는 로직에 있었다.
private void ApplyGravity()
{
if (!applyGravity) return;
// 중력 적용
verticalVelocity -= blackBoard.playerMovementData.gravity * Time.deltaTime;
if (_grounded && verticalVelocity <= 0)
{
verticalVelocity = -2f;
}
blackBoard.characterController.Move(new Vector3(0, verticalVelocity, 0) * Time.deltaTime);
}
중력이 꾸준히 적용되어서 낙하할 때 비정상적인 속도로 낙하하는 문제가 있어서 -2로 일정하게 유지하는 로직을 추가했더니 착지하는 순간 -2로 수직 속도가 고정되어서 애니메이션 보간이 이상해지는 것이 문제였다.
-2를 수직 속도의 최댓값으로 설정하면 자연스럽게 보간 되어 적용될 것이다.
verticalVelocity = -Mathf.Sqrt(2.0f * blackBoard.playerMovementData.gravity * blackBoard.playerMovementData.jumpForce);

Collider 크기 제한
Collider크기가 동적으로 변경되기는 하지만 x와 z 중 큰 값을 선택하기 때문에 캐릭터의 좌우가 커지는 경향이 있다.
characterController.height = _bounds.size.y + heightPadding;
characterController.radius = Mathf.Max(_bounds.size.x, _bounds.size.z) * 0.5f + radiusPadding;

x와 z중 작은 값을 골라 사용하면 될 것 같다.
characterController.height = _bounds.size.y + heightPadding;
characterController.radius = Mathf.Min(_bounds.size.x, _bounds.size.z) * 0.5f + radiusPadding;

점프 개선
현재 점프가 문제없이 실행되지만 이제 Collider가 동적으로 변경되므로 굳이 발에서 지면을 체크할 필요가 없다.
따라서 통합해 주자.
private void GroundCheck()
{
var checkPosition = transform.position + blackBoard.characterController.center;
checkPosition.y -= (blackBoard.characterController.height/2 + blackBoard.playerMovementData.groundedOffset);
var currentGrounded = Physics.CheckSphere(checkPosition, blackBoard.playerMovementData.groundedRadius, blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore);
// 발이 떨어지는 순간
if(_grounded && !currentGrounded)
{
if (Physics.Raycast(transform.position, Vector3.down, out var hit, float.MaxValue,
blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore))
{
_grounded = hit.distance < blackBoard.playerMovementData.fallThreshold;
}
}
else
{
_grounded = currentGrounded;
}
if (!blackBoard.animator.IsUnityNull())
{
blackBoard.animator.SetBool(AnimationHash.GroundedAnimID, _grounded);
}
}
그리고 지금 InAir상태에서 수직 속도를 높인 뒤 점점 줄이는 흐름인데 Controller에서 수직속도를 높인 뒤 발이 떨어지면 InAir 상태로 변경하는 게 일관된 흐름인 것 같다.
private void Update()
{
...
if (blackBoard.parkourInputController.jump && jumpTimeout <= 0)
{
blackBoard.parkourInputController.jump = false;
AnimatorStateInfo currentState = blackBoard.animator.GetCurrentAnimatorStateInfo(0);
if (!currentState.IsName("Landing") && !currentState.IsName("Jump"))
{
verticalVelocity = Mathf.Sqrt(2.0f * blackBoard.playerMovementData.gravity * blackBoard.playerMovementData.jumpForce);
}
}
...
}
private void GroundCheck()
{
var checkPosition = transform.position + blackBoard.characterController.center;
checkPosition.y -= (blackBoard.characterController.height/2 + blackBoard.playerMovementData.groundedOffset);
var currentGrounded = Physics.CheckSphere(checkPosition, blackBoard.playerMovementData.groundedRadius, blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore);
// 발이 떨어지는 순간
if(_grounded && !currentGrounded)
{
if (Physics.Raycast(transform.position, Vector3.down, out var hit, float.MaxValue,
blackBoard.playerMovementData.groundLayer, QueryTriggerInteraction.Ignore))
{
_grounded = hit.distance < blackBoard.playerMovementData.fallThreshold;
if (!_grounded) ChangeParkourState(ParkourState.InAir);
}
}
else
{
_grounded = currentGrounded;
}
if (!blackBoard.animator.IsUnityNull())
{
blackBoard.animator.SetBool(AnimationHash.GroundedAnimID, _grounded);
}
}
이러면 자유 낙하할 때도 InAir상태로 진입할 수 있어 동일한 로직으로 처리할 수 있다.
그리고 점프 모션이 살짝 늦게 실행되는 느낌이 있다.

디버깅해 보니 수직 속도가 0.2부터 시작되어 preJump가 실행이 안되어서 그렇다.
즉, 점프 실행 타이밍을 변경해줘야 한다.
현재는 ground가 false가 되면 점프가 실행되기 때문에 캐릭터가 뛰어오르고 애니메이션이 실행된다.
if (blackBoard.parkourInputController.jump && jumpTimeout <= 0)
{
blackBoard.parkourInputController.jump = false;
AnimatorStateInfo currentState = blackBoard.animator.GetCurrentAnimatorStateInfo(0);
if (!currentState.IsName("Landing") && !currentState.IsName("Jump"))
{
blackBoard.animator.SetTrigger(AnimationHash.JumpStart);
verticalVelocity = _maxJumpSpeed;
}
}
이렇게 타이밍을 같이 잡아주면 좀 더 자연스러운 점프가 가능하다.
