조이 웨이는 키프로스에 소재한 회사로, VR 게임을 개발 및 퍼블리싱하고 있습니다. 설립한 지 6년이 되는 이 회사에서 저희는 PC와 SteamVR, Meta Quest, PlayStation VR, Pico 등 다양한 플랫폼을 위한 독립형 VR 프로젝트를 다수 개발하였습니다.
액션 파쿠르 VR 게임인 스트라이드(STRIDE) 개발은 제작팀에게 있어 상당한 도전이었으며, 특히 크로스 플랫폼 플레이를 지원하는 멀티플레이어 모드의 개발이 어려웠습니다. 여러 플랫폼과 다양한 입력 디바이스, 피직스 기반 로코모션, 정확한 히트 등록 등 수많은 작업을 처리해야 했기 때문입니다.
이번 블로그에서는 스트라이드의 멀티플레이어 게임 모드 개발 과정에서 마주쳤던 난관을 처리한 솔루션에 대해 다뤄봅니다.
이 블로그는 언리얼 엔진을 활용한 크로스 플랫폼 게임 개발 및 커스텀 백엔드 서비스와의 연결 가이드로 활용할 수 있습니다. 이 블로그는 C++와 블루프린트를 사용하여 작업하는 언리얼 프로그래머 여러분께 유용합니다.
플랫폼별 코드 작성하기
크로스 플랫폼 게임 개발에서 모든 개발자들이 처음으로 마주하는 난관은 바로 플랫폼별 코드 작성의 필요성입니다.
첫 번째이자 가장 범용적인 사례는 플랫폼별 플러그인 및 모듈을 활용하는 것입니다. 언리얼 엔진에서는 플랫폼을 각 모듈별로 화이트리스트 및 블랙리스트 처리할 수 있습니다.
그리고 플러그인에 대해서도 BlacklistPlatforms, SupportedTargetPlatforms 및 WhitelistPlatforms를 사용하여 동일하게 처리할 수 있습니다. 이때 하나 또는 여러 개의 파라미터를 선택할 수 있습니다.
모듈을 C++ 종속성으로 사용하기 위해 플랫폼을 화이트리스트 또는 블랙리스트 처리한 후에는 모듈명을 PublicDependencyModuleNames 및/또는 PrivateDependencyModuleNames에 포함시켜야 합니다. 이 시점부터 작업이 복잡해집니다.
먼저, 일부 플랫폼에서 지원되지 않는 모듈을 제외해야 합니다. 가장 직관적인 방법은 모듈 .Build.cs 파일에서 if/else 구문을 사용하는 것입니다.
스트라이드를 개발하면서 타깃으로 삼은 VR 플랫폼 중 Pico 및 Meta Quest가 Android 기반이라는 문제가 있었습니다. 이로 인해 몇 가지 문제가 발생했습니다. 즉, 이때부터 더 이상 언리얼 플랫폼의 환경설정을 사용하여 Pico와 Meta Quest의 교차 환경설정 값을 분리할 수가 없게 된 것입니다. 기본적으로 코드에서는 어떤 플랫폼에 있는지 확인할 수 없습니다. 여기에 대한 솔루션은 다음과 같았습니다.
환경설정 값 문제는 언리얼 빌드 툴(Unreal Build Tool, UBT)에 -ini 인수를 주입하여 해결할 수 있었습니다. 이것을 CI/CD 빌드 스크립트에서 구현하고 -ini 환경설정을 언리얼 자동화 툴(Unreal Automation Tool, UAT)로 전달하여 환경설정을 오버라이드할 수 있습니다. 이 경우 다음 포맷으로 전달하게 됩니다.
환경설정 주입에 대한 자세한 내용은 여기에서 확인하실 수 있습니다. 또한 언리얼 자동화 툴을 수정하여 주입된 환경설정 값을 패킹된 게임에 적용하는 방법도 있습니다. 자세한 내용은 여기에서 확인하실 수 있습니다.
이제부터 환경설정 값을 UAT로 전달하여 어떤 플랫폼에 있는지 확인할 수 있습니다.
다음 단계는 Pico 및 Meta Quest 디바이스에 대한 플랫폼 정의를 추가하는 것입니다. 타깃 규칙의 GlobalDefinitions와 모듈 규칙의 PublicDefinitions 간의 차이에 주의하세요. 기본적으로 UAT는 두 빌드를 제작합니다. 하나는 바로 빌드 중 커맨드릿을 실행하기 위한 에디터 빌드이고 다른 하나는 타깃 플랫폼 빌드입니다. GlobalDefinitions는 타깃 전체에 대한 정의입니다. 즉, GlobalDefinitions에 Pico/Meta Quest 플랫폼 정의를 넣는다면 커맨드릿 실행에 필요한 에디터 빌드에 필요 없는 플랫폼 정의까지 갖춰진다는 것을 의미합니다. 이런 사항을 고려한다면 모듈 규칙의 PublicDefinitions에 플랫폼 정의를 배치하는 것이 낫습니다. 이렇게 하려면 다음과 같이 하면 됩니다.
이제 익숙한 프리프로세서 디렉티브를 사용하여 C++코드에서 어떤 플랫폼에 있는지 확인하고 BlueprintFunctionLibrary를 통해 플랫폼 체크를 블루프린트에 노출시킬 수 있습니다.
게임과 커스텀 백엔드 서비스 연결하기
이 부분에서는 백엔드 서비스 통합에 대한 접근 방식에 대해 다룹니다. 스트라이드의 백엔드는 다수의 HTTP API와 WebSocket API 하나로 구성되어 있습니다. HTTP 요청과 WebSocket 이벤트를 구현하는 것은 매우 간단하기 때문에 여기서는 비동기 호출 체인에 대한 접근 방식에 초점을 맞추려고 합니다.
스트라이드에서의 크로스 플랫폼 허브 위치
처음에는 응답에 대한 람다 함수 콜백을 사용하여 API 호출을 구현하는 것부터 시작했습니다. 하지만 곧 수많은 거대한 중첩 호출이 생겨 버렸고, 관련 코드를 유지보수하는 것도 상당히 어려워졌습니다. 그래서 UBlueprintAsyncActionBase마다 요청을 따로 만들기로 결정했습니다.
블루프린트에서 UBlueprintAsyncActionBase라는 자동 생성 노드를 사용하는 것은 매우 직관적입니다. 프로그래밍 가이드는 여기에서 확인하실 수 있습니다.
하지만 아직 해결해야 할 중요한 문제가 남아 있었습니다. 바로 이 노드를 어디에서 호출해야 하는지가 그것이었습니다. 일부 사례의 경우 인게임 엔티티에서 호출할 수도 있는데 꽤 훌륭한 배치이긴 합니다. 하지만 게임 인스턴스 레벨에서의 호출이라면 어떨까요? 우리의 솔루션은 «Worker» 엔티티에 대한 확장 UObject를 사용하는 것이었습니다.
Worker는 UObject 파생 클래스로, GameInstanceSubsystems를 사용하는 라이프 사이클을 제어하는 용도입니다. 프로그래밍 서브시스템에 대한 자세한 내용은 여기에서 확인하실 수 있습니다. 굳이 GameInstanceSubsystems를 꼭 사용할 필요는 없습니다. 그보다는 LocalPlayerSubsystems를 사용하는 것이 더 나은 솔루션이 될 수도 있습니다.
Worker는 호출 체인의 유지보수를 매우 직관적으로 만들어 줍니다.
이제 UObject의 확장에 대해 다뤄보면서 놓치기 쉬운 팁을 공유하겠습니다.
우선 확장이 필요한 것은 단 하나입니다. 바로 WorldContext를 사용하여 글로벌 함수를 호출할 GetWorld입니다. 다음은 Worker GetWorld 오버라이드의 코드 샘플입니다.
CDO에 대한 첫 체크를 주목해 주세요. 왜 이렇게 했는지 알게 될 것입니다.
다음은 Worker의 생성 코드 샘플입니다.
위의 코드를 요약해보겠습니다. UObject에서 상속하고, GetWorld를 오버라이드하고, C++ Worker에서 상속하고, 마지막으로 Worker의 라이프 사이클 코어에서 적절한 오브젝트를 인스턴싱합니다. 다음은 블루프린트에서 클래스를 구해 적절한 오브젝트를 인스턴싱하는 방법입니다.
이제 CDO 관련 내용입니다. 보시는 것처럼 TSubclassOf 변수를 GameInstanceSubsystem 생성에 할당했습니다. 여기서 CDO를 체크하지 않으면 문제가 발생할 수 있습니다. 스트라이드 개발 팀도 CDO 체크를 하지 않았다가 에디터 크래시와 주요 에셋 시스템 트러블 같은 문제를 경험한 적이 있습니다. 아마 여러분도 경험했을 수 있습니다.
각 요청을 분리하고 요청 체인을 블루프린트 그래프에 노출시킴으로써 '스파게티 코드'를 해소하는 데 도움이 되었으며, 백엔드 관련 코드의 유지보수가 훨씬 쉬워져 반복작업은 보다 빨라졌고 버그는 줄었습니다.
조이 웨이의 게임과 VR 게임 개발의 비하인드 스토리에 대해 자세히 알아보고 싶다면 조이 웨이의 Twitter를 팔로우하고 Discord 서버에 참여해 보세요.
지금 언리얼 엔진을 다운로드하세요!
세계에서 가장 개방적이고 진보된 창작 툴을 받아보세요.
모든 기능과 무료 소스 코드 액세스를 제공하는 언리얼 엔진은 제작에 바로 사용할 수 있습니다.