카타나 공격 구현


코드 (깃허브)


카타나 (칼)

일단 이것은 프로젝트 이쩜오 기획에 맞춰 구현한 것입니다. 같은 칼 공격이더라도 기획에 따라 구현이 많이 바뀔 것입니다.

그래도 구현하며 공부한 내용과 생각한 내용들을 기록해, 다른 기획에 맞춰 구현할 때도 도움이 될 것입니다.

구현

katana

우선, 애님 몽타주와 애니메이션 노티파이를 사용해서 구현했습니다.

판정은 휘두르는 타이밍동안 NotifyState를 사용했고, 콤보는 Notify를 사용해 특정 타이밍에 마우스가 눌려있는가를 검사했습니다.


타격 판정 구현

제가 생각하는 칼 판정 구현 방법 세 가지입니다.

  1. 무기의 콜리전을 공격 애니메이션 시작할 때 켜고, 끝날 때 끄는 방식. 적과 부딪히면 hit 또는 overlap 이벤트 발생. (무기 자체를 or 무기에 소켓을 붙여서)
  2. 칼 경로 따라서 Shape Sweep (틈이 적어서 정밀. 쉽게 두께 반영 가능)
  3. 칼 경로 따라서 Line Trace 연결 (Shape Sweep보다 비용이 쌈)

기획에 따라 고려해 볼 내용입니다.

  1. 판정 기대 수준 (정확한 궤적, 프레임 드랍 시 정밀도 등)
  2. 공격 속도
  3. 칼 두께

콜리전 방식은 프레임 단위로 현재 프레임 위치에서 검사하는 거라, 초당 프레임 수가 낮아지거나 공격 속도가 빨라지면 프레임 사이 칼 위치 차이가 커져서 정밀도가 떨어질 수 있습니다.


Shape Sweep과 Line Trace

  • 한 번의 Shape Sweep이 몇 번의 Line Trace와 성능이 비슷한지는 잘 모르겠습니다.
  • Shape마다 판정식이 다르기에 성능이 다릅니다.
  • 칼의 두께를 반영하는 경우에는 Line Trace를 많이 쏴야합니다.
  • Sweep 방식은 모양 그대로 Sweep 하기 때문에 칼의 회전을 고려하지 못 할 수 있습니다.

저 같은 경우엔, 기획상으로, 공격 속도가 많이 빨라질 수 있고, 카타나는 두께가 거의 없어서 두께를 반영하지 않아도 되고, 더 정확한 궤적으로 판정하기 위해 Line Trace 방식으로 구현했습니다.


칼에 소켓을 세 개 붙여서, 그 소켓 위치를 사용해 Line Trace 했습니다.

처음에는 가장 간단하게, 이전 소켓 위치에서 다음 소켓 위치로 쐈습니다.

그 후, 판정을 촘촘하게 하기 위해서 지그재그 모양으로 쐈습니다.

katana

(이후에, 지그재그 모양을 왼쪽에서 오른쪽으로 바꿨습니다. 라인 하나 추가하는 것으로 정확도가 크게 늘어나는 것 같습니다.)

공격 속도가 빠르거나, 초당 프레임 수가 적어졌을 때 프레임 사이 칼 간격이 멀어져 판정이 부정확해지는 문제가 있었습니다.

옵션 플래그(bStepTrace)와 최소 간격 프로퍼티를 넣어, 원한다면 칼 판정 최소 간격을 설정할 수 있게 구현하였습니다.

StepTrace로 판정을 촘촘하게 만들었지만, 그저 칼 간격이 넓을 시 일정 간격으로 나눠 판정을 진행하는 방식이라 궤적이 둥글지 않고 각지는 문제가 발생했습니다.

아직 해결하지 못했습니다. 해결했습니다. 일명 MetaTrace


MetaTrace (메타데이터 사용)

StepTrace처럼 LineTrace의 시작점과 끝점을 추가하는 것은 같은데, StepTrace와 다르게 칼의 궤적 위에 있는 점으로 추가를 해야 궤적을 살리면서 판정을 촘촘하게 만들 수 있습니다.

그래서 칼의 궤적, 즉 타격 판정을 위해 칼에 붙인 Socket의 위치를 원하는 어떤 시점이든 알아내야 했습니다. (StepTrace는 틱마다 엔진이 계산해서 주는 값만 사용)

칼 위 Socket의 위치를 구하는 과정을 나열해 봤습니다.

