简介:面向Windows GDI开发者的透明位图绘制源码,演示了利用BitBlt位块传输函数结合颜色键技术实现透明效果,同时简要涉及Alpha通道半透明扩展思路;适合需要处理UI、游戏或自定义控件图形合成的C/C++程序员参考学习,尤其适合GDI入门到进阶的开发者。压缩包共17个文件,主要包含cpp与h源文件、sln与vcxproj工程配置、6张bmp测试位图、编译好的exe可执行程序以及README说明等,整体仅881KB,目录中预置Debug输出,可直接运行并对照代码调试透明绘制流程。目前已有213人浏览学习,它是传统GDI透明绘图的落地示例,能帮助读者快速理解设备上下文兼容操作、SetBkColor透明键设置以及BitBlt的调用组合。配合README与LICENSE使用,可快速理清工程目录;源码、位图与可执行文件按目录区分,便于二次修改并观察不同位图素材下的透明效果差异,是入门Windows位图合成的实用素材。
1. 直接用 BitBlt 画透明位图:先看这一张图的完整思路
在 Win32 GDI 里画透明位图,最常见的做法不是“PS 抠好图再贴”,而是在内存里用一个掩码位图做两次 BitBlt。第一次把目标区域清空成黑色,第二次把源图的非透明部分用 AND 和 OR 的 ROP 码叠上去。很多人第一次看到这套流程总觉得绕,但那个本质是:透明不是让位图“变透明”,而是把底图在需要透明的位置先处理成黑色,然后再把前景的非透明区域补回去。这套方案的优点是不会依赖 PNG、不会引入 AlphaBlend,兼容性极强,从 Windows 98 到 Windows 11 都能跑。缺点也很明显:透明色是硬编码的,不支持半透明,边缘会有锯齿,而且如果掩码生成不对,画面会直接花掉。这篇文章适合正在学 Win32 绘图、维护老项目的人,也适合需要把透明位图打包成可复用源码模块的工程师。
2. BitBlt 的核心参数与透明位图的 ROP 码选型
2.1 从签名看 BitBlt 在做什么
先看 BitBlt 的函数签名,这是后续所有讨论的基础:
BOOL BitBlt( HDC hdcDest, // 目标 DC int xDest, // 目标区域左上角 X int yDest, // 目标区域左上角 Y int wDest, // 目标区域宽度 int hDest, // 目标区域高度 HDC hdcSrc, // 源 DC int xSrc, // 源区域左上角 X int ySrc, // 源区域左上角 Y DWORD rop // 光栅操作码 );这个函数把源 DC 上的一块矩形像素,用rop指定的逻辑组合方式搬到目标 DC 上。初学者常犯的错误是只把它当成“复制粘贴”,其实它做的是“像素位运算”,ROP 码决定的是源、目标、以及当前画笔三者之间怎么布尔组合。透明位图的核心就在rop参数上,不是 BitBlt 本身会自动识别透明色。
2.2 透明位图用哪几个 ROP 码
绘制透明位图的标准方案是两次 BitBlt,分别使用SRCAND和SRCINVERT:
| ROP 码 | 运算逻辑 | 作用 |
|---|---|---|
SRCCOPY | 源直接覆盖目标 | 普通拷贝 |
SRCAND | 源 AND 目标 | 把目标中与源黑色重合的部分清零 |
SRCINVERT | 源 XOR 目标 | 做两次 XOR 等于原样恢复 |
NOTSRCCOPY | 反转源后覆盖目标 | 用于生成反向掩码 |
透明绘制的流程是:先用SRCAND把目标区域上用掩码黑色标出的部分清成黑色,再用SRCINVERT把源图叠上去。这里的关键点是:掩码位图里,透明部分是黑色(RGB 0),保留部分是白色(RGB 255)。第一次SRCAND后,目标区在“保留部分”保持不变,在“透明部分”变黑;第二次SRCINVERT后,源图非透明部分被 XOR 贴上去,而源图自身透明区域是黑色,XOR 黑色等于不动底图。
提示:这套逻辑里,源图必须保证透明部分像素为纯黑。如果你的位图背景是纯白,还得先用
NOTSRCCOPY或其他方式生成反色掩码。
2.3 内存 DC 与位图加载的选型
在实际工程里,源图一般从资源加载,或从文件LoadImage得到 HBITMAP,不能直接拿着 HBITMAP 去 BitBlt。原因很简单:BitBlt 的源是一个 DC,不是位图句柄。要把 HBITMAP 放进去,需要先创建兼容 DC,再SelectObject进去。这一套流程决定了一个细节:掩码位图也需要单独一个 DC 和位图对象。所以你的源码里至少要有三个句柄:源图 HBITMAP、掩码 HBITMAP、两个内存 DC。如果你用的是单文档视图架构,还要注意在OnDraw里不要反复创建 DC,否则窗口重绘会非常卡。
3. 从零实现透明位图:掩码生成与两次 BitBlt 的完整代码
3.1 掩码位图的生成思路
掩码不是手工画的,必须根据源图动态生成。生成掩码的核心是遍历源图所有像素,把“透明色”像素写成黑色,其余写成白色。这里透明色如何定义,直接决定你的实现是否通用:
- 固定透明色(比如 RGB(255, 0, 255) 洋红色,这是老项目最常用的)
- 取左上角第一个像素作为透明色,适合游戏素材
- 让调用方显式传入透明色,源码接口更灵活
在动手之前,先想清楚一个问题:源图是 24 位还是 32 位。如果是 32 位带 Alpha 的 PNG,直接用 AlphaBlend 更合理,不需要掩码方案。只有“无 Alpha 通道、靠透明色抠图”的位图才适合 BitBlt 掩码绘制。
3.2 生成掩码的内存操作代码
下面这段代码,是从源位图生成掩码的标准做法,适用于 24 位或 32 位 DIB 段:
HBITMAP CreateMaskBitmap(HBITMAP hSrcBitmap, COLORREF crTransparent) { BITMAP bm; GetObject(hSrcBitmap, sizeof(BITMAP), &bm); // 创建一个与源图尺寸相同的 1bpp 掩码位图 HBITMAP hMask = CreateBitmap(bm.bmWidth, bm.bmHeight, 1, 1, NULL); // 用内存 DC 把源图封装起来,方便读取像素 HDC hdcSrc = CreateCompatibleDC(NULL); HDC hdcMask = CreateCompatibleDC(NULL); HBITMAP hOldSrc = (HBITMAP)SelectObject(hdcSrc, hSrcBitmap); HBITMAP hOldMask = (HBITMAP)SelectObject(hdcMask, hMask); // 先把掩码全部涂黑,表示“默认透明” HBRUSH hBlack = CreateSolidBrush(RGB(0, 0, 0)); HBRUSH hOldBrush = (HBRUSH)SelectObject(hdcMask, hBlack); PatBlt(hdcMask, 0, 0, bm.bmWidth, bm.bmHeight, PATCOPY); SelectObject(hdcMask, hOldBrush); DeleteObject(hBlack); // 外层循环:逐像素比较颜色,非透明区域写成白色 for (int y = 0; y < bm.bmHeight; y++) { for (int x = 0; x < bm.bmWidth; x++) { COLORREF crPixel = GetPixel(hdcSrc, x, y); if (crPixel != (crTransparent & 0x00FFFFFF)) { SetPixelV(hdcMask, x, y, RGB(255, 255, 255)); } } } SelectObject(hdcSrc, hOldSrc); SelectObject(hdcMask, hOldMask); DeleteDC(hdcSrc); DeleteDC(hdcMask); return hMask; }这段代码的逻辑很清楚:CreateBitmap(..., 1, 1, NULL)创建的是 1 位深度的位图,每个像素只有 0 和 1 两种状态。PatBlt负责把掩码全部初始化成黑色,这样凡是没有被SetPixelV覆盖到的像素,天然就是透明区。GetPixel是最直接的方法,但它的性能很差,一张 1024x768 的图大约要跑 80 万次 DC 访问,后续可以换成GetDIBits批量读取。
提示:比较颜色时,我用
crTransparent & 0x00FFFFFF是为了滤掉高字节的 Alpha 或标志位,避免两个相同 RGB 值因为高位不同被误判。这是一个特别容易踩的小坑。
3.3 两次 BitBlt 拼装:把掩码用到绘制流程里
掩码生成之后,绘制函数只需要按顺序执行两次 BitBlt 即可。这里我给出一个完整的绘制实现:
void DrawTransparentBitmap(HDC hdcDest, HBITMAP hSrcBitmap, int xDest, int yDest, COLORREF crTransparent) { BITMAP bm; GetObject(hSrcBitmap, sizeof(BITMAP), &bm); int w = bm.bmWidth; int h = bm.bmHeight; // 创建两个内存 DC,一个装源图,一个装掩码 HDC hdcSrc = CreateCompatibleDC(hdcDest); HDC hdcMask = CreateCompatibleDC(hdcDest); HBITMAP hOldSrc = (HBITMAP)SelectObject(hdcSrc, hSrcBitmap); HBITMAP hMask = CreateMaskBitmap(hSrcBitmap, crTransparent); HBITMAP hOldMask = (HBITMAP)SelectObject(hdcMask, hMask); // 第一步:用 SRCAND 把目标区域里“要透明”的地方清黑 BitBlt(hdcDest, xDest, yDest, w, h, hdcMask, 0, 0, SRCAND); // 第二步:用 SRCINVERT 把源图叠上去 BitBlt(hdcDest, xDest, yDest, w, h, hdcSrc, 0, 0, SRCINVERT); SelectObject(hdcSrc, hOldSrc); SelectObject(hdcMask, hOldMask); DeleteObject(hMask); DeleteDC(hdcSrc); DeleteDC(hdcMask); }第一步SRCAND的效果是:掩码中黑色(0)对应的目标像素变成 0,白色对应的目标像素保持不变。这个“黑色变透明、白色保留”的过程就是把底图“切开”。第二步SRCINVERT则是把源图与当前目标做异或;因为源图透明区域是纯黑(0),异或 0 不改变底图,而源图保留区域的颜色会被直接贴上去。两次操作合在一起,视觉上就是一个标准的透明位图。
3.4 为什么是“先 AND 再 INVERT”,而不是别的顺序
有人会问:能不能先SRCINVERT再SRCAND?不行。因为如果先做 XOR,源图透明区的黑色会直接改变底图像素,导致底图出现一块黑斑。之后再 AND 掩码,黑斑部分会被置 0,看似消除了,但源图像素和底图像素之间的颜色会互相干扰,最终结果是颜色发黑、发花。
另一个常见做法是用MERGECOPY加MERGEPAINT,它对源图背景色有硬性要求,通常在“源图背景是白色”的场景里更好用。但如果你脑中的位图背景色不统一,还是用SRCAND+SRCINVERT组合最稳。这里有一个判断依据:你的源图透明区域是不是纯黑,如果不是,需要先通过掩码位图把源图透明区统一抹黑。
4. 性能优化与高频踩坑:从 GetPixel 到批量像素处理
4.1 GetPixel 太慢时的落地替代方案
前文的掩码生成代码,用GetPixel逐像素访问。对宽高都在 500 以内的图标还行,但如果要处理 1920x1080 的截屏或大尺寸素材,它会在 CPU 上产生可感知的停顿。性能瓶颈主要在于跨模块调用 DC 层,每访问一个像素都要做一次 GDI 对象选择和颜色空间转换。
批量替代方案是先用GetDIBits把位图拷贝到一个自己管理的 DIB 缓冲区里,然后直接对内存数组读写。下面是一个通用封装:
void BuildMaskFromPixels(HBITMAP hSrc, COLORREF crTransparent, int width, int height, BYTE* pMaskBits) { // pMaskBits 是预先分配好的 (width * height) 字节缓冲区 // 值为 0xFF 表示保留,0x00 表示透明 HDC hdcScreen = GetDC(NULL); HDC hdcMem = CreateCompatibleDC(hdcScreen); BITMAPINFO bmi = { 0 }; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = width; bmi.bmiHeader.biHeight = -height; // 负值表示自顶向下扫描 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; // 统一用 32 位方便对齐 bmi.bmiHeader.biCompression = BI_RGB; BYTE* pBits = NULL; HBITMAP hDib = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, (void**)&pBits, NULL, 0); HBITMAP hOld = (HBITMAP)SelectObject(hdcMem, hSrc); HBITMAP hDibOld = (HBITMAP)SelectObject(hdcMem, hDib); BitBlt(hdcMem, 0, 0, width, height, hdcMem, 0, 0, SRCCOPY); // 这里有个细节:先 SelectObject(hdcMem, hSrc) 再选 hDib, // 实际上第二个 SelectObject 已经让 hDib 接管了 DC 的输出。 for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { BYTE* p = pBits + (y * width + x) * 4; COLORREF cr = RGB(p[2], p[1], p[0]); // DIB 是 BGR 顺序 pMaskBits[y * width + x] = (cr == (crTransparent & 0xFFFFFF)) ? 0x00 : 0xFF; } } SelectObject(hdcMem, hDibOld); SelectObject(hdcMem, hOld); DeleteObject(hDib); DeleteDC(hdcMem); ReleaseDC(NULL, hdcScreen); }这段代码的核心是CreateDIBSection,它允许你直接通过pBits指针读写像素,免去GetPixel的重复调用开销。注意biHeight = -height这个细节:正数表示自底向上存储,会让像素遍历方向反着来,特别容易把图像上下颠倒。统一用负值后,内存第一行就是图像顶行,循环里就不用费心思处理行序了。
4.2 高频踩坑:目标 DC 的透明背景处理
第二个高频踩坑是目标 DC 上没有做“清底”操作。很多人在对话框或自定义控件上画透明位图,底图是白底窗口,但SRCAND之后底图带有窗口背景色,最后叠加 SRCINVERT 就会出现一块灰蒙蒙的色块。解决办法是在绘制透明位图之前,把目标区域先用普通BitBlt或FillRect画上完整的底图内容,确保目标区是“最终显示内容”,而不是仅含背景色的空白区。
4.3 高频踩坑:掩码对象的内存泄漏
掩码位图在DrawTransparentBitmap里每次调用都重新创建一次,这在窗口频繁重绘时会造成大量 GDI 对象创建与销毁。监控 GDI 对象数的经验做法是:打开任务管理器,把进程列表切到 GDI 对象列,拖动窗口反复触发重绘,如果数字持续上涨不回落,基本就是DeleteObject没配对。
更稳妥的结构是把掩码和源图缓存成一个结构体,比如:
typedef struct _TRANSPARENT_BITMAP { HBITMAP hSrc; HBITMAP hMask; int width; int height; } TRANSPARENT_BITMAP, *PTRANSPARENT_BITMAP;初始化一次,之后每次绘制只做两次 BitBlt。如果你的源码是打包发给别人用的,这个缓存结构是值得放进去的封装层次。
4.4 使用场景差异:对话框控件与窗口 OnDraw 绘制的选择
如果透明位图是画在按钮、静态文本控件上,可以使用WM_CTLCOLORBTN或自绘按钮。自绘按钮里要注意DrawTransparentBitmap的目标 DC 是WM_ERASEBKGND之后才可用的,否则背景清除事件会把位图盖掉。而在OnDraw中绘制时,需要保证CBrush背景已经填充完毕。
如果是游戏或动画场景,位图频繁移动,建议把目标区域裁剪到源图与目标矩形的交集,避免 BitBlt 处理过多无效像素。裁剪可以通过IntersectClipRect实现,很小的改动,性能提升明显。
5. 源码打包的组织方式与一个实用的自检技巧
5.1 源码文件划分参考
如果你的交付物是“源码打包”,文件划分可以参考下面这种最小结构:
TransparentBitmap/ ├── TransparentBitmap.h // 接口声明,含透明色参数说明 ├── TransparentBitmap.cpp // 掩码生成、绘制函数实现 ├── ReadMe.txt // 编译环境说明,VS 版本和字符集设置 └── Sample/ ├── main.cpp // 最简单的 Win32 窗口演示 └── resource.h // 资源定义头文件的接口设计上,建议提供一个带缓存的高级接口和一个直接绘制的低层接口:
// 高级接口:内部缓存掩码,适合重复绘制 BOOL TransparentBmp_Init(PTRANSPARENT_BITMAP pBmp, HBITMAP hSrc, COLORREF crTransparent); void TransparentBmp_Draw(PTRANSPARENT_BITMAP pBmp, HDC hdc, int x, int y); void TransparentBmp_Release(PTRANSPARENT_BITMAP pBmp); // 低层接口:一次性绘制,适合单次显示 void DrawTransparentBitmapDirect(HDC hdcDest, HBITMAP hSrcBitmap, int xDest, int yDest, COLORREF crTransparent);这样使用方可以根据场景选择,不必被额外抽象绑架。
5.2 判断透明边缘是否正确的自检方法
最后分享一个很实用的自检技巧:把目标底图刷成红绿蓝渐变,然后在这个渐变背景上绘制透明位图。如果透明区域的内容完全跟随底图变化,说明掩码逻辑没问题;如果透明区出现偏色或黑边,说明源图透明区域不是纯黑,需要先做一次预处理,把源图里所有透明色像素统一改成 RGB(0,0,0)。
另外一个细致检查点是位图边缘的“白边”问题。这个通常不是掩码生成错误,而是源图本身带抗锯齿渐变边缘,透明色附近的像素其实不是纯透明色。处理方法有两个:一是把透明色阈值放宽,允许一定颜色距离内视为透明;二是使用 AlphaBlend 方案处理真正的半透明像素。前者在源码里只需修改颜色相等比较逻辑,改成“三个颜色分量的绝对值差小于 10 就视为透明”即可。
5.3 最后一段代码:带容差的透明色判断
static BOOL IsTransparent(COLORREF crPixel, COLORREF crKey, int tolerance) { int r = GetRValue(crPixel) - GetRValue(crKey); int g = GetGValue(crPixel) - GetGValue(crKey); int b = GetBValue(crPixel) - GetBValue(crKey); if (r < 0) r = -r; if (g < 0) g = -g; if (b < 0) b = -b; return (r + g + b) <= tolerance; }把这段函数替换掉前文crPixel != crTransparent的比较逻辑,你就获得了一个能吃掉边缘杂色的小改进。tolerance不要给得太大,否则会把视觉上应当保留的高对比度颜色误判成透明,一般取 15 到 30 之间即可。
本文还有配套的精品资源,点击获取