서론
캡스톤 디자인 2팀의 프로젝트는 자체적으로 개발한 엔진을 이용하여 게임을 제작하는 것이다. 해당 문서는 현재까지 개발된 엔진의 기본적인 메모리 관리부터 실시간 게임 루프, 개발에 유용한 유틸리티 등을 보고한다.
목차
- 개발 환경
- 메모리 관리
- 레이어 기반 어플리케이션 루프
- Entity Component System
- 렌더링
- 리소스
- 직렬화
- GUI 기반 에디팅 시스템
- 빌드 시스템
- 앞으로 남은 과제
1. 개발환경
주 개발 언어는 C++ 20 을 사용하고 컴파일러는 MSVC 를 사용한다. 그래픽 API로는 DirectX 11 를 사용한다. 더하여 사용한 서드파티는 다음과 같다.
1) SDL3: 크로스 플랫폼 UI 및 미디어 라이브러리
2) Imgui: 즉시 렌더링 GUI
3) ENTT: 게임 루프 구성
4) nlohmann json: JSON 기반 직렬화 라이브러리
5) spdlog: 로깅 시스템
6) google flatbuffer: 바이너리 기반 직렬화 라이브러리
개발 OS는 Windows x64기반이며 크로스 플랫폼은 지원하지 않는다.
2. 메모리 관리
일반적으로 가변 길이 메모리의 경우 new / delete 를 사용하지만, 부지불식간에 할당, 해제되는 리소스는 고정 길이 메모리 풀을 이용하여 관리한다. 또한 상위 레벨에서는 참조 카운트 기반 자체적인 스마트 포인터를 제공한다.
고정길이 메모리 풀
힙 할당은 효율적이지만, 할당과 해제가 빈번한 루프 상황에서는 병합 비용이 체감될 정도로 크고 메모리 파편화 우려가 있다. 이러한 상황에 대처하기 위해 자주 사용되는 오브젝트에 대해 메모리를 미리 할당하여 따로 해제하지 않고 지속적으로 재사용하도록 한다.
캐시라인을 고려하여 4kb 정도의 힙 메모리를 미리 할당한뒤 고정 길이 (ex. 32byte) 로 자른뒤 메모리 요청이 들어오면 해당 블록의 포인터를 반환한다. 해제 요청이 들어오면 따로 메모리는 해제하지 않는다. 이러한 시스템을 고정길이 메모리 풀이라고 정의한다.
4kb 가 넘는 메모리 할당이 요구될 수도 있기 때문에, 필요에따라 동적으로 메모리 풀을 추가롤 할당하여 사용자 입장에서는 일반적인 힙 할당과 동일한 감각으로 사용할 수 있다. 메모리 풀마다 8byte의 링크를 추가하여 메모리 풀들 끼리는 연결 리스트 형태로 연결되어있고, 요청에 따라 순차적으로 파괴할 수 있다.
초기에는 힙 메모리를 모방하여 범용 메모리 풀을 개발하였으나 이 경우 메모리 사용량이 과하게 많이 증가하여 필요이상의 메모리를 할당하고 있는 문제가 발생했다. 그래서 범용 메모리의 경우 new delete 를 사용을 하고 할당 해제가 빈번한 객체만 메모리 풀을 이용하여 관리한다.
또 다른 문제점으로는 멀티스레드 환경에서의 critical section이 될 수 있다는 것인데, 스레드의 로컬 스토리지 마다 메모리 풀을 생성하면 이 문제를 해결할 수 있다.
참조 카운트 기반 포인터
메모리 관리를 편리하게 하기 위해 참조 카운트를 세어 0이 될 경우 자동으로 메모리 해제가 되도록하는 포인터의 얇은 래퍼이다. 언어 기반에서도 std::shared_ptr 라는 래퍼를 제공하나, 반드시 malloc free 를 사용해야하며 참조 카운트가 thread-safe 하기에 성능 저하가 발생한다는 단점이 있다. 싱글 스레드 기반 해당 엔진에서는 참조 카운트의 원자적 연산이 불필요하고, std::shared_ptr의 객체와 참조 카운터 블록간의 캐시 히트율이 떨어진다는 단점이 존재했기 때문에 직접 래퍼를 만들 필요가 있었다.
JSharedPtr은 카운터 블록과 객체간의 연속된 메모리를 보장하기 때문에 참조 카운트 간의 캐시 히트율이 매우 높고 참조 카운팅이 아토믹하지 않기 때문에 일반 std::shared_ptr 대비 30% 가량의 성능 향상을 이끌어 냈다. 앞서 설명한 메모리 풀과 결합할시 할당 및 해제 속도는 최대 8배까지 빨라진다.
3. 레이어 기반 어플리케이션 루프
어플리케이션에는 고정된 루프가 존재하고 Layer 인터페이스를 상속한 Layer 객체를 메인 어플리케이션에 부착하여 자연스럽게 루프에 간섭할 수 있다.
레이어 시스템
Layer란 프로그램의 서브 시스템 정도로 볼 수 있는데, 이런 서브 시스템기반 Layer 구조는 사용자가 원하는 방향으로 어플리케이션을 확장해나갈 수 있도록 한다. 기본적으로 엔진에서 제공하는 Layer로는 SceneLayer, EditorLayer 등이 있는데 SceneLayer는 게임루프를 제공하고 EditorLayer는 에디팅 시스템을 제공한다.
즉, 엔진 차원에서도 레이어를 확장하여 어플리케이션을 구축하고 사용자 입장에서도 레이어를 확장하여 여러가지 기능을 부착할 수 있다. 물리엔진을 위한 PhysicLayer를 부착할 수도 있고, 네트워크를 위한 NetworkLayer를 부착할 수도 있다.
레이어 간의 통신
Layer 들은 서로 이벤트를 발생시켜 통신한다. 원하는 데이터를 담은 이벤트 구조체를 최상위 어플리케이션에 전송하면 어플리케이션은 이벤트를 모든 레이어에 브로드캐스트한다. 각 레이어는 자신이 처리할 이벤트 리스너를 정의하고 임의의 이벤트를 수신했을 때 적절한 이벤트 리스너를 실행시킨다. 이러한 이벤트 기반 통신은 레이어가 서로의 존재를 몰라도 통신할 수 있다는 장점이 있다.
어플리케이션은 레이어 및 이벤트 기반 구조를 이용하여 항상 느슨한 결합을 유지하며, 이는 개발 유연성을 보장해준다.
4. Entity Component System
전통적으로 가장 애용된 게임 시스템은 Object Oriented Programming 을 기저에 둔 Object Component 구조이다. 이 구조는 Object 라는 상위 클래스를 상속하여 구체적인 클래스 내부에서 로직를 구성하고, Component라고 하는 여러 Object에서 재사용 가능한 요소들을 Object와 결합한다. Object는 Component 를 담은 컨테이너임과 동시에 스스로 게임 로직을 구성하는 요소이다.
그러나 이런 컴포넌트 객체는 메모리가 분산되어 있기 때문에 1초에 60~120회 루프를 돌아야하는 게임 시스템상 성능 저하가 발생할 수 있다. 캐시 히트라는 건 생각보다 성능을 엄청 좌우한다. 동일한 컴포넌트는 동일한 로직을 수행시키기 때문에 성능을 위주로 생각한다면 동일한 컴포넌트를 한 데 모아서 루프를 수행하는 것이 더 효과적이다. 이를 달성하기 위해서는 전통적인 Object Component 구조를 포기하고 다른 대안을 찾아야 한다.
Entity Component System은 이러한 문제에 대한 완벽한 해결책이다. Object가 Component의 컨테이너가 되는 것이 아니라 Component 는 각자 고유의 컨테이너를 가지고 있다. 해당 컨테이너는 메모리 상 연속적인 공간을 할당하여, 공간 지역성과 시간 지역성이 매우 높다. 이러한 특성 때문에 다형성을 사용하지 못하고 Component는 POD 타입에 가까운 구조체이다. Entity은 Component의 소유자를 가리키는 단순 정수이고 식별자 이외의 역할은 하지 않는다. System은 순수한 로직을 의미한다.
Entity Component System은 결국 Object Component 를 식별자 - 데이터 - 로직 3가지 순수한 단위로 나눈 것이다. 이렇게 각각을 분리하여 효율적인 게임 루프를 구성할 수 있고 사용자에게 원활한 사용감을 제공한다.
5. 렌더링
렌더링 API는 앞서 기술했듯 DirectX11을 사용한다. 그래픽스 API 특성상 복잡한 로직이 다수 존재하기에 얇은 래퍼로 감싼 퍼사드를 제공한다. DirectX11은 드라이버 수준에서 많은 최적화를 제공해주기 때문에 중복된 데이터에 대한 캐시를 제외하고는 특별한 최적화를 하진 않는다.
렌더링의 경우 사용자 의도대로 프로그래밍할 수 있도록 하기 위해 정해진 루프는 제공되지 않는다. 그러나 여타 게임 엔진과 동일하게 내부 구조를 잘 모르더라도 게임을 개발할 수 있도록 여러가지 헬퍼 혹은 built-in 된 렌더링 객체들을 제공한다.
Built-in 쉐이더
Built-in 된 그래픽스 알고리즘을 제공하기 위해서는 쉐이더 코드가 필수적이다. 일반적으로는 HLSL 파일 형태로 제공하며 파일을 로딩하여 컴파일 후 사용하는 것이 일반적이나, 이 경우 배포가 어렵다는 단점이 있다. 그래서 컴파일된 쉐이더의 바이너리 코드를 라이브러리에 포함시켜서 배포하는 방식을 선택했다.
HLSL을 변경할 때마다 바이너리 코드를 업데이트 해줘야한다는 단점이 있다. 이는 개발중인 HLSL을 추적하여 프로젝트를 빌드할 때마다 스크립트를 이용해 자동으로 컴파일하는 방법을 사용하여 해결하였다.
Physical Based Rendering

