☰
GDI+高级绘图实战:Win32汇编中的画刷、字体、图像与双缓冲动画
2026/10/16 3:02:04 网站建设 项目流程

你如果一路跟到第四篇,说明前面三篇里的GDI+初始化、基本图形绘制和简单文本输出都已经跑通了。这一回我们把剩下的硬骨头啃掉:画刷的高级用法、字体渲染的细节、图像加载与像素操作,以及做动画时绕不开的双缓冲和坐标变换。这些内容比单纯画线画矩形有意思得多,也是GDI+相对于老GDI最值回票价的地方。

这篇文章仍然保持“Win32汇编+MASM32”的路线,不会扯C++封装,所有调用都是GDI+扁平API,也就是那些带Gdip前缀的导出函数。新来的读者也不用担心,我会在关键位置把前几篇依赖的背景补一点,至少你能知道每个GDI+对象是怎么来的、怎么用、怎么释放。整篇读完,你应该能在一个纯汇编窗口程序里完成图片显示、文字水印、像素反色,以及一个不闪烁的动画演示。

1. 这一篇到底要补什么:GDI+的调用套路和状态管理

1.1 为什么Win32汇编里绕不开GDI+

很多人在Win32汇编里画界面,第一反应是使用GDI,毕竟TextOut、Rectangle、BitBlt这些函数简单直接。但一旦涉及到抗锯齿、半透明、渐变刷、PNG图片、任意角度旋转,GDI就会非常痛苦。老GDI的路径、区域、变换接口不是没有,但使用起来和现代绘图需求差得太远。GDI+之所以在早期被大量GUI程序采用,就是因为它把这些能力统一成一个相对好用的对象模型,而且底层是纯C接口,汇编可直接调用。

在Win32汇编里使用GDI+,首先要接受一个事实:没有C++的Graphics类、Pen类,所有操作都变成带Gdip前缀的全局函数,第一个参数通常是对象指针。我自己的经验是,把C++头文件里的成员函数和扁平API名字对应起来就顺了。比如C++里的graphics.DrawLine(pen, x1, y1, x2, y2),对应汇编里的GdipDrawLine(graphics, pen, x1, y1, x2, y2);graphics.DrawRectangle对应GdipDrawRectangleI。记住这个规律,查MSDN或者看gdiplus.inc时就不会迷路。

1.2 GDI+的核心对象与释放原则

GDI+的主要对象包括Graphics(绘图表面)、Pen(画笔)、Brush(画刷)、Font(字体)、Image/Bitmap(图像)、StringFormat(文本格式)、Matrix(变换矩阵)。和GDI句柄不同,这些都是指向内部结构体的指针,创建成功后你拿到的实际上是一个DWORD类型的地址。所有对象都有对应的创建和释放函数,名字规律非常明显:

  • GdipCreatePen1/GdipDeletePen
  • GdipCreateSolidFill/GdipDeleteBrush
  • GdipCreateFont/GdipDeleteFont
  • GdipLoadImageFromFile/GdipDisposeImage
  • GdipCreateFromHDC/GdipDeleteGraphics

释放顺序也有讲究。如果一个Graphics对象是在某个HDC上用GdipCreateFromHDC创建的,那么必须先把图形对象删掉,才能释放HDC。反过来,画刷和画笔可以在Graphics对象删除之前或者之后都行,只要保证每个对象都释放一次即可。我在实际调试中见过很多人只创建不释放,程序跑一会儿内存就飙上去,这个问题在纯汇编里比C++更容易忽略,因为汇编没有析构函数帮你兜底。

2. 初始化与反初始化:第四次聊还要再强调一遍

2.1 一个稳妥的startup/shutdown流程

GDI+全局初始化在系列前几篇应该已经写过,但我还是建议你把它封装成一个固定流程,避免每次新建工程都复制错。GdiplusStartup需要的输入结构体在MASM32的gdiplus.inc里有定义,只需要在数据段放一个初始化的结构体实例即可,四个字段分别是版本号、调试回调指针、是否抑制后台线程、是否抑制外部编码器。

.data gdiplusInput GdiplusStartupInput <1, 0, 0, 0> gdiplusToken DWORD ? pGraphics DWORD ?

初始化代码放在WinMain里,或者在窗口创建成功之后,程序退出之前确保GdiplusShutdown被调用。一个比较稳的选择是在注册窗口类之后初始化,在收到WM_CLOSE或者主循环退出前反初始化。

invoke GdiplusStartup, ADDR gdiplusToken, ADDR gdiplusInput, NULL test eax, eax jnz start_failed ; 继续创建窗口,进入消息循环

