4、图形绘制基础:画点、画线、画圆算法(Bresenham)、区域填充与裁剪

嵌入式GUI的底层,说白了就是跟像素打交道。你看到的每一个按钮、每一根线条、每一个图标,最终都要落到一个个像素点上。这一章,我们就来拆解这些最基础的图形绘制操作。

我个人习惯把图形绘制分成四个层次:点是原子操作,线和圆是基本几何,填充让图形有面积,裁剪保证不出界。咱们一个一个来。

核心观点:所有复杂GUI控件,最终都归结为画点、画线、画圆、填充和裁剪这五个基础操作。理解它们的原理,你就能自己写出轻量级的图形库。

图形绘制基础 - 知识体系 图形绘制基础 画点 画线 (Bresenham) 画圆 (Bresenham) 区域填充 帧缓冲写入 坐标映射 整数运算 误差累积 八方向对称 八分圆对称 决策参数 种子填充 扫描线填充 裁剪 (Cohen-Sutherland) 💡 关键原则 所有绘制最终都调用画点函数 避免浮点运算,提升嵌入式性能

4.1 画点:一切图形的原子操作

画点是最基本的操作。在嵌入式系统中,屏幕通常对应一块内存区域,叫帧缓冲(Frame Buffer)。你往某个内存地址写一个颜色值,屏幕上对应的像素就亮了。

举个例子,假设屏幕分辨率是 320×240,颜色深度是 16位(RGB565),帧缓冲就是一个 320×240×2 字节的数组。画点函数大概长这样:

// 画点函数 - 在 (x, y) 位置画颜色 color
void draw_pixel(int x, int y, uint16_t color) {
    // 边界检查:防止越界写入
    if (x < 0 || x >= SCREEN_WIDTH) return;
    if (y < 0 || y >= SCREEN_HEIGHT) return;
    
    // 计算帧缓冲中的偏移地址
    uint16_t *fb = (uint16_t *)FRAME_BUFFER_ADDR;
    fb[y * SCREEN_WIDTH + x] = color;
}

我的经验:边界检查一定要做。我曾经在一个项目里忘了加这个判断,结果程序跑飞,画到屏幕外面去了,直接把相邻的内存区域给覆盖了,系统直接死机。从那以后,我所有画点函数第一件事就是做边界检查。

4.2 画线:Bresenham 算法

画线看起来简单,不就是两点之间连一条线吗?但如果你用浮点运算去算斜率,在嵌入式MCU上性能会很难看。Bresenham 算法的厉害之处在于——它只用整数加减法和移位操作。

核心思想是什么?说白了就是:每一步在 x 方向走一格,然后根据误差项决定 y 方向要不要走一格。这个误差项通过累加来更新,完全不用除法。

来看代码:

// Bresenham 画线算法 - 从 (x0, y0) 到 (x1, y1)
void draw_line(int x0, int y0, int x1, int y1, uint16_t color) {
    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) {
        draw_pixel(x0, y0, color);
        if (x0 == x1 && y0 == y1) break;
        
        int e2 = 2 * err;
        if (e2 > -dy) {
            err -= dy;
            x0 += sx;
        }
        if (e2 < dx) {
            err += dx;
            y0 += sy;
        }
    }
}

你想想看,这个算法里没有浮点,没有除法,只有加减和移位。在 Cortex-M 系列芯片上,画一条 100 像素的线,只需要几百个时钟周期。这就是嵌入式优化的精髓。

避坑指南:我曾经在移植这个算法时,把 e2 = 2 * err 写成了 e2 = err * 2,结果是一样的。但要注意,如果 dx 和 dy 很大,err 可能会溢出。建议用 int32_t 而不是 int,尤其是在 16位 MCU 上。

4.3 画圆:Bresenham 的八分圆对称

画圆比画线复杂一点,但 Bresenham 同样给出了优雅的整数解法。核心是利用圆的八分对称性——你只需要计算 1/8 圆弧上的点,其他 7 个点通过对称映射得到。

为什么是八分圆?因为圆在 45° 范围内,x 和 y 的变化关系刚好适合用决策参数来判断。