실제 물리현상을 모방하여 라이팅하는 기법이며 해당 엔진의 표준 쉐이더로 차용하고 있다. 배경이 방출하는 빛을 미리 계산하여 텍스처 형태로 저장하고, 그것을 이용하여 물리법칙에 의거한 빛 반사를 근사한다.
Post Process

이미 렌더링된 화면을 후처리를 이용하여 추가적인 효과를 제공하는 기법이다. Deferred Rendering 을 기반으로 동작하기 때문에 안티에일리어싱을 적용하기위해 FXAA 필터를 제공하고, 물리 기반 블러를 이용하여 자연스러운 빛 번짐, 발광 등을 구현한다.

추가적으로 안개 효과, 비네팅, 톤 매핑 등 영화적이고 엔터테이먼트 적인 후처리 효과도 이미지 필터 형태로 제공된다.
Lighting

전통 라이팅 기법은 오버헤드가 크기에 라이팅 볼륨을 이용하여 효율적으로 광원을 렌더링한다. 이 경우 광원을 1000개 이상 렌더링해도 실시간 게임 플레이에 문제없는 프레임 속도를 제공한다. 광원은 평행광, 점광, 스포트라이트 총 3종류를 제공하며 셋 다 직접적인 라이팅이 아닌 빛을 대변하는 지오메트리를 렌더링하여 빛 반사를 근사한다.
Shadow

