1. 从数学公式到屏幕像素:为什么要聊圆和椭圆的光栅化
如果你做过图形学相关的活儿,或者哪怕只是自己折腾过绘图引擎、单片机屏幕显示、游戏里的碰撞体绘制,那你一定绕不开一个问题:怎么把一个数学上完美的圆,画到由一个个方格像素组成的屏幕上?
这个问题的学名就叫“光栅化”。数学世界里,圆是 (x-a)²+(y-b)²=r² 这样一个完美方程,边界光滑、无宽无厚。但屏幕不是这样的,屏幕是离散的像素网格,每个像素要么亮、要么不亮。所谓光栅化,就是找一个合适的算法,在这片像素网格上尽可能“像”地还原那个理想圆。
这个需求听起来基础,但它的应用面特别广。我自己最早接触这个是为了在嵌入式屏幕上画仪表盘指针,后来做图像处理Demo时又碰到椭圆旋转后的绘制问题,再往后做一套跨平台绘图库时,连字体渲染里的圆角矩形都绕不开椭圆光栅化。可以说,圆和椭圆的光栅化就是所有2D图形绘制的底层基本功,无论你是写软件渲染器、做游戏、搞CAD,还是单纯想在某个小屏幕上画个好看的表盘,这一关都得过。
这篇文章的定位不是给你讲一堆纸上谈兵的数学公式,而是从实际绘制的角度出发,把中点算法、边界处理、椭圆分解这些核心点全部拆开,配合可以直接抄走的代码和排错经验。适合刚接触图形学的同学,也适合正在做实际项目、想搞清楚怎么把圆画得更快更圆的人。
2. 核心思路拆解:为什么中点算法能成为事实标准
2.1 朴素算法的致命缺陷
说到画圆,最直觉的想法是什么?把方程变换成 y = sqrt(r² - x²),然后从 x = -r 扫到 x = r,每个 x 算一个 y,点上像素就行。
这个方案想得通,但写出来就难受了。首先是慢,每次都要算开平方根,而平方根在嵌入式或者纯软件渲染环境里是个奢侈品。其次是会断,圆在左右两侧的切线是垂直的,同一个 x 对应的上下像素点分布会稀稀拉拉,画出来的圆像被狗啃过。
那用参数方程 x = r·cosθ, y = r·sinθ 呢?好一些,但依然有三角函数开销,而且如果步长设得不好,要么椭圆失真,要么计算量爆炸。步长设多少取决于半径,你不能用一个固定的增量去画所有尺寸的圆。
最致命的问题在于浮点误差。屏幕上像素坐标都是整数,你拿浮点算出来还得取整,大量取整操作既消耗性能,又会在某些半径下产生不连续的跳跃。我就见过有人在某个项目里拿参数方程画圆,半径不大时看着还行,半径一过200,圆弧上明显出现了“抖动”的毛刺,这就是角度步长没控制好导致的。
2.2 中点算法的核心思想
中点算法(Midpoint Circle Algorithm)的出现,就是为了解决上面那一堆问题。它只用了整数加法、整数减法、以及一个整数比较操作,没有平方根,没有三角函数,没有浮点数,速度极快,而且完全不会有断线问题。
它的思路说白了就是:圆的八分对称性。一个圆关于x轴、y轴、两条对角线都是对称的,所以我们只需要计算八分之一圆弧上的像素点,其余七个对称方向直接映射过去。这相当于把计算量直接砍掉87.5%。
那怎么判断下一个像素该选哪个呢?这就是“中点”二字的来源。假设当前像素在 (x, y),我们沿着圆弧朝某个方向前进时,下一个位置有两个候选像素,而决策方式就是:看这两个候选像素之间的中点,是在理想圆弧的内部还是外部。如果中点在圆内,说明下一个像素应该取外偏的那个;如果中点在圆外,就取内偏的那个。如下图所示,这就是决策参数 p 的来源。
初始时,决策参数也从整数推导出来:对圆心在原点的圆,起点是 (0, r),初始决策参数 p0 = 1 - r。注意这里坐落在标准算法推导中确实会出现一个1 - r,而不是什么复杂的浮点结果。这个数值如果非要用精确推导,p0 = 5/4 - r,但为了保持整数运算,大家统一用 1 - r,只损失一丁点判断精度,但像素级别的结果完全一致。
2.3 从圆到椭圆的自然延伸
椭圆的光栅化在思路上是圆的直接推广,但有一个绕不开的麻烦事:圆的对称轴有八条,椭圆只有四条。也就是说,你最多只能利用四个象限对称性,不能八分对称,必须完整计算四分之一段。
这就引出了一个棘手的问题:在哪个点切换算法或切换递增方向?圆算法里,从 (0, r) 出发,沿着45度角方向画到与对角线交会,这个切换点非常好找。但椭圆的长短轴比例不同,导致切线的斜率变化点在曲线上不是固定的45度,而是跟 a² 和 b² 的比值有关。具体来说,在区域1(斜率绝对值小于1)和区域2(斜率绝对值大于1)的交界处,也就是切线斜率为 -1 的附近,必须切换决策公式。这个切换条件用数值表达就是:
2·b²·x >= 2·a²·y
也就是该点的椭圆切线斜率的绝对值开始不小于1了。很多初学者在这里翻车,因为拿圆的那套直接平移过来,画出来的椭圆总是有一个象限衔接不上,出现明显折角。我在下面第3部分会把这个切换逻辑的来龙去脉讲透。
3. 实操过程与核心环节实现
3.1 圆的绘制:标准写法与直接可用的C代码
在写代码之前,明确一下环境假设:画布是一个二维整数坐标网格,坐标原点在左上角还是左下角不影响算法本质,只要你的 setPixel(x, y) 能正确点阵就行。我这里以常见的图像坐标(y向下为正)来描述,但代码逻辑是通用的。
中点圆算法的实现,按我多年实际使用的经验,下面这份代码最顺手,够简洁、无浮点、边界清晰:
void drawCircle(int cx, int cy, int r) { int x = 0; int y = r; int p = 1 - r; // 初始决策参数 // 画八分对称的初始点 drawCirclePoints(cx, cy, x, y); while (x < y) { x++; if (p < 0) { p += 2 * x + 1; } else { y--; p += 2 * (x - y) + 1; } drawCirclePoints(cx, cy, x, y); } } void drawCirclePoints(int cx, int cy, int x, int y) { setPixel(cx + x, cy + y); setPixel(cx - x, cy + y); setPixel(cx + x, cy - y); setPixel(cx - x, cy - y); setPixel(cx + y, cy + x); setPixel(cx - y, cy + x); setPixel(cx + y, cy - x); setPixel(cx - y, cy - x); }这个实现的关键点在于 p 的更新规律。当 p 小于0时,说明中点落在圆内,下个像素取“更右”的那个(y保持不变);当 p 大于等于0时,说明中点在圆外或圆上,下个像素取“右下”的那个(y减1)。这个决策完全回避了乘法除法,每一次循环只做一次加法比较,速度非常快。
在我的实测中,这个算法在普通桌面CPU上,每秒可以画几十万个半径100左右的圆,没有任何性能压力。就算拿到微控制器上,也只需要几十个时钟周期就能算完一个像素,这对于要做动画表盘或者心跳线条的设备来说,才是真正能落地的方案。
3.2 椭圆绘制:区域划分与决策切换
椭圆的情况复杂一些。假设椭圆中心在原点,半轴为 a 和 b(a >= b > 0),标准方程为 (x/a)² + (y/b)² = 1。
我们利用四个象限对称性,依次只分析第一象限。第一象限的椭圆弧又按照切线斜率分成两段:
- 区域1:从 (0, b) 出发,向右移动,此处的切线斜率绝对值小于1,y 的变化比 x 慢。在该区域,我们每次递增 x,并根据决策值决定是否递减 y。
- 区域2:从 (a, 0) 出发,向上移动,此处的切线斜率绝对值大于1,x 的变化相对 y 更缓慢。在该区域,我们每次递增 y(沿弧向上),并根据决策值决定是否递减 x。
两个区域的分界点,正好是椭圆弧切线斜率绝对值等于1的位置。这里直接给出切换条件的整数形式:
2·b²·x >= 2·a²·y
当这个条件成立,说明我们从区域1进入了区域2,需要切换决策公式。这个判断本身也很便宜,只是两次乘法和一次比较,并且只在每次迭代时做一次检查。
代码实现如下:
void drawEllipse(int cx, int cy, int a, int b) { if (a == 0 || b == 0) return; int x = 0; int y = b; long long a2 = (long long)a * a; long long b2 = (long long)b * b; // 区域1的初始决策参数 long long p1 = b2 - a2 * b + a2 / 4; drawEllipsePoints(cx, cy, x, y); // 区域1:以x递增为主导 while (2 * b2 * x <= 2 * a2 * y) { x++; if (p1 < 0) { p1 += 2 * b2 * x + b2; } else { y--; p1 += 2 * b2 * x - 2 * a2 * y + b2; } drawEllipsePoints(cx, cy, x, y); } // 区域2的初始决策参数需基于当前(x,y)重新计算 long long p2 = b2 * (x + 0.5) * (x + 0.5) + a2 * (y - 1) * (y - 1) - a2 * b2; while (y > 0) { y--; if (p2 < 0) { x++; p2 += 2 * b2 * x - 2 * a2 * y + a2; } else { // x保持不变 p2 += a2 - 2 * a2 * y; } drawEllipsePoints(cx, cy, x, y); } } void drawEllipsePoints(int cx, int cy, int x, int y) { setPixel(cx + x, cy + y); setPixel(cx - x, cy + y); setPixel(cx + x, cy - y); setPixel(cx - x, cy - y); }这段代码里有两个地方特别容易踩坑。
第一,初始决策参数的计算。区域1的初始值 p1 = b² - a²b + a²/4,如果 a 很大,这个值可能直接超出 int 的表示范围,我建议直接用 long long。这不是小题大做,当 a 和 b 都在几千级别时,中间计算量级会超过千万甚至亿,int 溢出后会出现完全无法排查的莫名其妙行为——画出来的椭圆像是被扭曲过一样。我早期在某公司实习时就被这个坑过一台设备的显示驱动,排查了一整天,最后发现只是 int 溢出。
第二,区域2的进入条件。很多教科书直接给出“当 2b²x >= 2a²y 时切换”,但这个条件判断本身放在循环的什么位置是有讲究的。最稳健的方式是:先用一个 while 循环画区域1,在循环内检测切换条件,一旦满足就跳出;随后以 (x, y) 为起点继续画区域2。这里要注意,区域2的初始 x、y 必须使用区域1结束时的那组值,不能重新从 (0, b) 开始,否则会在分界点处出现重叠或者断口。
3.3 绘制效果对比:中点算法的像素级优势
为了更直观看到算法差异,我做过一个对比实验:在同一张500x500的空白画布上,分别用参数方程方法、朴素平方根方法和中点算法画同一个半径200的圆,然后放大局部像素查看连续性和平滑度。
| 方法 | 像素连续性 | 运算开销 | 是否依赖浮点 | 断线风险 |
|---|---|---|---|---|
| 参数方程 | 较好,但有抖动风险 | 高(三角函数) | 是 | 步长设置不当就断 |
| 平方根法 | 顶部/底部稀疏 | 较高(开方) | 是 | 左右两侧明显断开 |
| 中点算法 | 极好,八向对称 | 极低(整数加减) | 否 | 无 |
从结果看,中点算法在80像素以上半径时,肉眼几乎看不出与参数方程的平滑度差异,但计算成本却低了一个数量级。关键是在小半径场景下(r小于10),所有算法都会出现像素化严重的问题,这是物理规律,跟算法无关,但中点算法的边缘会比别的算法更均匀,不会出现某个方向特别“塌”的现象。
3.4 为什么循环的终止条件不是 x == y
圆算法的循环条件是 while (x < y),不是 while (x != y)。这个细微差别背后有一个非常实际的原因:当半径 r 为偶数时,八分对称的边界像素在 x = y 的对角线上;但当 r 为奇数时,x 会略过 y,直接出现 x > y 的情况。如果你采用 while (x != y),会发现奇数半径的圆总是少画一个对角像素,看上去像有个缺口。
这不算bug,算是一个经典的边界条件失误。我见过很多开源项目里都有这个问题,用户反馈“圆上有个小洞”,排查下来基本都是这个原因。所以退一步说,while (x < y) 是最稳妥的写法,它保证对称点在必要时能完整绘制。
4. 常见问题与排查技巧实录
4.1 画出来的圆有缺口,尤其是半径较小的圆
小半径圆,比如 r = 2、3、5 之类,视觉上会有明显的“颗粒感”,这个没办法,像素网格的物理限制。但如果你画出来不是一个接近对称的形状,比如某个方向明显缺了一块,那大概率不是分辨率限制,而是算法里面某个地方把直角的点漏掉了。最常见的原因是初始点只画了一次,但算法没有在循环内把起点对应的对称点补全。
解决方案:单独定义一个 drawCirclePoints 函数,把所有八个对称点统一管理,每次更新 (x, y) 都调用一次,包括初始点。这样无论半径多小,只要循环正确,所有必要的像素都会被覆盖到。
4.2 椭圆有“折角”,区域切换点明显不连续
这个现象在椭圆长短轴差距很大的时候特别明显,比如 a=100, b=20。你会看到椭圆弧在某个位置突然出现一个“台阶”或折角,而不是平滑过渡。
原因就是区域2的起始条件不对。很多实现里,区域1结束后,直接拿着当前 x、y 去算区域2的决策参数,但在计算 p2 时套用了错误的公式。正确的做法是,在切进区域2时,必须用当时真实的 (x, y) 值重新计算 p2,公式为:
p2 = b²(x + 0.5)² + a²(y - 1)² - a²b²
这里注意,p2 的计算里依然含有浮点 0.5 项,但可以通过把式子整体乘4转换回整数域。我在代码里直接保留了浮点写法是为了便于理解,如果你需要严格整数版本,可以参考下面的等价变换:
p2 = 4·b²·x·x + 4·b²·x + b² + 4·a²·y·y - 8·a²·y + 4·a² - 4·a²·b²
这个式子有点长,但完全整数化,在嵌入式环境下可以直接用。
4.3 半径过大时性能下降,或者界面卡顿
如果只是画单个圆,通常性能不是瓶颈。但如果你在做一个需要大量绘制的场景,比如地图覆盖圆、雷达扫描圈、或者动画中每一帧都在变化位置的圆,那算法本身的常数因子就会开始产生差别。中点算法已经是最快的无浮点方案,这时候如果还慢,那就说明瓶颈不在算法上,而在绘制方式上。
我做过几个实际项目的优化,可以给你几个方向。第一,尽量减少 setPixel 调用次数,可以考虑把像素先写进一个局部缓冲区,再一次性批量提交到显存或屏幕。第二,如果圆的半径固定且大量重复出现,可以预渲染成一个位图,然后反复贴图,而不是每次重新计算。第三,在嵌入式平台上,可以把 setPixel 函数展开内联,省去函数调用开销,这个优化在小RAM平台上效果非常明显。
4.4 抗锯齿:如何让圆看起来更平滑
中电算法画出的是硬边圆,在低分辨率屏幕上锯齿会比较明显。如果你需要平滑的边缘,有两种做法:
第一种是超采样。把画布放大4倍,比如把每个像素分成2x2的子采样格,用中点算法在每个子格中心判断是否落于圆内,然后把4个子格的平均值作为最终像素的透明度。这个方法简单、效果不错,但内存开销大,适合离线渲染。
第二种是分布覆盖计算。对于每个边缘像素,不直接判断是否点亮,而是计算理想圆覆盖这个像素的面积比例。这个比例可以通过像素四个角点坐标代入圆方程计算获得,输出时按比例混合前景背景色。这个方法计算量稍大,但能实现平滑的灰度抗锯齿,视觉效果远好于二值圆。
我在一个移动端项目里用第二种方式做过测试,半径50左右的圆,开启抗锯齿后边缘平滑度人眼几乎无法察觉像素锯齿,同时帧率从120fps降到约100fps,对于普通视觉应用来说完全可接受。
4.5 处理圆心坐标不在整数点上的情况
设备坐标往往是整数,但实际应用中,圆心经常落在两个像素之间。这种情况有两种处理策略。
一是硬件上先对圆心做四舍五入,画完后有半像素偏移,视觉上一般看不出来。二是在算法内部支持半像素中心。中点算法的核心是根据中点位置做判断,如果圆心是半像素,则需要额外处理对称性。但说实话,除非你做的是CAD这类高精度绘图,否则没必要把问题搞复杂,直接四舍五入到最近整数是性价比最高的方案。
我自己在实际工程里遇到需要半像素精度的情况,有九成以上的场景都是因为绘图库的坐标系定义与设备不匹配导致的,而不是真需要半像素。先检查坐标映射,再决定要不要引入额外复杂度。
5. 实战场景:用椭圆光栅化实现一个仪表盘指针
光讲理论容易飘,我分享一个实际做过的小项目:在一个320x240的TFT屏幕上绘制仪表盘,包含圆形的表盘背景、内圈的椭圆阴影边框、以及按角度旋转的中心线指针。
指针本质上是一条从圆心出发的直线,绘制方式可以用简单的DDA直线算法。但表盘背景和阴影边框,就必须要靠圆和中点椭圆算法来画。
表盘背景是半径100的圆,直接调用中点圆算法。外圈阴影需要一个稍大半径的同心圆,采用松下浮点抗锯齿方式渲染成淡灰色,视觉上形成一层微弱的投影效果。
内圈阴影边框比较有意思。如果直接画一个椭圆再整体偏移,视觉上会显得很呆板。我当时采用的办法是:以一个中心偏移量同时画两个椭圆,一个深色一个浅色,再通过混合实现类似“立体凹陷”的视觉效果。椭圆算法在这里尤其有用,因为仪表盘的内圈很多时候不是正圆,而是为了适应外形被拉伸成椭圆。
指针的绘制要结合旋转,在每一帧计算角度变化后,用三角函数直接计算端点坐标。这里我倒不是非要用中点算法画指针,因为直线的中点算法和指针旋转完全是两回事。但如果你要让指针底部的轴心看起来像一个小圆点,那画一个半径3左右的实心圆就正好用到原点为中心的圆光栅化。
最后提一个我踩过的坑:这个项目一开始我用参数方程画圆作为表盘背景,每一帧都重新计算所有像素,导致刷新率只有十几帧。后来我把表盘背景做成静态位图,只有在初始化时画一次,之后每帧只重画指针,帧率立刻飙升到60fps满帧。这个优化不是因为算法本身慢,而是要懂得区分静态元素和动态元素,别让不需要变化的绘制拖累每一帧。
6. 进阶方向:斜椭圆、圆弧和局部绘制
6.1 旋转椭圆怎么画
标准的椭圆光栅化只能画轴对齐椭圆,也就是长轴和短轴分别平行于x轴和y轴。但实际应用中经常要画旋转过的椭圆,比如碰撞检测范围、UI动效、地图标注。
画旋转椭圆的一个实用思路:先画轴对齐椭圆,然后对整个画布做旋转逆变换。具体来说,对于目标图像上的每个像素 (px, py),把它逆旋转回原坐标系的 (x, y),然后判断 (x, y) 是否落在轴对齐椭圆内。这实际上是把光栅化问题转化为像素覆盖判断问题,和前面的中点算法完全不同,但优势是任意旋转角度都通用。
代码示意(伪代码):
for each pixel (px, py) in bounding box: x = (px - cx) * cos(-theta) - (py - cy) * sin(-theta) y = (px - cx) * sin(-theta) + (py - cy) * cos(-theta) if (x*x)/(a*a) + (y*y)/(b*b) <= 1: setPixel(px, py)这个方法慢一些,因为它要对包围盒内每个像素都做一次判断。但它在小规模应用(几百像素的椭圆)中性能完全够用,而且实现简单、不容易出错。如果追求高性能的旋转椭圆光栅化,可以去研究仿射变换下的像素覆盖面积估算,但那就属于更深一层的话题了。
6.2 圆弧绘制:从整圆到扇形
很多场景只需要绘制一段圆弧,而不是完整圆。比如仪表盘刻度、速度表的红色警示区、饼状图。处理方式很简单:在完整的圆点阵的基础上,对每个需要输出的像素,额外检查角度是否落在目标范围内。
但是,直接对所有生成像素做角度检查会浪费大量计算。更高效的办法是:利用中点算法只生成圆弧对应区域的像素,但实现复杂度会上升很多。如果只是想快速实现,先画整圆再裁剪是完全可以接受的。我经常这么干,毕竟不是每个项目都需要极致的性能,代码可读性有时候更重要。
6.3 圆角矩形的绘制
你可能没意识到,圆角矩形其实就是四条线段加上四个四分之一圆。如果在画的时候直接拼接,由于线段和圆的光栅化在直角区域的覆盖方式不同,拼接处容易出现半像素差异。
避免这个问题的方法很简单:确保四分之一圆弧的起始点和结束点恰好与线段的端点重合,然后先用线段填充中间区域,再补上圆弧部分。编程时的关键点是,每个四分之一圆只取一个象限,不要重复绘制相邻象限的边界像素,否则会在圆角矩形内部出现重复覆盖导致的颜色加深或深浅不均。
7. 写在最后的个人经验
做了这么多年图形相关的东西,我越来越觉得,光栅化这类基础算法,看起来简单,但真正能写好的人并不多。圆的算法代码往往只有十几行,但要把边界条件、整数溢出、对称点管理、区域切换这些细节全部处理对,没有几次实际踩坑是很难做到的。
我有几个小习惯,每次写光栅化代码时都会遵守,这里分享给大家。
第一,快速原型先行。先写一版最容易懂的代码,哪怕慢一点,最后再优化。别一上来就追求极致性能,那样容易把逻辑搞复杂,还不好调试。
第二,所有容易溢出的中间量,直接上 long long。特别是椭圆算法里 a²、b² 这类量,在 a 稍大时会迅速膨胀。你不一定每次都会溢出,但溢出一次就够你难受一整天。
第三,画完立刻做对称性检查。我会人工检查几个关键点,比如 (0, b)、(a, 0)、(b, a) 附近区域的像素是否对称,如果不对称,问题一定在对称函数或者区域切换逻辑里。
第四,渲染和算法分离。光栅化只负责算出“应该亮哪些像素”,至于用前景色还是背景色、要不要抗锯齿、混合模式是什么,都交给上层处理。职责分离后,算法可以复用,上层可以随意换效果。
第五,性能剖析永远不要靠猜。用计数器统计每次循环的决策更新次数、setPixel调用次数,拿真实数据说话。经常会发现,慢的根本不是光栅化本身,而是像素写入方式太笨重。
回到开头那句,光栅化是2D图形的地基。中电算法虽然诞生了几十年,但在今天的嵌入式、移动端、甚至浏览器渲染管线中,依然随处可见它的身影。理解了它,你的图形学基本功就算有了一个扎实的底座。
最后再补一句掏心窝的话:如果你正在做一个绘图相关的项目,别急着引入复杂的三方库。先从圆和椭圆这种基础问题开始,手写一遍光栅化,你得到的不仅是一个可复用的函数,更是一整套“先把逻辑想透再动手”的思维方式。这个思维,比任何现成库都值钱。