ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Win32透明窗口实战:游戏白板的分层渲染与性能优化

Win32透明窗口实战:游戏白板的分层渲染与性能优化

1. 从游戏画面到白板:透明窗口到底在解决什么问题

第一次接触"透明窗口"这个概念,很多人会以为是把窗口变半透明、加个毛玻璃效果。但真正做过游戏辅助工具、录屏标注、教学演示软件的人会告诉你,透明窗口的核心价值在于分层渲染——让一个窗口只负责画你想画的东西,其余区域完全"透"过去,露出底下的游戏画面或桌面内容。

这个需求在游戏场景里特别典型。比如你想做一个战术白板工具,玩家在游戏里实时画箭头、圈点位、写文字,这些标注要叠加在游戏画面上,但又不能遮挡游戏本身的视野。如果用一个普通窗口,要么全屏挡住游戏,要么只能做成一个小面板放在角落,体验很差。透明窗口就是解决这个矛盾的钥匙:窗口本身覆盖整个屏幕,但背景完全透明,只有你绘制的线条、图形、文字是可见的。

从技术角度看,Windows 下实现透明窗口有几条路:分层窗口(Layered Window)、DWM 扩展框架、以及基于WS_EX_LAYERED的多种混合模式。每条路的适用场景、性能表现、兼容性都不一样。选错了方案,轻则画面闪烁、鼠标穿透失效,重则游戏直接崩溃或者被反作弊系统盯上。

这篇文章会从零开始,把透明窗口的创建、绘制、鼠标穿透、性能优化这几个关键环节拆开讲清楚。适合有一定 C++ 和 Win32 基础、想动手做游戏标注工具或桌面白板的开发者。如果你之前没接触过 Win32 窗口编程,建议先补一下消息循环和 GDI 绘图的基础,不然有些地方会卡住。

注意:本文讨论的技术仅用于合法的教学演示、录屏标注、无障碍辅助等场景。任何绕过游戏公平机制的行为都不在讨论范围内,也不建议尝试。

2. 三条技术路线的选择逻辑与踩坑预判

在动手写代码之前,先把方案选型这件事想清楚。Windows 上做透明窗口,主流有三种做法,各自的原理和适用边界差别很大。

2.1 分层窗口加颜色键:最简单但有硬伤

颜色键(Color Key)的思路很直白:你指定一个颜色作为"透明色",窗口里所有这个颜色的像素都会变成透明。比如你设RGB(0, 0, 0)为透明色,那么窗口里所有纯黑区域都会透过去。

实现方式是给窗口加上WS_EX_LAYERED扩展样式,然后调用SetLayeredWindowAttributes:

SetLayeredWindowAttributes(hwnd, RGB(0, 0, 0), 0, LWA_COLORKEY);

这个方案的优点是简单,几行代码就能跑起来。但问题也很明显:你的绘制内容里不能出现透明色。如果你用黑色画了一条线,这条线也会变透明。对于白板工具来说,用户可能想用任何颜色画图,颜色键方案会限制调色板,体验很割裂。

另一个硬伤是边缘锯齿。颜色键是逐像素判断的,没有抗锯齿过渡,画出来的线条边缘会很生硬。如果你对画面质量有要求,这个方案基本可以放弃。

2.2 分层窗口加 Alpha 混合:效果好但性能吃紧

Alpha 混合方案用UpdateLayeredWindow来更新窗口内容,支持逐像素的透明度。你可以画出半透明的笔迹、带羽化边缘的图形,视觉效果最好。

核心流程是这样的:创建一个 32 位 DIB(设备无关位图),在内存里绘制好内容,然后调用UpdateLayeredWindow把位图推送到窗口。每个像素的 Alpha 通道决定透明度,0 是全透明,255 是不透明。

BLENDFUNCTION blend = { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA }; UpdateLayeredWindow(hwnd, hdcScreen, &ptPos, &size, hdcMem, &ptSrc, 0, &blend, ULW_ALPHA);

这个方案的问题在于每次更新都要重绘整个窗口。如果你的白板工具需要实时跟随鼠标画线,每一帧都要重新生成位图并推送,CPU 和内存带宽消耗会很大。在 4K 分辨率下,一张 32 位位图就是 3840×2160×4 字节,约 33MB,每秒推送 60 次就是 2GB/s 的数据量,普通机器扛不住。

2.3 DWM 扩展框架:现代方案但有兼容门槛

