游戏引擎开发系列——消息循环篇
2010-03-19 17:04:04| 分类: 游戏引擎编程|字号 订阅
写了很多关于IOCP和OLEDB的文章,今天换个话题,不然很多网友都以为俺就是一服务端程序,其实我真正的身份是——程序员,什么都写的程序员,呵呵呵。
这回讨论的话题主要集中到游戏引擎上来,目前国内游戏行业比较火爆,但是基础性的研究、技术资料都比较少,本人撰写这个系列,权当为产业尽绵薄之力。或者当做引玉之砖吧。
关于什么是游戏引擎,现在还是没有一个统一的定论,但是大体的功能结构已经有些比较规范的定义了。当然有些人认为引擎应该大而全,有些人则认为应该小而精,甚至有些人认为它应该是专用的,比如一个RPG类的引擎,你肯定不能随便拿来搞即时战略。而有些人又认为引擎应该是通用的。这些观点都有很多具有代表性的引擎,网络上也有很多开源的引擎代码,大家都可以参考。
我这里讨论的游戏引擎,主要是针对Windows系列平台的,“尤里平台(Unix、linux平台的简称)”的东西我不懂也不讨论,更不会发表任何评论。关于跨平台就更不会去讨论了。
当然在这一系列文章中很多的观点只是我个人的一些见解,如有偏妥之处,欢迎大家来拍砖。毕竟我也不是什么专家,只是个草根程序员而已。用诸葛先生的话说就是末了不知所云。
在这一系列文章中,我将按照比较公认的引擎模块结构,分别讨论一下每个部分的基本原理,核心技术,以及一些实现和优化的方法,当然偏重实战任然是最大的特色。
这次讨论的核心话题是引擎的消息循环,很多网友会对这个话题嗤之以鼻,别忙着关闭文章转向其他文章,让我慢慢来跟你说为什么要先讨论这个话题。
首先、在Windows平台上开发程序,尤其是面向桌面应用的程序,窗口系统是比较核心基础的一个技术,而游戏作为特殊的桌面应用,更是要和窗口以及消息循环打交道,有了好的核心消息循环,可以毫不夸张的说,引擎已经成功的打下了坚实的木桩。
其次、目前我所见过的很多游戏引擎中,核心的消息循环处理的都不是很“Windows”,很多更是用实时死循环来浪费宝贵的CPU时钟周期来定时或延时,严重浪费了大量的CPU资源,并且扩展性不敢恭维。
最后、好的消息循环,可以说是为一个引擎的结构划定了最顶层的纲,所谓纲举目张,有了这个基本保证,那么扩展性、性能、灵活性都会有非常好的基础。好的消息循环尤其是对性能的发挥起到不可替代的作用。我就曾优化一个引擎的核心消息循环,性能提升让我自己都咋舌。因此对消息循环影响引擎性能这个问题有着深刻而实际的体会。
如果性能这个话题提起了你的胃口的话,就请耐心的看下去。
ok,说了这么多口水话,那么就让我们来进入主题,首先看看一般传统的消息循环:
while( (bRet = GetMessage( &msg, NULL, 0, 0 )) != 0)
{
if (bRet == -1)
{
//处理错误或者退出循环
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
这里就有一个与内核同步的函数GetMessage,搞Windows程序的都知道,这意味着如果这个程序没有任何输入或者消息,那么这个循环很多时候都是“静止”的,对于一般的应用程序来说,这是个好的特性,这往往意味着低的CPU占用,对系统、对其它应用程序都是友好的。
但是对于游戏程序来说,这可不是个什么好特性,因为很多时候游戏程序的目标就是充分榨干机器中所有硬件的性能,让出CPU时间是一种非常不明智的举动,况且对于一个需要定时更新场景的游戏引擎来说,这个循环几乎无法使用,因为除非有消息,否则这个循环代表的线程几乎就是不工作的,连基本的更新都保证不了,更不要说是定时了。
于是很多游戏引擎采用了PeekMessage这个非内核同步方式的函数来打造循环,他看起来像下面这样:
fDone = FALSE;
while (!fDone)
{
......
while (PeekMessage(&msg, hwnd, 0, 0, PM_REMOVE))
{
switch(msg.message)
{
.....//处理其他消息
case WM_QUIT://窗口退出
fDone = TRUE;
break;
.....
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
//没有什么消息了,渲染场景
Update();//或者叫Render();
.......
}
在上面这个循环中,因为PeekMessage的非同步特性,所以这个循环看起来总是运行的,这看起来似乎很棒,但是问题远没有这么简单就解决了,这其实仍然是一个无法精确定时的循环,尤其是对渲染函数的调用,几乎是无法控制的,并且如果没有什么消息的时候,它几乎总是被调用的,这对于精确控制帧数,或者精确控制时间动画等都是很不利的,最终将导致有些与速度有关的动画看起来很不自然。而且对于有些游戏来说频繁的渲染其实造成了巨大的浪费。
于是有些引擎采取了死循环延时的方法来控制Update渲染函数的调用,比如某知名引擎就这样编写了消息循环:
for(;;)
{
if(!hwndParent)
{
if (PeekMessage(&msg,NULL,0,0,PM_REMOVE))
{
if (msg.message == WM_QUIT) break;
// TranslateMessage(&msg);
DispatchMessage(&msg);
continue;
}
}
_UpdateMouse();
if(bActive || bDontSuspend)
{
//注意这个死循环 造成CPU高占用率的罪魁祸首
do { dt=timeGetTime() - t0; } while(dt < 1);
if(dt >= nFixedDelta)
{
fDeltaTime=dt/1000.0f;
if(fDeltaTime > 0.2f)
{
fDeltaTime = nFixedDelta ? nFixedDelta/1000.0f : 0.01f;
}
fTime += fDeltaTime;
t0=timeGetTime();
if(t0-t0fps <= 1000) cfps++;
else
{
nFPS=cfps; cfps=0; t0fps=t0;
_UpdatePowerStatus();
}
if(procFrameFunc()) break;
if(procRenderFunc) procRenderFunc();
if(hwndParent) break;
_ClearQueue();
}
else
{
if(nFixedDelta && dt+3 < nFixedDelta) Sleep(1);
}
}
else Sleep(1);
}
上面这个消息循环不是我捏造出来的,是一个较知名的游戏引擎的核心消息循环,它使用了一个死循环do { dt=timeGetTime() - t0; } while(dt < 1);来精确延时,确实这是一种非常不错的想法,这里不去评论写的代码易读性的问题,首先让我们来看看这样做的性能问题,在实际使用这个引擎的过程中,我发现即使游戏是不活动的,即场景相对静止,也没有用户输入,甚至被切换到后台的情况下,这个引擎的CPU占用率也至少在80%以上,有时甚至一直是100%,造成用户机器出现非常卡的问题,其实很多时候CPU都被浪费在这个无聊的死循环上了。然后让我们来看看它的扩展性问题,这里我们看到一个貌似处理鼠标消息的函数_UpdateMouse(),这个函数看上去是主动工作的,这样的缺点就是如果鼠标没有任何动作时,这个主动查询几乎是浪费掉的,实际上我们更希望一种优雅的被动方式来响应鼠标或键盘的输入,当然不是靠那个低效率的鼠标键盘消息。
说道要定时,很多人会立刻想到说,这有何难,设置个WM_TIMER消息不就完了?据非正式统计,还没有游戏引擎消息循环是靠WM_TIMER运作的。当然靠WM_TIMER保持消息循环的“活度”是可以的,但是这种方式因为Windows平台定义的原因,永远是最后一个被放到消息队列中的消息,而且其精度是很糟糕的,已经被很多其他的文章炮轰的体无完肤了,这里我就不再啰嗦叙说它的是是非非了。
ok,好了到这里我们发现以上方式都不是最佳的方案,一个小小的消息循环,其实细想起来还是比较头疼的,更是要花费一番思量。
所谓“众里寻他千百度,暮然回首,那人却在灯火阑珊处!”,其实上面的一些考虑过程只是考虑消息循环结构的开始。接下来让我们深入一些考虑这个问题。
现代的CPU起码都是工作在纳秒级的设备,因为动辄几个G的高频率,并且支持每时钟周期执行n条指令,这样算来一个纳秒内CPU大概可以执行好几条指令,而刷新一帧游戏场景界面的频率顶多就是100fps,再高实际也没有什么意义了(你要搞《阿凡达》那样的3D视觉就另当别论了,呵呵呵),也就是10ms左右渲染一帧画面,这个时间内,如果按照1纳秒执行一条指令的速度算,那么一帧之内CPU大概可以执行10M条指令的水平,这样一来如果让CPU的时间白白浪费在空转的死循环上,显然是一种巨大的浪费。而且我们知道现在的游戏引擎不仅仅需要处理3D/2D的画面,还需要处理物理效果、人工智能、网络、键鼠输入、游戏手柄输入、音效等等,处理这么多任务,10M指令已经显的很力不从心了,这时显然需要一种更高效更合理的安排,让消息循环真正的做“有用功”。
当然现在的CPU基本都是多核的了,更可以预见未来是多核的时代,你也许会想用多线程不就可以了吗?真是这样嘛?其实简单一盘算,多核的效果在理想情况下,无非就是在一帧的时间内多了n个10M指令级的处理能力而已(目前n最大貌似=8),这样带来的好处也只是CPU个数的倍数而已,并不是质的飞跃,而真正有效利用好这些指令才是我们提高性能的真正有效途径。还有另一个好消息就是DX11中已经可以进行多线程渲染了,这也是提高性能的一个方法。
不管怎么样,引擎总是至少需要一个主线程来总控所有的这些逻辑处理,那么一个消息循环究竟要考虑哪些东西呢?答案其实已经有了,第一个要务就是精确定时、第二就是响应玩家输入、第三就是响应网络消息、第四就是做场景变换(人工智能、物理变换),第五就是渲染,还有就是处理音效。要做到这些那么就要一个既能响应消息、又要能与内核同步、还能等待对象状态改变的函数登场了,那就是——MsgWaitForMultipleObjects,这个函数很多人已经不陌生了,那么我也就不多啰嗦了,详细的说明可以直接去查阅MSDN,下面我们就消息循环的几个要素展开看具体的如何实现:
一、定时:
谈到定时,会立即联想到SetTimer、timeGetTime、甚至QueryPerformanceCounter等等,其中除了SetTimer以外,其它的方式都是要主动去查询或计算时间,显然这会引起一些不必要的麻烦,而SetTimer就是靠WM_TIMER工作的,性能就不敢恭维了,那么有没有其它被动工作,而定时又比较精确的方法呢?答案就是CreateWaitableTimer,这个函数会创建一个定时器内核对象,精度非常的高,传说中可以定时100纳秒级的时间,这已经是足够了,其实只要在10ms的渲染周期之内的定时值就足够了,场景变换也无非到这个时间精度就够了,因为再小的时间精度变换,都没有渲染成实际的帧画面,那么也就没有什么意义了。具体的使用就可以看下面的示例代码了:
HANDLE phWait = CreateWaitableTimer(NULL,FALSE,NULL);
LARGE_INTEGER liDueTime.QuadPart=-1i64; //1秒后开始计时
SetWaitableTimer(phWait,&liDueTime, 40, NULL, NULL, 0);//40ms的周期
DWORD dwRet = 0;
BOOL bExit = FALSE;
while(!bExit)
{
dwRet = ::MsgWaitForMultipleObjects(1,phWait,FALSE,INFINITE,QS_ALLINPUT);
switch(dwRet - WAIT_OBJECT_0)
{
case 1:
{//计时器时间到
SceneFun(); //场景变换
Render(); //渲染
}
break;
case 1 + 1:
{//处理消息
while(::PeekMessage(&WndMsg,NULL,0,0,PM_REMOVE))
{
if(WM_QUIT != WndMsg.message)
{
::TranslateMessage(&WndMsg);
::DispatchMessage(&WndMsg);
}
else
{
bExit = TRUE;
}
}
}
break;
default:
break;
}
}
上面的就是加上了精确定时改造后的消息循环了,因为MsgWaitForMultipleObjects是个内核同步函数,所以它既可以等待消息也可以等待内核对象,整个的主线程消息循环就变成了优雅的被动工作方式,不用担心那个定时器会像WM_TIMER一样糟糕,它精确的会让你吃惊,上面的例子中设定了40ms刷新一次场景,那么大概就是25fps,这是个很低的帧数,你可以把40这个常量定义成变量,然后根据需要动态调整这个值。也可以为此设计一个自动动态计算延时值的函数来动态调整每帧之后的延时值,当然这需要依赖于当前渲染场景时间周期的一个统计平均值,然后在每次渲染结束后再调用一下SetWaitableTimer。
最后跟其它的内核对象一样,CreateWaitableTimer返回的句柄再不需要的时候就可以用CloseHandle关闭。
CreateWaitableTimer的第二个参数,表示是否手动Reset一下定时器内核对象的状态,传入FALSE表示要自动Reset。有些时候,这并不是很好,尤其是复杂场景变换过程中有可能会耗费比较长的时间,这时,可以将这个值设定为TRUE,由我们自己在需要的时候重置这个标志,并进入下一个循环等待状态,不然会出现,前一帧还没有渲染完,而后一个定时的时间又到了,出现不必要的循环后跳帧的状态。如果是手动的重置的话,就需要再次调用SetWaitableTimer将对象置为无信号状态。
二、输入响应:
对于输入来说,有一些引擎直接就是用了Windows键鼠消息,当然对于一般的游戏来说这足够了,但是对于一些需要有微操作或者需要极高用户体验的场合,Windows消息就显得反应迟钝了,另外如果要使用游戏手柄等其他设备时Windows消息就更是无能为力了,这时必须使用DirectInput,如其名一样“直接输入”,没有比这更直接的方式了,很多时候很多程序员对DInput很迷惑,只是知道它性能高,但是为什么比Windows消息性能高,就真的不知所以然了,其实根本原因是因为,在Windows内部,键鼠产生的消息是由驱动先放到系统内核的输入消息队列上,然后再由系统内核线程把它取出来,丢到对应窗口线程的消息队列中,最后才由消息循环取出来,然后在Dispatch到真正响应消息的窗口的窗口过程中,这个过程由过程就可以看出是非常的低效的,而DInput则绕开了这些繁琐的过程,让程序具备了直接访问硬件驱动数据的能力,即“直接输入”,直接拿到输入数据,这就是为什么DInput要比Windows消息快的原因。
传统的教程中,多是使用主动调用IDirectInputDevice8函数的GetDeviceData方法得到输入的数据,这种方式如前所述,不是很合理的一种方式,也会造成一定的浪费,因为输入最终是人产生的,人的反应再快也是没有现在CPU的运算速度快的,让一个快速工作的CPU不断的访问一个低速产生数据的设备,这显然是一种不太明智的方法。这种时候,可以通过创建一个Event内核对象的方法,来让输入产生一个通知,然后再由消息循环去取数据。具体的例子如下:
LPDIRECTINPUTDEVICE8 pIKeyboard = NULL;
LPDIRECTINPUTDEVICE8 pIMouse = NULL;
LPDIRECTINPUTDEVICE8 pIJoystick = NULL;
//初始化并创建DirectInput
..............
HANDLE hMouseEvent = CreateEvent( NULL,FALSE,FALSE,NULL );
HANDLE hKeyboardEvent= CreateEvent( NULL,FALSE,FALSE,NULL );
HANDLE hJoystickEvent = CreateEvent( NULL,FALSE,FALSE,NULL );
HANDLE ah[3] = { hMouseEvent, hKeyboardEvent,hJoystickEvent };
//为DirectInput设备设定Event对象
pIKeyboard->SetEventNotification(hKeyboardEvent);
pIMouse->SetEventNotification(hMouseEvent);
pIJoystick->SetEventNotification(hKeyboardEvent);
while (TRUE)
{
dwResult = MsgWaitForMultipleObjects(3, ah, FALSE, INFINITE, QS_ALLINPUT);
switch (dwResult)
{
case WAIT_OBJECT_0: //鼠标事件
ProcessMouseEvent1();
break;
case WAIT_OBJECT_0 + 1: //键盘事件
ProcessKeyboardEvent2();
break;
case WAIT_OBJECT_0 + 2: //游戏手柄事件
ProcessJoystickEvent2();
break;
case WAIT_OBJECT_0 + 3: //Windows消息
while(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
break;
default:
break;
}
}
上面的例子中利用了MsgWaitForMultipleObjects可以等待多个内核对象的特性,并利用IDirectInputDevice8::SetEventNotification接口函数,为设备设置事件对象,这样一来,消息循环就只在真的有输入时才去响应输入,而且也是直接读取输入,所以性能上并没有损失,因为Event内核对象和等待函数的工作性能要比Windows消息方式的工作性能至少高几个数量级,所以不用担心等待函数会消极怠工。甚至在Windows NT以上的内核中,专门针对等待函数对系统线程调度函数做了各种优化,几乎可以认为等待函数与内核对象的状态改变是同时发生的一件事情,或者理解为,对象状态一变化,等待的线程就被立即执行了,这也是为什么有些时候称这些对象为“内核同步对象”的原因之一。因此上面的方案比之主动去取数据的方式是没有任何性能损失的,相反这种被动的工作方式大量的节约了无效轮询输入设备的操作,充分节约了CPU指令。
三、网络消息响应:
对于一个一般的游戏引擎来说,网络不是一个必选项,之所以还要再讨论下这个话题,是因为现在国内貌似只有网游赚钱了,所以不带网络功能的引擎,可以认为是一个被阉割的引擎,使用的时候还要自己考虑网络部分怎么整合,实在是不太方便。讨论这一话题的另一原因是因为这里假设网络部分是在完全不同的线程中运行,甚至是在另一个CPU内核上运行,以后引擎的发展也会朝着真正多线程的方向发展,因此讨论下线程间的这种同步与消息循环整合的问题有着一种十分现实的意义。
在本人博客其它几篇关于IOCP的文章中已经讨论了一些网络部分的内容,这里就不在赘述了,只是假设这里的网络模块也使用IOCP单线程网络模块的模式。首先网络模块有一个自定义的网络消息队列,当网络模块收到消息时,先将消息包如这个队列,然后将两个线程都公用的Event句柄置为有信号状态,而主消息循环等到这个状态之后就立刻从这个网络消息队列中读取消息包,依次处理之。大概的模型代码如下:
//全局变量,在主线程启动时初始化
HANDLE g_hNetEvent = NULL;
//网络模块线程
DWORD WINAPI IOCP_ThreadProc(LPVOID lpParameter)
{
......
SetEvent(g_hNetEvent);
......
}
//主消息循环
g_hNetEvent = CreateEvent(NULL,FALSE,FALSE,NULL);
CreateNet(); //创建网络模块并启动网络线程
DWORD dwRet = 0;
BOOL bExit = FALSE;
while(!bExit)
{
dwRet = ::MsgWaitForMultipleObjects(1,&g_hNetEvent
,FALSE,INFINITE,QS_ALLINPUT);
switch(dwRet - WAIT_OBJECT_0)
{
case 1:
{//网络消息包
PopMsg();//弹出网络消息包
ProcessMsg();//处理网络消息
}
break;
case 1 + 1:
{//处理Windows消息
while(::PeekMessage(&WndMsg,NULL,0,0,PM_REMOVE))
{
if(WM_QUIT != WndMsg.message)
{
::TranslateMessage(&WndMsg);
::DispatchMessage(&WndMsg);
}
else
{
bExit = TRUE;
}
}
}
break;
default:
break;
}
}
这样两个线程的工作职责非常明确,一个线程响应网络传输,而主线程负责总控游戏住消息循环,这样就比较好的解决了其它线程与主线程沟通的问题。
当然,网络线程可以做的更多,比如网络线程接到网络消息后,先不入队列,而是先判断是什么网络消息,如果是更新场景的消息,那么就直接去更新场景,这样就可以部分减轻主消息循环更新场景处理的压力,但是这样做的缺点就是场景必须能够适应多线程的并发处理,这对于场景逻辑结构的设计是一个不小的挑战。好在现在已经有很多并发类型的数据结构可以借鉴利用了,有些结构已经有很好的封装实现了,可以直接拿来利用。
四、音效处理:
声音是游戏的灵魂,一个引擎如果没有了声音的支持,仍然是一个不完整的引擎......(此处省去n多字)。幸好有DirectSound,游戏从此有了灵魂。尤其是哪动人心魄的3D音效,更是让一款游戏增色不少。
对于声音的控制,很多引擎中只是简单的封装了一下DirectSound,能够Play Stop就万事大吉了,当然一般的应用这足够了,但是对于一个精品游戏来说,这是不够的,起码没法做到界面声音的同步等效果,要同步声音,起码就需要知道声音播放到哪一帧了,要做到这点其实也很简单,只需要通过IDirectSoundNotify8::SetNotificationPositions函数设定指定播放到的位置以及对应的通知Event对象的句柄即可,而主线程就可以在这些Event句柄上等待即可,这种方式与使用DirectInput方式类似,就不给出示例代码了。大家可以自己做实验试一下。
五、总结:
有了上面一系列的知识,那么怎样做一个好的消息循环,以及如何集成DX中各个部分还有网络模块应该就很清楚了。组合时无非就是定义一个HANDLE的数组,把所有的句柄都放到这个数组中,然后统一MsgWaitForMultipleObjects。当然如果你反感后面的那个巨大的switch语句,你还可以对应建立一个函数指针的数组,然后按照MsgWaitForMultipleObjects函数的返回值调用对应索引处的函数即可。这两个数组以及MsgWaitForMultipleObjects函数都可以封装到一个统一的类中进行维护,这样就可以做到比较好的封装性,只是代码可能会比较难读一些。另外这种方式本身就具备了巨大的灵活性和可扩展性,因为HANDLE可以代表n多种内核对象。
这里的所有例子其实可以简单的合成一个比较完整的消息循环。而我就偷个懒不给出全貌的例子了,大家可以自行动手实现之。
总之,这里谈到的消息循环核心就是围绕“被动”这种工作模式,组合了各个可能的部分,而不是“主动”去延时、轮询输入、检测网络等,核心的目标就是充分利用系统内核同步的机制,节约CPU到更有用的指令上去,比如例子中假定的SceneFun()和Render()等函数上,这些函数才应当是CPU指令消耗的大户,任何其他的浪费都是没有理由和意义的