☰
DirectDraw全屏显示BMP图片:双缓冲翻转机制与实现详解
2026/9/25 13:01:43 网站建设 项目流程

简介:面向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 绘图走的是系统内存里的 DIB(Device-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路线送进离屏表面。整体思路就是“外部解码器负责生成 HBITMAP,DirectDraw 只负责接收像素数据”,这个解耦方式一直很好用。如果你手头素材也是 JPG/PNG,建议保留 GDI+ 做解码层,后面换格式只改解码部分,表面管理代码完全不用动。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询