7.1 렌더링 루프

while(!quit) {
    // 플레이어 입력을 받아 카메라를 업데이트 
    
    // 프레임 자원 인덱스 변경 
    
    // 장면에 존재하는 동적인 요소들의 위치, 회전, 크기 행렬 변경
    
    // 후면 버퍼에 그리기
    
    // 후면 버퍼의 내용을 전면 버퍼에 복사(swap)
}

 

7.2 게임 루프

게임은 상호작용하는 다양한 하부 시스템으로 이루어지는데, 장치 I/O, 렌더링, 애니메이션, 충돌 감지 및 처리, 부가적인 강체 역학 시뮬레이션, 멀티플레이어 네트워크, 오디오 등 다양한 부분들이 게임을 구성한다.

대부분의 하부 시스템은 게임이 돌아가는 동안 주기적으로 업데이트 해야 하지만 하부 시스템마다 주기가 다르다.

 

가장 단순한 방법은 루프 하나에 하부 시스템의 루프를 모두 넣는 방법이다.

 

7.3 게임 루프 구조의 형태

7.3.1 윈도우 메세지 펌프

DirectX3D 루나 책에서 사용하는 예제

윈도우 플랫폼에서 돌아가는 게임들은 엔진 하부 시스템들뿐 아니라 운영체계에서 오는 여러 메세지도 처리해야 하기 때문에 메세지 펌프라는 코드가 존재한다. 윈도우 메세지가 없을때만 게임의 엔진에 대한 처리를 한다.

게임 윈도우의 크기를 바꾸거나 바탕화면에서 창을 마우스로 끌고 있으면 게임은 멈춘다.

 

7.3.2 콜백 주도 프레임워크

게임 엔진 하부 시스템과 외부 게임 미들웨어 패키지들이 라이브러리 형태로 되어 있는 경우

ㄴ 프로그래머의 선택 폭이 넓어지지만 라이브러리의 함수와 클래스를 어떻게 사용할지 프로그래머가 잘 이해하고 있어야 한다.

게임 엔진 하부 시스템과 외부 게임 미들웨어 패키지들이 프레임워크 구조로 되어 있는 경우

ㄴ 게임 루프가 이미 짜여져 있지만 비어 있다. 비어 있는 세부적인 부분들을 채워 넣기 위해 게임 프로그래머는 콜백 함수를 짠다.

 

7.3.3 이벤트 기반 업데이트

이벤트 시스템을 활용해 주기적으로 하부 시스템을 업데이트하도록 구현하는 방법도 있다.

ㄴ 현재 시점보다 미래에 이벤트를 보낼 수 있는 기능을 이벤트 시스템이 지원해야 한다.(이벤트 큐에 저장했다가 나중에 보낼 수 있어야 한다.)

 

7.4 Abstract Timelines

7.4.1 실시간

CPU의 정밀 타임 레지스터 값으로 측정하는 시간을 사용할 수 있다.

예를 들어 3GHZ 펜티엄 프로세서는 1초에 30억번 타이머의 값을 증가시킨다.

이 경우 정밀도는 1/30억 초 = 0.333ns(1/3 나노초)가 된다.

마이크로프로세서마다, 운영체제마다 정밀 타이머의 값을 얻어오는 방법은 다르다. Win32 API에서는

QueryPerformanceCounter() 함수를 사용하여 64비트 카운터 레지스터를 읽어오고

QueryPerformanceFrequency() 함수를 사용하여 현재 CPU에서 초당 카운터를 몇 번 증가시키는지를 받는다.

 

7.4.2 게임 시간

ㄴ 타임라인에 실시간만 있는 것은 아니다. 필요한 만큼 다른 타임라인을 정의할 수 있다.

ㄴ 여기에서 말하는 게임 시간은 FPS 게임에서의 라운드당 시간 같은 것이다.

ㄴ 게임을 일시 정지하고 싶은 경우에는 게임 시간을 업데이트 하지 않으면 되고 게임 시간을 슬로우 모션으로 보이고 싶은 경우 게임 시간을 천천히 업데이트 하면 된다.

ㄴ 게임 시간을 정지하거나 느리게 하는 것은 디버깅할 때 도움이 된다.

 

7.4.3 로컬 타임라인과 글로벌 타임라인

애니메이션 클립이나 오디오 클립의 타임라인은 로컬 타임라인이라고 할 수 있다.

ㄴ 로컬 타임라인의 시작점을 글로벌 타임라인의 원하는 위치에 매핑하면 클립을 재생할 수 있다.

ㄴ 로컬 타임라인의 크기를 줄이거나 뒤집으면 재생 속도를 조절하거나 거꾸로 재생하는 기능을 구현할 수도 있다.

 

7.5 시간을 측정하는 방법과 처리하는 방법

7.5.1 프레임 레이트와 시간 델타

두 프레임 사이에 시간이 얼마나 흘렸는지를 나타내는 말에 [프레임 시간, 시간 델타, 델타 시간] 등의 용어를 사용한다.