쉐도우는 쉐도우 매핑 기반이며 광원의 종류 별로 1개씩만 만들어낼 수 있다. 렌더러가 Rasterizer 기반이기 때문에 그림자 렌더링에 상당한 오버헤드가 발생하기 때문에 제한할 수 밖에 없었다. 대부분의 겨우 평행광이 만들어내는 그림자가 가장 중요하기 때문에 극심한 오버헤드를 감수하고 무한정 그림자를 만들어내도록 구현할 이유는 없었다.
손전등같은 스포트라이트는 쉐도우 매핑을, 태양광같은 평행광은 레벨을 조정할 수 있는 Cascade Shadow Mapping을, 전구같은 점광에는 Omni-Directional Shadow Mapping 알고리즘이 수행된다.
Screen Space Ambient Occlusion

Deferred Rendering 을 기반으로 표준 쉐이더가 동작하기 때문에 차폐광을 저렴한 비용으로 도입할 수 있었다. 물체의 틈의 경우 빛이 도달하기 힘들기 때문에 오클루전이라는 그림자효과를 더하면 현실적인 느낌을 줄 수 있다. 실제로는 여러 번 Ray를 쏴서 계산해야하지만 G-buffer 데이터를 이용하여 근사할 수 있었다.
Pre Baked Texturing

빛과 그림자는 앞서 말했듯 비용이 매우 크기 때문에 미리 그림자를 계산하여 텍스처 매핑을 사용할 수 있다. 현재 이러한 텍스처링을 수행할 수는 있지만, 직접 굽는 기능은 제공되지 않는다. 이는 추후 개발할 과제로 남을 것이다.
앞으로 남은 과제
현재 과제로는 Rim light (테두리 효과), 애니메이션, 반사 효과, 반투명 효과등이 있다. 이는 본격적인 게임 개발을 시작하면서 점진적으로 개발할 계획이다.
리소스
초기에는 리소스를 미리 로딩하고 개발을 했지만, 메모리 공간은 한정되어 있기에 당장에 사용해야할 리소스와 아닌 리소스를 구분하여 필요할때 로딩하고 언로드할 필요성이 생겼다.
게임을 구성하는 기본적인 단위를 Scene이라고 부르는 데 Scene 별로 사용하는 리소스를 구분하여 로딩하는 것이 현실적인 해결방법이었다.
에셋 매니저
3D 모델, 텍스처 등을 에셋이라고 하고, 이들을 로드하고 저장하고 제공하는 클래스를 에셋 매니저라고 한다. 에셋 매니저는 씬 마다 하나씩 존재하며 해당 씬에서 사용하는 에셋 목록을 저장한다. 씬이 로드될 때 씬에서 필요한 자원을 한번에 로딩한다. 중복 로딩을 방지하기 위해서 씬이 변경되는 시점에서 중복된 에셋은 이전 씬의 에셋 포인터를 그대로 가져온다.
에셋은 게임이 배포되어도 정확하게 로딩되어야 하므로 각자의 경로를 저장하고 있고, 엔진 고유의 파일 시스템에 의해 엄격하게 관리된다.
모델 최적화

