☰
DirectDraw显示BMP图片:VC++初始化与BltFast实战详解
2026/10/13 13:37:03 网站建设 项目流程

简介:DirectDraw是DirectX中的经典2D图形加速接口,面向需要了解底层图像渲染的VC开发者。这份示例工程演示了如何通过DirectDraw在屏幕显示一张图片,覆盖初始化对象、设置协作级别、创建交换链、加载图像、绘制、翻转显示到最终释放的完整流程。工程压缩包共21个文件,主要包含C++源文件(.cpp)、头文件(.h)、位图文件(.bmp)以及MFC工程配置(.dsp/.dsw等),整体仅1.31MB,体量轻便,便于直接下载学习。目前已有383人学习下载。示例代码基于MFC框架组织,包含主框架、对话框等常规模块,并将初始化、加载、绘制等步骤拆分为独立函数,注释清晰,适合初学者对照实际工程理解双缓冲机制和IDirectDraw7等核心接口的用法。同时,通过Blt与Flip的配合演示了如何避免闪烁,为后续深入学习Direct3D或现代图形API打下基础。资源内附ReadMe.txt等说明文件,可快速上手运行。

1. 为什么到今天还有人用 DirectDraw 显示一张图

一个典型场景:某工控上位机项目,维护了十多年的 Visual C++ 代码,主界面是 MFC 对话框,现场要求把一张实时状态图全屏显示在副屏上。你自然会想到 GDI 的 BitBlt,但全屏独占、双缓冲、隔行扫描这些需求,老代码里早就用 DirectDraw 写好了,换 GDI 意味着重写一套显示逻辑。DirectDraw 作为 DirectX 7 时代的产物,在今天已经退化为系统模拟层,不再占用硬件加速,但它的接口体系完整、行为确定,在工业触摸屏、老设备维护、教学演示里依然有一批忠实用户。

本篇要解决的问题非常聚焦:用 Visual C++ 写一段最小可运行的 DirectDraw 示例,把一张 BMP 图片显示到屏幕中央。我会把初始化、离屏表面、GDI 互操作、BltFast 这几个环节拆开讲,并把最容易翻车的五个坑逐个列出来。适合两类人:一类是要在老项目里新增图片显示功能的新手,另一类是准备把 DirectDraw 迁移到 Direct2D 但暂时不能动的熟手——你需要知道这套老 API 的边界在哪里。

2. 初始化 IDirectDraw7:从获取接口到创建离屏表面

2.1 获取 IDirectDraw7 而不是 IDirectDraw:第一行代码的版本讲究

DirectDraw 的入口函数有两条路:老的DirectDrawCreate只能拿到IDirectDraw和IDirectDraw2/4,而DirectDrawCreateEx可以直接拿到IDirectDraw7。虽然只是显示一张图,用老接口也够,但我建议统一用 7。原因有两个:一是IDirectDraw7向下兼容,老代码里的BltFast、SetCooperativeLevel全都能用;二是新写的代码若将来要扩展多表面翻转,它提供的Flip系列方法和表面描述结构更完整,不用中途换接口。

#include <ddraw.h> #include <dxguid.lib> LPDIRECTDRAW7 lpDD = nullptr; HRESULT hr = DirectDrawCreateEx( nullptr, // 第一个参数填 NULL,表示使用当前默认显示设备 (LPVOID*)&lpDD, // 指向接口指针的指针,注意要强转成 LPVOID* IID_IDirectDraw7, // 请求的接口 ID,不能用 IID_IDirectDraw nullptr // 保留参数,必须为 NULL ); if (FAILED(hr)) { // 常见的失败原因是 ddraw.dll 初始化失败,老系统上才会出现 return -1; }

这段代码里有三个细节值得注意。第一个参数传NULL而不是某个设备 GUID,是因为示例程序只用默认显卡;多显卡机器想指定设备,需要先DirectDrawEnumerateEx枚举,这个动作在工控机上并不少见。第二参数必须强转成(LPVOID*),这是DirectDrawCreateEx的原型要求,漏掉强转会编译报错。第三个参数用IID_IDirectDraw7,而不是表面枚举里常见的CLSID_DirectDraw7,前者是接口 ID,后者是 COM 类 ID,混用会导致获取失败。

