개요
이전 글
TPS게임의 정조준 시스템 구현하기 with ALS 1편 - 카메라 전환 — 메모장 (tistory.com)
본 글에서는 이전에 구현했던 정조준 시스템((Aimimg Down Sight, ADS라고도 한다.)에 흔들림(Sway) 효과를 넣어 좀 더 자연스럽게 보이게 하는 방법에 대해서 설명한다. 구현 방법은 디테일하게 들어가겠지만 알고리즘 자체는 최대한 일반화해서 작성하려고 했다.
정조준 구현 이후 발생한 문제
정조준은 잘 되는데 문제는 너무 생동감이 없다는 것이었다.
왜 그런가 하고 레퍼런스 및 다른 게임들을 해보니 금방 원인을 찾을 수 있었다.
그것은 바로
캐릭터가 가만히 있거나 이동할 때,
마우스로 조준점을 움직일 때,
총기가 그대로 멈춰있는 것이 원인이었다.
직관적으로 구현 방법이 떠오르지 않아서, 이 역시도 자료 조사를 시작했다.
이런걸 해외에서는 Weapon Sway 라고 부르는 것 같았다. 생소하고 게임하면서도 들어본 적이 없는 말이다.
위의 영상을 찾다가 같이 찾은건지 아니면 어쩌다 찾게 되었는지 잘 기억은 나지 않는다.
아무튼 다음과 같은 영상을 찾아냈다.
- Weapon Sway in UE4- THE CORRECT WAY | Black Ops Zombies in UE4 #6
- Procedural Weapon Sway - FPS Animations Full Tutorial - # 10
이 영상들로 구현하면 완벽할 것 같아 보였지만, 영상은 블루프린트였기 때문에 C++로 다시 작성했다.
구현 방법은 간단하게 다음과 같다.
- 정조준했을때는 Input의 Pitch와 Yaw값을 구하고 이 값을 바탕으로 총기를 회전시킨다.
- 캐릭터가 이동할 때는 언리얼에서 만들어 놓은 Curve값을 바탕으로 총기를 회전시킨다.
애니메이션 시퀀스를 사용해서 연출할 수도 있겠지만 여기서는 C++로 메쉬 자체를 회전시키는 방법을 사용했다.
여기서 구현한 흔들림 효과에는
- 플레이어가 조준점을 이동할 때(마우스를 이리저리 움직일 때), 이를 조준 흔들림이라고 하겠다.
- 캐릭터가 이동할 때(WASD를 눌렀을 때), 이를 이동 흔들림이라고 하겠다.
이렇게 두 가지가 있다.
아래는 구현 결과 예시이다.
알고리즘
앞서 말했듯이 애니메이션 시퀀스를 사용해서 연출할 수도 있겠지만, 여기서는 C++로 메쉬 자체를 회전시키는 방법을 사용했다. 이렇게 하면 개인적인 생각이지만,
- 장점으로는 다른 애니메이션을 같이 재생해야 하는 경우(예를 들면 이동하면서 발사)에 대한 신경을 덜 써도 되고, 흔들림 효과 자체도 변수값으로 조정할 수 있기 때문에 여러모로 편리할 것이다.
- 단점으로는 매 프레임 각도 계산을 해야하기 때문에 오버헤드가 있다는 점...? 사실 애니메이션도 공짜로 굴러가는건 아니고 어쨌든 부하가 있을 것이기 때문에 상쇄될 것이다. 또 메쉬 자체를 회전시킨다는 문제가 있는데, 이 회전이 게임의 실제 로직에 사용되는 것이 아니라 그저 플레이어에게 보여주기용이기 때문에 미래에 다른 기능 구현에도 큰 문제가 없을 것이라 판단했다.
그리고 무엇보다 이런 애니메이션 시퀀스 에셋이 없었기 때문에, 사실 선택지는 C++밖에 없었다.
이제 세부적인 알고리즘에 대해서 알아보도록 하자.
공통적으로 타겟 회전각(FRotator
)만큼 총기를 부드럽게 회전시켜주는 것(RInterpTo
로 값을 보간)이 핵심이다. 타겟 회전각은 조준 흔들림에서는 마우스의 Input값으로, 이동 흔들림에서는 UCurveVector
를 만들어 이 커브 상에서 값을 가져와 계산할 것이다.
아래 코드에서 볼 수 있듯이 메쉬의 초기 회전각 + 계산한 각도(이하 최종 각도) = 메쉬의 타겟 회전각이라고 볼 수 있다.
조준 흔들림
마우스의 Input Pitch
와 Yaw
값을 저장한다. 여기에 회전할 만큼의 실수 SwayDegree
를 곱해준다.
예를 들어 Pitch
값이 [-5, 5]이고 실수가 2.0이라면 회전 각도는 [-10, 10]이 될 것이다.
이 값을 기반으로 최종 회전각과 타겟 회전각의 Roll
, Pitch
, Yaw
값을 정해준다.
//
// Input 함수에서 마우스의 Yaw값과 Pitch값을 저장한다...
// 초기화 시 총기의 초기 회전각을 저장한다...
//
float Turn = Yaw * SwayDegree;
float LookUp = Pitch * SwayDegree;
// 최종 각도
FRotator FinalRotation;
FinalRotation.Roll = LookUp;
FinalRotation.Pitch = Turn;
FinalRotation.Yaw = Turn;
// 타겟 회전각
FRotator TargetRotation;
// 타겟 회전각 = 초기 회전각 + 최종 각도
TargetRotation.Roll = InitialRotation.Roll + FinalRotation.Roll;
TargetRotation.Pitch = InitialRotation.Pitch + FinalRotation.Pitch;
TargetRotation.Yaw = InitialRotation.Yaw + FinalRotation.Yaw;
마우스 Input은 2D값이고 이를 기반으로 각도를 회전시켜주기 때문에 Roll
과 Yaw
값은 납득이 된다. Roll
은 총구의 좌우회전, Yaw
는 총구의 상하회전이라고 보면 쉽다.
하지만 Pitch
는 왜 이렇게 설정해줘야할까? Pitch
값은 총기 자체의 시계방향, 반시계 방향 회전이다.
예를 들어 마우스(총기)를 오른쪽 위로 향할때를 생각해보자.
이때 총기를 시계방향으로 돌려 움직이는 방향으로 기울이게 한다. 즉 총기 자체가 오른쪽을 향하는 것처럼 보이게 된다. 이때의 Pitch
값은 양수다. 마우스를 왼쪽 아래로 향할 땐 Pitch
값을 음수로 해서, 총기를 반시계 방향으로 돌려 총기를 왼쪽을 향하는 것처럼 보이게 만든다. 따라서 최종 회전각의 Pitch
값 또한 카메라의 Yaw
(좌우회전)에 기반하는 것을 알 수 있다. 그리고 개인적으로 Pitch
값을 바꿔가면서 여러 번 테스트를 거쳤을 때, 이 값이 제일 자연스러워보이기도 했다. 나중에라도 맘에 들지 않으면 최종 각도 값을 변경하면 된다.
잘 이해가 되지 않는다면 에디터에서 직접 메쉬를 회전시켜보며 마우스의 Pitch
, Yaw
값에 따라 어떻게 값을 변경해야 하는지 탐구해보자.
이동 흔들림
캐릭터가 이동중인지 판단해서, 이동 중일 경우 이동한 시간에 따라 UCurveVector
에서 값을 가져와 그만큼 회전시킨다.
사전에 설정한 X, Y, Z 커브 그래프대로 움직이는 것으로, 어찌 보면 굉장히 단순한 방법이라고 볼 수 있다.
사실 한시간 정도 테스트... 및 노가다를 하면서 썩 괜찮아 보이는 커브를 만들었다.
가로가 시간축, 세로가 색깔에 따라 각각 X, Y, Z축이다. 예를 들어 이동 시간이 0.5초인경우 0.5초에 해당하는 값을 커브로부터 가져온다. 이에 추가로 이 값을 "흔들림 정도" 범위의 값으로 정규화해주었다. 왜냐하면 조준 흔들림에서도 직접 SwayDegree
를 곱해 "흔들릴 정도"를 설정해줄 수 있었으므로, 이동 흔들림도 마찬가지로 비슷한 것이 있었으면 좋겠다고 생각했기 때문이다.
정리하자면 현재 이동시간 값에 따라 그래프에서 X, Y, Z값을 뽑아낸다. 뽑아낸 값은 정규화(NormalizeToRange
함수)를 거쳐 설정한 "흔들릴 정도"의 최소, 최대값 범위 내의 값으로 변환된다. 그리고 변환된 값으로 총기를 회전시킨다.
구현
알고리즘에서 언급한 내용을 C++코드로 옮긴다.
WeaponSway 구현
WeaponSway
함수는 조준 흔들림 기능을 하는 함수이다. 이전 글과 마찬가지로 값을 보간해서 회전시켜주기 때문에 DeltaTime
이 필요하고, 따라서 Tick에서 호출한다.
// AMyCharacter.h ...
void WeaponSway(float DeltaTime);
// AMyCharacter.cpp ...
void AMyCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
SmoothADSCamera(DeltaSeconds); // 이전글 참고!
WeaponSway(DeltaSeconds); // 조준 흔들림
MovingSway(DeltaSeconds); // 이동 흔들림, 추후에 설명
}
그리고 앞서 알고리즘에서 다뤘듯이 마우스 Input의 Pitch
, Yaw
값을 저장해야하므로 이를 위한 변수와 각도 계산을 위한 FRotator
를 선언해준다.
// AMyCharacter.h ...
//
// Sway
//
// 흔들림에 사용하는 각도
FRotator SwayFinalRotation;
FRotator SwayInitialRotation;
// 유저가 마우스를 이용해 화면을 회전시킬 때 총을 움직여주는 Sway 관련
float SwayPitch = 0.f;
float SwayYaw = 0.f;
float MaxSwayDegree = 2.f; // 흔들림 정도
float SwaySpeed = 2.f; // 흔들림 속도
// 사용하는 총기 메쉬에 따라 값이 다를 수 있으므로,
// 총기 클래스를 따로 만들어 그것들의 멤버 변수로 넣는게 좋을 수도 있다.
또 마우스에 바인딩된 액션 함수에서, 이들 값에 실제 마우스 Input값을 저장해준다. 이 글에서는 EnhancedInput
을 사용했지만, 그렇지 않다면 InputComponent
의 GetAxisValue
로 값을 얻을 수 있을 것이다.
// AMyCharacter.cpp ...
void AMyCharacter::InputLookMouse(const FInputActionValue& ActionValue)
{
const auto Value{ ActionValue.Get<FVector2D>() };
AddControllerPitchInput(Value.Y * LookUpMouseSensitivity);
AddControllerYawInput(Value.X * LookRightMouseSensitivity);
// 추가
SwayPitch = Value.Y; // Up, Down
SwayYaw = Value.X; // Left, Right
}
사전 준비는 다되었으므로, WeaponSway
함수에서 알고리즘 처리를 하면 된다.
void AMyCharacter::WeaponSway(float DeltaTime)
{
bool check = IsValid(CombatComponent) &&
IsValid(CombatComponent->GetWeapon()) &&
CombatComponent->GetWeapon()->GetWeaponType() != EWeaponType::EWT_Default &&
bADS;
if (check)
{
float MaxSwayDegree = CombatComponent->GetWeapon()->GetMaxSwayDegree();
float SwaySpeed = CombatComponent->GetWeapon()->GetSwaySpeed();
float Turn = SwayYaw * MaxSwayDegree;
float LookUp = SwayPitch * MaxSwayDegree;
SwayFinalRotation.Roll = LookUp;
SwayFinalRotation.Pitch = Turn;
SwayFinalRotation.Yaw = Turn;
FRotator TargetRotation(
SwayInitialRotation.Pitch + SwayFinalRotation.Pitch,
SwayInitialRotation.Yaw + SwayFinalRotation.Yaw,
SwayInitialRotation.Roll + SwayFinalRotation.Roll
);
if (IsValid(ADSSkeletalMesh))
{
FRotator ResultRotation = UKismetMathLibrary::RInterpTo(ADSSkeletalMesh->GetRelativeRotation(), TargetRotation, DeltaTime, SwaySpeed);
ResultRotation.Roll = UKismetMathLibrary::FClamp(ResultRotation.Roll, -MaxSwayDegree, MaxSwayDegree);
ResultRotation.Pitch = UKismetMathLibrary::FClamp(ResultRotation.Pitch, -MaxSwayDegree, MaxSwayDegree);
ResultRotation.Yaw = UKismetMathLibrary::FClamp(ResultRotation.Yaw, -MaxSwayDegree + SwayInitialRotation.Yaw, MaxSwayDegree + SwayInitialRotation.Yaw);
ADSSkeletalMesh->SetRelativeRotation(ResultRotation);
}
}
}
이 코드에서는 총기에 대한 정보와 전투에 대한 정보를 CombatComponent
, Weapon
클래스를 따로 만들어 캡슐화했다. 하지만 이렇게 하지 않아도 흔들림 구현에는 상관없다. (하지만 사용하는 메쉬에 따라 보기 좋은 흔들림 정도와 속도가 다르기 때문에, 총기를 클래스화해서 관련된 멤버변수를 갖는게 좋을수는 있다.) 이외에는 앞선 글에서 설명했던 컴포넌트들이다.
또, 마우스를 크게 움직인다고 총기도 휙 돌아가버리면 안되기 때문에 각도를 보간한 뒤에 다시 정해준 값 범위로 FClamp
를 해주고 있다. 이때 총기의 각도 초기값이 (0,0,-90)이기 때문에 Yaw
값은 초기값을 더해준 범위로 FClamp
를 한다.
MovingSway 구현
MovingSway
는 이동 흔들림 기능을 하는 함수이다. WeaponSway
와 똑같이 Tick
에서 호출해준다.
// AMyCharacter.h ...
void MovingSway(float DeltaTime);
// AMyCharacter.cpp ...
void AMyCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
SmoothADSCamera(DeltaSeconds); // 이전글 참고!
WeaponSway(DeltaSeconds); // 조준 흔들림, 이전에 설명
MovingSway(DeltaSeconds); // 이동 흔들림
}
그리고 앞서 알고리즘에서 다뤘던 필요한 변수들을 선언해준다.
// AMyCharacter.h ...
//
// Sway
//
// ...
// 캐릭터가 이동할 때 총을 움직여주는 Sway 관련
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<UCurveVector> SwayMovingVectorCurve; // 이동 흔들림에 사용할 값 커브
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SwayMovingSpeed = 2.5f; // 이동 흔들림 속도
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SwayMovingRange = 10.f; // 이동 흔들림의 값 범위(흔들리는 정도)
float SwayMovingTime = 0.f; // 이동한 시간
이제 필요한 준비는 끝났으므로, MovingSway
함수를 구현한다.
void AMyCharacter::MovingSway(float DeltaTime)
{
if (!bADS)
{
return; // 정조준중이 아니면 굳이 이동 흔들림할 필요가 없음.
}
double Speed = GetVelocity().Length();
SwayMovingTime += DeltaTime;
// 이동이 끝나면(속도가 0에 가까우면) 시간을 초기화하고 바로 함수를 끝냄.
if (Speed <= 0.1f)
{
SwayMovingTime = 0.f;
return;
}
// 이동 흔들림
if(IsValid(SwayMovingVectorCurve))
{
FVector NewVector = SwayMovingVectorCurve->GetVectorValue(SwayMovingTime);
float Roll = UKismetMathLibrary::NormalizeToRange(NewVector.X * Speed, -SwayMovingRange, SwayMovingRange);
float Pitch = UKismetMathLibrary::NormalizeToRange(NewVector.Y * Speed, -SwayMovingRange, SwayMovingRange);
float Yaw = UKismetMathLibrary::NormalizeToRange(NewVector.Z * Speed, -SwayMovingRange, SwayMovingRange);
SwayFinalRotation.Roll = SwayInitialRotation.Roll + Roll;
SwayFinalRotation.Pitch = SwayInitialRotation.Pitch + Pitch;
SwayFinalRotation.Yaw = SwayInitialRotation.Yaw + Yaw;
if(IsValid(ADSSkeletalMesh))
{
FRotator ResultRotation = UKismetMathLibrary::RInterpTo(ADSSkeletalMesh->GetRelativeRotation(), SwayFinalRotation, DeltaTime, SwayMovingSpeed);
ADSSkeletalMesh->SetRelativeRotation(ResultRotation);
}
}
}
이 함수는 캐릭터가 이동중이고 정조준중일때, 커브에서 벡터값을 가져온다. 이 값을 개발자가 정의한 값 범위 내로 정규화해서 일정 각도만큼 총기를 회전시켜주고 있다. 사실 커브를 먼저 만들어도 되지만, 이렇게 먼저 함수를 만들어놓고 직접 테스트하면서 커브값을 조정하는 것이 더 편하다.
그 뒤에 에디터에서 Curve Vector
를 만든다.
만들면 빨간색이 X, 초록색이 Y, 파란색이 Z인 그래프가 나오는데, 우클릭해서 Add Key한 뒤 값을 지정해주면 된다. 상단 바에서 여러 보간법들과 Pre-Infinity, Post-Infinity를 어떻게 할 지도 설정해줄 수 있다.
캐릭터 이동을 끊지 않고 무한히 하는 유저는 거의 없겠지만, 혹시 모르니 그래프의 값을 몇개만 적당히 설정해주고 뒤의 값은 Cycle로 설정해 계속 반복되게 만들자. 큰 의미는 없지만 필요하다면 보간법도 적절히 골라서 곡선형태로 만들어보자. 완성된 것은 다음과 같은 모양일 것이다.
개인적으로는 한번에 X, Y, Z축을 동시에 건드리려고 하지말고 하나씩만 수정하고 테스트해보고 됐다 싶으면 다른 축을 건드는 방식으로 작업하는 게 더 빨랐다.
멀티플레이어 환경 테스트
다른 캐릭터가 정조준을 한다고 해서 특별히 처리해줄 것은 없었고 애니메이션 같은걸 바꿔줘야했어도 ALS에서 레플리케이션을 해줬기 때문에 문제는 없었다.
하지만 정조준했을 때 이동속도를 느리게 하고 싶었어서 MaxWalkSpeed를 로컬로만 변경해줬는데, 이게 서버의 값과 충돌했다. 정확히 말하자면 서버는 서버의 값으로 갱신하려하고 클라는 클라의 값으로 계속 갱신하려했다. 빨라졌다 느려졌다를 수도없이 반복하니 캐릭터가 렉걸린것처럼 흔들렸다.
이를 해결하기 위해선 단순히 정조준했을 때 로컬에서 값을 변경하지말고, RPC를 호출해 서버에서 값을 변경해주면 되었다.
결론
이렇게 해서 정조준 시스템 구현에 이어 이를 자연스럽게 보이게 하는 흔들림까지 구현을 했다.
여담이지만 개인적으로 진행중인 프로젝트에 국한되지 않고 최대한 범용적으로 사용할 수 있게끔 알고리즘을 다루고자 하는데... 쉽지 않다. 그래도 누군가 글의 내용 전부는 아니더라도 방법정도만 보고 "아 이런 방법도 있구나" 해서 응용할 수 있다면 좋겠다.
발생할 만한 문제들
여러 가지 문제를 겪긴 했지만 치명적인 문제는 없었다. 문제들이 다 "흔들림이 부자연스럽다."는 문제로 일반화할 수 있었고 코드나 커브를 계속 수정하면서 개인적으로 마음에 드는 선에서 해결할 수 있었다.