블루프린트와 C++ 의 조화

블루프린트/C++ 를 간이 사용하는 게임을 만드는 법, 그 진행 과정에서 의사 결정법을 설명합니다.

Choose your operating system:

Windows

macOS

Linux

필요한 사전지식

이 글은 다음 주제에 대한 지식이 있는 분들을 대상으로 합니다. 계속하기 전 확인해 주세요.

게임의 전반적인 기술 디자인을 할 때 주요 의문점은 보통 블루프린트로는 무엇을 구현하고 C++ 로는 무엇을 구현할지가 됩니다. 이 문서의 목표는 이러한 의문점에 대한 해답을 찾는 방법을 논하고 탄탄한 데이터 기반 게임플레이 시스템 구축법에 대한 조언을 드리는 것입니다. 이 문서는 기본 프로그래밍 문서를 읽고 더 많은 것을 배우고자 하는 프로그래머와 테크니컬 디자이너를 위한 것입니다. 게임 설계에 단 하나의 "정답"은 없지만, 이 문서가 올바른 질문을 하는 데 도움이 되었으면 합니다.

게임플레이 로직 및 데이터

대체로 게임의 모든 것은 Logic (로직)과 Data (데이터)로 나눌 수 있습니다. instruction (인스트럭션)과 structure (구조체)에 게임의 일정 부분이 따르는 것이 게임플레이 로직이고, 그 로직에 사용되어 게임의 역할을 규정하는 것이 게임플레이 데이터입니다. 이러한 구분이 명확한 경우라면, C++ 코드로 화면에 캐릭터를 그리는 것은 명백히 로직 기반인 반면 캐릭터의 물리적 외형은 명확히 데이터 기반입니다. 그러나 실제로 이러한 구분이 서로 섞이게 되면서 프로젝트가 복잡해지므로, 어떤 기준으로 구분할지 그리고 어떤 옵션이 있는지 이해하는 것이 중요합니다.

언리얼 엔진 4 (UE4) 안에서 게임플레이 로직을 구현할 수 있는 옵션은 다음과 같습니다.

  • C++ 클래스: C++ 로 변수와 함수를 정의하고 베이스 게임플레이 로직을 구현합니다.

  • 블루프린트 클래스: 블루프린트 이벤트 그래프 로, 또는 그 그래프에서 호출하는 함수 로 로직을 구현할 수 있습니다. 여기서 변수를 추가할 수도 있습니다.

  • 커스텀 시스템: 여러 시스템과 게임에 작은 "마이크로 랭귀지"를 사용하여 게임플레이 로직 일정 부분을 규정합니다. UE4 머티리얼 에디터 , 시퀀서 트랙 , AI 비헤이비어 트리 모두 게임플레이 로직을 위한 커스텀 시스템 예제입니다.

데이터의 경우 옵션이 더 많습니다.

  • C++ 클래스: 네이티브 클래스 생성자에서 기본값을 설정하고 데이터 상속을 지원합니다. 함수 로컬 변수에 데이터를 하드코딩할 수도 있지만, 추적하기 어렵습니다.

  • 구성 파일: Ini 파일과 콘솔 변수 는 C++ 생성자에서 선언한 데이터 오버라이드를 지원하거나 직접 쿼리할 수도 있습니다.

  • 블루프린트 클래스: 블루프린트 클래스 디폴트 작동 방식은 C++ 클래스 생성자와 비슷하며 데이터 상속을 지원합니다. 함수 로컬 변수 또는 핀 리터럴 값으로 데이터를 안전하게 설정할 수도 있습니다.

  • 데이터 애셋: 인스턴싱할 수 없고 데이터 상속이 필요치 않은 오브젝트의 경우, 독립형 Data Asset (데이터 애셋)이 Blueprint Default (블루프린트 디폴트)보다 사용하기 쉽습니다.

  • 테이블: 데이터는 데이터 테이블 , 커브 테이블로 임포트하거나, 런타임으로 읽을 수 있습니다.

  • 인스턴스 배치: 데이터는 레벨 또는 다른 애셋 안에 설정된 블루프린트 또는 C++ 클래스 인스턴스로 저장할 수 있으며, 클래스 디폴트를 오버라이드합니다.

  • 커스텀 시스템: 로직과 마찬가지로 데이터 저장을 위한 커스텀 방식은 많습니다.

    세이브 게임: 런타임 세이브 게임 파일을 사용하여 위 데이터 유형을 오버라이드 또는 수정할 수 있습니다.