// Bresenham 画圆算法 - 圆心 (cx, cy),半径 r
void draw_circle(int cx, int cy, int r, uint16_t color) {
    int x = 0;
    int y = r;
    int d = 3 - 2 * r;  // 决策参数
    
    while (x <= y) {
        // 利用八分对称性画 8 个点
        draw_pixel(cx + x, cy + y, color);
        draw_pixel(cx - x, cy + y, color);
        draw_pixel(cx + x, cy - y, color);
        draw_pixel(cx - x, cy - y, color);
        draw_pixel(cx + y, cy + x, color);
        draw_pixel(cx - y, cy + x, color);
        draw_pixel(cx + y, cy - x, color);
        draw_pixel(cx - y, cy - x, color);
        
        if (d < 0) {
            d = d + 4 * x + 6;
        } else {
            d = d + 4 * (x - y) + 10;
            y--;
        }
        x++;
    }
}

嗯,这里要注意决策参数 d 的初始值。我见过很多初学者把 d = 3 - 2*r 记成 d = 1 - r,那是旧版本的写法,在整数运算下会有精度问题。用 3 - 2*r 更稳妥。

注意:当半径 r 很大时(比如超过屏幕尺寸的一半),画圆可能会超出屏幕边界。一定要在 draw_pixel 里做裁剪,或者在画圆函数里提前判断。我踩过这个坑——画一个大圆,结果屏幕边缘出现奇怪的像素点,排查了半天才发现是坐标溢出了。

4.4 区域填充:种子填充与扫描线

画完轮廓,接下来要填色。区域填充有两种主流方法:

方法 原理 优点 缺点
种子填充 从内部一点开始,向四个方向扩散 实现简单,适合任意形状 递归深度大,容易栈溢出
扫描线填充 按行扫描,找到边界内的区间并填充 效率高,无递归 实现稍复杂,需要处理边界

在嵌入式系统里,我强烈建议用扫描线填充。为什么?种子填充用递归,MCU 的栈空间通常只有几 KB,画一个稍微大点的图形,递归几百层,栈就爆了。

扫描线填充的核心步骤:

  1. 找到当前行的左边界和右边界
  2. 从左到右填充这一行
  3. 检查上一行和下一行,找到新的边界区间
  4. 重复直到所有行处理完毕

我的习惯:扫描线填充配合一个链表来存储待处理的区间,比用数组更灵活。我在一个温控器项目里用这种方法填充曲线下方的区域,效果很好,而且内存占用只有几百字节。

4.5 裁剪:Cohen-Sutherland 算法

裁剪解决的是「画到屏幕外面去了」的问题。最经典的算法是 Cohen-Sutherland 线段裁剪算法。

它的思路很巧妙:把屏幕分成 9 个区域,每个区域用一个 4 位编码表示。编码的每一位代表点在屏幕的哪一侧:

位 含义 条件
bit 3 (8) 上侧 y > y_max
bit 2 (4) 下侧 y < y_min
bit 1 (2) 右侧 x > x_max
bit 0 (1) 左侧 x < x_min

算法流程:

  1. 计算线段两个端点的编码
  2. 如果两个编码都是 0000,线段完全在屏幕内,直接画
  3. 如果两个编码按位与不为 0,线段完全在屏幕外,丢弃
  4. 否则,裁剪掉在屏幕外的部分,更新端点,重复
// 计算点的裁剪编码
int compute_code(int x, int y) {
    int code = 0;
    if (y > Y_MAX) code |= 8;  // 上
    else if (y < Y_MIN) code |= 4;  // 下
    if (x > X_MAX) code |= 2;  // 右
    else if (x < X_MIN) code |= 1;  // 左
    return code;
}

核心要点:裁剪不是可选项,是必需品。没有裁剪,你的 GUI 在屏幕边缘就会出现各种奇怪的「拖尾」和「溢出」。我见过一个产品,因为没做裁剪,在屏幕边缘画线时把显存写穿了,导致系统重启。

4.6 本章小结

这一章我们拆解了嵌入式 GUI 最底层的图形绘制操作。画点是基础,Bresenham 算法用整数运算解决了画线和画圆的性能问题,扫描线填充比递归填充更适合嵌入式环境,Cohen-Sutherland 裁剪保证了绘制不出界。

这些算法看起来简单,但它们是所有 GUI 控件的基石。你想想看,一个按钮就是圆角矩形 + 文字,一个进度条就是矩形填充 + 裁剪,一个图表就是折线 + 区域填充。把这些基础打牢,后面写控件就是搭积木了。