简介:这是一份面向VC++初学者的DirectDraw图形编程示例,演示在屏幕窗口中加载并显示BMP图片的完整实现。DirectDraw是DirectX中的经典2D图形加速接口,曾广泛用于游戏开发与高性能图形应用;示例采用IDirectDraw7接口,围绕对象创建、合作级别设置、表面建立、图像拷贝、缓冲区翻转以及资源释放等关键环节组织代码,能够让读者直观看到从初始化到最终显像的完整工程脉络。压缩包共21个文件,主要包含6个h头文件、4个cpp源文件,以及rc资源脚本、ico图标、bmp位图、ReadMe说明文档等,整体仅1.31MB,便于下载后直接阅读和编译调试。项目基于Visual C++对话框与文档视结构搭建,主程序、对话框逻辑、全局声明与框架代码分层清晰,适合课程设计或自学查漏补缺。已有380人学习下载,对于想了解DirectX早期图形编程原理或需要完成类似实验任务的开发者,是一份小巧而完整的参考范例。 搞过老一代图形编程的朋友,对“DirectDraw”这六个字母应该都不陌生。当年在VC 6.0时代,想在窗口里显示一张图片、做个小游戏或者播放一段动画,很多人第一反应就是开一个DirectDraw设备,创建表面,然后往上贴图。现在虽然Direct3D 9、11甚至12已经替代了这套老接口,但从学习角度讲,DirectDraw依然是理解“显存表面”、“页面翻转”、“离屏表面”这些2D图形核心概念的绝佳教材。
这篇笔记就从一个最简单的需求出发——用VC和DirectDraw在屏幕上显示一张图片。我会先讲清楚整套流程的设计思路,然后给出可以直接编译运行的完整示例代码,再逐段解释关键API的使用,最后把我踩过的几个坑和排查方法一并整理出来。无论你是想复习老技术,还是刚接触Win32图形编程想了解底层原理,这篇文章都很适合。
1. 为什么现在还要翻出DirectDraw:一套经典流程的价值
很多年轻的朋友可能一上来就会问:显示一张图片用GDI的BitBlt不行吗?用CreateCompatibleDC加StretchBlt几行代码就搞定了,为什么非要绕一圈去用DirectDraw?
这个问题问得挺好。单看“显示图片”这个需求,GDI确实更简单。但DirectDraw的价值不在于“简单”,而在于它给你展示了另一套图形处理路径——直接面向显存表面的操作思路。用GDI画图,系统要经过窗口管理器、GDI渲染队列,层层转换;而DirectDraw的思路是把你需要的东西提前放到一个“表面”里,然后通过硬件快速拷贝或者页面翻转直接显示。这个“表面”的概念,后来在D3D9、D3D11里变成Texture、RenderTarget,本质上是一脉相承的。
1.1 DirectDraw到底是什么
DirectDraw是DirectX 1.0到7.0时代的2D图形组件,它的核心抽象就是“表面”。表面可以理解成一块显存或者系统内存里的矩形缓冲区,里面保存着像素数据。DirectDraw允许你直接读写这个表面,也可以在表面之间做快速复制(Blt),还可以把一个后备表面通过翻转操作直接变成可见内容。
这套东西在当时的游戏开发里非常流行,像《红警2》早期版本、《仙剑奇侠传》的某些界面,底层都能看到DirectDraw的影子。后来微软在Windows Vista引入WDDM驱动模型之后,DirectDraw的硬件加速能力基本被移除了,现在的DirectDraw调用大都是在软件模拟层上执行。但API接口保持兼容,所以老代码依然能跑,学习用途依然成立。
1.2 一套完整的图片显示流程怎么拆
把一个“显示图片”的需求拆开,DirectDraw的做法其实就四步:
- 创建DirectDraw对象并设置协作级别、显示模式。
- 创建主表面,需要的话再创建一个后备表面,形成翻页结构。
- 把图片文件加载到一个离屏表面里。
- 在程序主循环里把离屏表面内容贴到后备表面,再执行翻转。
这种“离屏表面存素材、后备表面做合成、主表面负责显示”的三层设计,是DirectDraw的核心思路。今天读起来仍然很有价值,因为它天然解决了“闪烁”问题:你永远不直接在可见表面上画东西,而是先在不可见的表面里画好一整帧,再一次性切换显示。
1.3 这个示例适合谁
如果你满足下面任何一种情况,这个示例值得你亲手敲一遍:
- 正在学习Win32程序设计,想知道除了GDI之外还有什么绘制方式。
- 想读老游戏源码,但被DirectDraw API的基本概念卡住。
- 想理解现代图形API里“表面/纹理/交换链”这些概念的源头。
DirectDraw的API风格相对直白,不像D3D11那样动辄一堆管线状态、Shader绑定。它更接近“向显存画图”的直觉,适合作为你从GDI走向现代图形API之间的过渡桥。
2. 核心对象和初始化:先把DD对象“伺候”好
在DirectDraw的世界里,几乎所有东西都围绕“接口”展开。那个年代COM接口还非常流行,所以你会看到一大堆LPDIRECTDRAW7、LPDIRECTDRAWSURFACE7这样的指针类型。别被这些长名字吓到,它们本质上是“一组函数指针的集合”,你只需要记住每个接口负责什么就行。
2.1 DirectDrawCreateEx与DirectDraw7接口的选择
创建DirectDraw对象有老、新两个函数:老的DirectDrawCreate创建的是IDirectDraw接口,只支持到DirectDraw 5的功能;新的DirectDrawCreateEx则可以指定任意版本的接口,比如IID_IDirectDraw7。
实际开发中我建议直接用DirectDrawCreateEx创建DirectDraw7接口。原因很简单:DirectDraw7是这套API的最终版本,功能最全,而且性能接口的处理也更规范。老接口虽然能用,但官方早就不推荐了,没必要在自己代码里保留一个落后版本。
注意:使用
DirectDrawCreateEx时,记得在工程设置里链接dxguid.lib,否则IID_IDirectDraw7这个GUID会报链接错误。用VC 6那会儿,把dxguid.lib ddraw.lib加进“Project Settings -> Link -> Object/library modules”里就行;用新版Visual Studio则是在“链接器 -> 输入 -> 附加依赖项”里配置。
2.2 协作级别与显示模式:全屏独占还是窗口化
创建完DirectDraw对象之后,第一步永远是SetCooperativeLevel,它告诉系统你的程序准备怎么使用显示设备。设置协作级别之前,所有其他调用都可能失败,所以这步别省。
最经典的模式是DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN,也就是全屏独占。这种模式下你可以调用SetDisplayMode任意切换屏幕分辨率、色深,并且能够使用页面翻转功能。页面翻转是DirectDraw避免闪烁的利器——你先把内容画到后备表面上,然后通过Flip指令把后备表面和主表面交换,显示器下一个刷新周期就显示新画面了。
如果你想在普通窗口里显示图片,协作级别要用DDSCL_NORMAL,同时不能调用SetDisplayMode,也不能用Flip,只能用Blt或者BltFast把内容直接传到主表面。窗口模式下主表面自动与窗口客户区关联,但这种模式下主表面没有翻页结构,画面闪烁需要自己想办法缓解。
下面是一张对比表格,方便你快速理解两种模式的区别:
| 协作级别 | 显示模式 | 表面结构 | 适用场景 |
|---|---|---|---|
| DDSCL_EXCLUSIVE|DDSCL_FULLSCREEN | 可调用SetDisplayMode | 主表面+后备表面,支持Flip | 游戏、全屏演示 |
| DDSCL_NORMAL | 不可调用SetDisplayMode | 仅主表面,可Blt | 普通窗口显示 |
2.3 表面体系:主表面、后备表面、离屏表面
DirectDraw表面分很多种,你只需要搞清楚三个核心概念:
- 主表面:直接对应屏幕可见内容,用
DDSD_CAPS加DDSCAPS_PRIMARYSURFACE标志创建。窗口模式下它映射到窗口客户区,全屏模式下它映射整个屏幕。 - 后备表面:只在全屏翻页结构中存在,主表面创建时设置
DDSCAPS_FLIP | DDSCAPS_COMPLEX和DDSCAPS_BACKBUFFERCOUNT=1,然后用GetAttachedSurface获取。它和主表面构成一个翻转环。 - 离屏表面:不与屏幕直接关联的普通表面,用
DDSCAPS_OFFSCREENPLAIN创建,专门用来存放图片、精灵等静态素材。
理解这三者的关系,你会突然明白DirectDraw所有绘制操作的本质:把图片从离屏表面拷贝到后备表面,然后翻转,再拷贝,再翻转。如此往复,就形成了连续的画面。
3. 实操示例:从0到1显示一张BMP
理论铺垫够了,直接进入正题。下面这个例子是用Win32 + DirectDraw7实现的全屏显示图片程序,画面中央会显示一张pic.bmp图片,按Esc键或关闭窗口退出。代码我尽量精简,但每一部分都保留了完整逻辑。
3.1 工程配置:头文件库文件链接
在写代码之前,先把环境准备好。Visual Studio下新建一个Win32项目,把ddraw.h头文件包含进来,并在链接器设置里加上ddraw.lib和dxguid.lib这两个库文件。如果你的开发环境是VC 6.0,操作路径是“Project -> Settings -> Link -> Object/library modules”,然后在输入框里追加这两个库名。用新版Visual Studio则在项目属性里找“链接器 -> 输入 -> 附加依赖项”。
在较新的Windows SDK里,ddraw.h和ddraw.lib仍然存在,不需要额外安装DirectX SDK。我实测了一下,Windows 10/11自带的SDK就能编译通过,系统System32目录下也始终带着ddraw.dll,运行完全没问题。这一点对老技术爱好者来说相当友好——不用折腾直接就能跑。
3.2 初始化窗口和DD对象
在主函数里,我们首先创建一个普通的Win32窗口。因为用的是全屏独占模式,窗口风格直接设成WS_POPUP,没有标题栏和边框,显示的时候铺满屏幕。窗口的尺寸倒不需要和屏幕分辨率一致,因为SetDisplayMode会把整个显示模式切掉,屏幕最终分辨率以SetDisplayMode为准。
创建窗口后,关键代码登场:
#include <windows.h> #include <ddraw.h> LPDIRECTDRAW7 g_pDD = NULL; LPDIRECTDRAWSURFACE7 g_pPrimary = NULL; LPDIRECTDRAWSURFACE7 g_pBack = NULL; LPDIRECTDRAWSURFACE7 g_pBmp = NULL; const int SCR_W = 800; const int SCR_H = 600; BOOL InitDirectDraw(HWND hwnd) { // 1. 创建DirectDraw7对象 HRESULT hr = DirectDrawCreateEx(NULL, (void**)&g_pDD, IID_IDirectDraw7, NULL); if (FAILED(hr)) return FALSE; // 2. 设置协作级别为全屏独占 hr = g_pDD->SetCooperativeLevel(hwnd, DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN); if (FAILED(hr)) return FALSE; // 3. 设置显示模式 hr = g_pDD->SetDisplayMode(SCR_W, SCR_H, 32, 0, 0); if (FAILED(hr)) return FALSE; // 4. 创建主表面,并附带一个后备表面 DDSURFACEDESC2 ddsd; ZeroMemory(&ddsd, sizeof(ddsd)); ddsd.dwSize = sizeof(ddsd); ddsd.dwFlags = DDSD_CAPS | DDSD_BACKBUFFERCOUNT; ddsd.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE | DDSCAPS_FLIP | DDSCAPS_COMPLEX; ddsd.dwBackBufferCount = 1; hr = g_pDD->CreateSurface(&ddsd, &g_pPrimary, NULL); if (FAILED(hr)) return FALSE; // 5. 获取后备表面 DDSCAPS2 ddscaps; ZeroMemory(&ddscaps, sizeof(ddscaps)); ddscaps.dwCaps = DDSCAPS_BACKBUFFER; hr = g_pPrimary->GetAttachedSurface(&ddscaps, &g_pBack); if (FAILED(hr)) return FALSE; return TRUE; }这里有两个值得留意的细节。第一,DirectDrawCreateEx的第一个参数设为NULL,表示使用当前主显示器,多显示器环境下这个空值也够用,除非你要针对特定显示器做开发。第二,创建主表面时dwFlags必须包含DDSD_BACKBUFFERCOUNT,并且把后备表面数量设为1,否则GetAttachedSurface拿不到后备表面。
3.3 加载图片到离屏表面
图片加载的方式有很多种,这里我用最直接的方法:先用LoadImage从文件读入BMP,再用GetDC获取离屏表面的HDC,最后用GDI的BitBlt把位图内容拷贝到DirectDraw表面。这种做法的好处是代码量小,不需要逐像素操作,而且对这种一次性加载的静态图片来说性能足够。
BOOL LoadBitmapToSurface(LPCTSTR lpszFileName) { // 1. 用LoadImage读取BMP文件 HBITMAP hBitmap = (HBITMAP)LoadImage(NULL, lpszFileName, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); if (hBitmap == NULL) return FALSE; // 2. 获取位图尺寸 BITMAP bm; GetObject(hBitmap, sizeof(bm), &bm); // 3. 创建与位图等大的离屏表面 DDSURFACEDESC2 ddsd; ZeroMemory(&ddsd, sizeof(ddsd)); ddsd.dwSize = sizeof(ddsd); ddsd.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT; ddsd.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN; ddsd.dwWidth = bm.bmWidth; ddsd.dwHeight = bm.bmHeight; HRESULT hr = g_pDD->CreateSurface(&ddsd, &g_pBmp, NULL); if (FAILED(hr)) { DeleteObject(hBitmap); return FALSE; } // 4. 用GDI BitBlt把位图拷到离屏表面 HDC hdcMem = CreateCompatibleDC(NULL); HGDIOBJ oldBmp = SelectObject(hdcMem, hBitmap); HDC hdcSurface = NULL; g_pBmp->GetDC(&hdcSurface); BitBlt(hdcSurface, 0, 0, bm.bmWidth, bm.bmHeight, hdcMem, 0, 0, SRCCOPY); g_pBmp->ReleaseDC(hdcSurface); SelectObject(hdcMem, oldBmp); DeleteDC(hdcMem); DeleteObject(hBitmap); return TRUE; }这段代码里最需要留意的是LR_CREATEDIBSECTION标志。如果不加这个标志,LoadImage返回的可能是设备相关位图,GetObject拿到的BITMAP结构里点距信息会不准确,而且BitBlt拷贝时也可能出现颜色错乱。加了LR_CREATEDIBSECTION以后,我们会拿到一个独立于设备的DIB段,像素格式和尺寸都相对可控,拷到表面后颜色表现更稳定。
另一个容易被忽略的点是:离屏表面的宽高和原图宽高不一定需要完全一致。如果图片尺寸很大,可以创建更大的表面,然后用BltFast的RECT参数只贴出一部分;如果图片尺寸较小,就按原尺寸创建。这里直接按位图尺寸创建,逻辑最清晰。
3.4 主循环:贴图与翻页
窗口初始化完成、图片也加载到离屏表面之后,进入最关键的主循环。这个循环做的事情只有两件:把离屏表面的图片内容贴到后备表面,然后调用Flip让后备表面内容显示到屏幕上。
MSG msg; BOOL bDone = FALSE; while (!bDone) { while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) { bDone = TRUE; break; } TranslateMessage(&msg); DispatchMessage(&msg); } if (bDone) break; // 清空后备表面为黑色,避免上一帧残留 DDBLTFX ddbltfx; ZeroMemory(&ddbltfx, sizeof(ddbltfx)); ddbltfx.dwSize = sizeof(ddbltfx); ddbltfx.dwFillColor = 0; // 黑色 g_pBack->Blt(NULL, NULL, NULL, DDBLT_WAIT | DDBLT_COLORFILL, &ddbltfx); // 把图片从离屏表面贴到后备表面中央 int nX = (SCR_W - bmWidth) / 2; int nY = (SCR_H - bmHeight) / 2; RECT rcDest = { nX, nY, nX + bmWidth, nY + bmHeight }; g_pBack->Blt(&rcDest, g_pBmp, NULL, DDBLT_WAIT, NULL); // 翻页,后备表面变成可见表面 g_pPrimary->Flip(NULL, DDFLIP_WAIT); }这里我特意在每次贴图之前先对整个后备表面做了一次COLORFILL填充黑色,这样做是为了防止图片尺寸小于屏幕时,边缘残留上一帧的脏数据。如果你希望图片铺满全屏,也可以把Blt的目标矩形直接设为整个屏幕区域,但那样会拉伸图片,除非图片分辨率刚好等于屏幕分辨率,否则别这么做。
Flip(NULL, DDFLIP_WAIT)是这套流程里最核心的一步。它把主表面和后备表面互换,整个操作在显示器的垂直回扫期间完成,所以画面不会出现撕裂和闪烁。DDFLIP_WAIT标志表示等待翻转完成,如果画面更新速度要求极高,也可以用DDFLIP_DONOTWAIT,但初学者老老实实等待就好。
这段循环里我假设bmWidth、bmHeight是全局变量,LoadBitmapToSurface函数里从BITMAP结构赋值得到。为了代码简洁,我在这里直接用变量名表示,实际代码里你需要在全局区维护这两个变量。
3.5 资源清理的正确顺序
程序退出时,DirectDraw资源的释放顺序是有讲究的。原则很简单:先释放子对象,再释放父对象。具体到这个示例里,顺序是:先释放离屏表面g_pBmp,再释放后备表面g_pBack,再释放主表面g_pPrimary,最后释放DirectDraw对象g_pDD。
if (g_pBmp) { g_pBmp->Release(); g_pBmp = NULL; } if (g_pBack) { g_pBack->Release(); g_pBack = NULL; } if (g_pPrimary) { g_pPrimary->Release(); g_pPrimary = NULL; } if (g_pDD) { g_pDD->Release(); g_pDD = NULL; }有一个细节很容易踩坑:后备表面g_pBack是通过g_pPrimary->GetAttachedSurface拿到的,它实际上是主表面附属的引用,不是独立创建的新对象。理论上你只Release主表面后,后备表面会自动释放,但为了保险起见,显式地把它也Release掉没有任何问题。不过一定要先释放后备再释放主表面,顺序反了可能出现未定义行为,严重时进程退出会崩。
4. 踩坑实录:常见问题与排查技巧
写DirectDraw代码不难,调起来才是真正费时间的地方。这部分我把这些年帮人排查时遇到的高频问题整理成一个速查表,再展开说几个“文档里不会写”的实践心得。
4.1 问题速查表
| 现象 | 常见原因 | 排查与解决办法 |
|---|---|---|
| 编译时找不到ddraw.h | 未包含Windows SDK或老DX SDK | VS里安装“桌面开发”工作负载,Windows SDK自带该头文件 |
| 链接时IID_IDirectDraw7未定义 | 缺少dxguid.lib | 在链接器附加依赖项中加入dxguid.lib |
| 窗口一闪而过 | WM_PAINT处理不当或消息循环写错 | 确认主循环里没有把DispatchMessage漏掉,窗口过程别直接PostQuitMessage |
| 黑屏无内容 | 没有设置协作级别就调用其他API | 按顺序初始化:CreateEx -> SetCooperativeLevel -> SetDisplayMode -> CreateSurface |
| 图片显示出来颜色不对 | 显示模式色深和位图色深不一致 | 统一用32位色深,或把位图转成与显示模式一致的格式 |
| 画面剧烈闪烁 | 直接把图贴到了主表面而不是后备表面 | 全屏独占模式下一定要用后备表面+Flip,别用主表面直接Blt |
| 拖动窗口时画面不刷新 | 窗口模式没有处理WM_PAINT | 窗口模式下需要在WM_PAINT里重新执行贴图逻辑 |
| 程序退出时崩溃 | 释放顺序错误 | 严格按离屏 -> 后备 -> 主表面 -> DD对象的顺序释放 |
4.2 几个容易忽略的细节
先说颜色格式问题。DirectDraw表面里的像素格式取决于SetDisplayMode设置的色深。如果显示模式是16位色,表面里的像素可能按RGB565排列;如果位图文件本身是24位BMP,直接BitBlt过去,GDI会自己处理转换。但如果你后续要做像素级操作(比如逐像素去色、混合),就一定要搞清楚表面的像素格式,否则算出来的颜色完全不对。排查方法很简单:调用GetSurfaceDesc查看表面描述里的ddpfPixelFormat结构,里面会有dwRBitMask、dwGBitMask等掩码信息。
再说图片加载失败的隐蔽原因。LoadImage如果找不到文件不会报错崩溃,只会在返回NULL,很多人第一反应是代码错了,其实检查一下工作路径就明白了。在Visual Studio里调试运行,工作目录默认是$(ProjectDir),也就是工程文件所在目录。所以pic.bmp要放在工程目录下,或者你直接写绝对路径,比如D:\\test\\pic.bmp。我建议调试阶段用绝对路径,跑通以后再改成相对路径,省得被这种小问题耽误时间。
还有一点是关于DDBLT_WAIT和DDBLTFAST_WAIT的选择。这个示例里Blt用的是DDBLT_WAIT,它自带裁剪功能,目标矩形可以超出屏幕边界而不报错。BltFast则要求源矩形和目标矩形都在合理范围内,性能更快但约束也多。初学者建议先用Blt,等需要优化性能再切到BltFast。
最后说说现代的兼容性。Windows Vista之后,DirectDraw的硬件加速被禁用,所有操作都走软件模拟,所以性能肯定没法和当年真硬件加速比。但做教学示例、研究老代码完全够用。我实测过,在Windows 10/11上跑全屏800x600的翻转显示,CPU占用不高,画面稳定不闪。
4.3 这套流程还能怎么扩展
一个图像显示示例只是个起点,顺着这套DirectDraw流程继续往下挖,你可以做很多事情:
- 把离屏表面换成多个子图,实现精灵动画系统,就是老游戏里人物行走、子弹飞行的基础。
- 在贴图之前对表面执行
Lock和Unlock,直接修改像素缓冲区,实现淡入淡出、颜色变换等滤镜效果。 - 用
Blt的源矩形参数做局部贴图,轻松实现地图瓦片渲染。 - 结合
GetTickCount控制每帧间隔,做一个基本的帧率控制循环。
我自己的体会是,DirectDraw这套接口虽然技术上已经过时,但它的“表面+翻转”思想在现代图形API里依然是核心。D3D11的SwapChain本质上就是升级版的主表面加后备表面,Vulkan的Present Queue也承担着类似Flip的工作。把DirectDraw的示例写透,你再去看现代图形引擎的框架结构,会感觉很多概念一下子通了。
如果你在本地编译运行这个示例时碰到任何我上面没写到的问题,建议先从初始化顺序入手检查——DirectDraw的API调用顺序极其敏感,绝大多数“黑屏”、“无效调用”都和初始化顺序有关。那次我帮朋友调一个老项目图片闪烁问题,最后查到原因居然是协作级别里漏了DDSCL_EXCLUSIVE标志,导致翻转表面根本创建不出来。这种问题看代码很难发现,最好的办法就是按我列出的初始化顺序逐条核对,一步都不能错。
本文还有配套的精品资源,点击获取