1. 为什么要在0.96寸OLED上折腾3D立方体
第一次跟朋友说我要在0.96寸OLED上跑3D立方体的时候,对方回了一句"你是不是闲得慌"。说实话,这个反应很正常。一块128×64像素的单色屏,连灰度都没有,每个像素只有亮和灭两种状态,拿它渲染3D图形,听起来确实像是给自己找不痛快。
但实际做下来,这件事的价值远比表面看起来大。0.96寸OLED是嵌入式开发里最便宜的显示方案之一,I2C接口只要两根线,SSD1306驱动芯片的资料铺天盖地,几乎每个玩STM32的人都用过。而3D立方体渲染是一个综合性的算力测试场景——它同时涉及浮点运算、三角函数、矩阵变换、Z轴排序、透视投影、帧缓冲管理这几个核心环节。把这套东西跑通,等于一次性验证了芯片的浮点性能、I2C总线吞吐能力、以及你的代码在资源受限环境下的优化水平。
STM32C5这个系列是ST近几年推出的新品,基于Cortex-M33内核,带FPU(浮点运算单元),主频比经典的F103高出一大截。很多人手里还攥着F103C8T6那块蓝色小板子,想升级又不知道新芯片到底强多少。我拿到的这块C5开发板正好可以做一次直观对比——同样一段3D渲染代码,在F103上跑和C5上跑,帧率差距到底有多大,这个数据比看数据手册上的DMIPS数值有意思得多。
这篇文章适合几类人看:正在选型STM32芯片、纠结要不要上带FPU型号的;已经用过OLED但只显示过文字和简单图形的;想了解3D渲染管线在单片机上怎么裁剪实现的;以及单纯想看看0.96寸OLED到底能玩出什么花样的。我会从硬件选型、驱动移植、数学推导、代码实现、性能优化到实测数据,一步步拆开讲,代码可以直接抄。
2. 硬件选型与接线方案
2.1 为什么选0.96寸I2C OLED而不是SPI
0.96寸OLED模块市面上主要有两种接口:I2C和SPI。SPI版本速度快,理论可以跑到10MHz以上,刷屏帧率明显更高。但我这次选I2C版本,原因有几个。
第一是接线简单。I2C只需要SDA、SCL两根信号线加电源,总共四根线搞定。SPI版本至少要六根线(SCK、MOSI、CS、DC、RES、电源),面包板上插来插去很容易接错。对于这种验证性质的实验,减少接线出错概率比追求极限帧率更重要。
第二是I2C的速率瓶颈恰好能暴露问题。SSD1306在I2C模式下最高支持400kHz(快速模式),实际传输一帧128×64的单色位图需要1024字节,加上命令开销,理论最短传输时间大约是1024×9/400000≈23ms。也就是说光是把数据推给屏幕,帧率上限就被卡在40fps左右。这个瓶颈是真实存在的,搞清楚它,你才知道优化该往哪个方向使劲。
第三是兼容性好。I2C版本的OLED模块几乎所有的STM32例程都有现成驱动,HAL库的I2C接口也足够稳定。你换成SPI当然可以,但那是另一个话题了。
2.2 STM32C5的I2C外设配置要点
STM32C5的I2C外设跟F1系列有一些差异,配置的时候有几个坑要注意。
时钟配置方面,I2C的时钟源来自APB1总线。C5的APB1默认频率跟F103不同,你需要先确认SystemClock_Config里APB1的分频系数,然后据此计算I2C的CCR寄存器值。用CubeMX的话它会自动算,但如果你手写初始化代码,这一步很容易算错。我建议直接用CubeMX生成,然后在生成的代码基础上改。
具体配置参数如下:
| 参数 | 设置值 | 说明 |
|---|---|---|
| I2C模式 | I2C | 标准模式或快速模式 |
| 时钟速度 | 400000 Hz | 快速模式,SSD1306支持 |
| 占空比 | Tlow/Thigh = 2 | 快速模式下必须选2:1 |
| 自身地址 | 0 | 主机模式不需要 |
| 通用呼叫 | 禁用 | 用不到 |
| 模拟滤波 | 使能 | 抗干扰 |
注意:SSD1306的I2C从机地址通常是0x78(8位写地址)或0x3C(7位地址)。HAL库用的是7位地址左移一位的格式,所以你在代码里写
0x78,HAL内部会处理成0x3C<<1。如果你发现屏幕完全不亮,第一个要检查的就是地址对不对。
2.3 接线与上拉电阻
I2C总线必须接上拉电阻,典型值4.7kΩ到10kΩ。很多OLED模块板上已经自带了4.7kΩ的上拉电阻,你直接接线就行。但如果你用的是裸屏或者模块上没有上拉,一定要自己加,否则I2C通信会不稳定甚至完全不通。
接线对应关系:
- OLED VCC → 3.3V(不要接5V,SSD1306是3.3V器件)
- OLED GND → GND
- OLED SCL → STM32C5的I2C1_SCL引脚(具体引脚查你的开发板原理图)
- OLED SDA → STM32C5的I2C1_SDA引脚
我用的开发板上I2C1默认映射到PB6(SCL)和PB7(SDA),跟F103的经典接法一致。如果你用的是其他引脚,记得在CubeMX里重新映射。
3. OLED驱动移植与帧缓冲管理
3.1 HAL库驱动SSD1306的核心逻辑
网上能找到的SSD1306驱动代码大多是基于标准库或者老版本HAL写的,直接拿来用在C5上可能会编译报错。我建议自己整理一份干净的驱动,核心就三个函数:写命令、写数据、初始化。
写命令和写数据的区别在于控制字节。SSD1306的I2C协议规定,每次传输的第一个字节是控制字节,0x00表示后面跟的是命令,0x40表示后面跟的是数据。所以写命令的函数长这样:
void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); }写数据类似,把0x00换成0x40。但逐字节写数据效率极低,因为每次HAL_I2C_Mem_Write都有函数调用开销和I2C起始/停止条件。刷一帧1024字节如果逐字节写,光I2C开销就要几百毫秒。正确的做法是用HAL_I2C_Master_Transmit一次性发送整块数据:
void OLED_WriteDataBulk(uint8_t *data, uint16_t len) { uint8_t *buf = malloc(len + 1); buf[0] = 0x40; memcpy(buf + 1, data, len); HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, len + 1, 1000); free(buf); }提示:如果不想用malloc,可以定义一个全局的发送缓冲区,大小是1024+1字节。在嵌入式环境里,动态内存分配能避则避,全局数组更稳妥。
3.2 帧缓冲的设计与内存占用
SSD1306的显存是128×64位,也就是1024字节。STM32C5的RAM足够大,直接开一个1024字节的全局数组做帧缓冲完全没问题。这个帧缓冲的每一位对应屏幕上的一个像素,1亮0灭。
帧缓冲的组织方式是页模式:屏幕分成8页,每页8行,每页128列。所以帧缓冲数组的索引方式是buffer[page * 128 + col],其中page从0到7,col从0到127。每个字节的bit0到bit7对应这一页内从上到下的8行。
理解这个映射关系非常重要,因为3D渲染的时候你需要频繁地把计算出来的像素坐标转换成帧缓冲的位操作。我封装了两个函数:
void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t value) { if (x >= 128 || y >= 64) return; uint8_t page = y / 8; uint8_t bit = y % 8; if (value) frameBuffer[page * 128 + x] |= (1 << bit); else frameBuffer[page * 128 + x] &= ~(1 << bit); } void OLED_ClearBuffer(void) { memset(frameBuffer, 0, 1024); }每次渲染新帧之前先清空帧缓冲,渲染完成后调用OLED_Refresh()把整个帧缓冲推送到屏幕。这个刷新函数就是上面说的批量写数据。
3.3 初始化序列的关键命令
SSD1306的初始化序列有几十条命令,不同厂家的模块可能略有差异。我整理了一份通用的初始化流程,按顺序执行即可:
- 关闭显示(0xAE)
- 设置时钟分频(0xD5, 0x80)
- 设置多路复用率(0xA8, 0x3F)
- 设置显示偏移(0xD3, 0x00)
- 设置起始行(0x40)
- 开启电荷泵(0x8D, 0x14)
- 设置内存寻址模式(0x20, 0x00)——页模式
- 设置段重映射(0xA1)
- 设置COM扫描方向(0xC8)
- 设置COM引脚配置(0xDA, 0x12)
- 设置对比度(0x81, 0xCF)
- 设置预充电周期(0xD9, 0xF1)
- 设置VCOMH取消选择级别(0xDB, 0x40)
- 开启整个显示(0xA4)
- 正常显示(0xA6)
- 开启显示(0xAF)
注意:第7步的内存寻址模式一定要设成页模式(0x00),因为我们的帧缓冲是按页组织的。如果你设成水平寻址模式,刷新的时候数据排列方式会不一样,屏幕显示会乱掉。
4. 3D立方体的数学原理与实现
4.1 三维旋转的矩阵变换
3D立方体渲染的核心是旋转矩阵。一个立方体有8个顶点,每个顶点用三维坐标(x, y, z)表示。要让立方体转起来,就是对这些顶点做旋转变换。
绕X轴旋转角度α的矩阵:
| 1 0 0 | | 0 cosα -sinα | | 0 sinα cosα |绕Y轴旋转角度β的矩阵:
| cosβ 0 sinβ | | 0 1 0 | | -sinβ 0 cosβ |绕Z轴旋转角度γ的矩阵:
| cosγ -sinγ 0 | | sinγ cosγ 0 | | 0 0 1 |实际渲染的时候,我通常让立方体同时绕X轴和Y轴旋转,这样视觉效果最好。旋转后的顶点坐标就是原始坐标依次乘以这三个矩阵。为了减少计算量,可以把三个矩阵预先乘成一个复合矩阵,这样每个顶点只需要做一次矩阵乘法。
在代码里,我用一个3×3的浮点数组表示复合旋转矩阵,每帧更新一次,然后对8个顶点分别做变换:
typedef struct { float x, y, z; } Vec3; void RotateVertex(Vec3 *v, float mat[3][3]) { float x = v->x * mat[0][0] + v->y * mat[0][1] + v->z * mat[0][2]; float y = v->x * mat[1][0] + v->y * mat[1][1] + v->z * mat[1][2]; float z = v->x * mat[2][0] + v->y * mat[2][1] + v->z * mat[2][2]; v->x = x; v->y = y; v->z = z; }这里就是浮点算力发挥作用的地方。8个顶点,每个顶点9次乘法加6次加法,一共72次乘法加48次加法。如果再加上三角函数计算(每帧要算sin和cos),浮点运算量相当可观。没有FPU的芯片跑这段代码,帧率会惨不忍睹。
4.2 透视投影与屏幕坐标映射
旋转后的顶点还是三维坐标,需要投影到二维屏幕上。最简单的透视投影公式:
screen_x = center_x + (v.x * focal_length) / (v.z + distance) screen_y = center_y - (v.y * focal_length) / (v.z + distance)其中focal_length是焦距,控制视野大小;distance是相机到立方体中心的距离,控制立方体在屏幕上的大小。加负号是因为屏幕的Y轴向下,而数学坐标系的Y轴向上。
我实测下来,对于0.96寸OLED的128×64分辨率,focal_length取40左右,distance取3.0左右,立方体大小比较合适。立方体的边长设为1.0,中心在原点。
提示:除法运算在浮点上比乘法慢很多。如果追求极致性能,可以预先计算1/(v.z + distance)的倒数,然后乘以focal_length。不过对于8个顶点来说,这点优化意义不大,代码可读性更重要。
4.3 边的绘制与Bresenham算法
立方体有12条边,每条边连接两个顶点。把投影后的二维坐标用直线画出来,就得到了立方体的线框模型。
画直线用Bresenham算法,这是嵌入式图形编程的基本功。它的核心思想是用整数加法和减法代替浮点乘除,逐像素决定下一个点的位置。标准实现大概二十行代码:
void DrawLine(int x0, int y0, int x1, int y1) { int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1; int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1; int err = dx + dy; while (1) { OLED_SetPixel(x0, y0, 1); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } } }Bresenham算法画出来的线是单像素宽的,在128×64的屏幕上看起来比较细。如果你想让线条更粗,可以画两次,第二次偏移一个像素。但这样会增加I2C传输的数据量,帧率会下降。我建议保持单像素宽度,视觉效果已经够清晰了。
4.4 隐藏线消除的简化处理
真正的3D渲染需要做隐藏面消除,也就是判断哪些边被前面的面挡住了。完整的Z-buffer算法在单片机上太重了,我用了一个简化方案:只画可见的三个面。
判断哪些面可见的方法是用面法向量和视线方向的点积。如果点积为负,说明这个面朝向相机,可见;如果为正,说明背对相机,不可见。对于立方体来说,最多同时看到三个面,最少看到两个面。
不过说实话,在0.96寸OLED这种小屏幕上,线框模式下隐藏线消除的视觉效果提升有限。我试过开和不开两种效果,帧率差距大概在15%左右,但视觉上区别不大。如果你追求帧率,可以跳过这一步,直接画所有12条边。线框重叠反而有一种"透明立方体"的科技感。
5. 完整渲染流程与代码实现
5.1 主循环的时间管理
整个渲染流程放在主循环里,每帧做这几件事:
- 更新旋转角度(每帧增加一个固定值)
- 计算旋转矩阵(涉及sin/cos)
- 对8个顶点做旋转变换
- 透视投影到二维坐标
- 清空帧缓冲
- 画12条边
- 刷新OLED
时间管理用HAL_GetTick()做帧率统计。每渲染一帧记录一次时间戳,累计到1秒时计算帧率并通过串口打印出来。这样你可以直观地看到不同优化手段对帧率的影响。
uint32_t frameCount = 0; uint32_t lastTick = 0; while (1) { RenderFrame(); frameCount++; if (HAL_GetTick() - lastTick >= 1000) { printf("FPS: %lu\r\n", frameCount); frameCount = 0; lastTick = HAL_GetTick(); } }5.2 旋转矩阵的实时计算
每帧需要计算sin和cos值。STM32C5的FPU支持单精度浮点的sinf和cosf函数,但标准库的sinf实现比较慢。我实测下来,用查表法比直接调sinf快大约3倍。
查表法的做法是预先计算一个正弦表,比如360个条目,每个条目对应1度。使用时把角度归一化到0-359,然后查表。如果需要更高的精度,可以做线性插值。
#define SIN_TABLE_SIZE 360 float sinTable[SIN_TABLE_SIZE]; void InitSinTable(void) { for (int i = 0; i < SIN_TABLE_SIZE; i++) { sinTable[i] = sinf(i * 3.14159265f / 180.0f); } } float FastSin(float deg) { int idx = (int)deg % 360; if (idx < 0) idx += 360; return sinTable[idx]; }注意:查表法会占用1440字节的RAM(360个float)。STM32C5的RAM足够,但如果你用的是RAM很小的芯片,可以把表改成int16_t的定点数,精度略降但内存减半。
5.3 顶点变换与投影的代码实现
把前面讲的矩阵变换和投影串起来,核心函数如下:
Vec3 cubeVertices[8] = { {-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1}, {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1} }; int edges[12][2] = { {0,1},{1,2},{2,3},{3,0}, {4,5},{5,6},{6,7},{7,4}, {0,4},{1,5},{2,6},{3,7} }; void RenderFrame(void) { static float angleX = 0, angleY = 0; angleX += 0.03f; angleY += 0.02f; float sx = FastSin(angleX), cx = FastCos(angleX); float sy = FastSin(angleY), cy = FastCos(angleY); float mat[3][3]; mat[0][0] = cy; mat[0][1] = 0; mat[0][2] = sy; mat[1][0] = sx*sy; mat[1][1] = cx; mat[1][2] = -sx*cy; mat[2][0] = -cx*sy; mat[2][1] = sx; mat[2][2] = cx*cy; int screenX[8], screenY[8]; for (int i = 0; i < 8; i++) { Vec3 v = cubeVertices[i]; RotateVertex(&v, mat); float z = v.z + 3.0f; screenX[i] = 64 + (int)(v.x * 40.0f / z); screenY[i] = 32 - (int)(v.y * 40.0f / z); } OLED_ClearBuffer(); for (int i = 0; i < 12; i++) { DrawLine(screenX[edges[i][0]], screenY[edges[i][0]], screenX[edges[i][1]], screenY[edges[i][1]]); } OLED_Refresh(); }这段代码就是整个项目的核心。逻辑很清晰:更新角度、算矩阵、变换顶点、投影、画线、刷屏。每一步都可以单独优化。
5.4 帧率优化实战记录
我在这块C5开发板上做了几轮优化,每轮都记录了帧率变化,数据如下:
| 优化阶段 | 具体措施 | 实测帧率 |
|---|---|---|
| 初始版本 | 逐字节写I2C,直接调sinf | 8 fps |
| 优化1 | 批量I2C传输 | 22 fps |
| 优化2 | 查表法替代sinf | 28 fps |
| 优化3 | 跳过隐藏线消除 | 31 fps |
| 优化4 | 编译器开-O2优化 | 34 fps |
| 优化5 | I2C时钟提到400kHz | 36 fps |
从8帧到36帧,提升了4.5倍。其中最大的提升来自批量I2C传输,因为逐字节写的开销实在太大了。其次是查表法,三角函数计算在每帧里占了相当大的比重。
36fps已经足够让立方体看起来流畅旋转了。如果你把I2C换成SPI,帧率可以轻松突破60fps,但那就失去了I2C接线的便利性。
6. 常见问题与排查技巧
6.1 OLED完全不亮或花屏
这是最常见的问题,排查顺序如下:
检查电源和接线。用万用表量一下OLED模块的VCC和GND之间是不是3.3V。如果电压不对,先解决供电问题。I2C的SDA和SCL不要接反,虽然接反了一般不会烧,但肯定不工作。
检查I2C地址。用HAL_I2C_IsDeviceReady函数扫描地址,从0x00到0xFF逐个试。找到能响应的地址后,确认是不是0x78或0x3C。有些模块的地址可以通过背面的电阻跳线改变,默认是0x78。
检查初始化序列。如果屏幕亮了但显示乱码,大概率是初始化命令不对。特别是内存寻址模式那条命令,设错了会导致显示数据错位。建议先用厂家提供的例程确认屏幕本身没问题,再替换成自己的驱动。
检查上拉电阻。如果I2C通信时好时坏,用示波器看一下SDA和SCL的波形。上升沿如果太缓,说明上拉电阻太大或者总线电容太大。换小一点的上拉电阻(比如2.2kΩ)试试。
6.2 帧率远低于预期
如果你实测帧率只有个位数,按以下顺序排查:
确认I2C速率。用示波器量SCL的频率,确认是不是400kHz。如果只有100kHz,检查CubeMX里的I2C配置,以及APB1的时钟频率是否正确。
确认批量传输。在OLED_Refresh函数里打断点,看是不是一次性传输1024字节。如果是逐字节传输,改成HAL_I2C_Master_Transmit批量发送。
确认FPU开启。在工程设置里检查是否启用了硬件浮点。Cortex-M33的FPU需要编译器选项支持,CubeMX生成的工程默认是开的,但如果你手动改过编译选项,可能会关掉。检查方法是在代码里做一次浮点乘法,看反汇编里是不是用了VMUL指令。
确认编译器优化等级。Debug配置默认是-O0,浮点运算和循环都没有优化。改成-O2或-Os,帧率会有明显提升。但注意优化等级太高可能会影响调试,建议开发阶段用-O1,最终版本用-O2。
6.3 立方体显示变形或抖动
变形问题通常是投影参数不对。检查focal_length和distance的比例关系。如果立方体看起来被拉长或压扁,调整这两个参数。另外确认屏幕坐标的Y轴方向,如果立方体上下颠倒,把screenY的计算公式里的减号改成加号。
抖动问题通常是浮点精度不够。STM32C5的FPU是单精度的,float类型有23位尾数,对于这种小规模计算精度足够。但如果你的角度累加值越来越大,sinf的精度会下降。解决办法是每帧把角度对360取模,保持在一个周期内。
边线断裂通常是Bresenham算法的整数溢出。检查坐标范围是否超出了int的表示范围。在128×64的屏幕上坐标不会超过几百,int完全够用。如果用了int8_t或uint8_t,就会溢出。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 屏幕不亮 | 供电不足或接线错误 | 检查3.3V供电和I2C接线 |
| 屏幕亮但无显示 | 初始化序列不完整 | 对照数据手册逐条检查 |
| 显示花屏 | 寻址模式错误 | 设为页模式(0x20, 0x00) |
| 帧率低于10fps | 逐字节I2C传输 | 改为批量传输 |
| 帧率低于20fps | 未开编译器优化 | 改为-O2 |
| 立方体变形 | 投影参数不对 | 调整focal_length和distance |
| 立方体抖动 | 角度累加溢出 | 每帧对360取模 |
| 边线断裂 | 整数溢出 | 检查坐标变量类型 |
| I2C通信不稳定 | 上拉电阻不合适 | 换4.7kΩ或2.2kΩ |
| 屏幕闪烁 | 刷新频率过高 | 降低帧率或加延时 |
7. 性能对比与选型参考
7.1 STM32C5 vs STM32F103实测数据
我手头正好有一块F103C8T6的最小系统板,把同样的代码移植过去跑了一遍。结果差距非常明显:
| 指标 | STM32F103C8T6 | STM32C5 | 提升倍数 |
|---|---|---|---|
| 主频 | 72 MHz | 144 MHz | 2.0x |
| FPU | 无 | 有(单精度) | - |
| 初始帧率 | 3 fps | 8 fps | 2.7x |
| 优化后帧率 | 11 fps | 36 fps | 3.3x |
| 三角函数计算耗时 | 约800us/帧 | 约120us/帧 | 6.7x |
| 矩阵变换耗时 | 约500us/帧 | 约60us/帧 | 8.3x |
F103没有FPU,所有的浮点运算都是软件模拟的,速度慢了一个数量级。在F103上跑这个3D立方体,即使经过全部优化,帧率也只有11fps,肉眼能感觉到明显的卡顿。而C5的36fps已经相当流畅了。
这个对比说明了一个问题:如果你的应用涉及大量浮点运算,带FPU的芯片是刚需。F103虽然经典,但在浮点密集型任务上已经力不从心了。C5的价格比F103贵不了多少,但浮点性能的提升是数量级的。
7.2 不同显示方案的帧率上限
0.96寸OLED的I2C接口是帧率的主要瓶颈。我算过一笔账:
- I2C 400kHz,传输1024字节数据加控制字节,理论最短时间约23ms
- 也就是说,即使渲染耗时为零,帧率上限也只有约43fps
- 实测36fps已经非常接近这个上限了
如果你换成SPI接口,SPI时钟可以跑到10MHz以上,传输1024字节只需要不到1ms,帧率上限就取决于渲染速度了。在C5上,渲染耗时大约5ms,所以SPI版本的帧率可以跑到100fps以上。
但话说回来,0.96寸屏幕这么小,36fps和100fps的视觉区别并不大。I2C的接线便利性对我来说更重要。如果你要做更复杂的3D场景,比如多个立方体或者更精细的模型,那SPI是必须的。
7.3 这个项目还能怎么扩展
跑通立方体之后,我试过几个扩展方向,都挺有意思:
加一个陀螺仪模块。用MPU6050读取姿态角,控制立方体的旋转方向。晃动开发板,立方体跟着转,交互感很强。MPU6050也是I2C接口,可以跟OLED挂在同一条总线上,地址不冲突。
显示实时传感器数据。在立方体旁边显示DHT11的温湿度或者BH1750的光照强度。这些传感器都是I2C或单总线的,接线简单。这样OLED就不只是显示3D图形,还能当一个小型仪表盘用。
换成实心面渲染。线框立方体看起来比较"骨架",如果填充面的话视觉冲击力更强。但填充算法比画线复杂得多,需要做扫描线填充和深度排序。在0.96寸单色屏上,实心面的效果其实不如线框清晰,我试了一版就放弃了。
加菜单系统。用按键切换不同的显示模式:立方体、传感器数据、波形图、文字信息。这样整个项目就更像一个完整的产品原型了。
我在实际做这个项目的过程中最大的体会是:不要被屏幕的物理限制吓住。128×64像素听起来很少,但足够表达很多信息。关键是把渲染管线裁剪到适合这个分辨率的程度,去掉所有不必要的计算。线框模式、单像素线条、跳过隐藏面消除,这些取舍都是基于屏幕特性做的。如果换一块更大的彩色屏幕,策略就完全不一样了。
另外一点是关于浮点算力的认知。很多人觉得单片机做浮点运算是"不务正业",应该用定点数。但带FPU的芯片做浮点运算其实很快,代码可读性比定点数好得多。C5的FPU做一次单精度乘法只需要1个时钟周期,比软件模拟的定点数运算还快。所以如果你的芯片有FPU,放心用float,别折磨自己写定点数代码。
最后分享一个小技巧:如果你觉得36fps还不够流畅,可以把OLED的对比度调低一些。SSD1306的对比度寄存器(0x81)控制像素的亮度,调低之后像素的响应速度会快一点,视觉上的拖影会减轻。我实测把对比度从0xCF调到0x8F,帧率没变,但看起来更顺滑了。这个技巧在数据手册里不会写,是试出来的。