通用GUI编程技术------图形渲染实战(二十四)------GDI Region与裁切:不规则窗口与可视化控制
仓库已经开源!喜欢的话点个⭐!包含Win32的目前已完成教程,力争做一个完备的GUI教程!
欢迎各位大佬前来参观:https://github.com/Charliechen114514/anatomy_gui
上一篇文章我们搞定了 GDI 双缓冲技术,用内存 DC 一次性拷贝解决了绘制闪烁的老大难问题。双缓冲让画面变得流畅了,但我们的窗口形状始终被限制在矩形的世界里------标准的WS_OVERLAPPEDWINDOW样式给出的永远是一个方方正正的盒子。如果我们想要一个圆角窗口呢?或者一个星形窗口?甚至是一个完全自定义形状的悬浮控件?这就涉及到了 Windows GDI 中一个极其重要但经常被忽视的概念:Region(区域)。
Region 是 GDI 中描述"一块形状"的基础数据结构。它可以描述矩形、椭圆、多边形,甚至通过布尔运算组合出任意复杂的形状。Windows 系统在两个层面使用 Region:一是作为绘图时的裁切区域(Clipping Region),控制哪些像素可以画上去;二是作为窗口的可视区域(Window Region),直接决定窗口在屏幕上呈现什么形状。今天我们就要把 Region 的这两种用法彻底搞明白,最后实现两个实战案例------一个圆角窗口和一个星形异形窗口。
环境说明
在开始之前,先确认一下我们这次的开发环境:
- 操作系统: Windows 11 Pro 10.0.26200
- 编译器: MSVC (Visual Studio 2022)
- 目标平台: Win32 API 原生开发
- 图形库: GDI (Graphics Device Interface)
- 链接库 : 除了常规的
gdi32.lib、user32.lib之外,本篇不需要额外的库
这次不需要像后续文章那样链接 msimg32.lib,因为 Region 相关的 API 全都在 gdi32.lib 和 user32.lib 里,属于最基础的 GDI 功能。
Region 的本质:GDI 中的"形状描述器"
在深入代码之前,我们得先搞清楚 Region 到底是什么东西。从底层机制上说,Region 是一个 GDI 对象,句柄类型是 HRGN,它内部维护的是一系列扫描线(scanline)的集合。你可以把它理解为一张"蒙版"------在这张蒙版覆盖的范围内是"有东西"的,范围之外是"空白"的。
Region 的三种基本形态
GDI 提供了多个创建 Region 的函数,但归根结底,Region 只有三种基本形态。
最简单的是矩形 Region,用 CreateRectRgn 创建。它只需要两个坐标点就能定义一个矩形的区域。如果你想用 RECT 结构体来创建,还有一个等价的 CreateRectRgnIndirect 函数。
然后是椭圆 Region,用 CreateEllipticRgn 创建。注意这里说的是"椭圆",虽然你传进去的参数是一个矩形的左上角和右下角坐标,但 GDI 会在那个矩形内接一个椭圆来创建区域。
最后是圆角矩形 Region,用 CreateRoundRectRgn 创建。它的函数签名是这样的:
cpp
HRGN CreateRoundRectRgn(
int x1, // 左上角 x
int y1, // 左上角 y
int x2, // 右下角 x
int y2, // 右下角 y
int w, // 圆角椭圆的宽度
int h // 圆角椭圆的高度
);
前四个参数定义了一个矩形边界,w 和 h 定义了圆角处那个椭圆的宽和高。圆角就是用椭圆的四分之一弧线来拟合的------w 越大圆角越"扁平",h 越大圆角越"圆滑"。当 w 和 h 等于矩形宽高时,整个 Region 就退化成了一个椭圆。
还有一种更灵活的创建方式是多边形 Region。CreatePolygonRgn 可以用一组点来创建一个任意多边形的区域,而 CreatePolyPolygonRgn 甚至可以一次创建多个多边形组成的区域。这两个函数的最后一个参数 iMode 指定填充模式,可选 ALTERNATE(交替填充)或 WINDING(环绕填充),对于凸多边形两者效果一样,但对于有交叉的复杂多边形会有区别。
Region 的布尔运算:CombineRgn
单个基本形态有时候不够用。比如我们想要一个"两个圆的并集",或者一个"矩形减去一个圆"------这就需要布尔运算了。GDI 提供了 CombineRgn 函数来完成这件事:
cpp
int CombineRgn(
HRGN hrgnDest, // 结果 Region
HRGN hrgnSrc1, // 源 Region 1
HRGN hrgnSrc2, // 源 Region 2
int fnCombineMode // 组合模式
);
fnCombineMode 支持以下模式:
RGN_AND:两个 Region 的交集------只保留两者重叠的部分RGN_OR:两个 Region 的并集------两个 Region 覆盖的所有区域RGN_XOR:两个 Region 的异或------并集减去交集RGN_DIFF:Region1 减去 Region2------只保留在 Region1 中但不在 Region2 中的部分RGN_COPY:只复制 Region1------Region2 被忽略
这个函数的返回值也有讲究:SIMPLEREGION 表示结果是一个简单区域(没有重叠边框),COMPLEXREGION 表示结果是一个复杂区域,NULLREGION 表示结果为空,ERROR 表示出错了。在调试 Region 相关代码时,检查这个返回值是个好习惯。
有了布尔运算,我们可以把简单的 Region 组合出极其复杂的形状。比如要做一个"甜甜圈"形状的窗口,可以用一个大圆 Region 和一个小圆 Region 做 RGN_DIFF 运算。
裁切区域(Clipping Region):控制绘制范围
讲完了 Region 的创建和组合,我们先来看看 Region 在绘图层面的第一个用途------裁切区域。
什么是裁切区域
裁切区域是 DC(设备上下文)的一个属性,它决定了后续绘制操作"允许画到哪些地方"。当你在 DC 上设置了裁切区域后,所有落在裁切区域之外的绘制操作都会被系统自动丢弃------不是画成透明,而是完全不画。这比你自己去判断像素是否在范围内要高效得多,因为 GDI 在底层做了硬件级别的优化。
你可以把裁切区域理解为一张"镂空模板"------你往上面涂颜料,只有模板镂空的地方才能涂上去,其他地方全被挡住了。
SelectClipRgn:设置裁切区域
设置裁切区域最直接的方法是调用 SelectClipRgn:
cpp
int SelectClipRgn(
HDC hdc, // 目标 DC
HRGN hrgn // 要设置的 Region
);
这个函数的行为有一个很重要的细节:它使用的是 Region 的副本 。也就是说,SelectClipRgn 内部会复制一份你的 Region 然后设置为裁切区域,所以调用之后你仍然持有原始 Region 的所有权,仍然需要自己 DeleteObject 释放它。这一点和 SetWindowRgn(后面会讲)的所有权转移行为完全不同,千万别搞混了。
SelectClipRgn 的返回值和 CombineRgn 类似,也是 SIMPLEREGION、COMPLEXREGION、NULLREGION 或 ERROR。
SelectClipPath:用路径作为裁切区域
除了用现成的 Region,你还可以用 GDI 的路径(Path)来创建裁切区域。路径的创建方式是先用 BeginPath(hdc) 开启路径模式,然后在 DC 上执行一系列绘制操作(这些操作不会真正画到屏幕上,而是被记录为路径),最后用 EndPath(hdc) 结束路径。之后调用 SelectClipPath 就可以把这条路径转化为裁切区域:
cpp
// 用路径创建一个椭圆形的裁切区域
BeginPath(hdc);
Ellipse(hdc, 50, 50, 300, 200);
EndPath(hdc);
SelectClipPath(hdc, RGN_AND); // 与现有裁切区域求交集
SelectClipPath 的第二个参数是组合模式,和 CombineRgn 的模式参数含义相同。这种方式的优点是你可以用 GDI 的所有绘图函数来"画"出裁切区域的形状,比手动计算多边形顶点要方便得多。
实际应用场景
裁切区域在实际开发中有很多用途。比如我们要实现一个进度条控件,进度条外面的部分不应该画出来------这时候就可以用一个矩形 Region 作为裁切区域,宽度设为"当前进度百分比 × 控件宽度"。再比如实现一个圆形头像显示,可以用一个椭圆 Region 来裁切方形的位图。
下面我们写一个简单的演示,看看裁切区域的实际效果:
cpp
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rcClient;
GetClientRect(hwnd, &rcClient);
int cx = rcClient.right;
int cy = rcClient.bottom;
// 创建一个星形裁切区域
POINT star[10];
int cxCenter = cx / 2;
int cyCenter = cy / 2;
int outerR = min(cx, cy) / 2 - 20;
int innerR = outerR * 38 / 100; // 内半径约为外半径的 38%
for (int i = 0; i < 10; i++) {
double angle = (i * 36.0 - 90.0) * 3.14159265 / 180.0;
int r = (i % 2 == 0) ? outerR : innerR;
star[i].x = cxCenter + (int)(r * cos(angle));
star[i].y = cyCenter + (int)(r * sin(angle));
}
HRGN hrgnClip = CreatePolygonRgn(star, 10, WINDING);
SelectClipRgn(hdc, hrgnClip);
// 在整个客户区绘制彩色渐变条纹
for (int x = 0; x < cx; x += 10) {
HBRUSH hbr = CreateSolidBrush(RGB(x * 255 / cx, 128, 255 - x * 255 / cx));
RECT rcFill = { x, 0, x + 10, cy };
FillRect(hdc, &rcFill, hbr);
DeleteObject(hbr);
}
// 清理:取消裁切区域
SelectClipRgn(hdc, NULL);
DeleteObject(hrgnClip);
EndPaint(hwnd, &ps);
return 0;
}
这段代码创建了一个五角星形状的裁切区域,然后在整个客户区绘制彩色渐变条纹。由于裁切区域的存在,只有落在五角星范围内的条纹才会显示出来,最终你看到的就是一个彩虹色的五角星。
注意这里清理的时候,我们先调用 SelectClipRgn(hdc, NULL) 把裁切区域取消掉,然后才 DeleteObject 释放 Region。虽然 SelectClipRgn 用的是副本,释放时机不太敏感,但保持良好的资源管理习惯总是好的。
SetWindowRgn:异形窗口的核心
裁切区域只是控制绘制范围,窗口本身的形状并没有改变。如果我们想让窗口在屏幕上呈现出不规则的形状------比如圆角、圆形、星形------就需要用到 SetWindowRgn 了。
窗口 Region vs 裁切 Region
这里有一个很重要的概念区分。裁切区域(Clipping Region)是 DC 的属性,它只影响绘制操作------你设置了裁切区域后,画不出去的部分不显示,但窗口本身还是矩形。窗口 Region(Window Region)则是窗口的属性,它直接决定了窗口在屏幕上的可见形状。设置窗口 Region 后,系统不会显示窗口 Region 之外的任何部分,包括边框、标题栏、客户区------全部被裁掉。
你可以把窗口 Region 理解为"物理层面"的裁切,而裁切区域是"绘制层面"的裁切。窗口 Region 的优先级更高------如果窗口 Region 把某个角落裁掉了,不管你的绘制代码怎么画,那个角落都不会出现在屏幕上。
SetWindowRgn 函数详解
SetWindowRgn 的函数签名如下:
cpp
int SetWindowRgn(
HWND hWnd, // 窗口句柄
HRGN hRgn, // Region 句柄
BOOL bRedraw // 是否重绘窗口
);
三个参数都很直观。bRedraw 通常设为 TRUE,让系统立即重新绘制窗口以反映新的形状。
但是这里有一个极其重要的细节,也是很多人踩的坑:调用 SetWindowRgn 成功后,系统接管了 Region 的所有权,你不能再对它调用 DeleteObject 。根据 Microsoft Learn 的官方文档,系统不会复制 Region,而是直接使用你传入的句柄。当窗口销毁或者下次 SetWindowRgn 时,系统会自动释放旧的 Region。
如果你手滑在 SetWindowRgn 之后又 DeleteObject 了同一个 Region,后果是不确定的------可能马上崩溃,可能过一会儿才崩溃,也可能在某些 Windows 版本上看起来"没事"。这是一个典型的"未定义行为"陷阱,调试起来非常痛苦。
坐标系的坑
SetWindowRgn 的 Region 坐标是相对于窗口的左上角的,但不是客户区的左上角,而是整个窗口(包括标题栏和边框)的左上角。也就是说,坐标 (0, 0) 对应的是窗口边框的最左上角。如果你的窗口有标题栏(比如 WS_OVERLAPPEDWINDOW 样式),那客户区的起始 Y 坐标大约是 30 多像素往下。这意味着你在计算 Region 的坐标时需要考虑非客户区的高度和宽度。
不过在实际开发中,异形窗口通常会去掉标题栏和边框(使用 WS_POPUP 样式),这样窗口坐标和客户区坐标就一致了,省去了不少麻烦。
实战一:圆角窗口完整实现
理论讲了不少,现在我们开始动手。第一个实战案例是实现一个圆角窗口,这是一个非常常见的需求------很多现代应用的窗口都有圆角效果,即使 Windows 11 本身也给窗口加了圆角。
我们的计划是:创建一个无边框的窗口,用 CreateRoundRectRgn 创建一个圆角矩形 Region,然后通过 SetWindowRgn 应用到窗口上。由于去掉了标题栏,我们需要自己实现窗口拖动和关闭功能。
窗口创建部分
首先是窗口类注册和窗口创建:
cpp
#include <windows.h>
LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow)
{
const wchar_t CLASS_NAME[] = L"RoundedWindowClass";
WNDCLASS wc = {};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
wc.hbrBackground = CreateSolidBrush(RGB(45, 45, 45)); // 深色背景
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
RegisterClass(&wc);
// 创建无边框窗口
HWND hwnd = CreateWindowEx(
0,
CLASS_NAME,
L"圆角窗口",
WS_POPUP, // 无边框、无标题栏
CW_USEDEFAULT, CW_USEDEFAULT,
600, 400,
NULL, NULL, hInstance, NULL
);
if (hwnd == NULL) return 0;
ShowWindow(hwnd, nCmdShow);
MSG msg = {};
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
这里我们用 WS_POPUP 样式创建了一个完全无边框的窗口。hbrBackground 设为深灰色,这样窗口会有一个深色调的外观。
WM_CREATE:设置圆角 Region
窗口创建后,我们需要立即设置圆角 Region:
cpp
LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CREATE:
{
// 获取窗口尺寸(注意:这里用的是窗口尺寸,不是客户区尺寸)
RECT rc;
GetWindowRect(hwnd, &rc);
int w = rc.right - rc.left;
int h = rc.bottom - rc.top;
// 创建圆角矩形 Region
// 圆角半径设为 20 像素
HRGN hrgn = CreateRoundRectRgn(0, 0, w, h, 20, 20);
if (hrgn != NULL) {
// SetWindowRgn 成功后,系统接管 hrgn 的所有权
// 不能再 DeleteObject(hrgn)!
SetWindowRgn(hwnd, hrgn, TRUE);
}
return 0;
}
⚠️ 这段代码里有一个非常重要的细节。CreateRoundRectRgn 的坐标是 (0, 0, w, h) 而不是 (0, 0, w-1, h-1)。虽然很多 GDI 函数(比如 Rectangle)画的矩形右下边界是"排除"的(不画最后一个像素),但 CreateRoundRectRgn 的坐标定义的是一个完整的区域边界。如果你用 (0, 0, w-1, h-1),Region 会比窗口实际尺寸小一像素,右边和下边会露出一条线。
另外一个细节:这里用的是 GetWindowRect 而不是 GetClientRect。因为我们用的是 WS_POPUP 样式(没有边框和标题栏),两者返回的尺寸实际上是一样的。但如果你的窗口有边框,GetWindowRect 返回的是包含边框的整个窗口尺寸,而 GetClientRect 只返回客户区尺寸。对于 SetWindowRgn 来说,Region 坐标是相对于整个窗口左上角的,所以应该使用窗口尺寸。
WM_NCHITTEST:实现窗口拖动
去掉了标题栏之后,窗口默认就没法拖动了。我们需要自己处理 WM_NCHITTEST 消息来实现拖动功能:
cpp
case WM_NCHITTEST:
{
// 获取鼠标位置(相对于窗口)
POINT pt = { LOWORD(lParam), HIWORD(lParam) };
ScreenToClient(hwnd, &pt);
RECT rcClient;
GetClientRect(hwnd, &rcClient);
// 顶部条形区域作为"标题栏"------允许拖动
if (pt.y < 40) {
return HTCAPTION; // 系统会把这个区域当作标题栏处理
}
// 其余区域正常处理
return HTCLIENT;
}
WM_NCHITTEST 是 Windows 非客户区命中测试消息。当鼠标在窗口上移动或点击时,系统会发送这个消息来询问"鼠标现在在窗口的哪个部位"。返回 HTCAPTION 告诉系统"鼠标在标题栏上",系统就会允许拖动操作。返回 HTCLIENT 告诉系统"鼠标在客户区",系统就按正常方式处理。
这样处理之后,窗口顶部 40 像素的区域充当了"虚拟标题栏",用户可以拖动它来移动窗口。
绘制窗口内容
现在我们来给圆角窗口画点内容,让它看起来不那么单调:
cpp
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
int cx = rc.right;
int cy = rc.bottom;
// 使用双缓冲绘制
HDC hdcMem = CreateCompatibleDC(hdc);
HBITMAP hbm = CreateCompatibleBitmap(hdc, cx, cy);
HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbm);
// 填充深色背景
HBRUSH hbrBg = CreateSolidBrush(RGB(45, 45, 45));
FillRect(hdcMem, &rc, hbrBg);
DeleteObject(hbrBg);
// 绘制顶部"标题栏"区域
HBRUSH hbrTitle = CreateSolidBrush(RGB(55, 55, 55));
RECT rcTitle = { 0, 0, cx, 40 };
FillRect(hdcMem, &rcTitle, hbrTitle);
DeleteObject(hbrTitle);
// 绘制标题文字
SetBkMode(hdcMem, TRANSPARENT);
SetTextColor(hdcMem, RGB(220, 220, 220));
HFONT hFont = CreateFontW(18, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI");
HFONT hFontOld = (HFONT)SelectObject(hdcMem, hFont);
TextOut(hdcMem, 15, 10, L"圆角窗口示例", 7);
SelectObject(hdcMem, hFontOld);
DeleteObject(hFont);
// 绘制关闭按钮(右上角的小叉)
HPEN hPenClose = CreatePen(PS_SOLID, 2, RGB(200, 200, 200));
HPEN hPenOld = (HPEN)SelectObject(hdcMem, hPenClose);
int closeX = cx - 30;
int closeY = 13;
MoveToEx(hdcMem, closeX, closeY, NULL);
LineTo(hdcMem, closeX + 14, closeY + 14);
MoveToEx(hdcMem, closeX + 14, closeY, NULL);
LineTo(hdcMem, closeX, closeY + 14);
SelectObject(hdcMem, hPenOld);
DeleteObject(hPenClose);
// 在中央绘制一个圆形装饰
HPEN hPenCircle = CreatePen(PS_SOLID, 3, RGB(100, 180, 255));
HBRUSH hbrCircle = CreateSolidBrush(RGB(60, 80, 120));
SelectObject(hdcMem, hPenCircle);
SelectObject(hdcMem, hbrCircle);
Ellipse(hdcMem, cx/2 - 60, cy/2 - 60, cx/2 + 60, cy/2 + 60);
// 清理 GDI 对象
SelectObject(hdcMem, (HBRUSH)GetStockObject(NULL_BRUSH));
DeleteObject(hbrCircle);
DeleteObject(hPenCircle);
// 一次性拷贝到屏幕
BitBlt(hdc, 0, 0, cx, cy, hdcMem, 0, 0, SRCCOPY);
SelectObject(hdcMem, hbmOld);
DeleteObject(hbm);
DeleteDC(hdcMem);
EndPaint(hwnd, &ps);
return 0;
}
这里我们结合了上一篇文章学到的双缓冲技术。可以看到,在圆角窗口中绘制内容的方式和普通窗口完全一样------Region 只影响窗口的形状,不影响绘制的代码逻辑。
处理窗口大小变化
如果我们的窗口需要支持大小调整(比如通过代码调用 SetWindowPos 改变大小),那每次大小变化后都需要重新创建 Region 并重新设置:
cpp
case WM_SIZE:
{
// 窗口大小变了,重新设置圆角 Region
RECT rc;
GetWindowRect(hwnd, &rc);
int w = rc.right - rc.left;
int h = rc.bottom - rc.top;
HRGN hrgn = CreateRoundRectRgn(0, 0, w, h, 20, 20);
if (hrgn != NULL) {
SetWindowRgn(hwnd, hrgn, TRUE);
// 同样不需要 DeleteObject(hrgn)
}
return 0;
}
每次调用 SetWindowRgn 时,系统会自动释放旧的 Region,然后接管新的。所以你不用担心旧的 Region 泄漏。
关闭按钮和清理
最后,我们需要处理关闭按钮的点击和窗口的清理:
cpp
case WM_LBUTTONUP:
{
// 检查是否点击了关闭按钮区域
int xPos = LOWORD(lParam);
int yPos = HIWORD(lParam);
RECT rc;
GetClientRect(hwnd, &rc);
int cx = rc.right;
if (xPos >= cx - 35 && xPos <= cx - 10 && yPos >= 5 && yPos <= 35) {
DestroyWindow(hwnd);
}
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
到这里,一个完整的圆角窗口就实现了。编译运行后你会看到一个圆角的深色窗口,可以通过顶部区域拖动,右上角有关闭按钮。这就是 SetWindowRgn 配合 CreateRoundRectRgn 的基本用法。
实战二:星形异形窗口
圆角窗口只是 Region 最简单的应用。接下来我们来做一个更有挑战性的------用多边形 Region 创建一个五角星形状的窗口。这个案例会用到 CreatePolygonRgn,同时我们还要处理鼠标拖动和自定义绘制。
计算五角星顶点
五角星的顶点计算有一套固定的数学公式。一个五角星有 10 个顶点------5 个外顶点和 5 个内顶点交替排列。每个外顶点之间的角度是 72 度(360 / 5),内外顶点之间的角度是 36 度。
cpp
// 根据中心和半径计算五角星的顶点
void CalcStarPoints(POINT* pts, int cx, int cy, int outerR, int innerR)
{
for (int i = 0; i < 10; i++) {
// 从正上方(-90度)开始,每个顶点间隔 36 度
double angle = (i * 36.0 - 90.0) * 3.14159265 / 180.0;
int r = (i % 2 == 0) ? outerR : innerR;
pts[i].x = cx + (int)(r * cos(angle));
pts[i].y = cy + (int)(r * sin(angle));
}
}
内半径和外半径的比例决定了五角星的"尖锐程度"。经典的五角星内半径大约是外半径的 38%(更精确地说是 1 / (2 * sin(72°) * cos(36°)),约 0.382)。这个比例来自黄金分割------五角星是一个和黄金分割有深刻联系的几何图形。
完整的星形窗口代码
下面是完整的星形窗口实现。我们会创建一个五角星形状的窗口,用渐变色填充,支持鼠标拖动:
cpp
#include <windows.h>
#include <cmath>
// 全局变量:用于窗口拖动
POINT g_ptDragStart = {0};
bool g_bDragging = false;
void CalcStarPoints(POINT* pts, int cx, int cy, int outerR, int innerR)
{
for (int i = 0; i < 10; i++) {
double angle = (i * 36.0 - 90.0) * 3.14159265 / 180.0;
int r = (i % 2 == 0) ? outerR : innerR;
pts[i].x = cx + (int)(r * cos(angle));
pts[i].y = cy + (int)(r * sin(angle));
}
}
LRESULT CALLBACK StarWndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CREATE:
{
// 窗口尺寸
int winW = 500;
int winH = 500;
int cxCenter = winW / 2;
int cyCenter = winH / 2;
int outerR = 220;
int innerR = (int)(outerR * 0.382);
// 计算五角星顶点
POINT starPts[10];
CalcStarPoints(starPts, cxCenter, cyCenter, outerR, innerR);
// 创建多边形 Region
HRGN hrgnStar = CreatePolygonRgn(starPts, 10, WINDING);
if (hrgnStar != NULL) {
SetWindowRgn(hwnd, hrgnStar, TRUE);
}
return 0;
}
鼠标拖动实现
由于星形窗口没有标题栏,我们需要自己处理鼠标事件来实现拖动:
cpp
case WM_LBUTTONDOWN:
{
// 开始拖动
g_ptDragStart.x = LOWORD(lParam);
g_ptDragStart.y = HIWORD(lParam);
g_bDragging = true;
SetCapture(hwnd); // 捕获鼠标,确保拖动期间不会丢失消息
return 0;
}
case WM_MOUSEMOVE:
{
if (g_bDragging) {
// 计算偏移量并移动窗口
int dx = LOWORD(lParam) - g_ptDragStart.x;
int dy = HIWORD(lParam) - g_ptDragStart.y;
RECT rc;
GetWindowRect(hwnd, &rc);
MoveWindow(hwnd, rc.left + dx, rc.top + dy,
rc.right - rc.left, rc.bottom - rc.top, TRUE);
}
return 0;
}
case WM_LBUTTONUP:
{
if (g_bDragging) {
g_bDragging = false;
ReleaseCapture(); // 释放鼠标捕获
}
return 0;
}
这里我们用了 SetCapture / ReleaseCapture 来确保在拖动过程中,即使鼠标移出了窗口范围,窗口仍然能收到 WM_MOUSEMOVE 消息。如果不这样做,当鼠标移动过快、超出窗口边界时,拖动就会中断。SetCapture 是实现拖动功能的关键------它告诉系统"把所有鼠标消息都发给我",直到 ReleaseCapture 为止。
绘制五角星内容
现在我们来绘制五角星的填充内容,让它看起来漂亮一些:
cpp
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
int cx = rc.right;
int cy = rc.bottom;
// 双缓冲
HDC hdcMem = CreateCompatibleDC(hdc);
HBITMAP hbm = CreateCompatibleBitmap(hdc, cx, cy);
HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbm);
// 填充整个缓冲区为透明色(因为 Region 会裁掉窗口外的部分)
HBRUSH hbrBg = CreateSolidBrush(RGB(30, 30, 80));
FillRect(hdcMem, &rc, hbrBg);
DeleteObject(hbrBg);
// 绘制星形渐变效果------用同心五角星模拟
int cxCenter = cx / 2;
int cyCenter = cy / 2;
for (int i = 10; i >= 0; i--) {
double scale = i / 10.0;
int outerR = (int)(220 * scale);
int innerR = (int)(outerR * 0.382);
POINT pts[10];
CalcStarPoints(pts, cxCenter, cyCenter, outerR, innerR);
// 颜色从深蓝渐变到亮蓝
BYTE r = (BYTE)(30 + 170 * (1.0 - scale));
BYTE g = (BYTE)(60 + 140 * (1.0 - scale));
BYTE b = (BYTE)(180 + 75 * (1.0 - scale));
HBRUSH hbr = CreateSolidBrush(RGB(r, g, b));
HRGN hrgn = CreatePolygonRgn(pts, 10, WINDING);
FillRgn(hdcMem, hrgn, hbr);
DeleteObject(hrgn);
DeleteObject(hbr);
}
// 中央绘制文字
SetBkMode(hdcMem, TRANSPARENT);
SetTextColor(hdcMem, RGB(255, 255, 255));
HFONT hFont = CreateFontW(28, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI");
HFONT hFontOld = (HFONT)SelectObject(hdcMem, hFont);
const wchar_t* text = L"Star Window";
SIZE sz;
GetTextExtentPoint32(hdcMem, text, lstrlenW(text), &sz);
TextOut(hdcMem, cxCenter - sz.cx / 2, cyCenter - sz.cy / 2, text, lstrlenW(text));
SelectObject(hdcMem, hFontOld);
DeleteObject(hFont);
// 拷贝到屏幕
BitBlt(hdc, 0, 0, cx, cy, hdcMem, 0, 0, SRCCOPY);
SelectObject(hdcMem, hbmOld);
DeleteObject(hbm);
DeleteDC(hdcMem);
EndPaint(hwnd, &ps);
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
这段绘制代码用了 11 层同心五角星来模拟渐变效果。FillRgn 是一个很有用的函数------它直接用指定的画刷填充一个 Region,不需要先选入 DC。每一层的颜色从深蓝过渡到亮蓝,最终呈现出一个有立体感的五角星。
入口函数
最后是 wWinMain:
cpp
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow)
{
const wchar_t CLASS_NAME[] = L"StarWindowClass";
WNDCLASS wc = {};
wc.lpfnWndProc = StarWndProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
wc.hbrBackground = NULL; // 不需要默认背景
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
RegisterClass(&wc);
HWND hwnd = CreateWindowEx(
0,
CLASS_NAME,
L"Star Window",
WS_POPUP, // 无边框
CW_USEDEFAULT, CW_USEDEFAULT,
500, 500,
NULL, NULL, hInstance, NULL
);
if (hwnd == NULL) return 0;
ShowWindow(hwnd, nCmdShow);
MSG msg = {};
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
编译运行后你会看到一个五角星形状的窗口浮在桌面上,内部有蓝白渐变效果,可以任意拖动。这就是 CreatePolygonRgn + SetWindowRgn 的威力------任意多边形都可以变成窗口的形状。
常见问题与调试
在实现 Region 相关功能时,有几个常见问题值得单独拿出来说。
SetWindowRgn 后窗口边框残留
这个问题通常出现在你给一个有 WS_OVERLAPPEDWINDOW 样式的窗口设置 Region 时。Region 裁掉了一部分窗口,但标题栏和边框的渲染逻辑还在运行,结果就会出现一些奇怪的视觉残留。解决办法很简单------异形窗口统一用 WS_POPUP 样式,去掉所有非客户区元素。
Region 边缘锯齿
GDI Region 的坐标精度是像素级别的整数,所以圆形和圆角的边缘不可避免地会出现锯齿。这是因为 Region 内部用的是扫描线表示法,每条扫描线的起点和终点都是整数坐标,无法表达亚像素精度。在 DWM(桌面窗口管理器)时代,Windows 会对窗口边缘做一些抗锯齿处理来减轻这个问题,但 Region 本身是无法做到亚像素精度的。如果你需要平滑的异形窗口,后续文章会讲到用 Alpha 混合和分层窗口来实现。
CombineRgn 的结果为空
当你用 CombineRgn 做交集运算时,如果两个 Region 没有重叠区域,结果会是 NULLREGION。这在调试复杂的组合运算时特别容易踩坑------建议每次 CombineRgn 后都检查返回值,如果返回 NULLREGION 就说明你的 Region 位置或大小计算有问题。
GDI 对象泄漏
Region 也是 GDI 对象,每个 Create*Rgn 都要对应一个 DeleteObject------唯一的例外是已经传给 SetWindowRgn 的 Region。如果你在任务管理器中看到 GDI 对象数量持续增长,大概率就是 Region 泄漏了。一个好的编码习惯是:创建 Region 后立即检查是否为 NULL,使用完毕后确保释放,传给 SetWindowRgn 后就不要再碰了。
总结
到这里我们已经把 GDI Region 的核心用法全部走了一遍。从 Region 的三种基本形态(矩形、椭圆、多边形),到布尔运算(CombineRgn)组合复杂形状,再到裁切区域控制绘制范围,最后用 SetWindowRgn 实现了圆角窗口和星形异形窗口两个实战案例。
Region 是 Win32 GUI 编程中实现不规则形状的基础工具,但它有一个根本性的局限------只支持"硬边界"裁切,不支持半透明。你的窗口要么"有"要么"没有"某个像素,无法做到"这个像素 50% 透明"。这在很多现代 UI 场景中是不够的。下一篇文章我们就要引入 Alpha 混合技术,用 WS_EX_LAYERED 分层窗口和 UpdateLayeredWindow 来实现真正的逐像素透明效果------做出那种带有柔和阴影和半透明渐变的漂亮界面。
练习
-
实现一个圆形窗口 :用
CreateEllipticRgn创建一个完美的圆形窗口,在窗口中显示一张图片(可以用LoadImage加载一张位图),图片应该被裁切成圆形显示。 -
实现环形窗口 :用两个不同大小的椭圆 Region 通过
CombineRgn的RGN_DIFF模式创建一个"甜甜圈"形状的窗口,环形中间应该是透明的(可以看到桌面)。 -
实现支持大小调整的圆角窗口 :在圆角窗口的四个角和四条边添加拖拽热区(通过
WM_NCHITTEST返回HTTOPLEFT、HTRIGHT等值),使得用户可以通过拖拽边缘来调整窗口大小。注意每次大小变化后需要重新创建 Region。 -
用 Region 布尔运算创建十字形窗口 :创建两个细长的矩形 Region(一个水平、一个垂直),用
CombineRgn的RGN_OR合并成一个十字形窗口。给这个十字形窗口加上红色填充和拖动功能。
参考资料:
- CreateRoundRectRgn function - Microsoft Learn
- CombineRgn function - Microsoft Learn
- SetWindowRgn function - Microsoft Learn
- SelectClipRgn function - Microsoft Learn
- Region Functions overview - Microsoft Learn
- Window Regions - Microsoft Learn
- CreatePolygonRgn function - Microsoft Learn