최근 면접자리에서 짐벌락 관련 질문이 나온적이 있다.

 

정확히는, 내가 경력기술서에 작성한 내용 중

 

- 짐벌락 이슈에 대응하기 위해, 회전 정보 획득시 GetActorRotation이 아닌 GetComponentRotation 함수를 Root Component를 대상으로 호출하여 이용함.

 

라는 문장에 대한 질문이었는데, 내가 답변을 좀 에매모호하게 한거 같아서 정리를 좀 하고자 한다.

 

 


짐벌락?

 

짐벌락(Gimbal lock) 은 말 그대로 짐벌 (물체가 회전 가능하도록 중심축을 가진 물체)에 락이 걸린 상황을 의미한다.

 

보통, 3D 회전은 오일러 좌표계 (혹은 오일러 각 / Eular Angle)을 따르는데, 이때 3개의 축을 이용한다.

 

각각을 Roll, Pitch, Yaw로 표현하고, 보통은 각각의 수치가 X축 회전, Y축 회전, Z축 회전에 대응한다.

 

 

 

그래서 짐벌락은 언제, 왜 생기는가.

 

간단히 요약하는 짐벌락

 

 

간단히 요약하면, 축이 겹쳐서 발생하는 현상이다.

 

물체가 회전할때 명심해야 할 것이 있는데, 회전축이 회전할때는 다른 축의 회전에 영향을 받는다.

 

특히, 명심해야 하는 사항이 회전에는 순서가 있고, 순서에 따라 다른 결과값을 갖고온다는 점이다.

 

이러다보니 발생하는 문제가 바로 짐벌락. 임의의 축을 기준으로 회전했을때 회전한 축이 아닌 다른 축이 겹치는 현상이 발생하는데, 이렇게 되면 특정 축이 연산에 전혀 영향을 못주게 되면 문제가 발생한다.

 

예시로, X = 빨강, Y = 초록, Z = 파랑이라 생각해보자.

예를들어, X, Y축이 겹치는 현상이 발생했다면,  X축을 회전하던, Y축을 회전하던 결국 Z축을 기준으로 똑같이 빙빙 도는 결과가 발생한다.

 

짐벌락이 문제가 되는 경우는, 해당 상황과 같이 특정 축이 락이 걸려버린경우 해당 축의 연산값이 전혀 영향을 못 미치게 된다는것인데, 이로 인해 정상적인 회전을 보장할 수가 없게된다.

 

 

 

쿼터니언

 

선형대수학에서는 오일러 각을 이용할때 생기는 이러한 문제를 해결하기 위해 4개의 축을 이용하는 쿼터니언 이라는것을 이용하여 연산을 한다.

 

쿼터니언 표현식이 좀 많은데, 제일 많이 쓰는 식은

 

q = xi + yj + zk + w

 

의 식으로 기억하고 있다. 표현대로 각각 x축, y축, z축과.. 가상의 w축을 포함하여 4개의 축을 이용한다.

 

이를 이용해서 회전 처리를 할때 짐벌락이 걸리지 않도록 하는것인데... 쿼터니언 설명 자체는 다음에 필요하면 하도록 하겠다.

 

 


언리얼에서는

 

언리얼에서도 당연히 짐벌락을 막기 위해 많은 방법을 선택한다.

 

주로 사용되는 방식은 2가지인데,

 

첫번째. 회전시 쿼터니언을 사용한다. 

두번째. 회전값 표현시 축 분리를 한다.

 

 

우선 첫번째부터 설명하면, 언리얼에서 물체의 회전 및 위치, 스케일을 가지고 있는 "FTransform" 구조체를 뜯어보면 바로 답이 나온다.

 

protected:
	/** Rotation of this transformation, as a quaternion */
	TPersistentVectorRegisterType<T> Rotation;
	/** Translation of this transformation, as a vector */
	TPersistentVectorRegisterType<T> Translation;
	/** 3D scale (always applied in local space) as a vector */
	TPersistentVectorRegisterType<T> Scale3D;
public:
	/**
	 * The identity transformation (Rotation = TQuat<T>::Identity, Translation = TVector<T>::ZeroVector, Scale3D = (1,1,1))
	 */
	CORE_API static const TTransform<T> Identity;

그냥 대놓고 쿼터니언으로 변환해서 쓴다고 얘기를 한다.

 

