这时间给一块 0.96 寸的单色 OLED 写了个会转的立方体。立方体表面有软阴影、有高光、有地面反射,字母 MX2 像刻在面上一样跟着转。
STM32做实时渲染动画的难点有很多,首先是I2C通讯:一帧要往 SSD1306 送 1024 字节的显存,前面还有一个控制字节。I2C 时钟跑到 1 MHz,这 1025 个字节大约是 9.3 ms。这就是每帧雷打不动的固定成本。
除去通讯成本开支,剩下的就是要考验 MCU 的运算能力了,这里我使用的单片机是 ST 的新款明星产品:STM32C5。
STM32C5 是 ST 新推的一条低成本MCU线,144 MHz,Cortex-M33,Flash 从 128 KB 到 1024 KB,SRAM 从 64 KB 到 256 KB。硬件上它有两条指令路径非常适合算图形:一条是 DSP 扩展,一条是单精度 FPU。
而我用的是 NUCLEO-C562RE 板子来运行本次的Demo,通过CubeMX2配置基本 I2C 以及 GPIO。
1、CubeMX2的基本配置
在 CubeMX2 中使能 I2C1 设备,同时调节通讯速度为 1MHZ。
使能 ICACHE 增加缓存和指令效率。同时开启一个定时器用于用户按键事件检测。同时我们查看CubeMX2生成的CMake列表:cmake/target.cmake 里这几行说明了目标配置:
set(CMSIS_Dcore Cortex-M33)set(CMSIS_DcoreVersion r0p4)set(CMSIS_Ddsp DSP)set(CMSIS_Dfpu SP_FPU)set(CMSIS_Dmve NO_MVE)
SP_FPU 是单精度浮点单元,FPv5-SP,属于 ARMv8-M 的浮点扩展。它由硬件直接执行加、减、乘、除、平方根、比较、乘加和格式转换。ARM 自己的文档里把这组指令列得很清楚:VABS、VNEG、VSQRT 是一组,VADD、VMLA 这些算术指令是另一组。它没有三角函数。sinf、cosf、powf 这类函数,FPU 里没有对应指令,只能走 newlib 的软件多项式。这件事在项目的链接结果里能直接看出来——map 文件里这几个符号是从软件库拉进来的:
.text.powf 0x080049ac libm.a(libm_a-wf_pow.o) powf.text.cosf 0x08004a68 libm.a(libm_a-sf_cos.o) cosf.text.sinf 0x08004ad8 libm.a(libm_a-sf_sin.o) sinf.text.__kernel_sinf 0x08004c64 __kernel_sinf.text.__ieee754_powf 0x08004cf4 __ieee754_powf
这一份 map 里,库路径是 lib/thumb/v8-m.main+fp/hard。hard 这个词说明整个工程是按硬件浮点 ABI 编的,浮点参数走 FPU 寄存器传递,不走整数寄存器。这一点如果配错,代码照样编译通过、照样能跑,只是每一次浮点调用都变成一次软件模拟,性能会掉到另一个量级。
2、为什么要用浮点算像素?
理论上单色 OLED 上画个方块,用整数就够了:算四个角,填充,抖动出灰度。但是为了增加计算复杂程度以及效果,整个画面加了一个有符号的距离场:一个圆角盒子和一个无限大的地面。每个像素投一条射线,用球体追踪往前走——每一步考虑距离场"离最近表面还有多远",然后安全地跳这么远。因为跳的距离永远不超过真实距离,射线不会穿模。
这个算法没法用定点干净地实现。距离场里要做 length(max(|p| - inner, 0)) - radius,要归一化法线,要把相机基向量归一化,要在阴影步进里反复除法。定点数每一位的权重是固定的,这些操作每一步都需要重新定标,写出来的代码会比浮点版长得多,读起来也更难。世界坐标的范围又小(立方体边长 2 个单位),单精度 float 的 24 位尾数在这个尺度上精度绰绰有余。
于是整个光栅化流程——从相机基到着色——全是单精度 float。每个像素要查询好几次距离场,每次大约二十个浮点操作。一帧下来是几千万次浮点操作,帧时间由浮点吞吐量决定。
3、距离场和着色
圆角盒子的距离函数是这样的:
staticfloatcube_sdf_box(float px, float py, float pz){float qx = fabsf(px) - CUBE_INNER;float qy = fabsf(py) - CUBE_INNER;float qz = fabsf(pz) - CUBE_INNER;if (qx < 0.0f) { qx = 0.0f; }if (qy < 0.0f) { qy = 0.0f; }if (qz < 0.0f) { qz = 0.0f; }return sqrtf((qx * qx) + (qy * qy) + (qz * qz)) - CUBE_RADIUS;}
fabsf 和 sqrtf 都是 FPU 的一条指令。这段函数的 0x6c 字节里没有软件调用。
地面故意没有进距离场。地面是半个空间,一条起点在地面上方、但在盒子外面的射线,在距离场里已经算"在里面"了,距离会返回负值,球体追踪从第一步就坏掉。所以地面改成解析求交,和盒子的命中取更近的那个。
法线也不靠六次距离场采样去求梯度。圆角盒子的法线有一个解析形式:从内盒上离命中点最近的那个点,指向命中点,就是外法线方向。
staticboolcube_body_normal(float px, float py, float pz,float *nx, float *ny, float *nz){float dx = px - fmaxf(-CUBE_INNER, fminf(px, CUBE_INNER));float dy = py - fmaxf(-CUBE_INNER, fminf(py, CUBE_INNER));float dz = pz - fmaxf(-CUBE_INNER, fminf(pz, CUBE_INNER));float len2 = (dx * dx) + (dy * dy) + (dz * dz);float inverse;if (len2 <= CUBE_EPS2) { returnfalse; }inverse = 1.0f / sqrtf(len2);*nx = dx * inverse;*ny = dy * inverse;*nz = dz * inverse;returntrue;}
一次减法、一次归一化,替掉六次距离场求值,而且在圆角处仍然精确。fmaxf/fminf 是硬件指令,循环里没有分支。
着色用的是 Blinn-Phong,加一盏主光、一盏补光、一个边缘光项和半球环境光。这里有个细节值得说:边缘光项取的是掠射角的四次方。
rim = rim * rim;rim = rim * rim;
场景在一个小尺寸的内部缓冲里追踪,然后放大到 128×64。这个尺寸现在是每次调用传进去的:
voidcube3d_draw(uint16_t angle_x, uint16_t angle_y,uint16_t render_w, uint16_t render_h,bool quantise);
成本正比于射线数量,所以 64×32 是 128×64 的四分之一,32×16 又是 64×32 的四分之一。这是整个渲染器最大的性能旋钮。
4、亮暗变化
面板每像素只有 1 位,只有亮和灭。要表现明暗,只能靠抖动,而抖动的本质是把量化噪声推到高频。
#define CUBE_LEVEL_LO 0.22f#define CUBE_LEVEL_HI 0.52f
低于 LO 是纯黑,高于 HI 是纯白,中间才抖动。逐像素的亮度不是用 float 存下来,而是压成一个字节,0 是纯黑、1 是纯白、中间是抖动档位。全分辨率一帧 8192 个像素,float 要 32 KB,字节只要 8 KB。选择器那边的写法让这个压缩不丢精度——抖动本来就只需要 1/64 的分辨率,再细也显示不出来。
地面和背景也在这个逻辑里做了减法。背景是纯黑,不是渐变——任何介于黑白之间的色调,在上半屏会抖成一片均匀的点,在 0.96 寸上读起来是脏,不是天空。地面本身不带主光,它只显示立方体底下的那团阴影,向外径向淡出。留白也让立方体的轮廓有地方落。
5、字的渲染
面上的 MX2 是这套代码里最有意思的一段。
半分辨率下,一个渲染像素对应面板上 2×2 个像素。如果按渲染像素采样字形,一个笔画会被平均掉——字还没到屏幕就已经不见了。第一版的结果就是几个散落的点。
但把字按面板像素采样,又撞上另一个问题。要采样,就得知道那条射线打在哪、法线朝哪;而这些信息只在追踪的那一刻存在。要么把每个渲染像素的命中记录留下来,要么重新追一次。前者很贵:命中记录是 28 字节,全分辨率 8192 个像素就是 229 KB,这颗片子没这么多 RAM。
所以渲染分成三趟。第一趟追所有射线,算出亮度,同时把"打在朝向相机的平面上"的射线编号记进一张表。第二趟只走这张表,把编号还原成射线重追一次,得到命中信息,然后按面板像素采样字形,写进一张位掩码。第三趟才是放大、抖动、输出。
if (hit.hit&& cube_label_face_test(&hit, dx, dy, dz)&& (label_count < (uint16_t)CUBE_LABEL_INDEX_MAX)){cube_label_pixels[label_count] = (uint16_t)index;label_count++;}
编号表是 2 字节一条,而且只装得下"可能带字"的那部分射线。全分辨率面板上立方体大约占两成像素,再经过平面朝向筛选,实际入表的量很小。用 16 KB 的上限,换掉了 229 KB 的命中记录。
第二趟里每个编号的采样是这样的:
for (block_y = 0U; block_y < stop_y; block_y++){uint16_t panel_y = (uint16_t)((row * block_h) + block_y);for (block_x = 0U; block_x < stop_x; block_x++){uint16_t panel_x = (uint16_t)((col * block_w) + block_x);float sub_x = (((float)block_x + 0.5f) - (0.5f * (float)block_w))* (CUBE_LABEL_FLAT / (float)SSD1306_WIDTH);float sub_y = ((0.5f * (float)block_h) - ((float)block_y + 0.5f))* (CUBE_LABEL_FLAT / (float)SSD1306_HEIGHT);if (cube_label_sample(hit.px, hit.py, hit.pz, hit.nx, hit.ny, hit.nz,sub_x, sub_y) != 0U){cube_label_mask[(panel_y * CUBE_MASK_STRIDE) + (panel_x >> 3U)]|= (uint8_t)(1U << (panel_x & 7U));}}}
位掩码是每个面板像素 1 位,整屏 1 KB,跟渲染分辨率无关。第三趟读到哪一位是 1,就把那个像素压成黑色——字是刻进去的,不是画上去的。
总结
这套渲染器里,从相机基、归一化、距离场、法线,一直到逐像素的着色和抖动,全程单精度 float 直接写;fabsf、sqrtf、fminf、fmaxf、除法全是 FPU 的一条指令;只有 sinf/cosf 走软件,而它们一帧只调四次。如此大的计算量情况下,STM32C5 凭借它的FPU完全也能 Hold 住。144 MHz 的 Cortex-M33,加上单精度 FPv5、DSP 扩展和指令缓存,把一整条光栅化管线从相机基到逐像素着色全部吃下,还能跑到几十帧。
149