일반적으로 위 목록보다 한층 더 파생되는 옵션은 위의 베이스 레벨 옵션을 오버라이드 및 확장합니다. 그래서 베이스 레벨 시스템이 확장 시스템에서 정의한 것에 액세스하고 사용하는 것이 어렵습니다. 예를 들어 C++ 클래스에서 파생된 블루프린트 클래스가 추가한 변수에 액세스하는 것은 매우 어려워 권장하지 않습니다. 이와 같은 문제를 피하려면 함수와 변수 정의를 액세스가 자주 일어나는 가장 베이스 레벨에서 해야 합니다. 로직의 경우, 전체 구현을 베이스 레벨에서 하거나, 베이스 레벨에 스텁 함수를 두고 파생 레벨에서 오버라이드하는 것이 이치에 맞습니다.

데이터에 대한 규칙은 조금 복잡하고 시스템에 따라 달라지는데, 가능성이 다양하고 다단계 상속이 흔하기 때문입니다. 변수에 대한 기본값은 정의한 그 레벨에서 설정하고, 이후 파생되는 레벨에서 오버라이드해야 합니다. 또한 커스텀 규칙에 따라 한 오브젝트에서 다른 오브젝트로 데이터를 복사하여 로직을 작성하는 것이 일반적입니다. 아래는 데이터 구조 관련 자주 발생하는 이슈에 대한 설명입니다.

C++ vs 블루프린트

위 목록에서 C++ 또는 블루프린트 클래스로 로직 및 데이터를 저장할 수 있음을 알 수 있습니다. 이러한 유연성으로 인해 대부분의 게임플레이 시스템은 둘 중 하나로 (또는 약간 조합해서) 구현합니다. 모든 게임과 개발 팀은 고유해서 "올바른 선택"이란 것은 없지만, C++ 또는 블루프린트 중에서 무엇을 선택할지 도움이 되는 일반적인 지침은 있습니다.

C++ 클래스 장점

  • 빠른 런타임 퍼포먼스 : 일반적으로 C++ 로직은 블루프린트 로직보다 훨씬 빠릅니다. 이유는 다음과 같습니다.

  • 명확한 디자인 : C++ 에서 변수나 함수를 노출하면 세밀한 제어를 통해 원하는 것을 정확히 노출할 수 있으므로, 특정 함수/변수를 보호하고 클래스의 공식 "API"를 만들 수 있습니다. 따라서 지나치게 크고 따라잡기 어려운 블루프린트를 만들지 않아도 됩니다.

  • 광범위한 액세스 : C++ 에서 정의(하고 제대로 노출)한 함수와 변수는 다른 모든 시스템에서 액세스할 수 있어, 여러 시스템 사이 정보 전달에 완벽합니다. 또한, C++ 에는 블루프린트보다 많은 엔진 함수 기능이 노출되어 있습니다.

  • 더 많은 데이터 제어 : C++ 에서는 데이터 저장과 로드 관련해서 구체적인 함수 기능을 더 많이 사용할 수 있습니다. 버전 변경 및 시리얼라이즈 처리를 다양한 사용자 지정 방식으로 처리할 수 있습니다.

  • 네트워크 리플리케이션 : 블루프린트의 리플리케이션 지원은 간단하며 작은 게임이나 고유한 일회성 액터에 사용하도록 설계되었습니다. 리플리케이션 대역폭이나 타이밍같은 것을 엄격하게 제어해야 하는 경우 C++ 를 사용해야 합니다.

  • 강력한 연산력 : 블루프린트로 복잡한 수학 연산을 하는 것은 어렵고 약간 느릴 수도 있습니다. 복잡한 수학 연산은 C++ 를 고려하세요.

  • 쉬운 Diff/Merge : C++ 코드와 데이터는 (구성 및 커스텀 솔루션을 포함해서) 텍스트로 저장되므로, 여러 브랜치에서의 동시 작업이 쉽습니다.

