☰
用BitBlt绘制透明位图:掩码法原理、实现与常见坑
2026/10/22 18:38:30 网站建设 项目流程

简介:这份源码包面向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::vector<BYTE> 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 位图,要画第二帧,就传 xSrc=40, ySrc=0, w=40, h=40。注意源矩形必须完全落在源位图范围内,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 分别单独渲染出来看一眼,确认黑白约定和素材规格一致后,再动逻辑。这套方法我用了很多年,踩过透明键误判的坑,也改过白边和闪烁的老问题,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询