16.6 毫秒的账是怎么亏掉的
一帧预算的四笔开销:可变 dt、每帧 1KB 分配、"合批"这种听着免费的优化,和一个树叶材质拖垮整场景的 overdraw。
"我们掉帧了"这句话没信息量。有信息量的是:这 16.6ms 花在哪个科目上,以及那个科目的修法会不会让你欠下另一笔。
我这两年把性能问题按四个科目重新排过一遍,因为它们的坑位完全不同。
科目一:时间步长 —— 抖动不是 GPU 的错
可变 dt 的模拟有两个必然结果:物理行为依赖帧长(同一个阻尼参数在不同帧率下调出来的手感不一样),以及掉帧时的穿隧。Glenn Fiedler 的 Fix Your Timestep! 给的正解大家都熟:累加器把模拟和渲染解耦。
accumulator += std::min(frameTime, 0.25f); // 钳住,别追债
while (accumulator >= dt) {
previousState = currentState;
integrate(currentState, currentTime, dt);
currentTime += dt;
accumulator -= dt;
}
const float alpha = accumulator / dt;
const State blended = currentState * alpha + previousState * (1.0f - alpha);
三个数字和两个容易漏的细节:
min(frameTime, 0.25)那行不是可选的。 不钳的话,一次卡顿 300ms 就会让 while 循环要跑 30 步去追,追不上又产生更长帧 —— 这是"死亡螺旋",而它长得非常像"优化不够所以卡",其实是我自己造出来的。- 最后那行插值不能省。 直接画
currentState会看到轻微抖动,因为accumulator里剩的零头永远没被画进去。Fiedler 文中的dt是 0.01(100Hz 模拟)。 - 上限也要有。Robert Nystrom 在 Game Loop 模式里除了同样的
lag累加,还强调单次循环的最大步数要封顶,并且指出插值会让渲染"超前大约半帧",所以偶尔需要往回拉一次修正。
引擎里的对应开关:Unity 的 Time 设置里 Fixed Delta Time 定物理节奏,Maximum Timestep 官方定位就是"低帧率时封顶最坏情况",避免追赶队列无限堆。同一个页面上还有个容易顺手调的东西:Maximum Particle Timestep 用子步换视觉平滑,官方明确写了这是以性能为代价。我见过有人把它调大之后莫名多出 3ms,就是这么来的。
科目二:分配 —— 每帧 1KB 的账单
Unity 的 GC 概览给了个我很喜欢的换算:每帧只分配 1KB,60FPS 下就是每分钟 3.6MB 等着回收,所以热点路径的目标值是"理想 0 bytes/frame"。
关键是 1KB 这个数字听起来太无害了,无害到你不会去查它。查的方法在两篇官方文档里:Tracking GC allocations 说要用 CPU Usage 模块 Hierarchy 里的 GC.Alloc 列(该帧该线程在托管堆上分配的字节数),配 Call Stacks 定位到具体代码行 —— 而且不用开 Deep Profile 那种贵得离谱的采样。结论必须以 development build 为准,Editor 里的数据不可比,这条我吃过一次亏:Editor 里干净的路径,真机构建里有个 LINQ 在每帧分配。
回收本身的代价在 GC modes:一次性 stop-the-world 的标记清理可以长达数百毫秒,而增量 GC 就是把它拆成多帧的小时间片去守 16ms 那条线。天下没有白拿的:增量模式要付 write barrier 的成本,引用写得太频繁会退回全量回收;Web 平台不支持增量;三种模式都不压缩内存。
不压缩这件事直接连到碎片。Robert Nystrom 的 Object Pool 里那句"memory fragmentation is deadly"我抄进了项目笔记:堆上有 14 字节空闲但凑不出你要的那块连续空间,于是长时间 soak test 直接分配失败 —— 就是那种"跑两小时才复现"的崩溃。同一篇里两个实现细节:定长数组池找空位是 O(n),用 free list(把下一个可用节点直接串在被回收对象自己的内存里)能做到 O(1);以及回收槽位不会自动清零,取出来必须完整重初始化,而池内对象互相持有引用会让 GC 再也回收不掉它们。
顺带一条方法论,Godot 的 CPU optimization 写得比多数引擎文档老实:先测再改;手写基准要跑 1000 次以上取平均,因为首次运行被缓存预热和计时精度污染;改完必须复测。它把"访问内存要线性"列在很前面,理由是让硬件预取真的能预取 —— 这和下面那篇 ECS 的说法是同一件事的两个方向。
科目三:draw call —— "合批"不是免费的
我一度把"减少 draw call 数量"当成无条件正确的优化。Batching meshes 把代价写清楚了:
- 静态合批每段上限 64k 顶点,而且代价是丧失逐物体视锥剔除 —— 你省了提交,赔进了可见性剔除。
- 动态合批把顶点变换搬到 CPU 上做,官方直接写"CPU 成本常常高于省下的 draw call",并限定用于低端设备;限制还一堆:网格不超过 300 顶点 / 900 顶点属性、不兼容蒙皮、破坏依赖深度的透明排序、多 pass shader 只合第一 pass。
SRP Batcher 的定位更容易被误读:它省的是准备和派发 draw call 的 CPU 时间以及 render-state 切换,不是 draw call 数量本身 —— 所以瓶颈会在材质数量涨上去时换到别处。GPU Instancing 是"一次 draw call 画同 mesh + 同材质的多个对象,实例属性各自保留",但官方提醒逐实例数据的上传可能开销大于收益,而且移动端收益通常高于桌面。
结论是:先看瓶颈在 CPU 提交侧还是 GPU 执行侧,再决定要不要合批。方向搞反的优化会让人非常挫败,因为它一定"看起来什么都没变"。
科目四:overdraw —— 一个树叶材质拖垮整场景
MJP 的 To Early-Z, or Not To Early-Z(2025-04)有一组少见的实测数字,AMD RX 7900 XT 上同一个场景:
| 画法 | 片元调用次数 |
|---|---|
| 不透明几何,前→后排序 | 440,640 |
| 不透明几何,后→前排序 | 648,000 |
加了 discard / alpha test 且写深度 | 约 582,000 |
| 保守深度写入(等价于 early-Z 完全失效) | 648,000 |
排序带来的差距是 约 47%,而且机制说得很白:discard 并不改变像素深度,它只是让深度写入变成有条件,于是硬件没法在跑完昂贵的像素着色之前剔掉被遮住的片元。也就是说你那个"半透明但不透明队列"的树叶材质,把整场景的 early-Z 打回了后→前的表现。
文中的实践建议我照抄了:不透明、非 alpha-test 的先画;需要时加 depth prepass;以及在批次之间插全局 barrier(后者很贵,是最后手段)。
我会先做的三件事
- 把模拟 dt 固定 + 钳帧 + 插值,先确认"卡"和"抖"不是自己造的。这一步几乎不要钱,却常被误当成需要优化的东西。
- development build 上读 GC.Alloc 列,把每帧分配打到 0,再谈要不要增量 GC。分配是病根,GC 模式是止痛药。
- 排序与深度状态,不是合批。overdraw 有硬数字支撑,而"少几个 draw call"经常只是感觉。
参考来源
- Fix Your Timestep! — Glenn Fiedler, Gaffer on Games
- Game Loop 与 Object Pool — Robert Nystrom, Game Programming Patterns
- Time、GC overview、Tracking GC allocations、GC modes、Batching meshes、SRP Batcher、GPU Instancing — Unity Manual 6000.2
- CPU optimization — Godot Docs
- ECS FAQ — Sander Mertens(缓存行那段与上文"线性访问"互为正反面)
- To Early-Z, or Not To Early-Z — Matt Phelan(2025-04-13,实测数字出自此文)