ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

GDI抗锯齿实战:超采样+脏矩形实现VC++实时绘图

GDI抗锯齿实战:超采样+脏矩形实现VC++实时绘图

简介:面向Windows GDI绘图开发者的抗锯齿源代码包,针对LineTo()、Ellipse()等函数绘制曲线和斜线时出现的像素锯齿问题,提供可在近乎实时条件下改善边缘平滑度的实现思路,适合具备VC++基础、希望在桌面应用中提升图形显示质量的开发者参考。压缩包共69个文件,约312KB,包含源码与演示程序压缩包,以及htm示例页、js脚本、gif/png/jpg图片和css样式等网页展示素材,便于对照查看效果与实现。已有849人学习下载。代码围绕边缘检测、颜色混合、多级采样与性能优化展开,并涉及按分辨率、色深和系统性能调整抗锯齿策略,读者可从中理解GDI抗锯齿的落地方法、效果验证方式及效率取舍,用于学习借鉴或移植到自绘界面、图形控件等场景。

1. 用 LineTo 画出来的一条斜线,毛刺比数据本身还抢眼

工控上位机、示波器界面、频谱曲线这类东西,绘图部分十有八九还是 GDI 在扛:MoveToEx/LineTo/Polyline一套下来,逻辑简单、依赖为零。问题也很统一——斜线一旦不是 0°、45°、90°,边缘立刻变成台阶,细线尤其明显,放大到 4K 屏上简直没法看。这份GDI 源代码要解决的就是这件事:不改绘图逻辑,让锯齿在实时刷新下消失,工程直接能编进vc++项目里,最终输出的是干净的图像。它适合已经有一大堆 GDI 绘图代码、又不想推倒重写成 Direct2D 的人;如果你的界面本来就在用硬件加速渲染,那这套东西对你的价值不大。

2. 三条抗锯齿路线与源码骨架:主路径为什么押在超采样上

2.1 GDI 的锯齿不是画得不好,是光栅化规则里根本没有「覆盖率」

GDI 的图元光栅化遵循一条非常朴素的规则:像素中心落在图形内部,这个像素就被整块填成前景色;落在外面,就完全不碰。一条 1 像素宽的斜线,LineTo内部走的是 Bresenham 类的整数递推,每一步只决定「下一个像素是往右还是往右下」,输出结果是二值的——要么纯前景,要么纯背景,中间那层「半个像素被覆盖」的灰度从来不存在。

这就是锯齿的物理来源。抗锯齿的本质是让边界像素的颜色等于前景色与背景色按覆盖率加权的结果,而 GDI 的核心图元 API 没有暴露覆盖率这个量。所以有两条出路:要么换一套光栅化器(GDI+、Direct2D),要么自己造出覆盖率——把几何放大若干倍再画,缩小回来的时候自然就平均出了中间灰度。

第二条路的妙处在于它对绘图代码完全透明。你原来写LineTo(hdc, 400, 300),改成往一个 4 倍大的 DC 上画LineTo(hdc4x, 1600, 1200),看起来什么都没变,但缩小到 1:1 之后,原本硬邦邦的台阶被 16 个采样点平均成了 4 级灰度过渡。线宽、圆、圆弧、多边形的描边,全部自动受益,一行绘图逻辑都不用重写。这是存量 GDI 工程最省事的做法,也是这份源码包的主路径。

代价也很直接:像素量按倍数平方增长,填充成本也是。4 倍采样意味着每帧要清空并填充 16 倍于目标尺寸的像素,1080p 下就是 7680×4320 ≈ 3320 万像素、132MB 的 32 位缓冲。全屏这么干,60fps 是撑不住的,后面第 6 章会讲怎么用脏矩形把它压回来。

2.2 四条路线横向对比,选型别只看画质

很多人在动手之前会纠结要不要直接上 GDI+ 的SmoothingModeAntiAlias。我一般会先让对方把这张表看完再决定,因为画质只是一列,改动量和实时性才是决定项目能不能落地的两列。

路线典型 API画质表现1080p 单帧耗时量级对存量代码的改动适合的场景
GDI+ 抗锯齿Graphics::SetSmoothingMode曲线好,细直线偏软中,~3~8ms大,所有绘图调用要换对象模型静态图、低频刷新面板
Direct2DID2D1HwndRenderTarget最好,硬件加速低,<2ms很大,等于重写渲染层新建项目、动画密集
超采样 + 缩小CreateCompatibleBitmap+StretchBlt好,可调高,全屏 4x 约 20ms+极小,只换设备上下文存量 GDI 代码改造
逐像素覆盖率混合CreateDIBSection+AlphaBlend可控,线条锐利中,取决于图元数量中,只改关键图元少量曲线、仪表指针