언리얼에선 TQuat라는 쿼터니언 자료형이 있는데, 내부적으로는 해당 값을 사용하고, 오일러 좌표계로 표현되는 FRotator 값이 내부 함수에서 쿼터니언으로 변환되어 들어온다.

 

	FORCEINLINE explicit TTransform(const TRotator<T>& InRotation) 
	{
		TQuat<T> InQuatRotation = TQuat<T>::MakeFromRotator(InRotation);
		// Rotation = InRotation
		Rotation =  VectorLoadAligned( &InQuatRotation );
		// Translation = {0,0,0,0)
		Translation = GlobalVectorConstants::FloatZero;
		// Scale3D = {1,1,1,0);
		Scale3D = GlobalVectorConstants::Float1110;

		DiagnosticCheckNaN_All();
	}

 

 

두번째가 조금 설명하기 까다로운데,
우선 설명해야할것은, 쿼터니언 회전값은 해당 식만 보고는 이게 어떤 회전값을 가지고 있는지 바로 알기가 힘들다.
물론, FQuat와 FRotator를 변환해주는 함수가 없다는 뜻은 아니고, 쿼터니언 값만 가지고 원하는 오일러 각으로 변환하기가 쉽지 않다는 뜻이다. (물론, 지금은 시키면 할 수 있을거 같긴한데)

 

아무튼 그런 이유로, 언리얼 에디터에서는 회전값을 쿼터니언이 아닌 오일러 좌표계로 표현한다.

하지만, 오일러 좌표계를 이용하면 짐벌락을 피하기가 쉽지 않은데, 이를 "자동으로" 각도를 분할시키는 방식으로 해결하려고 했다.

 

무슨소리냐

 

예를들어, 멀쩡한 물체의 각도를 회전시키면

 

처음에는 0,0,0임

 

Y축 (Pitch)를 회전시키려고 하면

 

짜잔! 건들지도 않은 Yaw도 바뀌어버려요!

 

이처럼, 짐벌락을 피하기 위해 임의의 각을 연산하여 추가로 회전시키는 로직이 들어간다.

 

이러한 연산이 일반적인 회전 처리에서는 당연히 도움이 되지만...

회전이 들어간 이후 "인위적으로 회전값을 변경해야 하는 경우"에 문제가 발생했다.


그래서 뭐 한건데?


문제가 뭐였나면요...

 

 

위에서 말한것에서 이어서, 언리얼에서는 짐벌락을 피하기 위해 임의의 각 회전을 추가로 하는 경우가 있다고 했다.

하지만, 다음과 같은 경우에서 문제가 발생했었다.

 

 

예를들어, 특정 이동에서 X축값 (Roll)이 회전이 들어간 경우, Roll값을 0으로 만들고싶으면 Roll을 0으로 만드는 회전값 오버라이드를 하면 그만일것이다.

 

처음엔 당연히 그렇게 생각했다. 하지만 Roll을 회전 시킨 결과가 과연 Roll에만 있을까?

 

여기서 해당 회전 수치를 보장 할 수 없는 문제가 생겨버린다. 내가 임의의 축을 0으로 돌렸을때, 나머지 회전값은 내가 원하던 회전이 맞는가?

 

여기서 의문이 들 수 있다.

첫번째. "그냥 모든 축을 0으로 바꾸면 안됐나요?"

두번째. "내가 필요한 회전축을 제외하고 0으로 만들면 안되나요?"

 

둘다 답은 X다. 당연히 첫번째는 말도 안되고, 두번째의 경우, 처음 회전한 Yaw 값을 유지하는것이 목적이었는데
(언리얼에서 캐릭터를 조금이라도 만져보면 알겠지만, 서 있는 상태에서 회전은 Yaw값을 사용하여 움직인다)

 

임의의 축에서 회전이 발생 -> 짐벌락을 피하겠다고 언리얼에서 자체적으로 회전 처리 -> Yaw값에 손상 -> 나머지 회전값을 처리해도 Yaw값이 원하는 회전값이 아니게 되는 문제가 발생

 

이라는 문제가 발생해버리고 있었다.

 

 

 


해결방안

 

우선, Actor에서 회전값을 받아오는 GetActorRotation() 함수는 다음과 같은 구조로 작동한다.

template<class T>
static FORCEINLINE FRotator TemplateGetActorRotation(const T* RootComponent)
{
	return (RootComponent != nullptr) ? RootComponent->GetComponentRotation() : FRotator::ZeroRotator;
}

//---

/** Return rotation of the component, in world space */
FORCEINLINE FRotator GetComponentRotation() const
{
	return WorldRotationCache.NormalizedQuatToRotator(GetComponentTransform().GetRotation());
}

 

