ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Win32汇编调用GDI+实战:图像显示、透明混合与坐标变换

Win32汇编调用GDI+实战:图像显示、透明混合与坐标变换 1. 写在前面的GDI系列第三篇到底解决什么问题如果你已经跟着前两篇把win32汇编的窗口框架跑通也成功在窗口上画过几条笔直的线和几个实心矩形那这篇教程就是你从“会跑示例”跨到“能写工具”的关键一步。前两篇侧重的是怎么搭一个最小的窗口程序、怎么在WM_PAINT里拿到HDC以及GDI最基本的初始化流程。那些内容对我来说属于“地基”看懂了之后你手里已经有了一把能画图的锤子但还没学会怎么用这把锤子敲出真正有用的东西。这一篇我把重点放在四个真正会让你卡住的地方第一是画笔、画刷和抗锯齿解决“画出来太丑”的问题第二是从文件加载PNG/JPG并正确显示解决“闭门造图”的问题第三是透明通道与半透明叠加解决“PNG背景变黑底”的问题第四是坐标变换和简单的动画效果解决“图形死板不动”的问题。这篇内容适合谁适合已经写过win32窗口程序、知道RegisterClass和CreateWindowEx是怎么一回事、也见过GdiplusStartup长什么样的读者。如果你还不会这些建议先回头把前两篇补上。如果你会那这篇应该能帮你省下不少自己翻MSDN和gdiplus.inc的功夫。我写的时候尽量把参数、结构和汇编调度细节都摊开讲因为win32汇编调用GDI跟C语言不一样很多坑不是GDI本身造成的而是参数传递方式造成的。这篇就是把这些硬骨头一次性啃掉。1.1 前两篇的成果与第三篇的边界前两篇做到的事情简单回顾一下就是写了一个完整的Win32窗口程序处理了WM_PAINT调用GdiplusStartup初始化GDI用GdipCreateFromHDC把窗口DC包装成一个Graphics对象然后用GdipDrawLine、GdipFillRectangle这类函数画了些基础图形最后在退出时调用GdiplusShutdown。这些基础操作里有一个细节值得反复强调GDI的所有绘图API几乎都接收Graphics对象指针作为第一个参数而不是HDC。这个设计跟传统GDI有本质区别。GDI是把HDC当“画布”到处传GDI则是先把HDC“包装”成一个GpGraphics对象后续所有操作都针对这个对象进行。这就好比GDI是直接在墙上刷漆GDI是先给墙装了一块可移动的画板你可以在画板上做各种预处理再把画板整体贴到墙上。第三篇的边界很清楚不再讲怎么创建窗口也不再讲GDI初始化的基本原理而是专门讲“封装好的Graphics对象拿到手之后怎么做出更像样的东西”。我默认你已经能成功弹出一个窗口并且能在窗口上画出一条线。在这个基础上图像加载、透明混合、坐标变换、动画才是这一篇真正要展开的内容。超出这个边界的部分比如窗口子类化、多线程渲染后面可以单独开篇。1.2 本教程适合谁需要具备什么基础我把话先说清楚这篇不是零基础教程。你需要具备三个基础第一能用Win32 API写一个能显示窗口的程序知道消息循环和窗口过程的基本套路第二对汇编语言的寻址方式、栈平衡和invoke伪指令有基本的掌控力至少知道“参数从右往左压栈”这句话不是白说的第三对GDI的“Graphics对象”概念有印象知道它跟HDC之间的关系。如果你只是听说过汇编但还没亲手写过完整的窗口程序这篇对你来说会偏难。win32汇编调用GDI的复杂度是叠加式的窗口那一层的问题还没解决GDI的浮点参数和对象生命周期问题又压上来很容易把人劝退。反过来如果你已经写过几个win32汇编窗口程序只是对GDI心里没底那你就是这篇教程最典型的读者。我所有代码片段都以MASM32为主要环境使用invoke伪指令调用GDI的flat API。当你看到GdipCreatePen1、GdipDrawImageRect这类函数时不要被它们的长名字吓到它们本质上就是stdcall导出的DLL函数跟调用MessageBox没有区别只是参数列表比较挑剔而已。1.3 工程配置再唠叨一遍很多人包括我自己早期在这一步吃过亏MASM32默认的include目录里并没有gdiplus.inc需要自己去下载或者从其他项目里拷贝。实在找不到完整头文件的时候也可以自己用PROTO声明需要用到的函数再链接gdiplus.lib。比如GdipCreateFromHDC PROTO STDCALL :DWORD, :DWORD GdipCreatePen1 PROTO STDCALL :DWORD, :DWORD, :DWORD, :DWORD GdipDrawRectangle PROTO STDCALL :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD这样做的优点是可控缺点是GDI函数实在太多一个个声明比较累。我更推荐的做法是直接用现成的gdiplus.inc然后链接gdiplus.lib。注意链接器需要支持导入库通常MASM32自带的link.exe就能处理。除了头文件和库文件还有一个容易忽略的地方GDI的字符串参数几乎都是宽字符UTF-16LE。你可能习惯了用db test.bmp,0这种ANSI字符串但GdipCreateBitmapFromFile不认。要么在数据段里直接写宽字节常量要么在运行时用MultiByteToWideChar转换后面第三部分会专门讲这个问题。工程配置这一关把“宽字符”三个字刻在脑子里后面能少踩一半的坑。2. 图形外观升级画笔、画刷与抗锯齿GDI里有两个基础对象一个叫Pen画笔一个叫Brush画刷。这两个概念和GDI里的同名对象类似但能力规格完全不同。GDI的画笔基本就是“画线的工具”画刷就是“填充的工具”颜色和样式就那么几种GDI的Pen和Brush都涉及颜色、透明度、样式、甚至渐变的复杂组合。这一节先把它们说透再讲怎么让画出来的图形边缘不再像锯齿那么明显。2.1 Pen与BrushGDI里的“笔”和“刷子”跟GDI有什么不同传统GDI里创建画笔用CreatePen颜色是COLORREF格式是0x00BBGGRR没有透明度概念线宽以像素为单位。创建画刷用CreateSolidBrush同样没有透明度。GDI则把颜色彻底升级为ARGB格式也就是32位整数高8位是Alpha通道后面依次是Red、Green、Blue。你不光能画半透明的线还能用半透明的刷子填充区域让底下的内容透出来。创建Pen的函数是GdipCreatePen1它的声明在汇编里大致长这样GdipCreatePen1 PROTO STDCALL :DWORD, :DWORD, :DWORD, :DWORD四个参数分别是ARGB颜色值、线宽REAL浮点数、单位类型UnitPixel为2、输出指针。线宽这里特别容易栽跟头因为它是REAL4类型不是整数。我见过不少人在汇编里直接写invoke GdipCreatePen1, 0FFFF0000h, 2, 2, ADDR hPen然后发现画出来的线宽不对甚至程序崩溃。原因就是第2个参数在32位环境里占4字节但它的含义是单精度浮点数整数2的二进制布局和浮点数2.0完全不同。正确做法是先定义一个REAL4变量.data penWidth REAL4 2.0 .code invoke GdipCreatePen1, 0FFFF0000h, penWidth, 2, ADDR hPenMASM在处理REAL4变量作为invoke参数时会把变量对应的4字节内容压栈这样就跟浮点数的二进制表示对上了。直接写整数2压栈的是整数布局GDI拿去当浮点数解析得到的值约等于2.8e-45接近0线就看不见了。Brush的创建相对简单GdipCreateSolidFill的第二个参数同样是ARGB颜色值第三个参数是输出指针invoke GdipCreateSolidFill, 0FF3C6EA5h, ADDR hBrush这个hBrush是一个GpSolidFill指针后续GdipFillRectangle和绘制文字都会用到。2.2 让坐标有质感的关键参数REAL类型与UnitGDI和GDI还有一个显著区别坐标参数几乎都是REAL浮点数而不是整数。这就是它叫“GDI”而不是“GDI 2.0”的原因之一——它允许你用小数坐标绘图比如画一条从(1.5, 2.2)到(100.7, 200.3)的线。这在制作几何图形、图表、矢量图形工具时特别有用不会因为坐标取整造成累计误差。但在win32汇编里浮点参数是一道麻烦的坎。invoke伪指令能帮你压栈但压什么、怎么压取决于你给的是什么类型。在MASM32环境里REAL4变量可以直接传给invoke编译器会把那4字节原样压栈。如果参数是立即数浮点比如invoke GdipDrawLine, pGraphics, hPen, 0.0, 0.0, 100.0, 50.0MASM通常也能解析因为浮点立即数会被汇编成可识别的二进制。但为了稳妥我习惯把浮点坐标先存到变量里再通过变量传递。Unit参数常被忽略。GdipCreatePen1的第三个参数是Unit枚举通常用UnitPixel2。UnitWorld0UnitDisplay1UnitPoint3UnitInch4UnitMillimeter5UnitDocument6。如果你用UnitPixel线宽就是实际像素如果用UnitPoint线宽会受DPI影响在96DPI下1磅等于4/3像素。初学者统一用UnitPixel等需要做打印输出时再换别的。2.3 实例一张带描边、带填充、抗锯齿的矩形卡片下面给一个完整的绘制片段目标是在Graphics对象上画一张“卡片”背景是蓝色半透明填充边框是红色2像素描边整体抗锯齿。; 假设 pGraphics 已通过 GdipCreateFromHDC 获得 .data cardX REAL4 20.0 cardY REAL4 30.0 cardW REAL4 200.0 cardH REAL4 120.0 penW REAL4 2.0 .code ; 开启抗锯齿SmoothingModeAntiAlias4 invoke GdipSetSmoothingMode, pGraphics, 4 ; 创建半透明蓝色画刷ARGB 0x803C6EA5 invoke GdipCreateSolidFill, 803C6EA5h, ADDR hBrush invoke GdipFillRectangle, pGraphics, hBrush, cardX, cardY, cardW, cardH invoke GdipDeleteBrush, hBrush ; 创建红色画笔线宽2.0像素 invoke GdipCreatePen1, 0FFFF0000h, penW, 2, ADDR hPen invoke GdipDrawRectangle, pGraphics, hPen, cardX, cardY, cardW, cardH invoke GdipDeletePen, hPen这段代码里有几个细节值得说GdipFillRectangle的坐标参数是REAL4直接传变量GdipDrawRectangle画的是矩形边框它的坐标参数和GdipFillRectangle完全一致所以边框正好套在填充区域外侧。由于开启了抗锯齿矩形边缘的像素会出现过渡色看起来比普通GDI画的锯齿边光滑很多。GdipSetSmoothingMode的第二个参数是SmoothingMode枚举SmoothingModeNone3表示不抗锯齿SmoothingModeAntiAlias4表示抗锯齿。抗锯齿会轻微增加CPU开销但对现代CPU来说可以忽略。我画静态图形一律开抗锯齿如果要刷新频率很高比如动画每帧都在重绘且图形很多可以醒目地不开抗锯齿或者只在个别复杂图形上开。得补充一个画刷使用习惯每次创建用完就删除。GDI的画刷、画笔、字体、位图对象都要手动释放。GdipDeleteBrush、GdipDeletePen、GdipDeleteFont、GdipDisposeImage分别对应不同对象。早期我因为“反正进程退出会自动回收”的想法写工具时随手创建不释放任务管理器里GDI对象数一路飙升最后窗口重绘越来越卡。所以在示例代码里我几乎都是成对写创建和删除。2.4 ARGB颜色值深挖alpha字节和颜色字节顺序GDI的ARGB是一个32位DWORD内存中的字节顺序是B、G、R、A但数值写法上我们习惯写成AARRGGBB。举个例子不透明红色0xFFFF0000不透明绿色0xFF00FF00不透明蓝色0xFF0000FF50%透明度红色0x80FF0000不透明黄色0xFFFFFF00最容易被误解的是“50%透明度”不等于数值减半。Alpha值是直接表示不透明度的程度0x80是128大约是50.2%不透明度。如果你想要70%不透明度可以算一下0.7 * 255 ≈ 179也就是0xB3。在win32汇编里写颜色常量时可以直接用十六进制比如0xB3FF0000。这个比在C里习惯性写十进制要方便得多。GDI是独立于GDI的颜色体系所以它不使用COLORREF。你在传统GDI里常用的RGB(255,0,0)宏在GDI里不能直接用得自己把它拼成ARGB。一个小技巧是沿用GDI里RGB宏得到的值0x00RRGGBB然后加上Alpha位。比如GDI的红色是0x0000FF转成GDI不透明红色就是0xFFFF0000。但注意两者字节排列方向相反别直接拿GDI颜色值塞给GDI函数。如果需要在运行时把“红、绿、蓝、Alpha”四个字节拼成ARGB值可以简单做移位和或运算mov eax, alpha shl eax, 24 mov ecx, red shl ecx, 16 or eax, ecx mov ecx, green shl ecx, 8 or eax, ecx or eax, blue这段代码把四个字节按ARGB顺序组装成一个DWORD适合动态生成颜色。3. 图像加载与显示把PNG/JPG搬进你的窗口图形绘制解决的是“用代码造图”但很多应用场景更需要“显示已有的图片”。win32汇编里传统GDI加载位图通常用LoadImage、CreateBitmap等函数但麻烦在于LoadImage对PNG、JPEG支持不好需要GdiplusInit或者其他库而GDI自带的多格式图像解码能力让这件事变得非常简单。3.1 为什么选GdipCreateBitmapFromFile而不是LoadImageLoadImage能加载的是DIB/BMP遇到PNG、JPEG得先自己解码要么用GDI要么用第三方库麻烦还不安全。GdipCreateBitmapFromFile是GDI封装好的图像解码入口它内部会识别文件头根据文件类型选择对应的编解码器把PNG、JPEG、GIF、BMP、TIFF等格式统一解码成GpBitmap对象。这一步对win32汇编特别划算。你不需要自己去研究PNG的解压算法或JPEG的离散余弦变换只需要把文件路径给它它给你一个GpBitmap指针。这正是GDI作为高级绘图接口存在的意义把复杂度藏在二进制后面让调用者把心思放在业务上。当然代价是GDI解大图的速度一般10000x10000那种超大图加载会比较慢。但显示普通尺寸的图片、做图片查看器都足够了。3.2 三步走加载、取尺寸、绘制图像显示的核心流程分三步第一步调用GdipCreateBitmapFromFile传入宽字符文件路径和输出指针invoke GdipCreateBitmapFromFile, offset szWidePath, ADDR hBitmap返回值是GpStatus0表示成功Ok。失败时常见原因是路径里的字符是ANSI而不是宽字符或者文件根本不存在。第二步用GdipGetImageWidth和GdipGetImageHeight获取图像尺寸invoke GdipGetImageWidth, hBitmap, ADDR dwImgW invoke GdipGetImageHeight, hBitmap, ADDR dwImgH这两个API的第二个参数是UINT*输出图像宽度和高度。在绘制之前获取尺寸非常关键否则你没法决定绘制区域的大小也没法做比例缩放。第三步用GdipDrawImageRect把图像绘制到Graphics对象的指定矩形区域.data dstX REAL4 0.0 dstY REAL4 0.0 dstW REAL4 320.0 dstH REAL4 240.0 .code invoke GdipDrawImageRect, pGraphics, hBitmap, dstX, dstY, dstW, dstHGdipDrawImageRect的“Rect”后缀表示目标区域由x、y、width、height四个浮点数指定图像会被拉伸到这个区域。如果你不想拉伸传原始宽高即可。绘制完成后用GdipDisposeImage释放位图对象。这里有个非常容易踩的坑GdipDrawImageRect的参数顺序在MSDN里是(GpGraphics*, GpImage*, REAL x, REAL y, REAL width, REAL height)。在汇编里用invoke传参时参数从左到右依次写入但我们心里要清楚最终入栈顺序是从右往左。只要用invoke编译器会自动处理关键是别把x、y、width、height的顺序写反。早期我写过一次width和height对调结果图片变成奇怪的横竖比例。3.3 按比例缩放与居中一个图片查看器的核心逻辑如果你的目标是做一个图片查看器直接把图片拉伸到客户区尺寸会被迫变形。正确的做法是按比例缩放并居中让图片在保持原始宽高比的前提下最大化地显示在窗口内。计算逻辑很直观假设客户区宽高是cw和ch图片原始宽高是dw和dh分别计算横向缩放比例scaleX cw / dw和纵向缩放比例scaleY ch / dh取较小者作为最终缩放比例scale然后绘制区域宽为dw * scale高为dh * scale起始坐标x为(cw - dw*scale)/2y为(ch - dh*scale)/2。在汇编里实现这个计算要用到FPUfild cw fidiv dwImgW ; scaleX cw / dwImgW fstp dword ptr [scaleX] fild ch fidiv dwImgH ; scaleY ch / dwImgH fstp dword ptr [scaleY] ; 比较 scaleX 和 scaleY取较小者 finit fld dword ptr [scaleX] fld dword ptr [scaleY] fcomi st(0), st(1) jbe useScaleY useScaleX: fstp dword ptr [scale] jmp doneScale useScaleY: fstp dword ptr [scale] doneScale:这段代码的思路比具体指令更重要fild把整数加载到FPU栈fidiv把整数当作除数直接做除法结果再通过fstp存到REAL4变量。比较scaleX和scaleY时fcomi比较后根据进位标志跳转把较小值存入scale。之后再用这个scale计算绘制坐标。如果你觉得FPU代码太繁琐也可以全用浮点运算函数来做但性能差别不大。实际做工具时我倾向在这个环节封装一个汇编过程输入窗口尺寸和图像尺寸输出绘制矩形坐标。以后任何需要“等比例显示图片”的地方都可以复用。3.4 多格式支持背后的编码器概念GDI支持BMP、JPEG、PNG、GIF、TIFF等常见格式这个能力来自系统的图像编解码器机制。你可以通过GdipGetImageEncodersSize和GdipGetImageEncoders枚举系统里注册的编码器列出所有支持的图片格式。在win32汇编里这部分偏底层的枚举操作一般不常用但了解原理有助于理解为什么GdipCreateBitmapFromFile能“一函数吃遍所有格式”。如果你做过文件类型判断可能会想自己根据扩展名调用不同解码库这没有必要。GDI在收到GdipCreateBitmapFromFile时会先读文件头根据文件签名识别真实格式然后分派给对应解码器。这意味着你即使把PNG文件的扩展名改成.bmp它依然能正常打开。这个特性在某些“按扩展名判断图片类型”的旧代码里简直是救命稻草。实际上也因此注意GdipCreateBitmapFromFile返回的图像数据已经统一成32位ARGB格式大多数情况下所以后续绘制、透明处理都是一致的数据流。GDI帮你完成了“不同格式解码为统一像素格式”的关键工作。3.5 踩坑文件路径是宽字符、文件到底存不存在GDI所有接收字符串参数的API都是宽字符版本。MASM32里如果你写.data szPath db test.png, 0然后invoke GdipCreateBitmapFromFile, offset szPath, ADDR hBitmap大概率返回失败或读取乱码。正确写法是用宽字符串常量.data szPath dw t,e,s,t,.,p,n,g, 0这写起来太痛苦了我一般用TEXTEQU或者宏处理。更方便的方式是运行时用MultiByteToWideChar转换。比如你从命令行拿到了ANSI路径可以先转成UTF-16再传给GDIinvoke MultiByteToWideChar, CP_ACP, 0, offset szAnsiPath, -1, offset szWidePath, MAX_PATH其中CP_ACP是0表示当前系统ANSI代码页。-1表示源字符串以空字符结尾函数会一直转换到结束符。转换后szWidePath缓冲区里的内容就是宽字符路径可以直接传给GdipCreateBitmapFromFile。第二个坑更隐蔽文件不存在时GdipCreateBitmapFromFile返回的不是常见的ERROR_FILE_NOT_FOUND2而是GpStatus的一个值通常是非零的失败状态。如果你只判断“eax0表示成功”没问题但如果想区分“文件不存在”和“格式不支持”GDI返回的状态码含义比较模糊不如自己在调用前用GetFileAttributes或CreateFile验证文件存在性。我在工具里通常先做FileExists检查给出更友好的提示再把路径传给GDI。4. 透明与混合让PNG不再黑底很多人在用GDI显示PNG时遇到过这个问题图片明明有透明区域但画到窗口上时透明区域变成了黑色或白色块。这个现象背后的原因是绘制参数不对或者源图像的Alpha通道没有被正确处理。GDI默认的绘制行为其实已经考虑了Alpha通道只是很多人在调用GdipDrawImage时没正确理解“源图像带透明”与“目标画布支持透明”的关系。4.1 GDI对Alpha通道的处理方式GDI的光栅引擎在绘制图像时默认就把源像素的Alpha通道和目标的背景做AlphaBlend混合。也就是说你不需要像GDI那样专门调用AlphaBlend函数普通GdipDrawImageRect就能让PNG的透明区域正确透出窗口背景。关键在于你拿到的GpBitmap解码后是否保留了Alpha通道。PNG和GIF都支持透明但GIF的透明是“1位索引透明”也就是要么透明要么不透明没有半透明过渡。PNG-24和PNG-32则支持8位Alpha通道能做到平滑的半透明边缘。JPEG没有Alpha通道画出来自然不透明。所以如果你要做的素材带半透明效果一定要用PNG格式这个习惯一定要养成。如果你遇到黑底常见原因是把“用GDI显示PNG”和“先用GDI创建DC再BitBlt”混用了。比如你把PNG先解压成了32位DIB然后用传统BitBlt复制到目标DCBitBlt默认不处理Alpha通道Alpha位被丢弃就变成黑底了。用GDI时整个链路都应该走GDI的绘制接口让GDI替你完成Alpha混合。4.2 半透明遮罩与叠加效果除了显示PNGGDI的Alpha通道还有个非常实用的玩法绘制半透明矩形遮罩。比如你想让一张图片蒙上一层淡蓝色调可以在图片上再画一个半透明蓝色矩形; 先绘制图片 invoke GdipDrawImageRect, pGraphics, hBitmap, dstX, dstY, dstW, dstH ; 再画一个半透明蓝色矩形Alpha0x40 约25%不透明度 invoke GdipCreateSolidFill, 403C6EA5h, ADDR hBrush invoke GdipFillRectangle, pGraphics, hBrush, dstX, dstY, dstW, dstH invoke GdipDeleteBrush, hBrushGdipFillRectangle会用画刷覆盖目标区域画刷的Alpha决定遮挡程度。Alpha为0x40时底层图片透过来的亮度约为75%。这种技术在UI设计中很常见比如弹窗遮罩、按钮悬停效果、图片水印。半透明叠加的顺序也有讲究。你先画图片再画矩形矩形会盖在图片上面。如果你反过来图片会盖住矩形遮罩效果就看不到了。这个顺序和画家算法的“先画远、再画近”是一个道理。GDI本身没有Z序管理所有绘制都以调用顺序为准。4.3 为什么Win32的TransparentBlt做不到GDI的效果传统Win32 API也有TransparentBlt它可以指定一种颜色作为透明色把该颜色的像素不画到目标上。但是TransparentBlt有几个天然限制第一它只能做“二值透明”也就是要么完全透明要么完全不透明不能处理半透明像素第二它依赖源图像中某个具体颜色值来定义透明色如果图片里有同色的非透明像素也会一并被挖空第三缩放质量差拉伸后边缘锯齿明显。GDI的Alpha混合则是逐像素计算透明度和颜色是每个像素各自的属性不依赖“颜色键”。这意味着PNG里平滑的半透明抗锯齿边缘在GDI下能正确呈现而TransparentBlt永远做不到。所以在win32汇编里做图片叠加、圆角头像、阴影效果GDI是远比GDI合适的选择。我画自定义控件的时候喜欢把所有素材导出为带Alpha的PNG然后在GDI里整体绘制。这样代码量最少渲染效果最接近设计师的原始意图。如果强行用GDI和TransparentBlt美术资源得先做去边处理还很费劲。4.4 性能提醒半透明区域的分层策略半透明混合虽然漂亮但性能开销比普通拷贝要高。每一帧如果要在整个窗口上铺一层半透明矩形那每个像素都得做一次Alpha混合运算即使GDI在内部做了优化也比纯色填充慢不少。在win32汇编里做界面时要注意分层策略不需要半透明效果的静态背景直接用普通GDI或GDI的一次绘制完成只有需要动态变化的元素才使用半透明叠加。还有一个常见的性能误区在WM_PAINT里每次都对一个大位图做全区域的半透明处理其实很多内容是静态的。我通常会把静态背景提前渲染到一个内存Bitmap或系统内存DC里动态刷新时只绘制变化区域。在GDI里这相当于先GdipCreateBitmapFromHBITMAP或者直接把窗口DC的内容转成GpBitmap再从这张缓存图上取局部绘制到目标。这个模式改造起来稍微麻烦但对性能的提升是显著的。如果你的程序大量使用半透明而且窗口尺寸很大比如1920x1080就要认真考虑是不是该用Direct2D了。GDI的软件渲染在这种场景下很难做到满帧60FPS。不过对大多数工具型小程序来说GDI的半透明性能完全够用。5. 坐标变换让绘图拥有“打印机设置”坐标变换这一节很多人第一次听说时觉得抽象。我打个比方你在纸上画图纸本身可以被平移、旋转、缩放但你画的线条坐标不用改。GDI的世界变换就是这样的“纸张设置”你设置好变换矩阵以后后续所有绘图命令的坐标都会先经过这个矩阵换算再落到最终输出表面上。这对做动画和几何图形特别有用。5.1 世界变换到底改变了什么GDI提供了一组Transform相关函数常见的有GdipTranslateTransform平移、GdipRotateTransform旋转、GdipScaleTransform缩放、GdipResetWorldTransform重置为单位矩阵。它们改变的是Graphics对象的“世界变换矩阵”。默认情况下世界变换是单位矩阵坐标原样绘制。调用平移变换后假设你设置原点移动到(100, 100)那么你调用GdipDrawRectangle画一个(0,0)开始的矩形实际会出现在(100,100)的位置。旋转变换会让坐标系整体转动缩放变换会改变坐标的缩放比例。这个机制的价值在于你可以在同一套“本地坐标系”里定义图形然后通过变换把它摆放到窗口任意位置、任意角度、任意大小。做仪表盘、游戏地图、图表控件时这种模式能让代码清晰很多。5.2 Translate、Rotate、Scale的组合规则三个变换函数的调用顺序会直接影响结果。GDI的变换矩阵操作有MatrixOrderPrepend和MatrixOrderAppend两种枚举值分别是0和1。我通常习惯用MatrixOrderAppend1配合直观的“先平移、再旋转、再缩放”的调用顺序。比如下面这段代码想表达的是先把坐标系平移到(200, 150)然后旋转30度最后放大1.5倍invoke GdipResetWorldTransform, pGraphics invoke GdipTranslateTransform, pGraphics, 200.0, 150.0, 1 invoke GdipRotateTransform, pGraphics, 30.0, 1 invoke GdipScaleTransform, pGraphics, 1.5, 1.5, 1用MatrixOrderAppend时新的变换是乘在矩阵右侧的视觉效果和代码顺序一致。需要特别注意角度单位是“度”而不是弧度。GDI的旋转API内部会转换成弧度你直接传度数即可比如30.0表示30度。早期我一度写成弧度值0.5结果图形转了约28.6度看起来明明很接近却总觉得不对。组合变换的顺序还有个通俗理解先平移后续旋转围绕的圆心是平移后的新原点先旋转再平移物体先绕旧原点转动然后平移到新位置。这个差异在做动画时非常明显。想让一个图形绕着自己的中心旋转正确做法是先平移到中心位置再调用旋转。如果先旋转再平移图形就会绕着窗口原点转圈而不是原地打转。5.3 用WM_TIMER实现旋转动画有了坐标变换动画就只是“每帧改变变换参数然后重绘”的事情。最简单的方式是用WM_TIMER比如每隔16毫秒设置一个定时器约60FPS在WM_TIMER里让角度变量递增然后InvalidateRect触发重绘。下面的代码片段展示WM_PAINT里绘制一个绕自身中心旋转的矩形.data angle REAL4 0.0 .code ; 在WM_TIMER处理中 ; fld dword ptr [angle] ; fadd dword ptr [step] ; step3.0 ; fstp dword ptr [angle] ; invoke InvalidateRect, hWnd, NULL, TRUE ; 在WM_PAINT中 invoke GdipResetWorldTransform, pGraphics invoke GdipTranslateTransform, pGraphics, 200.0, 150.0, 1 invoke GdipRotateTransform, pGraphics, angle, 1 ; 此时原点已经平移到(200,150)所以矩形中心应该在(0,0) ; 画一个以中心为原点的矩形宽80高50 invoke GdipCreatePen1, 0FFFFAA00h, penW, 2, ADDR hPen invoke GdipDrawRectangle, pGraphics, hPen, -40.0, -25.0, 80.0, 50.0 invoke GdipDeletePen, hPen角度变量是REAL4浮点从整数定时器计数转换时可以这样fild dword ptr [counter] ; counter是整数 fmul dword ptr [stepDeg] ; stepDeg3.0 fstp dword ptr [angle]WM_TIMER实际触发的间隔不精确如果做高精度动画应该用QueryPerformanceCounter计算帧时间。但对入门教程WM_TIMER足够直观。需要注意的是InvalidateRect会立即返回下一帧WM_PAINT才会执行所以定时器频率可以设置成66毫秒左右对应约15FPS或者设置成16毫秒对应约60FPS。定时器太密集也不代表画面刷新率真的能达到WM_PAINT本身要等消息循环处理。5.4 变换状态恢复手残党最容易踩的坑坐标变换最让人头疼的问题不是设置而是忘记恢复。Graphics对象一旦设置了变换矩阵后续所有绘制都会受影响。如果你在一个WM_PAINT里前面画了旋转的矩形后面又直接画了一个需要保持原始坐标的按钮背景按钮很可能出现在错误位置或者被旋转。解决方案有两种一种是在每次绘制前调用GdipResetWorldTransform把矩阵恢复为单位矩阵再重新设置需要的变换另一种是先用GdipSaveTransform保存当前状态绘制完再GdipRestoreTransform恢复。从win32汇编的角度第一种更简单因为少一对Save/Restore配对的调用逻辑。我在写复杂界面时习惯在每组“需要变换”的绘制代码之前都调用GdipResetWorldTransform相当于坐标从零开始思路清晰。另外动画中频繁调用GdipResetWorldTransform和设置变换会增加少量CPU开销但相对于重绘本身的开销来说可以忽略。真正要避免的是在变换矩阵没有重置的情况下把大量静态UI元素也画进去导致所有控件都跟着旋转排查起来很费劲。这里给一个实用建议把“设置世界变换”和“重置世界变换”固定成一对宏或过程比如宏BeginTransform和EndTransform让自己形成习惯。虽然win32汇编里宏写起来略麻烦但对长期维护的帮助非常大。6. 关于“GDI 硬件加速”这件事讲点实话你可能在搜索GDI相关技术文章时看到过“GDI 硬件加速”这个说法也有人把它和Direct2D对比。这节我按自己的理解说点实话。6.1 GDI 到底是硬件渲染还是软件渲染GDI 本质上是一个软件渲染的2D图形库。它的绘图运算在CPU侧完成不依赖GPU来加速常见操作。你调用GdipDrawLine、GdipFillRectangle、GdipDrawImageRect这些API时内部执行的是软件光栅化算法把图元转换成像素写入目标位图或HDC。这一点与传统GDI类似——GDI也是以CPU为主。GDI 从诞生之初定位就是“接口比GDI高级、效果比GDI丰富”性能并不是它的首要追求。它更看重的是功能的完备性和实现的一致性。如果你的核心需求是大批量图形每秒60帧渲染GDI大概率力不从心。那么“硬件加速”这个词为什么会和GDI扯上关系一部分原因是Windows的桌面合成器DWM会在最终合成桌面对每个窗口内容做合成这个合成过程可以利用GPU。但这并不等于你的GDI绘图调用被GPU加速了它只是窗口内容在屏幕上呈现的最终阶段经过了GPU合成。真正意义上的“GDI硬件加速”在公开API层面并不存在。6.2 为什么网上会有“GDI 硬件加速”的说法我在搜索相关话题时看到过一些文章把GDI和硬件加速放在一起大致有几类来源。一类是在讨论GDI 1.1的新特性GDI 1.1确实引入了一些性能优化比如对某些操作的内部改进但不等于完整的GPU加速。另一类是讨论Win10/11上的GDI实现微软在较新系统中确实对GDI/GDI做了一些兼容与优化具体细节并未完全公开也谈不上可编程的硬件加速。还有一类是中文互联网常见的标题党为了SEO故意把“GDI 硬件加速”写成热门关键词。这类文章往往没有实际性能数据也没有告诉你GDI主要是靠CPU。我的建议是如果只是在win32汇编里做小工具、演示程序、教学项目完全不用纠结硬件加速如果是要做高性能渲染应当认真考虑用Direct2D、Skia等方案而不是试图把GDI压榨成游戏引擎。对win32汇编的开发者来说真正的优势是“用最小的运行时依赖完成复杂的2D绘图”这在写后门类小程序、系统工具、逆向分析辅助工具时特别方便。GDI的DLL在几乎所有Windows系统上都有不需要额外分发运行时这对绿色软件来说非常友好。6.3 在win32汇编里把GDI性能榨干的几个习惯虽然GDI不硬加速但通过合理的使用方式性能也能达到不错的水平。我的经验有三条第一避免在WM_PAINT里反复创建和销毁GDI对象。画笔、画刷、字体这种相对轻量的对象创建一次后如果颜色和样式没变可以缓存在全局变量里减少频繁创建的开销。位图这类重对象更要避免每帧都从文件重新加载。只加载一次保存GpBitmap指针需要重新显示时直接绘制。第二善用离屏位图做双缓冲。GDI绘制到可见窗口时如果重绘区域过大会出现肉眼可见的闪烁。解决的思路和GDI双缓冲一样先在内存中创建一个GpBitmap把Graphics对象绑定到这个内存位图上完成所有绘制后再一次性把整张位图画到窗口的Graphics上。GDI里的实现方式是GdipCreateBitmapFromScan0或创建一个与窗口等尺寸的DIB再GdipGetImageGraphicsContext得到Graphics对象绘制到这块内存上结束时把位图GdipDrawImageRect到目标窗口。第三控制刷新区域。GDI重绘效率低于GDI全窗口刷新往往很浪费。尽量利用InvalidateRect指定需要刷新的矩形区域让WM_PAINT期间只更新变化部分。尤其在做动画时大多数帧只有一小块区域在变化只刷新局部能明显降低CPU占用。结合前面提到的半透明和坐标变换你可以打造一个轻量级的2D小引擎。虽然不能和游戏引擎比帧率但做图形化小工具、自定义控件、教学演示已经是绰绰有余。7. 常见问题与排查实录最后的章节把我实际用win32汇编调GDI时踩过的问题整理成速查表。边界情况提到就停手看表就能定位。7.1 初始化失败与加载图片失败症状GdiplusStartup返回非0后面所有绘图都没效果或者GdipCreateBitmapFromFile返回失败图片画不出来。排查步骤检查GdiplusStartupInput结构的GdiplusVersion字段是否设置为1。未初始化就调用几乎必挂。检查是否在调用GDI前忘了调用GdiplusStartup或者在退出时重复调用了GdiplusShutdown。检查图片路径是不是宽字符。ANSI字符串直接传给GdipCreateBitmapFromFile失败概率极高。检查文件是否存在。可以用GetFileAttributes先验证别只依赖GDI的返回码。我自己的习惯是封装一个GdiPlus_LoadBitmap过程内部依次完成宽字符转换、GetFileAttributes检查、GdipCreateBitmapFromFile调用并把GpStatus转换成可读文本。这个小封装让排查效率高很多。7.2 图形不刷新、闪烁症状窗口内容没变化或者重绘时画面不断闪烁。原因解析没调用InvalidateRect或UpdateWindow消息循环没触发WM_PAINT图形当然不会刷新。这不是GDI的问题是Win32窗口机制的基本约束。闪烁通常是直接在窗口DC上绘制造成的。窗口重绘时背景先被擦成白色再画新内容这个擦除和重绘的时间差让眼睛看到了闪烁。解决办法是使用双缓冲。GDI下可以先用GdipCreateBitmapFromScan0创建内存位图再GdipGetImageGraphicsContext获取Graphics对象把所有绘制操作发到内存位图最后把整张位图一次性画到窗口。这样就不会出现“先擦后画”的闪烁。7.3 绘图对象泄漏问题症状程序运行一段时间后变慢打开任务管理器或Process Explorer看到GDI对象数量持续上涨。原因解析GDI对象必须手动释放。Pen、Brush、Font、Bitmap、Graphics都有自己的释放函数GdipDeletePen、GdipDeleteBrush、GdipDeleteFont、GdipDisposeImage、GdipDeleteGraphics。忘了释放不是编译错误也不是立即崩溃而是对象句柄一点一点累积最后把系统资源耗光。我在编写时会强制自己遵守一个规则每次GdipCreateXxx调用成功就在同一段逻辑的退出路径上写好对应的释放调用。这样代码虽然看起来“啰嗦”但能避免大量苦不堪言的调试时间。7.4 坐标错乱REAL4、DWORD与invoke的相爱相杀症状图形画的位置不对、线宽变成极细、图片拉伸比例异常。原因解析GDI的许多参数是REAL4浮点而你在汇编里容易把整数当浮点传。前面提过GdipCreatePen1的线宽参数坐标参数也一样。比如GdipDrawLine的第五、六、七、八参数分别是要画起点的x、y和终点的x、y都是REAL4。解决经验如果参数是浮点传REAL4变量不要传立即数整数。如果形参数量太多先列出函数原型在代码里对照参数从右到左的顺序。如果用了invoke但仍然不对可以临时改成手动pushcall排查。比如push dword ptr [x2] ; 最后一个参数先入栈 push dword ptr [y2] push dword ptr [x1] push dword ptr [y1] push hPen push pGraphics call GdipDrawLine这样能确保浮点值以4字节原样入栈不受invoke类型推导影响。虽然麻烦但在这个问题上很可靠。如果手动pushcall是正确的而invoke有问题那问题基本出在你的变量类型声明上——检查它到底是不是REAL4。最后再分享一点个人体会win32汇编调用GDI最大的价值不在于图形API本身而在于让你以一种非常“赤裸”的方式理解系统API调用约定、参数布局和对象生命周期。你每成功调用一个Gdip函数相当于亲手完成了从结构体初始化、参数压栈到返回值解析的完整链路这种对计算机底层运行机制的控制感是高级语言很难替代的。GDI虽然老但API稳定、功能丰富作为汇编级的2D图形实践项目它依然是一个非常耐打的选择。后面如果还有精力可以继续把命中测试、图形保存、自定义控件封装这些进阶内容展开聊聊。
返回列表