表格里「对存量代码的改动」这一列,往往是压死骆驼的最后一根稻草。一个跑了七八年的工程,绘图代码散在几十个函数里,光是把HDC换成Graphics对象就够改两周,还得处理 GDI 和 GDI+ 混用的句柄互转。超采样路线的改动量几乎为零,这也是这份源码包把主路径放在它上面的原因。

2.3 源码包里的四个可复用单元

拆开看,整套东西其实是四个相对独立的单元,可以只拿其中一个用,不必整体接入。

第一个是放大画布管理器:负责创建和维护那块 N 倍尺寸的兼容位图,尺寸变化时重建,其余时候复用。它解决的是「千万别每帧 CreateCompatibleBitmap」这个性能大坑,GDI 位图的创建和销毁成本远高于你的直觉。

第二个是缩小滤波器:两个实现,一个是现成的StretchBlt配HALFTONE模式,省事但慢且有 gamma 副作用;另一个是手写的 4×4 box filter,直接对 DIB 像素做算术平均,速度快、行为可控,代价是只支持整数倍缩小。

第三个是32 位 DIB 混合层:把CreateDIBSection拿到的像素指针包一层,对外提供「按覆盖率混合一个像素」这种操作,内部处理预乘 alpha。这个单元是逐像素路线的基础,也是AlphaBlend能正常工作的前提。

第四个是抗锯齿图元封装:Wu 直线、抗锯齿圆、抗锯齿折线描边,直接在 DIB 像素上落笔。它适合那种「只有几根曲线要抗锯齿,其余界面保持原样」的局部改造场景——不用放大整块画布,代价只在被画的那几百个像素上。

四个单元之间没有强耦合。全屏超采样只用前两个;只优化曲线用后两个;混着用也行,比如背景超采样、前景曲线走 Wu,只要注意别在同一块缓冲上重复叠加 alpha。

3. 把工程跑起来:VC++ 配置、最小示例与四个必须定死的参数

3.1 编译器与链接器要动的地方

新建一个空的 Win32 桌面工程,把源码包里的.h/.cpp拖进去,然后确认三件事,顺序别搞反。

字符集统一用 Unicode。源码里凡是涉及字符串的地方一律走TCHAR或宽字符,混用LPCSTR和LPCWSTR会在CreateWindowEx这类地方报一堆类型不匹配,新手最容易卡在这一步。工程属性 → 高级 → 字符集,选「使用 Unicode 字符集」,然后重新生成一次,让预编译头里UNICODE/_UNICODE宏生效。

附加依赖项里补msimg32.lib。AlphaBlend、TransparentBlt、GradientFill都在这个库里,忘了加就是经典的LNK2019: 无法解析的外部符号 _AlphaBlend@32。如果源码里还用了 GDI+ 做参考对比,再补gdiplus.lib,并且在WinMain开头调GdiplusStartup、退出前GdiplusShutdown,这两个必须成对。

运行库要保持一致。源码包里的静态库如果是/MD编的,你的主工程也必须是/MD,否则链接期会看到LNK2038: 检测到 RuntimeLibrary 的不匹配项。改法在 C/C++ → 代码生成 → 运行库,把「多线程调试 DLL (/MDd)」和「多线程 DLL (/MD)」跟库对上。

提示:这三处改完之后,先编一个只弹窗口、什么都不画的空壳跑一遍。确认能起来再往里加抗锯齿代码,否则出问题时你分不清是配置错还是绘图逻辑错。

3.2 最小可跑的抗锯齿窗口

下面这段是整个方案的最小骨架,去掉了一切跟演示无关的东西,只保留放大画布、缩小回贴这条主干。可以直接粘进WinMain所在的.cpp里跑。

