资讯详情

直线扫描转换算法详解:DDA、中点与Bresenham从原理到实现

📅 2026/10/12 19:59:26 | 华诺云谱 👁 阅读
直线扫描转换算法详解:DDA、中点与Bresenham从原理到实现
直线扫描转换算法DDA、中点、Bresenham实现——从原理到代码一次讲透想先从自己画的第一条直线说起。当年刚接触图形学满脑子都是画一条竖线不是很简单吗直到亲手打开一个像素点阵画布才意识到真实显示器上的直线根本不是数学意义上的连续线而是由一堆离散像素拼出来的阶梯状图案。这篇内容要做的就是拆解三种最经典的直线扫描转换算法——DDA、中点画线、Bresenham用最直白的语言把每一步推导和代码讲清楚。适合刚学图形学的新手、做嵌入式GUI或低层图形库的开发者以及任何想知道这两个像素点之间到底怎么选的人。把这三种算法的底层逻辑吃透后面画圆、画椭圆、多边形填充全都是同一套思路一通百通。1. 光栅化到底在解决什么问题1.1 直线的数学表达和像素表达差了多远数学课上我们写y kx b这是一条无限细的连续直线上面有无数个点。但在屏幕上画线能用的坐标只有整数像素点阵是离散的你只能从有限个格子里面选一些格子来近似这条线。比如从 (0,0) 到 (5,2)数学上的直线经过很多非整数坐标位置可像素只有 (1,1)、(2,1)、(3,2) 这些可选。这个离散化逼近的过程就叫扫描转换也就是光栅化。核心矛盾是像素坐标只能是整数但直线经过的真实坐标是实数。所以算法必须回答同一个问题每一步往哪个方向走两个候选像素选哪个1.2 三个算法的统一本质做决策DDA、中点画线、Bresenham表面上是三种不同的算法本质上都在做同一件事沿着某个主方向走一个像素然后决定另一个方向是否需要加一。区别只在于决策方式不同。DDA用浮点增量直接计算每次都算出下一个点的实际坐标然后取整。中点画线构造一个直线隐式方程用中点的符号来判断选哪个像素。Bresenham维护一个整数误差项用误差的正负来做唯一决策。理解到这一层三种算法就不再是三个孤立的知识点而是同一个问题在不同约束下的三种解法。下面的内容会沿着这个思路一个算法一个算法拆开来看。2. 准备工作先限定斜率为0到1的八分之一圆弧2.1 为什么只讨论斜率在[0,1]之间的直线所有图形学教材第一步都会先限定假设直线斜率k满足0 ≤ k ≤ 1。这看起来像偷懒其实是个很聪明的策略。斜率在0到1之间时直线的特点是每往右走一个像素x方向加1y方向要么不变要么加1一个方向是确定的另一个方向是两选一问题被大大简化。有人会问那斜率大于1、负数或者倒着画怎么办答案是用对称性做坐标变换。斜率大于1时交换x和y负斜率时取绝对值终点x小于起点x时交换端点。所有情况都能映射到0 ≤ k ≤ 1的基准场景上。后面的代码部分会补上完整的归一化处理。2.2 主位移方向和增量单位的理解在斜率0到1的场景里主位移方向永远是x方向每次x 1然后看y是否需要1。这样每步横坐标都是精确的整数只有纵坐标需要做决策。这个主方向概念很重要因为三种算法里所有的递推、误差项、判别式本质都是在追踪纵向偏移量——也就是当前真实直线位置和已选像素位置之间的差距。理解了这个基线下面可以正式开始推算法了。3. DDA算法最直观的浮点增量画法3.1 DDA的核心思想DDA全称是 Digital Differential Analyzer直译是数字微分分析器。它做的事情非常简单既然真实直线上相邻两个点的x坐标差1y坐标的增量就是斜率k那么我直接从起点开始每次x 1y k然后把y取整作为像素坐标。举个例子从 (0,0) 到 (5,2)斜率就是0.4。起点y0.0取整得到第0行像素第二步y0.4取整还是第0行第三步y0.8取整是第1行第四步y1.2取整是第1行第五步y1.6取整是第2行。每一步都是确定性的完全线性。这套逻辑的优点是极好理解缺点也直接摆在脸上每一步都有浮点加法和取整。当年CPU没有FPU的时候浮点运算是非常昂贵的操作在低端嵌入式设备上简直是灾难。而且浮点数有精度问题连续几千步的累加会导致误差堆积末尾的像素位置可能偏出一两个格。3.2 DDA的代码实现void dda_line(int x0, int y0, int x1, int y1) { int dx x1 - x0; int dy y1 - y0; int steps abs(dx) abs(dy) ? abs(dx) : abs(dy); float xIncrement dx / (float)steps; float yIncrement dy / (float)steps; float x x0; float y y0; for (int i 0; i steps; i) { plot(round(x), round(y)); x xIncrement; y yIncrement; } }一个值得注意的细节是不要直接写x 1或y k而是先算出总的步数steps再计算每一步的增量。这样不管斜率是0.2还是5steps始终是像素网格上较长方向要走的总步数代码天然兼容任意斜率的直线。上面这版代码我已经把任意方向处理掉了但要注意取整时用round因为C语言里强制类型转换(int)y是截断不是四舍五入。截断会导致像素点整体往下偏移一格视觉上线条明显偏斜。3.3 DDA的精度和性能边界DDA在实际项目里已经很少直接用了但它的价值在于帮助建立增量计算的直觉。每走一步只做一次加法加上一个固定增量这是后面所有优化算法的雏形。后面的中点算法和Bresenham算法优化的核心目标就是干掉这个浮点累加。我实测过DDA画一条5000像素长的直线在古老设备模拟器里能明显感觉到像素点尾部抖动这就是浮点累加误差的直观体现。所以DDA适合教学、适合快速原型验证但如果你要做的是工业级图形库请继续往下看。4. 中点画线算法用几何符号代替浮点运算4.1 从隐式方程到中点判别式中点画线算法的思路比DDA高级一个层次它不再计算实际坐标而是判断当前候选两个像素的中点在直线的哪一侧。这要用到直线隐式方程F(x, y) a*x b*y c 0对任意点(x,y)把这个点的坐标代入F(x,y)得到的结果有三种情况F(x,y) 0点在直线上F(x,y) 0点在直线下方F(x,y) 0点在直线上方现在假设已知当前像素是(xi, yi)主方向确定往右走。下一个像素只有两种可能右边相邻的(xi1, yi)或者右上方的(xi1, yi1)。取这两个候选像素的中点(xi1, yi0.5)代入直线方程如果中点位于直线下方说明真实直线离右上方的像素更近应该选(xi1, yi1)如果中点位于直线上方说明真实直线离右边像素更近应该选(xi1, yi)。决策就靠这一个F(xi1, yi0.5)的符号完成。整个过程完全没有浮点坐标相乘只剩符号比较。4.2 判别式的递推实现如果每一次都重新计算F(xi1, yi0.5)虽然工作量不大但还是有乘法。中点算法的精髓在于F的增量是固定的可以线性递推。定义判别式d F(xi1, yi0.5)分两种情况推导情况一d 0选右上像素(xi1, yi1)下一个要判断的点是(xi2, yi1.5)。新旧判别式相减d_new - d_old F(xi2, yi1.5) - F(xi1, yi0.5) a * 1 b * 1 a b情况二d ≥ 0选右边像素(xi1, yi)下一个判断点变为(xi2, yi0.5)d_new - d_old F(xi2, yi0.5) - F(xi1, yi0.5) a也就是说每次只需要给d加上一个常量ab或者a就可以得到下一步的判别式连乘法都省了。起点初始判别式也不再直接算中点坐标而是用直线两端点的系数代进去。为了进一步避免小数算法两边同时乘以2d的初始值和增量都变成整数。具体推导可以参照教材这里只展示最终代码void midpoint_line(int x0, int y0, int x1, int y1) { // 以斜率 0k1 为例 int dx x1 - x0; int dy y1 - y0; // F(x,y) dy*x - dx*y c取 ady, b-dx int a dy; int b -dx; int d 2 * a b; // 2 * F(x01, y00.5) int x x0, y y0; plot(x, y); while (x x1) { if (d 0) { x; y; d 2 * (a b); } else { x; d 2 * a; } plot(x, y); } }有一个容易出错的点判别式d的更新量根据a、b计算时很多教材直接写d 2*dy - 2*dx或者d 2*dy对照上面的推导会发现反直觉。这里的关键是符号约定ab在斜率小于1时是负数所以选右上像素时判别式是变小而不是变大。如果搞反了符号直线会往相反的方向偏出去。4.3 中点算法的实际体验中点算法的核心优势是彻底摆脱了浮点乘法甚至浮点加法和取整都没了。所有运算都是整数加法这在所有图形库底层都是巨大胜利。从视觉效果上讲在斜率小于1的情况下中点算法画出来的直线和Bresenham画出来的完全一样像素选择结果相同。所以这两个算法在结果层面是等价的。它俩的差异主要体现在推导路径和适用场景上。中点算法从点到直线的距离符号出发几何直觉强后续扩展画圆时可以直接套用画圆也是用中点构造判别式Bresenham是从误差累积出发代码层面更简洁。5. Bresenham算法纯整数误差项的极致优化5.1 误差项视角的重新推导Bresenham的出发点不是中点符号而是直接追踪实际直线高度和像素高度之间的误差。每次x走一步对应的真实y值也会往前走这个真实y值和你当前选的像素y值之差就是一个误差项e。仍然假设斜率为0到1起点在某个点(x, y)。真实直线上x1处的高度比当前像素y高k斜率。如果e k 0.5说明真实位置离下方像素更近y保持不变如果e k ≥ 0.5说明超过半格了y需要加1。为了避免小数把这个误差项乘以2再做一次代数变换把阈值0.5变成0。实际代码中常见的维护变量是p它的物理意义可以理解成误差是否过半的判别值但它不是正的误差而是误差的2倍再减去1所以有人也叫它决策参数。这个推导的终点是以下递推初始 p 2*dy - dx 在每个像素选完后 如果 p 0只有 x 加 1p 加 2*dy 如果 p ≥ 0x、y 都加 1p 加 2*dy - 2*dx5.2 Bresenham标准实现void bresenham_line(int x0, int y0, int x1, int y1) { // 以斜率 0k1 且 x0x1 为例 int dx x1 - x0; int dy y1 - y0; int p 2 * dy - dx; int x x0, y y0; plot(x, y); while (x x1) { x; if (p 0) { p 2 * dy; } else { y; p 2 * dy - 2 * dx; } plot(x, y); } }对比中点算法的代码会发现Bresenham的代码更精简没有系数a、b的概念直接以dx、dy为核心初值和增量全由dy、dx算出。这个版本的循环体内只有两个整数加法一个整数比较没有乘法、除法、浮点运算是教科书级别的高效实现。5.3 处理任意斜率对称变换的完整版本实际封装成工具类时不能只写这一个函数。要处理8个卦限的全部情况推荐做法是在调用入口统一做归一化而不是在每个算法内部写一堆分支。归一化逻辑如下如果 x0 x1交换起点和终点保证 x 方向从左到右 记录原始坐标如果 |dx| |dy|按 x 为主方向否则把 x、y 角色互换 如果斜率是负的y 的变化方向取反写成代码大致是这个框架void draw_line_general(int x0, int y0, int x1, int y1) { // 统一为第一卦限方向后送入核心循环 if (abs(x1 - x0) abs(y1 - y0)) { if (x0 x1) swap(x0, x1), swap(y0, y1); int dx x1 - x0; int dy y1 - y0; int stepY (dy 0) ? 1 : -1; // 斜率正负方向修正 dy abs(dy); // 核心 Bresenham用 stepY 控制 y 的方向 } else { // 交换 x、y 角色递归调用或镜像调用 draw_line_general(y0, x0, y1, x1); } }对称变换的思想很通用如果每一种情况都单独写一版算法代码会膨胀好几倍而且每多一份代码就多一批隐藏bug。我见过有人直接把8个分支展开写在同一个函数里最后自己都分不清哪个分支对应哪个卦限排查问题时痛苦不堪。6. 三种算法对比与实战选型6.1 直观对比表算法运算类型核心决策依据代码复杂度典型场景DDA浮点加减、取整真实y坐标取整最低教学、原型验证中点画线整数加减中点在直线上/下方中等嵌入式图形库、教学扩展画圆Bresenham整数加减、比较误差项是否过半最低工业级软件渲染、低端硬件从运算角度看中点画线和Bresenham没有本质区别都是整数加法速度差异可以忽略。真正拉开差距的是DDA和其他两种算法之间的鸿沟——浮点累加和取整的开销在现代CPU上虽然不明显但在低端MCU、FPGA、老式嵌入式设备上非常致命。6.2 实际项目里到底用哪个如果是做软件光栅化器比如自己写一个类似CPU渲染器的玩具引擎直接用Bresenham。为什么因为它是三者中代码最短、循环体重播最快的而且不用处理浮点误差边界情况最少。如果是学习图形学原理我觉得三种都要亲手写一遍。DDA帮你理解增量中点画线帮你建立隐式方程和判别式的直觉这个直觉在画圆、画椭圆时价值很大Bresenham则代表工程上用最少的运算做最多的事的典型范例。如果是给FPGA或VLSI设计硬件光栅化器Bresenham也是首选因为整数加法器在硬件里实现代价极低浮点单元则会占用大量逻辑资源。另外有些硬件设计方案甚至会直接把Bresenham的p值当成插值计数器顺带生成像素覆盖率为反走样服务。6.3 性能实测的一个小结论我自己做过一次粗略的基准测试画100万条长度在100到200像素之间的随机直线在普通PC上DDA、中点、Bresenham三种算法的耗时差异大约在10%到20%之间现代CPU上并没有传说的那么夸张。但这并不代表DDA也够用——在嵌入式CPU、无FPU环境、编译器优化等级低的情况下差距会拉大到数倍甚至更多。所以选型要结合目标平台不能只看桌面端的现象。7. 常见问题与避坑实录7.1 只能画向右方向的线归一化忘了做最典型的一个坑实现Bresenham只在0 ≤ k ≤ 1范围内有用一旦进入其他卦限就画错。解决方式就是前面讲的归一化先判断主方向、交换坐标、做镜像映射。我见过的大部分图形库入门项目90%的画线bug都出在这一步。建议把归一化和核心算法拆成两层函数测试时分别覆盖8个方向确保每一卦限都正确。7.2 取整给截断还是四舍五入在DDA里很多人直接plot((int)x, (int)y)。(int)是向零取整负数时会向零方向截断。画一条斜率略大于0的直线没问题但画负斜率的线时像素会整体偏移看起来像是被压扁。正确的做法是plot(round(x), round(y))或者手写0.5再截断。Bresenham和中点算法因为全程整数不存在这个问题。7.3 判别式的符号写反导致整条线向上偏中点算法和Bresenham的判别式符号都有哪一边是正的约定问题。一个技巧是先用dx、dy为正、起点在左下、终点在右上的情况手工跑3个点验证直线走向是否符合预期如果第一个转折点偏了就调符号。不要试图直接从数学公式里看懂符号因为不同教材的公式变形不一样有的乘了2有的乘了4有的F(x,y)定义正负方向都不一样。以能不能跑出正确的点为准。7.4 像素点是离散的看起来总是歪的初学会觉得 Bresenham 画出来的阶梯效果不太直尤其斜率为0.5左右时阶梯感最强。这个不是算法bug是光栅显示器的物理特性。如果用户需要更平滑的视觉效果引入反走样抗锯齿技术比如 Wu 算法Xiaolin Wus line algorithm计算每个像素的覆盖率让边缘像素降低亮度而不是直接二选一。Bresenham 的误差项可以直接复用到覆盖率计算中这也是它生命力强的另一个原因。7.5 别的语言里整型溢出在坐标范围很大的场景下2 * dx 2 * dy可能溢出。16位MCU上尤其明显坐标值超过约1万就危险。解决办法是使用长整型long或将误差项约束在较小区间内。这个细节很多教程不会提但做真实硬件开发时经常碰上。8. 实际使用的一些心得技巧额外说一个关于扩展的技巧中点画线的判别式很容易改造成画圆的版本。在圆上判别式从F(x1, y-0.5)构造出来推理过程和直线完全一致。所以如果你想深入掌握这些算法建议把三种直线的实现和一种画圆的实现放在同一个工程里对比阅读效果最好。调试这类光栅化算法时我习惯先在测试代码里把像素坐标打印成字符矩阵——用一个二维数组plot时往数组里写#跑完直接输出纯文本。这样每次改动算法肉眼扫一遍字符图案就能发现问题比直接跳到图形窗口里调试直观得多。等到算法基础正确了再把plot替换成真实的屏幕API。如果你的图形库需要画虚线也可以基于Bresenham修改只需在循环中加一个计数器偶数段画像素、奇数段跳过即可。这个用途在实际项目里非常常见比如CAD软件里的辅助线、矩形选框的虚线边框。最后再强调一个工程经验给直线扫描转换这类基础几何函数写自动化测试建议直接覆盖8个卦限、水平线、垂直线、单个像素几种边界情况。图形学里大量bug不是出在算法核心而是出在边界条件的归一化处理上。把边界测好了整套画线功能基本就能稳定交付。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