블루프린트 클래스 장점

  • 빠른 생성 : 대부분의 경우 블루프린트 클래스를 새로 만들어 변수와 함수를 추가하는 것이 비슷한 작업을 C++ 로 하는 것보다 빠릅니다. 그래서 완전 새로운 시스템의 프로토타입을 만드는 작업은 보통 블루프린트로 하는 것이 빠릅니다.

  • 빠른 반복처리 : 블루프린트 로직을 수정하고 에디터 안에서 미리보는 것이 핫 리로드 기능이 있더라도 게임을 다시 컴파일하는 것보다 훨씬 빠릅니다. 성숙한 시스템은 물론 새로운 시스템에서도 마찬가지이므로 "미세조정" 가능한 모든 값은 가급적 애셋에 저장해야 합니다.

  • 원활한 흐름 : C++ 로 "게임 흐름"을 그려 보는 것은 복잡할 수 있으므로, 보통 블루프린트로 (또는 딱 그 용도로 설계된 비헤이비어 트리같은 커스텀 시스템으로) 구현하는 것이 낫습니다. 딜레이 및 비동기 노드는 C++ 델리게이트보다 흐름을 따라잡기 훨씬 쉽습니다.

  • 유연한 편집 : 별도의 기술 훈련을 받지 않은 디자이너와 아티스트도 블루프린트를 생성하고 편집할 수 있으므로, 엔지니어 이외에도 수정해야 하는 애셋은 블루프린트가 이상적입니다.

  • 쉬운 데이터 사용 : 블루프린트 클래스 안에 데이터를 저장하는 것은 C++ 안에 저장하는 것보다 훨씬 간단하고 안전합니다. 블루프린트는 데이터와 로직이 밀접하게 섞인 클래스에 좋습니다.

블루프린트에서 C++ 로 변환

블루프린트는 생성 및 반복처리 작업이 쉬우므로, 블루프린트에서 프로토타입을 만든 후 그 기능의 일부 또는 전부를 C++ 로 옮기는 것이 일반적입니다. 보통 시스템의 기본 기능을 입증했고 다른 사람들이 원활하게 사용할 수 있도록 "강화"하고자 하는 "리팩터링 지점"에서 이 작업을 하는 것이 좋습니다. 이 시점에서, 어떤 클래스, 함수, 변수는 C++ 로 옮기고 어떤 것은 블루프린트에 남겨둘지 결정해야 합니다. 그 결정을 내리기 전, C++ 로 리팩터링하기 위해 거쳐야 하는 프로세스를 이해하는 것이 좋습니다.

일반적으로 첫 단계는 블루프린트 클래스가 상속할 "베이스" C++ 클래스 세트를 만드는 것입니다. 게임의 베이스 네이티브 클래스를 만들었으면 프로토타입 블루프린트의 부모를 새로운 네이티브 클래스로 변경합니다. 그 작업을 완료하면 블루프린트 클래스의 변수와 함수를 네이티브 C++ 로 옮기기 시작할 수 있습니다. 네이티브 클래스의 변수 또는 함수가 블루프린트 변수와 유형 및 이름이 같다면, 블루프린트를 로드할 때 그 레퍼런스가 자동 변환됩니다. 하지만 블루프린트로의 외부 레퍼런스가 네이티브 베이스 클래스를 가리키도록 변경하고 싶을 수 있습니다. 예를 들어 ActionRPG 샘플 작업을 하는 도중, DefaultEngine.ini 파일에 다음과 같은 블록을 추가했습니다.

[CoreRedirects]
+ClassRedirects=(OldName="BP_Item_C", NewName="/Script/ActionRPG.RPGItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Potion_C", NewName="/Script/ActionRPG.RPGPotionItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Skill_C", NewName="/Script/ActionRPG.RPGSkillItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Weapon_C", NewName="/Script/ActionRPG.RPGWeaponItem", OverrideClassName="/Script/CoreUObject.Class")

위 블록은 코어 리디렉트 시스템을 사용하여 Blueprint BP Item C 로의 레퍼런스를 전부 새로운 네이티브 클래스 RPGItem 으로 변환합니다. OverrideClassName 옵션이 필요한 이유는 이제 UBlueprintGeneratedClass 가 아닌 UClass 임을 알리기 위해서입니다. 부모변경 및 수정을 처음 한 이후에는 느려진 블루프린트 컴파일 문제를 고치고 게임의 모든 블루프린트를 다시 저장해야 할 것입니다. 목표는 블루프린트 경고 없이 리팩터링을 완료하는 것으로, 그래야 새 이슈가 생겼을 때 추적이 쉽습니다.모든 것이 정상 작동하면 이제 수정 프로세스 도중 추가한 CoreRedirect 를 제거하고 ini 파일을 정리하면 됩니다.

퍼포먼스 고려사항