// AntiAliasDemo.cpp —— 超采样抗锯齿最小骨架 #include <windows.h> static const int kScale = 4; // 超采样倍数:2 / 3 / 4 三档最常用 struct BackBuffer { HDC dc = nullptr; // 放大后的画布 HBITMAP bmp = nullptr; HBITMAP old = nullptr; int w = 0, h = 0; // 客户区 1:1 尺寸 }; static BackBuffer g_bb; static void EnsureBackBuffer(HWND hWnd) { RECT rc; ::GetClientRect(hWnd, &rc); const int cx = rc.right - rc.left; const int cy = rc.bottom - rc.top; if (cx <= 0 || cy <= 0) return; if (g_bb.dc && g_bb.w == cx && g_bb.h == cy) return; // 尺寸没变就复用,绝不重建 HDC hdcScreen = ::GetDC(hWnd); if (!g_bb.dc) g_bb.dc = ::CreateCompatibleDC(hdcScreen); if (g_bb.bmp) { ::SelectObject(g_bb.dc, g_bb.old); ::DeleteObject(g_bb.bmp); } g_bb.bmp = ::CreateCompatibleBitmap(hdcScreen, cx * kScale, cy * kScale); g_bb.old = (HBITMAP)::SelectObject(g_bb.dc, g_bb.bmp); ::ReleaseDC(hWnd, hdcScreen); g_bb.w = cx; g_bb.h = cy; // 关键一步:把画布的逻辑坐标仍然设成 1:1,设备坐标放大 kScale 倍 // 这样后面所有绘图调用都用原来的坐标,不用到处乘倍数 ::SetMapMode(g_bb.dc, MM_ANISOTROPIC); ::SetWindowExtEx(g_bb.dc, cx, cy, nullptr); ::SetViewportExtEx(g_bb.dc, cx * kScale, cy * kScale, nullptr); } static void Paint(HWND hWnd) { EnsureBackBuffer(hWnd); if (!g_bb.dc) return; RECT rc = { 0, 0, g_bb.w, g_bb.h }; // 1) 在放大画布上按 1:1 逻辑坐标作图 ::FillRect(g_bb.dc, &rc, (HBRUSH)::GetStockObject(WHITE_BRUSH)); HPEN pen = ::CreatePen(PS_SOLID, 1, RGB(20, 60, 160)); HGDIOBJ oldPen = ::SelectObject(g_bb.dc, pen); ::MoveToEx(g_bb.dc, 10, 10, nullptr); ::LineTo(g_bb.dc, 400, 300); // 逻辑坐标,实际落在 1600x1200 物理像素上 ::SelectObject(g_bb.dc, oldPen); ::DeleteObject(pen); // 2) 缩回 1:1 贴到窗口 PAINTSTRUCT ps; HDC hdc = ::BeginPaint(hWnd, &ps); const int oldMode = ::SetStretchBltMode(hdc, HALFTONE); ::SetBrushOrgEx(hdc, 0, 0, nullptr); // HALFTONE 模式下必须重设笔刷原点 // 源矩形用逻辑坐标写:映射模式会把它换算成 cx*kScale 的设备像素 ::StretchBlt(hdc, 0, 0, g_bb.w, g_bb.h, g_bb.dc, 0, 0, g_bb.w, g_bb.h, SRCCOPY); ::SetStretchBltMode(hdc, oldMode); ::EndPaint(hWnd, &ps); }

EnsureBackBuffer里的复用判断是整个骨架的性能命门。CreateCompatibleBitmap一次分配的是几百兆显存/GDI 内存,每帧重建一次,帧率直接掉到个位数,任务管理器里的 GDI 对象数还会一路往上飙。判断条件写g_bb.w == cx && g_bb.h == cy就够了,窗口拖动缩放时自然重建。

SetMapMode(MM_ANISOTROPIC)那段是省事的关键,也是最容易翻车的地方。设完之后,LineTo传的仍然是 1:1 坐标,GDI 内部会乘 4 落到放大画布上;StretchBlt的源矩形同理,写g_bb.w而不是g_bb.w * kScale,写错了会只取左上角四分之一块,画面看起来像被裁了一角。另外注意映射模式是挂在 DC 上的状态,CreateCompatibleDC出来的新 DC 默认是MM_TEXT,必须显式设。

SetStretchBltMode(hdc, HALFTONE)配SetBrushOrgEx是固定搭配。HALFTONE模式在缩小的时候会做带 gamma 校正的加权平均,画质是四种模式里最好的,但它依赖笔刷原点来对齐采样网格,不调SetBrushOrgEx(hdc, 0, 0, nullptr)就可能在缩放后出现整体偏移一像素的花屏。用完记得把旧的模式存下来还原,DC 是共享状态,改完不还原会影响后面的绘制。