反初始化的时候只需要传入token,不传会直接产生未定义行为。GDI+内部对token做了引用管理,所以必须保存好这个值。我在一个工程里犯过错误,把gdiplusToken当成普通变量反复清零,导致退出时程序崩溃,后来才发现是GdiplusShutdown收不到正确的token。

2.2 初始化失败该怎么办

GdiplusStartup返回0表示成功,非0就是失败。失败原因常见的就几种:系统版本太老、GDI+DLL缺失、已经有一个不兼容的初始化状态。对现代Windows来说,前两种基本不会出现,但你仍然应该写一个错误分支,总好过黑屏后没人知道为什么。

如果初始化失败,所有后续的GdipCreate...调用都会返回错误状态。因为GDI+函数本身不会触发异常,只会安静地返回非0值,所以最直接的排查方法就是在每个关键调用后面检查eax。不要觉得啰嗦,在汇编时代这就是唯一的容错手段。我习惯在调试版里把所有非0返回值都记下来,看到哪个函数第一次返回值异常,基本就能定位到问题区域。

3. 画笔画刷与颜色:从“能画”到“画得漂亮”

3.1 实线、虚线、宽度和LineCap

GDI+的画笔比GDI的HPEN灵活,主要体现在宽度是浮点、默认带抗锯齿、可以设置虚线样式。创建画笔用GdipCreatePen1,参数分别是ARGB颜色、宽度(REAL类型)、单位,最后输出画笔指针。单位一般用UnitPixel,在枚举里值是2。如果宽度小于1,它不会变成1像素,而是按浮点宽度渲染,这一点和GDI不同。

.data hPen DWORD ? penWidth REAL4 2.5 .code invoke GdipCreatePen1, 0FF000000h, penWidth, 2, ADDR hPen

设置虚线样式调用GdipSetPenDashStyle,传入DashStyle枚举值。典型的枚举:DashStyleSolid=0、DashStyleDash=1。如果只是想要一个虚线边框,在重用同一个画笔时记得在绘制完成后把样式恢复成实线,否则后续所有用这个画笔画的线都会是虚线。这个“状态残留”是新手最容易忽略的坑。

3.2 渐变画刷和透明色

GDI+最让人舒服的地方之一就是渐变刷,老GDI如果要实现渐变,要么用CreateDIBSection的逐像素填充,要么用FillRect加混合,麻烦到怀疑人生。GDI+里线性渐变刷用GdipCreateLineBrushI创建,这里I后缀表示用整数坐标的POINT结构作为起止点。两个颜色可以是任意ARGB值,包括带Alpha的。

.data ptStart GpPoint <10, 10> ptEnd GpPoint <200, 100> hBrush DWORD ? .code invoke GdipCreateLineBrushI, ADDR ptStart, ADDR ptEnd, 0FFFF0000h, 0FF0000FFh, ADDR hBrush invoke GdipFillRectangleI, pGraphics, hBrush, 10, 10, 190, 90

透明色用ARGB表示,32位颜色值中高8位是Alpha。0x80FF0000就是不透明度50%的红色。很多人第一次用0xFF000000和0xFFFF0000混在一起时,会惊讶地发现颜色完全不对,因为ARGB不是GDI的COLORREF,0x00RRGGBB在GDI+里是完全透明的黑色。这个坑我踩过很久。

3.3 释放资源不是可选项

画刷和画笔创建后必须释放。具体对应关系如下表:

对象类型创建函数释放函数
画笔GdipCreatePen1GdipDeletePen
纯色画刷GdipCreateSolidFillGdipDeleteBrush
渐变画刷GdipCreateLineBrushIGdipDeleteBrush
纹理画刷GdipCreateTextureBrushGdipDeleteBrush

在循环里创建对象尤其要小心。比如一个每帧改变颜色的动画,如果每帧都重新创建一支画笔,用完立刻释放,那没事;但如果创建后忘记释放,只要几十帧,内存占用就能看得见地往上涨。GDI+对这些对象有自己的缓存机制,不及时释放不只是浪费内存,还可能导致后续创建失败。

4. 字体与文本:让DrawString在汇编里不乱码

4.1 从CreateFont到GDI+的Font

在GDI里使用字体,无非是CreateFont配上SelectObject。GDI+的字体重心偏向了Unicode和布局,但流程并不复杂。你得先根据字体名创建一个FontFamily,再基于这个FontFamily创建真正的Font。字体名必须是宽字符,可以用英文字体名规避源代码编码问题。比如用“Microsoft YaHei”而不是“微软雅黑”,这样即使汇编器对源文件编码敏感,也不会出现乱码。

