自学读本:跟着读、照着做,卡住就问 AI
你好,我是吴老师。
上一课你的弹球跑起来了。今天我要请你先把它弄卡。
读完之后,你应该能:说出每一帧有多少时间预算;用"帧耗时"这个数字判断动画卡不卡(而不是凭感觉);列出卡顿的三个常见原因并逐个排查;用脏矩形把动画优化到不掉帧。
用时:约 90 分钟。准备:板子、数据线、第 6 课的弹球程序。
看不懂的地方,整段复制给 AI,加一句"请一次只解释一个概念,讲完问我一个小问题"。
一、先把它弄卡
不用重写,直接跟 AI 说:
请把上一课的弹球改成 50 个小球:
每个球有自己的位置和速度,各自运动、各自反弹。
其他都不变,用中文注释,另存一个版本。
烧录后老实回答:画面是顺滑的,还是一顿一顿的?
如果你的板子和大多数人的一样,小球会变得磕磕绊绊,像在泥地里走路。
一个球好好的,五十个就卡了。为什么?
二、帧预算:每一帧都是一份限时作业
上一课我们说过:一帧就是一次完整的画面,帧率是一秒画多少帧。
今天换个说法——每一帧,都是一份限时作业。
| 想要多少帧 | 每一帧有多少时间 | 像什么 |
|---|---|---|
| 每秒 10 帧 | 100 毫秒 | 翻页动画慢慢翻,一格一格跳 |
| 每秒 24 帧 | 约 42 毫秒 | 电影的感觉 |
| 每秒 30 帧 | 约 33 毫秒 | 游戏能玩了 |
| 每秒 60 帧 | 约 16.7 毫秒 | 很顺滑 |
**30 帧时,每一帧只有 33 毫秒。**你眨一下眼要 100 到 400 毫秒——也就是说,你眨一次眼的工夫,它得画完好几帧。
在这 33 毫秒里,板子必须干完:盖掉旧的 → 算新位置 → 判断有没有撞墙 → 画上新的 → 把数据送过去。
干完了,这一帧按时交卷,画面就顺。
干不完呢?没有"交晚了"这个选项。时间一到,屏幕该刷新就刷新,你这一帧没画完,画面就停在上一帧——看起来就是"卡了一下"。这叫掉帧。
卡,不是动画坏了,是每一帧的活超过了它的时间预算。
就像老师给你 33 秒钟做一道题,你没做完,她不会等你——直接收卷,这道题就是空的。
让板子自己告诉你
请给上一课的弹球加上帧率统计:
每一帧开始时记下时刻,画完以后算出这一帧用了多少毫秒;
每 60 帧算出一次平均值,显示在屏幕上并通过串口打印,格式像:
"平均帧耗时:__ 毫秒,约 __ 帧/秒"。
同时保留一个 BOOT 切换:在 1 个球和 50 个球之间切换,方便我对比。
请用中文注释,烧录前核对端口。
| 场景 | 我预测帧耗时 | 实测 | 约合多少帧/秒 |
|---|---|---|---|
| 1 个小球 | ____ | ____ | |
| 50 个小球 | ____ | ____ |
如果 50 个球超过 33 毫秒,它就到不了 30 帧——卡,就是这么被测出来的。
三、时间花在哪儿了:三个花钱的地方
| 卡的原因 | 它在干什么 | 怎么判断是不是它 | 怎么改 |
|---|---|---|---|
| 数据太多 | 每帧往屏幕送太多字节,SPI 塞不下 | 把要改的区域缩小,帧耗时立刻下降 | 只改该改的地方(脏矩形) |
| 算得太久 | 50 个球要算 50 次位置、判断 50 次边界 | 球数减半,帧耗时也大约减半 | 减少每帧计算量 |
| 等得不对 | 用 delay() 干等,期间啥也干不了 | 去掉 delay(),帧耗时明显下降 | 用 millis() 到点才做 |
这三个原因经常同时存在。所以每次只改一处,看帧耗时怎么变——还是那句老话:只改一个条件,先预测,再验证。
四、脏矩形:只擦真正脏了的那块
"只改该改的地方"有个正式名字:脏矩形。意思特别简单——哪块区域变了("脏"了),就只更新那一块。
半径 6 的小球,一帧之中影响的区域大约就是 12×12 那么一小块。它从 A 点跑到 B 点,真正变了的只有两块:A 点(要盖掉)和 B 点(要画上)。
中间那大片黑色背景压根没变过,为什么要重画?
| 做法 | 每帧要处理的点 | 像什么 |
|---|---|---|
| 整屏重画 | 76,800 个 | 作业写错一个字,把整页撕了重写 |
| 只清小球走过的小块 | 约 144 个 | 用橡皮擦掉那一个字,再写一遍 |
这就像改作业:写错一个字,擦掉那个字就行,没人会把整页撕了重写——除非他不知道错在哪儿。
**所以脏矩形的关键不是"擦得少",而是你得知道哪块脏了。**程序要自己记着:上一帧画在哪儿,这一帧要画到哪儿。
试试看差多少
请做一个对比实验,同样的 20 个小球,两种画法:
A 版:每一帧先用 fillScreen 把整屏填黑,再画 20 个球;
B 版:每一帧只把每个球上一帧的位置用背景色盖掉,再在新位置画上,不清整屏。
两版都要统计平均帧耗时并打印出来。
按 BOOT 在 A 版和 B 版之间切换。请用中文注释,保留旧版本。
| 版本 | 我预测 | 实测 | 约合多少帧/秒 |
|---|---|---|---|
| A 版(整屏重画) | ____ | ____ | |
| B 版(局部清除) | ____ | ____ |
五、屏幕自己也有节奏,而且它不等你
你可能会想:我老老实实一帧一帧画完整,不就没问题了吗?
不完全对。因为屏幕自己有节奏,而且它不等你。
屏幕内部有一块自己的小本子,叫显存(缩写 GRAM)。你通过 SPI 送过去的数据,先写在这块小本子上;然后屏幕按自己的节奏,一遍遍照着小本子把画面显示出来——大约每秒六十多次。
这意味着两件事。
第一,你写你的,它显示它的,两边不同步。
打个比方:你在黑板上写字,旁边有另一个同学负责把黑板拍下来给全班看。**他每隔一小会儿就拍一次,不等你写完。**你要是正好写了一半,全班看到的就是半句话。
在屏幕上,这叫撕裂——画面上半部分是新的、下半部分是旧的,中间错开一道。
第二,你改得越零碎,被拍到的"半句话"就越多,画面还会闪。
这个比喻有个地方要修正:屏幕里并没有真的两个人。真实情况是——同一块显存,一边被你写、一边被屏幕读出来显示。读写同时发生在同一块地方,才有了这些麻烦。
那怎么办?
工程师的办法是:先在一块草稿上把一整帧画完,再一次性搬到屏幕上。这块草稿有个名字叫 sprite(精灵)或缓冲区,这个办法叫双缓冲。
因为搬到屏幕上是"一整块一起换",屏幕要么拍到旧的、要么拍到新的,不会拍到一半——闪烁和撕裂就都没了。
代价是什么?草稿要占内存。我们这块板有 PSRAM(外挂内存),画一块 240×320 的草稿大约要 150 KB——这正是我们编译参数里坚持带 PSRAM=opi 的原因之一。
具体怎么实现,我们留到做游戏时专门讲。今天你只要记住:
屏幕不等你。它按自己的节奏显示,你负责让每帧完整地准备好。
六、把"不卡"读成中文步骤
启动时:
初始化屏幕,整屏填黑
记下 50 个小球各自的位置和速度
记下"上一帧每个球画在哪里"(一开始都是空的)
每一帧:
记下这一帧开始的时刻
对每个小球:
用背景色盖掉它上一帧所在的小矩形 ← 脏矩形:只清这一小块
有重叠时:把重叠的小矩形合并成一块大的再清
对每个小球:
算出新位置
如果撞到边界:把对应的速度取反
在新位置画上小球
记下这一帧画在了哪里(供下一帧清除)
统计这一帧花了多少毫秒
如果还没到下一帧的时间:等一小会儿(用 millis 判断,不要 delay 空等)
累加帧数,每隔一段时间算出平均帧耗时并打印
找一找:**哪一步最容易花掉几十毫秒?**如果球有 500 个,哪一步会先撑不住?
七、动手做:把它优化到不掉帧
明确的目标:让 50 个小球跑起来,平均帧耗时控制在 33 毫秒以内。
请帮我优化 50 个小球的动画,目标是平均帧耗时不超过 33 毫秒:
1. 不要每帧 fillScreen 整屏重画,改成只清除每个球上一帧占用的小矩形;
2. 如果有球的区域重叠,把要清除的区域合并后再清;
3. 用 millis() 控制节奏,不要用 delay() 空等;
4. 保留帧率统计,把平均帧耗时和帧率显示在屏幕上并通过串口打印。
请先告诉我你打算怎么改,等我确认后再动手。
完成后回答三个问题:
- 平均帧耗时降到了多少?进 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 毫秒,一帧一帧看清楚 | 慢下来以后,你看到哪些平时看不到的东西? |
倒数第二行那道题,是后面做游戏时的真本事。到时候你会发现:游戏流不流畅,靠的不是写得多快,而是每一帧少做了多少不该做的事。
本课的配套资源
本节配套的演示程序源码与可烧录固件,已整合到课程页面底部的「配套资源 · 在线烧录」区——插上你的 ESP32-S3 触摸屏板(Chrome / Edge 浏览器),点「在线烧录」即可直接写入,无需安装软件。
给 AI 的第一句话(每次新开对话先说这句):
请使用课程的 esp32s3-touchboard-hardware-dev Skill,
我现在做第 7 课(动画流畅度与帧率),需要你帮我____。
改动前先说清依据的项目文件路径,烧录前先运行 arduino-cli board list 确认端口。