ARTICLE DETAIL

资讯详情

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

BitBlt透明位图绘制详解:掩码生成与两次ROP运算实现

BitBlt透明位图绘制详解:掩码生成与两次ROP运算实现 简介面向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和SRCINVERTROP 码运算逻辑作用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它对源图背景色有硬性要求通常在“源图背景是白色”的场景里更好用。但如果你脑中的位图背景色不统一还是用SRCANDSRCINVERT组合最稳。这里有一个判断依据你的源图透明区域是不是纯黑如果不是需要先通过掩码位图把源图透明区统一抹黑。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 之间即可。本文还有配套的精品资源点击获取
返回列表