简介《Windows游戏编程大师技巧第二版》的随书源码光盘文件已完整整理为zip压缩包共1035个文件、总大小38.49MB。压缩包内按章节demo目录划分对应书中各章节示例包含159个cpp源文件与25个h头文件可直接查看核心实现另有140个exe演示程序能在未编译环境下直接运行观察效果同时配齐518个bmp位图、27个pal调色板、99个wav音效、11个mid音乐、19个scn场景、11个cur光标等素材可完整复现书中的位图动画、调色板变换、透明贴图、DirectDraw页面翻转、DirectSound音频播放等经典游戏编程技术。读者可结合源码、头文件与素材资源进行对照学习理解早期Windows游戏中的主循环结构、窗口消息处理、离屏表面绘制以及音效加载等基础实现对于研读此书或希望掌握传统游戏开发思路的人是一份难得的原始参考。目前已有492人学习完整度和学习价值均高于网络零散代码。1. Windows游戏编程大师技巧(第二版) 源码一套能当框架模板学习的DirectX游戏代码包拿到这份源码.zip解压后你看到的是DirectX 8时期的一批Win32工程项目它们不是教学玩具而是当年PC游戏里真实在用的那套东西窗口消息循环、DirectDraw表面翻转、DirectInput设备轮询、DirectSound音频缓冲。在今天直接双击工程编译基本必然翻车但这正是它的价值所在——你能亲眼看到老游戏引擎最核心的骨架是怎么组织的。适合两类人一是想补PC游戏底层基础、搞懂主循环和渲染管线来龙去脉的新手二是手里有老项目要迁移到现代API、需要参考旧逻辑的熟手。这篇就按“跑起来→拆模块→提取框架→避坑→迁移”的顺序把它讲透。2. 把这套源码在本地跑起来编译器配置与最小构建流程2.1 先想清楚为什么绕不开老版DirectX SDK这套源码里的DirectDraw、DirectInput、DirectSound 8代接口在今天默认安装的Windows SDK里已经找不到对应的头文件和导入库了。从较新版本的Windows SDK开始系统只带Direct3D 11/12和通用的系统组件老一代游戏接口的头文件被整体移出。所以你要做的第一件事不是改代码而是装上最后一代独立发布的DirectX SDK组件包常被称为2010年6月版并把它的Include和Lib路径告诉编译器。这份独立SDK里包含了ddraw.h、dinput.h、dsound.h、d3dx8.h这些老接口以及配套的导入库和调试库。装好之后它会自动设置一个环境变量DXSDK_DIR指到安装根目录后面所有编译脚本都靠这个变量拼路径。如果你解压源码后发现工程文件里写死的路径还是当年的C盘旧目录把工程里的Include目录和库目录全部改成用DXSDK_DIR开头即可。这一步是后续所有构建能过的基础跳过它的话第2.2节第一条命令就会在找头文件时直接失败。依赖项的来源大致是下面这张表排查头文件缺失时按这个对照源码里用到的接口对应头文件从哪里来DirectDraw 7ddraw.h, ddraw.lib独立DirectX SDKDirectInput 8dinput.h, dinput.lib独立DirectX SDKDirectSounddsound.h, dsound.lib独立DirectX SDKD3DX8工具库d3dx8.h, d3dx8.lib独立DirectX SDKWin32基础APIwindows.h系统自带Windows SDK2.2 最小可运行构建从命令行编译一个章节工程不急着用IDE先验证依赖链通不通。我一般用命令行直接编译一个最简单的章节工程这样环境问题能跟代码问题彻底隔离开。假设你的源码解压在D:\GamingBookSrc步骤是先用vswhere拿到当前Visual Studio的安装路径再切到32位编译环境最后调用cl.exe编译。:: 步骤1用vswhere定位Visual Studio安装目录 %ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe ^ -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ^ -property installationPath :: 步骤2把返回的路径设成VSINSTALLPATH再切到x86编译环境 call %VSINSTALLPATH%\VC\Auxiliary\Build\vcvars32.bat :: 步骤3编译某个章节工程的全部源文件 cd /d D:\GamingBookSrc cl /nologo /O2 /MTd /D WIN32 /D _WINDOWS /D _DEBUG ^ /I %DXSDK_DIR%Include ^ src\GameMain.cpp src\DirectDrawInit.cpp src\InputSystem.cpp ^ /link /LIBPATH:%DXSDK_DIR%Lib\x86 ^ user32.lib gdi32.lib ddraw.lib dinput.lib dsound.lib winmm.lib dxguid.lib上面这段命令里/O2是速度优化/MTd表示使用调试版静态运行时避免依赖vcruntime动态库/D定义的是老工程常见的宏开关老代码没有它们会漏掉Windows头文件里的声明/I指定额外头文件搜索路径编译器的搜索顺序是先/I再加系统目录/link后面的/LIBPATH指向老SDK的32位导入库目录。注意源文件列表要按你实际解压出来的文件名替换这里只是示意工程里可能叫Game.cpp、main.cpp之类你把对应.c/.cpp文件全部列上就行。还要强调一点老源码是32位时代的产物所以必须用vcvars32.bat而不是64位环境。用64位环境编译会碰到大量指针截断错误那些错误跟源码本身没关系纯粹是环境配错了。另外源码解压路径尽量放在纯英文目录下老代码对中文路径的兼容性很差。2.3 构建成功的判断标准先别追求画面追求窗口编译通过后你会得到一个exe但先别急着双击全屏运行——在调试阶段把窗口模式跑通更重要。这套源码里多数示例默认请求800x600或640x480的全屏独占模式老显卡能亮现在很多环境反而黑屏。第一章工程里通常会有一个常量或宏控制全屏/窗口切换我在本地验证时一律先改成窗口模式。怎么判断代码真的跑起来了看三件事窗口能创建出来并持续显示不退出标题栏或画面里有帧数统计老代码喜欢把FPS打在标题栏按方向键或鼠标时画面里的物体有响应。如果窗口一闪而过多半是消息循环里的退出条件被触发或者DirectDraw初始化失败后直接return了。这时可以在初始化函数后加一行OutputDebugString输出返回值用调试器看HRESULT是D3D接口打不开还是表面创建失败一眼就能定位。窗口模式时间长了你会发现鼠标移动很飘这是老轮询代码和现代Windows鼠标设置的叠加问题先不用管。第5章会专门讲这类坑。源码能跑出窗口和画面编译器与SDK的依赖链就算彻底通了后面拆模块才有基础。3. 源码里的核心模块拆解从窗口消息到DirectDraw表面翻转3.1 Win32窗口与消息循环整个游戏循环的宿主老式游戏引擎的外壳是一个标准Win32窗口程序注册窗口类、创建窗口、进入消息循环。这套源码也不例外但它有个关键设计——消息循环不是GetMessage阻塞式因为游戏逻辑需要每一帧都执行不能干等窗口消息。所以它把循环改成了PeekMessage配合忙检测这成了后来无数独立游戏框架模仿的对象。// 经典的游戏式消息循环 MSG msg; ZeroMemory(msg, sizeof(msg)); while (msg.message ! WM_QUIT) { if (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(msg); DispatchMessage(msg); } else { GameLoopOnce(); // 没有窗口消息时就执行一帧逻辑 } }PeekMessage跟GetMessage的区别是它不阻塞队列里有消息就取走没消息立刻返回。返回TRUE时正常分发返回FALSE时说明队列空闲正是执行游戏逻辑的时机。这种结构保证窗口缩放、系统命令这些消息不会被饿死同时游戏帧循环又能满速跑。源码里窗口处理函数还会屏蔽掉一些无关消息比如WM_ERASEBKGND避免背景擦除造成闪烁画面刷新完全交给DirectDraw。对新手来说这段代码是整份源码最值得抄的一段它把一个“完整窗口程序”和一个“游戏循环”干净地解耦了。后面你往GameLoopOnce里塞任何逻辑都不会破坏窗口响应。这也是为什么这本书过去常被当成引擎骨架入门的原因——它没把窗口和游戏混成一团。3.2 DirectDraw初始化与表面翻转链那个时代的引擎本体DirectDraw是整个源码里最核心的依赖承担了几乎所有渲染工作。DirectDraw 7接口这份源码基本上用的是这个版本口径做的事情可以简化成三步创建对象、设置协作级别和显示模式、创建含后备缓冲区的表面链。前两步是环境准备第三步决定了你后面怎么把画面推上屏幕。LPDIRECTDRAW7 lpDD nullptr; LPDIRECTDRAWSURFACE7 lpPrimary nullptr; LPDIRECTDRAWSURFACE7 lpBack nullptr; // 创建DirectDraw对象 DirectDrawCreateEx(nullptr, (void**)lpDD, IID_IDirectDraw7, nullptr); // 设置协作级别全屏独占模式 lpDD-SetCooperativeLevel(hwnd, DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN); // 设置显示模式800x60032位色 lpDD-SetDisplayMode(800, 600, 32, 0, 0); // 描述表面属性 DDSURFACEDESC2 desc {}; desc.dwSize sizeof(desc); desc.dwFlags DDSD_CAPS | DDSD_BACKBUFFERCOUNT; desc.ddsCaps.dwCaps DDSCAPS_PRIMARYSURFACE | DDSCAPS_COMPLEX | DDSCAPS_FLIP; desc.dwBackBufferCount 1; lpDD-CreateSurface(desc, lpPrimary, nullptr); // 拿到后备缓冲区指针 lpPrimary-GetAttachedSurface(desc, lpBack);参数说明SetCooperativeLevel里DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN表示独占全屏模式这时DirectDraw会接管屏幕窗口模式则改成DDSCL_NORMALSetDisplayMode的四个参数依次是宽、高、色深、刷新率刷新率传0表示用默认值dwFlags声明你要描述哪些字段DDSD_CAPS表示要填能力位DDSD_BACKBUFFERCOUNT表示要额外创建后备缓冲区dwBackBufferCount 1意味着一个主表面加一个后备表面正好够翻转。GetAttachedSurface是拿后备缓冲区的标准姿势拿到后绘图全部画在这块后备表面上。这套“主表面后备表面Flip”的模型就是当时游戏画面不闪烁的根本原因你永远不会直接往正在显示的画面里画东西而是先在幕后画完一整帧再通过Flip一次性切换显示。这个思路沿用至今现代图形API里的“渲染目标Present”本质上还是这一套。后面第6章的迁移就是围绕这个等价关系展开的。3.3 位图加载与blit图片到屏幕的关键路径源码里所有精灵图像的显示都走同一条路径加载位图数据到系统内存表面再通过BltFast拷贝到后备表面最后翻转上屏。这里有两层缓冲要分清位图被加载到的表面是离屏表面不直接参与显示后备表面是显存里的绘制区主表面才是正在显示的内容。BltFast干的就是把离屏表面的一块矩形拷贝到目标表面指定位置。RECT srcRect; srcRect.left frame * frameWidth; // 从精灵表里取一帧 srcRect.top 0; srcRect.right srcRect.left frameWidth; srcRect.bottom frameHeight; // 把精灵拷到后备表面 lpBack-BltFast(x, y, lpSpriteSurface, srcRect, DDBLTFAST_WAIT | DDBLTFAST_SRCCOLORKEY); // 后备表面和主表面交换 lpPrimary-Flip(nullptr, DDFLIP_WAIT);BltFast的第一个参数是目标坐标第三个参数是源表面第四个srcRect指定源矩形框最后那个标志位DDBLTFAST_WAIT表示等待硬件完成再返回速度慢但在老环境里稳定DDBLTFAST_SRCCOLORKEY表示使用源表面的颜色键即把位图里某个指定颜色视为透明。颜色键通常在加载位图时统一设置源码里常见做法是把位图的左上角第一个像素当作透明色。从这里你能看出那个年代的性能哲学一切物体最终都是一次内存拷贝所以大量技巧都围绕“怎么减少拷贝次数”“怎么让拷贝区域更小”展开。比如这个srcRect取帧的方式一张精灵宽表里不同列就是不同动作帧只需要改frame变量就能切换不用重新加载图片——这套精灵表思路在今天的2D游戏工具里仍然在沿用。第4章讲的帧率控制和输入轮询其实也是围绕这个blit循环展开的。4. 从源码里提炼可复用的游戏框架主循环、计时与输入合并4.1 游戏主循环别用GetMessage用PeekMessage第3.1节已经给了主循环的最简形态但源码里实际工程的主循环还要再完善两点一是把游戏逻辑和窗口消息彻底分离二是引入一个运行标志控制退出。这里给一个更完整的形态也是我从这套源码里提炼出来后一直沿用至今的版本bool isRunning true; while (isRunning) { while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) { isRunning false; break; } TranslateMessage(msg); DispatchMessage(msg); } if (!isRunning) break; GameLoopOnce(); }这里内层循环把队列里积压的消息一次性取完而不是每帧只取一条。好处是当系统消息爆发比如拖拽窗口、切换任务时游戏循环不会被分散成很多次小碎步而是集中处理完消息后再完整跑一帧游戏逻辑。isRunning这个标志还有个重要作用窗口处理函数收到WM_CLOSE或用户按Esc时不想立即退出可以先执行资源清理再设置标志让循环在安全位置退出。很多老游戏按Esc退出黑屏就是因为直接在消息处理里调了ExitProcess没给渲染资源释放的机会。这套循环设计的核心价值在于逻辑帧和消息处理互不阻塞且退出点可控。今天写SDL、写跨平台引擎的人拿到的还是这个骨架。你在自己的新工程里完全可以原样抄走只需要把GameLoopOnce换成自己的更新和渲染函数。4.2 帧率控制GetTickCount计时与sleep策略源码里没有现代引擎那套复杂的固定时间步长但已经有一个很实用的快速实现每帧开头记录时间帧末计算耗时如果跑得太快就sleep一下保证大约60帧。这套做法虽然精度不高但在当年机器的性能水平上够用而且逻辑非常简单。DWORD frameStart GetTickCount(); GameLoopOnce(); DWORD cost GetTickCount() - frameStart; // 目标16ms一帧太快就睡掉剩余时间 if (cost 16) { Sleep(16 - cost); }GetTickCount返回的是系统启动以来的毫秒数精度大约15ms级别刚好匹配这个16ms的目标。它的好处是零配置、所有Windows环境都有坏处是精度低且受系统时间调整影响。Sleep(16 - cost)的作用是让出CPU避免游戏空转发热这个习惯养成比具体API重要得多。今天我不建议在新代码里照抄这套计时至少换成QueryPerformanceCounter精度能到微秒级不受系统毫秒计数器扰动。但这套“帧首计时→帧末补偿”的框架性思路没错它和现代固定时间步长的差别只在于有没有累积时间戳和插值。如果你发现画面在60Hz显示器上撕裂说明sleep的节奏和垂直同步没对齐老代码的处理是直接在Flip里开启等待而不是靠sleep。4.3 DirectInput轮询与按键状态合并为什么不用窗口消息这份源码对键盘鼠标的处理方式和当时不少商业游戏一致不用WM_KEYDOWN/WM_KEYUP而是用DirectInput的GetDeviceState一次性读取全部按键状态。原因是窗口消息只能反映“按下/抬起”事件而游戏需要知道“这一帧里某个键是否一直被按住”比如按住方向键连续移动用消息事件就得自己维护按键状态表还容易漏掉高速输入。BYTE keyboardState[256]; ZeroMemory(keyboardState, sizeof(keyboardState)); // 读取整个键盘状态 HRESULT hr lpKeyboard-GetDeviceState(sizeof(keyboardState), keyboardState); if (FAILED(hr)) { // 设备可能被其他程序抢占重新获取 lpKeyboard-Acquire(); hr lpKeyboard-GetDeviceState(sizeof(keyboardState), keyboardState); } // 持续按住检测第0x80位表示按键处于按下状态 if (keyboardState[DIK_LEFT] 0x80) { MovePlayer(-1, 0); }GetDeviceState一次把256个键的状态全读出来每个键用一个字节表示0x80位代表当前是否按下。Acquire是DirectInput设备的“获取独占权”操作在窗口失去焦点再回来时经常需要重新调用。这套处理方式的优点是帧同步、无事件堆积你读取到的永远是“当前这一刻”的键盘状态不会因为消息处理不及时而丢失按键。缺点也很明显——依赖DirectInput这套设备接口已经属于过时技术现在的游戏基本都走Windows消息或XInput。但这套“状态快照”的思想值得抄。很多现代游戏框架里处理键盘也是维护一个数组每帧刷新状态用的时候查数组。区别只是底层来源从DirectInput换成了消息或RAW INPUT。你在自己新工程里完全可以把这套keyboardState 0x80位判断的模式保留下来只替换掉数据来源游戏逻辑层一行不用改。5. 编译老源码的避坑清单链接错误、头文件缺失与多字节编码5.1 LNK2019dxerr8.lib链接失败报错指向DXTrace现象编译能过链接时报一堆LNK2019: unresolved external symbol _DXTrace16后面跟着ddraw.lib、dinput.lib里引用的函数名。第一次编译老工程的人很容易被这一长串错误吓住以为是自己代码写错了。原因这套源码链接了老版SDK的调试错误报告库dxerr8.lib提供DXTrace等调试跟踪函数但你链接时没把它列进输入库列表。在DXSDK的Lib目录里这个库是存在的只是老工程文件里不会显式包含它因为当年IDE的全局设置里加了默认库路径。解决链接参数里补上dxerr8.lib然后调整/LIBPATH。完整追加方式是/link /LIBPATH:%DXSDK_DIR%Lib\x86 dxerr8.lib ddraw.lib dxguid.lib winmm.lib。dxguid.lib也要同时保证它提供CLSID_DirectDraw、IID_IDirectDraw7这类GUID符号漏掉它同样会引起LNK2019。如果你用的SDK版本里Lib目录下没有明显的x86子目录直接把整个Lib目录加进LIBPATH让编译器自己去找。排查顺序先确认DXSDK_DIR环境变量是否有效命令行里echo %DXSDK_DIR%看结果再确认Lib路径正确最后才去改代码。这条坑基本占了老工程链接错误的一半以上属于必踩项目。5.2 fatal error C1083找不到d3dx8.h现象编译某个步骤报fatal error C1083: Cannot open include file: d3dx8.h: No such file or directory。这个头文件是D3DX8数学和工具库的头源码里某个文件引入了它。原因较新版本的Windows SDK把D3DX8/D3DX9一系列辅助库的头文件移除了只保留核心D3D头文件。独立DirectX SDK里虽然有d3dx8.h但如果你在VS的包含路径里没把DXSDK_DIR排到前面编译器还是会优先找系统Windows SDK的目录然后失败。解决在命令行编译参数里把/I %DXSDK_DIR%Include放到第一个位置并且在vcvars32.bat之后再手动追加确保老SDK搜索顺序在Windows SDK之前。如果还是找不到检查SDK是否完整安装到了默认目录或者手动把Include目录路径写死。另一种思路是直接在源码里注释掉包含d3dx8.h的那行——如果只是用了里面的数学工具完全可以自己写个三行结构体替代D3DX8里的D3DXVECTOR3并不特殊老代码对它的使用深度多数都不深。需要注意排查时要区分报错发生在预处理阶段说明是头文件路径问题如果头文件能找到但函数符号对不上那是库版本混用得检查你用的头文件和lib是否来自同一个SDK版本。5.3 字符串与字符集混用C2664和C4819伪报错现象编译时大量C2664: cannot convert argument 2 from LPCWSTR to LPCSTR报错或者源文件出现奇怪的C4819警告文件包含不能在当前代码页(936)表示的字符。源码看起来没动过为什么在VS2022上死活编译不过原因老源码默认使用多字节字符集MBCS大量直接写字符串字面量的地方被当成char*处理。而新版本Visual Studio的默认工程字符集是Unicode所有L字面量变成wchar_tWindows API的A/W宏展开后指向W版本函数类型就对不上了。另一部分C4819则是因为源码注释是GB2312中文IDE按当前代码页解析出现字符不可映射。解决编译参数加/D _MBCS并且确认没有/D _UNICODE或/D UNICODE。更稳妥的做法是直接在编译命令行里加/utf-8让编译器把源码按UTF-8读入同时把注释乱码的源文件批量转成UTF-8 with BOM。转换时要注意只转码不改内容别把字符串里的转义序列弄坏。这里有个判断技巧如果C2664报错集中在Windows API调用上就是字符集问题如果报错出现在代码里的字符串拼接处先看引用的头文件里有没有混用TCHAR和char。老源码里两种混用的地方不少耐心逐个看下去基本半小时能清完。5.4 全屏模式黑屏DirectDraw初始化失败的典型场景现象程序启动后显示器黑屏键盘无响应只能强制重启调试模式下断点一停接着恢复就黑屏甚至花屏或者SetDisplayMode返回失败伴随DDERR_INVALIDMODE/DDERR_EXCLUSIVEMODEALREADYSET这类错误值。原因全屏独占模式下DirectDraw要求显卡支持对应的分辨率和色深。虚拟机或不带专用驱动的环境里800x600x32这种模式未必都能通过CheckDisplayMode验证。另一个常见原因是调试器和全屏独占模式打架——双显示器、远程桌面、全屏独占都容易互相抢显示资源。解决第一步把SetCooperativeLevel改成DDSCL_NORMAL窗口模式注释掉SetDisplayMode和Flip调用改用BltFast直接拷贝到主表面。很多示例只改这一处就能跑起来代价是帧率上限受窗口合成器影响。第二步如果坚持全屏在调用SetDisplayMode前先用EnumDisplayModes枚举一遍显卡支持的模式把枚举结果打印出来确认目标分辨率是否在其中。排查时要学会看返回值。DirectDraw的接口都会返回HRESULT不要忽略它。把初始化每个步骤的返回值都打出来哪个返回失败就查哪个比对着黑屏瞎猜效率高得多。这一条是最折磨人的因为症状表现成“整个系统卡死”但实际上只是CreateSurface没成功。把这些坑都过一遍后你会发现老源码真正难的根本不是代码逻辑而是环境和依赖——解决了这些剩下的全是能看懂的东西。6. 把翻转链搬到现代APIDirectDraw遗产在D3D11上怎么用老源码读懂了下一步自然是迁移。DirectDraw那套“主表面后备表面Flip”在D3D11里的对应物非常直观我列过一张对照表之后照着搬就行DirectDraw里做的事D3D11里的对应做法创建主表面和后备表面创建交换链SwapChain在后备表面上BltFast画图渲染到渲染目标视图RenderTargetViewFlip翻转上屏调用SwapChain的Present(1,0)调色板颜色键纹理数组或shader里做alpha判断精灵帧切换改纹理坐标区域一次提交迁移时最省力的路线是保留主循环和输入轮询的代码结构第4章那段框架直接原样保留只把渲染部分替换掉。D3D11版本的主循环末端就这样// D3D11版渲染帧 UpdatePlayer(keyboardState, playerPos); DrawSprite(pImmediateContext, pSpriteTexture, playerPos); // 把渲染好的内容送显 pSwapChain-Present(1, 0);Present(1,0)的第一个参数是同步间隔1表示开启垂直同步画面不撕裂但会锁定帧率到显示器刷新率第二个参数是表现标志0是常规情况。DirectDraw时代的Flip(nullptr, DDFLIP_WAIT)恰好和它一一对应。迁移的验证方法我习惯按三档走窗口模式先跑通逻辑和输入再开垂直同步看画面稳定性最后用RenderDoc或同类工具截帧对比渲染目标里的画面和旧版Flip后的输出是否一致。迁移过程中最容易忽略的是计时精度。老代码用GetTickCount做帧率控制到了D3D11还这么干会出现帧时间忽高忽低、动画一顿一挫的现象换成QueryPerformanceCounter后立刻顺滑。这一趟走下来你会发现自己真正掌握的不只是某个接口而是一条从90年代末延续到现在的游戏渲染主链。我每次拿到这类老代码都会先花半小时把主循环和表面创建流程画成图再动手改这个习惯帮我避开了无数黑匣子式的浪费希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?