3.3 四个参数:倍数、滤波模式、脏矩形、DPI

kScale是最重要的一个。取 2 时锯齿已经明显缓解,性能开销是 4 倍像素;取 4 时基本看不出台阶,开销 16 倍。3 是个折中值,但HALFTONE的StretchBlt对非 2 的幂次倍数支持一般,容易在斜线上出现轻微的周期性明暗条纹——我一般要么 2 要么 4,不取 3。只有在用自写 box filter 的时候,3 倍才有意义,因为那时缩放逻辑是你自己控制的。

滤波模式的选择直接影响观感和帧率。COLORONCOLOR最快,但它是直接丢弃多余像素,4 倍缩小时等于每 4 个像素只取 1 个,斜线上的中间灰度全丢了,抗锯齿等于白做,还会让 1 像素宽的虚线断续成点。HALFTONE最慢但画质最好,适合静态或低频刷新。介于两者之间的是留着HALFTONE但缩小采样倍数的做法,很多工程最后停在 2 倍 +HALFTONE这个组合上,帧率和画质都能接受。

脏矩形的粒度决定实时性能的天花板。如果画面只有一条曲线在动,背景是静态网格,那就没必要每帧重绘整块画布。通常的做法是把背景预先渲染到另一张 1:1 的位图上,每帧只对曲线经过的那块矩形区域做超采样重绘。粒度取 32 或 64 像素的整数倍比较合适,太小会让StretchBlt的调用次数暴涨,调用开销反而盖过了省下来的填充量。

高 DPI 场景下还有一个额外参数要留意。系统缩放 150% 时,客户区尺寸拿到的仍然是逻辑像素,CreateCompatibleBitmap按逻辑尺寸乘倍数创建的话,实际显示会被二次拉伸,抗锯齿的收益被抵消掉一半。稳妥的做法是在WinMain里声明 DPI 感知,然后统一用GetClientRect返回的物理像素做缓冲尺寸,绘图坐标也按物理像素算。

4. 逐像素混合:32 位 DIB、预乘 alpha 与 Wu 覆盖率落地

4.1 32 位 DIB 的创建与预乘 alpha

超采样解决的是「几何边缘」的锯齿,但有些场景绕不开逐像素混合:比如要做半透明的仪表指针、要在 1:1 画布上直接画抗锯齿细线、要把两层图形叠加。这时候就得离开HDC的抽象,直接摸像素。

起点是一块 32 位自上而下的 DIB。biHeight给负数表示行序从上到下,省掉后面每次写像素都要算(h - 1 - y)的翻转,代码干净很多,而且和大多数图像库的内存布局一致。

// 创建一块 32 位自上而下的 DIB,返回位图句柄,像素指针通过 ppBits 带出 HBITMAP CreateAlphaDib(HDC hdcRef, int w, int h, void** ppBits) { BITMAPINFO bi = {}; bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth = w; bi.bmiHeader.biHeight = -h; // 负数 = 自上而下,省一次翻转 bi.bmiHeader.biPlanes = 1; bi.bmiHeader.biBitCount = 32; bi.bmiHeader.biCompression = BI_RGB; return ::CreateDIBSection(hdcRef, &bi, DIB_RGB_COLORS, ppBits, nullptr, 0); } // 写像素时必须预乘:颜色分量先乘 alpha 再存 inline void PutPremul(BYTE* p, BYTE r, BYTE g, BYTE b, BYTE a) { p[0] = (BYTE)(b * a / 255); // B p[1] = (BYTE)(g * a / 255); // G p[2] = (BYTE)(r * a / 255); // R p[3] = a; // A }

预乘这一步是整章最容易翻车的地方,几乎每个人第一次用AlphaBlend都会撞上。原因在于AC_SRC_ALPHA模式要求源像素的颜色分量已经乘过 alpha,如果你按直觉存了「R=255, A=128」这种未预乘的数据,混合公式会把它当成「亮度 255 的红色」去和背景加权,结果就是边缘出现一圈发白发亮的亮边;换成深色背景时又变成一圈黑边。正确写法是存「R=128, A=128」,让亮度本身携带覆盖率信息。

PutPremul里的/ 255是整数除法,对单个像素来说没问题,但在几十万像素的循环里会明显拖慢速度。性能敏感的路径上换成(v * a + 127) / 255或者直接用查表法,甚至(v * a + 1 + (v * a >> 8)) >> 8这种近似,误差在 1 以内,肉眼看不出来。