보통은 RootComponent의 회전값을 갖고와서 리턴해주는 방식인데, 당연히 대체로는 문제가 없다.

 

그런데 갖고오는 부분 어딘가에서 문제가 생기는지, RootComponent의 회전값과 다른 무언가를 갖고오는 현상이 있었는데, 이를 막기 위해 RootComponent에서 갖고오는 (정확히는 엔진 코멘트에 "해당 값은 RootComponent의 값과 동일함" 이라는 코멘트가 있는 부분이 있었다...) 방안을 선택했는데...

 

.....근데 분명 작업할때는 그런 코멘트를 본 기억이 있는데 (심지어 내부 문서에도 다 기록해놨다) 지금와서 찾으려니까 안보인다...???

 

그냥 해당 기록은 안쓰는거로 해야할거같다... 증거가 없으면 못쓰지요

 

사실 이제와서 해결하려면 그냥 쿼터니언 연산으로 해결하면 되긴 하는데 (...)

쿼터니언 연산으로 X축 성분, Y축 성분 제거하고 Z축 성분만 리턴해주면 될거다 아마도...

 

 


자료 출처

 

1.https://en.wikipedia.org/wiki/Euler_angles

2.https://en.wikipedia.org/wiki/Gimbal_lock

 

회사 및 과거 프로젝트가 유추될 수 있는 사항은 제외했습니다.

 