C++ 가 블루프린트에 비해 갖는 강점은 퍼포먼스입니다. 하지만 많은 경우 사실 블루프린트 퍼포먼스가 문제되지는 않습니다. 대체로 C++ 코드 한 줄을 실행하는 것보다 블루프린트의 개별 노드 하나를 실행하는 속도가 느리다는 것이 큰 차이점인데, 한 노드에서 한 번 실행하는 것은 C++ 에서 호출하는 것만큼 빠릅니다. 예를 들어 값싼 최상위 노드 몇 개에 값비싼 Physics Trace 함수를 호출하는 블루프린트 클래스가 있다면, C++ 로 변환해도 퍼포먼스 향상이 크지 않습니다. 하지만 블루프린트 클래스에 빽빽한 루프가 많거나 펼치면 노드가 수백개나 되는 중첩 매크로가 많은 경우라면, 그 코드는 C++ 로 옮길 것을 생각해야 합니다. 퍼포먼스 문제가 매우 심한 것은 Tick(틱) 함수입니다. 블루프린트 틱은 네이티브 틱보다 훨씬 느릴 수 있으므로, 인스턴스가 많은 클래스에는 절대 틱을 사용하지 말아야 합니다. 대신 타이머나 델리게이트를 사용하여 블루프린트 클래스가 필요할 때만 일을 하도록 해야 합니다.

블루프린트 클래스에 퍼포먼스 문제가 있는지 알아내는 가장 좋은 방법은 프로파일러 툴 을 사용하는 것입니다. 프로젝트의 퍼포먼스 상황을 파악하려면, 먼저 블루프린트 클래스의 퍼포먼스 스트레스가 심한 (적을 한 무더기 스폰하는 등의) 상황을 만든 뒤, 프로파일러 툴을 사용하여 프로파일을 캡처합니다. 프로파일러 툴을 사용하면 Game Thread Tick (게임 스레드 틱)을 자세히 분석해서 트리를 펼쳐가며 문제가 되는 블루프린트 클래스를 찾을 수 있습니다 (같은 클래스의 모든 인스턴스는 하나의 그룹으로 묶여 있으니, 참고하세요). 블루프린트 클래스 안에서, 시간이 걸리는 블루프린트 함수를 확인할 수 있습니다. 그 함수를 펼칩니다. 대부분의 시간이 Self 에서 발생한다면, 블루프린트 오버헤드로 인해 퍼포먼스 손실이 있는 것입니다. 하지만 대부분의 시간이 그 함수 안에 중첩된 다른 네이티브 이벤트에서 발생한다면, 블루프린트 오버헤드 문제는 아닙니다.

블루프린트 네이티브화 를 통해 이러한 문제를 줄일 수 있지만, 몇 가지 단점이 있습니다. 첫째, 쿠킹 워크플로를 변경하므로 쿠킹된 게임의 반복처리 작업이 느려질 수 있습니다. 또한, 네이티브화된 블루프린트의 런타임 로직은 보통 블루프린트와 다르므로 게임의 특성에 따라 다른 작동방식 또는 버그가 생길 수 있습니다. 대부분의 블루프린트 기능은 네이티브화에 완벽 지원되지만, 명확하지 않은 몇 가지 예외가 있을 수 있습니다. 마지막으로, C++ 를 직접 변환한 것에 비해 퍼포먼스 향상이 크지 않을 수 있습니다. 네이티브화가 퍼포먼스 이슈를 전부 해결하지는 못하지만, 해법이 될 수 있는지 조사해 볼 수는 있을 것입니다.

아키텍처 노트

