简介:面向Windows开发者的AlphaBlend半透明绘制示例工程,由开发者u010918911整理并提供完整可编译运行的C++项目,适合有C++基础、希望掌握图形界面半透明绘制的读者;工程围绕Windows GDI中的Alpha混合技术展开,演示如何借助32位位图Alpha通道,将源图与目标设备上下文平滑混合,并适用于游戏开发、GUI界面设计、图像处理软件等实际场景。压缩包内共15个文件,约652KB,包含C++源文件与头文件、多张bmp位图素材、Visual Studio工程文件与资源脚本、已编译exe、README说明文档及license许可,整体目录结构清晰,代码量适中,适合作为入门与进阶之间的练习。目前已有189人学习/下载;代码详细展示了BLENDFUNCTION结构体字段、AC_SRC_OVER混合模式、sourceConstantAlpha与alphaFormat的含意,以及AlphaBlend坐标参数的传参方式;附带可执行程序便于边运行边观察半透明效果,调整透明度参数能直观看到混合变化。对于希望在游戏界面或GUI设计中实现自然融合效果的开发者,这份工程可省去环境配置与基础排错,直接基于示例扩展自己的绘制逻辑。
1. 把AlphaBlend用明白:Windows上位图半透明绘制的关键API
做桌面客户端时,你需要让一个图标、浮层或者提示条以半透明方式叠在界面上,这基本绕不开AlphaBlend。它是GDI里唯一一个不依赖GPU就能做批量像素混合的API,把源位图的Alpha通道和目标DC逐像素混合,再把结果写回目标。相比GDI+的高层封装,AlphaBlend更底层、更快,也更容易控制"全局半透明"和"逐像素半透明"两种模式。本项目是一份可以直接编译的最小工程AlphaBlend_Test,源码里包含一个完整的调用链,适合做GUI自绘、图像处理工具以及还在维护Win32老项目的开发者。
2. 先搞懂混合机制:Alpha通道、BGRA布局与预乘黑边的由来
2.1 从内存布局说起:32位位图里那四个字节的排列
Alpha混合的前提是源位图必须带Alpha通道,在Windows上这意味着位图格式必须是32位DIB(Device Independent Bitmap)。一个像素在内存里占4个字节,但顺序可能和你直觉相反——它按B、G、R、A排列,而不是R、G、B、A。
typedef struct _PIXEL32 { BYTE b; // 蓝色分量 0-255 BYTE g; // 绿色分量 0-255 BYTE r; // 红色分量 0-255 BYTE a; // Alpha 透明度 0-255 } PIXEL32;- 0代表完全透明,255代表完全不透明,中间的数值决定混合比例。
- GDI内部处理位图扫描线时,默认第一个字节是蓝色,所以代码里读像素时要把b放到结构体最前面。
- 从文件加载的普通BMP即使被读成32位,Alpha通道也可能全是0,这一点后面避坑章会展开。
这个结构体不是必须用的,你可以直接拿指针按字节偏移访问:pPixels[y * width + x],前面4个字节依次是b、g、r、a。排布顺序记不住没关系,出黑边时第一个排查点就是它。
2.2 BLENDFUNCTION结构体:五个字段其实只动三个
AlphaBlend的最后一个参数是BLENDFUNCTION,绝大多数调用场景只需要设置三个字段:
BLENDFUNCTION bf = {0}; bf.BlendOp = AC_SRC_OVER; // 0x00,标准源覆盖目标 bf.BlendFlags = 0; // 保留字段,必须为0 bf.SourceConstantAlpha = 255; // 全局透明度,0-255 bf.AlphaFormat = AC_SRC_ALPHA; // 0x01,启用源位图的逐像素AlphaBlendOp目前只接受AC_SRC_OVER,表示源覆盖目标,不存在"目标覆盖源"这种操作。SourceConstantAlpha是一个全局乘数,给255就是不额外调整,给128则是整体半透明。AlphaFormat填AC_SRC_ALPHA时,每个像素的Alpha值和SourceConstantAlpha相乘,得到最终透明度;填0时,源位图的Alpha通道被忽略,只认全局值。
这套参数组合要区分清楚:想做整个图统一半透明就AlphaFormat = 0;想保留PNG里每个像素自己的透明度就AlphaFormat = AC_SRC_ALPHA。两个都填是"逐像素透明度再整体乘一遍",最常见的应用是让一个带透明边缘的图标整体再变淡一些。
2.3 预乘Alpha:黑色边缘到底从哪来
很多人第一次用AlphaBlend,画出来的图边缘带一圈黑边或暗色光晕,这是最经典的翻车现场。原因在于AlphaBlend内部按"预乘Alpha"(Premultiplied Alpha)计算,而普通位图编辑器保存的是非预乘数据。
预乘的概念是:每个像素的RGB在存储前先乘以自己的Alpha值,即R' = R * A / 255。混合时系统直接用预乘后的RGB做线性插值,不再乘法。对非预乘数据,正确的还原公式是R = R' * 255 / A。如果你的源位图直接来自PNG解码,RGB保存的是原始颜色,没做预乘,那么边缘半透明区域的颜色值就会被AlphaBlend当作"已经乘过Alpha"的RGB去混合,结果混出来偏黑。
解决方式是在调用AlphaBlend之前,对每个像素做一次预乘转换:
// 把非预乘的像素转成预乘格式 PIXEL32* p = (PIXEL32*)pBits; for (int i = 0; i < width * height; i++) { if (p[i].a == 0) { p[i].r = p[i].g = p[i].b = 0; // 全透明区域RGB清零,防止污染目标背景 } else { p[i].r = (BYTE)((p[i].r * p[i].a + 127) / 255); p[i].g = (BYTE)((p[i].g * p[i].a + 127) / 255); p[i].b = (BYTE)((p[i].b * p[i].a + 127) / 255); } }这段代码里的+127是四舍五入,避免直接整除导致亮度损失。它在每个像素上做一次乘除,性能损耗可以接受,但如果位图很大而且每帧都做,建议只在加载时做一次。全透明区域的RGB清零尤其重要,否则AlphaBlend混合时残留下无用颜色,最终结果会带着一圈灰边。核心逻辑:GDI要求源数据是预乘的,你的数据如果不是,就必须在内存层面转成它要的样子,这不是API选项能解决的。
3. 搭建最小可运行工程:从CreateDIBSection到第一帧半透明
3.1 准备源位图:为什么LoadBitmap和LoadImage都不靠谱
不少新手第一步就踩坑,用LoadBitmap加载一个32位BMP,结果AlphaBlend后整个图全透明,或者根本看不到。原因是LoadBitmap返回的是GDI兼容位图(DDB),格式通常是16位或24位,没有Alpha通道。LoadImage加LR_CREATEDIBSECTION能得到32位DIB,但BMP文件本身如果没有写入Alpha数据,加载出来的Alpha通道全是0,画出来依然是透明的。
正确做法是直接创建DIB Section,自己往内存里填像素,或者从PNG解码出32位RGBA数据再拷贝进来。创建一个可用的32位源位图是这样写的:
BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = 128; bmi.bmiHeader.biHeight = -128; // 负数表示自上而下的扫描线 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; void* pBits = NULL; HDC hdcScreen = GetDC(NULL); HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &pBits, NULL, 0); ReleaseDC(NULL, hdcScreen);biHeight填负数,内存第一行对应图像最上面一行,处理像素时不容易犯方向错误。biBitCount必须是32,alpha通道才会被保留。pBits直接指向像素内存,填完数据之后不要释放,它归位图所有。- 从PNG解码时,常见做法是先用GDI+的
Bitmap::LockBits拿到BGRA原始数据,再memcpy到pBits。这一步要确保解码结果是预乘格式,GDI+默认不预乘,所以拷贝前要过一遍上一节的预乘循环。
3.2 屏幕上的第一帧半透明:完整调用序列
源位图准备好了,再把位图选进一个内存DC,然后调用AlphaBlend。完整流程如下:
// 在WM_PAINT里绘制 PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // 先画一个不透明的背景色,再叠半透明图 HBRUSH hBrush = CreateSolidBrush(RGB(120, 180, 220)); FillRect(hdc, &ps.rcPaint, hBrush); DeleteObject(hBrush); // 源DC:把DIB Section选进去 HDC hdcMem = CreateCompatibleDC(hdc); HBITMAP hOldBmp = (HBITMAP)SelectObject(hdcMem, hBitmap); BLENDFUNCTION bf = {0}; bf.BlendOp = AC_SRC_OVER; bf.BlendFlags = 0; bf.SourceConstantAlpha = 200; // 整体偏透明 bf.AlphaFormat = AC_SRC_ALPHA; // 逐像素Alpha生效 AlphaBlend(hdc, 30, 30, 128, 128, // 目标:位置(30,30),大小128x128 hdcMem, 0, 0, 128, 128, // 源:左上角(0,0),大小128x128 bf); SelectObject(hdcMem, hOldBmp); DeleteDC(hdcMem); EndPaint(hwnd, &ps);这段代码值得注意的细节有三个:
CreateCompatibleDC(hdc)必须在目标DC创建之后调用,否则兼容性可能出问题。AlphaBlend的源和目标矩形尺寸可以不一致,它内部会做缩放拉伸,但拉伸质量一般,放大后会有明显锯齿。BeginPaint/EndPaint之间的绘制会被系统缓存,窗口在多次WM_PAINT之间不需要手动保存结果。
3.3 链接Msimg32.lib:老工程最容易漏的一步
AlphaBlend不在标准的Gdi32里,它由Msimg32.dll导出。新建工程时如果直接调用AlphaBlend而没链接Msimg32.lib,编译会报一个" unresolved external symbol"错误。VS2013的项目可以在配置里加依赖项,也可以直接在源代码里按动态方式加载:
typedef BOOL (WINAPI *fnAlphaBlend)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); fnAlphaBlend pAlphaBlend = NULL; HMODULE hMsimg32 = LoadLibraryA("Msimg32.dll"); if (hMsimg32) { pAlphaBlend = (fnAlphaBlend)GetProcAddress(hMsimg32, "AlphaBlend"); }- 动态加载的好处是程序在Win98/2000上也不会启动失败,最多是混合功能不可用。
- 要注意函数指针的调用约定是
WINAPI,写少了在32位下会栈不平衡。 - 工程里如果用静态链接,直接在源文件前加
#pragma comment(lib, "Msimg32.lib")最省事。
4. 避坑实录:AlphaBlend常见的五个翻车点与排查清单
4.1 现象:图像边缘有一圈黑色光晕
放大或旋转带圆角、阴影的图标时,边缘发黑,看起来像脏脏的轮廓线。
原因:源位图的RGB没有预乘,半透明区域的原始颜色值在做混合时被错误地当作预乘值参与计算,导致混合结果比实际预期暗。核心是"AlphaBlend内部按预乘处理"这一点。
解决:在加载位图时,遍历全部像素做预乘转换,尤其把Alpha为0的像素RGB强制清零。转换后重画,光晕消失。
4.2 现象:整个图完全看不见,只留下一块背景色
调用AlphaBlend后什么都没画出来,或者画出来一个纯黑色矩形。
原因:常见的有三种。一是位图不是32位格式,没有Alpha通道,系统把每个像素的Alpha当成0,于是完全透明;二是AlphaFormat填了AC_SRC_ALPHA,但源位图Alpha通道数据为空;三是SourceConstantAlpha被误设成0。其中前两个占绝大多数。
解决:先用GetObject读回位图的biBitCount确认是32位;再用下面这个函数检查像素Alpha是否有非零值:
// 检查DIB Section的Alpha通道是否有内容 int CheckAlphaChannel(HBITMAP hBmp, int width, int height) { DIBSECTION ds = {0}; GetObject(hBmp, sizeof(ds), &ds); BYTE* p = (BYTE*)ds.dsBm.bmBits; int nonZeroCount = 0; for (int i = 3; i < width * height * 4; i += 4) { if (p[i] > 0) nonZeroCount++; } return nonZeroCount; }DS_BM.bmBits返回的是像素内存指针,按每像素4字节递增,取每4字节的第4个字节,就是Alpha值。如果返回值是0,说明源图根本没有透明度,需要重新准备数据,而不是继续调参数。
4.3 现象:缩放之后图像发虚、边缘布满杂色锯齿
把一个小图标用AlphaBlend放大绘制到窗口背景上,边缘出现颗粒状过渡,黑色杂点明显。
原因:AlphaBlend的拉伸本质是点采样,不做高质量双线性滤波,边缘半透明像素在放大后被插值成不正确的混合值,视觉上就是杂色。
解决:尽量不做运行时大比例缩放,在加载阶段就把位图预缩放到需要尺寸,把缩放次数控制在一次;如果必须动态缩放,可以用GDI+的DrawImage配合高质量插值模式,但那是另一套API,混用时要保持DC状态统一。你还可以限制窗口最小尺寸,避免用户把窗口拖到极小导致拉伸倍数过大。
4.4 现象:多层半透明叠加后背景越来越脏,越叠越黑
界面上有多个半透明层叠在一起,例如一个半透明提示框浮在半透明地图上,叠加区域颜色逐渐加深,像蒙了一层灰。
原因:半透明混合不可交换,必须先画不透明底层,再按从远到近的顺序画半透明层。如果先画了一个半透明层,又在其上叠另一个半透明层,两个层的RGB会在混合中互相吞噬,背景色参与计算的次数太多。
解决:调整绘制顺序,保证每次AlphaBlend的目标DC里画的是已经定稿的背景;多个半透明层需要叠加时,先在一个临时内存DC里按顺序绘制,最后一次性AlphaBlend到窗口。临时DC用32位格式创建,避免中间过程丢失精度。
4.5 现象:拖动窗口或快速刷新时闪烁,半透明区域撕裂
窗口上有大块半透明区域,拖动过程中边缘闪动,图像像被扯开一样。
原因:WM_PAINT里直接往屏幕DC绘制,每次重绘整片区域都重新混合,又没做双缓冲,导致系统先擦背景再画前景之间出现空窗。
解决:用双缓冲。先把所有内容画到一个内存DC,最后用BitBlt整个提交到窗口,避免背景擦除和前景绘制之间的时间差。只对变化的脏矩形做InvalidateRect,不要全窗口无效化。
5. 让透明度动起来:淡入淡出循环与AlphaBitmap封装
5.1 sourceConstantAlpha逐帧变化:淡入淡出的正确写法
实现渐显渐隐的常见错误是把Sleep放在主线程里阻塞窗口消息循环。正确做法是用定时器或帧时间戳驱动重绘。这里以SetTimer为例:
// 在窗口创建时启动定时器,每16毫秒触发一次 SetTimer(hwnd, IDT_FADE, 16, NULL); // 全局变量维护当前透明度 int g_alpha = 0; // 从0开始淡入 int g_dir = 4; // 每帧增加4 case WM_TIMER: { if (wParam == IDT_FADE) { g_alpha += g_dir; if (g_alpha >= 255) { g_alpha = 255; g_dir = -4; } if (g_alpha <= 0) { g_alpha = 0; g_dir = 4; } InvalidateRect(hwnd, NULL, FALSE); // 触发重绘,不清除背景 } break; } case WM_PAINT: { // 绘制时代码里把SourceConstantAlpha = g_alpha }- 定时器间隔16ms对应约60帧,这里不追求平滑到极致,够用。
InvalidateRect最后一个参数传FALSE,不要清背景,否则淡入过程中会看到底色闪烁。- 全局透明度叠加逐像素Alpha后,最终效果是PNG本身的透明度基础上再整体乘一个系数。
5.2 封装一个AlphaBitmap类:后续直接复用
把创建、预乘、绘制装进一个类里,后面做多个控件、多张图时不用重复踩坑:
class AlphaBitmap { public: HBITMAP hBitmap; int width, height; bool Create(int w, int h) { width = w; height = h; BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h; bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; HDC hdc = GetDC(NULL); hBitmap = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &pixels, NULL, 0); ReleaseDC(NULL, hdc); return hBitmap != NULL; } void Premultiply() { PIXEL32* p = (PIXEL32*)pixels; for (int i = 0; i < width * height; i++) { if (p[i].a == 0) p[i].r = p[i].g = p[i].b = 0; else { p[i].r = (BYTE)((p[i].r * p[i].a + 127) / 255); p[i].g = (BYTE)((p[i].g * p[i].a + 127) / 255); p[i].b = (BYTE)((p[i].b * p[i].a + 127) / 255); } } } void Draw(HDC hdc, int x, int y, BYTE globalAlpha) { HDC hdcMem = CreateCompatibleDC(hdc); HBITMAP hOld = (HBITMAP)SelectObject(hdcMem, hBitmap); BLENDFUNCTION bf = {AC_SRC_OVER, 0, globalAlpha, AC_SRC_ALPHA}; AlphaBlend(hdc, x, y, width, height, hdcMem, 0, 0, width, height, bf); SelectObject(hdcMem, hOld); DeleteDC(hdcMem); } void Cleanup() { if (hBitmap) DeleteObject(hBitmap); } private: void* pixels; };这个类把整条调用链收口了,实例化后只需三行:Create、Premultiply、Draw。要注意Premultiply是幂等的,重复调用不会出问题,但没必要。如果你想更新某个像素,更新后要重新做预乘,不然新像素的颜色又变成非预乘状态。
5.3 验证写法:把混合结果读回来确认不是"看上去对"
半透明效果用肉眼判断往往不准,尤其是颜色偏差在10以内人眼根本分辨不出。严谨做法是把混合后的结果拷到另一个DIB里,按坐标读像素算期望值。
// 假设背景色是 RGB(120, 180, 220), 源图某像素是 RGB(200, 50, 50), alpha=128 // 期望混合结果: // R = (200 * 128 + 120 * (255 - 128)) / 255 = 160 // G = (50 * 128 + 180 * (255 - 128)) / 255 = 123 // B = (50 * 128 + 220 * (255 - 128)) / 255 = 146 // 用BitBlt把窗口内容拷到32位DIB,再比较对应像素坐标的值,误差在1以内视为正确。判断标准是混合公式:结果 = (源色 * 源Alpha + 目标色 * (255 - 源Alpha)) / 255,前提是源色已经预乘。写一个断言函数,跑一遍比肉眼看十遍管用,后面第6章还会把这个思路再往前推一步。
6. 一个压箱底的验证技巧:把混合结果按像素读回来校准透明度
调试半透明效果最头疼的是"看不清对不对"。颜色差一点、透明度差百分之几,靠肉眼根本发现不了。我后来养成了一个习惯:凡是涉及AlphaBlend的逻辑改动,都会强制跑一遍像素回读,把混合结果和公式算出来的期望值做对比。
具体操作是先在纯黑背景和纯白背景上各画一次目标图,分别读回同一坐标的像素值。设源图那个像素的预乘颜色是C,透明度是A,黑背景混合结果B、白背景混合结果为W,那么:
A = 255 - (W - B) * 255 / (255 - 0); // 精确算出实际生效的Alpha R = (B * 255 - A * 0) / (255 - A); // 还原源图实际参与混合的颜色(预乘后)如果还原出来的A值和SourceConstantAlpha乘以像素alpha后的预期不一致,偏差超过2,就说明预乘处理或DC设置里有问题。这套方法曾在一次项目里帮我抓到一个非常隐蔽的bug:某个位图加载路径漏了预乘,透明边缘在浅色背景下看着正常,一换深色背景就发灰。用黑白背景双采样,一秒定位到是Alpha通道被当成直通值使用。
从那以后,我每次写半透明相关代码都强制走一遍回读验证的流程,宁可多写十几行校验代码,也不愿意在一个看不清的显示效果上反复试探。这个小技巧,希望帮到你。
本文还有配套的精品资源,点击获取