多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

中点算法详解:圆与椭圆的光栅化实现与优化

中点算法详解:圆与椭圆的光栅化实现与优化 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 和 ba b 0标准方程为 (x/a)² (y/b)² 1。我们利用四个象限对称性依次只分析第一象限。第一象限的椭圆弧又按照切线斜率分成两段区域1从 (0, b) 出发向右移动此处的切线斜率绝对值小于1y 的变化比 x 慢。在该区域我们每次递增 x并根据决策值决定是否递减 y。区域2从 (a, 0) 出发向上移动此处的切线斜率绝对值大于1x 的变化相对 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 椭圆有“折角”区域切换点明显不连续这个现象在椭圆长短轴差距很大的时候特别明显比如 a100, b20。你会看到椭圆弧在某个位置突然出现一个“台阶”或折角而不是平滑过渡。原因就是区域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图形的地基。中电算法虽然诞生了几十年但在今天的嵌入式、移动端、甚至浏览器渲染管线中依然随处可见它的身影。理解了它你的图形学基本功就算有了一个扎实的底座。最后再补一句掏心窝的话如果你正在做一个绘图相关的项目别急着引入复杂的三方库。先从圆和椭圆这种基础问题开始手写一遍光栅化你得到的不仅是一个可复用的函数更是一整套“先把逻辑想透再动手”的思维方式。这个思维比任何现成库都值钱。
返回列表