블루프린트와 C++ 를 같이 사용하여 게임을 만들면, 게임의 규모가 커지고 복잡해짐에 따라 여러가지 문제가 생길 것입니다. 프로젝트가 커지기 시작할 때 유념해야 할 몇 가지 사항입니다.

  • 비싼 블루프린트로의 형변환 금지 : 블루프린트 클래스 BP_B 에서 BP_A 로 형변환( 또는 함수나 다른 블루프린트의 변수형으로 선언)할 때마다 해당 블루프린트의 로드 종속성이 생깁니다. 그러면 BP_A 가 커다란 스태틱 메시 4 개와 사운드 20 개를 참조하는 경우, 형변환이 실패한다 하더라도 BP_B 를 로드할 때마다 커다란 스태틱 메시 4 개와 사운드 20 개를 로드합니다. 바로 그 이유로 네이티브 베이스 클래스 또는 최소한의 블루프린트 베이스 클래스에 중요 함수와 변수만 정의하는 것이 필수입니다. 거기서 비싼 블루프린트를 자손 클래스로 만들어야 합니다.

  • 순환 블루프린트 레퍼런스 금지 : 순환 레퍼런스(, 즉 한 클래스가 다른 클래스를 레퍼런스하는 데 거기서 첫 클래스를 레퍼런스하는 경우)는 C++ 의 경우 헤더 파일이 있어서 문제가 되지 않습니다. 하지만 과도한 순환 블루프린트 레퍼런스는 에디터 로드 및 컴파일 시간을 악화시킬 수 있습니다. 위와 마찬가지로 비싼 자손 블루프린트로 형변환( 또는 변수 레퍼런스를 생성)하는 대신 C++ 클래스 또는 값싼 베이스 블루프린트 클래스로 형변환하여 개선할 수 있습니다.

  • C++ 클래스에서 애셋 레퍼런스 금지 : C++ 생성자에서 FObjectFinder FClassFinder 를 사용한 애셋 레퍼런스는 가능하지만, 가급적 피해야 합니다. 이런 식의 애셋 레퍼런스는 프로젝트 시작 시 로드되므로, 레퍼런스가 실제 필요치 않은 경우 로드 시간 및 메모리 이슈가 발생합니다. 또 생성자에서 레퍼런스된 애셋은 삭제나 이름변경도 쉽지 않습니다. 일반적으로 C++ 에서 특정 스태틱 메시 레퍼런스를 만드는 것보다는 Game Data (게임 데이터) 애셋 또는 블루프린트 유형을 조금 생성한 뒤 애셋 매니저나 구성 파일을 사용하여 로드하는 것이 좋습니다.

  • 스트링으로 애셋 레퍼런스 금지 : C++ 클래스에서 애셋을 로드할 때 발생하는 이슈를 피하려면, C++ 의 LoadObject 같은 함수로 디스크의 특정 애셋을 수동 로드할 수 있습니다. 하지만 이 레퍼런스는 쿠커가 전혀 추적하지 못하므로 패키지 게임에서 문제가 생길 수 있습니다. 그래서 대신 C++ 클래스에서 FSoftObjectPath 또는 TSoftObjectPtr 유형을 사용하고, ini 또는 블루프린트 클래스에서 설정한 뒤, 요청 시 로드 또는 비동기 로드를 통해 로드해야 합니다.

  • 사용자 구조체 및 열거형 주의 : C++ 에서 정의한 열거형과 구조체는 C++ 에서도 블루프린트에서도 사용할 수 있지만, 사용자 구조체/열거형은 C++ 에서 사용할 수 없고 세이브 게임 섹션의 설명대로 수동 고칠 수도 없습니다. 보통 게임플레이 로직을 조금씩 더 많이 C++ 로 옮기게 되므로, 중요한 열거형과 구조체는 C++ 에서 구현하는 것이 좋습니다. 기본적으로 블루프린트 둘 이상이 사용하는 것이면 아마 네이티브 C++ 로 구현해야 할 것입니다.

  • 네트워크 아키텍처 고려 : 게임의 네트워크 아키텍처 특성은 클래스 구조에 큰 영향을 줍니다. 일반적으로 프로토타입은 네트워킹을 염두에 두고 만들지 않으므로, 뭔가 "진짜로" 리팩터링을 시작할 때 어떤 액터가 어떤 데이터를 리플리케이트할지 고려하기 시작해야 합니다. 반복처리를 어렵게 만드는 부분에 대한 결정을 잘 내려야 리플리케이트되는 데이터의 원활한 흐름을 만들 수 있습니다.

  • 비동기 로드 고려: 게임의 크기가 커지면 처음에 모든 것을 로드하던 것에서 필요할 때 애셋을 로드하도록 해야 합니다. 이 시점에 도달하면, 하드 레퍼런스 소프트 레퍼런스 또는 PrimaryAssetId (프라이머리 애셋 ID)로 변환하기 시작해야 합니다. AssetManager (애셋 매니저)는 애셋 비동기 로드를 손쉽게 해주는 함수를 여럿 제공하며, 로우 레벨 함수를 제공하는 StreamableManager (스트리머블 매니저)도 노출되어 있습니다.

언리얼 엔진 문서의 미래를 함께 만들어주세요! 더 나은 서비스를 제공할 수 있도록 문서 사용에 대한 피드백을 주세요.
설문조사에 참여해 주세요
취소