게임을 30FPS로 렌더링하면 델타 시간은 1/30초 (=16.6 ms, 16.6 밀리초)

밀리초라는 단위를 많이 사용한다.

 

7.5.2 프레임 레이트와 속도의 관계

게임에서 움직이는 물체의 위치 변화를 표현하기 위해서는 프레임마다 변위값을 더해주는 방법을 사용하는데, 변위값이 고정되어 있다면 델타 시간에 따라 속도가 다르게 보일 것이다.

하지만 델타 시간은 CPU 속도에 따라 달라질 수 있으므로 CPU에 따라 게임이 다르게 동작할 여지가 있다.

 

7.5.2.2 경과 시간에 따른 업데이트 

움직이는 물체의 위치 변화를 표현할 때 델타 시간을 사용하여 변위 값을 계산하는 방법이다

이 방식은 k 프레임의 시간을 측정하여 k + 1 프레임의 시간에 대한 예측 값으로 사용하기 때문에 정확하다는 보장이 없다.

ㄴ 레이트 스파이크 현상이라고 한다.

 

7.5.2.3 이동 평균 사용

ㄴ 순간적인 성능 스파이크로 인한 부작용은 줄어들지만 프레임 레이트 변화에 즉각 대응하기에는 어렵다.

 

7.5.2.4 프레임 레이트 조절

ㄴ 다음 프레임의 시간을 추측하는 대신 모든 프레임의 시간을 고정시킨다.

ㄴ 현재 프레임 시간을 측정하여 목표 시간 (30FPS 기준 33.ms)보다 짧은 경우, 목표 시간이 채워질 때까지 메인 스레드를 잠들게 한다. 목표 시간보다 긴 경우, 한 프레임을 더 기다린다.

ㄴ 일부 엔진 시스템, 대표적으로 물리 시뮬레이션에 쓰이는 수치 적분 모듈 같은 경우 일정한 간격으로 업데이트 했을 때 최적의 성능을 낸다.

ㄴ 테어링을 방지하는데도 도움이 된다.

ㄴ 게임 내 녹화 및 재생 기능의 안정성이 높아진다. (게임 플레이를 녹화한 후 그대로 재생하는 기능)

 

7.5.2.5 화면 간격과 V-sync

메인 게임 루프의 프레임 레이트가 스크린의 재생 빈도의 배수가 되게 조정

 

7.5.3 정밀 타이머로 실제 시간 측정

= 7.4.1 내용

 

7.5.4 시간 단위와 클록 변수

어떤 시간 단위를 사용할 것인가? [초, 밀리초, 하드웨어 주기]

측정한 시간 값을 어떤 데이터 타입에 저장할 것인가? [64비트 정수, 32비트 정수, 32비트 부동소수]

 

7.5.4.1 64비트 정수 클록

하드웨어 주기 단위(CPU 주기 단위)로 측정하는 경우에 가장 정확도가 높은 방법이다.

값의 범위가 크다 (약 195년)

 

7.5.4.2 32비트 정수 클록

하드웨어 주기 단위로 사용하되 짧은 기간을 측정하는 경우 사용할 수 있다.

64비트 정수값(이후 측정 시간) - 64비트 정수값(이전 측정 시간) 의 결과를 32비트 정수에 저장한다.

 

7.5.4.3 32비트 부동소수 클록

CPU 주기 단위로 측정된 값을 CPU 클록 주파수 (3GHZ 펜티엄 프로세서의 경우 30억)로 나눈 값을 초 단위의 부동소수로 저장하는 방식이다.

짧은 기간에 대해서만 사용하는 것이 좋다.

 

7.5.4.4 부동소수 클록의 한계

ㄴ 기간이 길어져 클록의 절대값(CPU 주기 단위로 측정된 값)이 커지면 정수 부분에 많은 비트가 쓰여 소수 부분에 쓸 비트가 줄어들게 되어 소수부분의 정확도가 떨어질 수 있다.

 

7.5.4.5 기타 시간 단위

1/300초를 자주 사용한다.

 

7.5.5 중단점과 시간

게임을 실행하다가 중단점에 도달하면 게임 루프는 멈추고 디버거가 실행된다. 중단된 프로그램을 재개하는 순간 엄청나게 큰 델타 시간이 엔진 하부 시스템에 전달되면 이상한 문제가 생길 수 있다.

델타 시간이 미리 정한 한계(예, 1/10초)를 벗어나면 목표 프레임 레이트로 강제 설정하는 방법으로 해결 가능하다.

 

7.6 멀티프로세서 게임 루프

멀티 프로세서 시대가 도래하면서 한 개의 게임 루프가 모든 하부 시스템을 담당하지 않게 되었다.

 

7.6.2 SIMD (single instruction multiple data)

오늘날의 거의 모든 CPU가 SIMD 명령어들을 지원한다.

CPU에 따라 지원하는 명령어는 조금씩 다르지만 게임에서 가장 많이 쓰는 것은 32비트 부동소수점 수 4개를 병렬로 계산하는 명령어들이다.

 

7.6.3 분기와 결합