언리얼 리플렉션 시스템 (https://www.unrealengine.com/ko/blog/unreal-property-system-reflection)

  • 프로그램이 실행시간에 자기 자신을 조사할 수 있는 기능. (본인이 어떤 클래스인지 등을 알 수 있음)
  • 언리얼 엔진에서 C++ 클래스, 구조체, 함수, 멤버 변수, 열거형 관련 정보를 수집, 질의, 조작하는 별도의 시스템
  • 리플렉션 시스템에 등록시키고자 하는 변수 등에 UProperty 키워드 등을 부착시 Unreal Header Tool (UHT) 에 의해 리플렉션 시스템에 등록됨
    • UProperty 사용시, 언리얼 GC에 포함이 됨.
    • 각종 메타데이터들을 추가하여 해당 변수가 어떤 속성을 가졌는지를 알 수 있음.

 

 

 

언리얼 최적화 관련

  • 언리얼에서는 자체적인 GC를 사용함.
  • GC는 다음과 같이 작동함 :: 
    Unreachable 플래그를 모든 UObject에 부착 -> RootSet에 걸려있는 UObject에서 플래그 회수 -> 플래그 남은 UObject 기록 -> 리스트업 된 UObject Destroy (Begin/Finish) -> UObject의 소멸자 호출
  • GC의 타겟이 되지 않게 하기 위해서는 관련 오브젝트에 "AddToRoot" 혹은 "SetFlags(RF_MarkAsRootSet)" 플래그를 붙이면 됨.
  • 주로, 인게임에서 이미 소멸된 액터거나 Replicate 범위 밖 (NetCullDistance)에 존재하는 경우 타겟이 될 수 있음.

 

 

 

언리얼 리플리케이션 관련

  • 기본 개념 : 서버-클라간 동기화를 위한 개념
    • 서버->클라로는 호출한 / 소지한 (Autonomous) 클라이언트에게, 혹은 모든 클라이언트에게 전달 (Multicast) 가능
    • 클라->서버로는 클라이언트가 소지한 액터가 Autonomous일때만 서버로 전송가능 (이것도 RPC만 가능)
    • 변수 리플리케이션은 "원칙적으로는" 서버->클라만 가능함.
  • Role
    • Role은 Local Role / Remote Role 2개가 있음.
    • 보통 서버 입장에서 Local Role은 Authority인 경우가 대부분
    • 클라에서는 Remote는 Authority, Local이 다를 수 있음.
    • 비교
      • 클라 입장에서 Autonomous - "현재 클라이언트가 컨트롤 중인 플레이어"
      • 클라 입장에서 Simulated - "현재 클라이언트의 영향 외의 동기화 액터"
  • 기타
    • RPC는 단발성 리플리케이션에 주로 사용, 변수 리플리케이션은 영구적 리플리케이션에서 주로 사용함.

 

 

언리얼 포인터 관련


기본적으로 언리얼 스마트 포인터는 Object 포인터와 일반 포인터 (비 Object 포인터 = C++ 노멀 포인터)가 있음

  • 오브젝트 포인터 (Actor 클래스 등의 언리얼 오브젝트에서 사용. 언리얼 GC 타겟이 된다.)
    • TWeakObjectPtr - 순환참조 이슈 등을 대응하기 위한 오브젝트 포인터. 참조 대상 파괴시 nullptr로 세팅됨.
    • TObjectPtr - 64bit 기반 신규 포인터.
      원시포인터 (Normal Pointer) 와 유사하나, 다이나믹 해상도 (액터에 대한 동적 해상도 전환), 엑세스 트래킹 등의 신규 기능 추가.
      • 5.0 되면서 추가된 신규 포인터임. 원시 포인터를 TObjectPtr로 바꾸는걸 언리얼에서는 권장함.

  • 노멀 포인터
    • TUniquePtr - 일반적인 Unique Pointer와 동일. (하나의 포인터로 단 하나의 메모리만을 참조)
    • TSharedPtr - 일반적인 Shared Pointer와 동일
    • TWeakPtr - 일반적인 Weak Pointer와 동일 (Shared Pointer의 보조)
      • TWeakPtr은 TSharedPtr를 통해서만 복사 생성, 대입 연산이 가능하다.
      • TWeakPtr은 TSharedPtr를 참조하되, 레퍼런스 카운트를 증가시키지 않는다.
      • TWeakPtr을 통해 객체를 참조하려면 반드시 TSharedPtr로 변환하여 사용해야 한다.

 

 

 

* 주의 *

1. 해당 작업은 StandAlone에서만 테스트되었습니다.

2. 해당 작업은 임시 연동을 위해 작업되었으며, 타 환경 (네트워크 환경 등)에서는 정상 작동을 보장하지 않습니다.

3. 필요시, 코드를 적당히 수정해서 사용해주세요!

 

 

(아마 기억이 맞다면) 언리얼 5.3에 들어오면서 큰 변화가 몇가지 생겼다.

그중에 프로그래머 입장에서 유의미하게 볼것이 바로 EnhancedInput의 정식 사용 시작 및 AbilitySystem의 공식 플러그인 화 일것이다.

 

근데 웃긴게, 이 두가지가 하나는 정식기능, 다른 하나는 플러그인이라서 그런가 공식적으로 지원하는 연동 코드가 없다.....

 

물론, 진짜 하나도 없진 않다. 대신, LyraStarterGame에 포함되어있을뿐...

 

LyraStarterGame에서는 해당 기능을 HeroComponent라는 연동 컴포넌트와, EnhancedInput을 위한 LyraInputComponent를 만들어서 사용하는데, 해당 컴포넌트 및 Lyra 식 사용에 대한 분석은 다음에 하기로 하고, 우선은 조금 빠른 사용법을 익혀보도록 하자.

 

 

* 해당 코드의 일부는 EpicGames에서 제공하는 Lyra Starter Game에서 발췌했습니다.


우선, 이전 글을 참고하여 AbilitySystemComponent를 정상적으로 사용할 수 있는 상태를 기준으로 한다.

 

https://locketgoma.tistory.com/80

 

짧막 팁 : AbilitySystem 관련 헤더를 불러오지 못할때

대충 이런 상황이다. AbilitySystemComponent 및 기타 관련 헤더를 사용해야하는데 못불러오는경우... 해당 문제는 모듈이 빠져있는 상황으로, 프로젝트 명칭으로 된 폴더에 있는 "(ProjectName).Build.cs"

locketgoma.tistory.com

 

 

두가지를 연동하려면 우선, InputAction과 GameplayTag를 연동시켜둔 데이터파일이 필요하다.

해당 파일 타입을 Lyra에서는 "FLyraInputAction" 라는 struct를 사용하는 ULyraInputConfig라는 DataAsset를 만들어 사용한다.

 

일단, 해당 방법을 그대로 사용하자.

 

UDataAsset를 상속받는 신규 클래스를 만들고, 해당 파일에

 

USTRUCT(BlueprintType)
struct FInputAction		//이름은 적당히 바꿔서 쓰면 됩니다.
{
	GENERATED_BODY()

public:

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<const UInputAction> InputAction = nullptr;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (Categories = "InputTag"))
	FGameplayTag InputTag;
};

해당 구조체를 추가, uproperty로 추가해주면 된다.

Lyra에서는 (EditDefaultsOnly, BlueprintReadOnly) 조건으로 사용중이나, 적당히 필요한대로 쓰시면 될것같다.

 

 

 

