ESP32-S3 做 8×8 重力液体动画:互斥占格、倾角死区与 60 FPS 调度
2026/9/23 12:39:53 网站建设 项目流程

在 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°、背面安装或点阵翻转后,符号和轴都可能变化。

建议按以下顺序校准:

  1. 暂时关闭动画,只打印accelXaccelY
  2. 分别向左、右、上、下缓慢倾斜;
  3. 确认哪一轴变化以及正负方向;
  4. 再写出屏幕坐标映射;
  5. 最后用实物观察液体是否流向低侧。

不要因为串口数值“有变化”就认为方向正确。

四、加入倾角死区,解决手抖漂移

手持设备即使看起来静止,加速度数据也会轻微波动。如果重力向量刚出现一点偏移就移动格子,画面会一直抖。

可以给重力幅值设置死区:

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;}

按投影从大到小排序,让最靠近低侧边界的格子先移动。处理单个格子时:

  1. 先从占用表移除当前位置;
  2. 尝试正前方;
  3. 正前方被挡住时,尝试两个斜向空位;
  4. 都不能移动则恢复当前位置占用。

核心逻辑如下:

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 点阵上模拟液体,关键不是增加粒子数量,而是尊重显示分辨率的限制:

  1. 用互斥占格避免多个粒子挤到同一像素;
  2. 用倾角死区过滤手持噪声,并清除未完成步进;
  3. 按重力投影排序,让靠近低侧的格子先移动;
  4. 禁止不增加重力投影的纯横向漂移;
  5. 用真实dt控制移动和余辉,使 30/60 FPS 表现一致;
  6. 把算法测试、板端日志和实物测量分开验证。

这套方法计算量小、状态可测试,也比连续粒子更适合只有 64 个像素的嵌入式显示。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询