简介:面向仍在使用VC6.0进行C++桌面开发的程序员,或刚开始接触图像透明处理的入门者,这份资源提供了一套基于GDI+的PNG图片加载与透明化处理方案,解决老版本编译器不直接支持PNG显示、Alpha通道读取和图形混合等常见问题。压缩包共70个文件,大小4.73MB,核心为34个GdiPlus系列头文件,完整覆盖图形绘制、图像编解码和颜色矩阵等接口;另有3个cpp源文件、8张PNG素材构建出可运行的示例程序,配合dsw/dsp工程文件、lib静态库及exe演示程序,下载后可直接用VC6.0打开编译查看效果。目前已有714人学习/浏览。示例工程以一个会走时的时钟界面展示多张PNG图片的透明叠加效果,代码中演示了GdiplusStartup初始化、Bitmap加载、ColorMatrix与ImageAttributes修改Alpha通道、DrawImage绘制以及GdiplusShutdown释放等关键环节,逻辑完整,便于在实际项目中提取复用。对于需要在旧工具链中实现现代图像视觉效果、又不便升级编译环境的开发者,这套资料提供了从代码到工程配置的完整参考,具有较高的实用价值。
1. 从一张带透明通道的 PNG 说起:VC6.0 的显示困境
接手一个 2005 年落地的 MFC 对话框工程,界面要上带圆角和不规则形状的 PNG 图标,还要在按钮禁用、悬停时做半透明效果。VC6.0 的CBitmap只认 BMP,LoadImage对 PNG 无能为力,把 PNG 当位图硬塞进去,轻则黑底,重则直接花屏。透明化处理这几个字,在 VC6.0 里不是“扣个色”那么简单,它牵涉到解码器、alpha 通道、GDI 混合函数和平台 SDK 版本这一整条链。这篇文章就是给老工程做 PNG 支持、又不想重写 UI 层的人准备的,目标只有一个:让 VC6.0 能把 PNG 加载进来,并且透明区域真正透明、半透明区域真正半透明。
2. 用 GDI+ 在 VC6.0 里加载 PNG:为什么加载和透明化必须一起考虑
2.1 三种常见做法的对比:CBitmap 做不到,CImage 不省心
老工程里提到“加载图片”,大多数人第一反应是LoadImage配CBitmap。这条路的边界很清楚:VC6.0 自带的 SDK 里,LoadImage只保证 BMP、ICO、CUR 的加载,PNG 不在支持列表里。就算你把 PNG 后缀改成 BMP 骗过资源管理器,解码出来的数据也是错的,花屏是必然结果。所以CBitmap这条线直接从选项里划掉。
第二种做法是 MFC 的CImage。它确实封装了IImage接口,能读 PNG、能调AlphaBlend,但有个历史包袱:atlimage.h在 VC6.0 时代不是标准分发的一部分,需要额外从 Platform SDK 里拿。而且CImage的Draw方法在老版本上对 32 位 PNG 的 alpha 支持不稳定,经常出现“透明区域变成黑色”的翻车。我早年试过一次,最后为了一个GetPixelAddress折腾了半个下午,得不偿失。
第三种是 GDI+。Windows XP 开始系统自带gdiplus.dll,它内置 PNG、JPEG、GIF 解码器,加载 PNG 只是调用一个解码器的事。更关键的是,GDI+ 的Graphics::DrawImage天然支持逐像素 alpha 混合,透明化处理和加载是同一套体系,不用在 GDI 位图和 alpha 数据之间反复横跳。唯一的代价是 VC6.0 的默认 SDK 太老,需要配新一点的 Platform SDK,这部分放到避坑章节细说。
2.2 初始化 GDI+ 并从文件加载 PNG:最小可运行代码
GDI+ 不是 MFC 的一部分,它是纯 Win32 的 C 风格 API 加一层 C++ 封装。使用前必须调用GdiplusStartup做一个进程级初始化,退出时再GdiplusShutdown。这个初始化的时机有讲究:必须在所有Bitmap、Graphics对象创建之前。放在CWinApp::InitInstance开头、ExitInstance结尾是最稳的。
// png_tool.cpp #include <gdiplus.h> #pragma comment(lib, "gdiplus.lib") using namespace Gdiplus; ULONG_PTR g_gdiplusToken = 0; BOOL InitGdiPlus() { GdiplusStartupInput in; in.GdiplusVersion = 1; // GDI+ 1.0,VC6 时代只有这个版本 in.DebugEventCallback = NULL; // 不需要调试回调 in.SuppressBackgroundThread = FALSE; // 让 GDI+ 自己管理后台线程 in.SuppressExternalCodecs = FALSE; // 加载系统自带的所有图片解码器 Status st = GdiplusStartup(&g_gdiplusToken, &in, NULL); return (st == Ok); }GdiplusStartupInput的四个字段里,SuppressBackgroundThread和SuppressExternalCodecs在大多数工程里保持FALSE即可。前者负责让 GDI+ 内部维护一个后台线程做资源回收,后者决定是否允许外部编解码器插件。如果只加载 PNG,把SuppressExternalCodecs设成TRUE能略微软件启动速度,但一旦后续要读 JPEG 就得改回来,我一般不动它。
有了初始化,加载 PNG 文件就简单了。Bitmap::FromFile是静态工厂方法,注意它在内部持有文件句柄,所以加载期间不要抢占这个文件:
Bitmap* LoadPngFromFile(const char* path) { // VC6.0 工程很多是 ANSI 编码,GDI+ 只吃宽字符 WCHAR wpath[512]; MultiByteToWideChar(CP_ACP, 0, path, -1, wpath, 512); Bitmap* bmp = Bitmap::FromFile(wpath); if (bmp == NULL || bmp->GetLastStatus() != Ok) { delete bmp; return NULL; } return bmp; }Bitmap::FromFile返回的Bitmap*必须用delete释放,这一点比 GDI 的DeleteObject更隐蔽。还有一处容易踩:返回的Bitmap如果加载失败,指针可能不是NULL而是一个“坏对象”,所以不能只判空,必须用GetLastStatus()确认状态为Ok。路径转换用CP_ACP保证中文路径在 ANSI 工程下不至于乱码。
2.3 保留 alpha 的显示路径:先把 PNG 画进内存 DC,再 AlphaBlend 上屏
加载只是第一步,怎么把 PNG 画到窗口上还能保留透明,才是多数人翻车的地方。有人直接把Bitmap*转成HBITMAP:
HBITMAP hbmp = NULL; pImg->GetHBITMAP(Color(0, 0, 0), &hbmp); // 注意:透明区域会变成黑底GetHBITMAP返回的 GDI 位图是 24 位或 32 位的不透明 DIB,alpha 通道在转换过程中被直接丢了。所以这条路只适合不带 alpha 的 JPEG,对 PNG 来说等于白费劲。
正确的做法是:创建一个 32 位 DIB 段内存 DC,用 GDI+ 的Graphics把 PNG 画进去,再用AlphaBlend把内存 DC 的内容和窗口 DC 做混合。这个“中间画布”是关键,它保证 alpha 数据在 GDI 和 GDI+ 之间传递时不被丢弃。
// 在 hdc 的 (x, y) 处绘制完整的透明 PNG void DrawPng(HDC hdc, int x, int y, Bitmap* pImg) { int w = (int)pImg->GetWidth(); int h = (int)pImg->GetHeight(); // 1. 创建兼容内存 DC 和 32 位 DIB 段 HDC memDC = CreateCompatibleDC(hdc); BITMAPINFO bi = {0}; bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth = w; bi.bmiHeader.biHeight = -h; // 负高度 = 顶向下,和 GDI+ 坐标一致 bi.bmiHeader.biPlanes = 1; bi.bmiHeader.biBitCount = 32; bi.bmiHeader.biCompression = BI_RGB; void* bits = NULL; HBITMAP dib = CreateDIBSection(memDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, dib); // 2. 用 GDI+ 把 PNG 绘制到内存 DC Graphics g(memDC); g.DrawImage(pImg, 0, 0, w, h); // 3. AlphaBlend 混合上屏 BLENDFUNCTION bf; bf.BlendOp = AC_SRC_OVER; bf.BlendFlags = 0; bf.SourceConstantAlpha = 255; // 255 = 不额外做整体透明 bf.AlphaFormat = AC_SRC_ALPHA; // 逐像素 alpha 生效 AlphaBlend(hdc, x, y, w, h, memDC, 0, 0, w, h, bf); // 4. 还原并释放 SelectObject(memDC, oldBmp); DeleteObject(dib); DeleteDC(memDC); }这个函数里CreateDIBSection的 32 位位图是给 GDI+ 当“画布”用的,AlphaBlend的AlphaFormat置成AC_SRC_ALPHA后,GDI 会逐像素读取 DIB 里的 alpha 值来混合。如果将SourceConstantAlpha设成 255,实际效果就是 PNG 原样显示,透明区域透出背后的窗口内容。这个函数可以原封不动地塞进WM_PAINT里,配合双缓冲就有不错的效果。
3. 透明化处理:从“显示不出黑块”到“随心控制不透明度”
3.1 透明化不是“去掉背景”:alpha 合成公式先说清楚
透明化在 VC6.0 里最常见的误解是“把背景色抠掉”。BMP 时代确实这么做:遍历像素,把某种颜色设成透明色。但 PNG 用的是 alpha 通道,它记录的是每个像素的不透明度,取值范围 0 到 255。合成到屏幕上的公式是:
结果色 = 前景色 × alpha + 背景色 × (1 - alpha)
alpha 等于 255 时完全不透明,等于 0 时完全透明,中间值是半透明。这跟“抠背景色”有本质区别:alpha 是逐像素存在的,不是颜色比较的结果。所以透明化处理要解决两件事,一是让 PNG 自带的 alpha 能在绘制时被识别,二是按业务需要去“改写”alpha,比如把整张图变淡、把某区域镂空、把图标做成禁用态。
第一件事在第 2 章已经完成了:32 位 DIB 段加AlphaBlend是关键。第二件事就是这一章要说的——alpha 不仅可以从 PNG 里读出来,也可以用代码去改。改之前必须知道BLENDFUNCTION里的两个参数各管什么,不然经常出现“我设了透明,怎么整张图都看不见了”的翻车。
3.2 整图半透明、渐变透明、禁用态:SourceConstantAlpha 怎么用
最常见的透明化需求是整图半透明,典型场景是按钮禁用态或弹层遮罩。BLENDFUNCTION的关键字段就三个:BlendOp固定为AC_SRC_OVER(源覆盖目标),SourceConstantAlpha控制整图的整体不透明度,AlphaFormat决定是否使用源图的逐像素 alpha。
// 以指定不透明度绘制 PNG,alpha 范围 0~255 void DrawPngWithAlpha(HDC hdc, int x, int y, Bitmap* pImg, BYTE alpha) { int w = (int)pImg->GetWidth(); int h = (int)pImg->GetHeight(); // 内存 DC 和 32 位 DIB 的创建代码同 DrawPng,此处省略 HDC memDC = CreateCompatibleDC(hdc); BITMAPINFO bi = {0}; bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth = w; bi.bmiHeader.biHeight = -h; bi.bmiHeader.biPlanes = 1; bi.bmiHeader.biBitCount = 32; bi.bmiHeader.biCompression = BI_RGB; void* bits = NULL; HBITMAP dib = CreateDIBSection(memDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, dib); Graphics g(memDC); g.DrawImage(pImg, 0, 0, w, h); BLENDFUNCTION bf = {0}; bf.BlendOp = AC_SRC_OVER; bf.BlendFlags = 0; bf.SourceConstantAlpha = alpha; // 128 = 50% 透明度 bf.AlphaFormat = AC_SRC_ALPHA; // 必须加上,否则逐像素 alpha 被忽略 AlphaBlend(hdc, x, y, w, h, memDC, 0, 0, w, h, bf); SelectObject(memDC, oldBmp); DeleteObject(dib); DeleteDC(memDC); }SourceConstantAlpha与像素 alpha 的关系是相乘,不是相加。也就是说,如果 PNG 本身某个像素 alpha 是 200,SourceConstantAlpha设为 128,最终这个像素的有效 alpha 是200 × 128 / 255约等于 100。这个细节决定了两层透明叠加时不会出现“突然变实”的突兀感。按钮禁用态一般用 128,悬停高亮用 220 左右,具体数值要配合背景色微调。
3.3 局部透明处理:LockBits 改 alpha 通道的分区做法
整图透明容易,局部透明就得动像素了。比如做一张只亮一半的进度图标,或者把某个矩形区域挖空显示背景。GDI+ 的Bitmap::LockBits能把像素数据锁到内存里,直接改 alpha 字节再解锁,这是老工程里改透明区域最常用的手段。
// 将 pImg 中 rect 区域的 alpha 全部置为 0(完全透明) void EraseRegion(Bitmap* pImg, const Rect& rect) { BitmapData data; Rect full(0, 0, pImg->GetWidth(), pImg->GetHeight()); // 用 PARGB 格式锁内存,保证 alpha 字节一定在正确位置 Status st = pImg->LockBits(&full, ImageLockModeRead | ImageLockModeWrite, PixelFormat32bppPARGB, &data); if (st != Ok) return; BYTE* base = (BYTE*)data.Scan0; int stride = data.Stride; // 每行的字节数,注意不是 width*4 for (int y = 0; y < rect.Height; y++) { BYTE* row = base + (rect.Y + y) * stride + rect.X * 4; for (int x = 0; x < rect.Width; x++) { row[3] = 0; // 第 4 个字节是 alpha(小端模式) row += 4; } } pImg->UnlockBits(&data); }这里最值得说的是data.Stride。GDI+ 的内存行不是按照“宽度乘像素字节数”连续排列的,每行的末尾可能有多余的填充字节,用来保证行首对齐到 4 字节边界。如果按Width * 4去算下一行的起点,图片宽度不是 4 的整数倍时会错位,改完的透明区域边缘会出现斜向锯齿。这种问题肉眼很难定位,属于那种“看着不对劲又说不清哪错了”的玄学。所以一切 LockBits 遍历,都要以data.Stride为准,不能自己按宽度推算。
PixelFormat32bppPARGB指的是预乘 alpha 格式,和 PNG 解码出来的PixelFormat32bppARGB有区别。改 alpha 这个操作本身不涉及颜色值,用哪种都行,但后面如果要缩放、旋转这种重采样操作,PARGB 能避免半透明边缘出现黑边。这里用 PARGB 锁内存,等于提前做了格式归一化。
4. 避坑:VC6.0 + PNG 透明化的 5 个翻车现场
4.1 启动失败但程序不报错:GdiplusStartup 返回值被忽略,画出来全是黑块
现象:程序能跑,DrawImage也调了,但画出来的 PNG 是全黑,或者透明区域是黑色矩形。
原因:GdiplusStartup根本没有成功。常见于把初始化代码放在某个对话框的构造函数里,而那个对话框在InitInstance之前就被创建了;或者gdiplus.lib没有正确链接,启动函数返回GenericError却没被检查。
解决:在CWinApp::InitInstance的开头调用InitGdiPlus(),并把返回值存下来,失败直接弹框提示。检查gdiplus.lib是否在“项目设置 → 链接 → 对象/库模块”里,依赖#pragma comment(lib, "gdiplus.lib")也要看编译器是否识别。别在静态全局对象的构造里做 GDI+ 初始化,那时机不受你控制。
4.2 带透明通道的索引 PNG 偏色或花屏:先统一成 32bppARGB 再处理
现象:同一张 PNG,在图片查看器里正常,加载到程序里颜色发紫、发灰,透明区域出现杂色噪点。
原因:PNG 内部像素格式不只有 32 位真彩色,还有 8 位索引色、4 位索引色、灰度加透明等多种格式。GDI+ 解码后直接以原始格式返回,Graphics::DrawImage对索引格式的透明支持不完善,经常把调色板里的索引值当成颜色直接输出。
解决:加载后统一转成 32 位 ARGB,再进入绘制流程:
Bitmap* bmp = LoadPngFromFile("icon.png"); Bitmap* bmp32 = (Bitmap*)bmp->Clone(0, 0, bmp->GetWidth(), bmp->GetHeight(), PixelFormat32bppARGB); delete bmp;转完之后再用DrawPng就不会有偏色问题。这个坑对 16×16、32×32 的小图标尤其常见,因为工具导出图标时经常压成 8 位索引色来省体积。我一般会在加载函数里直接做格式归一化,统一返回 32bppARGB,调用方不用关心源 PNG 是什么格式。
4.3 编译期找不到 gdiplus.h 或 AlphaBlend:Platform SDK 老版本的系统性缺口
现象:#include <gdiplus.h>报Cannot open include file;或者AlphaBlend报undeclared identifier;更极端的是装完 VC6.0 后新建工程直接提示 “no compile tool”。
原因:VC6.0 发布时对应的是 Windows 98 时代的 SDK,而gdiplus.h是 Windows XP 时代的头文件,AlphaBlend也要_WIN32_WINNT >= 0x0500才能看到声明。VC6.0 自带的 Platform SDK 里根本没有这些东西。
解决:装一个支持 VC6.0 的后期 Platform SDK(Windows Server 2003 R2 Platform SDK 是最后一代支持 VC6 的版本),然后在 Tools → Options → Directories 里把新 SDK 的 Include 和 Lib 路径加到最前面。另外在stdafx.h顶部加上:
#define WINVER 0x0500 #define _WIN32_WINNT 0x0500不加这两行,就算装好了 SDK,AlphaBlend依然会被预编译宏挡住。这一步是 VC6.0 老环境最常见的拦路虎,新工程一定要先确认编译工具链是完整的再谈代码。
4.4 窗口透明了但内容闪烁:内存 DC 的位图只有 1x1,或忘了双缓冲
现象:AlphaBlend调用成功,但窗口上什么都没有,或者只有一个小点;另外在窗口大小变化时透明区域闪烁严重。
原因:很多教程里写的是CreateCompatibleBitmap(memDC, w, h),但CreateCompatibleBitmap的第一个参数传memDC就等于让 GDI 在“没有实际位图的内存 DC”上创建兼容位图,得到的经常是 1×1 的单色位图。正确做法是传目标窗口的hdc,或者用第 2.3 节里的CreateDIBSection直接创建 32 位 DIB 段。闪烁则是透明绘制直接发生在窗口 DC 上,没有先画到内存 DC 再整体 BitBlt。
解决:把透明绘制函数全部改成“内存 DC 绘制 → 一次性 AlphaBlend 上屏”。如果界面有多个 PNG 在同一个区域内叠加,先按顺序都画到同一个内存 DC,最后只做一次 AlphaBlend。这也符合双缓冲的原则,透明效果只有在无闪烁的前提下才有实用价值。
4.5 长时间运行 GDI 对象涨到一万五:Bitmap 没释放 + Shutdown 时机不对
现象:程序跑一晚上,任务管理器里 GDI 对象数持续上涨,最后界面卡死、绘制全部异常。
原因:Bitmap是 C++ 对象,但内部持有 GDI+ 解码器分配的 GDI 句柄,不delete就不会释放。常见于定时器里反复LoadPngFromFile,画完就把指针丢了。另一个隐蔽问题是GdiplusShutdown在ExitInstance里被调用时,某些Bitmap成员变量还没析构,导致析构时 GDI+ 已经关闭,句柄泄漏。
解决:养成“谁 new 谁 delete”的习惯,加载函数返回的Bitmap*用完即删。作为类成员的Bitmap,在对话框的OnDestroy里先delete,再等ExitInstance做GdiplusShutdown。另外,静态图片只在OnInitDialog加载一次存入成员变量,不要在WM_PAINT里反复解码。GDI+ 对象泄漏不会像内存泄漏那样马上崩溃,但它会在几个小时后慢慢要你的命。
5. 进阶:预乘 alpha、检验透明效果与三种透明方案的取舍
到了这一步,PNG 加载和透明化已经能跑通,剩下的就是把方案做细。三种透明处理方式各有适用场景,我按工程经验做了个对比:
| 处理方式 | 原理 | 适用场景 | 备注 |
|---|---|---|---|
| 保留 PNG 原始 alpha | 逐像素 alpha 混合 | 不规则图标、圆角图形 | 最常用,无需额外处理 |
SourceConstantAlpha | 整图统一乘系数 | 禁用态、半透明遮罩 | 一行参数,256 级可调 |
LockBits改写 alpha | 直接改像素 alpha 字节 | 局部镂空、渐变透明 | 注意 Stride 对齐 |
如果要对 PNG 做缩放、旋转这类重采样操作,建议先转成PixelFormat32bppPARGB再交给Graphics::DrawImage。非预乘 ARGB 在插值计算时会把透明像素的 RGB 值也拿来平均,导致半透明边缘出现一圈黑边;预乘格式把 RGB 值乘以 alpha 存起来,插值后黑边会明显减少。这一步对高分辨率图标缩放最有效。
验证透明化是否做对了,最直观的方法是准备一张纯白背景和一张纯黑背景,分别把 PNG 画上去做对照。如果两张图的边缘颜色完全一致,说明 alpha 没有参与合成,透明处理是假的。更精确的做法是用 LockBits 把目标区域读回来,检查 alpha 字节的分布是否符合预期,但这只在写自动化测试时有必要。
我在这类老工程里的习惯是:把 GDI+ 初始化封装成唯一的入口,加载函数固定返回 32bppARGB 的Bitmap*,绘制函数固定走内存 DC 加 AlphaBlend。这三条规矩定下来,整个工程里任何对话框用 PNG 都不会再出幺蛾子。曾有段时间我为了省事,在多个对话框里各写各的加载代码,结果一半窗口有锯齿、一半是黑底,最后统一重构成一个CImageEx包装类才算消停。那些为了省半小时重构省下来的时间,最后都花在了调试莫名其妙的透明边缘上。希望帮到你。
本文还有配套的精品资源,点击获取