4.2 AlphaBlend 的参数表与三个必错点

AlphaBlend的参数不多,但每一个都有明确的语义边界,填错一个就是黑屏或者全透明。

参数常用取值说明
BlendOpAC_SRC_OVER只有这一个合法值,其他值直接失败
BlendFlags0必须为 0,MSDN 明确要求
SourceConstantAlpha255整体透明度,255 表示不额外衰减,只在做淡入淡出时改
AlphaFormatAC_SRC_ALPHA启用逐像素 alpha,要求源是 32bpp 且已预乘
// 把带 alpha 的 DIB 叠加到目标 DC 上 void BlendDib(HDC hdcDst, int x, int y, int w, int h, HDC hdcSrc) { BLENDFUNCTION bf = {}; bf.BlendOp = AC_SRC_OVER; bf.BlendFlags = 0; bf.SourceConstantAlpha = 255; // 不额外衰减 bf.AlphaFormat = AC_SRC_ALPHA; // 逐像素 alpha,源必须预乘 ::AlphaBlend(hdcDst, x, y, w, h, hdcSrc, 0, 0, w, h, bf); }

第一个必错点是源 DC 的位图格式。AC_SRC_ALPHA只认 32bpp,如果hdcSrc里选的是一张 24 位的CreateCompatibleBitmap,AlphaBlend会返回FALSE,而且不给你任何错误码提示,画面上就是什么都没有——一个典型的黑匣子行为。排查方法是先GetObject拿BITMAP结构看bmBitsPixel,不是 32 就直接判定这条路径无效。

第二个必错点是 alpha 通道全零。CreateDIBSection出来的内存初始是全 0,如果你只往里写了 RGB 三元组、忘了写第四个字节,整块图就是全透明,AlphaBlend之后什么都看不见,但返回值是TRUE。这个坑比第一个更隐蔽,因为 API 层面没有任何报错。

第三个必错点是混用SourceConstantAlpha和AC_SRC_ALPHA时的语义叠加。两个 alpha 是相乘关系而不是覆盖关系,做淡入淡出动画时如果同时在像素里写 128、又把SourceConstantAlpha设成 128,实际透明度是 25%,动画会提前「消失」。做动画时要么只动SourceConstantAlpha,像素 alpha 保持几何覆盖率的原值。

4.3 Wu 直线与圆的覆盖率落地

不是所有场景都值得开一块 4 倍大的画布。如果整个界面只有三根实时曲线需要抗锯齿,超采样的成本全花在了没必要的背景上。这种时候更适合直接在 1:1 的 32 位 DIB 上画,用 Wu 的算法逐像素算覆盖率。

Wu 直线的核心思想很朴素:直线在每一列像素上只穿过一小段,这一段的长度就是覆盖率。整数部分对应的像素拿满覆盖,小数部分拆成上下两个像素,各自按距离加权。斜率的绝对值大于 1 时把 x、y 对调,画完再换回来,保证每一列只有一个主像素。