그 다음부터는 선택지가 갈리는데, EnhancedInputComponent 및 PanwComponent를 상속받아서 Lyra와 같이 InputComponent + HeroComponent를 만들어서 사용하거나, 간략하게 AbilitySystemComponent를 상속하여 구현해주는 방법 등이 있다.

 

여기서는 AbilitySystemComponent 을 상속하여 추가 함수를 만들어주는 방식으로 구현하고자 한다.

 

Lyra 방식은... 공부도 하실겸 Lyra 코드를 열어보시는걸 추천드립니다.

 

 

 

첫번째. UAbilitySystemComponent를 상속받은 컴포넌트를 추가한다.

두번째. 위에서 만든 DataAsset Class를 헤더에 추가한다.
(단순히 전방선언으로 사용하면 구조체의 사이즈를 알 수 없어 오류가 발생한다)

세번째. 다음과 같이 (혹은 적당히 수정하여) 헤더를 작성한다.

UCLASS()
class PROJECTCLOUD_API UCLAbilitySystemComponent : public UAbilitySystemComponent
{
	GENERATED_BODY()

public:
	//인풋 액션을 세팅하는 함수 (return bool인 이유는 오류 검출 용도)
	bool BindInputActions(const UCLAbilityInputConfig* InputConfig, UEnhancedInputComponent* EnhancedInputComponent);
	
	//인풋 액션을 호출하는 바인딩 함수
	void TryActiveAbilityFromInputAction(const FInputActionInstance& Value);

private:
	//인풋 액션 리스트
	TMap<TObjectPtr<const UInputAction>, FGameplayTag> InputactionList;
};

네번째. cpp 파일에 필요한 헤더들을 추가한다.
EnhancedInput관련, GameplayTag 관련 헤더를 추가해주면 된다.

다섯번째. 바인딩 코드 작성.

bool UCLAbilitySystemComponent::BindInputActions(const UCLAbilityInputConfig* InputConfig, UEnhancedInputComponent* EnhancedInputComponent)
{
	if (!ensure(EnhancedInputComponent))
	{
		UE_LOG(LogTemp, Error, TEXT("UCLAbilitySystemComponent :: EnhancedInputComponent is Null."));
		return false;
	}

	for (const FCLInputAction Action : InputConfig->AbilityInputActions)
	{
		InputactionList.Add(Action.InputAction, Action.InputTag);

		EnhancedInputComponent->BindAction(Action.InputAction, ETriggerEvent::Triggered, this, &UCLAbilitySystemComponent::TryActiveAbilityFromInputAction);
	}
	return true;
}

 

Input으로 받은 DataAsset에서 리스트를 가져와서 EnhancedInput에 바인드하고, 로직 검사를 위해 ASC에 추가한 Map에 등록해주는 로직이다.

 

여섯번째. 바인드 될 호출함수 작성

void UCLAbilitySystemComponent::TryActiveAbilityFromInputAction(const FInputActionInstance& Value)
{
	//1. 이벤트로 들어온 InputAction과 매치되는 Tag가 있는지 검사
	FGameplayTag* InputTag = InputactionList.Find(Value.GetSourceAction());
	
    //2. 매치되는 Tag가 있다면
	if (InputTag != nullptr)
	{
    	//3. Pay로드 작성 후
		FGameplayEventData TempPayload;
		TempPayload.EventTag = *InputTag;
		TempPayload.Instigator = GetOwner();
        
        //4. ASC에 포함된 HandleGameplaytEvent를 호출한다.
        HandleGameplayEvent(TempPayload.EventTag, &TempPayload);
	}
}

Payload값은 임의로 수정해도 된다. (단, EventTag 제외)

 

 

다음과 같이 작성하면 함수 준비는 끝났다.

 

이를 어떻게 사용하느냐...

 

사실 생각보다 별거 없다. EnhancedInput과 ASC를 사용하고자 한다면, 아마 여러가지 검색을 하면서 Player Pawn에다가 연동하는 작업도 수행했을것이다.

아니었다구요? 그렇다면..

 

	UEnhancedInputComponent* EnhancedInputComponent = GetComponentByClass<UEnhancedInputComponent>();

	if (ACLPlayerState * PS = Cast<ACLPlayerState>(GetPlayerState()))
	{
		PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
		PS->SetAbilitiesFromActionSet(AbilitySet);
		PS->GetAbilitySystemComponent()->BindInputActions(InputConfig, EnhancedInputComponent);
	}

다음과 같은 로직을 Player Pawn의 Beginplay 등의 Init 단계에서 호출하도록 추가해주면 된다.

 

