在 8×8 RGB 点阵上做“液体”效果,看起来像一个简单的粒子动画:读取加速度计,给粒子施加重力,再把粒子画到 LED 上。
但真正上板以后,很容易遇到几个问题:
- 多个粒子落入同一像素,看起来像凭空消失;
- 静止手持时画面仍不停抖动;
- 粒子落到底部后沿边缘随机横移;
- 从 30 FPS 提升到 60 FPS 后,余辉明显变短;
- 串口显示帧率正常,但物理方向与实物倾斜相反。
本文不使用复杂的连续流体方程,而是针对 8×8 这种低分辨率显示,设计一个更适合 MCU 的“互斥占格液体”算法:18 个液体单元始终占据不同格子,根据重力方向移动和堆叠。
文中的目录、端口和设备信息均为通用示例。电流数据如未使用仪表测量,只能标记为软件估算值。
一、为什么连续粒子在 8×8 上容易失效
假设系统内部有 96 个浮点粒子,但最终只有 64 颗 LED。多个粒子经过取整后可能映射到同一个像素:
intx=static_cast<int>(particle.x+0.5f);inty=static_cast<int>(particle.y+0.5f);frame[y*8+x]+=particle.color;当点阵向一侧倾斜时,几十个粒子会重叠在少量像素上。数学上粒子还在,视觉上却只剩几个亮点。
对于 8×8 点阵,与其追求连续坐标,不如直接让每个液体单元占据一个格子:
constexpruint8_tWIDTH=8;constexpruint8_tHEIGHT=8;constexprsize_t LIQUID_CELLS=18;structCell{int8_tx;int8_ty;};Cell cells[LIQUID_CELLS];18 个单元大约占点阵的 28%,既能形成可见的液面,也为流动留下足够空位。
二、初始化成一个紧凑水池
开机时可以先铺满底部两行,再在上方放两格:
voidresetLiquid(){for(size_t i=0;i<LIQUID_CELLS;++i){if(i<16){cells[i].x=i%8;cells[i].y=7-i/8;}else{cells[i].x=3+(i-16);cells[i].y=5;}}}每次求解前建立占用表:
booloccupied[WIDTH*HEIGHT]{};for(constCell&cell:cells){occupied[cell.y*WIDTH+cell.x]=true;}所有移动都先查询occupied[]。只有目标格为空时才能进入,因此不会发生两个液体单元叠在同一像素的问题。
三、把加速度转换为屏幕重力
板载 IMU 输出的是传感器坐标,不一定与屏幕坐标一致。某块横向安装的开发板可以使用类似映射:
floatscreenGravityX=-accelY;floatscreenGravityY=accelX;这不是适用于所有硬件的固定公式。传感器旋转 90°、背面安装或点阵翻转后,符号和轴都可能变化。
建议按以下顺序校准:
- 暂时关闭动画,只打印
accelX、accelY; - 分别向左、右、上、下缓慢倾斜;
- 确认哪一轴变化以及正负方向;
- 再写出屏幕坐标映射;
- 最后用实物观察液体是否流向低侧。
不要因为串口数值“有变化”就认为方向正确。
四、加入倾角死区,解决手抖漂移
手持设备即使看起来静止,加速度数据也会轻微波动。如果重力向量刚出现一点偏移就移动格子,画面会一直抖。
可以给重力幅值设置死区:
constexprfloatTILT_DEADZONE=0.14f;floatstepAccumulator=0.0f;voidupdateLiquid(floatgx,floatgy,floatdt){constfloatmagnitude=sqrtf(gx*gx+gy*gy);if(magnitude<TILT_DEADZONE){stepAccumulator=0.0f;return;}// 后续计算移动方向和步进速度}这里除了直接返回,还要把未完成的步进累积清零。否则设备在死区边缘反复晃动时,小数步长会悄悄累加,最终仍会突然跳一格。
0.14g只是一个可用起点,实际值要根据 IMU 噪声、安装角度和手持体验调整。
五、把连续重力量化成格子方向
点阵格子只能离散移动。可以比较 X、Y 分量,得到水平、垂直或对角方向:
int8_tdx=0;int8_tdy=0;constfloatax=fabsf(gx);constfloatay=fabsf(gy);if(ax>ay*1.35f){dx=gx>0.0f?1:-1;}elseif(ay>ax*1.35f){dy=gy>0.0f?1:-1;}else{dx=gx>0.0f?1:-1;dy=gy>0.0f?1:-1;}1.35相当于给主方向留出一定优势区间,避免 X、Y 分量接近时在水平与垂直之间频繁切换。
六、用时间累积器控制移动速度
渲染可以保持 60 FPS,但液体没有必要每帧都移动一格,否则速度会快得无法观察。
floateffectiveTilt=(magnitude-TILT_DEADZONE)/(1.0f-TILT_DEADZONE);effectiveTilt=constrain(effectiveTilt,0.0f,1.5f);stepAccumulator+=dt*(0.9f+effectiveTilt*8.0f);while(stepAccumulator>=1.0f){stepAccumulator-=1.0f;stepOneCell(dx,dy);}这样有两个好处:
- 小角度倾斜时缓慢流动;
- 大角度倾斜时加速,但不依赖主循环实际帧率。
dt必须来自真实帧间隔,不能直接假定每帧永远等于1/60秒。
七、先移动靠近低侧的单元
更新顺序会直接改变结果。如果从高处先移动,后面的格子可能抢占前方空间,甚至出现覆盖。
可以计算每个格子在重力方向上的投影:
floatprojection(constCell&cell,int8_tdx,int8_tdy){returncell.x*dx+cell.y*dy;}按投影从大到小排序,让最靠近低侧边界的格子先移动。处理单个格子时:
- 先从占用表移除当前位置;
- 尝试正前方;
- 正前方被挡住时,尝试两个斜向空位;
- 都不能移动则恢复当前位置占用。
核心逻辑如下:
booltryMove(Cell&cell,int8_tdx,int8_tdy,booloccupied[64]){int8_tcandidateX[3]={dx,0,0};int8_tcandidateY[3]={dy,0,0};if(dx==0){// 垂直重力:正下方、左下、右下candidateX[1]=-1;candidateY[1]=dy;candidateX[2]=1;candidateY[2]=dy;}elseif(dy==0){// 水平重力:正侧方、斜上、斜下candidateX[1]=dx;candidateY[1]=-1;candidateX[2]=dx;candidateY[2]=1;}else{// 对角重力:对角、水平分量、垂直分量candidateX[1]=dx;candidateY[1]=0;candidateX[2]=0;candidateY[2]=dy;}occupied[cell.y*8+cell.x]=false;for(size_t i=0;i<3;++i){constintnx=cell.x+candidateX[i];constintny=cell.y+candidateY[i];if(nx<0||nx>=8||ny<0||ny>=8){continue;}constsize_t index=ny*8+nx;if(occupied[index]){continue;}cell.x=nx;cell.y=ny;occupied[index]=true;returntrue;}occupied[cell.y*8+cell.x]=true;returnfalse;}工程实现中还应根据水平、垂直、对角三种情况分别生成候选方向,并随机交换两个斜向候选的先后顺序,避免液体永远偏向同一侧。
八、为什么落稳后还会横向爬行
早期实现经常把“左右空位”也作为候选。液体到达底部以后,虽然不能继续下降,却可以一直左右交换位置,看起来像在桌面上自己蠕动。
解决办法是增加一条约束:
每一次被接受的移动,都必须让该格子沿重力方向的投影增大。
也就是说,纯横向移动如果没有更接近低侧,就不应该被接受。这样填满空位后,液体才会真正停靠。
九、60 FPS 下余辉要按时间衰减
如果每帧都简单乘以固定系数:
trail*=0.72f;从 30 FPS 改到 60 FPS 后,一秒内衰减次数翻倍,拖尾会明显变短。
更合理的做法是根据帧间隔计算衰减:
constfloatclampedDt=constrain(dt,0.001f,0.05f);constfloatdecay=powf(0.72f,clampedDt*30.0f);for(size_t i=0;i<64;++i){trailR[i]*=decay;trailG[i]*=decay;trailB[i]*=decay;}0.72表示以 30 FPS 为参考时每帧保留的比例。无论实际刷新率接近 30、50 还是 60 FPS,一秒内的整体衰减速度都会更接近一致。
十、主循环要把采样、求解和输出分开
一个清晰的帧循环可以写成:
constexpruint32_tFRAME_US=1000000UL/60;uint32_tpreviousFrameUs=0;uint32_tnextFrameUs=0;voidloop(){constuint32_tnowUs=micros();if(static_cast<int32_t>(nowUs-nextFrameUs)<0){return;}constfloatdt=previousFrameUs==0?1.0f/60.0f:(nowUs-previousFrameUs)/1000000.0f;previousFrameUs=nowUs;nextFrameUs=nowUs+FRAME_US;readImu();updateLiquid(screenGravityX,screenGravityY,dt);renderLiquid(dt);outputPixels();}如果还需要网络、BLE 或按键处理,不要用一个长时间delay()阻塞主循环。可以让这些任务按自己的周期执行,LED 渲染只在到达下一帧时间时运行。
十一、软件限流只能作为保护层
WS2812B 的电流随点亮数量、颜色和亮度变化。可以根据 RGB 通道总和估算当前帧,再计算缩放比例:
uint16_testimateMilliAmps(constRgb*pixels,size_t count,uint8_tbrightness){uint32_tchannelSum=0;for(size_t i=0;i<count;++i){channelSum+=pixels[i].r;channelSum+=pixels[i].g;channelSum+=pixels[i].b;}constuint32_tdynamic=channelSum*20U*brightness/(255U*255U);constuint32_tidle=count*brightness/255U;returndynamic+idle;}这里使用“单个全亮颜色通道约 20 mA”的保守模型。它适合做亮度保护和趋势判断,但不是电流表:
- 不同灯珠批次的实际电流不同;
- 电源电压、压降和 PWM 实现都会影响结果;
- 软件估算不能证明 USB、电池或稳压器真的处于安全范围。
最终电源设计仍需查看原理图,并用仪表测量整机峰值电流和温升。
十二、至少验证这三层
1. 算法测试
在水平、垂直和对角重力下连续更新,确认格子数量始终不变:
constfloatdirections[][2]={{0.0f,1.0f},{1.0f,0.0f},{0.0f,-1.0f},{-1.0f,0.0f},{0.7f,0.7f},};for(constauto&direction:directions){setGravity(direction[0],direction[1]);for(intframe=0;frame<240;++frame){update(1.0f/60.0f);}assert(occupiedCellCount()==LIQUID_CELLS);}2. 板端运行测试
确认:
- IMU 能被识别,连续读取错误为 0;
- 渲染循环稳定接近目标帧率;
- 快速移动不会出现数组越界或非数值;
- 四个方向测试后仍保持 18 个占用格。
一套实际调试版本在 ESP32-S3 上可稳定运行在约 58.8 FPS,但这个数字只代表对应实现和配置,不能直接当作所有开发板的性能保证。
3. 实物观察与电气测量
确认:
- 向左倾斜时液体确实流向物理左侧;
- 静置时液体能停稳;
- 快速晃动后不会丢格或卡死;
- 供电电流、压降和温升满足硬件要求。
串口中的“估算电流”不能替代万用表或电源仪表;帧缓存方向正确也不能替代肉眼观察物理灯阵。
总结
在 8×8 点阵上模拟液体,关键不是增加粒子数量,而是尊重显示分辨率的限制:
- 用互斥占格避免多个粒子挤到同一像素;
- 用倾角死区过滤手持噪声,并清除未完成步进;
- 按重力投影排序,让靠近低侧的格子先移动;
- 禁止不增加重力投影的纯横向漂移;
- 用真实
dt控制移动和余辉,使 30/60 FPS 表现一致; - 把算法测试、板端日志和实物测量分开验证。
这套方法计算量小、状态可测试,也比连续粒子更适合只有 64 个像素的嵌入式显示。