katana

이 중에 1번, 3번을 제외한 값들은 우리 게임에서 게임 시작 전 결정되고 게임동안 바뀌지 않는 값입니다.

1번은 게임 중에 계속 바뀌는 값이고, 3번은 한 애니메이션이 진행됨에 따라 포즈가 바뀌어 값이 바뀌지만, 한 애니메이션을 통째로 봤을 때는 바뀌지 않습니다.

그래서 이전 틱과 현재 틱 사이의 시간에서 소켓 위치를 구할 때,

1번은 이전 틱과 현재 틱에서의 값을 보간해서 쓰는 것이 최선이고, (참고로, 애니메이션에서 캐릭터가 도는 건 뼈가 돌아가는 것이라 그런지 World Rotation이 바뀌지 않았습니다. 그저 움직이는 만큼 Position만 조금씩 바뀝니다.)

3번은 애니메이션 단위로는 변하지 않는 값이니깐, 미리 추출해서 애님 메타데이터로 가지고 있는 것이 가능합니다. 이것이 런타임에 직접 얻는 것보다 속도 측면에서 효율적일 것입니다.

그래서 칼이 부착되는 Bone Space에서 Skeletal Component으로의 변환을 메타데이터로 추출하기로 했습니다.
그리고 그때의 시간을 알기 위해 메타데이터에 시간 배열도 넣기로 했습니다.

그리고 추출하기 쉽게 에디터 툴을 만들기로 했습니다.


메타데이터 추출

생각한 추출 방법은

  1. 에디터 환경에서 레벨에 Skeletal Mesh Component를 스폰하고,
  2. Skeletal Mesh를 입히고,
  3. Animation을 설정하고,
  4. 설정한 주기씩 애니메이션을 움직이면서,
  5. 메타데이터를 추출하는 것입니다.

( 쉽게 생각한 것만큼 쉽게 구현되진 않았습니다:) )


처음에 Anim Sequence와 Anim Montage 중 어떤 단위로 메타데이터를 추출하는 것이 좋을지 고민했습니다.

Anim Sequence별로 메타데이터를 두는 것이 더 분할되어 좋다고 생각했습니다.


성능 측면에서는,
시퀀스 단위의 경우 : 몽타주에서 현재 재생 시간을 사용해서 현재 재생중인 시퀀스를 찾는 것은 고작 몽타주 속 시퀀스 개수만큼만 for문을 돌리면 됩니다.

몽타주 단위의 경우 : 메타데이터 인덱스가 배로 늘어나지만, 시간 데이터가 정렬되어 있으니 이진탐색을 사용하면 최악의 경우에도 시퀀스 단위보다 계산이 횟수가 적습니다.

시퀀스 개수가 많지 않기 때문에 둘 사이에 성능 차이는 크게 없다고 판단했습니다.

그래서 Anim Sequence 단위로 메타데이터를 추출하는 것을 구현했고, 성공했습니다.

(이후 몽타주 단위로도 추출하고 추가로 깨달은 것은, 몽타주 단위로 하면 메타데이터 크기가 커져서 에디터에서 다룰 때 렉이 걸려 불편합니다.)


몽타주 단위로 메타데이터를 추출하는 것도 해 보고 싶어서 해 봤습니다.

몽타주는 애님 블루프린트의 슬롯이 없으면 재생이 안 된다고 알고 있었기에 시퀀스랑 다르게 캐릭터도 스폰하고 애님 블루프린트도 적용해 줬습니다.

그럼에도 Anim Sequence로 추출했을 때의 로직을 사용했는데, 모든 변환 값이 똑같이 나오는 문제가 발생했습니다.


TickAnimation()을 호출했는데도 불구하고, Transform 계산 로직이 진행이 안 된 것 같아서

TickAnimation() 내부 함수들 외에, 애님 인스턴스나 몽타주만의 Bone Transform 값을 업데이트하는 다른 로직이 있는지 찾아봤지만 찾지 못 했습니다.


이 문제의 원인과 최종 해결법은…
TickAnimation()에 DeltaTime으로 0.f를 넣어준 것이 문제였습니다. 어쩌다 혹시몰라 다른 값을 넣어줬더니 해결됐습니다.

이미 애니메이션 시간은 직접 설정했으니, 내부 로직만 돌리면 된다고 생각해서 0.f를 넣었는데… 엔진 코드를 더 뜯어보지는 않았지만 dirtyFlag 등 뭔가 이유가 있는 것 같습니다.