당연한 얘기겠지만, 이미 EnhancedInput을 Player Pawn에 등록했어야 하고,
해당 로직의 경우에는 PlayerState에 ASC를 등록해서 사용했기에, 해당 과정도 거친 상태여야 한다.

(스텐드 얼론이어서 Pawn에 직접 ASC를 붙여서 사용한다면, 코드를 적당히 수정하면 된다.)

 

다음과 같이 등록하고, Player Pawn에 "InputConfig"  변수를 추가해주면 끝.

 

정상적으로 잘 따라왔다면,

 다음과 같은 InputConfig를 만들 수 있고,

해당 InputConfig를 추가할 수 있는 공간이 Player Pawn에 추가된다.

 

이제 자유롭게 InputAction과 InputTag를 부착하면, Player의 ASC에 부착된 GameplayAbility 중 Trigger Event로 호출되는 Ability를 사용할 수 있게 된다.

 

 


ASC 사용법은 저 말고도 다른 분들이 잘 설명해준 문서들이 많으니 참고 바랍니다...
EnhancedInput과 ASC 연동하는 방법은 어디에도 없길래 Lyra 코드 분석해가면서 간략화해서 만들어봤어요

 

틀린 내용에 대한 지적은 언제나 받습니다.

대충 이런 상황이다.

 

AbilitySystemComponent 및 기타 관련 헤더를 사용해야하는데 못불러오는경우...

 

해당 문제는 모듈이 빠져있는 상황으로, 프로젝트 명칭으로 된 폴더에 있는 "(ProjectName).Build.cs" 파일에 모듈을 추가해주면 된다.

 

	PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" });

Build.cs 파일의 해당 위치에

 

 

	PublicDependencyModuleNames.AddRange(new string[] { "GameplayAbilities", "GameplayTags", "GameplayTasks", "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" });

다음과 같이 어빌리티 시스템 관련을 추가해주면 됨.

 

 

어빌리티 시스템 관련을 모듈로 추가해준 뒤, 프로젝트 파일을 Re-Generate 해주면 완료.

 

 

 

 

참고 자료 : https://dev.epicgames.com/documentation/en-us/unreal-engine/gameplay-ability-system-for-unreal-engine?application_version=5.0

1. 스프라이트 셋에서 스프라이트 추출

스프라이트 셋을 불러온 뒤, 불러와진 텍스쳐파일에서 우클릭 -> 스프라이트 추출

 

추출 옵션은 적당히 선택하면 되나, 대체로 Grid를 사용하게 될것임..

정확한 너비를 모르겠다면, 셀 수 X,Y를 입력한 뒤 셀 너비 및 높이 옆의 돌아가기 버튼을 누르면 알아서 지정해준다.

https://youtu.be/W4oZq4tn57w?si=SxHKHf56uecrfZk1

 

어쩌다 찾은 자료.

블리자드 오버워치팀에서 17년도에 발표한 자료인데,

네트워크 동기화, 하이라이트, 킬캠 등의 자료를 담고 있다.

 

 

주말중에 여러번 돌려보면서 정리해볼 예정

(사실 내가 필요해서 정리함)

 

언리얼 입문자들이면 다 알것이다.

어느정도 배우기 시작하면 나타나는 "리플리케이션" (Replication) 이라는 개념을....

 

처음에는 굉장히 생소하고, 이게 뭐... 어쩌라는건지? 싶을때도 있는데

익숙해지고 나면 이거 없이는 개발을 어떻게하지? 싶은 순간도 와버리게 되는 마성의 개념인것같다 (글쓴이 주관임)

 

 

 

여튼,

 

언리얼 네트워크 시스템에 대해 찾아보려고 검색을 하면 다들 이 그림을 보게 될 것이다.

 

 

굉장히 예전부터 보이던 그림이라 어디가 원조인지 궁금하긴 했는데 2020년에도 쓰였다는거 말곤 잘 모르겠더라 (...)

 

 

여튼, 이 그림을 기준으로 정리를 해보도록 하자.

 

결국 리플리케이션 개념은 서버/클라 통신과 관련된 개념이다.

 

모든 데이터를 서버에 둘 수도 없고, 모든 데이터를 클라에만 둘 수도 없으며, 클라이언트에 필요는 하지만 서버에서만 관리해야하는 데이터가 있을수도 있고 등등... 온갖 이슈들이 존재하는데

이를 관계 정리한것이 리플리케이션 개념인것.

 

우선은, 언리얼 네트워크 시스템상에서 각 요소(=액터) 들은 크게 3가지로 나뉜다.

 

