Appearance
录制渲染卡顿攻克
录制卡顿的根因不是 GPU 也不是内存,而是编码被放在 Fetch 线程里同步执行,堵住了下一帧的读取。修法是经典的生产者-消费者解耦:编码独立成线程,中间加一个有界队列,满了就丢最老的帧。
队列上限3 帧
单帧大小7-10MB → ~1MB
背压策略丢最老帧
背景
高德车道级导航的录制回放功能,录的是 3D 渲染画面(SR)。录制对帧率要求高,H.264 编码的计算压力也大——一边要维持渲染帧率,一边要把每帧编码落盘。功能上线后录制时画面明显卡顿。
问题
SR 卡顿在排查纪律上要先分成三类症状,修法完全不同:
| 症状类别 | 特征 | 典型根因 |
|---|---|---|
| 严重掉帧 | 渲染帧率被钉在 1fps | Surface 前后台门控(可见区域信号下发 foregroundState=0),是设计行为不是渲染 bug |
| 轻微掉帧 | 逻辑/渲染帧率低于 25 | 云配下发的帧率档位本身偏低,或命中了 P4/P5 低帧率档 |
| 偶发卡顿 | 帧率决策正常,但单帧超时 | 单帧内某个任务耗时过长(帧超时异常) |
按这个分类走排查链路:先搜「帧率变化」日志,渲染帧没有掉到 1、决策档位也正常——排除前两类,落到「偶发卡顿」。同时做了两个量化排除:
- 怀疑 GPU 瓶颈 → 量化 GPU 占比,录制时未超 80%,排除。
- 怀疑内存 → Heap 分析,录制时内存增长正常,排除。
最后定位到编码:H.264 编码在 Fetch 线程里同步执行,编码耗时长,直接阻塞了下一帧的读取——渲染管线被录制拖住,表现为卡顿。
佐证来自单帧耗时的监控:CartonProfiler 会对超时帧打「帧超时异常」日志,每条带帧号和耗时(cost_time)。卡顿时刻附近这类日志成簇出现,且耗时集中在编码调用上——帧率决策正常、单帧超时,正是「偶发卡顿」这一类。
方案
mermaid
flowchart LR
subgraph Before["重构前"]
A1["Fetch 线程"] --> B1["读渲染帧"] --> C1["编码 H.264<br>耗时阻塞"] --> D1["写文件"]
end
subgraph After["重构后"]
A2["Fetch 线程"] --> B2["读渲染帧"] --> C2["入队即返回"]
C2 --> Q["有界队列<br>上限 3 帧"]
Q --> E2["独立编码线程"] --> F2["写文件"]
Q -.->|"满"| G2["丢弃最老帧<br>丢帧背压"]
end核心是把「读帧」和「编码」解耦:Fetch 线程只负责把渲染帧搬进队列,立刻返回去读下一帧;编码在自己的线程里慢慢跑。队列必须有界——无界队列只是把卡顿换成内存无限增长。
关键实现
有界录制队列
- 队列大小上限 3 帧。
- 队列满时丢弃最老的帧(丢帧背压):编码跟不上时,宁可录制文件跳帧,也不让延迟逐帧累积,更不让内存无界增长。
- 丢最老而不是丢最新:新帧代表最新画面,跳帧发生在旧帧上,观看体感更连贯。
帧率策略侧的配合
录制回放要求渲染画面流畅,手势/运镜场景由帧率策略的 P3 档(Gesture,默认 60/60)保证高帧率;决策层 FrameRatePolicyResolver 是纯逻辑、不依赖 MonoBehaviour,优先级判断可以 EditMode 单测。云配下发的帧率配置走原子校验:任一字段非法(负数/非数字)整包拒绝,防止半个配置生效导致帧率状态不一致。
排查时还有一个容易误判的点:Surface 退到后台时渲染帧率被门控到 1fps(不可见时几乎不渲染,最大化省 GPU),这是设计行为。如果录制时看到渲染帧是 1,先查「可见区域」信号,别先去怀疑编码。
帧率配置链路本身可以用 ADB 注入做等效验证:广播一条云配同格式的帧率字符串,走的是同一条解析-应用链路——注入成功说明 Unity 侧 apply 链路正常,失败则一定伴随 [CloudConfigFpsApplier] 的错误日志(原子校验拒绝整包),不会出现「一半生效」的中间态。
为什么队列上限是 3 帧
3 帧是在「编码偶发抖动」和「录制时效性」之间取的值:小于 3 帧,编码线程一次正常的抖动就会触发丢帧;大于 3 帧,队列里积的帧越来越旧,录制文件和实时画面之间会出现可感知的延迟,内存占用也随之上去了。
后续:P 帧压缩
录制文件体积的进一步优化:
高德录制 P 帧压缩:细化颗粒度 + mask 压缩,预估单帧 7-10MB → 约 1MB。
指标
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 录制时帧率 | 明显卡顿 | 稳定 |
| 录制内存风险 | 编码积压可无限增长 | 有界,最多 3 帧 |
| 编码 CPU 占用 | 阻塞 Fetch/渲染管线 | 独立线程,不影响主线程 |
| 单帧大小(P 帧压缩后) | 7-10MB | 约 1MB |
| 录制文件质量 | — | 与实时渲染一致 |
复盘
- 卡顿先分类再排查:严重掉帧查前后台门控,轻微掉帧查云配档位,偶发卡顿查单帧超时。三类混在一起看,只会互相误导。
- 排除法要有量化依据:「GPU 未超 80%」「Heap 增长正常」这种结论才能支撑继续往下走,拍脑袋排除会把排查带进沟里。
- 生产者-消费者之间只要有耗时不对称,队列就必须有界;背压策略(丢哪帧、什么时候丢)是设计的一部分,不是事后补丁。
- 编码这类确定性耗时的任务,永远不要和帧读取这类时效性任务放在同一个线程里。
- 可观测性是分类排查的前提:没有
CartonProfiler的帧超时日志,「偶发卡顿」很难和帧率决策类问题区分开,只能继续靠猜。