2.2 协作模式与显示模式:窗口和全屏两套配置

SetCooperativeLevel决定程序以什么权限操作显示设备,窗口模式和全屏模式是两套完全不同的配置。窗口模式下用DDSCL_NORMAL,程序不独占显卡,主表面颜色格式跟随当前桌面,不需要也不能调用SetDisplayMode。全屏模式用DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN,程序独占显示输出,然后必须调SetDisplayMode指定分辨率、色深和刷新率。

// 窗口模式 hr = lpDD->SetCooperativeLevel(hwnd, DDSCL_NORMAL); if (FAILED(hr)) return -1; // 全屏模式 hr = lpDD->SetCooperativeLevel(hwnd, DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN); if (FAILED(hr)) return -1; hr = lpDD->SetDisplayMode(800, 600, 32, 0, 0); if (FAILED(hr)) return -1; // 参数依次是:宽、高、色深、刷新率(0表示默认)、格式标志(必须为0)

全屏模式下有个容易忘的收尾动作:程序退出前必须调用RestoreDisplayMode和SetCooperativeLevel(hwnd, DDSCL_NORMAL)恢复桌面显示状态,否则现场会看到桌面分辨率被永久改成 800×600。窗口模式则不需要这两步。另外,SetDisplayMode不是任意组合都成立,老显卡上 1366×768 这种非标分辨率可能失败,稳妥做法是先EnumDisplayModes枚举一遍确认支持,或者直接捕获失败后回退到 1024×768。

2.3 主表面与离屏表面:一个显示、一个存图

DirectDraw 显示图片的核心思路是:主表面代表屏幕本身,离屏表面是一块看不见的显存区域,先把图片画进离屏表面,再把离屏表面的内容一次性拷贝到主表面。表面上拷来拷去听起来绕,但这一步是 DirectDraw 的老规矩——主表面不直接接受 HBITMAP,只认自己的表面对象。

// 创建主表面(窗口模式) DDSURFACEDESC2 ddsd = { sizeof(ddsd) }; ddsd.dwFlags = DDSD_CAPS; ddsd.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE; LPDIRECTDRAWSURFACE7 lpPrimary = nullptr; hr = lpDD->CreateSurface(&ddsd, &lpPrimary, nullptr); if (FAILED(hr)) return -1; // 窗口模式必须加 Clipper,否则图片显示不出来 LPDIRECTDRAWCLIPPER lpClipper = nullptr; hr = lpDD->CreateClipper(0, &lpClipper, nullptr); lpClipper->SetHWnd(0, hwnd); // 第二个参数 0 表示撤换原有窗口关联 lpPrimary->SetClipper(lpClipper);

主表面的dwCaps只需DDSCAPS_PRIMARYSURFACE,不需要DDSCAPS_FLIP,后者是全屏双缓冲翻页时才用。窗口模式创建完主表面后,必须创建 Clipper 并把它绑定到窗口句柄,这一步的作用是让 DirectDraw 知道图片只画在窗口客户区内,不会画出窗口边界外。很多入门示例漏掉 Clipper,结果窗口模式下一片黑——这是第一个大坑,第 4 章细说。

离屏表面的创建略复杂,要指定宽高和像素格式:

DDSURFACEDESC2 ddsdOff = { sizeof(ddsdOff) }; ddsdOff.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT; ddsdOff.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN; ddsdOff.dwWidth = bmpWidth; // 图片宽度,单位像素 ddsdOff.dwHeight = bmpHeight; // 图片高度 LPDIRECTDRAWSURFACE7 lpOffscreen = nullptr; hr = lpDD->CreateSurface(&ddsdOff, &lpOffscreen, nullptr);

离屏表面的尺寸我建议和图片像素尺寸严格一致,不要为了“留余量”创建比图片更大的表面。原因有二:一是BltFast整块拷贝时,源矩形如果和图片尺寸不一致容易算出错位;二是表面越大,显存占用越多,工控机显存往往很紧张。格式方面,离屏表面默认会继承当前显示模式的像素格式,窗口模式下就是桌面色深,全屏模式下就是你SetDisplayMode指定的色深,这为第 3 章的 GDI 互操作省了不少事。

3. 把 BMP 画进 DirectDraw 表面:GDI 互操作是条捷径

