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

文章详情

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

嵌入式屏幕高效画圆:Bresenham算法原理与ILI9806G实战

嵌入式屏幕高效画圆:Bresenham算法原理与ILI9806G实战 嵌入式屏幕上画一个圆听起来不是什么复杂事但真正做过的人都知道这里面坑不少。如果用数学库里的sin、cos去算圆周点在电脑上毫无压力换到单片机上一跑尤其是配了ILI9806G这类TFT彩色屏幕速度立刻拉胯有时候甚至一个圆都画不利索。这时候Bresenham画圆算法几乎是标准答案它只用整数加减法和比较运算就能把圆完整地画出来而且每个圆周点最多只需要几次整数运算对单片机来说非常友好。这篇文章我打算从两个层面展开先讲透Bresenham画圆算法到底是怎么从数学公式变成递推代码的然后把它落到ILI9806G驱动的LCD屏上给出可以直接抄的C代码、画点函数、画圆函数以及我在实际移植过程中踩过的坑和处理技巧。不管你是刚开始玩屏幕驱动的新手还是已经在做仪表盘、菜单界面、小游戏这类图形功能的老手这份内容应该都能帮你少走不少弯路。1. 先搞清楚为什么嵌入式画圆要用Bresenham1.1 浮点数学函数在单片机上的真实开销很多人第一次在单片机上画圆脑子里蹦出来的第一反应是圆上的每个点坐标不是可以用圆心加半径乘角度算出来吗公式很简单x x0 r * cos(θ)y y0 r * sin(θ)理论上没错但在单片机上执行就完全是另一回事。假设我们要画一个半径100的圆如果每3度取一个点一圈需要120个点这意味着要计算120次cos和120次sin。在带FPU的Cortex-M4F上还能忍受如果你用的是经典的51单片机或者低端Cortex-M0浮点库函数的开销非常可观一次sin调用可能消耗几百甚至上千个指令周期再加上浮点转整型的操作画一个圆可能要让屏幕明显卡顿一下。这还没算另一个问题用等角度步进画出来的点在圆的左右两端会比较稀疏上下两端又很密集视觉上不美观。如果为了均匀而增加取点密度计算量还会进一步上升。所以在嵌入式场景里用浮点三角函数画圆并不是一个划算的方案。1.2 Bresenham的优势和适用场景Bresenham画圆算法的核心思路是把“找圆上点”的问题转化成“从当前像素点走到下一个像素点应该选右边还是右下”的判断题。这个判断用到的所有值都是整数只有加法、减法和比较运算不涉及乘法、除法、浮点连sqrt都不需要。这套算法最初是由Jack Bresenham在画直线时提出的后来经过扩展中点圆算法也被很多教材统一归到Bresenham名下。它在计算机图形学里的地位非常高尤其适合资源受限的嵌入式平台。具体到应用场景常见的包括仪表盘上的圆形表盘、进度环、状态指示圆点菜单系统的圆形按钮、头像裁剪框小游戏里的碰撞体显示、角色范围圈调色板、色环、雷达扫描界面本质上只要你面对的是一块带“画点”能力的LCD屏无论是ILI9806G、ILI9341、ST7735还是SSD1306这类单色OLED这套算法都能用因为它和具体屏幕芯片无关最终只依赖一个底层函数往指定坐标画一个点。2. Bresenham画圆算法原理从圆的对称性到整数递推2.1 圆的八分对称性只需算八分之一的点圆有一个很漂亮的几何性质它关于x轴对称、关于y轴对称还关于yx和y-x这两条对角线对称。也就是说整个圆可以分成八个完全相同的扇区。只要算出第一象限里面从(0, r)到(r/√2, r/√2)这段45度弧上的点其他七个部分的点全部可以通过坐标映射得到。举个例子如果算法当前计算出一个点(x, y)那么它对应的八个对称点分别是(x, y)(-x, y)(x, -y)(-x, -y)(y, x)(-y, x)(y, -x)(-y, -x)这个性质是Bresenham画圆能够做到高效率的关键。一次循环里虽然画了8个点但决策计算只做一次相当于用一份计算量画了八段圆弧。2.2 决策参数的推导怎么判断下一个像素落在哪现在我们只看从(0, r)开始向右下方向走的这段弧线。假设当前已经确定了像素点(x, y)下一步只有两个候选位置正右方的(x1, y)以及右下方的(x1, y-1)。问题就变成哪一个点离真实圆弧更近为了判断取这两个候选点之间的中点也就是(x1, y-0.5)。把这个中点代入圆的隐式方程F(x, y) x² y² - r²如果F(mid) 0说明中点在圆内部那么圆弧更靠近右边点(x1, y)所以下一步选择向右走y保持不变如果F(mid) 0说明中点在圆外部或正好在圆上那么圆弧更靠近右下点(x1, y-1)所以下一步选择向右下走y减1。这里定义的F(mid)就是决策参数p。标准教科书里p的初始值是5/4 - r但为了避免浮点数大家通常直接用 p 1 - r。这个改动只影响初始判断的微小偏差对整数坐标下的显示效果几乎无影响却能让全程都用整数运算。接下来就是推导递推式。每次x都会加1但y是否减1取决于p的正负所以p的更新分两种情况当p 0时下一个点取(x1, y)所以新的决策参数为p_new p 2x 3这个式子里x是当前点的x不是更新之后的x。当p 0时下一个点取(x1, y-1)所以p_new p 2(x - y) 5。代码实现时如果先让x自增再更新p这两个递推式可以改写成p 0时p 2 * x 1此时x已经加1p 0时y--p 2 * (x - y) 1x和y都是更新后的值两种写法结果完全等价只是维护方式不同。我后面给的C代码采用第二种写法因为它可以少写几次加减法。2.3 初始值和终止条件不要写错边界算法从圆的顶部点(0, r)开始所以x 0y rp 1 - r循环条件是 x y直到x超过y说明已经越过45度分界线再继续画就会和已经画过的对称点重复了。这个终止条件很容易被写成 x y那样会漏掉最后靠近对角线的点。虽然因为对称点的存在漏掉的点有时候已经被(sy, sx)这类映射补上但在半径比较小的圆上可能出现缺口。从数学上看这个循环每次迭代最多画8个点总共执行约r/√2次整体复杂度O(r)。一个半径100的圆循环大约71次就能画出全部像素效率非常高。3. 把算法变成C代码ILI9806G画点与画圆实战3.1 先搞定LCD的画点函数Bresenham算法再漂亮最终落地的依赖只有一个能在屏幕上指定坐标画出一个指定颜色的点。所以第一步是把ILI9806G驱动的画点函数写稳定。ILI9806G是一款常见的TFT LCD控制器通常用在4.3寸800x480的屏幕上内部自带GRAM显示内存。它的操作流程很典型初始化控制器发送初始化命令序列这部分一般由屏厂或驱动库提供每次绘图前先通过命令0x2A设置列地址Column Address通过命令0x2B设置行地址Page Address这两个命令共同划定一个绘图窗口发送命令0x2C进入写GRAM状态连续送入像素数据数据会自动按窗口从左到右、从上到下填充这里有一个非常重要的细节ILI9806G的窗口左边界和右边界都是闭区间也就是说画一个在坐标(x0, y0)到(x1, y1)之间的矩形窗口实际宽度是x1 - x0 1高度是y1 - y0 1。如果这个区间计算错了画出来的图会出现偏移或拉伸。画单点的函数可以这样写#define LCD_WIDTH 800 #define LCD_HEIGHT 480 static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); lcd_write_data(x0 8); lcd_write_data(x0 0xFF); lcd_write_data(x1 8); lcd_write_data(x1 0xFF); lcd_write_cmd(0x2B); lcd_write_data(y0 8); lcd_write_data(y0 0xFF); lcd_write_data(y1 8); lcd_write_data(y1 0xFF); lcd_write_cmd(0x2C); } void lcd_draw_point(int16_t x, int16_t y, uint16_t color) { if (x 0 || x LCD_WIDTH || y 0 || y LCD_HEIGHT) { return; } lcd_set_window(x, y, x, y); lcd_write_data(color 8); lcd_write_data(color 0xFF); }注意画点函数里的边界判断不能省。Bresenham算法在圆弧靠近屏幕边缘时会出现负坐标或超出分辨率的坐标如果不先过滤轻则画出错误颜色重则导致窗口坐标溢出整个屏幕显示异常。颜色这里我直接按RGB565格式发送先发高字节再发低字节。部分屏幕或接口需要反序这个我在后面的排查章节会单独讲。3.2 标准Bresenham画圆C代码有了画点函数画圆函数就水到渠成了。先定义一个内部的对称点绘制函数一次性把8个对称点全部画出来static void draw_circle_points(int16_t xc, int16_t yc, int16_t x, int16_t y, uint16_t color) { lcd_draw_point(xc x, yc y, color); lcd_draw_point(xc - x, yc y, color); lcd_draw_point(xc x, yc - y, color); lcd_draw_point(xc - x, yc - y, color); lcd_draw_point(xc y, yc x, color); lcd_draw_point(xc - y, yc x, color); lcd_draw_point(xc y, yc - x, color); lcd_draw_point(xc - y, yc - x, color); }然后是根据递推式实现的画圆主函数void lcd_draw_circle(int16_t xc, int16_t yc, int16_t r, uint16_t color) { int16_t x 0; int16_t y r; int16_t p 1 - r; while (x y) { draw_circle_points(xc, yc, x, y, color); x; if (p 0) { p 2 * x 1; } else { y--; p 2 * (x - y) 1; } } }这段代码逻辑很紧凑但有一个容易让人困惑的地方p的更新是在x之后进行的。我在2.2节说过这样写等于把标准递推式里“2x3”里的那个3拆成了“2 * (x_new) 1”数学上是完全等价的。如果你看别的资料时发现递推式对不上多半就是因为x的取值时机不同代码没有错只是表述习惯不同。用的时候很简单// 在屏幕中央画一个半径120的白色圆 lcd_draw_circle(LCD_WIDTH / 2, LCD_HEIGHT / 2, 120, 0xFFFF);3.3 为什么代码这样写能直接跑可能有读者会问这个算法不是从圆心(0,0)推出来的吗为什么我传入任意圆心(xc, yc)也能正常显示因为算法里真正的几何计算始终是以圆心为原点进行的。x和y只是相对圆心的偏移量画点的时候再加上圆心坐标就完成了从局部坐标系到屏幕坐标系的平移。这个过程只需要两次加法不改变算法的整数性质也不增加额外复杂度。另外一点需要注意的是数据类型。上面代码里我用了int16_t因为坐标值和半径在绝大多数屏幕上不会超过32767。但如果你的屏幕分辨率特别大或者半径值接近1000以上建议直接升到int32_t避免乘法或累加过程中溢出。我第一次在51上移植时吃过这个亏后面专门列一节来讲。4. 性能优化与功能扩展填充圆、局部刷新与圆弧4.1 填充圆的实现思路画完空心圆很多人下一步就想画实心圆。最直观的办法是在Bresenham循环的每一步不再画圆周上的8个点而是画4条水平线把整个圆内部的像素从圆心所在行开始逐行填充。实现代码如下void lcd_draw_hline(int16_t x0, int16_t x1, int16_t y, uint16_t color) { if (x0 x1) { int16_t t x0; x0 x1; x1 t; } if (y 0 || y LCD_HEIGHT) { return; } if (x1 0 || x0 LCD_WIDTH) { return; } if (x0 0) { x0 0; } if (x1 LCD_WIDTH) { x1 LCD_WIDTH - 1; } lcd_set_window(x0, y, x1, y); for (int16_t x x0; x x1; x) { lcd_write_data(color 8); lcd_write_data(color 0xFF); } } void lcd_fill_circle(int16_t xc, int16_t yc, int16_t r, uint16_t color) { int16_t x 0; int16_t y r; int16_t p 1 - r; while (x y) { lcd_draw_hline(xc - x, xc x, yc y, color); lcd_draw_hline(xc - x, xc x, yc - y, color); lcd_draw_hline(xc - y, xc y, yc x, color); lcd_draw_hline(xc - y, xc y, yc - x, color); x; if (p 0) { p 2 * x 1; } else { y--; p 2 * (x - y) 1; } } }这段代码里lcd_draw_hline把画点函数升级成了画水平线函数一次设置窗口后连续写入多个像素比逐个点填充快很多。因为窗口本身是矩形的画一条水平线就是设置一个1像素高的窗口然后连续写多个颜色数据。稍微解释一下为什么一次要画4条水平线当前算法维护的(x, y)同时代表了圆在第一象限的两个对称位置(xc ± x, yc ± y)和(xc ± y, yc ± x)分别对应靠近水平轴和靠近垂直轴的两段弧线。对这两段弧线同时做水平填充就能把圆内所有行都覆盖到不会出现漏行或者半圆空白。这个实现有一个轻微缺点当x和y很接近时部分水平线可能重复绘制但因为只是多写几次同样颜色的像素视觉结果完全正确性能影响也有限。如果你特别在意效率可以记录上一次绘制的y值重复时跳过但多数场景下没必要。4.2 减少闪烁的局部窗口刷新画小圆还好一旦半径超过200或者界面里同时有很多圆需要频繁重绘“闪烁”和“重影”就会出现。原因很简单LCD的GRAM写入速度有限如果你每次重绘前都执行lcd_clear把整个屏幕清掉一帧画面里有大量时间是黑屏状态肉眼看起来就是闪烁。解决思路有几个第一能用局部刷新就不要全屏刷新。比如进度环变化时只需要在圆的外接矩形范围内重新绘制外接矩形的范围是左上角(xc - r, yc - r)右下角(xc r, yc r)如果屏幕有硬件窗口功能可以先设置这个矩形窗口然后在这个区域内重绘而不是全屏清空。第二如果MCU支持DMA可以把一行或一个窗口的像素数据放到缓冲区里用DMA发送到LCD接口。这样CPU不用一个点一个点地等时序可以去做其他事情整体刷新速度提升非常明显。第三如果界面元素固定引擎可以只在初始化时画一次之后不重绘只在参数变化时重绘对应区域。这是做嵌入式GUI最基础的优化思路。4.3 圆弧、椭圆的进一步扩展Bresenham画圆算法直接画的是完整圆但很多界面需要的是圆弧或扇形。一个比较省事的办法是在draw_circle_points里对每个对称点判断角度只绘制落在指定角度范围内的点其他点跳过。不过角度判断需要用到atan2或查表比较费资源。更好的做法是利用点(x, y)与坐标轴的关系来计算角度。比如在从(0, r)向下走的弧线上当前点相对圆心的角度就是 atan2(y, x)。如果不想引入浮点可以预置一个角度阈值表或者通过比较x和y的比例关系来粗粒度筛选。椭圆可以看成x轴和y轴方向半径不同的圆Bresenham算法也能扩展到椭圆但递推式更复杂需要同时维护两个决策参数分别对应x方向和y方向的误差累积。碰到这种需求时如果屏幕只是用来显示静态椭圆用参数方程加查表反而更简单如果要做实时旋转的椭圆或环形动画那就得老老实实研究中点椭圆算法了。对大多数嵌入式GUI来说掌握空心圆和实心圆已经能覆盖80%以上的圆形需求更复杂的图形通常用图片或矢量字库来替代。5. 上机实测与高频问题排查5.1 从空工程到出现圆的完整步骤我每次在新板子上跑这套代码都会按下面的顺序来基本不会出乱子先确保屏幕能初始化成功用lcd_clear(0x0000)把屏幕清成黑色再逐行刷几条彩色横线确认接口读写正常。单独画几个离散点比如在屏幕四角和中心各画一个点验证lcd_draw_point的坐标方向是否正确。如果点出来的位置和预期镜像了多半是初始化序列里的扫描方向或RGB顺序没设对。画一条横线和一条竖线验证单像素直线正常。这个步骤能把窗口命令0x2A、0x2B和写GRAM命令0x2C的问题暴露出来。调用lcd_draw_circle先用半径10的小圆再用半径100的大圆。小圆用来检查对称性大圆用来检查边界裁剪。如果一切正常再加上填充圆、进度环等扩展功能。这套流程看起来很简单但能帮你把问题快速隔离到“驱动层”还是“算法层”。我见过太多人一上来就直接调画圆函数结果屏幕花屏最后发现是初始化序列里扫描方向设置错了算法本身一点问题没有。5.2 不同绘制方式的耗时对比下面这张表是我在STM32F103主频72MHz、使用16位并口驱动ILI9806G屏幕时的经验数据不同主频和不同接口会差很多但相对关系是固定的绘制方式主要开销体验感受浮点三角函数逐点画每个点做sin/cos和乘法FPU缺失时极慢明显卡顿甚至无法流畅刷新查表法逐点连线查表快但点数多时画线指令多较快但代码占ROM角度分辨率固定Bresenham画圆纯整数加减每步画8点很快适合实时刷新Bresenham填充圆画水平线窗口连续写入比逐点快很多适合大圆我给一个参考值半径100的圆用Bresenham算法计算部分只循环约71次也就是几百条整数指令在72MHz主频下耗时几乎可以忽略真正的时间都花在LCD写点上。如果屏幕接口是并行16位并且有DMA画一个半径100的圆可以做到几毫秒内完成。所以当你在单片机上觉得画圆慢先不要怀疑算法优先检查LCD接口时序和lcd_draw_point到lcd_draw_hline的优化。把逐点画改成窗口连续写入往往会有几倍到十几倍的提升。5.3 常见问题速查表我在实际调机过程中整理过一些高频问题放在这里方便对照排查现象可能原因排查方向圆变成断断续续的点窗口设置没生效或者写GRAM和设置窗口之间插了多余命令先用画点函数测试再连续画水平线测试圆变成椭圆LCD_WIDTH和LCD_HEIGHT反了或者扫描方向设置和驱动IC不一致检查初始化序列中的扫描方向寄存器和宏定义分辨率颜色不对偏色或反色RGB565高低字节顺序反了交换lcd_write_data的高字节和低字节顺序圆位置不对偏移了半个屏列地址或行地址的区间计算错误对照ILI9806G数据手册仔细检查0x2A和0x2B参数半径超过一定值后画圆死机坐标溢出或变量类型溢出把int16_t换成int32_t检查越界坐标裁剪屏幕闪烁、有残影全屏反复清空重绘改成局部窗口刷新小圆画出来很粗糙半径太小整数像素本身就有锯齿属正常现象可考虑开启LCD的抗锯齿或改用更高分辨率屏幕注意ILI9806G的窗口区间是闭区间也就是说设置窗口参数时x1和y1就是实际右下角坐标不是“宽度减一”之外再减一。这个细节一旦搞错整个画面都会错位。6. 移植中容易被忽略的细节6.1 变量类型宽度会让算法突然“卡死”我第一次在8051平台上移植这段代码时为了省RAM把坐标变量全部定义成了char。当时画的圆半径不大一切正常。后来把半径调大到150画圆函数直接不输出任何点程序看起来像死循环了。查了半天才发现char在51平台是8位最大只有127半径150时的y r直接变成负数循环条件x y永远为假。这个坑需要特别提醒在嵌入式平台int的宽度并不是固定的8位机上的int是16位而char可能是8位。跨平台移植时不要依赖默认类型直接用int16_t、uint16_t这类明确宽度的类型或者统一用int然后保证半径和坐标都落在有效范围内。6.2 接口时序与颜色字节顺序ILI9806G本身支持多种接口常见的包括16位并口、8位并口、SPI和RGB接口。不管用哪种都要确认数据线的高低位顺序和写时序是否与驱动器匹配。这个问题最典型的症状是屏幕能亮、能显示内容但颜色完全不对或者画出来的是“噪点”而不是规则图形。调试这类问题时不要直接去分析画圆对不对而是先写一个纯色测试函数依次填充红、绿、蓝观察屏幕颜色是否正确。如果红色显示成蓝色问题大概率出在RGB565的数据字节顺序上调整一下lcd_write_data(color 8)和lcd_write_data(color 0xFF)的顺序就能解决。6.3 从画圆到图形界面的扩展建议如果你做的是一个完整的嵌入式GUI只靠画圆函数肯定不够但画圆是整个基础图形库里非常重要的一个模块。可以考虑把画圆、填充圆、画水平线、画垂直线、填充矩形这些基础函数封装成统一的图形库再基于它们实现圆环进度条、雷达扫描、菜单按钮等控件。我在实际项目里的习惯是把LCD底层驱动和图形绘制分开两层底层驱动只负责lcd_set_window、lcd_draw_point、lcd_draw_hline这类函数上层图形库再基于底层函数实现画圆、填充、圆弧、多边形。这样换屏幕型号时只需改底层驱动上层图形代码可以无缝复用。最后再说一个隐蔽的小经验如果你在画填充圆时发现圆心附近有一条竖线颜色不对先别怀疑算法看看lcd_draw_hline里窗口宽度是不是0。有些屏幕控制器在窗口宽度为0时不会写入任何数据有些则会只写一个点不同芯片的差异比较大遇到时把窗口宽度强制设为1就能规避。
返回列表