- 빠른 FPS 환경에서는 늦게 도착한 상태 정보의 가치가 낮기 때문에, Quake 3는 UDP/IP 중심 설계로 지연을 줄이는 쪽을 택함
- NetChannel은 손실 가능성이 있는 UDP 위에서 통신을 추상화하고, 서버는 클라이언트별 스냅샷 기록으로 필요한 상태 차이만 다시 계산함
- 서버는 Master Gamestate, 최근 32개 gamestate, dummy gamestate를 함께 써서 전체 업데이트와 델타 업데이트를 같은 절차로 만듦
- 클라이언트 ACK가 없으면 서버는 마지막으로 확인된 스냅샷과 현재 상태를 비교해, 누락된 변경과 새 변경을 한 메시지에 담음
- C에 내장 인트로스펙션이 없어도
netField_t와 매크로로 필드 차이를 찾고, NetChannel은 1400바이트 사전 분할로 라우터 단편화를 피함
UDP/IP를 전제로 한 네트워크 모델
- Quake 3의 네트워크 모델은 엔진에서 가장 우아한 부분으로 평가되며, 낮은 수준에서는 Quake World에서 처음 등장한 NetChannel 모듈로 통신을 추상화함
- 빠른 게임에서는 첫 전송에서 놓친 정보가 곧 오래된 정보가 되므로, 다시 보내는 것보다 최신 상태를 보내는 편이 더 유리함
- 이 때문에 엔진에는 TCP/IP 흔적이 없고, 신뢰 전송이 만드는 지연을 감수하기 어렵다고 봄
- 네트워크 스택에는 서로 배타적인 두 계층이 추가됨
- 사전 공유 키를 이용한 암호화
- 사전 계산된 Huffman 키를 이용한 압축
- 서버는 UDP datagram 크기를 줄이면서 비신뢰성을 보완함
- 스냅샷 기록으로 델타 패킷을 생성함
- 메모리 인트로스펙션 방식으로 변경된 필드만 찾아 보냄
서버와 클라이언트의 역할
- 클라이언트 쪽 흐름은 단순함
- 매 프레임 서버에 명령을 보냄
- 서버에서 gamestate 업데이트를 받음
- 서버는 각 클라이언트에 Master Gamestate를 전파하면서 손실된 UDP 패킷까지 고려해야 함
- 핵심 메커니즘은 세 요소로 구성됨
- Master Gamestate: 보편적으로 참인 게임 상태이며, 클라이언트 명령은 NetChannel을 통해 들어와
event_t로 바뀐 뒤 서버에서 게임 상태를 수정함
- 클라이언트별 최근 32개 gamestate: 네트워크로 보낸 상태를 순환 배열에 저장하며, 스냅샷이라고 부름
- dummy gamestate: 모든 필드가 0인 상태이며, 이전 상태가 없을 때 델타 생성 기준으로 쓰임
- 서버는 이 세 요소로 NetChannel에 넘길 업데이트 메시지를 만듦
- 클라이언트별 gamestate를 많이 유지해야 하므로 메모리 사용량이 커짐
- 측정 기준으로 플레이어 4명에 8MB를 사용함
스냅샷으로 전체 업데이트와 부분 업데이트 만들기
- 예시는 Client1에 업데이트를 보내는 상황에서, Client2의 상태가
pos[X], pos[Y], pos[Z], health 네 필드로 구성된 경우를 사용함
- 통신은 UDP/IP로 이루어지며, 인터넷에서는 메시지가 자주 손실될 수 있음
-
첫 번째 서버 프레임
- 서버는 모든 클라이언트에서 받은 업데이트를 Master Gamestate에 반영한 뒤 Client1에 상태를 전파함
- 네트워크 모듈은 매번 같은 절차를 따름
- Master Gamestate를 클라이언트 기록의 다음 슬롯에 복사함
- 복사된 스냅샷을 다른 스냅샷과 비교함
- 첫 업데이트에서는 Client1 기록에 유효한 스냅샷이 없으므로 dummy snapshot과 비교함
- dummy snapshot의 모든 필드가 0이므로 결과는 전체 업데이트가 됨
- 각 필드 앞에는 변경 여부를 나타내는 비트 마커가 붙음
- 예시 전체 업데이트는 132비트를 사용함
- 형식은
[1 A_on32bits 1 B_on32bits 1 B_on32bits 1 C_on32bits]
-
두 번째 서버 프레임
- 다음 프레임에서 Client2가 Y축으로 이동해
pos[1] 값이 E가 됨
- Client1은 이전 업데이트 수신을 ACK했으므로 Snapshot1이 ACK 상태가 됨
- 서버는 Master Gamestate를 다음 기록 슬롯에 복사해 Snapshot2를 만들고, 유효한 Snapshot1과 비교함
- 결과적으로 변경된
pos[1] = E만 네트워크로 전송됨
- 각 필드에 비트 마커가 붙기 때문에 이 부분 업데이트는 36비트를 사용함
- 형식은
[0 1 32bitsNewValue 0 0]
-
세 번째 서버 프레임
- 다음 프레임에서 Client2는 체력을 잃어
health = H가 됨
- Client1은 마지막 업데이트를 ACK하지 않음
- 서버의 UDP 패킷이 손실됐을 수도 있고, 클라이언트의 ACK가 손실됐을 수도 있음
- 어느 경우든 해당 스냅샷은 사용할 수 없음
- 서버는 Master Gamestate를 다음 슬롯에 복사해 Snapshot3을 만들고, 마지막으로 ACK된 Snapshot1과 비교함
- 전송되는 메시지는 부분 업데이트이며, 이전 변경
pos[1] = E와 새 변경 health = H를 함께 포함함
- Snapshot1이 너무 오래되어 사용할 수 없으면 엔진은 다시 dummy snapshot을 기준으로 전체 업데이트를 보냄
같은 절차로 손실을 보완하는 방식
- 스냅샷 시스템의 단순함은 같은 알고리듬이 두 작업을 자동으로 처리한다는 데 있음
- 전체 업데이트 또는 부분 업데이트 생성
- 수신되지 않은 이전 정보와 새 정보를 한 메시지로 재전송
- UDP 패킷 손실을 별도 복잡한 흐름으로 처리하지 않고, 마지막으로 ACK된 스냅샷과 현재 Master Gamestate의 차이를 계산해 보완함
- 이전 상태가 없거나 사용할 수 없을 때는 dummy snapshot을 기준으로 전체 상태를 보내 복구함
C에서 필드 차이를 찾는 방식
- Quake 3는 C 언어에 인트로스펙션이 없지만, 각 필드 위치를
netField_t 배열과 전처리 지시문으로 미리 구성함
netField_t는 필드 이름, offset, 비트 수를 담음
NETF(x) 매크로는 문자열화 연산자와 entityState_t에 대한 offset 계산을 이용해 필드 정보를 짧게 작성하게 함
- 예시 구조는 다음과 같음
typedef struct { char *name; int offset; int bits; } netField_t;
// using the stringizing operator to save typing...
#define NETF(x) #x,(int)&((entityState_t*)0)->x
netField_t entityStateFields[] = {
{ NETF(pos.trTime), 32 },
{ NETF(pos.trBase[0]), 0 },
{ NETF(pos.trBase[1]), 0 },
...
}
- 전체 구현은 MSG_WriteDeltaEntity의 일부에 있음
- Quake 3는 비교 대상의 의미를 해석하지 않고,
entityStateFields의 index, offset, size를 따라가며 차이를 네트워크로 전송함
1400바이트로 미리 나누는 이유
- NetChannel 모듈은 UDP datagram의 최대 크기가 65507바이트임에도 메시지를 1400바이트 조각으로 나눔
- 관련 코드는 Netchan_Transmit에 있음
- 대부분의 네트워크 MTU가 1500바이트이므로, 1400바이트 분할은 인터넷 경로에서 라우터가 패킷을 단편화하지 않도록 하기 위한 선택임
- 라우터 단편화를 피해야 하는 이유는 두 가지임
- 네트워크에 들어갈 때 라우터가 패킷을 단편화하는 동안 패킷을 붙잡아야 함
- 네트워크를 나갈 때 datagram의 모든 조각을 기다린 뒤 비용이 큰 재조립을 해야 함
반드시 전달되어야 하는 메시지
- 스냅샷 시스템은 네트워크에서 손실된 UDP datagram을 보완하지만, 일부 메시지와 명령은 반드시 전달되어야 함
- 플레이어가 종료하거나 서버가 클라이언트에 새 레벨 로드를 요구하는 경우가 여기에 해당함
- 이 보장은 NetChannel이 추상화함
관련 읽을거리