1. 从一条锯齿线说起:CLine 类要解决的真实问题
如果你在 MFC 或任何 GDI 环境里用SetPixel直接画过斜线,大概率见过那种台阶感:斜率越接近 1 或 -1,锯齿越明显;水平、垂直、45 度反而干净。这不是代码写错了,而是光栅化的固有代价——屏幕是离散像素网格,理想直线是连续的,采样点落在格子边界上就会“跳格”。
Bresenham 改进算法负责的是“选对像素”:用整数误差项e决定每一步该走 x 还是走 y,避免浮点除法,速度快、逻辑稳。但它只解决“画在哪”,不解决“边缘硬”。Wu 反走样算法则是在相邻两个像素上按距离分配亮度权重,让边缘过渡柔和,代价是要处理浮点权重和颜色混合。
把两者塞进同一个CLine类,用anti参数切换,是图形学教学里非常经典的做法:既能对比走样与反走样的差异,又能把CP2(点)、CRGB(颜色)、CLine(线)三层职责拆清楚。这篇就围绕这套结构,给出可复制的类骨架、Bresenham 改进实现、Wu 反走样参数配置,以及一套能直接跑的验证动作。适合正在做光栅化实验、计算机图形学课程设计,或者想在 MFC 里快速落地直线绘制的同学。
2. 前置准备:TaoToken 接入与工程环境
在动手写CLine之前,先把两件事理清:一是编译环境,二是如果你打算用大模型辅助调试算法或生成测试用例,可以先把 TaoToken 的接入配好。
工程侧需要的是 Visual Studio(带 MFC 组件)+ 一个基于CDC的绘图窗口。CLine依赖CDC::SetPixelV,所以必须挂在 MFC 的OnDraw或类似绘制流程里。颜色类CRGB用 0~1 的浮点分量,最后乘 255 转RGB,这样 Wu 反走样的权重混合才自然。
模型侧,TaoToken 提供 OpenAI 兼容接口,适合用来做算法解释、边界用例生成、报错定位。接入信息如下:
| 项目 | 地址 |
|---|---|
| 官网 | https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= |
| API Base | https://taotoken.net/api |
| API Keys | https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite |
| 接入文档 | https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite |
| 模型对话 | https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite |
| Coding Plan | https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite |
注意:API Base 填
https://taotoken.net/api,不要带 UTM 参数;Key 只在本地环境变量或配置文件里保存,别硬编码进Line.cpp。
如果你只是纯本地跑图形学实验,这一步可以跳过;但涉及算法排障、批量生成测试直线时,接一个模型对话入口会省很多时间。
3. 可复制配置:CP2 / CRGB / CLine 三层骨架
先把三个类的头文件写清楚。CP2存坐标和颜色,CRGB负责颜色运算,CLine只暴露MoveTo/LineTo。
3.1 CP2 与 CRGB 定义
// Class.h #pragma once class CRGB { public: double r, g, b; CRGB(); CRGB(double rr, double gg, double bb); virtual ~CRGB(); CRGB& operator=(const CRGB& c); void Normalize(); // 把分量压回 0~1 friend CRGB operator+(const CRGB&, const CRGB&); friend CRGB operator-(const CRGB&, const CRGB&); friend CRGB operator*(const CRGB&, double); friend CRGB operator*(double, const CRGB&); friend CRGB operator/(const CRGB&, double); }; class CP2 { public: double x, y; CRGB clr; CP2(); CP2(double xx, double yy, CRGB c = CRGB()); virtual ~CP2(); };CRGB的Normalize()很关键:Wu 反走样算出的权重可能因为浮点误差略微超过 1,不归一化会出现RGB(260,260,260)这种越界值,SetPixelV会截断成奇怪颜色。
3.2 CLine 类声明
// Line.h #pragma once #include "Class.h" class CLine { public: CP2 P0; CP2 P1; public: void MoveTo(CDC* pDC, CP2 p0); void MoveTo(CDC* pDC, double x, double y); void MoveTo(CDC* pDC, double x, double y, CRGB c); void LineTo(CDC* pDC, CP2 p1, BOOL anti = FALSE); void LineTo(CDC* pDC, double x, double y, BOOL anti = FALSE); void LineTo(CDC* pDC, double x, double y, CRGB c, BOOL anti = FALSE); CLine(); virtual ~CLine(); };anti默认FALSE,也就是默认走 Bresenham 改进算法;传TRUE才切到 Wu 反走样。这样调用方不用改结构,只改一个参数就能对比效果。
3.3 Bresenham 改进实现要点
原实现里用interchange处理dy > dx的情况,本质是利用直线关于 y=x 的对称性,把“陡线”旋转成“缓线”再统一处理,避免写两套循环。误差项e的更新用e += dy + dy和e -= dx + dx,全程整数,没有除法。
void CLine::LineTo(CDC* pDC, CP2 p1, BOOL anti) { P1 = p1; CP2 p = P0; int dx = abs((int)(P1.x - P0.x)); int dy = abs((int)(P1.y - P0.y)); int s1 = P1.x > P0.x ? 1 : -1; int s2 = P1.y > P0.y ? 1 : -1; BOOL interchange = FALSE; if (dy > dx) { int temp = dx; dx = dy; dy = temp; interchange = TRUE; } if (!anti) { int e = dy + dy - dx; for (int i = 1; i < dx; i++) { pDC->SetPixelV((int)p.x, (int)p.y, RGB(p.clr.r * 255, p.clr.g * 255, p.clr.b * 255)); if (e > 0) { if (!interchange) p.y += s2; else p.x += s1; e -= dx + dx; } if (!interchange) p.x += s1; else p.y += s2; e += dy + dy; } } else { // Wu 反走样分支,见 3.4 } }这里有个容易忽略的点:循环从i = 1开始,意味着起点P0本身没被画。如果两条线首尾相接,接缝处会缺一个像素。教学演示无所谓,但做连续折线时建议在MoveTo后补一次起点绘制。
3.4 Wu 反走样参数配置
Wu 的核心是:每一步在主轴方向前进 1,副轴方向落在两个整数像素之间,按小数部分e分配亮度。e越接近 0,主像素越亮;越接近 1,副像素越亮。
double k = (P1.y - P0.y) / (P1.x - P0.x); double s3 = P1.x > P0.x ? 1.0 / k : -1.0 / k; double s4 = P1.y > P0.y ? k : -k; double e = interchange ? s3 : s4; for (int i = 1; i < dx; i++) { double a = p.x, b = p.y; CRGB c0 = CRGB(e, e, e) * 255; CRGB c1 = CRGB(1.0 - e, 1.0 - e, 1.0 - e) * 255; pDC->SetPixelV(Round(p.x), Round(p.y), RGB(c0.r, c0.g, c0.b)); if (!interchange) b = p.y + s2; else a = p.x + s1; pDC->SetPixelV(Round(a), Round(b), RGB(c1.r, c1.g, c1.b)); if (!interchange) { p.x += s1; e += s4 * s1; } else { p.y += s2; e += s3 * s2; } if (e >= 1.0) { if (!interchange) p.y += s2; else p.x += s1; e -= 1.0; } }参数含义对照:
| 参数 | 作用 | 取值注意 |
|---|---|---|
k | 直线斜率 | 垂直线时P1.x == P0.x会除零,需单独处理 |
s3 | 陡线时的误差步长 | 依赖k,k为 0 时同样异常 |
s4 | 缓线时的误差步长 | 直接等于k或-k |
e | 当前像素的亮度权重 | 必须保持在 0~1,越界要修正 |
Round | 四舍五入宏 | #define Round(a) int(a+0.5) |
注意:垂直线(
dx == 0)和水平线(dy == 0)必须提前拦截,否则k计算会崩。建议在LineTo开头加if (dx == 0 || dy == 0)走专用分支。
4. 验证请求:跑起来看走样改善
配置写完,得有一套能直接观察的验证动作,不然“反走样有没有生效”全靠感觉。
4.1 最小验证代码
在 MFC 的OnDraw里画一组对比线:
void CMyView::OnDraw(CDC* pDC) { CLine line; CRGB white(1.0, 1.0, 1.0); // 走样:Bresenham 改进算法 line.MoveTo(pDC, 50, 50, white); line.LineTo(pDC, 400, 180, FALSE); // 反走样:Wu 算法 line.MoveTo(pDC, 50, 250, white); line.LineTo(pDC, 400, 380, TRUE); }两条线斜率一致,唯一变量是anti。放大截图后对比边缘:上面那条会有明显台阶,下面那条在台阶处出现灰度过渡像素。
4.2 用模型对话辅助验证
如果手边没有合适的截图工具,或者想批量生成不同斜率的测试用例,可以把LineTo的实现贴进模型对话,让它帮你列出边界情况。入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,把dx == 0、dy == 0、k为负、e越界这几种情况描述清楚,让它给出对应的测试坐标对。
4.3 成功结果长什么样
跑通后应该看到:
anti = FALSE的线:像素点严格落在整数格上,边缘锐利,斜率为 1 附近时台阶最明显。anti = TRUE的线:每个主轴步进对应两个像素,一个亮一个暗,暗的亮度随e变化,整体边缘有柔化。- 颜色分量不越界:
RGB三个通道都在 0~255,没有出现纯黑或纯白的异常点。
如果反走样线看起来和走样线一样硬,八成是e没更新或者c1权重算反了。
5. 本篇常见错排查
5.1 垂直线崩溃或画不出来
k = (P1.y - P0.y) / (P1.x - P0.x)在P1.x == P0.x时除零。表现是程序直接崩,或者画出乱点。解决方式是在LineTo开头判断:
if (dx == 0) { int step = P1.y > P0.y ? 1 : -1; for (int y = (int)P0.y; y != (int)P1.y; y += step) pDC->SetPixelV((int)P0.x, y, RGB(255,255,255)); return; }水平线同理,dy == 0时直接沿 x 走。
5.2 反走样线颜色发灰或过曝
CRGB c0 = CRGB(e, e, e) * 255;这里如果e因为浮点累积超过 1,c0就会大于 255。SetPixelV对越界值的行为依赖平台,可能截断也可能取模。稳妥做法是在CRGB的Normalize()里做钳制:
void CRGB::Normalize() { if (r < 0) r = 0; if (r > 1) r = 1; if (g < 0) g = 0; if (g > 1) g = 1; if (b < 0) b = 0; if (b > 1) b = 1; }每次算完c0、c1后调一次。
5.3 陡线反走样方向错乱
interchange为TRUE时,主轴是 y 不是 x,s3和s4的用法要跟着换。原实现里e = interchange ? s3 : s4就是干这个的。如果发现陡线的反走样像素跑到了错误的一侧,检查s3的符号:P1.x > P0.x时取1/k,否则取-1/k,符号错了整条线的权重都会偏。
5.4 起点缺失导致折线断缝
前面提过,循环从i = 1开始,P0没画。连续画折线时,上一段的终点和下一段的起点之间会缺像素。最简单的修法是在MoveTo之后、进入循环之前补一句:
pDC->SetPixelV((int)P0.x, (int)P0.y, RGB(P0.clr.r * 255, P0.clr.g * 255, P0.clr.b * 255));5.5 编译期Round宏冲突
#define Round(a) int(a+0.5)这种宏在参数是表达式时容易出问题,比如Round(p.x + s1)展开成int(p.x + s1 + 0.5)没问题,但Round(a > b ? a : b)就会因为运算符优先级出错。建议改成内联函数:
inline int Round(double v) { return (int)(v + 0.5); }6. 继续往下走:把 CLine 接进你的渲染流程
CLine这套结构最大的好处是职责清晰:CP2管数据,CRGB管颜色运算,CLine管光栅化策略。你可以在不改调用方的前提下,把anti参数从BOOL扩展成枚举,比如ANTI_NONE/ANTI_WU/ANTI_SUPERSAMPLE,后续加超采样反走样也不用动接口。
如果要把这套代码接进更大的工程,或者用模型帮你做算法对比、生成测试矩阵,可以先把 API Key 配好:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,接入方式参考文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。长期做图形学编码或 Agent 类任务的话,Coding Plan 入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,按需取用。
最后留一个实测下来很省事的习惯:每次改完LineTo,先画一组固定坐标的测试线(水平、垂直、45 度、缓斜、陡斜各一条),截图存档再对比。图形学算法的 bug 往往藏在特定斜率里,肉眼扫一遍比读十遍代码快。