// 单像素覆盖率混合:dst = src*a + dst*(1-a) static inline void BlendPixel(BYTE* p, BYTE r, BYTE g, BYTE b, BYTE a) { if (a == 0) return; if (a == 255) { p[0] = b; p[1] = g; p[2] = r; return; } const int ia = 255 - a; p[0] = (BYTE)((b * a + p[0] * ia) / 255); p[1] = (BYTE)((g * a + p[1] * ia) / 255); p[2] = (BYTE)((r * a + p[2] * ia) / 255); } // bits 为 DIB 像素首地址,stride 为一行的字节数(通常 = w * 4) void WuLine(BYTE* bits, int stride, int w, int h, float x0, float y0, float x1, float y1, BYTE r, BYTE g, BYTE b) { const bool steep = fabsf(y1 - y0) > fabsf(x1 - x0); if (steep) { std::swap(x0, y0); std::swap(x1, y1); } if (x0 > x1) { std::swap(x0, x1); std::swap(y0, y1); } const float dx = x1 - x0; const float dy = y1 - y0; const float grad = (dx == 0.0f) ? 1.0f : dy / dx; auto plot = [&](int x, int y, BYTE a) { if (steep) std::swap(x, y); // 换回真实坐标 if (x < 0 || y < 0 || x >= w || y >= h) return; // 裁剪必须做,否则越界写脏内存 BlendPixel(bits + (size_t)y * stride + (size_t)x * 4, r, g, b, a); }; // 起点:端点像素的覆盖长度要考虑像素中心偏移 float xend = floorf(x0 + 0.5f); float yend = y0 + grad * (xend - x0); float xgap = 1.0f - (x0 + 0.5f - xend); const int xpxl1 = (int)xend; const int ypxl1 = (int)floorf(yend); plot(xpxl1, ypxl1, (BYTE)(255 * (1.0f - (yend - ypxl1)) * xgap)); plot(xpxl1, ypxl1 + 1, (BYTE)(255 * (yend - ypxl1) * xgap)); float intery = yend + grad; // 终点 xend = floorf(x1 + 0.5f); yend = y1 + grad * (xend - x1); xgap = 1.0f - (xend - (x1 + 0.5f)); const int xpxl2 = (int)xend; const int ypxl2 = (int)floorf(yend); plot(xpxl2, ypxl2, (BYTE)(255 * (1.0f - (yend - ypxl2)) * xgap)); plot(xpxl2, ypxl2 + 1, (BYTE)(255 * (yend - ypxl2) * xgap)); // 中间段:每列两个像素,覆盖率互补 for (int x = xpxl1 + 1; x < xpxl2; ++x) { const int yi = (int)floorf(intery); const float f = intery - yi; plot(x, yi, (BYTE)(255 * (1.0f - f))); plot(x, yi + 1, (BYTE)(255 * f)); intery += grad; } }

steep分支里的两次swap是 Wu 算法的标准写法,plot内部再换回来。这里必须强调那个边界裁剪:blend之前不判断坐标范围,一次越界的x = -1就会写到上一行末尾的像素上,画面上表现为左侧边缘出现莫名其妙的杂点,而且随图形移动而变化,非常难查。裁剪判断加在plot里最省事,虽然每个点都要判一次,但比起调试越界写入的痛苦,这点开销完全值。

端点那两个xgap的计算容易看懵。它的物理意义是:直线起点不在像素中心时,这一列的覆盖率要按起点到像素中心的距离做一次额外衰减。两个端点的xgap表达式看起来符号相反,是因为起点用「1 减去小数部分」、终点用「小数部分减 1」,本质上都是算距离,写错一个符号的表现是线条两端各长出一小截淡淡的尾巴。

圆和弧线的抗锯齿用另一种方式更划算:把圆弧按 0.05 弧度的步长离散成很多小线段,然后对每段调用一次WuLine。段数取够密时,接缝处的覆盖率会自然累积,看不出断点。如果对圆形质量要求更高,那就退回超采样路线,4 倍画布上Ellipse一次,缩小后的圆边缘比任何手工算法都干净,还省了写算法的功夫。

5. 实时场景的避坑与排查:五个翻车现场

5.1 画面类问题:偏色、断续、边缘发黑

现象一:超采样缩小之后,整体画面比原来发灰、发暗,白底变成浅灰底。原因在HALFTONE模式的 gamma 处理。这个模式会把缩放当成一次图像重采样,按 sRGB 曲线做加权,对于「白底上画黑线」这种对比度极高的内容,平均之后得到的灰度比线性平均更亮或更暗,观感就是整体偏色。解决办法有两个:要么改用自写的 box filter 做纯线性平均,要么把HALFTONE换成HALFTONE+ 手动校正——我一般直接上 box filter,可控性比调参强得多。

现象二:1 像素宽的虚线在缩小后断成点,实线变成断续的。这是COLORONCOLOR模式的典型症状。它缩小的时候直接丢弃多余像素,不做任何平均,一条覆盖了 4 个采样点的细线如果恰好没落在被采样的那一个点上,整段就消失了。判断方法很简单:把SetStretchBltMode改成HALFTONE再跑一次,如果线连上了,就是模式选错。另外HALFTONE模式下务必配SetBrushOrgEx,否则采样网格错位,同样会出现周期性断续。

现象三:AlphaBlend叠上去的图形边缘一圈发白,或者换成深色背景时变成一圈黑边。这是预乘 alpha 没做。排查路径是先确认源 DIB 是 32bpp,再确认写入像素时颜色分量除过 255。有个快速验证法:把AlphaFormat改成 0、SourceConstantAlpha设成 128 再画一次,如果亮边消失了,说明问题就出在逐像素 alpha 这条路径上而不是别的地方。

