4、图形绘制原语:画点、画线、画矩形、画圆、区域填充、颜色混合(Alpha Blending)

图形绘制原语,说白了就是 GUI 系统里最基础的「画笔动作」。你想想看,屏幕上再复杂的界面,拆到最底层,无非就是点、线、面这些基本元素的组合。我刚开始做嵌入式 GUI 时,总觉得这些太简单,结果在优化一个手表表盘渲染时,被一个画圆算法卡了整整两天……嗯,基础不牢,地动山摇。

4.1 画点:一切图形的起点

画点是所有绘制操作的基础。你屏幕上每个像素,本质上就是一个点。在嵌入式系统中,画点通常直接操作帧缓冲(Frame Buffer)的某个内存地址。

举个例子,假设你的帧缓冲起始地址是 fb_addr,屏幕分辨率是 320×240,每个像素占 2 字节(RGB565 格式)。那么画一个点 (x, y) 的代码大概长这样:

void draw_pixel(int x, int y, uint16_t color) {
    if (x < 0 || x >= 320 || y < 0 || y >= 240) return;  // 裁剪
    uint16_t *fb = (uint16_t *)FB_ADDR;
    fb[y * 320 + x] = color;
}

这里有个坑,我踩过好几次——边界检查。有些新手图省事不写裁剪,结果坐标越界时,直接写到了帧缓冲之外的内存区域,轻则花屏,重则系统崩溃。我曾经在一个项目里因为这个 bug 排查了三天,最后发现是触摸坐标没做限幅。

注意:画点函数必须做裁剪(Clipping)。不做裁剪的后果,轻则显示异常,重则内存踩踏。这是嵌入式 GUI 开发的第一条铁律。

4.2 画线:从 Bresenham 到实际应用

画线比画点复杂一点。最经典的算法是 Bresenham 直线算法——它只用整数加减法,没有浮点运算,非常适合嵌入式环境。

我个人习惯把 Bresenham 算法封装成一个通用函数,支持任意斜率。核心思路是:根据误差项决定下一个点是在水平方向走一步,还是在对角线方向走一步。

void draw_line(int x0, int y0, int x1, int y1, uint16_t color) {
    int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
    int err = dx + dy, e2;

    while (1) {
        draw_pixel(x0, y0, color);
        if (x0 == x1 && y0 == y1) break;
        e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; }
        if (e2 <= dx) { err += dx; y0 += sy; }
    }
}

你可能会问:为什么不用更简单的 DDA 算法?DDA 涉及浮点运算,在 Cortex-M0 这类没有 FPU 的芯片上,性能差距很明显。我实测过,同样画一条 200 像素的线,Bresenham 比 DDA 快了将近 3 倍。

4.3 画矩形:填充与边框

矩形分两种:空心(边框)和实心(填充)。空心矩形其实就是四条线,实心矩形则是逐行填充。

实心矩形的优化空间很大。最直接的做法是嵌套循环逐点画,但效率太低。更好的做法是利用 CPU 的 memset 或 DMA 批量写内存:

void fill_rect(int x, int y, int w, int h, uint16_t color) {
    for (int row = y; row < y + h; row++) {
        uint16_t *line_start = (uint16_t *)FB_ADDR + row * 320 + x;
        for (int col = 0; col < w; col++) {
            line_start[col] = color;
        }
    }
}

如果硬件支持 DMA 2D 传输,还可以进一步优化。我在做智能家居面板时,就用 DMA 填充背景色,帧率从 15fps 直接提到了 30fps。

小技巧:画圆角矩形时,四个角用画圆算法生成弧线,中间部分用普通矩形填充。这样既美观,又不会增加太多计算量。

4.4 画圆:中点圆算法

画圆最常用的是中点圆算法(Midpoint Circle Algorithm)。它和 Bresenham 直线算法类似,也是纯整数运算,利用圆的八对称性,只计算 1/8 圆弧,然后镜像到其他象限。