Server Only

Server/Client Multy

ClientOnly

 

Server Only는 게임 관리자에 해당하는 "GameMode"  가 포함

Server/Client Multy는 게임 통제 (점수 기록 등)을 담당하는 "GameState", 각 플레이어의 상태를 가지고 있는 "PlayerState", 플레이어의 이동 정보 등을 가지고 있어야하는 "PlayerController", 그리고...

"Pawn" (or Character)

생각해보면, 결국 서버/클라 무관하게 모든 캐릭터는 똑같이 보여야하니까 Pawn이 서버/클라 모두에 있는건 별로 이상한 것은 아닌것같다.

 

사실 네트워크 요소중 다른 사항들은 크게 신경쓰지 않아도 된다. (어차피 네트워크 통신 이슈가 발생하진 않으니까)

하지만, Server/Client Side 양측에 모두 존재하는 액터들이 제일 큰 문제인데

 

여기서도 몇가지 갈린다.

1. 서버가 무조건 우선권을 가짐

2. 모든 클라이언트가 소유하고 있어야 하는 경우

3. 지금 화면을 보고 있는 클라이언트의 정보만 있어야 하는 경우

 

간단히 설명하면 위의 3가지인데, 이를 좀 더 상세하게 풀어가면

1. "관리 권한이 존재"

2. "관리 권한은 없으나 소유권은 있음"

3. "관리 권한도 없고, 소유권도 없음"

 

이렇게 3가지 타입으로 구별이 가능하다.

이를, "Authority", "Autonomous", "Simulated" 이라고 표현한다.

 

디테일한 내용은 아래 공식문서를 참고바랍니다. ->

https://docs.unrealengine.com/4.27/ko/InteractiveExperiences/Networking/Actors/Roles/

 

액터 롤 및 리모트 롤

액터 오브젝트 리플리케이션의 여러가지 부분에 대한 것입니다.

docs.unrealengine.com

 

 

자세한 롤 관련 얘기는 다음 문서에서 설명하기로 하고, 여기서는 네트워크 관계도(?)에 대한 설명을 좀 더 하기로 하겠다.

결국, 리플리케이션이 필요한 객체라도 어떤 클라에선 없을수도 있고, 클라 서버 관계없이 다 있을수도 있고 이런 애들이 나타날 수 있는데, 이게 초반에는 좀 굉장히 헷깔린다. (실제로 일하다가도 좀 실수를 했음..)

 

해서, 정리를 하자면

 

액터 타입/명칭 배치 위치 클라이언트 배치 조건 각 클라이언트당 개수
GameMode Server Only None 0
GameState Server / Client All Clients 1
PlayerState Server / Client All Clients N
PlayerController Server / Client "Controlled Client Only" 1
Other Controller
(ex. AIController)
Basically Server Only "If you need" 0?
Player Pawn Server / Client All Clients N
HUD / UI Client Only Controlled Client Only 1

 

여기서 제일 골치아픈것이 "PlayerController"

내가 컨트롤중인 클라이언트에서는, 본인의 캐릭터에 연결되어있는 PlayerController를 제외하고, 타인의 PlayerController는 없다고 생각하면 된다.

...혹시나 동기화 필요하다고 PlayerController 갖고와서 하고 있으면 어라 이거 왜 안되지 하는 실수를 할 수 있을것이다..

 

그리고 추가, "이펙트는 동기화가 어떻게되나요" 할 수 있는데

이펙트는 재생 시점 / 재생 위치 / 재생 종류만 동기화가 되고, 직접적인 이펙트 랜더링은 클라이언트에서 각자 해결한다.

 

그야 그럴게, 같은곳에 같은 이펙트가 재생은 되어야하지만, 그게 어떤 모양으로 랜더링 될지는 각자 클라이언트가 판단할 문제니까...

 

 

 

 

 

예전에 작업하다가 좀 어처구니 없는 실수했던것이 기억나서 글로 남겨본다.

언리얼에는 트레이스 (a.k.a. "레이캐스트") 라는 시스템이 있다.

 

간단히 요약하면 원하는 방향으로 빔을 쏴서 무언가가 걸리면 인식하는 시스템 정도로 이해해주면 되겠다.

 

언리얼에서는 해당 시스템을 좀 더 "디테일하게" 나눠서 판단을 하도록 해두었는데

 

이번에 회사에서 작업하면서 해당 시스템 관련 문제를 하나 마주쳤다.

 

 

 

 

언리얼 트레이스는 크게 2가지로 나뉘어진다.

