
简介面向VC环境下希望入门DirectDraw 2D图形编程的开发者该示例以在屏幕上显示一张图片为目标从创建DirectDraw对象开始依次讲解设置合作级别、建立交换链、加载位图以及通过Blt绘制、Flip切换缓冲区的关键步骤并配以清晰注释直观呈现双缓冲消除闪烁的原理。DirectDraw曾是Windows平台2D游戏和图形加速的重要接口这个示例以最小工程展示了它在窗口模式下的完整调用链。压缩包共21个文件以C头文件与实现代码为主同时包含Visual C工程文件、MFC对话框资源、位图素材和使用说明文本整体仅1.31MB可直接打开工程编译运行。目前已有380人学习下载适合初次接触DirectX的读者对照接口逐步调试也便于需要维护老旧图形程序的开发者快速定位关键代码。理解这套流程还能为学习现代DirectX版本、以及阅读DirectDraw遗留项目打下基础。 写这个示例的起因挺简单有台老式触摸屏查询机Windows 2000 系统客户点名要用 DirectDraw 做开机全屏图因为 GDI 画一张图虽然不难但要实现双缓冲无缝切换、避免画面闪烁处理起来始终隔着一层。现在回看 DirectDraw 确实是个老古董可当年它却是 DirectX 家族里最容易上手的 2D 接口尤其在 VC 6 环境下一个几十行的程序就能把 BMP 整屏推到屏幕上。这篇文章就是把我整理过的 DirectDraw 显示图片最小示例拆开讲一遍内容包括初始化、表面创建、BMP 加载、Blit 和 Flip 的完整流程顺便把调试中踩过的坑也一并列出来。适合两类人看一是要接手维护老 DDraw 项目的开发者二是想从底层理解显卡表面和双缓冲机制的学生。1. 还在用 GDI 慢慢画DirectDraw 做全屏贴图的适用场景1.1 和 GDI 的核心差别GDI 绘图走的是系统内存里的 DIBDevice-Independent Bitmap每次刷新都要把整块像素通过 BitBlt 搬运到窗口 DC 上这个过程的瓶颈在 CPU 和内存带宽。DirectDraw 则把图形数据直接放到显存表面Surface显卡负责最终的合成输出CPU 只需要告诉显卡“把哪个表面、哪块区域、搬到哪个位置”剩下的活由显卡完成。更重要的是 DirectDraw 提供了一种叫“翻转”Flip的机制。它让主表面和后备表面组成一条链后备表面在后台被画好然后一条指令把两个表面的角色互换由于换的是显卡显示地址而不是逐像素拷贝这一帧到下一帧之间几乎没有撕裂和闪烁。GDI 要实现同样的效果通常得用内存 DC 定时器模拟始终不如 DDraw 直接。1.2 这套代码适合谁这套最小示例只做一件事程序启动后切换到全屏模式把一张 BMP 图片显示在屏幕上直到按 Esc 退出。它不涉及窗口客户区贴图不涉及视频播放也不涉及 3D。如果你是要在嵌入式工控机、老式广告机、教学模拟器上快速实现“开机就显示一张界面”这个示例可以直接改改分辨率、换张图就投入使用。如果你是想在普通窗口里贴图也可以参考这套思路但需要把SetCooperativeLevel改成DDSCL_NORMAL并且不能用翻转链得直接把离屏表面贴到主表面 DC 上效果类似 GDI 的双缓冲但没有 Flip 那么干脆。我后面会专门解释这个差别。2. DDraw7 对象模型里绕不开的几个“表面”2.1 DirectDrawSurface 是什么DirectDraw 的核心抽象就是IDirectDrawSurface7一个表面就是一块可寻址的像素内存区域。它可以是显存、可以是系统内存也可以是显存和系统内存之间的一个复合体。表面有宽度、高度、像素格式、颜色键、调色板等属性单个表面上还能做锁定Lock、位块传输Blt、直接读像素等操作。大多数人第一次接触 DDraw 时会被一堆表面名搞晕主表面、后备表面、离屏表面、覆盖表面、纹理表面。这些名字听起来复杂实际分成两类就好理解一类是承载最终显示结果或参与翻转链的另一类是离屏缓存用的。本文只用了前三者。2.2 三类表面怎么分工主表面Primary Surface对应显示器当前的显示地址。全屏独占模式下主表面就是屏幕上那块显示区域硬件把它持续扫描输出后备表面Back Buffer是一个和主表面尺寸、格式完全一致、挂在主表面后面的兄弟表面离屏表面Off-Screen Surface是一块和显示无关的普通表面我的示例里面用来存放 BMP 图片数据需要显示的时候再把图片从离屏表面贴到后备表面。为什么要这样设三层因为画面帧的组装不能直接在主表面上进行否则用户会看到绘制过程的一半成品产生闪烁和撕裂。正确流程是在离屏表面放好素材把素材 Blit 到后备表面等后备表面内容完整后再 Flip 一次让主表面和后备表面直接互换。帧率要求再高一点还可以多用几个后备表面组成交换链但显示一张静态图片已经不需要了。2.3 创建表面时必须注意的 DDSURFACEDESC2所有表面创建都通过DDSURFACEDESC2描述结构完成。这个结构最坑的地方在于它的dwSize必须被设置成sizeof(DDSURFACEDESC2)很多人漏了这一步结果CreateSurface一直返回DDERR_INVALIDPARAMS。DDSURFACEDESC2 ddsd; ZeroMemory(ddsd, sizeof(ddsd)); ddsd.dwSize sizeof(ddsd); // 这一行绝对不能省 ddsd.dwFlags DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH;dwFlags是告诉驱动结构里哪些字段有效。比如创建表面时给了DDSD_WIDTH和DDSD_HEIGHT就必须有这两个字段对应的值创建主表面时用DDSD_CAPS | DDSD_BACKBUFFERCOUNT结构和后面代码要一致否则同样报参数错误。3. 从创建 DirectDraw 到拿到可以画的后备表面3.1 DirectDrawCreateEx 与对象版本选择DirectDraw 的创建函数在老 SDK 里有好几个版本如果直接用DirectDrawCreate拿到的是IDirectDraw接口功能少且无法使用 DDraw7 的新特性。正确做法是用DirectDrawCreateEx把 GUID 换成IID_IDirectDraw7这样拿到的就是 DDraw7 接口。LPDIRECTDRAW7 g_pDD NULL; HRESULT hr DirectDrawCreateEx(NULL, (LPVOID*)g_pDD, IID_IDirectDraw7, NULL); if (FAILED(hr)) { /* 创建失败处理 */ }第一个参数传NULL表示使用当前活动显示器第三个参数是接口 GUID最后一个参数传NULL即可。这个函数要求程序链接 ddraw.lib并且头文件包含ddraw.h。如果你用的还是 VC 6链接时还需要加入 dxguid.lib否则IID_IDirectDraw7会报链接错误。代码里LPDIRECTDRAW7实际上是IDirectDraw7*的别名g_pDD转成LPVOID*是历史遗留的写法看着别扭但必须这么做因为函数签名要求的是LPVOID*。3.2 SetCooperativeLevel 和 SetDisplayMode 的参数细节拿到的 DDraw 对象要先设置协作级别再设置显示模式。协作级别告诉系统程序打算怎么使用显示设备全屏独占模式下必须同时指定DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN这两个标志缺一不可。只给其中一个后面调用SetDisplayMode时就会失败。hr g_pDD-SetCooperativeLevel(hwnd, DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN); if (FAILED(hr)) { /* 切换全屏失败处理 */ } hr g_pDD-SetDisplayMode(640, 480, 32, 0, 0); if (FAILED(hr)) { /* 分辨率设置失败处理 */ }SetDisplayMode的五个参数分别是宽度、高度、色深、刷新率、标志。刷新率填 0 表示用驱动默认值多数老机器上这是最稳妥的选择。色深这里直接用 32 位目的是绕开调色板问题如果选了 8 位色表面还需要额外设置调色板LoadImage加载的 256 色图还得做颜色匹配很麻烦。示例里选 640x480x32 是为了保证任何老显卡都能支持实际项目可以根据屏幕参数改成 800x600 或 1024x768。3.3 主表面与后备表面的创建、GetAttachedSurface主表面和后备表面是同时创建出来的关键在描述结构的dwCaps里加上DDSCAPS_COMPLEX和DDSCAPS_FLIP前者表示这是一个由多个表面组成的复杂对象后者表示这个对象支持翻转。dwBackBufferCount决定后备表面数量显示静态图用 1 个就够如果做动画或游戏建议用 2 个。LPDIRECTDRAWSURFACE7 g_pPrimary NULL; LPDIRECTDRAWSURFACE7 g_pBack NULL; DDSURFACEDESC2 ddsd; ZeroMemory(ddsd, sizeof(ddsd)); ddsd.dwSize sizeof(ddsd); ddsd.dwFlags DDSD_CAPS | DDSD_BACKBUFFERCOUNT; ddsd.ddsCaps.dwCaps DDSCAPS_PRIMARYSURFACE | DDSCAPS_COMPLEX | DDSCAPS_FLIP; ddsd.dwBackBufferCount 1; hr g_pDD-CreateSurface(ddsd, g_pPrimary, NULL); if (FAILED(hr)) { /* 主表面创建失败处理 */ } DDSCAPS2 caps; ZeroMemory(caps, sizeof(caps)); caps.dwCaps DDSCAPS_BACKBUFFER; hr g_pPrimary-GetAttachedSurface(caps, g_pBack); if (FAILED(hr)) { /* 后备表面获取失败处理 */ }CreateSurface虽然只返回了主表面但当它带有DDSCAPS_COMPLEX和DDSCAPS_FLIP时驱动会同时创建后边的后备表面。后备表面拿不到显式返回值要用GetAttachedSurface按能力标识DDSCAPS_BACKBUFFER去取。这个接口返回的指针不需要再单独Release它属于主表面附属对象主表面释放时会一起释放。新手常犯的错误是把主表面和后备表面都调一次Release结果退出程序时二次释放导致崩溃。4. 把 BMP 塞进离屏表面两种思路及代码实现4.1 LoadImage GetDC BitBlt 的常规路线现在场景中有了一块主表面和一块后备表面两块都不能直接用来贴图因为它们的尺寸固定是屏幕分辨率。正确的素材存放地是离屏表面。这张表面要建多大取决于 BMP 图片的原始尺寸所以先加载图片拿到宽高。LPDIRECTDRAWSURFACE7 g_pBack NULL; // 后备表面 LPDIRECTDRAWSURFACE7 g_pSurface NULL; // 离屏表面存放图片 HBITMAP hBmp (HBITMAP)LoadImage(NULL, _T(logo.bmp), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE); BITMAP bm; GetObject(hBmp, sizeof(BITMAP), bm);LoadImage是用户态 GDI 的函数它只负责把磁盘上的 BMP 文件解码成HBITMAP解码结果还留在系统内存里和 DirectDraw 的显存表面没有任何关系。要把它送进离屏表面最简单的桥梁是 HDC先让 DirectDraw 表面给你一个设备上下文然后把 HBITMAP 画进去。DDSURFACEDESC2 ddsd; ZeroMemory(ddsd, sizeof(ddsd)); ddsd.dwSize sizeof(ddsd); ddsd.dwFlags DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH; ddsd.ddsCaps.dwCaps DDSCAPS_OFFSCREENPLAIN | DDSCAPS_SYSTEMMEMORY; ddsd.dwWidth bm.bmWidth; ddsd.dwHeight bm.bmHeight; hr g_pDD-CreateSurface(ddsd, g_pSurface, NULL); if (FAILED(hr)) { /* 离屏表面创建失败处理 */ } HDC hdcMem CreateCompatibleDC(NULL); HBITMAP hOldBmp (HBITMAP)SelectObject(hdcMem, hBmp); HDC hdcSurf NULL; hr g_pSurface-GetDC(hdcSurf); if (SUCCEEDED(hr)) { BitBlt(hdcSurf, 0, 0, bm.bmWidth, bm.bmHeight, hdcMem, 0, 0, SRCCOPY); g_pSurface-ReleaseDC(hdcSurf); } SelectObject(hdcMem, hOldBmp); DeleteDC(hdcMem); DeleteObject(hBmp);这段代码里有一个决定性能的关键选择离屏表面用DDSCAPS_SYSTEMMEMORY还是DDSCAPS_VIDEOMEMORY创建。SYSTEMMEMORY表面在系统内存里写入速度快但每次 Blit 到后备表面时数据要从系统内存搬到显存VIDEOMEMORY表面直接在显存里从磁盘加载时通过GetDC/BitBlt会慢一点但后面每次 Blit 都是纯显存操作速度最快。静态图只加载一次后续不频繁更新素材所以用VIDEOMEMORY更划算。我示例里写的是SYSTEMMEMORY因为它在老显卡上兼容性更好不会因为显存不足创建失败如果你确认目标机器显存足够改成DDSCAPS_VIDEOMEMORY即可。4.2 Lock 像素直接复制的路线什么时候用GetDC BitBlt这条路线有一个隐含前提BMP 被 GDI 解码成的颜色格式和 DirectDraw 表面颜色格式必须匹配一般是 24 位或 32 位 RGB。匹配的时候 GDI 会自动完成转换代码很简单。但如果你要处理的不是 BMP而是外部采集的裸数据、视频帧或者一张需要做像素级特效反色、灰度、马赛克的图像那就不能走 GDI 了。正确做法是用Lock函数锁定表面拿到一个指向像素内存起始地址的指针直接按行写入。这个方法要求你处理像素跨度Pitch和像素格式。DDSURFACEDESC2 ddsd; ZeroMemory(ddsd, sizeof(ddsd)); ddsd.dwSize sizeof(ddsd); hr g_pSurface-Lock(NULL, ddsd, DDLOCK_WAIT | DDLOCK_WRITEONLY, NULL); if (SUCCEEDED(hr)) { BYTE* pBits (BYTE*)ddsd.lpSurface; int pitch ddsd.lPitch; // 按行写入像素pitch 可能大于 bm.bmWidth * 4不能直接用 g_pSurface-Unlock(NULL); }Lock返回的描述结构里lPitch和图片的像素宽度不一定相等。为了对齐显存每行实际占用的字节数会比理论值多几个字节读取或写入时必须用lPitch作为行步长如果按图片宽度来算会得到一条斜线。这个细节在GetDC/BitBlt路线上是看不到的但一旦手工操像素就会遇到所以单独拿出来提醒一句。4.3 Blit 到后备表面并 Flip 的完整帧流程素材全部就位后每一帧的渲染流程只有两段话可以说清楚把离屏表面里的图片区域传到后备表面然后把后备表面翻转为主表面。显示静态图时这段逻辑走一次就够了如果做循环动画就把这段逻辑放进消息循环里每帧执行一次。RECT rcSrc { 0, 0, bm.bmWidth, bm.bmHeight }; hr g_pBack-BltFast(0, 0, g_pSurface, rcSrc, DDBLTFAST_WAIT | DDBLTFAST_NOCOLORKEY); if (FAILED(hr)) { /* Blit 失败处理 */ } hr g_pPrimary-Flip(NULL, DDFLIP_WAIT); if (FAILED(hr)) { /* Flip 失败处理 */ }BltFast比Blt少了目标矩形缩放功能速度更快实现也更简单适合 1:1 贴图。DDBLTFAST_WAIT表示如果显卡正忙就等待保证调用返回时操作已经完成DDBLTFAST_NOCOLORKEY表示不使用颜色键整个源矩形都会被复制。Flip的第一个参数传NULL表示让后备表面和主表面按它们在创建时形成 的顺序自动交换DDFLIP_WAIT的含义和上面类似避免操作未完成就返回。如果图片在屏幕上位置不对可以改BltFast前两个参数它们是目标矩形的左上角坐标如果图片太大裁掉了可以改最后一个标志去掉DDBLTFAST_NOCOLORKEY或者改用Blt带缩放目标矩形。5. 编译运行时最容易踩的五个坑5.1 dwSize 未初始化导致创建表面失败这个错误小得让人抓狂。DDSURFACEDESC2里字段非常多很多人会先ZeroMemory清理整个结构但忘记在dwSize里写入结构体大小。DirectDraw 驱动靠这个字段判断你传的是哪个版本的结构体拿到 0 就会直接返回参数错误。建议每次建表面都写一段固定的初始化模板结构体清零、赋dwSize、按需设置标志不要手写半边。5.2 完整初始化顺序先协作级别再显示模式SetCooperativeLevel和SetDisplayMode的调用顺序不能颠倒。如果先切分辨率再设置协作级别SetDisplayMode会在独占模式尚未建立时被系统拒绝。而且SetCooperativeLevel必须传入窗口句柄一般是主窗口的HWND。全屏模式下这个句柄还承担接收键盘消息的职责所以消息循环里要处理WM_KEYDOWN和WM_DESTROY否则按 Esc 程序没反应只能强制关闭。case WM_KEYDOWN: if (wParam VK_ESCAPE) PostQuitMessage(0); break;5.3 调色板与 8/16 位色深选择问题我前面强调用 32 位色深避开调色板但老项目里经常有机器只支持 16 位色或者只有 8 位显存。8 位模式下SetDisplayMode后表面是 256 色索引格式BMP 里的 RGB 像素需要通过调色板转换才能正确显示。这时要创建IDirectDrawPalette把 BMP 文件的调色板条目复制进去再调用SetPalette设置到主表面和离屏表面。16 位模式相对简单但要注意像素格式是RGB555还是RGB565颜色深度信息可以从DDSURFACEDESC2的ddpfPixelFormat里读取不能写死。5.4 全屏下调试器中断导致画面显示残留全屏独占模式下DirectDraw 锁定了显示设备的独占权。这种模式下如果 IDE 断点命中了程序暂停后台表面可能只画了一半屏幕上残留的画面会保持被打断时的状态有时还会导致调试器本身无法正常刷新界面。处理办法是先正常退出全屏模式再下断点或者把重要的调试信息用OutputDebugString输出不要在全屏状态下频繁中断。5.5 资源释放顺序与 Release 调用次数程序退出时释放顺序和创建顺序是反的。先释放离屏表面和图片相关 GDI 资源再释放后备表面严格说后备表面不是独立Release的它跟主表面绑定最后释放 DirectDraw 对象本身。离屏表面通过g_pSurface-Release()释放HBITMAP通过DeleteObject释放g_pDD-Release()放最后。资源释放方式注意事项后备表面不单独 Release随主表面自动释放离屏表面Release()先释放再释放主表面主表面Release()翻转链一起释放HBITMAPDeleteObject()在 Release 表面后执行DirectDraw 对象Release()最后执行如果你在前面的GetDC调用后忘了ReleaseDC后面系统会积累 DC 句柄泄漏表面看起来没问题但反复进入退出后内存稳步增长。这是老程序里最隐蔽的泄漏点之一。6. 从显示静态图到做启动动画的扩展思路核心示例跑通后最自然的扩展是让图片动起来。DirectDraw 做 2D 动画非常简单在消息循环里每帧修改目标矩形的坐标或大小然后执行 Blit 和 Flip。比如做一个全屏渐入效果不需要改像素只要把BltFast换成Blt并用一个逐渐变大的目标矩形来重复贴同一张图。DDBLTFX ddbltfx; ZeroMemory(ddbltfx, sizeof(ddbltfx)); ddbltfx.dwSize sizeof(ddbltfx); RECT rcDest { 0, 0, curWidth, curHeight }; hr g_pBack-Blt(rcDest, g_pSurface, rcSrc, DDBLT_WAIT, ddbltfx);curWidth和curHeight每帧增大图片就会像拉幕布一样展开。如果要做淡入淡出则需要对素材表面的像素做透明度混合工作量会大一些但核心表面模型没有任何变化。还有一个常见的坑BMP 默认不支持透明。如果你希望图片中某种颜色是透明的有两条路可走。一是给离屏表面设置颜色键Color Key在BltFast或Blt时使用DDBLTFAST_SRCCOLORKEY标志让源表面的特定颜色不被复制二是把图片转成带 Alpha 通道的 32 位格式用Blt加DDBLT_ALPHADEST做逐像素混合。前者在老项目里最常见适合 Logo 展示后者需要准备带 Alpha 的素材。我实际做触摸屏开机画面时遇到过图片素材是 JPG 的情况。LoadImage不能直接解码 JPG我的处理方案是先用 GDI 把 JPG 解码成HBITMAP再走GetDC/BitBlt路线送进离屏表面。整体思路就是“外部解码器负责生成 HBITMAPDirectDraw 只负责接收像素数据”这个解耦方式一直很好用。如果你手头素材也是 JPG/PNG建议保留 GDI 做解码层后面换格式只改解码部分表面管理代码完全不用动。本文还有配套的精品资源点击获取