void draw_circle(int cx, int cy, int r, uint16_t color) {
    int x = 0, y = r, d = 1 - r;
    while (x <= y) {
        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 += 2 * x + 3;
        else { d += 2 * (x - y) + 5; y--; }
        x++;
    }
}

这里有个细节:圆的边界像素可能会因为取整问题出现「锯齿」。我一般会在画圆后做一次简单的抗锯齿处理,或者直接使用硬件加速的圆绘制接口。

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

区域填充用于给一个封闭区域上色。常见的有两种方法:

  • 种子填充(Flood Fill):从一个种子点开始,向四周扩散,直到碰到边界。实现简单,但递归深度大,容易栈溢出。
  • 扫描线填充(Scanline Fill):先找到区域的边界,然后逐行填充。效率高,适合嵌入式。

我建议优先用扫描线填充。曾经有个项目,用种子填充给一个复杂多边形上色,结果递归深度超过 500 层,直接导致栈溢出。换成扫描线后,问题迎刃而解。

算法 优点 缺点 适用场景
种子填充 实现简单,代码量少 递归深度大,可能栈溢出 小区域、简单形状
扫描线填充 效率高,内存占用稳定 实现稍复杂,需要边界表 大区域、复杂多边形

4.6 颜色混合(Alpha Blending)

Alpha Blending 是实现透明效果的核心技术。它的公式很简单:

输出颜色 = 前景色 × α + 背景色 × (1 - α)

其中 α 的取值范围是 0~1(或 0~255)。在嵌入式系统中,为了避开浮点运算,我们通常用整数实现:

uint16_t alpha_blend(uint16_t fg, uint16_t bg, uint8_t alpha) {
    // 将 RGB565 拆分为 R、G、B 分量
    uint8_t fg_r = (fg >> 11) & 0x1F;
    uint8_t fg_g = (fg >> 5) & 0x3F;
    uint8_t fg_b = fg & 0x1F;
    uint8_t bg_r = (bg >> 11) & 0x1F;
    uint8_t bg_g = (bg >> 5) & 0x3F;
    uint8_t bg_b = bg & 0x1F;

    // 混合计算(使用查表法加速)
    uint8_t out_r = (fg_r * alpha + bg_r * (255 - alpha)) >> 8;
    uint8_t out_g = (fg_g * alpha + bg_g * (255 - alpha)) >> 8;
    uint8_t out_b = (fg_b * alpha + bg_b * (255 - alpha)) >> 8;

    return (out_r << 11) | (out_g << 5) | out_b;
}

这里有个性能问题:每次混合都要做乘法和除法,在低端 MCU 上很慢。我常用的优化手段是预计算查表法——把 alpha 值对应的混合系数提前算好存成数组,运行时直接查表,速度能提升 5~10 倍。

核心要点:Alpha Blending 是 GUI 实现阴影、渐变、半透明窗口的基础。如果你的硬件支持硬件混合(如 STM32 的 Chrom-ART),尽量用硬件加速,能省下大量 CPU 时间。

4.7 知识体系总览

下面这张图把本章的核心知识点串了起来。你可以看到,所有复杂图形都建立在「画点」这个原语之上,而颜色混合则贯穿始终。

图形绘制原语知识体系 画点 (draw_pixel) 画线 (Bresenham) 画矩形 (填充/边框) 画圆 (中点圆算法) 区域填充 (扫描线) 颜色混合 (Alpha Blending) 核心原则 所有复杂图形都建立在「画点」原语之上 颜色混合贯穿所有绘制操作,是实现透明效果的基础 裁剪(Clipping)是每个绘制函数必须做的第一件事

4.8 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • 颜色格式不匹配:RGB565 和 RGB888 混用,导致颜色显示完全不对。我曾经花了一下午才发现是颜色转换函数写错了。
  • Alpha 混合顺序:多个半透明图层叠加时,混合顺序会影响最终效果。正确的做法是从后往前混合。
  • 性能瓶颈:在低端 MCU 上,逐像素操作非常慢。能用 DMA 就用 DMA,能用查表就用查表。