Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
https://youtu.be/-iaw-ifiUok?si=raM_aBeABXfqFQ98
UENUM(BluprintType)
enum ECustomMovementMode
{
CMOVE_None UMETA(Hidden),
CMOVE_Slide UMETA(DisplayName = "Slide"),
CMOVE_MAX UMETA(Hidden),
};
물리 기반 슬라이드를 구현하기 전, 약간의 준비를 해야한다. 가장 먼저, Custom Movement Mode Enum을 추가하자. 이것은 우리의 커스텀 무브먼트 모드를 추적하고 다양한 무브먼트 모드 사이를 전환하는 데 사용하게 될 것이다.
UPROPERTY(Transient) class ANyongCharacter* NyongCharacterOwner;
protected:
virtual void InitializeComponent() override;
void UNyongMovementComponent::InitializeComponent()
{
Super::InitializeComponent();
NyongCharacterOwner = Cast<ANyongCharacter>(GetOwner());
}
다음은 캐릭터를 가져와야 한다. InitializeComponent()
함수를 오버라이드 하여 가져와주자.
public:
UFUNCTION(BlueprintPure) bool IsCustomMovementMode(ECustomMovementMode InCustomMovementMode) const;
bool UNyongMovementComponent::IsCustomMovementMode(ECustomMovementMode InCustomMovementMode) const
{
return MovementMode == MOVE_Custom && CustomMovementMode == InCustomMovementMode;
}
우리가 커스텀 무브먼트에 있는지 확인하기 위한 함수를 선언해준다. 먼저 우리가 Custom Movement 상태인지 확인하고, InCustomMovementMode
가 Custom Movement 인지 한번 더 체크한다.
private:
void EnterSlide();
void ExitSlide();
void PhysSlide(float deltaTime, int32 Iterations);
bool GetSlideSurface(FHitResult& Hit) const;
슬라이드 구현을 위해 이렇게 네개의 함수를 구현해야 한다. 나머지는 슬라이드 구현을 위한 보조 함수이지만, PhysSlide
함수의 경우 모든 movement에 필요한 시스템의 실제 부분이다. 모든 movement mode는 해당 movement의 작동 방식을 정의하는 물리 함수가 필요하다. 모든 Phys* 변수는 deltaTime과 Iterations을 매개변수로 갖는다.
FCollisionQueryParams GetIgnoreCharacterParams();
FCollisionQueryParams ANyongCharacter::GetIgnoreCharacterParams()
{
FCollisionQueryParams Params;
TArray<AActor*> CharacterChildren;
GetAllChildActors(CharacterChildren);
Params.AddIgnoredActors(CharacterChildren);
Params.AddIgnoredActor(this);
return Params;
}
먼저 캐릭터에 캐릭터와 캐릭터의 모든 자식을 무시하는 충돌 쿼리 파라미터를 만들어주는 함수를 만들어준다. (Line Trace가 캐릭터에 닿지 않도록 하기 위해 필요함)
bool UNyongMovementComponent::GetSlideSurface(FHitResult& Hit) const
{
FVector Start = UpdatedComponent->GetComponentLocation();
FVector End = Start + CharacterOwner->GetCapsuleComponent()->GetScaledCapsuleHalfHeight() * 2.f * FVector::DownVector;
FName ProfileName = TEXT("BlockAll");
return GetWorld()->LineTraceSingleByProfile(Hit, Start, End, ProfileName, NyongCharacterOwner->GetIgnoreCharacterParams());
}
슬라이드 할 면을 찾아주는 GetSlideSurface
함수를 만들어준다. 아래로 Line Trace를 쏘고 결과를 반환한다.
UPROPERTY(EditDefaultsOnly) float Slide_MinSpeed = 350.f;
UPROPERTY(EditDefaultsOnly) float Slide_EnterImpulse = 500.f;
UPROPERTY(EditDefaultsOnly) float Slide_GravityForce = 5000.f;
UPROPERTY(EditDefaultsOnly) float Slide_Friction = 1.3f;
네 가지 파라미터를 추가해주자. 먼저 Slide_MinSpeed
의 경우, 슬라이드의 최소 속도를 나타낸다. 해당 속도보다 낮다면 미끄러질 수 없으며, 슬라이드를 유지할 수도 없다. Slide_EnterImpulse
의 경우, 슬라이드에 들어가자마자 얻을 수 있는 속도의 부스트이다. Slide_GravityForce
는 플레이어가 지면에 서있게 하기 위해 적용되는 힘의 양이며, 또한 슬로프를 따라 미끄러질 때 얼마나 빠르게 속도를 변경하는 지에 대해 영향을 미친다. Slide_Friction
은 슬로프에서 미끄러질 때 얼마나 느리게, 얼마나 빠르게 속도를 떨어지게 할 지 결정한다.
여기서부턴 PhysSlide
의 구현 코드이다.
if(deltaTime < MIN_TICK_TIME)
{
return;
}
deltaTime이 MINTICK_TIME보다 작지 않은지 확인한다. Default CMC에 적용되어 있는 코드인데, 언리얼은 최소 틱 시간보다 큰 델타 타임을 갖고있지 않으면 물리를 계산하는걸 원하지 않는다. 이 강의의 저자가 추측하건대, 보일러 플레이트인 것 같다고 한다. (_boiler plate : 반복적으로 사용하는 코드나 텍스트 조각을 의미한다. 특정 프레임워크를 사용할 때 필수적으로 포함되어야 하는 설정 코드)
RestorePreAdditiveRootMotionVelocity();
루트모션은 캐릭터 애니메이션 데이터로부터 캐릭터의 위치 및 속도를 제어하는 방식을 의미한다. 슬라이드는 매우 구체적인 움직임이므로 루트 모션이 발생하면 안된다. RestorePreAdditiveRootMotionVelocity();
함수는 루트 모션이 끝난 후, 애니메이션이 적용되기 전의 원래 속도와 방향으로 복원해주는 함수이다.
FHitResult SurfaceHit;
if (!GetSlideSurface(SurfaceHit) || Velocity.SizeSquared() < pow(Slide_MinSpeed, 2))
{
ExitSlide();
StartNewPhysics(deltaTime, Iterations);
return;
}
먼저, 슬라이드 표면이 없다면 슬라이드에서 빠져나와야 하므로 GetSlideSurface
를 통해 슬라이드 표면이 없는지 확인한다. 또한, 슬라이드 속도가 최소 슬라이드 속도 미만인 경우 슬라이드에서 빠져나와야 하므로, 둘 중 하나라도 해당되면 ExitSlide()
을 호출해 슬라이드를 종료하고 StartNewPhysics()
를 호출한다. StartNewPhysics()
의 경우, 동일한 프레임에서 새로운 물리 함수를 실행하게 된다.
// Surface Gravity
Velocity += Slide_GravityForce * FVector::DownVector * deltaTime; // v += a * dt
가장 먼저 할 일은, surface gravity를 적용해 속도를 시간에 따라 점진적으로 업데이트 해주는 것이다. 직접적으로 Velocity에 힘을 더해주는건 아니다.
// Strafe
if (FMath::Abs(FVector::DotProduct(Acceleration.GetSafeNormal(), UpdatedComponent->GetRightVector())) > 0.5f)
{
Acceleration = Acceleration.ProjectOnTo(UpdatedComponent->GetRightVector());
}
else
{
Acceleration = FVector::ZeroVector;
}
슬라이드할 때 스트레이프를 허용한다. Acceleration
는 기본적으로 입력 벡터를 의미한다. 캐릭터의 입력이 Strafe(좌우 이동)인지 확인하고, 그 값이 0.5f 이상의 충분히 큰 값일 때만 작동하도록 한다.
// Calc Velocity
if (!HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
{
CalcVelocity(deltaTime, Slide_Friction, false, GetMaxBrakingDeceleration());
}
ApplyRootMotionToVelocity(deltaTime);
보일러 플레이트 구문이다. 루트 모션이 없으면 실행시키는데, CalcVelocity
는 보일러 플레이트보단 도우미 함수에 가깝다. Default CMC 안에는 Phys* 함수에서 호출할 수 있는 일반적인 작업을 수행하는 헬퍼 함수가 많이 있다.
bFluid 변수가 true로 되어있는 것을 볼 수 있는데, 기술적으로 유체 안에 있지 않으면 마찰이 적용되지 않기 때문이다.
그런 다음, ApplyRootMotionToVelocity
함수를 사용해 Velocity에 루트 모션을 적용한다. 이 함수도 마찬가지로, 루트모션을 사용하지 않는다면 의미는 없는 구문이다. (완성도를 위한 코드)
// Perform Move
Iterations++;
bJustTeleported = false;
FVector OldLocation = UpdatedComponent->GetComponentLocation();
FQuat OldRotation = UpdatedComponent->GetComponentRotation().Quaternion();
FHitResult Hit(1.f);
FVector Adjusted = Velocity * deltaTime;
FVector VelPlaneDir = FVector::VectorPlaneProject(Velocity, SurfaceHit.Normal).GetSafeNormal();
FQuat NewRotation = FRotationMatrix::MakeFromXZ(VelPlaneDir, SurfaceHit.Normal).ToQuat();
SafeMoveUpdatedComponent(Adjusted, NewRotation, true, Hit);
bool SafeMoveUpdatedComponent(
const FVector& Delta, // 이동시키려는 변위 (델타 벡터)
const FQuat& NewRotation, // 이동시키려는 새로운 회전 (쿼터니언)
bool bSweep, // 충돌 검사를 수행할지 여부
FHitResult* OutHit = nullptr // 충돌 결과를 저장할 수 있는 히트 결과 포인터
);
SafeMoveUpdatedComponent
함수는, 실제로 캐릭터를 움직이기 위해 호출하는 함수이다. 캡슐의 위치를 직접 변경하는 것이 아니라 이 구문을 호출해야 한다.
타겟 위치까지의 이동 경로 상에 무언가와 부딪힌다면, 이동을 멈추거나 다른 처리를 한다. OutHit의 경우, 우리가 스윕했을 때 무언가를 Hit할 경우의 결과가 들어갈 것이다.
FVector Adjusted = Velocity * deltaTime;
Velocity는 속도, deltaTime은 프레임 간격이므로 속도 x 시간 = 이동거리
공식에 따라 이동할 거리를 구할 수 있다.
FVector VelPlaneDir = FVector::VectorPlaneProject(Velocity, SurfaceHit.Normal).GetSafeNormal();
VectorPlaneProject
함수는 Velocity 벡터를 SurfaceHit.Normal 벡터에 수직인 평면으로 투영하는 함수이다. 이걸 통해 현재 표면을 따라 이동할 방향 벡터를 얻을 수 있다.
FQuat NewRotation = FRotationMatrix::MakeFromXZ(VelPlaneDir, SurfaceHit.Normal).ToQuat();
VelPlaneDir
을 X축으로 하고, SurfaceHit.Normal
을 Z축으로 하는 회전 행렬을 생성한다. 이 회전 행렬을 ToQuat
함수를 사용하여 쿼터니언 형태로 변환한다.
if (Hit.Time < 1.f)
{
HandleImpact(Hit, deltaTime, Adjusted);
SlideAlongSurface(Adjusted, (1.f - Hit.Time), Hit.Normal, Hit, true);
}
위 로직에서 문제가 생겼는지 확인하고 처리하는 로직이다. HandleImpact
는 마찬가지로 보일러 플레이트로, 안전한 이동 업데이트의 영향을 처리하기 위해 호출한다.
SlideAlongSurface
는 정말 유용한 헬퍼 함수이다. 안전한 이동 업데이트를 할 때 무언가에 부딪힐 때까지 움직이는데, 표면에 따라 미끄러지고 있을 때 무엇인가에 부딪히자마자 트랙에서 멈추게 된다. SlideAlongSurface
함수는, 타격을 받고 나서도 벽같은 부딪힌 곳에 평행하게 계속해서 이동하게끔 한다. 벽과 바닥처럼 부딪힌 모든 곳에 해당된다. 표면을 따라 미끄러지는 방법일뿐이며, 어떤 물리 함수에서도 호출할 수 있다. 완전히 움직임에 안전하고 바로 작동한다.
FHitResult NewSurfaceHit;
if (!GetSlideSurface(NewSurfaceHit) || Velocity.SizeSquared() < pow(Slide_MinSpeed, 2))
{
ExitSlide();
}
슬라이드 조건이 충족되는지를 다시 확인한다.
// Update Outgoing Velocity & Acceleration
if (!bJustTeleported && !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
{
Velocity = (UpdatedComponent->GetComponentLocation() - OldLocation / deltaTime);
}
캐릭터의 속도와 가속도를 업데이트 한다. 현재 위치와 이전 위치의 차이를 deltaTime
으로 나누어, 캐릭터가 지난 프레임 동안 얼마나 이동했는지를 기반으로 속도를 계산한다.
void UNyongMovementComponent::EnterSlide()
{
bWantsToCrouch = true;
Velocity += Velocity.GetSafeNormal2D() * Slide_EnterImpulse;
SetMovementMode(MOVE_Custom, CMOVE_Slide);
}
슬라이드를 시작할 때, Crouch 상태를 참으로 한다. 슬라이드를 할 때 여전히 웅크리고 있을 것이기 때문이다. 다음으로 Velocity.GetSafeNormal2D()
를 통해 속도의 수평 요소를 얻고, Slide_EnterImpulse 값을 곱해 속도를 설정해준다. 마지막으로, Movement Mode를 우리가 생성한 CMOVE_Slide로 설정해주면 된다.
void UNyongMovementComponent::ExitSlide()
{
bWantsToCrouch = false;
FQuat NewRotation = FRotationMatrix::MakeFromXZ(UpdatedComponent->GetForwardVector().GetSafeNormal2D(), FVector::UpVector).ToQuat();
FHitResult Hit;
SafeMoveUpdatedComponent(FVector::ZeroVector, NewRotation, true, Hit);
SetMovementMode(MOVE_Walking);
}
ExitSlide 도 비슷하다. bWantsToCrouch
를 false로 설정하고, 중간의 세 줄은 회전을 교정한다. 슬라이드가 끝나면, 캡슐을 다시 수직으로 만들어야 하기 때문이다. 마지막으로, 무브먼트 모드를 Walking으로 변경해준다. SafeMoveUpdatedComponent
함수는 safe move movement context에서만 호출해야 한다.
이제 지금까지 구현한 슬라이드를 시스템에 연결하는 일이 남았다.
class FSavedMove_Nyong : public FSavedMove_Character
{
typedef FSavedMove_Character Super;
uint8 Saved_bWantsToSprints : 1;
uint8 Saved_bPrevWantsToCrouch : 1;
virtual bool CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter, float MaxDelta) const override;
virtual void Clear() override;
virtual uint8 GetCompressedFlags() const override;
virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character& ClientData) override;
virtual void PrepMoveFor(ACharacter* C) override;
};
먼저 SavedMove 클래스 안에 Saved_bPrevWantsToCrouch
를 선언해준다. 이동 로직의 상태에 중요하기 때문이다. 이전 값을 저장하는 이유는, Crouch를 원하는 경우 플래그가 이전에 false고 현재 true인지 또는 그 반대인지를 감지하기 위함이다.
왜 CrouchPressed
함수가 실행됐다는 것을 알기 위해 bWantsToCrouch
가 있음에도 새로운 변수를 만드냐면, 클라이언트가 c키를 누를 때 서버에서 호출되지 않기 때문이다. 다른 시뮬레이트 프록시에서도 호출되지 않는다. 모든 클라이언트와 서버에서 이 동작을 재현할 수 있도록 이전 값을 저장해야 하는 것이다.
safe saved 종류의 아키텍처에 익숙하다면, 저장된 모든 변수에 대해 안전한 변수가 필요하다는 것을 알게 될 것이다.
bool Safe_bPrevWantsToCrouch;
우리의 실제 CMC 로직에서는 Safe_bPrevWantsToCrouch
변수를 사용하고, 나중에 해당 이동을 생성할 수 있었던 상태를 다시 생성하기 위해 Saved_bPrevWantsToCrouch
에 이 상태를 저장할 것이다.
void UNyongMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, FNetworkPredictionData_Client_Character& ClientData)
{
FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);
UNyongMovementComponent* CharacterMovement = Cast<UNyongMovementComponent>(C->GetCharacterMovement());
Saved_bWantsToSprints = CharacterMovement->Safe_bWantsToSprint;
Saved_bPrevWantsToCrouch = CharacterMovement->Safe_bPrevWantsToCrouch;
}
void UNyongMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
Super::PrepMoveFor(C);
UNyongMovementComponent* CharacterMovement = Cast<UNyongMovementComponent>(C->GetCharacterMovement());
CharacterMovement->Safe_bWantsToSprint = Saved_bWantsToSprints;
CharacterMovement->Safe_bPrevWantsToCrouch = Saved_bPrevWantsToCrouch;
}
변수를 동기화되도록 하려면 우리는 이것을 SetMoverFor, PrepMoveFor
함수에 추가해야 한다. SetMoveFor
함수에서 값을 저장하고, PrepMoveFor
함수에서 이를 꺼내온다.
void UNyongMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity)
{
Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);
if (MovementMode == MOVE_Walking)
{
if (Safe_bWantsToSprint)
{
MaxWalkSpeed = Sprint_MaxWalkSpeed;
}
else
{
MaxWalkSpeed = Walk_MaxWalkSpeed;
}
}
Safe_bPrevWantsToCrouch = bWantsToCrouch;
}
OnMovementUpdated
함수는 모든 움직임이 업데이트된 후이기 때문에 변수를 안전하게 설정할 수 있고, 다음 프레임에 적용될 수 있다. 그러므로, 여기서 Safe_bPrevWantsToCrouch 변수에 bWantsToCrouch를 설정해주면 된다.
virtual void UpdateCharacterStateBeforeMovement(float DeltaSeconds) override;
이제 이 함수를 오버라이드 해주자. 이 함수 내에서 edge detection을 수행하고, 슬라이드 이동 모드로 들어갈 수 있게 된다. 이 함수에서 수행하는 이유는, 여기가 Crouch 매커니즘이 처리되는 곳이므로, Crouch 매커니즘이 업데이트 되기 전에 업데이트 하려면 이 함수에서 업데이트 해야 한다.
void UNyongMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
if (MovementMode == MOVE_Walking && !bWantsToCrouch && Safe_bPrevWantsToCrouch)
{
FHitResult PotentialSlideSurface;
if (Velocity.SizeSquared() > pow(Slide_MinSpeed, 2) && GetSlideSurface(PotentialSlideSurface))
{
EnterSlide();
}
}
if (IsCustomMovementMode(CMOVE_Slide) && !bWantsToCrouch)
{
ExitSlide();
}
Super::UpdateCharacterStateBeforeMovement(DeltaSeconds);
}
현재 걷는 중이고, 웅크리기를 원하고 이전에 웅크리고 있던 상태라면(C를 두번 클릭했을 경우) 슬라이드로 들어간다. EnterSlide
안에서 bWantsToCrouch
를 true로 바꿔주므로, 여전히 bWantsToCrouch
의 상태가 된다. 매커니즘이 캡슐 높이를 다시 원래 높이로 올리기 전에 True로 설정한 것이 된다.
커스텀 무브먼트 모드가 슬라이드이고 bWantsToCrouch가 false라면 슬라이드를 종료한다.
void UCharacterMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
// Proxies get replicated crouch state.
if (CharacterOwner->GetLocalRole() != ROLE_SimulatedProxy)
{
// Check for a change in crouch state. Players toggle crouch by changing bWantsToCrouch.
const bool bIsCrouching = IsCrouching();
if (bIsCrouching && (!bWantsToCrouch || !CanCrouchInCurrentState()))
{
UnCrouch(false);
}
else if (!bIsCrouching && bWantsToCrouch && CanCrouchInCurrentState())
{
Crouch(false);
}
}
}
이 모든 것은, Super 이전에 동작해야 한다. Super 함수의 내부 구현을 보면, 여기서 Crouch 매커니즘이 구현된 곳인 것을 알 수 있다.
virtual void PhysCustom(float deltaTime, int32 Iterations) override;
void UNyongMovementComponent::PhysCustom(float deltaTime, int32 Iterations)
{
Super::PhysCustom(deltaTime, deltaTime);
switch (CustomMovementMode)
{
case CMOVE_Slide:
PhysSlide(deltaTime, Iterations);
break;
default:
UE_LOG(LogTemp, Fatal, TEXT("Invalid Movement Mode"));
}
}
이렇게 커스텀 무브먼트 모드에 대한 모든 물리를 다루는 PhysCustom
함수를 오버라이드 해야한다.
virtual bool IsMovingOnGround() const override;
virtual bool CanCrouchInCurrentState() const override;
bool UNyongMovementComponent::IsMovingOnGround() const
{
return Super::IsMovingOnGround() || IsCustomMovementMode(CMOVE_Slide);
}
bool UNyongMovementComponent::CanCrouchInCurrentState() const
{
return Super::CanCrouchInCurrentState() && IsMovingOnGround();
}
마지막으로, 두 함수를 오버라이드하여 만들면 된다. 슬라이딩은 지상에서 유효하지만,IsMovingOnGround()
의 Super 함수는 그걸 알 수 없기 때문에 슬라이딩 할때도 IsMovingOnGround()
함수가 true를 반환하도록 해준다.
웅크린 상태의 경우, 공중에서 웅크리기를 원치 않기 때문에 IsMovingOnGround()
도 함께 true일 때만 true를 반환하도록 해준다.
코드를 똑같이 따라 친 것 같은데 동작이 잘 안된다.
우선 바닥면 프로파일을 모두 "BlockAll" 로 바꿔줬다.
로그를 찍어보니 Crouch 되는 순간 속도가 줄어들어서, 슬라이드로 진입이 어렵다는 것을 알게 됐다. (Crouch의 Max Speed = 300.f / 슬라이드 진입의 Min Speed = 350.f) Crouch의 Max Speed를 400.f로 올려주었다.
슬라이드에 진입하고 나니, 이렇게 바로 벽에 박혀버리는 현상이 발생했다.
if (!bJustTeleported && !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
{
Velocity = (UpdatedComponent->GetComponentLocation() - OldLocation) / deltaTime;
}
요 코드에서 괄호의 위치를 잘못 작성했었다..ㅎㅎ
이제 슬라이드가 동작하고 동기화도 잘 되는 것을 볼 수 있다!