用BitBlt绘制透明位图:掩码法原理、实现与常见坑

发布时间:2026/10/22 18:38:34
用BitBlt绘制透明位图:掩码法原理、实现与常见坑
简介这份源码包面向Windows GDI编程开发者针对界面与游戏开发中透明位图绘制的常见需求演示了如何借助BitBlt位块传输函数实现透明效果。BitBlt作为GDI的核心位操作可高效完成设备上下文间的像素复制是实现这类特效的基础。资源共17个文件以cpp/h源码、bmp测试图为主附带Visual Studio工程文件、说明文档和可执行程序压缩包约881KB适合希望快速上手GDI位图处理的初学者及中级开发者。目前已有216人学习。示例代码围绕颜色键透明展开完整展示创建兼容DC、SelectObject选入位图、SetBkColor设定透明色、调用BitBlt配合SRCCOPY与ROP2完成透明拷贝以及结束时DeleteDC/DeleteObject释放资源的流程同时讨论了颜色键与Alpha通道的差异补充了AlphaBlend在半透明处理中的用法帮助读者理解不同透明机制的适用场景。工程结构简洁、注释清晰拿到后可直接编译运行并在此基础上扩展出带透明效果的按钮、图层或游戏精灵。1. 用 BitBlt 绘制透明位图一个绕不开又容易翻车的 GDI 难题在维护一个纯 GDI 的老界面程序时经常会遇到把图标画成透明背景、不能露出方块底色的需求。很多新手第一反应是换 GDI 或 Direct2D但在不能随便引入依赖的老工程里BitBlt 配合掩码位图才是被验证过无数遍的老办法。标题里的“BitBlt 函数实现绘制透明位图”指的就是这套方案BitBlt 本身不带 alpha 通道的概念它只是把一块像素按指定的 ROP光栅操作灌到目标 DC 上。所谓透明其实是用一张黑色轮廓的掩码位图配合两次位块传输拼出来的效果属于像素级形状抠图而不是半透明渐变。方案解决的是自绘控件、图标、气泡提示这类小尺寸位图的透明需求适合维护 Win32/MFC/纯 API 程序、手上只有几张位图、又不想为一张图标引入整个图形框架的人。2. 原理先立住为什么 BitBlt 不带透明掩码法又为什么能成立2.1 BitBlt 是布尔运算搬运工不是合成器BitBlt 的签名很直接BOOL BitBlt( HDC hdcDest, // 目标 DC int xDest, // 目标左上角 x int yDest, // 目标左上角 y int w, // 绘制宽度 int h, // 绘制高度 HDC hdcSrc, // 源 DC int xSrc, // 源区域左上角 x int ySrc, // 源区域左上角 y DWORD rop // 三元光栅操作码 );它把一个矩形像素区域从源 DC 搬到目标 DC搬的过程中用 rop 参数做布尔运算。GDI 里没有“颜色键Color Key”这个概念它不做像素级的透明判断。你在目标上看到的任何透明效果本质上都是源像素、目标像素和画笔图案三者之间做 AND、OR、XOR、NOT 的结果。BitBlt 不对图像做缩放、不清除目标、不处理 alpha它只做一件事按 ROP 逐位搬运。理解这一点就能明白后面掩码法的每一步都是在凑一个“模板运算”。2.2 掩码法核心先 AND 挖洞再 OR 贴图BitBlt 透明方案的全部秘密就是准备两张位图彩色位图图形内容为彩色背景区域必须是纯黑色 RGB(0,0,0)。掩码位图图形内容位置为纯黑色背景区域为纯白色 RGB(255,255,255)。这两张位图的尺寸和内容位置必须完全对齐。绘制时分两步// 第一步目标与掩码做 AND把图形区域挖成黑 dst dst mask // 第二步上一步结果与彩色位图做 OR图形区域被彩色填充 result dst | color分区域看推导在图形区域掩码是黑 0目标先 AND 0 变成 0第二步 OR 上彩色像素得到的就是彩色图内容在背景区域掩码是白 0xFFFFFF目标 AND 后保持不变第二步 OR 上纯黑背景结果还是原目标。两步叠加就做到了“图形区域的像素来自位图背景像素保持原样”。这个逻辑没有任何玄学成分完全是布尔代数。注意第一步和第二步的执行顺序不能换先 OR 会把背景区域直接覆盖再 AND 又把图形区域清掉等于白做。2.3 选型判断什么图适合走掩码法动手写代码前先对素材做三问。第一素材的透明度是不是只有“完全透明”和“完全不透明”两种状态第二图形边缘是不是比较硬朗、抗锯齿导致的手指不多第三需不需要旋转、缩放、半透明阴影这类效果如果三个回答都是“是”BitBlt 掩码法是性价比最高的选择只要有一个是“否”说明需求里有半透明或者动态效果应该考虑 AlphaBlend 或 Direct2D。掩码法能卖的是零依赖、快、稳卖不了的是平滑的半透明过渡。这里提前判断清楚能省掉后面大量返工。3. 把掩码法跑通最小可复现代码与封装3.1 准备位图透明键检测、背景压黑、掩码生成很多源码包里会直接放两张 BMP一个彩色图一个掩码图加载完直接用。如果手头素材只有一张图最常见的可靠做法是用“透明键”自动生成掩码。美术约定一种颜色作为透明标志程序扫描像素、生成掩码同时把彩色图的背景压成黑色。这套流程久经考验尤其适合图标素材。// srcBits: BGRA 像素数组stride 为每行字节数w/h 为宽高 std::vectorBYTE maskBits(stride * h, 0); for (int y 0; y h; y) { for (int x 0; x w; x) { int i y * stride x * 4; BYTE b srcBits[i]; BYTE g srcBits[i 1]; BYTE r srcBits[i 2]; // 约定洋红 RGB(255,0,255) 为透明键 int dr r - 255; int dg g - 0; int db b - 255; bool isKey (dr*dr dg*dg db*db) (24 * 24); if (isKey) { // 掩码区填白色背景区 maskBits[i] maskBits[i 1] maskBits[i 2] 255; // 彩色图背景压成纯黑 srcBits[i] srcBits[i 1] srcBits[i 2] 0; } else { // 图形区填黑色 maskBits[i] maskBits[i 1] maskBits[i 2] 0; } } }这段代码用色距而不是绝对相等判断透明键因为压缩解压后洋红边缘会有一点点色差。24 的阈值是个经验值图标越大可以适当收紧到 16。接下来把两个数组分别转成 DIB 位图BITMAPINFO bmi {0}; bmi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth w; bmi.bmiHeader.biHeight -h; // 负数表示 top-down扫描顺序与数组一致 bmi.bmiHeader.biPlanes 1; bmi.bmiHeader.biBitCount 24; bmi.bmiHeader.biCompression BI_RGB; LPVOID colorBits nullptr; HBITMAP colorBmp CreateDIBSection(memDC, bmi, DIB_RGB_COLORS, colorBits, nullptr, 0); memcpy(colorBits, srcBits, stride * h); LPVOID maskBitsBuf nullptr; HBITMAP maskBmp CreateDIBSection(memDC, bmi, DIB_RGB_COLORS, maskBitsBuf, nullptr, 0); memcpy(maskBitsBuf, maskBits.data(), stride * h);这里最容易翻车的是 DIB 的扫描行顺序。CreateDIBSection 的 biHeight 为负时表示 top-down也就是数据从左上角开始正好配合普通扫描顺序如果写成正数图像会上下颠倒。位图生成后优先用 24bpp 而非 32bpp避免 alpha 字节干扰 ROP 运算。3.2 执行绘制一个内存 DC两次 BitBlt掩码法绘制函数本身很短但 DC 的创建和位图的换入换出要仔细处理void DrawMaskedBitmap(HDC hdc, int x, int y, int w, int h, HBITMAP colorBmp, HBITMAP maskBmp) { HDC mem CreateCompatibleDC(hdc); // 源 DC 不能和目标 DC 是同一个 // 第一次换入掩码位图 HGDIOBJ original SelectObject(mem, maskBmp); BitBlt(hdc, x, y, w, h, mem, 0, 0, SRCAND); // 第二次换入彩色位图 SelectObject(mem, colorBmp); BitBlt(hdc, x, y, w, h, mem, 0, 0, SRCPAINT); // 恢复原始选择避免 GDI 对象悬空 SelectObject(mem, original); DeleteDC(mem); }逻辑说明第一次 BitBlt 使用 SRCAND把目标 DC 里要贴图的区域清成黑色背景区域保持不变第二次换入彩色位图对目标 DC 做 SRCPAINT 即 OR 运算黑色区域被彩色像素填满而背景那部分因为彩色位图对应位置是黑 0所以原样保留。使用 CreateCompatibleDC 是为了保证源 DC 颜色格式和目标屏幕 DC 兼容减少颜色空间转换。函数内部的 original 保存了最初选入 mem 的位图函数结束前恢复再删除 DC这是防止 GDI 句柄泄漏的习惯做法。3.3 封装成可复用工具把上面的函数包进一个类里可以省掉每次调用时重复创建 DC 的开销也方便统一释放位图class TransparentBitmap { public: TransparentBitmap(int width, int height) : w_(width), h_(height), colorBmp_(nullptr), maskBmp_(nullptr) {} ~TransparentBitmap() { if (colorBmp_) DeleteObject(colorBmp_); if (maskBmp_) DeleteObject(maskBmp_); } void Draw(HDC dc, int x, int y) { DrawMaskedBitmap(dc, x, y, w_, h_, colorBmp_, maskBmp_); } // 从 BGRA 数组构建位图与掩码内部调用 3.1 的生成逻辑 bool CreateFromBGRA(const BYTE* srcBits, int stride); private: HBITMAP colorBmp_; HBITMAP maskBmp_; int w_, h_; };封装时要注意生命周期位图对象在析构时必须 DeleteObject否则窗口反复重建会造成 GDI 句柄耗尽。常见做法是让 CreateFromBGRA 内部完成透明键检测、背景压黑和 CreateDIBSection对外只暴露一个构造入口。如果你拿到的是别人打包好的源码里面的组织方式可能不一样但核心流程绕不开这三件事准备掩码、准备纯黑背景彩图、两次 BitBlt。4. 参数与边界ROP 对照、位深和半透明问题4.1 绘制区域与源区域怎么给BitBlt 的参数里xDest/yDest 决定贴图到目标 DC 的位置w/h 是目标绘制矩形的宽高xSrc/ySrc 是源位图的起始坐标。多数人只传 0,0但帧动画和贴图集场景下源坐标很关键。比如一张横向排了四帧的 160x40 位图要画第二帧就传 xSrc40, ySrc0, w40, h40。注意源矩形必须完全落在源位图范围内GDI 对超出源图边界的访问不会帮忙剪裁结果未定义常见表现是绘制区域出现乱码或直接失败。另外BitBlt 的源 DC 和目标 DC 不能是同一个句柄同一个 HD C 同时作为 src 和 dest 在很多显卡驱动上会死锁这是老生常谈的坑。4.2 常用 ROP 参数对照表ROP 名称hex 值效果在掩码法中的用途SRCCOPY0x00CC0020目标 源直接拷贝、调试时确认素材SRCAND0x008800C6目标 目标 AND 源第一步挖洞SRCPAINT0x00EE0086目标 目标 OR 源第二步贴图SRCINVERT0x00660046目标 目标 XOR 源特殊反转效果NOTSRCCOPY0x00330008目标 源取反可用于反色调试PATCOPY0x00F00021目标 画笔图案填充背景时用SRCAND 和 SRCPAINT 是这套方案的地基。XOR 类和取反类 ROP 适合做调试时的临时观察比如想确认掩码和图的位置是否对齐可以单独用一次 SRCCOPY 把彩图拖出来看。不要试图用 SRCINVERT 替换 SRCPAINT效果完全不同。ROP 数值在 MSDN 里有完整 256 项实际常用的就这几个复制粘贴比手工记全值更稳。4.3 位深不一致时会发生什么掩码法的正确性依赖“黑就是 0、白就是全 1”这一前提。当目标 DC 是 24bpp 时没有任何问题如果目标 DC 是 8bpp 调色板模式GDI 会把掩码的白色映射到调色板里的纯白项黑色映射到纯黑项逻辑依然成立。最容易出问题的是 16bpp也就是 RGB565白色 0xFFFF 参与 AND 不会改变目标但彩色图的颜色精度会被截断边缘可能出现色阶断层。写代码前用 GetDeviceCaps(hdcDest, BITSPIXEL) 查一下目标位深低于 16bpp 的老机器最好走软件合成而不是依赖 GDI 转换。另外掩码位图用 24bpp 最直观不要用 32bpp 带 alpha 的位图做掩码因为 ROP 是逐位运算alpha 字节会把结果搅乱。4.4 什么时候别硬用 BitBlt半透明素材走 AlphaBlend掩码法的透明是 1 比特透明只有透明和不透明两个值。如果素材边缘有抗锯齿、有半透明阴影或发光效果二值化掩码会造成白边、颗粒感和锯齿。此时该用 AlphaBlend它按源像素的 alpha 通道做线性混合BLENDFUNCTION bf {0}; bf.BlendOp AC_SRC_OVER; bf.BlendFlags 0; bf.SourceConstantAlpha 255; // 全局不透明度 bf.AlphaFormat AC_SRC_ALPHA; // 逐像素 alpha AlphaBlend(dstDC, x, y, w, h, srcDC, 0, 0, w, h, bf);AlphaBlend 要求源 DC 的位图带 alpha 通道通常是 32bpp 的 DIB。这里有个很多人不知道的组合先用 BitBlt 的 SRCAND 挖出形状区域再用 AlphaBlend 贴一张带 alpha 的半透明素材这样既能抠出不规则形状又能保留素材内部的半透明细节。这是 BitBlt 掩码法向新需求的自然延伸也是老项目中从“经典透明”升级到“画质透明”的最短路径。5. 常见问题与排查从黑方块到白边的定位方法5.1 贴出来是一整个黑色矩形现象图标位置变成一块黑背景和图形全丢了。原因有两个第一是彩色位图的背景没有压成纯黑第二是透明键检测失败导致掩码里本该属于背景的区域被当成了图形区。解决先用 SRCCOPY 把彩色图单独拖到屏幕空白处看如果显示出来背景是黑的说明问题出在掩码生成如果背景还是原来的洋红就是透明键没匹配上。排查时把阈值条件放宽到 32 并检查素材格式有时候 PNG 解码后带 gamma 修正洋红色的 RGB 数值已经偏移了。5.2 图形边缘有一圈白边现象图标轮廓外围发白像是套了个模糊的白色描边。原因掩码是严格二值化的而彩色图边缘存在半透明的抗锯齿像素。这些像素在掩码里被划到背景区导致彩图边缘没被 OR 上去露出了目标区域里挖洞后本应被覆盖的残余色。解决生成掩码时对图形区域做 1 像素收缩也就是把紧邻背景的图形像素强制当作背景处理。更省事的办法是让美术裁掉边缘残留或者把素材缩到目标尺寸后再生成掩码避免放大时边缘被插值污染。5.3 图形变成镂空背景颜色滞留现象图形区域是空的能看到原来的背景而本应透明的区域反而被不透明颜色盖住。原因掩码颜色约定写反了。有人把图形区填成了白色、背景区填成了黑色结果第一步 AND 把目标背景清空第二步 OR 又把彩色图的背景带了上来整个逻辑颠倒了。解决检查掩码生成循环确认图形区填 0、背景区填 255。不需要改 BitBlt 顺序直接翻转掩码赋值就行如果素材是别人给的把掩码位图用 SRCCOPY 单独拉出来看图形轮廓必须是黑的背景必须是白的。5.4 图形内部出现被挖掉的杂点现象图案中间有些碎块变成透明露出背景。原因透明键匹配阈值过宽图形内部某些像素的色距刚好落在阈值内被误判成了背景。最典型的是暗红色和洋红在色距上分不开。解决改用基于 RGB 距离的阈值而不是三通道分别判断并把这个阈值参数化如果图形内部确实存在接近透明的颜色就放弃颜色键直接从带 alpha 通道的 PNG 素材生成掩码。这个方法推荐优先尝试因为 alpha 通道是显式的不存在误判。5.5 拖动控件时画面闪烁现象窗口或自绘控件刷新时图标区域一闪一闪像是有东西先被擦掉再画回来。原因两步 BitBlt 之间系统做了背景擦除或者直接在 WM_PAINT 里对屏幕 DC 操作导致绘制中间状态暴露出来。解决拦截 WM_ERASEBKGND 并返回 TRUE再把 BitBlt 的目标改成一个离屏内存 DC。先在内存 DC 上完成背景拷贝和两次透明绘制最后用一次 SRCCOPY 把完整结果搬到屏幕。一整批老工程的自绘控件都有这个毛病闪烁不是 BitBlt 慢而是你让用户看到了绘制过程。6. 更进一步把透明绘制放进内存渲染管线6.1 离屏合成解决闪烁与 DC 复用我一般会在程序里维护一个常驻的离屏 DC不随每次绘制创建销毁。窗口尺寸变化时重建一次然后把所有图标、文字、背景都画到这个离屏 DC 上最后统一拷贝到窗口。透明图标的绘制就嵌在这个管线里先拷贝背景再调 DrawMaskedBitmap完成后再整块输出。这样每次刷新只有一次真实 BitBlt不会有中间态暴露。实际测试时同一个界面从直接绘制改成离屏合成后GDI 对象创建次数从每帧十几个降到了几乎为零闪烁问题也消失了。void Render(HWND hWnd, HDC windowDC) { HDC mem GetMemDC(); // 常驻的内存 DC // 拷贝背景、绘制文字、绘制透明图标 DrawMaskedBitmap(mem, 100, 80, 40, 40, colorBmp_, maskBmp_); // 最后整体输出 BitBlt(windowDC, 0, 0, winW_, winH_, mem, 0, 0, SRCCOPY); }6.2 处理半透明边缘与高 DPI对于有半透明边缘的素材把第 4.4 节的组合固化到管线里先 SRCAND 挖洞再用 AlphaBlend 贴带 alpha 的位图可以做到形状硬朗、边缘平滑。至于高 DPI老 GDI 程序默认会被系统按 96 DPI 缩放粘贴位图图像会发糊需要在进程启动时调用 SetProcessDpiAwarenessContext 声明 Per-Monitor DPI Aware或者写一份 DPI Awareness manifest。这两件事是掩码法方案能走完最后一公里的关键。我个人的习惯是新写的代码优先考虑 GDI 或 Direct2D但维护老项目时BitBlt 掩码法依然是零依赖、可预测、最容易解释的透明方案。拿到任何打包好的源码先不做任何修改把彩色图和掩码图用 SRCCOPY 分别单独渲染出来看一眼确认黑白约定和素材规格一致后再动逻辑。这套方法我用了很多年踩过透明键误判的坑也改过白边和闪烁的老问题希望帮到你。本文还有配套的精品资源点击获取