.data szFontName WCHAR "Microsoft YaHei", 0 hFamily DWORD ? hFont DWORD ? fFontSize REAL4 24.0 .code invoke GdipCreateFontFamilyFromName, ADDR szFontName, NULL, ADDR hFamily invoke GdipCreateFont, hFamily, fFontSize, 0, 2, ADDR hFont

GdipCreateFont的第三个参数是字体样式,0是常规、1是粗体、2是斜体、4是下划线,可以组合。第四参数是单位,2仍然是UnitPixel。我不建议用CreateFont拿到HFONT再转GDI+字体,因为两者模型不同,转接过程中很容易丢失样式信息。

4.2 DrawString的坐标体系

文本输出函数GdipDrawString的参数比GDI的TextOut多得多:

invoke GdipDrawString, pGraphics, ADDR szHello, len, hFont, ADDR layoutRect, hFormat, hBrush

其中layoutRect是一个RectF结构,包含四个REAL字段:X、Y、宽度、高度。GDI+把文本绘制限定在一个布局矩形内,配合StringFormat决定对齐方式。创建StringFormat使用GdipCreateStringFormat,设置水平对齐用GdipSetStringFormatAlign。注意,对齐枚举中0是靠近原点(左对齐),1是居中,2是右侧对齐。垂直对齐另用GdipSetStringFormatLineAlign控制。

.data layoutRect RectF <10.0, 10.0, 500.0, 200.0> hFormat DWORD ? .code invoke GdipCreateStringFormat, 0, 0, ADDR hFormat invoke GdipSetStringFormatAlign, hFormat, 1

这里有两个细节。第一,GdipDrawString的第二个参数是宽字符串,如果是常量字符串可以直接用ADDR跳转,但字符串长度最好显式传入,不要传-1让GDI+自己算。第二,布局矩形用的单位是像素,不是DPI下的点。在程序里手动缩放窗口时,如果想保持字体大小不变,需要自己处理HI-DPI缩放,GDI+不会替你处理。

4.3 中文字体名和文本抗锯齿

如果一定要在源码里写中文字体名,必须确保.asm文件以合适的编码保存,并且汇编器能正确识别宽字符。MASM32对UTF-16的兼容性不是特别理想,所以我的习惯是能英文就英文。需要显示中文字符内容时,字符串本身依然需要宽字符格式。比如:

szHello WCHAR "你好,GDI+", 0

汇编器会按照源文件编码处理,建议统一保存为UTF-8 with BOM,或者直接用Unicode。如果你在链接后发现文字变成乱码,先检查这个字符串在内存里的十六进制是否和预期一致,别急着怀疑GDI+。另外,文本抗锯齿通过GdipSetTextRenderingHint控制,在绘制文字前设置一次即可。枚举值里2是默认抗锯齿,4是ClearType,如果你对字体清晰度有要求,可以用4,但要注意在一些特殊背景上ClearType反而会有彩边。

5. 图像加载、保存和像素级操作

5.1 加载PNG/JPG:两行代码的事

GDI+加载图片的API非常简单,GdipLoadImageFromFile接收一个宽字符文件名,输出GpImage*。它支持的格式包括BMP、GIF、JPEG、PNG、TIFF。然后在Graphics上绘制时,GdipDrawImageRectI可以直接指定目标区域,相当于一次完成缩放和绘制。

.data szFileName WCHAR "demo.png", 0 pImage DWORD ? .code invoke GdipLoadImageFromFile, ADDR szFileName, ADDR pImage test eax, eax jnz load_failed invoke GdipDrawImageRectI, pGraphics, pImage, 0, 0, 400, 300

这里有一个经验:加载图片后最好用GdipGetImageWidth和GdipGetImageHeight拿到原始宽高,再根据目标区域做等比缩放,避免图片被拉伸变形。如果你桌面是DPI缩放状态,逻辑坐标和物理像素会有偏差,先把当前窗口的缩放因子算好,再决定绘制尺寸。

5.2 用LockBits读写像素

如果不想只做整体缩放,而想直接处理像素,比如反色、亮度调整、加马赛克,那么必须把Bitmap锁进内存。GDI+提供了GdipBitmapLockBits,但很多人一看到BitmapData结构就发怵,其实它只是返回一块连续内存的地址、宽高、步长和像素格式。

.data bitmapData BitmapData <> lockRect GpRect <0, 0, 512, 512>

调用时,锁定的矩形用GpRect,操作标志用ImageLockModeRead(值1)或ImageLockModeWrite(值2),想同时读写就把两个标志按位或,但读取和写入分开更清晰。像素格式通常固定用32位ARGB,因为这是GDI+内部最通用的格式,处理速度也最快。

