你好,我是吴老师。
上一课你的弹球跑起来了。今天我要请你先把它弄卡。
读完之后,你应该能:说出每一帧有多少时间预算;用"帧耗时"这个数字判断动画卡不卡(而不是凭感觉);列出卡顿的三个常见原因并逐个排查;用脏矩形把动画优化到不掉帧。
用时:约 90 分钟。准备:板子、数据线、第 6 课的弹球程序。
看不懂的地方,整段复制给 AI,加一句"请一次只解释一个概念,讲完问我一个小问题"。
一、先把它弄卡
不用重写,直接跟 AI 说:
请把上一课的弹球改成 ___ 个小球:
每个球有自己的位置和速度,各自运动、各自反弹,
球的颜色你自己定 ___(可以都给一个颜色,也可以各不相同);
每一帧仍然先擦掉旧位置、再画上新位置,其它写法保持不变。
请在中文注释里写清:这样每一帧一共要往屏幕送多少次数据。
请用中文注释;烧录前先运行 arduino-cli board list 核对端口;
另存一个新版本,把上一课 1 个球的版本留着方便对比。
<details>
<summary>想好了再点开:我们的参考数字</summary>
**这不是标准答案。**参考值:**50** 个球。
**为什么球数值得你自己定?**因为它决定了这一课后面所有实验的"卡"的程度:
- 填 **50**:卡得明显,一眼能看出来,帧耗时会超过 33 毫秒;
- 填 **10**:可能还不够卡,你会以为"我的板子挺快的",后面优化就没有对比;
- 填 **200**:一开始就卡死了,看不出优化前后的差别。
> 想体验"从顺到卡"的过程?先填 5 跑一次,再改成 50 跑一次——**中间那个感觉就是帧预算被吃完了。**
**颜色**随便选。不过有个小提示:**都给同一个颜色**的话,代码简单、每帧的数据量也固定,后面测帧耗时更干净。
</details>
烧录后老实回答:画面是顺滑的,还是一顿一顿的?
如果你的板子和大多数人的一样,小球会变得磕磕绊绊,像在泥地里走路。
一个球好好的,五十个就卡了。为什么?
二、帧预算:每一帧都是一份限时作业
上一课我们说过:一帧就是一次完整的画面,帧率是一秒画多少帧。
今天换个说法——每一帧,都是一份限时作业。
| 想要多少帧 | 每一帧有多少时间 | 像什么 |
|---|---|---|
| 每秒 10 帧 | 100 毫秒 | 翻页动画慢慢翻,一格一格跳 |
| 每秒 24 帧 | 约 42 毫秒 | 电影的感觉 |
| 每秒 30 帧 | 约 33 毫秒 | 游戏能玩了 |
| 每秒 60 帧 | 约 16.7 毫秒 | 很顺滑 |
30 帧时,每一帧只有 33 毫秒。 你眨一下眼要 100 到 400 毫秒——也就是说,你眨一次眼的工夫,它得画完好几帧。
在这 33 毫秒里,板子必须干完:盖掉旧的 → 算新位置 → 判断有没有撞墙 → 画上新的 → 把数据送过去。
干完了,这一帧按时交卷,画面就顺。
干不完呢? 没有"交晚了"这个选项。时间一到,屏幕该刷新就刷新,你这一帧没画完,画面就停在上一帧——看起来就是"卡了一下"。这叫掉帧。
卡,不是动画坏了,是每一帧的活超过了它的时间预算。
就像老师给你 33 秒钟做一道题,你没做完,她不会等你——直接收卷,这道题就是空的。
这 33 毫秒到底被谁吃掉了?自己拖一下:
试着做两件事:
- 把球数从 1 拖到 200 —— 看"算"和"画"这两块怎么越长越宽;
- 勾上"用整屏重画" —— 看"擦"那一块是不是一下子变成一大截。
第 2 件事就是你下一节要解决的问题。
让板子自己告诉你
请给上一课的弹球加上帧率统计:
每一帧开始时记下时刻,画完以后算出这一帧用了多少毫秒;
每 ___ 帧统计一次平均值(你自己定,参考 60),同时算出大约多少帧/秒;
结果显示在屏幕上,并通过串口以 115200 打印一份,
格式像"平均帧耗时:__ 毫秒,约 __ 帧/秒"。
再加一个 BOOT(GPIO0)切换,在 1 个球和 50 个球之间来回切,方便我对比。
请用中文注释;烧录前先运行 arduino-cli board list 核对端口;
另存一个新版本,保留旧程序。
<details>
<summary>想好了再点开:我们的参考数字</summary>
**这不是标准答案。**参考值:每 **60** 帧统计一次。
**这个数字管的是"数字有多稳":**
- 每 **60** 帧算一次:约 2 秒刷新一次读数,**看得清、也够稳**;
- 每 **1** 帧算一次:屏幕上的数字疯狂乱跳,你根本读不出来;
- 每 **600** 帧算一次:等 20 秒才更新一次,调参数时急死人。
> 60 帧还有个好处:**60 帧 ÷ 30 帧/秒 = 2 秒**,你心里好换算。
> 想看清楚"稳定值"和"瞬时抖动"的区别?故意填 1,看屏幕上的数字怎么跳。
</details>
| 场景 | 我预测帧耗时 | 实测 | 约合多少帧/秒 |
|---|---|---|---|
| 1 个小球 | |||
| 50 个小球 |
如果 50 个球超过 33 毫秒,它就到不了 30 帧——卡,就是这么被测出来的。
三、时间花在哪儿了:三个花钱的地方
| 卡的原因 | 它在干什么 | 怎么判断是不是它 | 怎么改 |
|---|---|---|---|
| 数据太多 | 每帧往屏幕送太多字节,SPI 塞不下 | 把要改的区域缩小,帧耗时立刻下降 | 只改该改的地方(脏矩形) |
| 算得太久 | 50 个球要算 50 次位置、判断 50 次边界 | 球数减半,帧耗时也大约减半 | 减少每帧计算量 |
| 等得不对 | 用 delay() 干等,期间啥也干不了 | 去掉 delay(),帧耗时明显下降 | 用 millis() 到点才做 |
这三个原因经常同时存在。所以每次只改一处,看帧耗时怎么变——还是那句老话:只改一个条件,先预测,再验证。
四、脏矩形:只擦真正脏了的那块
"只改该改的地方"有个正式名字:脏矩形。意思特别简单——哪块区域变了("脏"了),就只更新那一块。
半径 6 的小球,一帧之中影响的区域大约就是 12×12 那么一小块。它从 A 点跑到 B 点,真正变了的只有两块:A 点(要盖掉)和 B 点(要画上)。
中间那大片黑色背景压根没变过,为什么要重画?
| 做法 | 每帧要处理的点 | 像什么 |
|---|---|---|
| 整屏重画 | 76,800 个 | 作业写错一个字,把整页撕了重写 |
| 只清小球走过的小块 | 约 144 个 | 用橡皮擦掉那一个字,再写一遍 |
这就像改作业:写错一个字,擦掉那个字就行,没人会把整页撕了重写——除非他不知道错在哪儿。
自己动手对比一下(勾上"显示脏的方块",就能看见程序到底擦了哪几块):
把小球数量拖到 20、半径拖到 20,看右边"每帧要处理的点"涨到多少——它还是远小于 76,800。
所以脏矩形的关键不是"擦得少",而是你得知道哪块脏了。 程序要自己记着:上一帧画在哪儿,这一帧要画到哪儿。
试试看差多少
请做一个对比实验,同样的 ___ 个小球,两种画法:
A 版:每一帧先用 fillScreen 把整屏填黑,再画这些球;
B 版:每一帧只把每个球上一帧的位置用背景色盖掉,再在新位置画上,不清整屏。
两版都要统计平均帧耗时,同时显示在屏幕上并从串口 115200 打印;
按 BOOT(GPIO0)在 A 版和 B 版之间切换,
屏幕上用中文写清现在正在跑的是哪一版;
亮度不超过 ___(参考 80)。
请用中文注释;烧录前先运行 arduino-cli board list 核对端口;
另存一个新版本,保留旧程序。
<details>
<summary>想好了再点开:我们的参考数字</summary>
**这不是标准答案。**参考值:**20** 个小球。
**这一节的任务是"比出差别",所以球数要选得刚好:**
- **20 个**:两个版本都跑得动,但 B 版明显更快——差别一眼看得出;
- **5 个**:两个版本都不卡,比了等于没比;
- **50 个**:A 版直接卡死,B 版也吃力,反而说不清"谁快"。
> 更关键的是**为什么 A 版慢**:A 版每帧要送 **76,800 × 2 = 153,600 字节**,
> B 版只送 `球数 × 小矩形面积 × 2`。20 个球的话大约是 **20 × 144 × 2 ≈ 5,760 字节**——
> **差了 26 倍多**。这就是第 6 课"改多少就送多少"的又一次验证。
</details>
| 版本 | 我预测 | 实测 | 约合多少帧/秒 |
|---|---|---|---|
| A 版(整屏重画) | |||
| B 版(局部清除) |
五、屏幕自己也有节奏,而且它不等你
你可能会想:我老老实实一帧一帧画完整,不就没问题了吗?
不完全对。因为屏幕自己有节奏,而且它不等你。
屏幕内部有一块自己的小本子,叫显存(缩写 GRAM)。你通过 SPI 送过去的数据,先写在这块小本子上;然后屏幕按自己的节奏,一遍遍照着小本子把画面显示出来——大约每秒六十多次。
这意味着两件事。
第一,你写你的,它显示它的,两边不同步。
打个比方:你在黑板上写字,旁边有另一个同学负责把黑板拍下来给全班看。他每隔一小会儿就拍一次,不等你写完。 你要是正好写了一半,全班看到的就是半句话。
在屏幕上,这叫撕裂——画面上半部分是新的、下半部分是旧的,中间错开一道。
下面左边那版就能看到撕裂(那道粉线所在的位置就是"写到哪儿了"):
把"绘制一帧要多久"拖到最慢——左边错位得越厉害;拖到最快——几乎看不出来。 右边那版(双缓冲)从头到尾都是完整的,代价是多占一块内存。
第二,你改得越零碎,被拍到的"半句话"就越多,画面还会闪。
这个比喻有个地方要修正:屏幕里并没有真的两个人。真实情况是——同一块显存,一边被你写、一边被屏幕读出来显示。读写同时发生在同一块地方,才有了这些麻烦。
那怎么办?
工程师的办法是:先在一块草稿上把一整帧画完,再一次性搬到屏幕上。 这块草稿有个名字叫 sprite(精灵)或缓冲区,这个办法叫双缓冲。
因为搬到屏幕上是"一整块一起换",屏幕要么拍到旧的、要么拍到新的,不会拍到一半——闪烁和撕裂就都没了。
代价是什么?草稿要占内存。我们这块板有 PSRAM(外挂内存),画一块 240×320 的草稿大约要 150 KB——这正是我们编译参数里坚持带 PSRAM=opi 的原因之一。
具体怎么实现,我们留到做游戏时专门讲。今天你只要记住:
屏幕不等你。它按自己的节奏显示,你负责让每帧完整地准备好。
六、把"不卡"读成中文步骤
启动时:
初始化屏幕,整屏填黑
记下 50 个小球各自的位置和速度
记下"上一帧每个球画在哪里"(一开始都是空的)
每一帧:
记下这一帧开始的时刻
对每个小球:
用背景色盖掉它上一帧所在的小矩形 ← 脏矩形:只清这一小块
有重叠时:把重叠的小矩形合并成一块大的再清
对每个小球:
算出新位置
如果撞到边界:把对应的速度取反
在新位置画上小球
记下这一帧画在了哪里(供下一帧清除)
统计这一帧花了多少毫秒
如果还没到下一帧的时间:等一小会儿(用 millis 判断,不要 delay 空等)
累加帧数,每隔一段时间算出平均帧耗时并打印
找一找:哪一步最容易花掉几十毫秒? 如果球有 500 个,哪一步会先撑不住?
七、动手做:把它优化到不掉帧
明确的目标:让 50 个小球跑起来,平均帧耗时控制在 33 毫秒以内。
请帮我优化 ___ 个小球的动画,目标是平均帧耗时不超过 ___ 毫秒:
1. 不要每帧 fillScreen 整屏重画,改成只清除每个球上一帧占用的小矩形;
2. 如果有球的区域重叠,把要清除的区域合并后再清;
3. 用 millis() 控制节奏,不要用 delay() 空等;
4. 保留帧率统计,把平均帧耗时和帧率显示在屏幕上并通过串口打印。
如果试下来还是达不到目标,请直接告诉我瓶颈卡在哪一步。
请先告诉我你打算怎么改,等我确认后再动手。
<details>
<summary>想好了再点开:我们的参考数字</summary>
**这不是标准答案。**参考值:**50** 个球、**33** 毫秒。
**33 毫秒是怎么来的?**上一节算过:**每秒 30 帧 → 1000 ÷ 30 ≈ 33.3 毫秒**。
那**球数**为什么也让你填?因为它决定了这个目标**难不难**:
- **50 个球 + 33 毫秒**:要动真格优化,是这一课的正题;
- **10 个球**:可能不优化就达标了——你学不到东西;
- **200 个球**:也许优化到极限也达不到 33 毫秒。**那也是一种正确答案**——
这时候程序该做的不是硬撑,而是**如实告诉你瓶颈在哪**。
> 目标定得够不着的时候,"**测出瓶颈**"比"假装达标"有价值得多。
> 第 4 问就是为这个写的:达不到就说出来,别硬撑。
</details>
完成后回答三个问题:
- 平均帧耗时降到了多少?进 33 毫秒了吗?
- 画面还会闪或者撕裂吗?
- 如果加到 200 个球,会先卡在"数据太多"还是"算得太久"?
第 3 题值得认真想。判断依据是:把球加大(数据变多)和把球加多(计算变多),哪一种先让帧耗时爆掉?
只改一个条件
| 我改的条件 | 我预测帧耗时会 | 实测 |
|---|---|---|
| 球数从 50 减到 25 | ||
| 小球半径从 6 改成 20 | ||
| 每帧等待从 20 毫秒改成 0 | ||
改回整屏重画 fillScreen |
最后一行是故意的:知道怎么变慢,才知道怎么变快。
八、自检小测
先自己想,再点开对答案。
1. 30 帧/秒时每一帧有多少时间?60 帧呢?
30 帧约 33 毫秒,60 帧约 16.7 毫秒。
2. 什么叫掉帧?为什么会掉帧?
一帧的活没在预算时间内干完,时间一到屏幕照常刷新,画面停在上一帧。掉帧就是"这一帧没交上卷"。
3. 卡顿最常见的三个原因是什么?
数据太多(SPI 塞不下)、算得太久(每帧计算量大)、等得不对(delay() 空等)。
4. 什么是脏矩形?关键在哪里?
只更新变化了的那块区域。关键不是"擦得少",而是程序要记住上一帧画在哪儿。
5. 屏幕内部那块"小本子"叫什么?为什么会撕裂?
叫显存(GRAM)。主控往里写、屏幕按自己节奏读出来显示,两边不同步;写了一半就被显示出去,就出现撕裂。
6. 双缓冲是怎么解决闪烁的?代价是什么?
先在内存草稿上画完整一帧,再一次性推到屏幕,屏幕只会看到旧的一帧或新的一帧,不会看到一半。代价是占用内存(240×320 约 150 KB),所以要开 PSRAM。
7. 判断动画卡不卡,应该凭感觉还是凭数字?
凭数字——测平均帧耗时。超过 33 毫秒就到不了 30 帧。别靠眼睛感觉。
九、还想玩点什么
| 方向 | 可以描述给 AI 的效果 | 值得继续想 |
|---|---|---|
| 帧率仪表 | 屏幕上画出帧耗时柱状图,实时变化 | 帧耗时突然变高的那一帧,发生了什么? |
| 分级动画 | 卡的时候自动减少小球数量 | 谁来判定"卡"?减到多少才合适? |
| 星空 | 200 个点往下掉,看它什么时候开始卡 | 点的大小和点的数量,哪个先撑不住? |
| 双缓冲版弹球 | 先在内存画布画完一帧,再整体推到屏幕 | 它会不会变慢?换来的是什么? |
| 慢动作 | 每帧等待调到 500 毫秒,一帧一帧看清楚 | 慢下来以后,你看到哪些平时看不到的东西? |
倒数第二行那道题,是后面做游戏时的真本事。到时候你会发现:游戏流不流畅,靠的不是写得多快,而是每一帧少做了多少不该做的事。