2026/11/4 8:08:38

GDI实时抗锯齿源码:双缓冲、超采样与像素混合消除斜线毛刺

GDI实时抗锯齿源码:双缓冲、超采样与像素混合消除斜线毛刺 简介面向Windows GDI绘图开发者的一份抗锯齿实现参考聚焦LineTo、Ellipse等绘制曲线和斜线时因像素栅格产生的边缘锯齿问题。代码围绕边缘检测、邻近像素颜色加权混合、多级采样以及实时性能优化展开并可能依据分辨率、色深与系统性能动态调整策略适合具备VC与GDI基础、希望提升界面图形显示质量的开发者对照研究。压缩包为rar格式共69个文件约312KB包含2个zip源码与演示工程包以及htm、js、css等网页说明和示例页面配合gif、png、jpg等效果图与界面素材另有少量swf展示文件便于从代码与视觉对比两个层面理解抗锯齿处理流程。已有849人学习说明该方向有稳定关注度。读者可从中获取可编译的GDI抗锯齿源码、演示程序及网页版效果示例借助边缘混合与性能优化思路在自己的绘图代码中复现平滑边缘并参考其按环境调整策略的做法形成可落地的排错与优化路径。1. 为什么 GDI 画出来的斜线总带毛刺一份实时消除图像锯齿源代码的定位接手一个设备监测上位机的界面改造时绘图区里全是斜向的轨迹线和标注框运行时那条斜线像被啃过一样一格一格的台阶投影到大屏上尤其明显。这不是显卡的问题原生 GDI 只对文字做 ClearType画线、画多边形、画圆全是硬边采样屏幕上每个像素只能取“全画”或“全不画”斜边自然就成锯齿。这份 GDI 实时消除图像锯齿源代码解决的正是这件事它面向还在用 VC 和原生 GDI 画图、又不愿意为了几条斜线把整个渲染层换成 Direct2D 或 OpenGL 的项目——轨迹回放、工业组态、老 MFC 界面、嵌入式设备监视屏。思路不玄乎一边把画面先画到放大的离屏位图再缩回来做像素平均另一边直接对 32 位 DIB 逐像素做覆盖度混合两条路各有各的适用场景。2. 工程骨架与绘图链路VC 单文档工程里为什么要备三张位图拿到源码先别急着编译先把工程结构和重绘链路捋清楚。抗锯齿本身只有几十行算法真正让人栽跟头的是“谁负责画、画到哪、什么时候上屏”这条流程链路一乱代码再漂亮也会闪。2.1 目录结构与工程配置这类 Demo 一般不会做得很重典型布局是一个解决方案加一个 Win32 窗口程序也有做成 MFC 单文档的核心渲染类是一样的GdiAA/ ├── GdiAA.sln ├── src/ │ ├── main.cpp 入口、注册窗口类、消息循环 │ ├── MainWnd.cpp/.h 窗口过程接管 WM_PAINT / WM_ERASEBKGND / WM_SIZE │ ├── AARenderer.cpp/.h 核心离屏画布管理 抗锯齿合成 │ ├── WuLine.cpp/.h 像素级直线覆盖度混合 │ └── Scene.cpp 业务绘制轨迹、标注框、网格坐标保持 1:1 ├── include/ │ └── pch.h 公共头、链接库声明 └── res/Scene.cpp是刻意拆出来的业务代码只管按原始坐标画完全不知道抗锯齿的存在这样以后换算法不用动业务。AARenderer才是这份源代码真正值钱的部分。工程配置上有两个地方要动VC 新建的空项目默认是不带的// include/pch.h把依赖库和宏集中声明避免每个 cpp 重复 #define WIN32_LEAN_AND_MEAN #include windows.h #include algorithm #include vector #pragma comment(lib, msimg32.lib) // AlphaBlend混合 32 位位图时要用 #pragma comment(lib, winmm.lib) // timeGetTime / timeBeginPeriod定帧用msimg32.lib对应AlphaBlend和GradientFillwinmm.lib对应高精度计时。字符集建议统一用 UnicodeCreateWindowExW一把到底混用 ANSI 和宽字符在注册窗口类时最容易出现“窗口创建成功但标题乱码”这种低级翻车。2.2 双缓冲接管 WM_PAINT 与 WM_ERASEBKGNDGDI 抗锯齿天然需要“先画到别处、再整体拷到屏幕”所以双缓冲不是可选项是前置条件。窗口过程里要把两件事处理干净// MainWnd.cpp一次 WM_PAINT 拆成「画到后台位图」和「一次性 BitBlt 上屏」 LRESULT CALLBACK MainWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_ERASEBKGND: return 1; // 背景由我们自己擦从源头掐掉闪烁 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); RECT rc; GetClientRect(hWnd, rc); // BeginFrame 返回后台 DC尺寸变化时才重建位图 HDC hdcBack g_renderer.BeginFrame(hdc, rc); g_scene.Draw(hdcBack, rc); // 业务按原始坐标画1:1 g_renderer.EndFrame(hdc, rc); // 内部做抗锯齿并上屏 EndPaint(hWnd, ps); } return 0; case WM_SIZE: g_renderer.Resize(LOWORD(lParam), HIWORD(lParam)); // 只标记不重建 return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }WM_ERASEBKGND直接返回 1 是关键。默认情况下系统会用背景刷先填一遍客户区再让你画两次绘制之间那一帧就是白屏于是拖动窗口时画面疯狂闪。返回 1 等于告诉系统“背景我自己负责”配合下面的双缓冲闪烁立刻消失。另一个坑是WM_SIZE里立刻重建位图——拖动窗口时WM_SIZE会连发几十次每次都申请 30MB 内存卡顿是必然的正确做法是只记录新的宽高留到下一次BeginFrame里判断尺寸变了再重建。还有一点容易被忽略不要在WM_PAINT之外用GetDC直接画。BeginPaint会处理无效区域和裁剪GetDC不会两者混用会出现“窗口被遮挡再恢复画面只重绘了一半”的怪现象。2.3 帧驱动WM_TIMER 与自建渲染循环的取舍静态界面画一次就够了实时轨迹必须定帧刷新。最省事的是SetTimer但它的精度受系统时钟片影响默认 15.6ms 一跳想要 60fps 就该用timeGetTime自己做节流// 定帧驱动这里只投递重绘请求真正的绘制仍在 WM_PAINT 里完成 static DWORD s_dwLastTick 0; void Tick() { DWORD now timeGetTime(); if (now - s_dwLastTick 16) return; // 约 60fps超采样 4 倍时建议放宽到 33 s_dwLastTick now; InvalidateRect(g_hMainWnd, NULL, FALSE); // 第三个参数必须 FALSE }InvalidateRect的第三个参数是bErase传TRUE会额外触发一次背景擦除配合WM_ERASEBKGND返回 1 虽然不至于闪但会平白多一次无效区域的合并开销。传FALSE让重绘完全由我们自己控制这是实测下来最稳的组合。如果项目里已经有多线程采集别在工作线程里直接调 GDI。GDI 句柄是进程级的但不是线程安全的跨线程画同一个 DC 会出各种莫名其妙的现象画面撕裂、随机花屏标准做法是工作线程只更新数据然后PostMessage(hWnd, WM_APP 1, 0, 0)通知 UI 线程重绘。3. 超采样路线拆解把 800×600 画到 3200×2400 再缩回来这是源码里的主路线也是改动最小的一条业务绘制代码一行不用动只是在外面套一层“放大画、缩小显示”。3.1 RenderAA4 倍离屏画布的实现核心在于用两个 DIB大的那个按nScale倍尺寸绘制小的那个按原始尺寸接收缩放结果最后一次性BitBlt到屏幕。// AARenderer.cpp把场景绘制到 nScale 倍离屏画布再用 HALFTONE 缩回目标尺寸 void CAntiAliasRenderer::RenderAA(HDC hdcTarget, const RECT rcTarget, int nScale) { const int w rcTarget.right - rcTarget.left; const int h rcTarget.bottom - rcTarget.top; const int bw w * nScale; // 大画布宽nScale4 时是 4 倍 const int bh h * nScale; // 尺寸变化才重建避免每帧 CreateDIBSection 把 GDI 对象数打爆 if (m_bw ! bw || m_bh ! bh) { DestroyBuffers(); m_hBig CreateARGBDIB(hdcTarget, bw, bh, m_pBig); m_hSmall CreateARGBDIB(hdcTarget, w, h, m_pSmall); m_bw bw; m_bh bh; } HDC hdcBig CreateCompatibleDC(hdcTarget); HDC hdcSmall CreateCompatibleDC(hdcTarget); HGDIOBJ oldBig SelectObject(hdcBig, m_hBig); HGDIOBJ oldSmall SelectObject(hdcSmall, m_hSmall); // 1) 业务绘制用 SetWorldTransform 或直接按 nScale 乘坐标 SetGraphicsMode(hdcBig, GM_ADVANCED); XFORM xf { (FLOAT)nScale, 0, 0, (FLOAT)nScale, 0, 0 }; SetWorldTransform(hdcBig, xf); m_pScene-Draw(hdcBig, rcTarget); ModifyWorldTransform(hdcBig, xf, MWT_IDENTITY); // 画完恢复别污染后续绘制 // 2) 缩回原尺寸HALFTONE 会做加权平均等价于一次盒式滤波 SetStretchBltMode(hdcSmall, HALFTONE); SetBrushOrgEx(hdcSmall, 0, 0, NULL); // 不设这一句输出会整体偏移 StretchBlt(hdcSmall, 0, 0, w, h, hdcBig, 0, 0, bw, bh, SRCCOPY); // 3) 一次性上屏避免中间态被用户看到 BitBlt(hdcTarget, rcTarget.left, rcTarget.top, w, h, hdcSmall, 0, 0, SRCCOPY); SelectObject(hdcBig, oldBig); SelectObject(hdcSmall, oldSmall); DeleteDC(hdcBig); DeleteDC(hdcSmall); }参数上nScale是唯一需要调的旋钮。SetWorldTransform那一段是让业务代码继续用原始坐标写的关键——线宽、字号、坐标全乘 4业务层无感。画完必须MWT_IDENTITY还原因为 DC 是有状态的忘了还原会导致同一帧里后续绘制全被放大 4 倍这种翻车我在第一次改的时候就遇到过找了两个小时才定位到。3.2 HALFTONE 缩放与 SetBrushOrgEx 的配合StretchBlt的缩放模式直接决定抗锯齿效果四个模式里只有一个能干这活模式行为适合场景BLACKONWHITE/WHITEONBLACK按与/或运算取像素硬边单色位图快但锯齿更重COLORONCOLOR直接丢弃多余像素不平均缩小照片最快锯齿明显HALFTONE4×4 采样加权平均产生中间色抗锯齿缩放本项目默认HALFTONE是唯一会生成过渡色的模式但代价是每次调用要做颜色映射计算800×600 的输出大概几毫秒。有两点必须注意一是SetBrushOrgEx(hdc, 0, 0, NULL)。HALFTONE模式内部依赖笔刷原点做抖动不设这一句输出会莫名其妙地整体偏移半个到两个像素而且偏移量跟窗口位置有关看起来像“图飘了”。这个坑非常隐蔽因为代码逻辑完全正确就是位置不对。二是HALFTONE不做 alpha 混合。如果的大画布是 32 位带 alpha 的StretchBlt会把 alpha 通道当成普通数据一起平均结果就是半透明区域发灰。要保留透明度得走AlphaBlend但它不支持缩放得先StretchBlt到中间位图再AlphaBlend成本翻倍。多数工程场景背景是不透明的直接用SRCCOPY最省事。3.3 内存、帧率与缩放倍率的取舍超采样的成本是平方级的别拍脑袋选 8 倍。以 800×600 客户区、32 位色计算倍率大画布尺寸单张大画布内存像素量实测帧率普通办公机2×1600×12007.3 MB192 万60fps 稳定3×2400×180016.5 MB432 万4555fps4×3200×240029.3 MB768 万3040fps8×6400×4800117 MB3072 万个位数不建议2 倍只有 4 个采样点对 1 像素宽的细线几乎没改善4 倍开始斜线明显平滑但内存和填充率都上去了。我一般的取舍是静态或低频刷新的界面组态图、标注框用 4 倍实时轨迹、示波器这种要求 60fps 的用 2 倍再叠加下面第 4 章的像素级混合补细节。另外大画布一定要复用别每帧新建——CreateDIBSection分配几十兆内存再释放光是缺页中断就够把帧率打下来。4. 像素直写路线CreateDIBSection 之后逐像素做覆盖度混合超采样是“用分辨率换质量”代价是内存和填充率。当分辨率上不去、或者只想对某几条线做抗锯齿时就得直接操作像素。这条路线要求你完全掌控位图内存。4.1 CreateDIBSection拿到可以直写的像素内存CreateCompatibleBitmap创建的位图是设备相关的你拿不到像素指针CreateDIBSection会同时返回HBITMAP和内存首地址这是所有像素级操作的入口// CreateARGBDIB创建 32 位自顶向下 DIB返回位图句柄并回填像素首地址 HBITMAP CreateARGBDIB(HDC hdc, int w, int h, void** ppBits) { BITMAPINFO bi {0}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth w; bi.bmiHeader.biHeight -h; // 负数 自顶向下行序与屏幕一致 bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount 32; // 32 位才能做逐像素混合 bi.bmiHeader.biCompression BI_RGB; return CreateDIBSection(hdc, bi, DIB_RGB_COLORS, ppBits, NULL, 0); }biHeight取负数这一手很关键。默认自底向上第 0 行在内存末尾写像素时算行偏移要(h - 1 - y) * pitch稍不留神就上下颠倒。用负数直接变成自顶向下y * pitch就是行首代码可读性和正确性都高一个档次。另外行跨度pitch一定是 4 的倍数32 位色下正好等于width * 4不要用width * 3这类经验值去算遇到奇数宽度必然错行。4.2 BlendPixel把覆盖度换算成整数 alpha有了像素指针混合就三行算术。这里用整数运算避免浮点在每帧几百万次调用下成为瓶颈// BlendPixelsrc-over 混合a 是 0~255 的覆盖度不是透明度 inline void BlendPixel(BYTE* pRow, int x, BYTE r, BYTE g, BYTE b, int a) { BYTE* p pRow x * 4; // 内存顺序是 B,G,R,A小端 int ia 255 - a; p[0] (BYTE)((b * a p[0] * ia 127) / 255); // 127 是四舍五入 p[1] (BYTE)((g * a p[1] * ia 127) / 255); p[2] (BYTE)((r * a p[2] * ia 127) / 255); p[3] 255; // 目标不透明alpha 直接写满 }127那个偏置是整数除法的舍入补偿去掉它整体会偏暗一点点肉眼在渐变区能看出来。/255编译器会自动优化成移位加乘法不用手写 8——用 8反而会引入系统性偏差。这里的a语义是“这个像素被覆盖了多少”不是“源有多透明”。同一个公式两种叫法但在直线、多边形填充里按覆盖度理解更直观线正好穿过像素中心就是 255压在边缘就是几十两行像素加起来刚好等于 255视觉上就连续了。4.3 WuLine一次循环同时处理两行像素吴小林直线的核心是沿主步进方向走每次同时给相邻两行像素按比例分配覆盖度。// WuLine.cpp对 32 位 DIB 画一条带覆盖度混合的直线 void DrawWuLine(BYTE* pBits, int nPitch, int nW, int nH, int x0, int y0, int x1, int y1, COLORREF cr) { const BYTE r GetRValue(cr), g GetGValue(cr), b GetBValue(cr); if (x0 x1) { std::swap(x0, x1); std::swap(y0, y1); } // 统一成从左到右 int dx x1 - x0; int dy y1 - y0; if (dx 0) // 垂直线退化单独处理避免除零 { int yb min(y0, y1), ye max(y0, y1); for (int y yb; y ye; y) PutPixel(pBits, nPitch, nW, nH, x0, y, r, g, b, 255); return; } float k (float)dy / dx; // 陡坡时把 x/y 对调走同一套逻辑这里只给缓坡版 float y (float)y0; for (int x x0; x x1; x) { int yi (int)floorf(y); float f y - yi; // 落在 yi 与 yi1 之间的比例 PutPixel(pBits, nPitch, nW, nH, x, yi, r, g, b, (int)((1.0f - f) * 255)); PutPixel(pBits, nPitch, nW, nH, x, yi 1, r, g, b, (int)(f * 255)); y k; } }配一个带边界检查的PutPixel把裁剪一次做完// 无符号比较一次性搞定上下两端越界比写四个 if 干净也更快 static inline void PutPixel(BYTE* pBits, int nPitch, int nW, int nH, int x, int y, BYTE r, BYTE g, BYTE b, int a) { if (a 0) return; if ((unsigned)x (unsigned)nW || (unsigned)y (unsigned)nH) return; BlendPixel(pBits y * nPitch, x, r, g, b, a); }(unsigned)x (unsigned)nW这个写法把x 0和x nW合并成一次比较因为负数转无符号后是个巨大的正数。这是像素循环里的常用技巧但对int到unsigned的转换要保持警惕别在别处照抄。完整版里还有陡坡分支|k| 1时沿 y 步进处理方式是把 x、y 互换后走同样的循环最后写回时再换回来。这段如果不写画 60 度以上的线会明显变淡——因为沿 x 步进时同一列只覆盖 1 个像素采样密度不够。4.4 BlurEdge只对边缘像素做 3×3 加权对已经画好的位图做后处理是第三条路。全图卷积太慢实际做法是先算梯度只处理边缘// BlurEdge梯度超过阈值的像素才参与 3×3 加权平坦区直接跳过 void BlurEdge(BYTE* pBits, int nPitch, int nW, int nH, int nThreshold) { std::vectorBYTE src((size_t)nPitch * nH); memcpy(src.data(), pBits, src.size()); // 必须读写分离否则结果污染输入 for (int y 1; y nH - 1; y) for (int x 1; x nW - 1; x) { const BYTE* c src.data() y * nPitch x * 4; int gx abs(c[4] - c[-4]); // 左右亮度差比 Sobel 便宜且够用 int gy abs(c[nPitch] - c[-nPitch]); if (gx gy nThreshold) continue; // 平坦区跳过省掉大半像素 BYTE* d pBits y * nPitch x * 4; for (int ch 0; ch 3; ch) { int sum c[-nPitch - 4 ch] 2 * c[-nPitch ch] c[-nPitch 4 ch] 2 * c[-4 ch] 4 * c[ch] 2 * c[4 ch] c[nPitch - 4 ch] 2 * c[nPitch ch] c[nPitch 4 ch]; d[ch] (BYTE)(sum / 16); // 高斯核总和 16 } } }nThreshold一般取 2448太小会把纯色块的压缩噪声也当成边缘画面发糊太大则细线边缘漏处理。这个方法的优点是能处理任意来源的位图截图、贴图、图标缺点是对细线有“变胖”的副作用——因为模糊是双向扩散的。5. 避坑与排查GDI 抗锯齿最容易翻车的五个现场这段是血泪经验集中区。算法本身不难难的是 GDI 这套 API 的历史包袱很多问题现象相似但病根完全不同。5.1 缩放后的图整体发灰、边缘糊成一团现象开了抗锯齿斜线确实平滑了但整个画面像蒙了层雾纯黑变成深灰颜色饱和度也掉了。原因九成是StretchBlt用了COLORONCOLOR或者压根没设SetStretchBltMode此时 GDI 的默认行为在某些驱动上是简单取样另一种情况是大画布用了 32 位带 alphaStretchBlt把 alpha 一起平均导致颜色被稀释。解决确认SetStretchBltMode(hdcSmall, HALFTONE)在每次StretchBlt之前都设了——注意这个模式是 DC 状态如果你在同一个 DC 上又做了别的绘制模式可能被重置。同时把大画布的 alpha 通道在绘制前统一填 255或者在缩放前用SRCCOPY把 alpha 覆盖掉。5.2 拖动窗口时画面疯狂闪烁现象静态看没事一拖窗口或者拉伸边框客户区就开始白一下黑一下地跳。原因两个地方。一是WM_ERASEBKGND没拦截系统背景刷和你的绘制交替上屏二是WM_SIZE里同步重建了离屏位图重建期间那一帧没东西可画直接露底。解决WM_ERASEBKGND返回 1WM_SIZE只记尺寸变化标记把重建推迟到下一次BeginFrame的首行InvalidateRect第三个参数一律传FALSE。这三条一起上闪烁基本绝迹。5.3 跑几分钟后卡死任务管理器里 GDI 对象数一路涨现象刚启动很流畅跑几分钟越来越慢最后界面僵住。用任务管理器加“GDI 对象”列一看几千上万。原因典型的 GDI 对象泄漏。CreateCompatibleDC、CreateDIBSection每帧都建、没删或者SelectObject之后没把旧对象选回去。进程的 GDI 对象上限默认是 10000到顶就分配失败之后所有绘制操作静默失效。解决所有 DC 和位图在类里做成成员尺寸不变就不重建。SelectObject必须配对还原HGDIOBJ old SelectObject(hdc, hBitmap); // ... 绘制 ... SelectObject(hdc, old); // 少这一句位图删不掉 DeleteObject(hBitmap);调试期可以加个看门狗在EndFrame里调GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)超过 2000 就OutputDebugString报警。5.4 32 位位图的透明区域变成黑块现象明明画的是半透明遮罩上屏后透明区全黑。原因GDI 的BitBlt和StretchBlt完全无视 alpha 通道它们把 32 位位图当 24 位用。要做透明必须AlphaBlend而AlphaBlend要求源位图的 alpha 是预乘的RGB已经乘过 alpha。解决要么放弃BitBlt改用AlphaBlend并手工预乘要么干脆用不透明背景绕开这个问题。半透明叠加这类需求用 GDI 硬扛性价比很低能上 GDI 就上。5.5 直线端点出现亮斑两条斜线接缝处颜色偏深现象Wu 直线单看很漂亮但两条线一连接接点处明显亮一块或者暗一块。原因相邻两段线在端点重复混合了同一个像素覆盖度被叠加了两次。a200加a200不等于 255而是接近饱和。解决多段线连接时把端点覆盖度减半再混合或者简单粗暴——每段线跳过最后一个像素让下一段接管。这个技巧在画折线图时特别有用。6. 二次开发把抗锯齿收成一个能开关、能测耗时的渲染器要把这套东西用到真实项目里第一件事就是把散落的绘制逻辑收成一个接口让业务层只认三句话质量档位随时可调// AARenderer.h质量枚举值直接当超采样倍率用省一层映射 class CAntiAliasRenderer { public: enum Quality { AA_OFF 1, AA_FAST 2, AA_HIGH 4 }; bool Init(HWND hWnd, Quality q); HDC BeginFrame(HDC hdcTarget, const RECT rc); // 返回绘制用 DC void EndFrame(HDC hdcTarget, const RECT rc); // 内部抗锯齿 上屏 void SetQuality(Quality q); // 切换时只重建离屏画布 DWORD GetLastFrameCost() const { return m_dwCostMs; } private: Quality m_q; HBITMAP m_hBig, m_hSmall; void *m_pBig, *m_pSmall; int m_w, m_h; DWORD m_dwCostMs; };验证别靠眼睛。一是像素级检查画一条 30 度斜线取跨线的一行像素打印灰度值合格的结果应该是 35 个渐变值连续过渡出现两个值来回跳就是没生效。二是性能在EndFrame里用timeGetTime掐头去尾统计 200 帧看 P95 而不是平均值——超采样最怕的就是偶发的重建卡顿平均值会把尖峰抹平。三是回归把AA_OFF和AA_HIGH两档的截图做逐像素差值差值全为 0 说明你的开关根本没接进绘制链路这种情况我见过不止一次。从那以后我每次接这类渲染改造都强制先跑一遍“关掉抗锯齿的对比基线”确认开关真能生效再谈效果和帧率。希望帮到你。本文还有配套的精品资源点击获取