Windows Vista 之后引入的 DWM(桌面窗口管理器)提供了一套扩展框架,允许窗口使用系统合成的透明效果。通过DwmExtendFrameIntoClientArea可以把窗口边框扩展到整个客户区,配合DwmEnableBlurBehindWindow实现毛玻璃或全透明效果。

这个方案的优势是由系统合成器处理透明,性能比UpdateLayeredWindow好很多,而且支持硬件加速。但它的透明是"整体性"的,你很难做到"只有我画的线条可见、其余全透明"这种精细控制。它更适合做半透明面板、毛玻璃侧边栏这类效果,不太适合游戏白板这种需要精确控制每个像素透明度的场景。

2.4 选型结论与混合策略

综合来看,游戏白板工具的最佳方案是分层窗口 + Alpha 混合,但要配合脏矩形更新来优化性能。也就是说,不要每次都重绘整个屏幕,只更新发生变化的区域。

具体做法是维护一个"脏区域"列表,鼠标画线时只把线条经过的矩形区域标记为脏,然后只对这些区域重新生成位图并推送。这样在 1080p 下,一条线的影响区域可能只有几百×几百像素,数据量降到原来的百分之一不到。

方案透明度控制性能抗锯齿适用场景
颜色键单色透明高无简单叠加层
Alpha 混合逐像素低(全量更新)支持精细白板
Alpha 混合 + 脏矩形逐像素中高支持游戏白板首选
DWM 扩展整体透明高支持半透明面板

提示:如果你的目标机器是 Windows 7 及以上,分层窗口方案都能跑。但要注意,某些全屏独占模式的游戏会绕过 DWM,这时候透明窗口可能无法正常叠加,需要把游戏改成无边框窗口模式。

3. 透明窗口的创建流程与关键参数拆解

选好方案之后,接下来把窗口创建这一步做扎实。很多新手在这一步踩坑,是因为对扩展样式和窗口风格的理解不到位。

3.1 窗口类注册与扩展样式设置

创建透明窗口的第一步是注册窗口类。这里的关键是WNDCLASSEX结构体里的hbrBackground要设为nullptr,否则系统会用默认画刷填充背景,透明效果就没了。

WNDCLASSEX wc = { sizeof(WNDCLASSEX) }; wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WndProc; wc.hInstance = hInstance; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = nullptr; // 关键:不设背景画刷 wc.lpszClassName = L"TransparentBoard"; RegisterClassEx(&wc);

然后在CreateWindowEx里加上WS_EX_LAYERED和WS_EX_TRANSPARENT两个扩展样式:

HWND hwnd = CreateWindowEx( WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST, L"TransparentBoard", L"白板", WS_POPUP, 0, 0, screenWidth, screenHeight, nullptr, nullptr, hInstance, nullptr );

WS_EX_LAYERED是分层窗口的必需样式,没有它UpdateLayeredWindow会失败。WS_EX_TRANSPARENT让鼠标事件穿透到下层窗口,这样用户点击白板区域时,点击会落到游戏上而不是白板上。WS_EX_TOPMOST保证白板始终在游戏画面之上。

3.2 鼠标穿透的两种模式与切换逻辑

鼠标穿透这件事有个矛盾:用户需要点击白板上的工具栏按钮来切换画笔颜色、粗细,但又需要点击穿透到游戏里操作。如果整个窗口都穿透,工具栏就点不了;如果都不穿透,游戏就没法玩。

解决方案是动态切换穿透状态。默认情况下窗口穿透,鼠标事件落到游戏;当用户按下某个快捷键(比如Alt+B)进入"绘制模式"时,取消穿透,鼠标事件由白板接收;再按一次退出绘制模式,恢复穿透。

切换穿透状态用SetWindowLongPtr修改扩展样式:

// 进入绘制模式:取消穿透 LONG_PTR exStyle = GetWindowLongPtr(hwnd, GWL_EXSTYLE); SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle & ~WS_EX_TRANSPARENT); // 退出绘制模式:恢复穿透 SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle | WS_EX_TRANSPARENT);

这里有个坑:修改扩展样式后,需要调用SetWindowPos并带上SWP_FRAMECHANGED标志,否则样式变更不会立即生效。

SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);

3.3 全屏覆盖与多显示器适配

游戏白板通常需要覆盖整个屏幕。获取屏幕尺寸用GetSystemMetrics:

int screenW = GetSystemMetrics(SM_CXSCREEN); int screenH = GetSystemMetrics(SM_CYSCREEN);

