Quake是Id Software公司推出一款風迷全球的FPS遊戲.至今為止已經發展到了第三代,而且作為一個優良的遊戲引擎,它也被大量的運用到其他公司開發的遊戲當中.例如我們所熟知的CS,它就是在Quake2引擎上改良而來的.雖然裡面的代碼實現並不完全相同,但是整體框架還是Quake2的,只要是稍微接觸過Quake引擎的人都很容易看得出來.(它是那麼的經典,以至於一直沿用到今天,個人認為它是遊戲領域大型結構化設計最好的一個典範.現在很多遊戲已經改用面向對象的程序設計方法來編寫,但Quake的影響卻是深遠的.包括前段時間洩露的CS2源碼,它的架構仍然保留著些許多Quake風格.)
很多人告述我Id Software 很早之前就已經公開了 Quake 3全部源碼.這裡我要告述大家,其實Id公開的並不是所有的源代碼,而僅僅是邏輯層代碼,你想想看哪個公司會笨到將自己的核心技術傾囊倒出,這些可是他們吃飯的本錢啊!邏輯層的代碼只包括了UI,AI等實現.像圖像渲染,網絡傳輸部分均沒有給出.你看到的唯有函數的聲明部分.因此要徹底研究Quake3就必須獲得這部分的源代碼,可上哪去找呢?令人興奮的是,早前已經有牛人通過逆向工程的方法將Quake3核心代碼整理出來,做了一個仿Quake3引擎(Dusk3D).我下面的分析就是根據他的代碼來寫的,雖然跟真實的Quake3引擎比較也許會有些出入,但我相信那並不影響我們去理解Quake3.
類如大多數Win32應用程序,Quake的Win32部分也是從WinMain函數進入的.邏輯層被分成了cgame,game,ui,q3_ui四個主要的模塊.(這幾個模塊被做成dll程序,由引擎負著加載它們)Quake中dll與引擎之間的交互通過vmMain和dllEntry這兩函數來完成.
vmMain作為引擎程序訪問邏輯層dll的接口,引擎使用VM_Call函數調用邏輯層dll引出的vmMain,然後vmMain再根據引擎提供的要訪問函數的索引號查找到相應的函數實現.
dllEntry是邏輯層dll接收引擎系統函數的接口,邏輯層dll利用它調用引擎專門提供給他的系統函數.
SV_GameSystemCalls是引擎提供給game模塊的系統函數調用接口.CL_CgameSystemCalls提供給cgame模塊,而CL_UISystemCalls則是提供給ui模塊使用.上述這幾個函數的實現方法與vmMain十分相似,它們都是通過一個Switch語句根據傳入的索引號跳轉到指定的函數處.
以下是dllEntry函數的實現,可以看到它只有一個參數,一個函數指針,這個函數指針就是指向引擎提供給邏輯層模塊的接口函數,例如SV_GameSystemCalls:
static int (CDECL *syscall)( int arg, ... ) = (int (CDECL *)( int, ...))-1;
void dllEntry( int (CDECL *syscallptr)( int arg,... ) ) {
syscall = syscallptr;
}
常見的trap打頭的函數實際上是調用了引擎代碼的,具體如下:
void trap_Printf( const char *fmt ) {
syscall( G_PRINT, fmt );
}
了解了調用規則後,我們就可以比較輕鬆地跟蹤調試Quake源碼啦.
在上一篇文章里,我談到過Quake的各個邏輯模塊被封裝在不同的DLL中,它們之間的交互利用了引出函數作為接口.這一點有點像COM,不同的是COM需要對DLL進行註冊,因為COM有時要提供給多個應用程序來使用,應用程序通過註冊表裡的GUID定位DLL,然後再載入到程序的進程空間中.但是Quake的DLL就不同了,它不必讓所有的應用程序都知道它的存在,它是專屬於引擎的,離開了引擎它一無事處.另外一個COM與Quake DLL的不同點是,COM的主力編程語言是C++,一種面向對象語言,它產生的接口都是以類出現的,所以使用它的語言也必須是面向對象的.但Quake是純C寫的,沒有類的概念,因此它提供的接口是純函數的形式給出.
DLL模式被大量的運用到遊戲設計上,選擇它作為遊戲的建構方法不是沒有它的道理的.
消息處理:
我把Quake的消息分為兩類,一種是常用輸入設備產生的消息,譬如KeyBoard,Mouse,JoyStick等.
另一種就是網絡或本地傳輸數據包時引發的消息.
引擎中Com_EventLoop()函數負責將抓獲到的消息根據事件的類型分發給對應的處理函數,
Com_GetEvent()可以從com_eventQueue和eventqueue數組隊列中獲取到所有的未處理消息,
typedef enum sysEventType_s {
SE_NONE, // evTime is still valid
SE_KEY, // evValue is a key code, evValue2 is the down flag
SE_CHAR, // evValue is an ascii char
SE_MOUSE, // evValue and evValue2 are reletive signed x / y moves
SE_JOYSTICK_AXIS, // evValue is an axis number and evValue2 is the current state (-127 to 127)
SE_CONSOLE, // evPtr is a char*
SE_PACKET // evPtr is a netadr_t followed by data bytes to evPtrLength
} sysEventType_t;
typedef struct {
int evTime;
sysEventType_t evType;
int evValue, evValue2;
int evPtrLength; // bytes of data pointed to by evPtr, for journaling
void *evPtr; // this must be manually freed if not NULL
} sysEvent_t;
static sysEvent_t com_eventQueue[COM_MAX_EVENTS];
static sysEvent_t eventqueue[SYS_MAX_EVENTS];
從以上的聲明部分我們可以看到com_eventQueue和eventqueue其實就是一個sysEvent_t結構的數組.
這裡你可能要會問了,com_eventQueue裡面的數據又是從何而來的呢?當Com_GetEvent()函數發現com_eventQueue裡面沒有數據的時候,例如程序剛啟動時,它會調用Com_GetRealEvent()來蒐集未處理的消息.然後再從eventqueue中讀出事件.
while( PeekMessage( &msg, NULL, 0U, 0U, PM_NOREMOVE ) ) {
if( !GetMessage( &msg, NULL, 0, 0 ) ) {
Sys_Quit();
}
TranslateMessage( &msg );
DispatchMessage( &msg );
})
先将消息交由WndProc()处理,然后WndProc()再把诸如按键,鼠標移動等外部设备输入信息通过调用Sys_QueEvent()函数存储到全局队列eventqueue中。
com_eventQueue隊列事件其實是通過Com_PushEvent()函數把eventqueue中的事件壓入到com_eventQueue中.Quake採用這種雙隊列的方式來保存消息.
(1) 網絡部分被分為接收和傳送兩個部分.
(2) 傳送部份被分為本地數據包傳輸和異地數據包傳輸兩個部分.
(3) 本地封包傳輸由NET_SendLoopbackPacket()負責.
(4) 異地封包傳輸由Sys_SendPacket()負責.
(5) 數據包傳輸又可以分為單包傳輸和多包傳輸.
(6) NET_SendPacket()可以傳送本地或異地不超過一個封包大小的數據包.也就是單包傳輸.
(7) Netchan_Transmit()
根據數據包的大小選擇傳輸方式,如果數據包大於一個封包的尺寸,
那麼就調用Netchan_TransmitNextFragment()函數,
将需要传送的数据块(MAX_MSGLEN)切割成若干等大小(MAX_PACKETLEN - 100)的封包,然後再啟動NET_SendPacket()傳送.
否則如果數據包小於一個封包的尺寸,那麼就直接調用NET_SendPacket()傳送.
上述数据包被NET_SendPacket()傳送前都会先被Netchan_ScramblePacket()进行加密(搅乱里面的数据),然后再用CL_Netchan_Encode()给它们编码.
這就是多包傳輸.
從上面的說明能夠看出其實多包傳輸最終還是要轉變為單包傳輸.
(8) 數據包接收也分為本地數據包接收和異地數據包接收.
(9) 本地數據包接收: NET_GetLoopbackPacket()
(10) 異地數據包接收: Sys_GetPacket()
(11) 當系統接收到異地數據包時會觸發SE_PACKET事件,這個事件到達Com_EventLoop()時,啟動CL_PacketEvent()處理客戶端傳送過來的數據包,
啟動SV_PacketEvent()處理服務器端傳送過來的數據包.
CL_PacketEvent()CL_Netchan_Process()Netchan_Process()
SV_PacketEvent()SV_Netchan_Process()Netchan_Process()
對於那些多包的數據,交由CL_Netchan_Process()或SV_Netchan_Process()來處理,它會一直等到全部封包都接收下來後再調用
Netchan_UnScramblePacket()解密,接著用CL_Netchan_Decode()解码.最後用戶就可以讀到完整的數據包了.
(12) 當系統接收到本地數據包時就直接啟動
CL_PacketEvent()或SV_PacketEvent()
而且不需要等待封包.因為這些封包沒有進入網絡,所以
while( NET_GetLoopbackPacket( NS_CLIENT, &adr, &msg ) )
while( NET_GetLoopbackPacket( NS_SERVER, &adr, &msg ) )
上面的循環可以把全部被切割的封包一次性全部讀出.
(13)
利用NET_SendPacket()
直接傳送數據的函數有NET_OutOfBandPrint()NET_SendPacket()
註:用NET_OutOfBandPrint()传送出来的数据包,前四个字节一定是FFFFFFFF,即-1.
它們会被SV_ConnectionlessPacket()和CL_ConnectionlessPacket()處理.
網絡部分補充說明:
負責傳送網間封包Sys_SendPacket()函數,調用了sendto這個Win socket API,它的作用是:
The sendto function is normally used on a connectionless socket to send a datagram to a specific peer socket identified by the to parameter. Even if the connectionless socket has been previously connected to a specific address, the toparameter overrides the destination address for that particular datagram only. On a connection-oriented socket, the to and tolen parameters are ignored, making sendtoequivalent to send.
所以很明顯,使用sendto並不需要建立可靠的連接,也就是不必先調用connect,直接能夠發送datagram.
客戶端連接服務器端的步驟:
1. 客戶端執行CL_Connect_f()
如果客戶端連接的服務器不是本地機器,需要多执行SVC_GetChallenge这一步
cls.state置為CA_CONNECTING.
判斷語句如下:
cls.state=NET_IsLocalAddress(&clc.server_address)?CA_CHALLENGING: CA_CONNECTING;
2. 如果客戶端變量cls.state = CA_CONNECTING,那麼當系統執行到
CL_CheckForResend()時就會啟動
NET_OutOfBandPrint( NS_CLIENT, &clc.server_address, "getchallenge" );
發送”getchallenge”消息給服務器端
3. 服務器端接收到”getchallenge”消息就會調用SVC_GetChallenge()à
NET_OutOfBandPrint(NS_SERVER,address,"challengeResponse %i", challenge );
發送”challengeResponse”消息給客戶端
4. 客戶端接受到”challengeResponse”消息:
cls.state = CA_CHALLENGING;
5. 如果客戶端變量cls.state = CA_CHALLENGING,那麼當系統執行到
CL_CheckForResend()時就會啟動
NET_OutOfBandPrint(NS_CLIENT, &clc.server_address, "connect /"%s/"", info );
發送”connect”消息給服務器端
6. 服務器端接受收到”connect”消息啟動SVC_DirectConnect(),如果客戶身分被確認,那麼就調用函數NET_OutOfBandPrint( NS_SERVER, address, "connectResponse" );
發送”connectResponse”消息給客戶端
否則NET_OutOfBandPrint( NS_SERVER, address, "disconnect" );
發送” disconnect”消息給客戶端
7. 客戶端接收到”connectResponse”,啟動Netchan_Setup,然後調用函數CL_WritePacket()寫入一條空白消息給服務器端.