Tick에서 관련 로직들이 다 실행이 될 것이기 때문에, 그냥 몽타주를 Pause하거나 재생 속도를 0으로 하고 Tick을 돌리면 되는 것이었습니다.
TickAnimation() 함수에 DeltaTime을 넣어도, 몽타주는 Pause 혹은 재생 속도가 0이라서 문제 없기 때문에 Tick 로직들을 호출하는 용도로 사용할 수 있는 것입니다.


TickAnimation()을 호출하면서 생긴 추가적인 문제가 있었는데, 노티파이가 호출되면서 크래시가 나는 것입니다.

이를 해결하기 위해,
애니메이션 사이에 메타데이터를 Copy 할 수 있는 기능을 만들어서
노티파이가 있는 경우에는, 노티파이 없는 버전으로 추출해서 노티파이 있는 버전으로 복사할 수 있게 만들었습니다.


에디터 툴

katana

코드를 모르는 팀원도 쉽게 추출하고, 빠르게 추출해서 실험할 수 있게 구현한 추출 함수를 호출하는 에디터 툴을 만들기로 했습니다.

에디터 유틸리티 위젯을 사용해서 쉽게 만들 수 있었습니다.

시퀀스나 몽타주를 선택하고, Skeletal Mesh를 선택하고, Bone 이름을 입력하고 추출하면 됩니다.

추가로, 위에 적어둔 메타데이터 복사 기능도 넣어뒀습니다.


밑에는 설명 없는 시연 영상입니다.


최종 결과

추출한 메타데이터를 사용해서 간단하게 궤적 보정을 해 보니,
(파란 디버그 점이 메타데이터를 사용해서 얻은 점입니다.)

katana

일반적인 공격 속도에서는 아무런 문제가 없는데,

katana

빠른 공격에서는 첫 부분이 궤적이 뭔가 이상해 졌습니다.

katana

더 편하게 문제를 찾기 위해서 Tick 때 계산되는 소켓 위치들도 찍어보니, 메타데이터 문제가 아님을 쉽게 알 수 있었습니다.

이건 Blend 때문이었습니다. 실제 게임에서는 애니메이션이 Blend 되면서 칼의 궤적이 변하는 것이었습니다.

katana

블렌드를 아예 없애거나, 혹은 이건 부자연스러울 수 있으니 블렌드 시간을 좀 줄이거나 Exponential Out과 같이 초반에 빠르게 Blend하는 Curve를 사용하는 것으로 해결이 가능했습니다.


최최종 결과

katana

일반 속도

katana

재생 속도 2배


죄종적으로, Tick 사이에 칼이 얼마나 이동하든지 상관없이 설정한 값보다 많이 이동하면 그 사이에 칼의 궤적 위의 점을 추가해서

궤적을 유지하면서 판정을 촘촘하게 할 수 있게 됐습니다.


성능 프로파일링

Unreal Insights로 각 Trace 방식들의 성능을 비교해 봤습니다.

한 번의 ZigzagTrace에 소요되는 시간과 하나의 Trace를 두 개의 Trace로 나누는 경우 얼마나 더 소요되는지 궁금했습니다.

katana


한 번의 지그재그 트레이스는 8~12us가 걸렸습니다.

katana

Step Trace는 지그재그 시간 * Step 횟수가 걸렸습니다.

katana

Meta Trace도 Step Trace와 크게 다르지 않았습니다. (아주 살짝 더 걸리는 정도)

katana

결론적으로, 성능에 큰 문제가 없었습니다.


콤보 구현

특정 타이밍에 키를 누르고 있어야 콤보가 진행되어야 한다는 기획에 맞춰서, 키를 누른 상태를 bool 값으로 저장하고 노티파이 시 이를 사용해 콤보 진행 유무를 결정했습니다.

위의 방식은 콤보 공격을 하는 재미가 없어서, 다음 콤보 판정 전에 공격 키를 눌러야 콤보가 진행되게 변경했습니다.

NotifyState를 사용해서, Notify Begin에 커맨드 입력을 활성화하고 Notify End에 커맨드 입력이 있었으면 다음 콤보를 진행하게 구현했습니다.

하지만, 이후 네트워크 지연 및 패킷 손실 문제를 고려하다가 방식을 변경했습니다.


멀티 플레이어 구현 (리슨 서버)