멀티코어를 활용하는 방안으로 분할 정복 알고리즘을 병렬화에 적용하는 방안이 있다.

문제를 작은 단위로 쪼갠 후 이것들을 여러 개의 코어, 혹은 하드웨어 스레드에 분배하고 (분기, fork) 모든 작업이 끝나면 그 결과를 합치는 (결합, join) 것이다.

 

예를 들어 선형 보간을 통해 애니메이션을 블렌딩할 때 뼈대의 각 관절들은 다른 관절들과 독립적으로 보간되기 때문에 병렬적으로 처리할 수 있다. 처리해야 할 관절이 500개라면 각 스레드나 코어에 500개를 분배

 

7.6.4 하부 시스템마다 스레드 하나씩 두기

특정 엔진 하부 시스템을 별도의 스레드로 돌게 하는 방식이다.

이런 디자인 방식은 어느 정도 분리된 역할을 반복적으로 맡아 하는 엔진 하부 시스템에 적합하다. (물리, 렌더링, 애니메이션 파이프라인, 오디오 엔진)

메인 스레드는 하이레벨 로직(메인 게임 루프)에 대한 처리를 담당하면서도 하부 시스템 스레드를 제어하고 동기화하는 역할을 동시에 한다.

스레드 아키텍처를 사용하려면 일반적으로 하드웨어 플랫폼의 스레드 라이브러리를 이용한다. 윈도우는 Win32 스레드 API를 사용한다.

 

7.6.5 잡 (스레드와 비슷하지만 작은 단위)

하부 시스템을 멀티스레드로 분리하는 접근 방식의 문제점 중 하나는 각 스레드가 비교적 큰 단위의 작업을 처리하기 때문에 한 스레드가 일을 제때 끝마치지 못하면 메인 게임 루프를 포함한 다른 스레드의 진행도 멈출 수 있다는 문제가 있다는 것이다. (게임 루프의 유연성이 떨어진다)

 

잡 구조에서는 작업을 작은 단위의 덩어리로 쪼개서 여유가 있는 어떠한 프로세서에서도 실행할 수 있게 만든다.

 

7.6.6 비동기 프로그램 디자인

비동기 디자인의 경우 요청 함수를 호출하면 요청을 잡 큐에 넣은 후 즉시 리턴한다.

보통 작업 요청을 한 후 다음 프레임에서 결과를 받아 처리하는 경우가 많다.

 

7.7 네트워크 멀티플레이어 게임 루프

7.7.1 클라이언트-서버

게임 로직의 거의 모든 부분이 하나의 서버에서 돌아간다.

클라이언트는 단순한 렌더링 엔진으로써 로컬에 있는 플레이어 캐릭터를 조정하는 역할만 하고, 그 외에는 서버가 그리라고 지시하는 것들을 화면에 보여주기만 한다.

클라이언트 코드를 짤 때에는 로컬 플레이어의 입력이 화면 속 플레이어 캐릭터의 움직임에 즉시 반영되도록 해야한다.

 

서버를 별도의 전용 머신에서 돌리는 경우에는 전용 서버 모드(dedicated server)라고 하고

클라이언트 머신 중 하나가 서버를 같이 돌리고 있는 경우는 서버 위 클라이언트 모드(client-on-top-of-server)라고 한다.

 

서버와 클라이언트의 게임 루프가 반드시 같은 빈도로 업데이트되야 하는 것은 아니다.

 

1. 클라이언트와 서버를 별개의 프로세스로 구현하는 방법

 

2. 한 프로세스 안에서 두 개의 스레드로 구현하는 방법

 

1, 2번 방법은 서버 위 클라이언트 모드로 동작하는 경우, 로컬에서 서로 통신하는데 상당한 오버헤드가 든다.

 

3. 한 개의 스레드 안에서 클라이언트와 서버를 같이 구동하고 하나의 게임 루프로 관리하는 방식을 사용할 수도 있다.

ㄴ 같은 게임 루프에서 서버는 20FPS로 업데이트하고 클라이언트는 60FPS로 업데이트 하는 방식

 

7.7.2 피어 투 피어 (p2p)

모든 머신이 어느 정도 서버 역할을 맡는다.

각 머신은 게임의 동적 객체들을 일정 부분 담당하는데, 한 객체에 대해서는 오직 한 머신만 독점적으로 관리한다.

로컬 머신이 직접 관리하는 게임 객체(진짜 객체)와 다른 머신이 관리하는 게임 객체(가짜 객체)를 구분할 수 있어야 한다.

로컬 머신이 관리하는 객체에는 완전한 기능을 갖춘 진짜 게임 객체를 사용하고, 그렇지 않은 객체에 대해서는 최소한의 상태만 갖는 프록시 버전을 사용한다.

객체에 대한 관리 권한이 머신 사이에 동적으로 변할 수 있기 때문에 복잡하다. (게임 객체를 관리하던 머신의 연결이 끊기면 그 머신이 관리하던 게임 객체는 다른 머신들이 가져가 관리해야 한다.)

+ Recent posts