-
[Unreal] Unreal Engine 에서 아이템 만들기 일지쾌락없는 책임 (공부)/Unreal 2025. 8. 18. 22:58반응형
개요
해당 글은 언리얼 엔진에서 '생존게임' 장르를 만들면서 겪었던 과정들을 이야기하는 글입니다. 일기성의 글이고 생존게임의 아이템은 겉으로 보이는 거라고는 무기 정도만 구현되어서 상황에 따라 개개인의 프로젝트에 맞지 않는 구조를 선택하기도 합니다. 때문에 재미 정도로만 참고해 두는 게 좋습니다.
[ 1 ] 아이템은 전부 UObject Class로, 이걸 다 리플리케이션 하자!
일단 제가 FPS 게임 개발을 해서 그런지'아이템=클래스'라는 인식을 가지고 있었습니다. 'FPS에서 무기들은 전부 Actor 기반으로 만들어지게 되는데 이건 너무 무거우니 UObject 상속으로 하자! ' 라며 일말의 양심을 챙긴 정도입니다. 멀티 요소들도 생각하고 있으니 액터 터채널에 붙여 리플리케이트 되는 UObject를 가정하고 제작할 예정이었습니다.
그런데 생존게임에는 아이템이 상당히 많이 필요하게 됩니다. 작게는 몇백 개, 크게는 몇천, 몇만 개가 로드되기도 합니다. 한 씬에 모든 게 있지는 않겠지만 서버의 입장에서는 이 많은 아이템의 정보들을 물고 있어야 합니다. 이로 인해 아이템이 '있다'는 걸 위해서 몇천 개의 UObject를 리플리케이트 해야 하는 일이 생기게 되는 것이죠. 이때 아이템의 개수를 생각해 보고는 '앗차' 싶었습니다. 경험이 부족해서 보이는 실수였거든요.
그 외 단점으로는 '소유권'에 대해서 계속되는 고민을 해줘야 한다는 것입니다. 아이템은 UObject입니다. 멀티플레이에서 Actor처럼 따로 채널이 있는 게 아니기에 플레이어나 다른 Actor에 붙어야만이 리플리케이트가 됩니다. 때문에 이 경우로 인해 아이템을 획득하거나 버릴 때 소유권의 이전이 굉장히 복잡해집니다. 각기 다른 클래스들에 아이템 클래스를 서브오브젝트로 등록해 주고 해제해 주는 등... 여러 가지 복잡한 상황이 펼쳐졌고, 아이템의 활용 시나리오가 다양해질 때마다 코드는 점점 더러워졌습니다.
이렇게 첫 시도인, 아이템을 전부 클래스로 만든다는 생각을 버리게 되었습니다.
[ 2 ] 리플리케이트 되는 정보를 간소화, 서버와 클라이언트는 각자 아이템 클래스를!
위에서 한 번의 '앗차'를 겪은 뒤 잠시의 고민을 해봤습니다. 오래 고민해보지는 않았던 거 같습니다. 사이드 프로젝트의 진도도 중요하거든요.
한 번의 고민으로 더 이상 아이템 클래스를 리플리케이트 하지 않게 되었습니다. 아이템의 정보들 자체는 데이터 테이블에 저장되어 있습니다. 이건 클라이언트/DS 둘 다 가지고 있는 정보죠. 그러면 '필요한 정보' 들만 리플리케이트를 해주는 것입니다. 예를 들면 제조일자(음식을 위해)나 내구도 같은 요소들이죠.
그래서 대략적으로 아래와 같은 코드가 나오게 되었습니다.
struct FItemEncodedInfo { FName ItemKey; int32 ItemEncodedInfo; }여기서 ItemEncodedInfo라는 int32 형 자료를 추가했습니다. 아이템에 필요한 정보는 내구도/제조일자가 현재 상태인데 이 둘을 따로 int32로 선언해 두어도 되지만 뭔가 메모리 한 줄을 아끼고 싶었습니다. 마침 이 시기에 메테리얼 관련 강의를 들으니 Vector4 형에 온갖 변수들을 다 넣는 걸 보기도 했죠.
뭐 크게 압축한 것은 아니고 int32 형이 대략 21억 얼마, 10 진수로 10자리 정도 되니 앞 3자리를 내구도, 뒤 7자리를 (년, 월, 일)로작성하기로 했습니다. 물론 년도는 4자리가 필요한데 이는 게임 시작 연도를 알고 있으니 여기서부터 몇 년 지났는지로 (혼자서) 협의했습니다. 게임 시간으로 1000년이 지난다면... 그때는 생각해두지 않았지만요.
아무튼 이렇게 아이템 정보를 간략화했으니 이 정보들만을 리플리케이트 하면 네트워크 대역폭을 줄일 수 있을 거라 생각했습니다. 하지만 여기서 간과한 점은 대부분의 기능들이 아이템 클래스에 있는 채로 리팩토링을 했다는 것입니다. 이로 인해 서버와 클라이언트는 아이템 정보를 통해 각각 클래스를 만든다는 괴랄한 구조가 만들어 졌습니다. 어차피 정보는 리플리케이트 되는 FItemEncodedInfo에 있으니 기능만을 하는 클래스를 남겨둔 것이죠.지금에야 와서 보면 ‘왜 리팩토링을 한다면서 쓰지도 않을 클래스는 그대로 뒀냐’라고 물어볼 수 있지만 이때 새로운 기능을 본다고 너무 혈안이 된 나머지 아이템 리팩토링은 금세 건너뛰게 되었습니다. ‘사이드 프로젝트는 길게 길게 하는 거니깐~’이라는 생각과 함께 말이죠.
이때의 구조는 만들 당시에도 괴랄하다 생각했습니다. ’ 아이템 정보를 리플리케이트 하면 DS/Client에서 각각 아이템 클래스를 만들기‘ 였는데 말하자니 좀 부끄럽네요. 많은 우려점이 있는 구조였으니 ’ 잘못되었다 ‘라고만 이야기를 하고 빠르게 넘기도록 하겠습니다. 부끄럽거든요.[ 3 ] 현재의 모습, 어빌리티를 활용해 아이템 클래스를 제거
그리고 사이드 프로젝트는 생각보다 길지 않았고 아이템 리팩토링을 또다시 해야 할 시기가 왔습니다. 2번의 리팩토링을 지난 뒤 아이템 클래스는 디렉토리에서 악취만 풍길 뿐 실제 쓰임새는 없이 점점 썩어만 갔습니다. 결국 그날이 오고야 말았습니다. ‘리팩토링’. 그래도 회사에서는 시간 없어서 못하는데 사이드 플젝에서는 가능하잖아요?
지금의 목표는 이전의 상황보다 명확했습니다. ‘아이템 클래스를 제거하고 어빌리티를 통해 아이템을 사용하자’. 아이템 클래스에서 하는 일을 어빌리티로 위임하면 어빌리티는 아이템 종류에 따라 애니메이션, 사용을 다르게 하면 된다는 생각으로 구조를 잡았습니다.
이런 생각이 났으니 어빌리티를 먼저 제작합니다. 사이드 프로젝트에서 어빌리티는 입력에 따라 활성화 가능하도록 했습니다. 여기서 ‘아이템 사용’이라는 어빌리티가 활성화되면 현재 손에 지닌 아이템의 분류에 따라 각 분류에 맞는 ‘아이템 사용 어빌리티’를 실행하는 것입니다. 소비 아이템을 쥐고 있다면 섭취를, 무기 아이템을 가지고 있으면 공격을 하도록 했습니다. 이 과정에서 아이템의 사용 로직은 전부 어빌리티로 들어오게 되었고 더 이상 아이템 클래스는 필요하지 않게 되었습니다.
당시 리팩토링 할 때 간단히 정리한 것 마지막으로 네트워크에서 FName을 리플리케이트 하지 않고 int32 형으로 암호화(?) 해서 리플리케이트 하는 걸 진행하였습니다. 게임 내 아이템 테이블에서는 구분 인자가 Weapon_001, Consume_003 이런 식으로 되어 있습니다. 규칙을 {아이템 분류}_{넘버링 3자리}로 되어 있기 때문에 충분히 int32에 담을 수 있다고 생각했죠.
USTRUCT(BlueprintType) struct FItemEncodedInfo { private: /** Item Key 를 담는 uint32 형의 타입 */ UPROPERTY(EditAnywhere) uint32 ItemKeyEncoded; /** 아이템의 내구도와 제조일자를 담는 uint32 형의 타입 */ UPROPERTY(EditAnywhere) uint32 EncodedPercentageDate; private: static TOptional<FString> GetTypePrefixString(EItemType ItemType) { switch (ItemType) { case EItemType::Weapon: return FString(TEXT("Weapon")); case EItemType::Consume: return FString(TEXT("Consume")); case EItemType::Wearing: return FString(TEXT("Wearing")); default: return {}; } } public: TOptional<FName> GetItemKey() const { if (ItemKeyEncoded == 0) { return {}; } const EItemType ItemType = static_cast<EItemType>(ItemKeyEncoded / 1000); const int32 ItemID = ItemKeyEncoded % 1000; const TOptional<FString> TypePrefixOpt = GetTypePrefixString(ItemType); if (TypePrefixOpt.IsSet()) { const FString FormattedKey = FString::Printf(TEXT("%s_%03d"), *TypePrefixOpt.GetValue(), ItemID); return FName(*FormattedKey); } return {}; } };최대한 개인적인 욕심을 넣어서 만든 구조입니다. int32의 아이템 ID를 받아 FName을 TOptional로 반환하는 함수를 제작했습니다. TOptional 이냐 bool 반환에 참조 인자로 값을 반환하는 걸 고민했지만 좀 더 직관적일 수 있게 위 코드를 선택하게 되었습니다.
관련해서 FName -> uint32 로 변경하는 게 정말 좋았냐고 하면 애매합니다. 이 부분은 저의 욕심적인 부분들이 더 강하게 작용하고 있습니다. 관련해서 재미나이의 도움을 받아 어디가 더 효율적 일지 도움을 구해보았습니다.
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { SerializeIntPacked(Ar, ItemKeyEncoded); SerializeIntPacked(Ar, EncodedPercentageDate); bOutSuccess = true; return true; }FName의 경우 첫 리플리케이션이 될 때 FString을 한번 준 다음 그 이후로는 계속해서 uint32 형을 리플리케이트 하게 됩니다. 다만 여기서 에픽의 SerializeIntPacked 함수를 활용하면 4바이트를 풀로 사용하는게 아니라 필요한 부분들만 사용하기에 4바이트보다 적은 양만큼 리플리케이션 할 여지도 있는 것이죠.
이 때문에 FName의 대역폭은 한 번을 제외하고는 크게 신경 쓸 여지없이 최적화가 되어 있습니다. 그런데 문제는 메모리죠. 메모리에는 여러 아이템들이 올라가는데 이걸 계속 들고 있자니... 뭔가 마음 한편이 걸렸습니다. 아 물론 드라마틱한 최적화는 아니지만 일단 욕심내서 uint32로 확 줄이기로 결심한 것이죠. (재미나이가 개당 8바이트 차이라 하니 100만 개가 있으면 8MB 차이가 납니다)
때문에 위와 같은 구조체 변수들과 함수가 만들어졌고 최종적으로는 UItemBase 클래스에서 위 구조체로 단순화할 수 있었습니다. 다른 작업들을 거치면서 한 리팩토링이라 한 2달이 지나서 현재의 코드가 되었는데 나름대로 만족하고 있습니다.
이런 과정들을 거치며
사실 직관적이지는 않고 단순 네트워크 효율에 좋겠지?라는 생각으로 진행된 구조체라 함수가 많아지고 관련 변수들에 대해서 주석이 많이 늘어나게 됩니다. 추후 다른 개발자와 협업을 한다면 각 변수(암호화된 uint32) 들이 어떤 역할로 존재하는지를 잘 알아야지만 유지보수도 가능할 구조입니다. (주석으로 적어두겠지만 한눈에 보이지는 않죠)
하지만 이 프로젝트는 개인 프로젝트이며 아이템 관련된 정보들은 최소화를 해야지 생존 게임에서의 수많은 아이템들을 감당 가능할 것이라 생각했습니다. 이건 그 결과물이고 개인적으로는 충분히 만족하고 있습니다.
혹시 또 모르죠. 나중에 이게 또 복잡해서 다시 돌아갈지, 정보가 더 추가될지. 아무튼 해당 아이템을 만들어 가면서 회사에서 보는 FPS의 무기와 생존 게임에서의 아이템을 다르게 보는 법을 알아가게 되는 과정이었습니다.
반응형'쾌락없는 책임 (공부) > Unreal' 카테고리의 다른 글
[Unreal] Urneal MVVM plugin, UMG ViewModel 사용 및 후기 (1) 2025.08.31 [Unreal] Unreal Engine 에서 RAII 패턴을 통해 자동으로 함수에서 EndAbility 하기 (1) 2025.08.20 [Unreal] 언리얼 애니메이션 본 위치 옮기기 (0) 2025.07.20 [Unreal] Game Animation Sample Project 의 Attach 본을 넣기 (2) 2025.07.14 [Unreal] 언리얼 모션 매칭 Motion Matching #4 - Rewind Debugger, Chooser Table 디버깅 (0) 2025.07.04