invoke GdipBitmapLockBits, pBitmap, ADDR lockRect, 1, PixelFormat32bppARGB, ADDR bitmapData

拿到Scan0之后,就可以用普通汇编指令遍历内存。Stride是每行扫描线的字节数,它可能比Width * 4大,因为GDI+会按4字节或者8字节边界对齐。遍历时不能用“宽度乘以4再乘高度”当作总长度线性走,否则会在行尾漏掉对齐填充字节,处理出来的图会斜。必须逐行按Stride跳转。

mov esi, bitmapData.Scan0 mov ecx, bitmapData.Height row_loop: push ecx mov ecx, bitmapData.Width mov edi, esi pixel_loop: ; <<< 对 [edi] 处的DWORD做像素处理 >>> add edi, 4 loop pixel_loop add esi, bitmapData.Stride pop ecx loop row_loop

反色处理时,注意不能直接对整个DWORD取反,否则Alpha通道也被反转。正确做法是保留最高8位Alpha,只对低24位RGB取反:

mov eax, [edi] and eax, 0FF000000h mov edx, [edi] not edx and edx, 00FFFFFFh or eax, edx mov [edi], eax

处理完成后,必须调用GdipBitmapUnlockBits把内存还给GDI+。不要直接删除Bitmap而不调用解锁,否则内部状态会混乱,严重时会崩溃。解锁之后,这个Bitmap才能继续参与绘制或者保存。

5.3 实际案例:给图片加水印并缩放

我建议你自己写一个简单的小模块:从磁盘加载一张图片,在内存中把它绘制到宽高缩放过的新Bitmap上,然后在右下角用半透明画刷绘制一行文字,最后把整个结果绘制到窗口客户区。这个综合练习能把你前面的知识全部串起来。

一个可行流程是这样的:先用GdipLoadImageFromFile获得图像,再用GdipCreateBitmapFromFile或GdipCreateBitmapFromGraphics创建目标Bitmap,拿到目标Bitmap的Graphics对象,调用GdipDrawImageRectI做缩放绘制,接下来设置一个带Alpha的实心画刷和字体,用GdipDrawString在右下角写字。整个过程最忌讳的是直接对原图操作,因为水印是不可逆的,如果原图是唯一资产,处理完就后悔了。正确做法是先加载原图到内存,然后复制一份再处理,最后释放原图对象。

6. 变换与双缓冲:动画是GDI+的加分项

6.1 用GdipTranslateTransform做平移旋转

GDI+的变换体系基于Matrix,但如果你只是做平移、缩放、旋转这些简单操作,完全可以不直接创建Matrix,而是用GdipTranslateTransform、GdipScaleTransform、GdipRotateTransform这些快捷函数。这三个函数作用于Graphics对象本身的World Transform,调用后所有后续绘制都会受到变换影响。

invoke GdipTranslateTransform, pGraphics, 100.0, 50.0 invoke GdipRotateTransform, pGraphics, 45.0

旋转角度的单位是度,不是弧度。这里有个容易掉进去的坑:旋转是围绕原点进行的,不是围绕图形中心。你先把图形画在坐标原点附近,再旋转,最后通过平移把结果挪到目标位置,比直接尝试“绕中心旋转”省心得多。恢复变换时,用GdipResetTransform把World Transform重置为单位矩阵,否则后续绘制全部带偏移。

6.2 为什么GDI+直接画到HDC会闪

GDI+反锯齿渲染本来就比GDI慢,如果每帧在WM_PAINT里直接往窗口HDC上绘制,窗口缩放、移动、刷新时一定会闪。解决办法不是硬调消息循环,而是做双缓冲。

经典流程是:在WM_PAINT里先BeginPaint拿到窗口HDC,创建一个兼容内存DC,再创建一个兼容位图并选入内存DC,然后调用GdipCreateFromHDC在内存DC上创建Graphics对象。所有GDI+绘制都画到内存DC上,绘制完成后先GdipDeleteGraphics,再用BitBlt把内存DC整块拷贝到窗口HDC,最后清理位图和DC。

invoke CreateCompatibleDC, hdc mov hMemDC, eax invoke CreateCompatibleBitmap, hdc, winWidth, winHeight mov hBmp, eax invoke SelectObject, hMemDC, hBmp mov hOldBmp, eax invoke GdipCreateFromHDC, hMemDC, ADDR pMemGraphics ; 在这里绘制所有图形 invoke GdipDeleteGraphics, pMemGraphics invoke BitBlt, hdc, 0, 0, winWidth, winHeight, hMemDC, 0, 0, SRCCOPY invoke SelectObject, hMemDC, hOldBmp invoke DeleteObject, hBmp invoke DeleteDC, hMemDC invoke EndPaint, hWnd, ADDR ps