但如果你有多显示器,SM_CXSCREEN只返回主显示器的尺寸。要覆盖所有显示器,需要用EnumDisplayMonitors枚举每个显示器,然后创建一个覆盖虚拟桌面的大窗口:

int vLeft = GetSystemMetrics(SM_XVIRTUALSCREEN); int vTop = GetSystemMetrics(SM_YVIRTUALSCREEN); int vWidth = GetSystemMetrics(SM_CXVIRTUALSCREEN); int vHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);

窗口位置设为(vLeft, vTop),尺寸设为(vWidth, vHeight),这样就能覆盖所有显示器。不过要注意,多显示器下 DPI 缩放可能不一致,绘制坐标需要做相应转换,否则笔迹位置会偏移。

注意:如果你的游戏是全屏独占模式,透明窗口可能无法叠加。解决办法是把游戏改成"无边框窗口"或"窗口化全屏"模式。大多数现代游戏都支持这个选项,在画面设置里能找到。

4. 双缓冲绘制与脏矩形更新的性能优化

窗口创建好之后,核心工作就是绘制。游戏白板的绘制有两个特点:一是内容会频繁变化(用户一直在画),二是大部分区域其实没有变化(只有笔迹经过的地方需要更新)。针对这两个特点,双缓冲和脏矩形更新是必须做的优化。

4.1 内存 DC 与 32 位 DIB 的创建

分层窗口的绘制不能直接画在窗口 DC 上,而是要先画在内存 DC 里,再通过UpdateLayeredWindow推送。内存 DC 需要绑定一张 32 位 DIB(设备无关位图),因为只有 32 位位图才有 Alpha 通道。

BITMAPINFO bmi = {}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = width; bmi.bmiHeader.biHeight = -height; // 负值表示自上而下 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; void* pBits = nullptr; HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &pBits, nullptr, 0); HDC hdcMem = CreateCompatibleDC(hdcScreen); SelectObject(hdcMem, hBitmap);

biHeight设为负值是个小技巧,表示位图数据自上而下存储,这样坐标原点在左上角,和窗口坐标一致,省去了翻转的麻烦。

创建好 DIB 之后,每次绘制前要先把内存 DC 清空为全透明。注意不能用FillRect加白色画刷,那样会把 Alpha 通道也填成不透明。正确做法是手动把像素数据清零:

memset(pBits, 0, width * height * 4);

这样每个像素的 RGBA 都是 0,即完全透明。

4.2 脏矩形追踪与局部更新

全量更新的性能问题前面已经分析过,这里说具体怎么做脏矩形追踪。

维护一个std::vector<RECT>记录需要更新的区域。鼠标移动时,把上一个点到当前点构成的线段包围盒加入列表:

RECT dirtyRect; dirtyRect.left = min(lastX, curX) - brushRadius; dirtyRect.top = min(lastY, curY) - brushRadius; dirtyRect.right = max(lastX, curX) + brushRadius; dirtyRect.bottom = max(lastY, curY) + brushRadius; dirtyRects.push_back(dirtyRect);

然后每帧(比如用SetTimer或消息循环空闲时)合并所有脏矩形,只对这些区域重新绘制和推送。合并时可以用简单的并集操作,或者用更高效的区域合并算法。

推送时UpdateLayeredWindow的prcDirty参数可以指定脏区域:

UpdateLayeredWindow(hwnd, hdcScreen, &ptPos, &size, hdcMem, &ptSrc, 0, &blend, ULW_ALPHA);

不过实测下来,UpdateLayeredWindow对脏区域的支持并不完美,有时候还是会全量合成。如果性能还是不够,可以考虑用SetDIBitsToDevice配合WS_EX_LAYERED的LWA_ALPHA模式,但那样就失去了逐像素透明度控制。

4.3 笔迹平滑与抗锯齿处理

白板工具的笔迹质量直接影响使用体验。如果直接用LineTo画线,线条会有明显锯齿。解决办法是用 GDI+ 的Graphics类来绘制,它支持抗锯齿:

Gdiplus::Graphics graphics(hdcMem); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); Gdiplus::Pen pen(color, width); graphics.DrawLine(&pen, lastX, lastY, curX, curY);

但 GDI+ 和 Alpha 通道的配合需要小心。GDI+ 绘制时默认会把目标像素的 Alpha 通道也覆盖掉,导致透明区域变成不透明。解决办法是在绘制前设置混合模式:

graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);