3.1 为什么不能直接把 HBITMAP 交给 DirectDraw

很多第一次接触 DirectDraw 的人会想:既然图片在内存里已经是 HBITMAP 了,DirectDraw 能不能直接接收?答案是不行。HBITMAP 是 GDI 体系的设备相关位图(DDB),而 DirectDraw 表面是另一套显存对象,两者之间没有直接的赋值接口。你唯一能做的就是:把表面拿到一个 HDC,然后用 GDI 的BitBlt把图片画到这个 DC 上,再释放 DC。这个路径看起来绕,但它绕过了 DirectDraw 最麻烦的像素格式处理——颜色匹配由 GDI 去完成,代价是性能损耗,但对单张静态图片来说毫无压力。

这个思路来自当年大量游戏和工控代码的通用做法,也是 DirectDraw 文档里明确支持的“GDI 与 DirectDraw 互操作”路径。另一种方案是Lock表面后直接写像素,需要手动处理 16 位、24 位、32 位三种格式的像素打包,代码量成倍增加。所以我推荐先把互操作路径跑通,确认显示效果正常后,再考虑要不要优化成像素直写。

3.2 最小可运行代码:加载 BMP,填进离屏表面,再从离屏 Blt 到主屏

下面这段代码把整条链路串起来:加载 BMP 文件 → 取得离屏表面的 DC → BitBlt 画入 → 释放 DC → BltFast 拷贝到主表面。

// 第 1 步:用 GDI 把图片读到 HBITMAP HBITMAP hBitmap = (HBITMAP)LoadImage( nullptr, szFilePath, // BMP 文件路径 IMAGE_BITMAP, // 只支持位图格式 0, 0, // 0,0 表示按原图尺寸加载 LR_LOADFROMFILE // 必须加这个标志,表示从文件加载 ); if (hBitmap == nullptr) return -1; // 第 2 步:把 HBITMAP 选进内存 DC HDC hMemDC = CreateCompatibleDC(nullptr); HGDIOBJ hOldBmp = SelectObject(hMemDC, hBitmap); // 第 3 步:取得离屏表面的 DC,注意成对使用 HDC hSurfaceDC = nullptr; hr = lpOffscreen->GetDC(&hSurfaceDC); if (FAILED(hr)) return -1; // 第 4 步:从内存 DC 拷贝到表面 DC BitBlt( hSurfaceDC, // 目标 DC:离屏表面 0, 0, bmpWidth, bmpHeight, // 目标区域,按图片原始尺寸 hMemDC, // 源 DC:内存中的图片 0, 0, // 源区域起点 SRCCOPY // 直接覆盖,不做透明混合 ); // 第 5 步:释放表面 DC 和 GDI 资源 lpOffscreen->ReleaseDC(hSurfaceDC); SelectObject(hMemDC, hOldBmp); DeleteDC(hMemDC); DeleteObject(hBitmap); // 第 6 步:离屏表面 -> 主表面 RECT srcRect = { 0, 0, bmpWidth, bmpHeight }; hr = lpPrimary->BltFast( (screenWidth - bmpWidth) / 2, // 目标 X,居中显示 (screenHeight - bmpHeight) / 2, // 目标 Y lpOffscreen, // 源表面 &srcRect, // 源矩形 DDBLTFAST_WAIT // 等待拷贝完成,防止绘制半截 );

这段代码里最关键的规矩是GetDC与ReleaseDC必须配对。如果你只拿不还,表面会一直处于“被 GDI 锁定”状态,下一次BltFast会返回DDERR_SURFACEBUSY,表现出来就是图片偶尔显示不出来,调了很久才发现是 DC 泄漏。另一个关键是BltFast的目标坐标计算,窗口模式和全屏模式都以窗口客户区左上角为原点,所以居中公式是用客户区宽高减去图片宽高再除以二。若在 MFC 里做,应该用GetClientRect(hwnd)拿客户区尺寸,直接用GetWindowRect会把标题栏也算进去,图片位置会偏下十几个像素。

3.3 关键参数说明:BltFast 的四个参数与两种拷贝标志