这里必须强调一点:GdipCreateFromHDC之后,Graphics对象持有内存DC的引用,如果没调用GdipDeleteGraphics就调用DeleteDC,内存DC不会真正释放,等到下次创建DC时系统可能报“内存不足”。别问我怎么知道的,这是我用调试器翻遍错误代码才总结出来的。

6.3 定时器驱动的动画循环

动画循环最简单的驱动方式还是SetTimer。设置一个16毫秒的定时器,大约60FPS,然后在WM_TIMER里更新状态变量,调用InvalidateRect触发重绘。

invoke SetTimer, hWnd, 1, 16, NULL

WM_TIMER里不要做复杂计算,更不要做图像解码。它的职责只是更新位置或角度,然后标记客户区无效。真正决定性能的是WM_PAINT里的渲染速度。如果某帧绘制时间超过16毫秒,画面自然会掉帧,这时可以适当降低定时器频率到30FPS,或者把抗锯齿关掉,等稳定后再开。

如果做复杂动画,比如同时旋转、缩放多个图形,建议用双缓冲配合GdipResetTransform,每帧开始重置变换,再按需要调用平移和旋转。绝不要在上一帧变换的基础上继续叠加变换,否则累计误差会以肉眼可见的速度让图形飞出去。

7. 常见问题与性能建议

7.1 速查表:GDI+调用失败的几种典型现象

现象可能原因处理方式
画面完全空白GdipCreateFromHDC失败,Graphics指针无效检查HDC是否有效,检查BeginPaint和EndPaint配对
颜色偏色或透明ARGB顺序写错,把0xFFRRGGBB写成0xRRGGBB检查颜色常量高8位,确保包含Alpha
文字乱码宽字符串没有正确以UTF-16编码编译查看内存中的字节,确认是Unicode
内存持续增长画笔画刷或Graphics对象未释放在循环中严格配对创建与释放
程序退出时崩溃未调用GdiplusShutdown,或token被改动保存token并在程序退出前调用
图片加载后拉伸变形直接硬编码目标宽高,未按原始尺寸等比缩放用GdipGetImageWidth/Height取宽高计算比例
动画闪烁没有双缓冲,或GDI+对象未释放就BitBlt使用内存DC双缓冲流程

7.2 性能建议和最后的调试技巧

GDI+不是以性能见长的绘图库,尤其是在纯汇编程序里,函数调用栈比C++更薄,但并不意味着可以滥用。减少绘制开销的第一条原则就是:不要在WM_PAINT里创建和销毁大量对象。把画笔、画刷、字体、StringFormat在初始化时就创建好,需要改变颜色时才创建新的,不用立刻释放,等下次再复用。

第二条原则是合理使用抗锯齿。GDI+默认很多绘制不上抗锯齿,如果你想漂亮,可以用GdipSetSmoothingMode开启抗锯齿,但代价是速度降低。对于静态界面无所谓,动画里能用关闭就关闭,或者只在文字上开启抗锯齿,图形保持快速绘制。

第三条是善用GdipSaveGraphics和GdipRestoreGraphics。这两个函数能把当前Graphics的变换、抗锯齿、裁剪区域作为一个容器保存,之后统一恢复,比手动GdipResetTransform加重新设置状态更安全。我特别喜欢在绘制复杂场景前先Save,绘制完自定义动画再Restore,这样写出来的代码不容易出现“这个对象状态被上一帧污染”的诡异问题。

最后分享一个调试技巧:GDI+函数失败只是返回非0状态码,不会输出到调试器。你可以给每个关键函数返回值写一段OutputDebugString日志,把失败的函数名和返回值一起打出来,然后用DebugView观察。这比断点单步跟踪高效得多,尤其是处理图像和字体这种对象类型复杂的逻辑时,日志能直接告诉你哪一步开始异常。

我在实际写这一类程序的体会是,GDI+在汇编里的使用难点从来不是某个函数不会调用,而是对象生命周期和状态管理。一旦你养成了“创建后必有释放、变换后必恢复”的习惯,就会发现其他一切不过是查参数表的问题。这一篇的实战内容比较多,建议你把图片加载、像素反色和双缓冲动画三个模块分别单独写成小Demo,跑通后再合并,不要一上来就指望一次写出复杂的综合程序。

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

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

立即咨询