이번에는 과제를 하면서 애먹었던 부분에 대해서 짚고 넘어가고자 한다. 수학적인 부분에서 미숙하기도 하고 어떻게 작동되는지 잘몰라서 막혔던 부분이 많았다.
흔히 Actor의 Local방향을 들고오는 함수인
GetActorForwardVector()
,
GetActorRightVector()
,
GetActorUpVector()
는 해당 액터의 방향에 따른 단위벡터를 반환한다.
그리고 우리는 이 단위벡터를 통해 캐릭터 또는 폰의 이동을 구현을 할 수 있게 된다.
void ADronePawn::Move(const FInputActionValue& value)
{
FVector MoveInput = value.Get<FVector>();
FVector Forward = GetActorForwardVector() * MoveInput.X * XYaxisNormalSpeed;
FVector Right = GetActorRightVector() * MoveInput.Y * XYaxisNormalSpeed;
FVector Up = GetActorUpVector() * MoveInput.Z * ZaxisNormalSpeed;
FVector TargetVelocity = Forward + Right + Up;
}
void ADronePawn::Tick(float DeltaTime)
{
if (!TargetVelocity.IsNearlyZero())
{
CurrentVelocity = FMath::VInterpTo(CurrentVelocity, TargetVelocity, DeltaTime, AccelerationValue);
}
else
{
CurrentVelocity = FMath::VInterpTo(CurrentVelocity, FVector::ZeroVector, DeltaTime, DecelerationValue);
}
// 이동 처리
if (!CurrentVelocity.IsNearlyZero())
{
FVector NewLocation = GetActorLocation() + (CurrentVelocity * DeltaTime);
SetActorLocation(NewLocation, true);
}
}
이 코드스니펫을 보면 우선 MoveInput
은 언리얼 엔진의 Enhanced Input 시스템에서 사용되는 구조체로, 에우리가 에디터에서 만들었던 InputAction
과 InputMappingContext
의 설정값을 통해 입력 데이터를 처리한 후 최종적으로 나온 값을 저장하는 구조체이다.
우리는 W키에 X값이 반응하도록 이벤트를 설정하였으므로 W키를 누르면 MoveInput = { 1, 0, 0 }, S는 Modifier에서 Negate으로 수정하였으므로 S키를 누르면 MoveInput = { -1, 0, 0 } 이다. A, D키는 Modifier에서 Swizzle Input Axis Value를 YXZ로 수정하였으므로 입력을하면 Y값이 반응을 한다. 따라서 D키를 누르면 MoveInput = { 0, 1, 0 }, A키를 누르면 Negate이므로 MoveInput = { 0, -1, 0 } 이다. Z축도 이와 마찬가지이다.
따라서 MoveInput을 설명하자면 GetActorVector()
는 액터의 로컬 XYZ 축을 가져오고, MoveInput은 입력키에 따라 양인지 음인지 결정하는 값이다. 따라서 입력키에 따라 액터의 방향은 GetActorVector() * MoveInput
이라고 할 수 있다.
속도 벡터는 (이동방향 X 이동속도) 이기 때문에 벡터의 정면방향 속도 벡터는
FVector Forward = GetActorForwardVector() * MoveInput.X * XYaxisNormalSpeed;
(여기서 XYaxisNormalSpeed는 내가 선언한 변수, 원하는 속도값) 이다.
벡터의 합의 연산에 따라
우리가 목표하는 TargetVelocity
는 Forward + Right + Up
의 값이 되게 된다.
여기서 우리는 매틱(프레임)마다 액터의 위치값을 갱신해줘야 한다.
우선 이동이 자연스러운 느낌을 주기위해 보간법을 쓴다. 보간법이 없다면 TargetVelocity는 입력이 없을 땐 0, 입력이 생길땐 MoveInput값이 갑자기 1이 되면로 키에 해당하는 방향에 NormalSpeed만큼 값이 갑자기 생기므로 이동이 부자연스럽다. 이것을 로그형식(무조건 로그형식은 아님 y=kx
처럼 선형으로 보간하는 것도 있다)으로 속도를 프레임마다 점진적으로, 자연스럽게 TargetVelocity에 도달하게 하는것이 보간법이다.
따라서 CurrentVelocity는 점점 TargetVelocity에 수렴한다. (반대의 경우(입력이 없을 경우) 0에 수렴)
거리 = 시간 * 속도 이므로 매 틱(프레임) 마다 이동한 거리는 DeltaTime * CurrentVelocity이다.
월드 기준에서 보면 원래위치 + 이동한거리 이기 때문에
FVector NewLocation = GetActorLocation() + (CurrentVelocity * DeltaTime);
SetActorLocation(NewLocation, true)
가 되는것이다.
여기서 헷갈릴 수 있는건 보간계산에서 DeltaTime이 쓰이면서 계산된거 아니냐 생각할 수 있지만 보간계산에서 나온 반환값은 이동한 거리가 아닌 점진적 속도를 계산하는 것이다. 따라서 반환값도 속도. 보간계산에서 사용한 DeltaTime은 이동거리를 계산하는 것과는 완전 별개의 계산이다.
GetActorRotation()
액터의 월드 기준 회전값을 반환한다. 즉, 액터가 월드 공간에서 어떤 방향을 바라보고 있는지 나타내는 FRotator
를 얻는다.
(참고로, 로컬(상대) 회전값이 필요하다면 컴포넌트의 GetRelativeRotation()
등을 사용해야 한다.)
void ADronePawn::Move(const FInputActionValue& value)
{
MoveInput = value.Get<FVector>();
TargetPitch = MoveInput.X * MaxPitchAngle;
TargetRoll = MoveInput.Y * MaxRollAngle;
}
void ADronePawn::Tick(float DeltaTime)
{
FRotator CurrentRotation = GetActorRotation();
CurrentRotation.Pitch = FMath::FinterpTo
(CurrentRotation.Pitch, TargetPitch, DeltaTime, PitchInterSpeed);
CurrentRotation.Roll = FMath::FinterpTo
(CurrentRotation.Roll, TargetRoll, DeltaTime, RollInterSpeed);'
SetActorRotation(CurrentRotation);
}
VInterpTo (벡터의 보간 함수), FInterpTo(float형의 보간 함수), RInterpTo(Rotation형 보간함수)
여기서 독립적인 계산을 위해 SetActorRotation(CurrentRotation * DeltaTime)
가 들어가야되는지 헷갈릴 수 있지만 속도에서의 보간법은 SetActorLocation
을 구하기위해서 이동거리가 필요했던 반면 SetActorRotation
은 프레임만큼 회전한 회전값이 그대로 필요하므로 보간법에서 계산된 DeltaTime은 매 틱마다 회전한 값을 계산하기 위해 쓰인 것이다. 보간 속도처럼 별개의 계산이 아닌 것이다. 따라서
SetActorRotation(CurrentRotation);
MoveInput은 -1, 0 ,1 값을 가지기 때문에 Pitch와 Roll의 최솟값과 최댓값은 각각 MaxPitchAngle, MaxRollAngle을 통해 조절이 가능하다. (리플렉션 시스템으로 에디터에서 수정가능)
현재 로직에서는 입력에 따라 Pitch, Roll의 값을 조절하고 있다.
bUseControllerRotationRoll = false;
bUseControllerRotationPitch = false;
해야 액터의 회전이 적용이 된다.
지금 액터의 이동은 GetActorForwardVector()
등으로 인해 액터의 로컬방향으로 이동한다.
즉, 액터가 Roll 회전을 한다고 했을 때, 왼쪽으로 가는 키를 누르더라도 액터가 기울게 되면 원하는 방향으로 이동하지 않고 액터의 왼쪽으로 이동하게된다.
파란색이 기대 이동방향, 빨간색이 실제 이동방향.
따라서 이동입력에 따라 액터가 회전한다고 했을 때, 우리는 방향을 GetActorVector
가 아닌 다른 값을 들고올 필요가 있다.
FRotator YawRotation(0.0f, GetActorRotation().Yaw, 0.0f);
FVector Forward = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X) * MoveInput.X * XYaxisNormalSpeed;
FVector Right = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y) * MoveInput.Y * XYaxisNormalSpeed;
FVector Up = GetActorUpVector() * MoveInput.Z * ZaxisNormalSpeed;
TargetVelocity = Forward + Right + Up;
액터의 Yaw만 GetActorRotation().Yaw
를 통해 가져온다. 이렇게 함으로써 액터의 현재 Yaw값 (즉, 수평 회전 값)만 반영하여 이동 방향을 결정하게 된다.
즉, 오브젝트가 기울어져 있거나 상하로 회전한 상태와 무관한게 오직 수평방향만 고려한다. (Yaw는 XY 평면위 수평적으로 움직임)
FRotationMatrix는 주어진 FRotator를 회전 행렬로 변환해주는 도구이다. 이 행렬을 통해 회전에 따른 방향 벡터(Foward, Right, Up)을 쉽게 계산할 수 있다.
회전 행렬을 공간에서의 벡터 회전을 수행하는 선형 변환 행렬이다. P'(α + θ) = P(α) * (회전행렬)
3D 공간에서는 보통 3X3, 4X4로 표현된다.
언리얼에서는 좌표계가 X축: 전방, Y축: 우측, Z축: 상향으로 정의되어 있다.
Yaw만 포함한 3D 회전 행렬은 다음과 같이 구성된다.
GetUnitAxis()
는 행렬의 각 축에 해당하는 단위 벡터를 반환한다.쉽지 않은 작업이였다. 여전히 수학은 잘 모르겠다.
특히 Tick 함수는 자원을 많이 먹는다고 생각해서 Move함수에 모든 이동, 회전로직을 다 넣는 방향도 해봤었는데 보간함수를 넣으면 계속 이동이 이상하게 되거나 자연스럽게 구현되지 않았다. 아마 이벤트 트리거(입력)동안 Move함수는 틱마다 호출이 되다가 끝나는 순간 호출이 안되고 보간함수 안에있는 DeltaTime도 계산이 되지않으면서 문제가 생기는 것 같았다. 해결책으로 SetTimer을 통해 이벤트 트리거(입력)가 끝나더라도 보간함수는 계속 반복해서 유지되도록 로직도 짜보았지만 Tick에서 로직을 짤 때보다는 훨씬 부자연스러웠다. Triggered, Complete 사이에 미세한 응답반응 틈이라도 있던걸까?... 이 문제는 아직도 의문이다.
여튼 로직도 훨씬 길어지도 가독성도 떨어질 뿐더러 어짜피 SetTimer자체가 트리거가 완료되더라도 Tick함수처럼 지속적으로 자원을 소모하므로 그냥 Tick함수에 구현하는게 낫겠다 싶어서 로직을 바꿨다. 그러니깐 내 생각대로 회전, 보간, 이동이 너무 잘 되어서 '처음부터 이렇게 할 걸...' 라고 생각하게 되었다.