BltFast一共五个参数,第四个参数&srcRect是源矩形,它指的是离屏表面上的哪一块区域要被拷贝,尺寸应该与图片宽度高度一致。第五个参数dwTrans是最容易被忽视的:填DDBLTFAST_WAIT表示让调用阻塞直到拷贝完成,适合单张静态图片;填DDBLTFAST_NOCOLORKEY表示忽略所有颜色键处理;若图片有透明色,需要填DDBLTFAST_SRCCOLORKEY。这三个标志可以按位或组合,但常见错误是把两者写成DDBLTFAST_WAIT | DDBLTFAST_NOCOLORKEY,这在图片不带透明时没问题,一旦遇上需要透明效果的图,会被强制忽略颜色键,边缘出现黑块。

还有一点容易踩:BltFast要求源表面和目标表面的像素格式一致。窗口模式下,离屏表面继承了桌面颜色格式,主表面也是桌面格式,通常没问题;但如果你在全屏模式SetDisplayMode(800, 600, 16)后创建离屏表面,而加载的 BMP 是 24 位色,GDI 的 BitBlt 自动做了颜色转换,但BltFast不转换,它只是硬拷贝。这种情况下要么将全屏色深也设为 32 位,要么手动查一下表面的GetPixelFormat再决定图像色深。

4. DirectDraw 显示图片的六个常见坑

4.1 现象:全屏模式下黑屏或者画面撕裂

原因往往是两个:一是在全屏模式下调用了SetDisplayMode但不是先设协作模式,或者协作模式里少了DDSCL_EXCLUSIVE;二是创建主表面时只用DDSCAPS_PRIMARYSURFACE没有使用后台缓冲。撕裂的根源是主表面在没有双缓冲的情况下直接把正在扫描的帧数据改写,显示器扫到一半就被换了。

解决方法是严格按顺序操作:先SetCooperativeLevel(DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN),再SetDisplayMode,然后创建主表面时加DDSCAPS_PRIMARYSURFACE | DDSCAPS_FLIP,再创建后台缓冲表面。每次绘制先画到后台表面,再lpPrimary->Flip(nullptr, DDFLIP_WAIT)完成翻页。单张静态图片用双缓冲看起来有点浪费,但对许多老项目来说,后面加动画加刷新是必然的事,一次到位省得返工。

4.2 现象:图片显示出来颜色严重偏色,或者反色

原因大概率是色深不一致。加载的 BMP 是 24 位,而显示模式是 16 位,BltFast是逐像素硬拷贝,不会做颜色转换,16 位表面上的 RGB 分量被直接截断,出来的颜色就完全不对了。还有一种情况是颜色通道顺序问题,比如 BMP 是 BGR 顺序,而表面格式是 XRGB 或 RGBA,BitBlt画入时可能正常,BltFast 显示时才暴露。

解决思路分两步。第一步:在SetDisplayMode统一用 32 位,避免 16 位带来的通道截断;第二步:如果必须用 16 位显示(老触摸屏常见),就改为Lock离屏表面后手动把 24 位像素转换成 R5G6B5 格式再写入。转换很简单但有不少人遗漏,具体是把 RGB 分量右移后按位或进一个 WORD,格式固定为低 5 位红、中 6 位绿、高 5 位蓝。这个细节直接影响老设备上的画面表现。

4.3 现象:窗口模式下一片黑,但 DirectDraw 初始化全部成功

这个坑出现的频率极高,原因只有一个:窗口模式创建主表面后没有指定 Clipper。DirectDraw 文档里明确说窗口模式必须设置 Clipper,否则窗口客户区之外的内容不可见,客户区本身也可能得不到绘制。这个问题难在于它不报错——CreateSurface不失败,BltFast也返回成功,只是屏幕上看不到任何东西。

解决就是在第 2 章那段代码里补上CreateClipper和SetClipper两步。另外一个隐藏问题是:如果窗口被拖动或者客户区大小变化,Clipper 关联的窗口句柄还在,但目标坐标不变的话,图片会显示在旧位置上。最简单处理是在WM_MOVE/WM_SIZE消息里重新计算目标坐标并再次调用BltFast,或者用SetWindowPos固定窗口位置。

4.4 现象:切换分辨率或切换到全屏程序后再回来,图片显示报错

典型报错是DDERR_SURFACELOST,DirectDraw 表面丢失。原因是主表面在用户按 Alt+Tab 切走、显示器分辨率变化、电源节能唤醒时,显存内容会被系统清除,表面对象虽然还在,但底层数据已经没了。最要命的是离屏表面里的图片数据也不会自动恢复,需要手动重新装载。

