롤러코스터 타이쿤의 전설적인 최적화 기법
최근 운 좋게도 독일의 유명한 게임 팟캐스트 'Stay Forever'에 출연할 기회를 얻었습니다. 롤러코스터 타이쿤(1999)의 기술적 특징에 대해 이야기하는 시간이었는데, 정말 좋은 인터뷰였어요. 독일어를 아신다면 전체 에피소드를 꼭 들어보시길 추천합니다. 혹시 독일어가 어렵다면 괜찮아요. 이 글에서 그때의 내용을 정리해뒀으니까요.
왜 이 게임은 특별했나
롤러코스터 타이쿤과 그 후속작은 역사상 가장 최적화가 잘 된 게임으로 손꼽힙니다. 제작자 Chris Sawyer가 거의 전부를 어셈블리로 작성했거든요. 1999년의 하드웨어에서 수천 개의 에이전트를 시뮬레이션하는 대규모 테마파크를 끊김 없이 구동해냈다는 게 정말 놀라운 성과입니다. 요즘도 비슷한 건설 게임들이 안정적인 프레임률을 유지하는 데 애먹는데, 그 옛날에 이걸 해냈다니요.
그럼 Chris Sawyer는 어떻게 이런 기적을 만들어낸 걸까요?
어셈블리로의 여정
대부분의 기사에서 가장 먼저 언급하는 것은 어셈블리 언어 선택입니다. 당시 기준으로 C나 C++ 같은 고급 언어보다 훨씬 효율적인 코드를 작성할 수 있었거든요.
게임 개발에서 어셈블리는 오랫동안 표준이었지만, 이 시점엔 이미 거의 사라진 기법이었습니다. 6년 앞서 출시된 둠(Doom)도 대부분 C로 작성됐고, 일부만 어셈블리를 사용했으니까요. 둠이 최적화가 덜 되었다고 하는 사람은 없습니다.
정확히 확인할 순 없지만, 롤러코스터 타이쿤이 이렇게 개발된 마지막 대형 게임일 가능성이 높습니다. 당시의 성능 향상이 얼마나 컸는지 정량화하기는 어렵지만, 요즘보다는 훨씬 더 컸을 거예요. 현대의 컴파일러는 고급 언어를 훨씬 잘 최적화하니까요. 예전엔 손으로 일일이 해야 했던 최적화들을 이제는 컴파일러가 처리합니다.
원본을 분석하는 방법: OpenRCT2
어셈블리 외에도 RCT의 코드는 극도로 최적화됐습니다. 근데 원본 소스 코드가 공개된 적이 없는데, 어떻게 이걸 알 수 있을까요?
그건 바로 OpenRCT2 때문입니다. 열정적인 팬들이 만든 이 프로젝트는 100% 호환되는 롤러코스터 1, 2의 재구현판입니다. 원본 에셋을 사용하면서 전체 게임을 재현해냈어요.
이건 원본 소스 코드는 아니지만, 특히 초기 버전부터 수년간의 역공학(reverse engineering)을 통해 원본에 매우 근접한 결과물을 만들어냈습니다. 물론 요즘의 OpenRCT2는 원본보다 더 나은 개선사항들도 포함하고 있습니다. 앞으로 이런 개선 사항들도 함께 살펴볼 거예요.
모든 최적화를 다 다루진 않겠지만, 게임의 모든 부분이 철저히 최적화됐다는 걸 보여주는 몇 가지 사례를 골라서 설명하겠습니다.
돈을 저장하는 다양한 방식
게임에서 돈을 어떻게 저장할까요? 보통은 게임에서 필요한 최대값을 생각해서 그에 맞는 자료형을 선택하겠죠. Chris Sawyer도 같은 방식을 썼는데, 훨씬 더 세분화했습니다.
코드 곳곳에서 서로 다른 자료형을 사용해서 돈 값을 저장하는데, 그 위치에서 예상되는 최대값에 따라 다릅니다. 예를 들어 공원 전체의 자산을 저장하는 변수는 큰 수를 다뤄야 하니까 4바이트를 씁니다. 반면 상점 물품의 가격 조정값? 훨씬 작은 범위만 필요하니까 1바이트면 충분합니다.
흥미롭게도, OpenRCT2에서는 이 최적화를 제거했습니다. 현대 CPU에서는 성능 차이가 없으니까, 모든 값을 간단한 8바이트 변수로 통일했거든요.
수학 연산을 비트 시프트로 대체하기
OpenRCT2의 소스를 읽다 보면 현대 코드에선 거의 보기 힘든 이런 문법을 자주 만나요:
NewValue = OldValue << 2;
C++의 연산자 오버로딩 덕분에 '<<' 기호는 여러 의미를 가질 수 있습니다. 이 코드가 실제로 하는 일은 다음과 같습니다:
NewValue = OldValue * 4;
여기서 '<<'는 비트 시프트(bit shifting)라고 불리는데, 변수의 모든 비트를 왼쪽으로 이동시킵니다. 이 경우엔 2칸 이동하고, 빈 자리는 0으로 채웁니다. 숫자가 이진법으로 저장되니까, 왼쪽으로 한 칸 이동할 때마다 수가 2배가 됩니다.
처음 보면 이상하게 느껴지겠지만, 십진법에서 곱셈을 할 때도 우린 같은 원리를 씁니다. 57 × 10을 계산할 때, 진짜 곱하기를 하나요? 아니면 57 뒤에 0을 붙이나요? 같은 원리인데 수체계만 다른 거예요.
반대 방향도 나눗셈을 줄이는 데 쓸 수 있습니다:
NewValue = OldValue >> 3;
이건 다음과 같은 거죠:
NewValue = OldValue / 8;
RCT는 이 트릭을 계속 써먹습니다. OpenRCT2에서도 이 문법은 그대로 유지되고 있어요.