Single Trace

Multi Trace

 

Single Trace는 "히트한 단 한개의 타겟"에 대한 결과만을 리턴

Multi Trace는 "목적지까지 다다랐을때 히트한 모든 타겟" 에 대한 결과를 리턴한다.

 

히트한 결과는, "HitResult" 라는 자료형/배열로 리턴되며, Trace 자체가 히트했는지는 각 함수의 리턴값을 통해 알 수 있다.

 

그런데 여기서 함정.

 

HitResult가 있더라도 Trace 함수의 Hit 결과는 False가 나올 수 있다.

 

이게 무슨소리냐

 

 

이걸 설명하기 위해서는 언리얼 콜리전 판정의 특이사항을 알 필요가 있다.

 

https://www.unrealengine.com/pt-BR/blog/collision-filtering

 

Collision Filtering in Unreal Engine 4

Choosing what collides is obviously very important, but it can be tricky, and it’s a problem that we have spent quite a while discussing while developing UE4. The system we have can seem a little complex at first, but it is very powerful and consistent,

www.unrealengine.com

 

 

자세한 내용은 위의 링크를 참고하면 확실하게 볼 수 있으니, 필요한 설명만 하도록 하겠다.

 

 

언리얼의 콜리전은 총 3가지 타입으로 분리된다.

 

무시 (Ignore) / 오버랩 (Overlap) / 충돌(Block)

 

 

 

Ignore는 모든 충돌 방식을 Ignore

Overlap은 Overlap / Block을 Overlap (판정은 발생하나 지나침)

Block은 Block끼리 충돌

 

 

 Trace에서 추출되는 결과는, Overlap과 Block 2가지만 추출된다. (아무래도 Ignore는 충돌을 안하니까...)

그런데, Single Trace의 경우, "Overlap" 결과는 리턴되지 않는다.

 

이에 대해서 여러 자료를 좀 찾아보았으나, 공식 문서 상에서는 확답이 없어 분석해본 결과,

공식문서에서는

1. Trace 결과는 "Hit" 라는 표현을 사용한다.

2. 공식문서에서, SingleTrace에는 없는 "Overlap" 이라는 표현이 MultiTrace에서부터 등장한다.

 

라는 결론을 얻을 수 있었다.

 

 

 

이게 무슨말이냐, 예시를 들어 설명하자면...

 

 

 

우선 간단하게 테스트용 함수를 구성했다.

테스트용 채널은 Camera 채널을 이용.

 

SingleTrace를 이용하며, Hit 시 출력은 True, 아니라면 False가 나올것이다.

 

 

뒤의 파란 블럭은 Camera 채널을 Block, 앞의 빨간 블럭은 채널을 Overlap으로 지정했다.

 

 

그리고....

 

 

빨간색 가까이에 가면 Hit Result가 False

 

 

 

파란색의 경우, 화살표 위치에서처럼 Hit 결과가 보이면서 Hit가 True로 나온다.

 

 

 

 

 

그럼 Overlap된 액터는 검출이 불가능한가?

 

그건 또 아닌게, 위에서 말한것과 같이 MultiTrace의 경우에는 Overlap이 검출이 된다.

 

 

 

 

Trace를 Multi로 변경했다.

 

 

 

아무것도 없으면 Hit Result False, Hit 0

 

 

Overlap만 있으면 Hit Result : False, Hit 1

 

 

Block이 있다면 Hit Result : True, Hit 1

 

 

즉, 결과를 요약하면 다음과 같다.

 

 

Single Trace Hit Result (히트 결과) Out Hit (히트 액터 검출)
Ignore False False
Overlap False False
Block True True
Multi Trace Hit Result (히트 결과) Out Hit (히트 액터 검출)
Ignore False False
Overlap False True
Block True True

 

=
Overlap 설정으로 충돌한 (채널/타입) 경우에는 Hit Event Result (Trace의 성공/실패여부) 를 변화시키지 않는다.
SingleTrace의 경우, Overlap만 발생한 경우에는 Hit Event Result가 False로 발생하면서 결과물이 리턴되지 않음.
MultiTrace의 경우 Overlap이 걸려도 HitResult가 채워지나, Hit Event Result는 Block으로 끝나지 않는 한 False로 리턴된다.

 

 

 

왜 Overlap에만 이런 특이한 경우가 반영되었는지는 (공식문서상 확답이 없어서) 잘 모르겠지만... 

작업할때 참고할 필요가 있을것 같다.

 

+ Recent posts