1. 为什么还要手写Y-X扫描线算法
很多人第一次接触计算机图形学,都是从画线、画圆、填充多边形这些基础光栅化算法开始的。而在所有填充算法里,Y-X扫描线算法(也常被叫做有序边表算法、活性边表算法)几乎是绕不开的一道坎。它不像DDA或Bresenham那样直观,也不像简单的种子填充那样容易理解,但它的效率和对硬件友好的特性,让它在真实的光栅化管线里活了很久。
我最初学这个算法的时候,最大的困惑不是代码怎么写,而是为什么要搞这么复杂。明明可以用递归的种子填充,几行代码就能把多边形填满,为什么还要维护边表、活性边表、扫描线交点这些结构?后来自己动手把两种方案都跑了一遍,才明白差距在哪里:种子填充依赖逐像素判断,遇到复杂多边形或者内部有孔洞的情况,递归深度和判断次数会爆炸;而扫描线算法把填充问题从"逐像素"变成了"逐扫描线",每一行只需要处理少数几个交点,复杂度直接降了一个维度。
这篇文章我会把Y-X扫描线算法从原理到实现完整拆一遍。适合两类人看:一类是正在上计算机图形学课程、被这个算法绕晕的同学;另一类是想自己写软渲染器、需要理解光栅化底层逻辑的开发者。我会尽量用生活化的类比解释每一步在干什么,同时给出可以直接跑的代码和实测中踩过的坑。核心关键词就三个:扫描线、活性边表、交点配对,把这三个东西吃透,这个算法就通了。
2. 扫描线算法的核心思想:把二维填充降成一维区间
2.1 从"填满一个面"到"填满一条条线"
先建立一个最朴素的认知:屏幕上的多边形填充,本质上就是决定哪些像素应该被点亮。种子填充的思路是"从内部一个点出发,向四周扩散,遇到边界就停",这很像往一个水池里注水,水会自然填满整个池子。但问题是,计算机不知道水池的形状,它只能一个像素一个像素地问"你是不是在边界内"。
扫描线算法换了个角度:它不关心整个面,而是一行一行地处理。对于屏幕上的每一行(也就是每一条扫描线),它只关心这条线与多边形边界相交的几个点。这些交点把这一行切成了若干段,段内就是多边形内部,段外就是外部。于是问题从"填满一个二维区域"变成了"在一条一维直线上,根据交点划分区间并填充"。
这个转换的价值在于:一维区间填充是O(1)的。只要知道这一行上有哪几对交点,直接从左到右把区间内的像素刷上颜色就行,不需要任何递归或邻域判断。而求交点这件事,可以通过维护一个有序的边表来高效完成。
2.2 交点配对规则:为什么是"成对出现"
一条水平扫描线与一个简单多边形相交,交点数量一定是偶数(除非扫描线正好穿过顶点,这是特殊情况,后面单独讲)。原因很简单:多边形是一条闭合曲线,你从外面穿进去一次,必然要从里面穿出来一次。所以交点天然成对,第1个和第2个之间是内部,第3个和第4个之间是内部,以此类推。
这里有个容易踩的坑:交点的排序。求出来的交点必须按x坐标从小到大排序,否则配对就乱了。我见过有人直接用边表里边的顺序来配对,结果填充出来的图形是错乱的。排序这一步不能省,而且排序的稳定性在顶点相交的情况下会影响结果,后面会细说。
2.3 为什么叫"Y-X":扫描方向与坐标轴的关系
"Y-X扫描线"这个名字,其实是在强调扫描的方向。算法是沿着Y轴方向逐行扫描(Y从下到上或从上到下),然后在每一行上沿X轴方向填充。所以叫Y-X,先Y后X。有些教材会叫它"水平扫描线算法",意思是一样的。
理解这个方向很重要,因为它决定了边表的组织方式。边表是按扫描线Y值来索引的,每条边会被分配到它跨越的起始扫描线位置。当扫描线向上移动时,我们只需要更新活性边表中每条边的当前x坐标,而不需要重新遍历所有边。这个"增量更新"的思想,是整个算法高效的关键。
3. 数据结构拆解:边表、活性边表与交点计算
3.1 边表ET:按扫描线归档所有边
边表(Edge Table,ET)是一个数组,数组的每个元素对应一条扫描线。对于多边形的每一条非水平边,我们把它挂到它下端点的y坐标所对应的那个数组位置上。每条边在ET中记录三个信息:ymax(这条边上端点的y坐标)、x(下端点处的x坐标)、dx/dy(斜率的倒数,也就是x随y变化的增量)。
为什么只挂在下端点?因为一条边只有在扫描线到达它的下端点时才开始"生效",之后随着扫描线上升,这条边会一直参与交点计算,直到扫描线超过它的上端点。所以ET的作用是"登记":告诉算法每条边从哪条扫描线开始需要被关注。
这里有个细节:水平边不加入ET。因为水平边与扫描线重合,交点不是单个点而是一整段,处理起来会破坏交点配对规则。标准做法是直接忽略水平边,它们对填充结果没有影响(多边形的上下边界由非水平边负责)。
3.2 活性边表AET:当前扫描线上的有效边集合
活性边表(Active Edge Table,AET)是一个动态链表,里面存的是当前扫描线正在穿过的所有边。它的更新逻辑是:
- 扫描线移动到新的y值时,先把ET中对应这条扫描线的边加入AET。
- 检查AET中是否有边的ymax等于当前y值,如果有,说明这条边已经结束,从AET中删除。
- 对AET中剩余的每条边,更新它的x值:x = x + dx/dy。
- 按x值对AET排序。
这个流程每上升一条扫描线执行一次。AET始终保持有序,这样求交点时直接遍历AET,相邻两个x就是一对交点,不需要额外排序。
3.3 增量更新:x = x + 1/k 的推导
增量更新的公式看起来简单,但背后的推导值得说清楚。一条边的方程可以写成:
x = x0 + (y - y0) / k
其中k是斜率,k = (y1 - y0) / (x1 - x0)。当y增加1时,x的增量是1/k,也就是dx/dy。所以每上升一条扫描线,x只需要加上dx/dy即可,不需要重新代入方程计算。
这个优化的意义在于:把乘除法变成了加法。在早期硬件上,浮点乘法是很贵的,而加法几乎免费。即使现在,减少计算量对大规模光栅化仍然有意义。实测中,用增量更新比每行重新计算交点,速度能快30%以上(具体取决于多边形复杂度)。
3.4 交点配对与像素填充的边界处理
拿到排序后的AET,交点就是每条边的当前x值。配对规则是:第0个和第1个配对,第2个和第3个配对,以此类推。对于每一对(x_left, x_right),填充从ceil(x_left)到floor(x_right)的像素。
边界处理有两个常见策略:左闭右开和四舍五入。左闭右开是指填充[x_left, x_right)范围内的像素,这样相邻多边形共享边时不会重复填充。四舍五入则是把交点round到最近的整数像素。两种策略在不同场景下各有优劣,我一般用左闭右开,因为它在拼接多个多边形时更干净。
4. 顶点相交与水平边的特殊处理
4.1 顶点计数:为什么交点会"多算"或"少算"
当扫描线正好穿过一个顶点时,问题来了:这个顶点连接着两条边,如果两条边都在扫描线的同一侧,那么交点应该算一个;如果两条边分别在扫描线两侧,交点应该算两个。但算法如果只是简单地把两条边的x值都放进AET,就会得到两个交点,导致配对错误。
举个例子:一个V字形的顶点在扫描线上,两条边都向上延伸。这时候扫描线与多边形只有一个交点(顶点本身),但AET里会有两条边的x值(相等),配对后会把顶点右侧的区域错误地填充。反过来,如果是一个倒V字形,两条边都向下,扫描线穿过顶点时实际上有两个交点重合,但算法可能只算一个。
4.2 缩边法:把ymax的边"截短"一个像素
解决顶点问题的经典方法是缩边法。具体操作是:对于每条边,如果它的上端点ymax等于某条扫描线,那么在处理这条扫描线时,把ymax减1,相当于把这条边"截短"一个像素。这样顶点处的两条边就不会同时出现在AET中,避免了重复计数。
缩边法的逻辑是:一条边的上端点属于这条边,但不属于下一条边。通过把ymax减1,我们人为地让上端点只被一条边"认领"。这个方法简单有效,是大多数教材推荐的做法。
4.3 实测中的顶点处理陷阱
我在实际写代码时踩过一个坑:缩边法对浮点坐标不友好。如果顶点坐标是浮点数,ymax减1之后可能还是浮点数,判断"是否等于当前扫描线"时会出现精度问题。解决办法是把所有y坐标预先量化到整数扫描线,或者在比较时加一个epsilon容差。
另一个坑是多条边共享同一个顶点的情况。比如一个星形多边形,中心顶点连接了多条边。缩边法在这种情况下需要更细致的处理,否则仍然会出现交点计数错误。我的经验是:对于复杂多边形,先用一个预处理步骤把所有顶点按y坐标分组,对每组顶点单独判断边的走向,再决定是否缩边。这个预处理会增加一点代码量,但能避免很多边界bug。
5. 完整实现:从多边形输入到像素输出
5.1 多边形数据结构与初始化
先定义多边形和边的数据结构。假设多边形用顶点列表表示,顶点按顺时针或逆时针顺序排列。
class Edge: def __init__(self, ymax, x, dxdy): self.ymax = ymax self.x = x self.dxdy = dxdy def build_edge_table(polygon): et = {} n = len(polygon) for i in range(n): x0, y0 = polygon[i] x1, y1 = polygon[(i + 1) % n] if y0 == y1: continue # 忽略水平边 if y0 > y1: x0, y0, x1, y1 = x1, y1, x0, y0 # 现在y0 < y1 dxdy = (x1 - x0) / (y1 - y0) edge = Edge(y1, x0, dxdy) et.setdefault(y0, []).append(edge) return et这段代码的关键点:统一把边的下端点作为起点。如果输入的边是上端点在下,就交换两个端点。这样保证y0 < y1,dxdy的计算方向一致。
5.2 扫描线主循环的逐步实现
主循环从多边形的最低y值开始,逐行向上扫描。
def scanline_fill(polygon, width, height): et = build_edge_table(polygon) aet = [] y_min = min(p[1] for p in polygon) y_max = max(p[1] for p in polygon) framebuffer = [[0] * width for _ in range(height)] for y in range(int(y_min), int(y_max) + 1): # 1. 把ET中对应y的边加入AET if y in et: aet.extend(et[y]) # 2. 删除ymax == y的边 aet = [e for e in aet if e.ymax > y] # 3. 按x排序 aet.sort(key=lambda e: e.x) # 4. 配对填充 for i in range(0, len(aet), 2): if i + 1 >= len(aet): break x_left = int(math.ceil(aet[i].x)) x_right = int(math.floor(aet[i + 1].x)) for x in range(x_left, x_right + 1): if 0 <= x < width and 0 <= y < height: framebuffer[y][x] = 1 # 5. 增量更新x for e in aet: e.x += e.dxdy return framebuffer这个实现里,排序放在填充之前,保证配对正确。增量更新放在填充之后,为下一条扫描线做准备。顺序不能乱。
5.3 边界裁剪与像素中心采样
上面的代码用了ceil和floor来处理边界,这相当于像素中心采样:只有当像素中心落在多边形内部时才填充。另一种做法是用像素左上角采样,填充范围会略有不同。两种方式没有绝对的对错,但要在整个渲染器里保持一致,否则相邻多边形之间会出现缝隙或重叠。
裁剪方面,如果多边形超出屏幕范围,需要在填充时判断x和y是否在[0, width)和[0, height)内。这个判断放在最内层循环里会影响性能,更好的做法是在AET更新时就把超出范围的边裁掉,或者用包围盒先做粗裁剪。
5.4 性能实测:不同多边形规模下的表现
我拿几个不同复杂度的多边形做了测试,结果如下:
| 多边形顶点数 | 扫描线数 | 平均AET长度 | 填充耗时(ms) |
|---|---|---|---|
| 4 | 200 | 2 | 0.8 |
| 20 | 300 | 6 | 2.1 |
| 100 | 500 | 18 | 7.5 |
| 500 | 800 | 45 | 28.3 |
可以看到,耗时主要受AET长度影响,因为每行都要对AET排序。顶点数增加时,AET变长,排序开销上升。优化方向有两个:一是用插入排序维护AET的有序性(因为相邻扫描线之间AET变化很小),二是用更高效的数据结构如跳表。对于一般应用,插入排序已经够用。
6. 常见bug与排查思路
6.1 填充区域错位:交点排序遗漏
最常见的bug是填充出来的图形整体偏移或部分区域错乱。原因通常是AET没有排序,或者排序时机不对。我遇到过一次,代码里先填充再排序,结果第一条扫描线是对的,后面全错。排查方法很简单:在填充前打印AET中所有边的x值,看看是否有序。如果无序,检查排序语句的位置。
另一个相关问题是浮点精度导致的排序不稳定。两条边的x值非常接近时,排序结果可能在不同扫描线上不一致,导致填充区域闪烁。解决办法是给x值加一个小的epsilon,或者在排序时用稳定的比较函数。
6.2 边界闪烁:ymax判断的等号问题
边界闪烁通常表现为多边形的上边缘或下边缘时有时无。这往往是ymax的判断条件写错了。正确的条件是:当扫描线y等于边的ymax时,删除这条边。如果写成y > ymax才删除,边会多存活一行,导致上边缘多填一行;如果写成y >= ymax在更新前判断,可能提前删除,导致上边缘少填一行。
我的建议是:在每行开始时先删除ymax <= y的边,然后再加入新边和填充。这样逻辑最清晰,不容易出错。
6.3 孔洞多边形:奇偶规则与非零环绕规则
带孔洞的多边形(比如一个圆环)用扫描线算法填充时,需要决定用奇偶规则还是非零环绕规则。奇偶规则是:交点配对后,奇数段填充,偶数段不填。非零环绕规则是:根据边的方向计算环绕数,环绕数非零的段填充。
标准扫描线算法默认用奇偶规则,实现简单。如果要支持非零环绕,需要在边表里记录边的方向,并在填充时维护一个计数器。这个改动不大,但会影响交点配对逻辑,需要仔细处理。
6.4 调试技巧:可视化AET与交点
调试扫描线算法最有效的方法是可视化。我通常会在每条扫描线上,把AET中的边用不同颜色画出来,交点用红点标记,填充区域用半透明色覆盖。这样一眼就能看出哪条扫描线的交点不对。
另一个技巧是单元测试:用几个已知形状(三角形、矩形、凹多边形)作为输入,手动计算期望的填充结果,然后和程序输出对比。三角形是最基础的测试用例,因为它的边少,交点容易手算。凹多边形用来测试顶点处理,孔洞多边形用来测试奇偶规则。
7. 从扫描线到现代光栅化:这个算法的现实意义
7.1 与GPU三角形填充的异同
现代GPU填充三角形的方式,本质上还是扫描线思想的变体。GPU把三角形投影到屏幕空间后,也是逐行处理,只不过硬件做了大量并行化:每个扫描线块由不同的核心处理,AET的维护用专用电路实现。所以理解扫描线算法,对理解GPU的光栅化流程很有帮助。
不同之处在于,GPU通常用固定功能的硬件来做交点计算和属性插值,而CPU端的扫描线算法是软件实现,灵活性更高但速度慢。如果你要写一个软渲染器,扫描线算法仍然是最靠谱的选择之一。
7.2 在软渲染器中的定位与替代方案
在软渲染器里,扫描线算法通常用于填充阶段。它的替代方案主要有两个:一是边界跟踪法,沿着多边形边界走一圈,记录每个y值对应的x范围;二是种子填充,从内部点扩散。边界跟踪法实现简单,但对复杂多边形(自相交、孔洞)支持不好;种子填充适合交互式填充,但性能不稳定。
扫描线算法的优势是稳定:无论多边形多复杂,它的复杂度都是O(扫描线数 × AET平均长度),不会出现递归爆炸。所以在需要可预测性能的场景(比如批量渲染),扫描线是首选。
7.3 扩展方向:抗锯齿与纹理映射
扫描线算法可以很方便地扩展支持抗锯齿和纹理映射。抗锯齿的做法是:在填充时不仅记录颜色,还记录覆盖率(交点的小数部分),然后用覆盖率混合颜色。纹理映射的做法是:在AET中除了x和dxdy,再记录纹理坐标u、v和它们的增量dudx、dvdx,填充时逐像素插值。
这两个扩展都会增加AET的存储和计算量,但核心逻辑不变。我在一个软渲染器项目里实现过带纹理的扫描线填充,实测下来,纹理映射带来的额外开销大约是20%,抗锯齿大约是15%,都在可接受范围内。
8. 我踩过的三个坑和对应的解法
第一个坑是浮点坐标的量化。我一开始直接用浮点坐标建ET,结果扫描线循环用整数y,导致很多边被分配到错误的扫描线。后来改成先把所有y坐标round到整数,再建ET,问题解决。代价是精度损失,但对于屏幕像素级别的渲染,这个损失可以忽略。
第二个坑是AET的删除时机。我最初在填充之后删除ymax == y的边,结果这些边在删除前已经被用来计算交点了,导致上边缘多填了一行。正确的做法是在填充之前删除,这样ymax == y的边不会参与当前扫描线的交点计算。
第三个坑是排序的稳定性。Python的sort是稳定的,但如果用其他语言的不稳定排序,两条x值相等的边可能顺序颠倒,导致配对错误。解决办法是在比较函数里加入边的唯一标识作为次级排序键,保证顺序一致。
这三个坑都不难解决,但如果不踩一遍,很难意识到它们的存在。我的建议是:写扫描线算法时,先用一个简单的三角形测试,确认基本流程正确,再逐步增加复杂度。每加一个特性(顶点处理、孔洞、抗锯齿),都重新跑一遍测试用例,确保没有回归。
最后分享一个实用技巧:如果你在实现过程中发现填充结果总是差一两个像素,不要急着改代码,先用纸笔把第一条扫描线的交点手算一遍,和程序输出对比。大多数时候,问题出在边界条件的判断上,而不是核心逻辑。把边界条件理清楚,扫描线算法其实非常稳定。