转载请注明出处:【译】Doom3源码剖析(1/6)——引论
在2011年11月23号,id Software继续维持他们开放源码的作风,开放了他们先前游戏引擎的源代码。这次公布的源码是idTech4,这款游戏引擎曾用来制作猎魂,雷神之锤4,当然还有毁灭战士3。公布源代码之后数小时之内,Github上就已经fork了400多次。同时人们开始探索这款游戏的内部实现机制,并试着将该游戏转移到其他平台上。我(下文均指本文作者)也立即实现了Mac OS X Intel 版本,并得到了John Carmack的肯定。
根据别人的评价,这次新发布的引擎是自从Doom iPhone发布后最好的代码了。我强烈推荐每个人来阅读这份代码,编译并运行它。
这份笔记记录了我对代码的理解,照例,我好好整理了一下这份笔记:我希望这可以帮助到一些人,我也希望这可以鼓励我们中间的一些人去读更多的代码,并成为更优秀的程序员。
从笔记到文章…
我意识到我将使用越来越多的图片以及越来越少的文字来解释代码。如今我已经使用gliffy(一种制作流程图的工具)去作图,但是这个工具有些限制(比如缺少alpha channel(此处不明,可能作者是要表达图形不能设置透明度吧))。我在想是否存在一个专门用来剖析3D引擎的工具,这个工具将使用SVG(可缩放矢量图形)和Javascript,不过我怀疑这样的工具是否存在?咳咳,扯远了,回到代码上…
背景
着手剖析这样一个史无前例的源代码是一件让人兴奋的事。2004年Doom3的发布,意味着它在视觉效果和音频效果上成为实时引擎上的一个新标准。最有名的就是游戏中使用的“Unified Lighting and Shadows”(使用了一个统一的光照阴影模型来实时渲染场景中的光照和阴影,这意味着光照是全局光照,而非局部光照,另外阴影可能使用了阴影体(锥)技术,上课时老师说Doom3确实使用了阴影体(锥)技术)。这种技术的出现使艺术家可以做出好莱坞一样的效果。即使8年后(这篇文章写于2012年6月8号),当你在游戏中的德尔塔实验室4区遇到HellKnight时,仍会被惊艳到。(原网址上图片显示不出来,在网上盗图一张)
初次接触
目前Doom3源码发布到Github上了,而不是之前的FTP服务器上,因为id Software公司的FTP服务器总是当机或过载。
最初的发布版本来自TTimo,可以使用VS2010专业版进行编译。不幸地是,VS2010 Express版本因为缺少MFC而不能使用。这确实令人失望,但是有些人已经移除了代码中对MFC的依赖,想要无MFC的代码请猛戳这里。
获取Doom3的VS工程:
Window 7 : ========== git clone https://github.com/TTimo/doom3.gpl.git
注:我(真的是我)使用的是git clone https://github.com/id-Software/DOOM-3.git。不过这份源码也是从TTimo fork过来的,主要因为我使用TTimo版本源码会报一个诡异的编译错误。
打开neo文件夹下的doom.sln。
对于阅读代码和剖析代码,我更喜欢使用Mac OS X上的XCode 4.0:因为它的搜索速度很快,支持语法高亮,并且可是使用Mac中的“Common键+鼠标左键”可以快速找到函数和变量的定义位置,这些功能给我一种超越VS的体验。XCode的工程有些小问题,但是经过几步简单修复即可。并且现在在Github上有一个“bad sector”的repository,这个repository上有一份源代码可以在Mac OS X Lion上很好运行。
获取Doom3的Xcode工程:
MacOS X : ========= git clone https://github.com/badsector/Doom3-for-MacOSX-.git
打开工程。
注意:貌似VS2010在装了Visual Studio 2010 Productivity Power Tools后,可以使用语法高亮和“Control-Click”功能。我很难理解为什么不把这些平常的功能集成到VS2010中。
现在两份代码都已经准备就绪:只要轻轻按下一个键就可以生成可执行文件了!
- 下载代码
- 按下F8/Command-B
- 运行!
Trivia:为了运行游戏,你必须把包含Doom3游戏资源的base文件夹覆盖到源代码的base文件夹里,因为源代码的base文件夹里面是空的,除了一个空的default.cfg。因为我不想浪费时间从Doom3的CD中解压资源文件,然后还要更新到1.3.1版本。我直接下载Steam版本。看上去id Software团队也做了同样的事,因为在VS工程的调试设置中,仍然存在“+set fs_basepath C:\Program Files (x86)\Steam\steamapps\common\doom 3”(你可以修改该路径以符合你的源代码位置)。
Trivia:游戏引擎的开发是用Visual Studio .NET(源码),但是代码里面却没有一行C#代码,并且为了正常编译,应使用VS2010专业版。
Trivia:Id Software开发团队看上去是黑客帝国的骨灰粉:Quake Ⅲ工作目录名称是“Trinity”(黑客帝国中的角色),而Doom3的源码都放在名叫“neo”(黑客帝国中的角色)的子目录下。
架构
整个工程被分为好几个解决方案,这些解决方案就反应了整个引擎的架构:
Projects | Builds | Observations | |
Windows | MacO SX | ||
Game | gamex86.dll | gamex86.so | Doom3 gameplay(Doom3原版的游戏逻辑) |
Game-d3xp | gamex86.dll | gamex86.so | Doom3 eXPension (Ressurection) gameplay(Doom3邪恶复苏的游戏逻辑) |
MayaImport | MayaImport.dll | - | Part of the assets creation toolchain: Loaded at runtime in order to open Maya files and import monsters, camera path and maps.(运行时加载游戏资源文件,包括打开Maya模型文件,导入怪物数据,相机路径数据以及地图文件) |
Doom3 | Doom3.exe | Doom3.app | Doom 3 Engine(Doom3游戏引擎部分) |
TypeInfo | TypeInfo.exe | - | In-house RTTI helper: Generates GameTypeInfo.h : A map of all the Doom3 class types with each member size. This allow memory debugging via TypeInfo class.(这个不是很明白,好像是用TypeInfo类封装了所有的Doom3的类类型的成员大小,这样方便了开发中的内存调试,即内部支持RTTI机制(运行时类型信息)) |
CurlLib | CurlLib.lib | - | HTTP client used to download files (Staticaly linked against gamex86.dll and doom3.exe)(在客户端使用HTTP协议下载文件). |
idLib | idLib.lib | idLib.a | id Software library. Includes parser,lexer,dictionary ... (Staticaly linked against gamex86.dll and doom3.exe).(id Software自己的代码库,包括解析器,词法分析器,字典数据结构...) |
自从idTech2引擎后,每个引擎的里面都可以找到一个闭源的二进制代码(doom.exe)和一个开源的动态链接库(gamex86.dll)。:
自从2004年10月,大多数的源码可以通过Doom3 SDK获得:Doom3中仅仅缺少了Doom3的可执行代码,很多Modders 可以编译idlib.a和gamex86.dll,但是引擎核心仍然闭源。
Note:该游戏引擎并未使用C++标准库:所有的容器(map,linked list…)都重新实现了,而libc在代码中被大量使用。
Note:在游戏各个模块中,每个类都是对idClass类的扩展。这种方法允许引擎实现内部RTTI机制,并且通过类名实例化该类。
Trivia:从上图可以看到Doom3.exe工程的一些基本框架(比如Filesystem)。因为gamex86.dll也需要加载游戏资源(而Doom3.exe中已经有Filesystem),所以会存在一个重复利用的问题。从上图可以看到一些Doom3.exe中的子系统可以由gamex86.dll动态地加载(就是上图中箭头所表达的内容)。如果我们对DLL使用PE explorer进行分析,我们会发现gamex86.dll导出了一个方法:GetGameAPI(从名称可以看出,是用来获得Doom3.exe中的API,比如那些加载资源的子系统的API):
GetGameAPI的实现方式和这篇文章(Quake2如何加载render和game的dlls)(后面有时间会翻译下这篇文章,对于使用C来实现OO很有感触)一样:交换指针对象。
当Doom3.exe启动时:
- 使用LoadLibrary来加载DLL到Doom3.exe的运行内存中。
- 使用win32的GetProcAddress来获得dll中的GetGameAPI的地址。
- 调用GetGameAPI。
gameExport_t * GetGameAPI_t( gameImport_t *import );
最终Doom3.exe和Gamex86.dll进行交互时是通过下面所述的方式:Doom3.exe拥有一个idGame的指针,而Game.dll有一个gameImport_t的指针(gameImport_t中又包含了其他子系统,如idFileSystem)
以Doom3可执行对象的角度看Gamex86.DLL(翻译得不知所云,见谅!)(大概意思是Gamex86.DLL中封装的Doom3.exe的功能模块,这样Gamex86.ex就可以和Doom3.exe进行交互了,下面一段是同样的道理)
typedef struct { int version; // API版本 idSys * sys; // 可移植的系统服务 idCommon * common; // common(不好翻译) idCmdSystem * cmdSystem; // 控制台命令系统 idCVarSystem * cvarSystem; // 控制台变量系统 idFileSystem * fileSystem; // 文件系统 idNetworkSystem * networkSystem; // 网络系统 idRenderSystem * renderSystem; // 渲染系统 idSoundSystem * soundSystem; // 音频系统 idRenderModelManager * renderModelManager; // render model manger(管理渲染的模型) idUserInterfaceManager * uiManager; // UI接口的管理 idDeclManager * declManager; // 声明的管理,感觉有C11中的decltype的类似功能 idAASFileManager * AASFileManager; // AAS文件的管理 idCollisionModelManager * collisionModelManager; // 碰撞模型的管理 } gameImport_t;
Doom3中封装的Game/Modd对象
typedef struct { int version; // API版本 idGame * game; // 运行游戏的接口 idGameEdit * gameEdit; // 游戏编辑的接口,估计是给Modders用的吧 } gameExport_t;
Notes:有一个介绍这些子系统的好资源,Doom3 SDK 文档(后面有时间会翻译下这篇文章)。看上去这篇文章对2004年的代码理解很深(估计是开发组的成员写的)
代码
在深入钻研之前,我们先使用cloc(代码统计工具)分析下代码:
./cloc-1.56.pl neo 2180 text files. 2002 unique files. 626 files ignored. http://cloc.sourceforge.net v 1.56 T=19.0 s (77.9 files/s, 47576.6 lines/s) ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- C++ 517 87078 113107 366433 C/C++ Header 617 29833 27176 111105 C 171 11408 15566 53540 Bourne Shell 29 5399 6516 39966 make 43 1196 874 9121 m4 10 1079 232 9025 HTML 55 391 76 4142 Objective C++ 6 709 656 2606 Perl 10 523 411 2380 yacc 1 95 97 912 Python 10 108 182 895 Objective C 1 145 20 768 DOS Batch 5 0 0 61 Teamcenter def 4 3 0 51 Lisp 1 5 20 25 awk 1 2 1 17 ------------------------------------------------------------------------------- SUM: 1481 137974 164934 601047 -------------------------------------------------------------------------------
代码的行数并不能代表一切,但是这对理解这款引擎所花费的精力有一个很好的评估。601047行的代码量使理解难度看上去是Quake Ⅲ的两倍。下图从代码行数角度来看id Software引擎的历史。
#Lines of code | Doom | idTech1 | idTech2 | idTech3 | idTech4 |
Engine | 39079 | 143855 | 135788 | 239398 | 601032 |
Tools | 341 | 11155 | 28140 | 128417 | - |
Total | 39420 | 155010 | 163928 | 367815 | 601032 |
Note:在idTech3中的代码量的巨大提升主要是因为lcc代码(这是C编译器用来产生QVM字节码的,我估计是模仿Java的JVM的吧,呵呵,随便猜猜…)
Note:Doom3中没有Tool,因为Tool集成到游戏引擎中了。
从引擎的顶层设计来看,这里罗列了一些有趣的事实:
- 这是id Software第一次在代码中使用C++而非C。John Carmack在我们的Q&A中对此进行了详细说明。
- 代码中使用了很多抽象和多态。但是使用了一个非常漂亮的技巧来避免某些对象在使用虚函数表时的性能损失。
- 所有的游戏资源都以人类可读的文本形式进行存储,没有二进制形式。所以代码中广泛使用了语法分析器和解释器。John Carmack对此进行了详细说明。
- 模板只在引擎底层的实用(utility)类里使用(主要是在idLib使用模板),你绝不会在顶层设计看到模板。所以不会有看到Google的V8时那种刺瞎狗眼的感觉。
- 这款代码的注释是id Software发布的代码中第二好的。唯一比它好的是Doom iphone,可能是因为它比Doom3发布晚吧。30%的注释可以称得上杰出,并且你很难在别的工程中发现这样好的注释了。在有些部分(比如dmp),注释确实比一般性的叙述更多。
- OOP 的封装是代码看上去更干净,而且也易于阅读。
- 底层汇编的优化时代已经过去了。一些技巧比如idMath::InvSqrt(神作),还有一些本地化的优化,但是大部分代码使用了我们可用的工具进行优化(比如GPU Shaders,OpenGL VBO,SIMD,Altivec(一个浮点和整型的单指令流多数据流(SIMD)指令集),SMP(对称多处理机),L2 Optimizations(每个模型处理过程中的
R_AddModelSurfaces很类似L2
))。
看下idTech4的代码规范(镜像pdf)也很有意思。这份代码规范是由John Carmack确立的(我非常欣赏关于const使用的介绍)。
展开主循环
下面是引擎中最重要的部分——主循环(暂时没有深入研究,大体上感觉使用了多线程,然后在一个while(1)的循环中进行游戏的更新,如游戏逻辑,渲染等等)。
idCommonLocal commonLocal; // OS Specialized object idCommon * common = &commonLocal; // 使用了接口指针(因为Init是依赖OS的,它是一个抽象方法) int WIINAPI WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow ) { Sys_SetPhysicalWorkMemory( 192 << 20, 1024 << 20 ); //Min = 201,326,592 Max = 1,073,741,824 Sys_CreateConsole(); // 因为引擎是多线程的,所以在此处进行初始化。每个代码临界区对应一个互斥量。 for (int i = 0; i < MAX_CRITICAL_SECTIONS; i++ ) { InitializeCriticalSection( &win32.criticalSections[i] ); } common->Init( 0, NULL, lpCmdLine ); // 获取到有多少显卡可用 (不是用OpenGL而是用系统调用来确定的l) Sys_StartAsyncThread(){ // 下一帧由另一个线程运行 while ( 1 ){ usleep( 16666 ); // 以60Hz速度运行 common->Async(); // Do the job Sys_TriggerEvent( TRIGGER_EVENT_ONE ); // 解锁其他线程 pthread_testcancel(); // 检测是否当前线程被主进程取消了 } Sys_ShowConsole while( 1 ){ Win_Frame(); // 显示/隐藏控制台 common->Frame(){ session->Frame() // 游戏逻辑部分 { for (int i = 0 ; i < gameTicsToRun ; i++ ) RunGameTic(){ game->RunFrame( &cmd ); // 从此处开始,代码跳到GameX86.dll的地址空间去执行. for( ent = activeEntities.Next(); ent != NULL; ent = ent->activeNode.Next() ) ent->GetPhysics()->UpdateTime( time ); // ,主要是更新entities,比如进行一些物理碰撞什么的,不知道AI是不是也在这处理? } } session->UpdateScreen( false ); // 更新屏幕画面 { renderSystem->BeginFrame idGame::Draw //前台渲染. 并不和GPU进行交互 !! renderSystem->EndFrame R_IssueRenderCommands // 后台渲染. 发射GPU优化指令到GPU上. } } } }
更多细节请参见完整的循环展开,当我阅读代码时,我会用这段循环来作为一条主线进行学习。
这是id Software引擎系列的标准主循环。除了Sys_StartAsyncThread的使用意味着Doom3使用了多线程。引入多线程的目的是为了除了对时间要求高(time-critical)的函数,这样就可以减少对帧率的限制。下面两个方面对时间要求就很高。
- 混音
- 用户输入
Trivia:idTech4的高层类对象都使用了基于虚函数的抽象类。这会带来性能损失,因为运行时查找虚函数表需要一些很多时间。但是这里使用了一个技巧,所有的对象都被静态的实例化(但是我看代码好像是定义成了全局对象,可能文中指的静态包括全局吧,毕竟这两者都是放在内存的数据段,不是很明白),注意下面idCommonLocal和idCommon都是接口类:
idCommonLocal commonLocal; // Implementation idCommon * common = &commonLocal; // Pointer for gamex86.dll
因为一个对象被静态分配到内存的数据段后,比如commonLocal方法在调用时,编译器可以优化掉在虚函数查找虚函数表时的损失。这个接口指针在gamex86.dll和doom3.exe交互时使用,所以doom3.exe可以和gamex86.dll通过对象引用进行交换信息,但是在这个例子中不能优化掉虚函数表的性能损失(有点不明白,上面说可以,下面又说不可以?)。
Trivia:看了很多id Software的引擎后,我发现很多方法名从doom1以来就没有变过:比如鼠标和游戏手柄的输入一直用的就是IN_frame()。
渲染器
两个重要的部分:
- 因为Doom3使用了一个入口系统(portal system,我估计指的就是一个扩展系统,可以把别的工具或组件扩展到引擎中)。比如场景预处理工具dmap就完全从传统的bsp场景构建中分离。我在这里对其进行了更深入的讨论。下图显示了一个典型的bsp树的构建。
- 运行时的渲染器有一个很有意思的架构,因为它把渲染分为了前台和后台:更多细节请戳这里。
性能剖析
我是用了Xcode的Instrument工具来分析代码的运行耗时。分析结果请戳这里。
脚本和虚拟机
每个版本的idTech都会更新它的虚拟机和脚本语言,这次也不例外。具体细节请戳这里。
访谈
当阅读代码时,有些新奇的地方让我迷惑不解,因此我给John Carmack写信,他也非常友好地给我进行了详细而有深度的讲解,包括以下内容:
- C++
- 渲染器为啥要分成两个部分
- 基于文本的游戏资源
- 解释型的字节码
我把所有关于idTech4的视频和访谈报告整理并放在了这里。
推荐读物
如果你很喜欢Doom的话,推荐下面两本书。
其他
夏天来啦,很久没关注了…
…毕竟读idTech4不是一件容易的事。而idTech5的源码不会很快发布(如果会的话),我将看看idTech3(Quake Ⅲ)的源码。如果很多人感兴趣的话,我会写点关于idTech3的东西。