在 Windows 平台下写游戏,相比 console 等其它平台,最麻烦之事莫过于让游戏窗口于其它窗口良好的相处。
即使是全屏模式,其实也还是一个窗口。如果你不去跑窗口的消息循环,一个劲的刷新屏幕,我估计要被所有 Windows 用户骂死。
那么怎样让你的游戏程序做一个 Windows 下的良好公民呢?
最简单的方法是用循环用 PeekMessage 来处理 Windows 消息,一旦消息队列为空,就转去跑一帧游戏逻辑,这帧逻辑完成后游戏屏幕也被刷新了一帧。主循环代码大概看起来是这样的:
for(;;) {
MSG msg;
while(PeekMessage(&msg,NULL,0,0,PM_REMOVE)) {
if (msg.message==WM_QUIT) {
goto _quit;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
render_frame();
}
_quit:
现在大部分游戏,尤其是许多 3d 游戏,大概就是这样的框架吧。如果你用 MFC 的话,消息循环的代码不由自己写,那么 render_frame()
一般就 OnOdle 里面。OnIdle 的位置大置和这段代码中 render_frame()
的位置相当。
这么干其实并不是一个 Windows 下的好公民。因为程序主循环一跑起来,虽然处理了窗口系统上的各种消息,但是几乎占据了所有的 CPU 资源。
为了不让游戏显得那么霸道,最简单的方法是在 render_frame()
调用一下 Sleep ,甚至只是 Sleep(0) 也可以暂时让出 CPU 。可惜这并不解决根本问题。
对以上方式的改进是对游戏限帧,然后在 render_frame()
时检查是否过了当前帧的时间段,只要没有超时,就一直等待下去。其实,就是加一个一个不断查询时刻并让出 CPU 的循环。这样看起来会好一些,只要你的 render_frame()
负担不是很重且 CPU 足够快的话,运行起来 CPU 占有率很相对低很多。
这种方式在现代的许多 2d 游戏中用的很多。但是 3d 游戏大多不这样用,由于各种原因,几乎所有的 PC 上的 3d engine/游戏 都追求所谓的高帧率。三位数的 fps 数不光是 engine 制作者的追求,也是众多玩家的梦想。实际上,太低的帧率也的确让 3d 游戏的操作感下降,通过 Sleep 让出 CPU 的方式,也就是通知 os 暂时放弃控制权,难以将游戏维持在一个恒定的高 fps 数上。
退一步说,即使我们容忍游戏固定在一个较低帧率上,以上改进方案依然不够优雅。Windows 整个是以消息驱动方式工作着,非消息驱动的模式的桌面程序在 Windows 平台下都仿佛是个异种。
那么,可以让游戏用 Windows 的 Timer 来驱动吗?利用 Windows 的 timer 消息,似乎我们也可以完成游戏中的动感画面,而不需要脱离消息循环。那么主循环只需要写成:
while(GetMessage(&msg,NULL,0,0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
这样就够了,设置 Timer 在消息循环中相应 WM_TIMER ,解决方案似乎很完美。GetMessage 在没有消息时完全阻塞等待,os 会帮我们处理好 CPU 的利用问题。
实际上,大部分游戏程序员都不会选择这个方案。主要原因是,WM_TIMER 这个东西太不可靠了。
而不能信任 WM_TIMER 的主要原因则是精度不够。在 Windows 98 下,只有可怜的不到 20Hz 的频率。这显然不能满足大多数交互性强的游戏的需要。Windows NT 下要好一点,可以达到 100Hz ,这才差不多够用。
即使有足够精度的 timer ,我们依然不能直接使用 WM_TIMER
这个消息。因为它并不能完全准确的表示时刻。它和 WM_PAINT
一样,属于比较特别的消息:优先级低,在消息队列中同时只存在一份(即多个同样的消息可能被合并)。
不过这个思路走下去却行的通,云风这个给出自己的一个解决方案:
首先,我们需要自己实现一个定时器,并可以调节精度。也就是说,定时器的时间单位可以是 ms 甚至ns 都没关系,我们设定一个精度后,可以把精度范围内的事件都当成同时发生的。
这样一个定时器很容易实现,因为一般游戏动画的控制需要的定时器间隔都很短,假如我们把精度设为 1/100 秒的话。开一个 100 的队列数组,把需要设的定时器按精度量化后,只需要放在数组指定位置的队列中去就可以了。这个数组也可以随着时间流逝循环使用。
Windows 的 Timer 消息只用来触发我们自己的定时器系统,把最近的一个事件的时刻告诉 os 。那么到那个时间来临时,消息循环就被唤醒。然后我们可以不段的查询当前时刻,并依次处理自己的定时器系统中应该被执行的那些事件就可以了。
我们的定时器不光为了控制游戏的帧率,可以让游戏对象中的每一个对象都受单独的定时器事件控制。那么何时刷新游戏画面呢?只需要设置一个需要被刷新的事件,类似 WM_PAINT
消息就可以了。(但不建议直接发 WM_PAINT
消息,以便于跟系统产生的 WM_PAINT
相区分)
为了解决 Windows 的定时器精度问题,我们可以使用 WaitableTimer 这个内核对象。具体可以查询 msdn 上关于 CreateWaitableTimer 和 SetWaitableTimer 的说明。
另外我们在创建一个自己的 Event 用于刷新画面的事件通知。
这样,主循环就变成了这个样子:
#define EVENT_TIMER 0
#define EVENT_RENDER 1
#define EVENT_TOTAL 2
static HANDLE g_event[EVENT_TOTAL];
/* 初始化 */
g_event[EVENT_TIMER]=CreateWaitableTimer(NULL,FALSE,NULL);
g_event[EVENT_RENDER]=CreateEvent(NULL,FALSE,FALSE,NULL);
/* 主循环 */
for (;;) {
DWORD result=MsgWaitForMultipleObjects(
EVENT_TOTAL,g_event,FALSE,INFINITE,QS_ALLEVENTS);
switch (result-WAIT_OBJECT_0) {
case EVENT_TIMER:
/* 处理 timer */
process_timer();
break;
case EVENT_RENDER:
render_frame();
break;
case EVENT_TOTAL: {
MSG msg;
while (PeekMessage(&msg,NULL,0,0,PM_REMOVE)) {
if (msg.message==WM_QUIT) {
goto _quit;
}
else {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
break;
}
}
}
_quit:
具体的代码就不多贴了,提供一个思路而已。用 Windows 的 WaitableTimer 来触发自己实现的 timer 设施,并用一个额外的 event 来通知画面重绘。
当我们调整自己的 timer 设施的精度时,同时也调整了程序的 cpu 占用率。