// 在 WM_DISPLAYCHANGE 或 WM_ACTIVATE 消息处理中 HRESULT hr = lpOffscreen->IsLost(); if (hr == DDERR_SURFACELOST) { // 先恢复显存分配 lpOffscreen->Restore(); // 再把图片数据重新画进去,因为 Restore 后内容全空 DrawBmpToSurface(lpOffscreen, szFilePath); lpPrimary->Restore(); // 最后重新显示一次 lpPrimary->BltFast(dx, dy, lpOffscreen, &srcRect, DDBLTFAST_WAIT); }

Restore只是恢复显存空间,不会替你恢复像素内容,所以重新加载图片这步不能省。这也是许多项目调试时发现“切出去再切回来,图片没了”的直接原因。

4.5 现象:透明图片的边缘出现黑边或白边

透明效果的实现原理是颜色键(ColorKey),即指定一个颜色当作透明,BltFast时跳过这个颜色的像素。黑边通常是因为颜色键设置错了。SetColorKey时给出的颜色值必须与表面实际像素格式一致,常见错误是直接把 RGB 的 COLORREF 值(0x00RRGGBB)填进去,但表面是 16 位格式时,正确的键值应该是一个 16 位值。

解决办法不是猜,而是在Lock表面后读取图片角落处的实际像素值,那个值才是颜色键。更稳妥的路径:创建离屏表面时把表面像素格式设成与图片一致,或用DDCKS_SETCOLORKEY配合GetPixelFormat转换一次颜色键值。

4.6 现象:图片大或频繁刷新时明显卡顿,GDI 路径拖慢帧率

原因显而易见:每帧都走GetDC → BitBlt → ReleaseDC这条 GDI 路径,性能远低于 DirectDraw 原生的Lock像素操作。GDI 互操作适合初始化时装图,不适合作为每帧绘制的主路径。

解决办法是分两条路走:若只是显示一张静态图,卡顿影响不大,无需优化;若是定时刷新(比如每秒 10 次显示更新),就把图片在初始化时一次性从 GDI 画入离屏表面,之后每帧只做BltFast或Flip,绝不在渲染循环里碰 GDI。

5. 从“显示一张图”到能用的显示模块:三小时改成翻页

把你刚刚跑通的静态显示扩展成能翻页的显示模块,核心改动只有两块:一是加入后台表面做双缓冲,二是用Flip代替BltFast。先创建后台表面,dwCaps加DDSCAPS_BACKBUFFER,并确认主表面的能力带有DDSCAPS_FLIP。绘制时把图片BltFast到后台表面上,然后调用lpPrimary->Flip(nullptr, DDFLIP_WAIT),画面切换干净利落,不会闪。Flip的DDFLIP_WAIT会让线程等待垂直同步,如果不想等,填 0 但画面偶尔会撕裂。

验证模块是否工作正常的办法很有用:创建表面后调用GetCaps读一下dwCaps,确认你的显卡驱动(哪怕是模拟层)是否声明支持DDSCAPS_FLIP。GetCaps返回的是硬件能力,不检查的话,在某些精简版系统上Flip会静默失败。另一个检查点是GetDisplayMode,拿它确认全屏的色深和分辨率是否真的生效。

这个模块的边界你心里要有数:DirectDraw 今天跑在系统模拟层上,性能比当年的真硬件差一个数量级,别指望它做视频播放或游戏,做状态图翻页足够。我还有个习惯,写完之后把离屏表面的内容写成SaveSurfaceToFile函数,把屏幕内容存成一张 BMP 看效果——比起盯着代码猜,直接把显示结果导出对比是最快的排错姿势。

这些年做维护项目,最深刻的教训就是:DirectDraw 的坑大多不在 API 本身,而在它的“表面”对象和 GDI 资源的配对管理上。配对数不清,画面就玄学。养成写完立刻检查GetDC/ReleaseDC和Lock/Unlock是否配对的习惯,能省下大量调试时间。如果你从本篇只记住一个函数,记住BltFast即可,它贯穿今天所有图片显示的落地场景。希望帮到你。

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

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

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

立即咨询