AI脑洞板
完整教案
自学课件
第 7 课 · 共 7/51 课

为什么有的动画卡,有的不卡 帧预算、脏矩形和屏幕的节奏

课时
待定
器材
ESP32-S3 触摸屏板 + USB 线
前置知识
无
配套代码
见项目 demo 目录

你好,我是吴老师。

上一课你的弹球跑起来了。今天我要请你先把它弄卡。

读完之后,你应该能:说出每一帧有多少时间预算;用"帧耗时"这个数字判断动画卡不卡(而不是凭感觉);列出卡顿的三个常见原因并逐个排查;用脏矩形把动画优化到不掉帧。

用时:约 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. 把球数从 1 拖到 200 —— 看"算"和"画"这两块怎么越长越宽;
  2. 勾上"用整屏重画" —— 看"擦"那一块是不是一下子变成一大截。

第 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>

完成后回答三个问题:

  1. 平均帧耗时降到了多少?进 33 毫秒了吗?
  2. 画面还会闪或者撕裂吗?
  3. 如果加到 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 毫秒,一帧一帧看清楚慢下来以后,你看到哪些平时看不到的东西?

倒数第二行那道题,是后面做游戏时的真本事。到时候你会发现:游戏流不流畅,靠的不是写得多快,而是每一帧少做了多少不该做的事。


第 7 课 · 50 个球(先把它弄卡)

第 7 课 · 50 个球(先把它弄卡)

把第 6 课的 1 个弹球变成 50 个,画面明显一顿一顿。

07性能对比
第 7 课 · 让板子自己报帧耗时

第 7 课 · 让板子自己报帧耗时

不靠"看起来顺不顺",改用数字说话:每 60 帧报一次平均帧耗时和帧率。

07性能测量
第 7 课 · 整屏重画 vs 局部清除

第 7 课 · 整屏重画 vs 局部清除

同样是 20 个球,A 版每帧清整屏、B 版只擦各自小方块,按 BOOT 切换比数字。

07性能对比
第 7 课 · 优化到不掉帧

第 7 课 · 优化到不掉帧

还是 50 个球,但要让平均帧耗时降到 33 毫秒以内,也就是每秒 30 帧以上。

07性能优化