第3章 像素坐标映射与数学基础
说实话,很多做嵌入式显示的朋友,上来就怼像素点,画个圆、画条线,看着能跑就行。但真到了要做动态UI、做灰度渐变、做旋转动画的时候,就发现屏幕上的东西怎么都对不齐。我当年第一次做128×64的OLED菜单动画,就栽在这个坑里——坐标算错了,整个图标飞到了屏幕外面。
这一章,咱们就把坐标这件事彻底讲透。从笛卡尔坐标系怎么落到OLED上,到平移、缩放、旋转的数学变换,再到像素寻址公式和显存管理。这些都是后面做动态灰度控制和复杂UI的基石。
3.1 笛卡尔坐标系在OLED上的应用
OLED屏幕本质上是一个二维像素矩阵。我们通常把左上角定为原点(0,0),X轴向右,Y轴向下。这和数学课上学的笛卡尔坐标系不太一样——数学里Y轴是向上的。
关键区别:
- 数学坐标系:原点在左下,Y向上为正
- OLED屏幕坐标系:原点在左上,Y向下为正
这个差异,在做旋转和镜像时特别容易出错。我建议你在代码里统一用一个转换函数,别到处手动取反。
举个例子,一个128×64的OLED,像素坐标范围是:
- X: 0 ~ 127
- Y: 0 ~ 63
你想想看,如果要在屏幕中心画一个点,中心坐标就是(64, 32)。这个很简单,但一旦涉及到多个图层的叠加、不同分辨率的适配,坐标映射就没那么直观了。
3.2 坐标变换:平移、缩放、旋转
动态UI的核心,其实就是坐标变换。说白了,就是把一个图形从原始位置挪到目标位置,或者把它变大变小、转个角度。
3.2.1 平移变换
平移是最简单的。假设原始点坐标为(x, y),平移量为(tx, ty),新坐标为:
x' = x + tx
y' = y + ty
我在项目中遇到过一个问题:做菜单滑动动画时,平移量用整数累加,结果动画一卡一卡的。后来改成用浮点数累加,最后才取整,动画就平滑了。嗯,这里要注意,OLED的像素是离散的,但动画的中间状态可以用连续坐标算。
3.2.2 缩放变换
缩放变换需要指定一个缩放中心。通常以屏幕中心或图形中心为锚点。公式如下:
x' = (x - cx) * sx + cx
y' = (y - cy) * sy + cy
其中(cx, cy)是缩放中心,(sx, sy)是X和Y方向的缩放因子。
我的经验:缩放因子不要用整数比,比如2、3这种。用浮点数0.5、1.5更灵活。但要注意,缩放后的坐标要四舍五入取整,否则像素会抖动。
3.2.3 旋转变换
旋转稍微复杂一点。绕原点逆时针旋转θ角度的公式是:
x' = x * cos(θ) - y * sin(θ)
y' = x * sin(θ) + y * cos(θ)
但OLED的原点在左上角,直接套这个公式,图形会绕着左上角转,而不是绕着图形中心转。所以实际做法是:
- 先把图形平移到原点
- 做旋转
- 再平移回去
我曾经在做一个仪表盘指针旋转动画时,忘了做这个“先平移再旋转”的步骤,结果指针绕着屏幕左上角飞了一圈……调试了整整一个下午。
3.3 像素寻址公式推导
OLED的像素寻址方式和显存的组织方式密切相关。常见的OLED驱动芯片(比如SSD1306)把显存分成若干页,每页8个像素(一个字节)。
对于128×64的OLED,显存结构是这样的:
| 参数 | 值 |
|---|---|
| 列数 | 128 |
| 行数 | 64 |
| 页数 | 8(每页8行) |
| 每页字节数 | 128 |
像素坐标(x, y)对应的显存地址计算公式:
page = y / 8 // 页号,0~7
column = x // 列号,0~127
bit_position = y % 8 // 该字节内的位偏移,0~7
举个例子,坐标(50, 20):
- page = 20 / 8 = 2
- column = 50
- bit_position = 20 % 8 = 4
也就是说,这个像素位于第2页、第50列,对应字节的第4位(从0开始计数)。
注意:不同驱动芯片的寻址方式可能不同。SSD1306支持页寻址模式和水平/垂直寻址模式。我建议你统一用页寻址模式,代码逻辑最清晰。曾经有个学员用水平寻址模式做滚动效果,结果坐标算错了,画面全乱了。
3.4 显存缓冲区管理
直接操作OLED的显存,速度很慢。因为每次写入都要通过I2C或SPI通信。所以实际项目中,我们会在MCU的RAM里维护一个显存缓冲区,所有绘图操作都在缓冲区里完成,最后一次性刷新到OLED。
缓冲区大小计算:
buffer_size = 列数 × 页数
= 128 × 8
= 1024 字节
对于128×64的OLED,只需要1KB的RAM。这在大多数MCU上都不是问题。
3.4.1 缓冲区操作函数
我一般会封装这几个基础函数:
// 设置单个像素
void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t color) {
if (x >= 128 || y >= 64) return; // 边界检查
uint8_t page = y / 8;
uint8_t bit = y % 8;
if (color) {
buffer[page][x] |= (1 << bit);
} else {
buffer[page][x] &= ~(1 << bit);
}
}
// 清空缓冲区
void OLED_ClearBuffer(void) {
memset(buffer, 0x00, 1024);
}
// 刷新缓冲区到OLED
void OLED_Refresh(void) {
for (uint8_t page = 0; page < 8; page++) {
OLED_SetPageAddress(page);
OLED_SetColumnAddress(0, 127);
for (uint8_t col = 0; col < 128; col++) {
OLED_WriteData(buffer[page][col]);
}
}
}
我的习惯:缓冲区用二维数组 buffer[8][128] 来定义,这样代码可读性更好。有些工程师喜欢用一维数组 buffer[1024],然后手动计算偏移量。两种都可以,但我个人觉得二维数组更直观,不容易算错地址。
3.4.2 局部刷新优化
全屏刷新1024字节,在I2C下大约需要10~20ms。如果只是更新一个小图标,全刷就太浪费了。我建议做局部刷新:
void OLED_RefreshRegion(uint8_t x_start, uint8_t x_end,
uint8_t y_start, uint8_t y_end) {
uint8_t page_start = y_start / 8;
uint8_t page_end = y_end / 8;
for (uint8_t page = page_start; page <= page_end; page++) {
OLED_SetPageAddress(page);
OLED_SetColumnAddress(x_start, x_end);
for (uint8_t col = x_start; col <= x_end; col++) {
OLED_WriteData(buffer[page][col]);
}
}
}
这样做的好处很明显:如果只更新一个16×16的图标,只需要刷新2页×16列=32字节,速度提升30倍以上。
3.5 知识结构总览
下面这张图把本章的核心知识点串起来了。你可以看到,从坐标系定义到坐标变换,再到像素寻址和缓冲区管理,是一条完整的技术链路。
3.6 本章小结
这一章的内容,说白了就是三件事:
- 坐标怎么算——从数学坐标系到屏幕坐标系的映射,以及平移、缩放、旋转的数学公式
- 像素怎么找——通过页号和位偏移,精确找到每个像素在显存中的位置
- 显存怎么管——用缓冲区做中间层,避免频繁I2C/SPI通信,提升刷新效率