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 排查了三天,最后发现是触摸坐标没做限幅。
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 倍。
4.7 知识体系总览
下面这张图把本章的核心知识点串了起来。你可以看到,所有复杂图形都建立在「画点」这个原语之上,而颜色混合则贯穿始终。
4.8 避坑指南
最后,分享几个我实际项目中踩过的坑:
- 颜色格式不匹配:RGB565 和 RGB888 混用,导致颜色显示完全不对。我曾经花了一下午才发现是颜色转换函数写错了。
- Alpha 混合顺序:多个半透明图层叠加时,混合顺序会影响最终效果。正确的做法是从后往前混合。
- 性能瓶颈:在低端 MCU 上,逐像素操作非常慢。能用 DMA 就用 DMA,能用查表就用查表。