简介:面向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和GradientFill,winmm.lib对应高精度计时。字符集建议统一用 Unicode,CreateWindowExW一把到底,混用 ANSI 和宽字符在注册窗口类时最容易出现“窗口创建成功但标题乱码”这种低级翻车。
2.2 双缓冲接管 WM_PAINT 与 WM_ERASEBKGND
GDI 抗锯齿天然需要“先画到别处、再整体拷到屏幕”,所以双缓冲不是可选项,是前置条件。窗口过程里要把两件事处理干净:
// 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 RenderAA:4 倍离屏画布的实现
核心在于用两个 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; // 大画布宽,nScale=4 时是 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 | 直接丢弃多余像素,不平均 | 缩小照片,最快,锯齿明显 |
HALFTONE | 4×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×1200 | 7.3 MB | 192 万 | 60fps 稳定 |
| 3× | 2400×1800 | 16.5 MB | 432 万 | 45~55fps |
| 4× | 3200×2400 | 29.3 MB | 768 万 | 30~40fps |
| 8× | 6400×4800 | 117 MB | 3072 万 | 个位数,不建议 |
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
有了像素指针,混合就三行算术。这里用整数运算,避免浮点在每帧几百万次调用下成为瓶颈:
// BlendPixel:src-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 与 yi+1 之间的比例 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::vector<BYTE> 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一般取 24~48,太小会把纯色块的压缩噪声也当成边缘,画面发糊;太大则细线边缘漏处理。这个方法的优点是能处理任意来源的位图(截图、贴图、图标),缺点是对细线有“变胖”的副作用——因为模糊是双向扩散的。
5. 避坑与排查:GDI 抗锯齿最容易翻车的五个现场
这段是血泪经验集中区。算法本身不难,难的是 GDI 这套 API 的历史包袱,很多问题现象相似但病根完全不同。
5.1 缩放后的图整体发灰、边缘糊成一团
现象:开了抗锯齿,斜线确实平滑了,但整个画面像蒙了层雾,纯黑变成深灰,颜色饱和度也掉了。
原因:九成是StretchBlt用了COLORONCOLOR或者压根没设SetStretchBltMode,此时 GDI 的默认行为在某些驱动上是简单取样;另一种情况是大画布用了 32 位带 alpha,StretchBlt把 alpha 一起平均导致颜色被稀释。
解决:确认SetStretchBltMode(hdcSmall, HALFTONE)在每次StretchBlt之前都设了——注意这个模式是 DC 状态,如果你在同一个 DC 上又做了别的绘制,模式可能被重置。同时把大画布的 alpha 通道在绘制前统一填 255,或者在缩放前用SRCCOPY把 alpha 覆盖掉。
5.2 拖动窗口时画面疯狂闪烁
现象:静态看没事,一拖窗口或者拉伸边框,客户区就开始白一下黑一下地跳。
原因:两个地方。一是WM_ERASEBKGND没拦截,系统背景刷和你的绘制交替上屏;二是WM_SIZE里同步重建了离屏位图,重建期间那一帧没东西可画,直接露底。
解决:WM_ERASEBKGND返回 1;WM_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 直线单看很漂亮,但两条线一连接,接点处明显亮一块或者暗一块。
原因:相邻两段线在端点重复混合了同一个像素,覆盖度被叠加了两次。a=200加a=200不等于 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 度斜线,取跨线的一行像素打印灰度值,合格的结果应该是 3~5 个渐变值连续过渡,出现两个值来回跳就是没生效。二是性能:在EndFrame里用timeGetTime掐头去尾统计 200 帧,看 P95 而不是平均值——超采样最怕的就是偶发的重建卡顿,平均值会把尖峰抹平。三是回归:把AA_OFF和AA_HIGH两档的截图做逐像素差值,差值全为 0 说明你的开关根本没接进绘制链路,这种情况我见过不止一次。
从那以后我每次接这类渲染改造,都强制先跑一遍“关掉抗锯齿的对比基线”,确认开关真能生效,再谈效果和帧率。希望帮到你。
本文还有配套的精品资源,点击获取