5.2 资源与性能类问题:句柄泄漏、帧率塌方

现象四:程序跑十几分钟后界面卡死,任务管理器里 GDI 对象数持续上涨。GDI 对象有进程级上限,默认一万个左右,超了就CreatePen、CreateFont全部返回NULL,接着就是整片界面不刷新。根源通常是绘图函数里每次调用都新建HPEN/HBRUSH却没DeleteObject,或者SelectObject换出来的旧对象直接丢了没还回去。解决方法是把常用的笔和刷子做成静态缓存,程序启动时建一次、退出时删一次;实在要临时创建的,用 RAII 包一层,析构里SelectObject(hdc, old)再DeleteObject。这个坑我在不止一个项目里见过,血泪经验是:写完绘图代码先跑半小时看 GDI 对象数曲线,平的就是对的,往上爬的一定有漏。

现象五:静态截图很漂亮,一动起来帧率从 60 掉到 12。三个原因叠在一起:每帧CreateCompatibleBitmap、全屏 4 倍超采样、HALFTONE模式的StretchBlt。定位方法是分段计时,在EnsureBackBuffer、绘制、StretchBlt三处各打一次QueryPerformanceCounter。如果时间主要花在第一段,就是缓冲没复用;花在第二段,是采样倍数或重绘面积太大;花在第三段,是滤波模式选得太贵。对应的解法分别是缓存位图、上脏矩形、换自写 box filter。这三步做完,通常能把 20ms 压到 4ms 以内。

注意:调试 GDI 性能时别用OutputDebugString打点,字符串格式化本身的开销会污染测量结果。用QueryPerformanceCounter累加到全局变量,退出时一次性输出。

6. 把 4 倍采样压进 16 毫秒:脏矩形分块与自写 box filter

全屏 4 倍超采样在 1080p 下的填充量是 3320 万像素,光清屏就要写 132MB 内存,60fps 意味着每秒 8GB 的写入带宽,任何一条 DDR 通道都扛不住。真正能跑起来的方案一定是「只处理变化的那一小块」。

做法是把客户区分成 64×64 的网格,每帧对比前后两帧的绘制内容,标记出发生变化的格子,合并成一个或几个脏矩形,只对这些矩形区域在放大画布上重绘、只把这些区域缩回 1:1 贴到窗口。曲线类应用里,变化的区域通常只有整屏的 5% 到 15%,填充量直接降一个数量级。

// 只对脏矩形做超采样回贴:rc 是 1:1 客户区坐标 void FlushDirty(HDC hdcWnd, HDC hdcScale, const RECT& rc, int scale) { const int w = rc.right - rc.left; const int h = rc.bottom - rc.top; if (w <= 0 || h <= 0) return; // 源坐标必须手乘倍数:这里用的画布没设 MM_ANISOTROPIC ::StretchBlt(hdcWnd, rc.left, rc.top, w, h, hdcScale, rc.left * scale, rc.top * scale, w * scale, h * scale, SRCCOPY); }

这段代码和第 3 章骨架有个关键差异:hdcScale没有设MM_ANISOTROPIC,源坐标是手乘的。两种写法只能选一种,混用必然错位。我的取舍是——静态全屏重绘用映射模式图省事,脏矩形分块用手乘倍数图可控。因为脏矩形的坐标是从网格算出来的,本来就要做一次整数乘除,顺手乘个倍数不增加复杂度;而映射模式在跨 DC 传递坐标时容易出意外,AlphaBlend和 GDI+ 都不认它,做混合的时候还得反算回去。

缩小滤波换成自写的 4×4 box filter 之后,还能再省一截。它的行为完全线性,不会像HALFTONE那样引入 gamma 偏移,颜色和原始画面严格一致;而且只处理脏矩形的话,外层循环的边界裁剪很好写。

// 4x4 box filter:把放大画布的一块直接平均成 1:1 像素 void Downsample4x(const BYTE* src, int srcStride, BYTE* dst, int dstStride, int dstW, int dstH) { for (int y = 0; y < dstH; ++y) { const BYTE* s0 = src + (size_t)(y * 4) * srcStride; BYTE* d = dst + (size_t)y * dstStride; for (int x = 0; x < dstW; <p> <a href="https://download.csdn.net/download/toursheep/759518" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
返回列表