第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的原点在左上角,直接套这个公式,图形会绕着左上角转,而不是绕着图形中心转。所以实际做法是:

  1. 先把图形平移到原点
  2. 做旋转
  3. 再平移回去

我曾经在做一个仪表盘指针旋转动画时,忘了做这个“先平移再旋转”的步骤,结果指针绕着屏幕左上角飞了一圈……调试了整整一个下午。

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章 知识结构图:像素坐标映射与显存管理 笛卡尔坐标系 → OLED坐标系 坐标变换:平移 | 缩放 | 旋转 平移:x+tx, y+ty 缩放:×sx, ×sy 旋转:cosθ, sinθ 像素寻址公式:page=y/8, bit=y%8 显存缓冲区管理:全刷 vs 局部刷新 核心思路:所有绘图操作在缓冲区完成,最后一次性或局部刷新到OLED硬件

3.6 本章小结

这一章的内容,说白了就是三件事:

  • 坐标怎么算——从数学坐标系到屏幕坐标系的映射,以及平移、缩放、旋转的数学公式
  • 像素怎么找——通过页号和位偏移,精确找到每个像素在显存中的位置
  • 显存怎么管——用缓冲区做中间层,避免频繁I2C/SPI通信,提升刷新效率