RPC를 사용해서 모든 플레이어에서 공격 애니메이션이 재생되고, 콤보 체크 및 판정 구현은 서버에서만 진행되게 구현했습니다.

katana

원래 PIE 환경에서는 네트워크 지연이 없어서 아무런 문제가 없었지만,

문득 네트워크 지연이 생겼을 때를 생각해 보니 예상되는 문제들이 많이 생각나서 네트워크 에뮬레이션을 사용해 트래픽 지연, 패킷 손실 상황을 에뮬레이션 했습니다.


클라이언트에서 ServerRPC를 보내서, 서버에서 이를 검증하고 다시 ClientRPC (or Multicast)로 애니메이션을 재생하게 하면 딜레이가 생겨 플레이어 경험에 좋지 않습니다.

그래서 타격 판정은 서버에서 하고,

애니메이션 재생과 콤보 체크는 클라이언트와 서버 둘 다 진행하기로 했습니다.

클라이언트에서 애니메이션을 먼저 재생하고, ServerRPC, MulticastRPC를 사용해 서버와 나머지 클라이언트에도 애니메이션을 재생해 줬습니다.


콤보 커맨드 입력은 클라이언트에서 받아서, 서버로 전달해 줬습니다.

클라이언트에서는 바로 처리가 되니 bool 값으로 관리했지만, 서버에서는 int 값으로 관리했습니다.(전송받은 커맨드 횟수)
만약 짧은 간격으로 콤보 판정을 하는 경우, 서버에서 이전 콤보 커맨드를 판정하기 전에 콤보 커맨드가 하나 더 들어올 수도 있기 때문입니다.


검증 및 동기화

클라이언트에서도 직접 콤보 커맨드를 판정해서 다음 콤보 애니메이션을 재생했습니다.

katana

만약 서버에서 콤보 진행이 끊겼는데 클라이언트에서 계속 진행 중이라면 클라이언트 애니메이션 시간을 서버 애니메이션 시간으로 맞춰주기로 했습니다.

하지만, 애니메이션 시간을 강제로 맞춰주는 것은 플레이어 경험상 좋지 않을 것입니다.

NotifyState를 사용해서 Notify Begin에 커맨드 입력을 활성화하고 Notify End까지 커맨드 입력이 있었으면 다음 콤보를 진행하는 현재 방식은,

네트워크 지연이 처음 애니메이션을 재생할 때보다 조금이라도 늘어나면,

Notify End가 거의 다 됐을 때 커맨드를 입력하게 되면, 클라이언트에서는 커맨드 입력을 해서 콤보가 진행되지만, 서버에서는 Notify End가 끝나고 커맨드 입력 RPC를 받게 되어 콤보가 중단될 것입니다.

이러한 상황을 줄이기 위해, 콤보 커맨드 인풋을 받는 구간을 뜻하는 NotifyState와 콤보 진행을 유무를 체크하는 Notify를 따로 두고 그 사이 간격을 줬습니다.


정리

만약 플레이어에서 콤보 진행했지만, 서버에 패킷이 늦게 도착해서 서버에서는 콤보가 멈췄을 경우에

  • 플레이어의 콤보 진행이 끝나고, 애니메이션이 끝나는 중이라면 그대로 둔다.
  • 콤보가 아직 진행 중이면, 더 이상 콤보를 진행하지 못하게 하고 서버와 애니메이션 시간을 동기화한다.
  • 이런 상황이 발생하는 것 자체를 줄이기 위해 콤보 입력 기간과 콤보 판정 시간을 띄어놓는다.


리슨 서버 노티파이 무시되는 문제

네트워크 에뮬레이션을 했더니, 서버에서 Notify가 처리되지 않는 문제가 있었습니다.

이는 RootMotion을 하는 중에 AnimEvents를 무시하기 때문이었습니다.

void UDSCharacterMovementComponent::ServerMove_PerformMovement(const FCharacterNetworkMoveData& MoveData)
{
	Super::ServerMove_PerformMovement(MoveData);

	if (CharacterOwner && !CharacterOwner->bClientUpdating && CharacterOwner->IsPlayingRootMotion() && CharacterOwner->GetMesh())
	{
		CharacterOwner->GetMesh()->ConditionallyDispatchQueuedAnimEvents();
	}
}

MovementComponent의 ServerMove_PerformMovement()를 오버라이드해서 RootMotion 중에 AnimEvents를 처리하게 하는 방법으로 해결됐습니다.

Leave a comment