这样 GDI+ 会把新绘制的像素和已有像素做 Alpha 混合,而不是直接覆盖。

另一个技巧是用GraphicsPath把连续的鼠标点连成平滑曲线,而不是逐段画直线。可以用贝塞尔曲线拟合:

Gdiplus::GraphicsPath path; path.AddCurve(points, count); graphics.DrawPath(&pen, &path);

这样画出来的线条更自然,不会有折线感。

提示:GDI+ 的初始化需要在程序启动时调用GdiplusStartup,退出时调用GdiplusShutdown。别忘了在窗口销毁时清理 GDI+ 资源,否则会有内存泄漏。

5. 消息循环中的穿透判定与快捷键响应

透明窗口的消息处理有几个特殊之处:鼠标穿透状态下收不到鼠标消息,但键盘消息还是能收到的;绘制模式下要正常处理鼠标消息;快捷键的注册和响应需要独立处理。

5.1 全局快捷键注册与冲突处理

因为窗口可能处于穿透状态,用普通的WM_KEYDOWN收不到按键。需要用RegisterHotKey注册全局快捷键:

RegisterHotKey(hwnd, HOTKEY_TOGGLE_DRAW, MOD_ALT, 'B');

然后在消息循环里处理WM_HOTKEY:

case WM_HOTKEY: if (wParam == HOTKEY_TOGGLE_DRAW) { ToggleDrawMode(); } break;

这里有个常见坑:RegisterHotKey可能失败,原因是快捷键被其他程序占用了。比如Alt+B可能被某个浏览器或输入法占用。解决办法是准备几个备选快捷键,注册失败时依次尝试:

if (!RegisterHotKey(hwnd, ID, MOD_ALT, 'B')) { if (!RegisterHotKey(hwnd, ID, MOD_CONTROL | MOD_SHIFT, 'B')) { // 提示用户快捷键被占用 } }

程序退出时记得UnregisterHotKey,否则快捷键会一直被占用,影响其他程序。

5.2 绘制模式下的鼠标消息处理

进入绘制模式后,窗口取消穿透,开始接收鼠标消息。核心是处理WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP三个消息:

case WM_LBUTTONDOWN: isDrawing = true; lastX = GET_X_LPARAM(lParam); lastY = GET_Y_LPARAM(lParam); break; case WM_MOUSEMOVE: if (isDrawing) { int curX = GET_X_LPARAM(lParam); int curY = GET_Y_LPARAM(lParam); DrawLine(lastX, lastY, curX, curY); lastX = curX; lastY = curY; } break; case WM_LBUTTONUP: isDrawing = false; break;

注意GET_X_LPARAM和GET_Y_LPARAM宏需要包含windowsx.h。这两个宏能正确处理负坐标(多显示器场景下坐标可能为负)。

5.3 橡皮擦与撤销功能的实现思路

白板工具少不了橡皮擦和撤销。橡皮擦的实现有两种思路:一种是"擦除"模式,把经过的区域 Alpha 设为 0;另一种是"背景色"模式,用白色或透明色覆盖。

推荐用第一种,因为白板本身是透明的,用白色覆盖会留下白斑。擦除模式下,把脏矩形区域的像素 Alpha 清零即可:

for (int y = rect.top; y < rect.bottom; y++) { for (int x = rect.left; x < rect.right; x++) { int offset = (y * width + x) * 4; ((BYTE*)pBits)[offset + 3] = 0; // Alpha 设为 0 } }

撤销功能需要维护一个操作栈。每次画线或擦除前,把当前位图状态保存一份快照。撤销时弹出栈顶快照恢复。快照可以用CreateDIBSection创建新的位图,把当前像素数据拷贝过去。内存占用会比较大,可以限制栈的深度,比如只保留最近 20 步。

6. 实测中的性能瓶颈与兼容性问题

代码跑起来之后,真正的挑战才开始。下面是我在实际测试中遇到的一些典型问题和解决思路。

6.1 高分辨率下的内存带宽瓶颈

在 4K 显示器上测试时,全量更新模式下 CPU 占用率直接飙到 30% 以上,帧率掉到 20 多。用性能分析工具一看,瓶颈在UpdateLayeredWindow的内存拷贝上。

解决办法前面提过,用脏矩形更新。但脏矩形的合并策略有讲究。如果每次鼠标移动都推送一次,即使区域很小,调用开销也不低。更好的做法是批量合并:用一个定时器每 16ms(约 60fps)触发一次推送,把这段时间内积累的所有脏矩形合并成一个大矩形或几个不相交的矩形,一次性推送。

实测下来,1080p 下画线时 CPU 占用从 15% 降到 3% 左右,4K 下从 30% 降到 8% 左右,效果很明显。

6.2 与游戏全屏模式的兼容性

前面提过,全屏独占模式下透明窗口无法叠加。但有些游戏即使设成无边框窗口,透明窗口还是会被游戏画面盖住。原因是游戏的渲染层级比普通窗口高。

解决办法是给窗口加上WS_EX_TOPMOST样式,并且定期调用SetWindowPos把窗口提到最前:

SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);

但这样会有一个副作用:窗口会抢焦点,导致游戏失去焦点后可能自动暂停。解决办法是用SWP_NOACTIVATE标志,让窗口置顶但不激活。

另一个兼容性问题是某些游戏使用 DirectX 独占全屏,会绕过 DWM 合成。这种情况下透明窗口基本无解,只能建议用户把游戏改成窗口模式。

6.3 输入法状态下的绘制异常

在中文输入法激活状态下,鼠标消息可能会被输入法窗口拦截,导致画线断断续续。解决办法是在进入绘制模式时,主动把输入法切换到英文状态:

HKL hkl = LoadKeyboardLayout(L"00000409", KLF_ACTIVATE); ActivateKeyboardLayout(hkl, 0);

或者用ImmAssociateContext把输入法上下文从窗口上解除关联:

ImmAssociateContext(hwnd, nullptr);

这样输入法就不会干扰鼠标消息了。退出绘制模式时再恢复。

6.4 多显示器 DPI 缩放导致的坐标偏移

在多显示器且缩放比例不同的环境下,鼠标坐标和窗口坐标可能不一致。比如主显示器 150% 缩放,副显示器 100% 缩放,鼠标从主显示器移到副显示器时,坐标会跳变。

解决办法是在程序启动时声明 DPI 感知:

SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

然后在绘制时用PhysicalToLogicalPoint和LogicalToPhysicalPoint做坐标转换。这个坑比较隐蔽,单显示器下不会遇到,但多显示器用户不少,值得提前处理。

问题现象根本原因解决方案
高分辨率下卡顿全量更新带宽不足脏矩形 + 批量推送
窗口被游戏盖住渲染层级低WS_EX_TOPMOST + 定期置顶
画线断断续续输入法拦截消息解除输入法关联
多显示器坐标偏移DPI 缩放不一致声明 DPI 感知 + 坐标转换
快捷键无效被其他程序占用备选快捷键 + 失败提示

7. 几个容易被忽略的细节与个人经验

最后分享几个在实际开发中踩过的坑,都是文档里不会写、但实际会遇到的问题。

第一个是窗口销毁时的资源清理顺序。分层窗口的位图、内存 DC、GDI+ 对象都要在窗口销毁前释放。如果顺序不对,可能会出现内存泄漏或者程序退出时崩溃。正确的顺序是:先GdiplusShutdown,再DeleteObject位图,再DeleteDC内存 DC,最后DestroyWindow。

第二个是UpdateLayeredWindow的调用时机。这个函数必须在窗口可见之后调用,否则会失败。如果窗口还没显示就调用,返回FALSE,GetLastError返回ERROR_INVALID_PARAMETER。解决办法是在WM_CREATE里先ShowWindow,再初始化位图。

第三个是笔迹的存储结构。如果只把笔迹画在位图上,撤销和重做会很麻烦。更好的做法是把笔迹存成矢量数据(点序列 + 颜色 + 粗细),每次重绘时根据矢量数据重新绘制。这样撤销只需要删除最后一个笔迹对象,重绘整个画布即可。虽然重绘开销大一些,但逻辑清晰,也方便做导出功能。

第四个是性能监控。建议在开发阶段加一个简单的帧率显示,用QueryPerformanceCounter统计每帧的绘制和推送耗时。这样能快速定位性能瓶颈是在绘制阶段还是推送阶段。

第五个是测试环境的多样性。至少要在 Windows 10 和 Windows 11 上各测一遍,因为 DWM 的行为在两个版本上有细微差别。另外,集成显卡和独立显卡的表现也可能不同,有条件的话都测一下。

这个白板工具后续还可以扩展不少功能,比如加入图形识别(把随手画的圆自动修正成正圆)、多图层管理、笔迹回放等。核心的透明窗口和绘制框架搭好之后,这些扩展都是在这个基础上做加法。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进