얼마전부터 계속 만들고 있는 게임 엔진에 사용할 리플랙션 시스템에 대한 연구를 하고 있다.
필자가 원하는 것은 언리얼 엔진과 같이
내가 직접 개발하는 것은 아무래도 현실성이 없다고 생각하여 오픈 소스쪽으로 눈을 돌렸다.
그러나 여러 오픈 소스 라이브러리를 찾아보아도 프로그래머가 일일이 리플랙션 데이터를 입력해주어야하는 매우 매우 불편한 라이브러리들 밖에 없었다.
그러다가 clang 컴파일러가 리플랙션에 활용 가능한 여러 데이터들을 제공해준다는 사실을 알고 이쪽에 관심을 가졌다.
그리고 clReflect라는 오픈 소스 라이브러리를 찾았다.
딱 내가 원하던 것이었다.
clang 컴파일러가 컴파일을 하면서 생기는 여러 데이터들을 가지고 Reflection 데이터를 만들어준다.
귀찮게 일일이 매크로를 입력해줄 필요 없기 코드 몇줄이면 자동으로 Reflection 데이터가 바이너리 파일로 뽑혀나오는 형태였다.
그러나 문제가 있었다.
이 오픈 소스 라이브러리가 업데이트된지도 오래되었고 메인테이너도 따로 없어서 x64 타겟으로 컴파일을 하면 제대로 동작을 하지 않는 것이다.
개발자에게 물어보니 자기도 x64을 위한 버그 수정이나 다른 작업은 할 생각이 없다고 말한다.
어쩔 수 없이 내가 다 고치기로 결정하였다.
문제는 간단했다. Reflection 데이터를 생성하는 코드에서 메모리 맵 파일 방식으로 여러 데이터를 저장했는데 그 중 size_t 타입을 사용하는 변수가 문제가 되었다. Reflection 데이터를 생성하는 코드에서는 size_t로 데이터를 가져왔지만 Reflection 데이터를 읽는 코드에서는 size_t가 아니라 고정된 4바이트 타입으로 데이터를 읽어오니 당연히 데이터 layout이 맞지 않아서 버그가 발생했던 것이다.
그래서 Reflection 데이터를 받아오는 쪽의 변수 타입을 size_t로 바꾸어주니 제대로 작동했다.
또 따른 문제는 clReflect가 clang의 컴파일 데이터를 이용하다보니 결국 컴파일을 하는 것과 마찬가지인데 이 컴파일이 매우 느려터졌던 것이다. 그래서 컴파일을 멀티스레드로 하기로 결정하였다.
이 또한 라이브러리가 지원하지 않아서 멀티스레드로 동작시 버그가 발생하였고 문제가 되는 코드들을 다 뜯어 고쳤다.
그러고 나니 잘 작동했다.
그 외에도 이 clReflect라는 라이브러리에는 문제가 너무나도 많았다. 관리가 안되고 있는 라이브러리다 보니 많은 기능들을 내가 거의 다 뜯어 고쳐야했고, 현재는 그럭 저럭 작동을 하고 있다.
( 비주얼 스튜디오쪽에서 map 파일을 제대로 생성해주지 않아 함수의 주소를 제대로 연결 짓지 못하는 문제가 있는데 이건 내가 해결할 수 있는 범위를 넘어서는거라 그냥 일단은 몇가지 버그를 안은 채로 사용하기로 결정했다. )
사실 여기까지만해도 내 게임 엔진에 사용하기 큰 불편함은 없었다.
그러나 여전히 필자의 게임엔진에는 프로그래머가 일일이 입력해주어야하는 매크로들이 몇가지 있었다.
대표적으로는 고속 런타임 타입 캐스팅을 지원하기 위한 매크로였다. 자세한건 내가 제작한 오픈 소스 라이브러리를 보면 이해가 쉬울 것이다.
고속 런타임 타입 캐스팅을 위해 클래스마다 현재 클래스명과, 부모 클래스 명을 매크로로 일일이 입력해주어야했는데 매우 불편한 작업이었다.
clReflect를 이용하면 런타임에 간단히 해결할 수도 있지만 필자는 이를 컴파일 타임에 반드시 데이터가 저장되게 만들고 싶었다.
그래서 새로운 기능이 필요했다. 바로 여러 리플랙션 데이터를 컴파일 타임에 저장하기 위해 코드를 자동으로 생성해주는 기능이 필요했다.
쉽게 말하면 언리얼 엔진에서 흔히 볼 수 있는 ~.generated.h 파일을 만들려고한다.
이 generated.h 파일은 매우 다양하게 활용이 된다.
예를 몇가지 들어보면 Base 클래스에 대한 type alias 지원, 여러 컴파일 타임 데이터 제공 등 언리얼 엔진에서 자체적으로 헤더 파일을 파싱해서 코드를 생성하였다.
필자 또한 이러한 기능이 필요하다고 생각하였다.
이를 위해서는 또 추가적인 작업들이 필요했다.
이 기능은 clReflect에서 아예 지원 조차 하지 않았기 때문에 이 부분은 전적으로 필자가 코드를 모두 짜야했다.
다행히도 clang 컴파일러는 이를 위한 수 많은 데이터들을 제공해주고 있었다!!!!
이를 위해서 clang 오픈 소스도 뜯어보고 커뮤니티도 뒤져가면서 원하는 데이터들을 가져왔다.
현재는 완전 자동화가 되어서 프로젝트에 소스파일을 추가하기면 하면 자동으로 해당 소스파일에 대한 리플랙션 파일이 생성되는 구조이다.
아래 링크의 영상은 현재 구현된 리플랙션 데이터 자동 생성 툴의 시연 영상이다.
https://youtu.be/KGihaYTzqG8, https://youtu.be/9DKGvkdR6zw
또한 고속 런타임 타입 캐스팅을 위한 기능도 추가했다.
class DObject
{
virtual void Do1(){}
};
class B
{
virtual void Do2(){}
};
class C : public B, public DObject // vs public DObject, public B
{
};
int main()
{
DObject* dObjectPtr = new C():
C* c = CastTo<C*>(dObjectPtr); // CastTo는 DObject 클래스를 상속받는 클래스들간의 런타임 타입 캐스팅에 사용된다.
printf("%llu %llu", (unsigned long long)a, (unsigned long long)c);
}
output : 94025612332728(!!!) 94025612332720
위의 상황에서 C 스타일 타입 캐스팅을 하면 포인터 dObjectPtr와 포인터 c는 같은 주소를 반환할까?
정답은 No이다.
클래스 C는 클래스 B, DObject 두 개의 virtual 클래스를 상속하고 있는데 이러한 다중 상속 때문에 클래스 C는 오브젝트의 맨 앞에 두 개의 virtual table 포인터를 가지고 있게 된다.
그러므로 위의 경우 포인터 a는 실제 클래스 C의 오브젝트에서 포인터 사이즈 ( virtual table pointer )를 더한 주소를 가리키고 있게 된다.
이는 클래스 C에서 상속을 할 때 클래스 DObject를 두 번째로 상속하였기 때문이다.
이는 현재 필자의 엔진에서 사용 중인 타입 캐스팅에서 큰 문제가 된다. 길게는 설명하지 않겠고 필자의 엔진에서는 런타임 타입 캐스팅에서 reinterpret_cast를 사용한다. ( 자세한건 이 글을 읽어보라. )
즉 virtual table pointer의 offset을 고려하지 않는다는 것이다…..
그렇기 때문에 이 상속 순서를 강제할 필요가 있다.
DObject 클래스 혹은 그 자녀 클래스를 상속할 때는 반드시 첫번째로 상속을 선언해야한다. 또한 DObject 클래스에 대한 다중 상속 또한 해서는 안된다.
이를 감지하고 에러를 발생시키는 코드도 추가해서 리플랙션 툴에 넣었다.
또한 clReflect를 내 엔진에 적용하기 위해 자동화 툴도 만들었다. ( clReflect_automation )
간단히 설명하면 비주얼 스튜디오 프로젝트 폴더를 분석해서 소스파일 목록을 모두 가져오고 소스파일의 Dependency 파일들을 모두 분석하여서 소스파일의 리플랙션 데이터가 다시 생성될 필요가 있다고 판단되면 ( 소스파일 혹은 소스파일의 Dependency 파일이 수정된 경우 ) clReflect를 호출해서 자동으로 Reflection 데이터를 재생성해준다.
이 부분은 C#으로 작성하였고 코드도 간단하다.
그렇지만 비주얼 스튜디오 프로젝트 폴더 경로만 던져주면 알아서 Reflection 데이터를 생성해주니 매우 매우 편리한다.
이후 리플랙션 시스템을 imgui와 완전히 연동하여서 언리얼 엔진과 같은 시스템을 구현하였다.
원하는 멤버 변수나 함수를 추가하고 리플랙션 매크로만 붙여주면 알아서 gui가 생기는 방식이다.
매우 편리하고 간편하여서 향후 개발에 유용하게 사용될 것 같다.
필자가 작성한 코드들은 아래에서 볼 수 있다.
커스터마이징한 clReflect
clReflect 자동화 툴