1. 从"画一条直线"说起:为什么像素级算法值得单独拎出来讲
很多人第一次接触图形学,都是从"在屏幕上画一条直线"开始的。听起来简单得不像话——两点之间连一条线,这不是小学几何吗?但真正动手在像素网格上实现过的人都知道,这件事远没有想象中那么理所当然。屏幕不是连续的画布,它是由一个个离散像素点组成的矩阵,你没法真的画出一条数学意义上无限细、无限平滑的直线,你只能决定"哪些像素点应该被点亮",让人的眼睛看起来那是一条直线。
这就是光栅化要解决的核心问题之一。而在这个问题上,Bresenham直线算法是一个绕不过去的经典。它诞生于上世纪六十年代,至今仍然是很多图形库、绘图软件、嵌入式显示驱动里的基础组件。为什么一个这么"老"的算法还值得反复研究?因为它把"用整数运算高效地逼近一条理想直线"这件事做到了极致优雅——没有浮点、没有除法、没有复杂的三角函数,只有整数加减和一次位移,就能在任意斜率下画出正确的像素序列。
我写这篇东西的出发点,是自己在重新梳理这个算法时踩过的一些坑。网上很多资料一上来就甩给你一段代码,或者直接给出那个著名的误差判别式,但很少有人把"这个判别式到底是怎么推出来的""为什么可以只用整数""不同象限怎么统一处理"这些真正卡人的地方讲透。所以这篇博文不打算做成一份速查手册,而是想按照一个从业者重新理解它的思路,把背后的推导链条、实现细节和实际写代码时容易翻车的地方都摊开来讲。适合已经有一点编程基础、想真正搞懂光栅化底层逻辑的读者,也适合那些被各种"优化版本"绕晕、想回到原点重新理一遍的人。
2. 像素网格上的直线困境:连续与离散的鸿沟
2.1 理想直线和像素点根本不在一个世界里
数学上的直线是连续的,任意两点之间有无穷多个点。但屏幕上的像素是离散的,坐标只能是整数。假设我要从点 (0, 0) 画到点 (10, 4),理想直线的斜率是 0.4。当 x 从 0 走到 1 时,理想的 y 应该是 0.4,可像素坐标没有 0.4 这个位置,我只能选择点亮 y=0 或者 y=1。这个"选哪个"的决策,就是整个算法的核心。
最朴素的想法是:对每一个整数 x,算出理想的 y 值,然后四舍五入取整。这个方法叫直接求值法,逻辑上完全正确,画出来的线也没问题。但它有两个致命缺点:第一,每一步都要做一次浮点乘法和一次浮点加法(y = kx + b),在早期硬件上浮点运算极其昂贵;第二,当斜率很大时,x 每增加 1,y 会跳变很多,按 x 遍历会导致线条出现明显的断裂或稀疏。所以直接求值法虽然直观,却不是一个好的工程方案。
2.2 增量思想:把乘法降级成加法
改进的第一步是引入增量的概念。既然 y = kx + b,那么当 x 增加 1 时,y 就增加 k。也就是说,我不需要每次都重新算 kx + b,只需要维护一个当前的 y 值,每走一步就加上斜率 k。这样每步只剩一次浮点加法,比乘法快了不少。这个思路叫数值微分法(DDA),是理解 Bresenham 的重要跳板。
但 DDA 依然没有摆脱浮点。而且它还有个隐患:随着步数增加,浮点累加会积累舍入误差,画长线时可能出现偏移。更关键的是,在很多嵌入式场景里,处理器根本没有浮点单元,或者浮点运算会拖慢整个渲染循环。于是问题就变成了:能不能只用整数运算,完成同样的决策?Bresenham 算法的价值,正是在这里体现出来的。
2.3 决策的本质:每一步只回答一个问题
把问题重新表述一下:假设我们沿着 x 轴逐步前进(先考虑斜率在 0 到 1 之间的情况),每走到一个新的整数 x,我们只需要决定当前的 y 是保持不变,还是加 1。因为斜率在 0 到 1 之间,y 每步最多增加 1,不可能跳更多。所以整个画线过程,就变成了一连串的"是/否"决策。
这个视角的转换非常关键。它把一个"计算坐标"的问题,变成了一个"做判断"的问题。而判断,是可以用整数比较来完成的。Bresenham 的精髓,就是找到了一个只依赖整数的量,用它和 0 比较,就能决定 y 要不要加 1。这个量,就是所谓的决策参数。
3. 决策参数是怎么被"逼"出来的:一步步推导
3.1 用中点做裁判
要决定 y 加不加 1,最自然的思路是看理想直线到底更靠近哪个像素。假设当前像素是 (x, y),下一个候选是 (x+1, y) 和 (x+1, y+1)。理想直线在 x+1 处的真实 y 值是 y + k。如果这个值离 y 更近,就选下面的点;离 y+1 更近,就选上面的点。分界线正好是 y + 0.5,也就是两个候选点的中点。
所以判断条件可以写成:如果 y + k < y + 0.5,即 k < 0.5,选下面的点;否则选上面的点。但这里出现了 k 和 0.5,还是浮点。怎么把它变成整数?答案是两边同时乘以一个正整数,把小数消掉。
3.2 把斜率换成分数形式
斜率 k = dy / dx,其中 dy 和 dx 都是整数(两个端点的坐标差)。把 k 代入判断条件,得到 dy/dx < 0.5,两边乘以 2dx(dx 为正),得到 2dy < dx。这就是一个纯整数的不等式了。但注意,这个条件只在第一步成立,因为随着 y 的变化,理想直线和中点的相对位置会变。我们需要一个能持续更新的量。
设决策参数为 d,初始时 d = 2dy - dx。每一步根据 d 的符号决定 y 是否加 1,然后更新 d:如果选了下面的点(y 不变),d 增加 2dy;如果选了上面的点(y 加 1),d 增加 2dy - 2dx。这个更新规则看起来有点突兀,但它的来源其实很清晰——d 本质上衡量的是"理想直线相对于中点的偏移量的两倍再乘以 dx",每一步偏移量的变化要么是 2dy,要么是 2dy - 2dx。
3.3 为什么这个推导值得自己动手一遍
我强烈建议每个学这个算法的人都自己拿纸笔推一遍。因为网上很多代码直接写d = 2*dy - dx,然后if (d > 0) { y++; d += 2*(dy-dx); } else { d += 2*dy; },你照着抄能跑,但一旦遇到斜率大于 1、或者要处理负方向,就完全不知道该怎么改。只有理解了 d 的几何含义——它是"理想点与中点距离"的一个整数缩放版本——你才能在换象限、换主轴时灵活地调整,而不是死记硬背。
这里有个我自己的经验:推导时一定要把"主轴"这个概念搞清楚。所谓主轴,就是变化范围更大的那个坐标轴。当 |dy| <= |dx| 时,x 是主轴,我们按 x 步进;当 |dy| > |dx| 时,y 是主轴,要按 y 步进。很多初学者写的 Bresenham 只能画斜率在 -1 到 1 之间的线,就是因为忽略了主轴切换。
4. 把八个象限统一起来:一份能直接用的实现
4.1 先处理主轴,再处理方向
写一个通用版本,我的习惯是分两层处理。第一层判断主轴:如果 |dx| >= |dy|,说明 x 方向跨度大,按 x 步进;否则按 y 步进。第二层处理方向:步进可能是 +1 也可能是 -1,取决于终点在起点的哪一侧。把这两个维度组合起来,就能覆盖全部八个象限。
下面是一份我常用的 C 语言实现,逻辑清晰,方便移植到各种语言:
void drawLine(int x0, int y0, int x1, int y1) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while (1) { plot(x0, y0); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }这段代码用的是另一种等价形式,叫误差累积法。它和前面推导的决策参数法本质相同,但写法更紧凑,而且天然支持任意斜率和任意方向。err 的初始值是 dx - dy,每一步比较 2*err 和 -dy、dx 的关系,决定 x 和 y 是否各自前进。我第一次看到这段代码时觉得很神奇——它居然不需要区分主轴,两个 if 就搞定了一切。
4.2 这段代码为什么能统一所有情况
关键在于 err 的含义。它表示的是"当前像素位置与理想直线的偏差"的一个累积量。当 e2 > -dy 时,说明 x 方向的偏差已经大到需要修正,于是 x 前进;当 e2 < dx 时,说明 y 方向需要修正,于是 y 前进。在斜率平缓时,y 的修正条件很少触发,所以大部分步进只动 x;在斜率陡峭时,x 的修正条件几乎每步都触发,于是两个方向一起动,等效于按 y 步进。
这种写法的好处是不需要显式判断主轴,代码路径统一,分支预测友好。缺点是可读性对新手不太友好,第一次看容易懵。我的建议是:先用前面推导的决策参数法写一版,理解清楚每一步在干什么,然后再看这个误差累积版本,体会它为什么等价。两者对照着看,理解会深很多。
4.3 端点处理和边界情况
有几个细节容易被忽略。第一,循环的终止条件。上面用的是if (x0 == x1 && y0 == y1) break;,保证终点被绘制。如果写成先步进再判断,可能漏掉终点或者多画一个点。第二,当起点和终点重合时,dx 和 dy 都是 0,err 也是 0,循环会先画一个点然后因为坐标相等而退出,行为正确。第三,当线完全水平或完全垂直时,其中一个方向的修正条件永远不触发,退化成单纯的横线或竖线绘制,也没问题。
提示:如果你的绘图环境坐标原点在左上角、y 轴向下,这套算法完全不用改。因为算法只关心坐标的数值关系,不关心物理方向。这一点在移植到不同平台时能省不少事。
5. 实测中那些文档不会告诉你的坑
5.1 整数溢出:长线绘制时的隐形杀手
误差累积版本里有个2 * err,如果 dx 和 dy 很大,err 本身可能接近 dx 或 dy 的量级,乘以 2 之后有可能超出 16 位整数的范围。在早期嵌入式平台或者用 short 类型时,这会直接导致画线错乱。我的做法是:决策参数和误差量一律用至少 32 位有符号整数。对于绝大多数屏幕分辨率来说,32 位绰绰有余。如果是在极端受限的单片机上,可以考虑用无符号数配合偏移,但那样代码会复杂不少,除非真的缺那几个字节,否则没必要。
5.2 浮点混入:一个不经意的类型转换
我见过不少"Bresenham 实现"里偷偷混进了浮点。比如有人为了算斜率写了float k = (float)dy / dx;,然后在循环里用 k 做判断。这就完全背离了算法的初衷。更隐蔽的一种是:某些语言的除法默认返回浮点,比如在旧版 JavaScript 里dy / dx就是浮点数,如果不小心把它用在决策参数里,精度问题会在长线上暴露出来。写的时候一定要确认所有参与决策的量都是整数。
5.3 对称性:从 A 画到 B 和从 B 画到 A 结果不一样
这是一个很有意思的现象。理论上直线是对称的,但从 A 画到 B 和从 B 画到 A,由于步进方向和舍入决策的细微差异,点亮的像素集合可能不完全相同。在大多数应用里这无所谓,但如果你在做需要严格对称的图形(比如某些对称图案生成),就要注意了。解决办法是固定一个规范方向,比如总是从坐标较小的端点开始画,或者接受这种微小差异并在上层做处理。
5.4 性能:现代硬件上还值得优化吗
有人会问,现在 GPU 这么强,CPU 也快得离谱,还有必要纠结 Bresenham 吗?我的看法是:在通用图形渲染里确实不需要你手写,但在一些特定场景下它依然不可替代。比如在没有 GPU 的嵌入式设备上驱动 LCD 屏、在自定义的软件渲染器里画 UI 元素、在像素艺术工具里做精确的像素级控制,这些场景下 Bresenham 的整数特性和确定性输出就是刚需。而且理解它之后,你再去看抗锯齿、超采样这些进阶话题,会有一个坚实的落脚点。
6. 从直线到更远:这个算法教会我的事
Bresenham 直线算法最打动我的地方,不是它有多快,而是它展示了一种用离散手段逼近连续目标的思维方式。这种思维在图形学里无处不在:画圆有 Bresenham 圆算法,画椭圆有对应的变体,甚至在做路径规划、网格寻路时,判断两个格子之间是否"视线可达",用的也是类似的像素步进逻辑。
我自己在做一个网格地图的可视化工具时,就借用了这套思路来判断两个格子之间能否连成一条不穿过障碍的直线。当时直接把 Bresenham 的步进逻辑抽出来,每一步检查当前格子是否是障碍,是就中断。代码量极小,效果却很好。这让我意识到,这个算法的价值早就超出了"画线"本身,它本质上是一个在离散空间里生成两点间近似路径的通用工具。
如果你正在学图形学,我建议不要只停留在"能默写代码"的层面。试着问自己几个问题:如果坐标是浮点数但要求输出整数像素,怎么改?如果要在画线的同时做颜色渐变,决策参数还能顺便承担什么职责?如果要并行化,哪些步骤可以拆开?这些问题没有标准答案,但思考它们的过程,比记住任何一个公式都更有价值。