4、图形绘制基础:画点、画线、画圆算法(Bresenham)、区域填充与裁剪
嵌入式GUI的底层,说白了就是跟像素打交道。你看到的每一个按钮、每一根线条、每一个图标,最终都要落到一个个像素点上。这一章,我们就来拆解这些最基础的图形绘制操作。
我个人习惯把图形绘制分成四个层次:点是原子操作,线和圆是基本几何,填充让图形有面积,裁剪保证不出界。咱们一个一个来。
核心观点:所有复杂GUI控件,最终都归结为画点、画线、画圆、填充和裁剪这五个基础操作。理解它们的原理,你就能自己写出轻量级的图形库。
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,画一个稍微大点的图形,递归几百层,栈就爆了。
扫描线填充的核心步骤:
- 找到当前行的左边界和右边界
- 从左到右填充这一行
- 检查上一行和下一行,找到新的边界区间
- 重复直到所有行处理完毕
我的习惯:扫描线填充配合一个链表来存储待处理的区间,比用数组更灵活。我在一个温控器项目里用这种方法填充曲线下方的区域,效果很好,而且内存占用只有几百字节。
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 |
算法流程:
- 计算线段两个端点的编码
- 如果两个编码都是 0000,线段完全在屏幕内,直接画
- 如果两个编码按位与不为 0,线段完全在屏幕外,丢弃
- 否则,裁剪掉在屏幕外的部分,更新端点,重复
// 计算点的裁剪编码
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 控件的基石。你想想看,一个按钮就是圆角矩形 + 文字,一个进度条就是矩形填充 + 裁剪,一个图表就是折线 + 区域填充。把这些基础打牢,后面写控件就是搭积木了。