에셋 중에 가장 복잡하고 로딩시간이 긴 리소스는 모델이다. 모델은 적어도 3만개 이상의 버텍스로 이루어져 있기 때문에 원본 형식으로 로딩하면 모델 당 3 ~
5초 정도의 로딩시간이 걸린다. 이를 개선하기 위해 엔진은 고유의 모델 구조를 사용하며 바이너리 코드 형태로 관리한다. 엔진에 내장된 ModelImporter은 외부 형식 모델을 로딩하여 엔진 표준 형식으로 바꿔준다. 이러면 로딩시간을 10배 ~
15배 가량 단축할 수 있다.
직렬화
C++는 언어 특성상 빌드 시간이 매우 많이 걸린다. 이는 C++ 기반 게임 엔진인 언리얼 엔진도 가지고 있는 고질적인 문제이다. 이를 개선하기 위해서 최대한 개발 작업을 에디터를 이용하여 런타임에 진행할 수 있어야한다. 런타임에 개발한다는 것은 게임의 구성요소가 읽고 쓸 수 있는 데이터로 변환될 수 있어야한다는 것을 의미하기도 한다.
그래서 게임의 로직은 C++ 언어 기반으로 작성하되 오브젝트의 구성, 배치, 테스트, 씬 구성등을 데이터 형식으로 변환할 수만 있어도 생산성이 매우 올라간다.
로직 자체도 스크립트 언어를 사용하면 런타임에 구성할 수 있지만, 개발 시간 대비 리턴이 적다고 판단했기에 이번 프로젝트에 포함되지 않았다.
씬 데이터 직렬화
앞서말한 오브젝트의 구성, 배치, 씬 구성등 모두 씬을 직렬화/역직렬화 할 수 있다면 한 번에 달성할 수 있는 목표들이다. 여기서 핵심은 오브젝트의 직렬화인데, Entity Component System을 채용하는 게임 특성상 Component 가 부지기수적으로 늘어날 수 있기 때문에 직렬화하기 매우 까다롭다는 점이 문제점으로 다가왔다.

바꿀 수 없는 진실은 모든 컴포넌트가 직렬화 역직렬화 로직을 제공해야하며, 런타임에 추가 및 삭제를 제공해야한다는 점이다. 이를 위해서 컴포넌트들이 런타임 리플렉션을 제공하도록 매크로를 정의하였으며 매크로를 가지고 있지 않은 컴포넌트들이 있으면 아예 컴파일이 되지 않도록 막아버렸다.

이를 통해서 런타임에 씬 데이터를 모두 직렬화 및 역직렬화할 수 있는 기반을 마련했고 런타임에 게임을 구성할 수 있게 하였다.
GUI 기반 에디팅 시스템

런타임에 에셋을 로드하고 씬을 쉽게 구성할 수 있도록 GUI 기반 시스템을 제공한다. 버튼 조작으로 씬을 구성하고 데이터를 직렬화 및 역직렬화할 수 있고 게임 구성요소를 간단히 조작할 수 있다.
또한, 씬을 멈추고 프레임 단위로 분석하는 등 다양한 기능을 제공한다. 최대한 드래그 드랍을 이용하여 빠르게 제품을 생산할 수 있도록 하였다.
빌드 시스템
프로그램은 개발 시점과 배포 시점의 단계로 나눌 수 있으며, 이 둘은 달라야한다. 스크립트를 이용하여 배포할 수 있는 실행파일을 빌드할 수 있으며 아직 많은 테스트가 필요한 기능이며 게임 개발과 함께 점진적으로 개발해나갈 계획이다.
앞으로 남은 과제
생각보다 엔진 기반을 잡는데 오래 걸렸고 팀원들도 엔진 사용에 익숙하지 않으며, 테스트도 충분히 진행되지 않았기 때문에 방학동안은 게임을 만들기전 충분한 엔진 사용과 테스트를 이용하여 프로토타입 개발에 힘 쓸 계획이다.