对Android游戏画面抖动现象的研究
- 作者:admin
- /
- 时间:6小时前
- /
- 浏览:25 次
- /
- 分类:厚积薄发
【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
一、前言
近期笔者一直在研究一个专题:在Android平台下,游戏以30帧运行时,即便整体性能稳定,仍普遍存在画面抖动现象。即使是知名厂商的游戏也不例外。例如笔者测试《星穹轨道》时发现:

角色跑动时留意那盆绿植会比较明显
可能是出于功耗的考虑,一进来就默认推荐了30帧,玩起来也有持续性的画面抖动,不像是偶发的卡顿,我的手机性能是较好的,不至于连30帧都跑不流畅。
通过一段时间的研究,对这个问题的原理和解决方案有了一定的见解。
二、原因分析
1. 显示机制
Android的显示机制是一个典型的生产者-消费者模型。应用端负责生产画面,通过调用SwapBuffer将内容写入BufferQueue,系统端(SurfaceFlinger进程)则从BufferQueue中取出内容,执行后续的合成与显示流程。

SurfaceFlinger取BufferQueue的节奏是根据系统Vsync-sf信号调节的,一般跟屏幕刷新率走,比如60hz那么就是16.6ms为一个周期定期触发,当游戏为30帧时,平均是33.3ms生产一个Buffer,生产的速率远远慢于消耗的速率,当Vsync-sf信号来临但BufferQueue里面为空时,系统会重复显示上一帧的内容。
理想情况下,在60Hz屏幕下运行30帧,SurfaceFlinger应该每两个周期消耗一帧内容。然而通过分析发现,实际的消耗周期并不均匀:有时一个周期消耗,有时甚至三个周期消耗。导致每帧画面在屏幕上的停留时间不一致,从而在视觉上产生抖动感。
2. 量化工具
接下来使用Perfetto来看一下这个游戏,Perfetto是Android上系统级别的Trace工具,可以使用它来观察具体的BufferQueue、Vsync情况。
Perfetto有一些配置,针对画面抖动问题需要确认开启ATrace的gfx、view模块:

下图中标注了每一次Buffer被消耗时,距离上一次的周期数:

这里筛选出了Trace数据中相关的轨道,从BufferQueue里面可以看到大多数情况是2个周期消耗一次,偶尔会有1、3个周期出现。
Perfetto生成的Trace文件可以通过SQL进行统计。笔者统计了每次BufferQueue减少时的时间点,进一步计算了这些时间间隔的标准差。该指标可用于量化画面抖动的严重程度,为后续方案对比与优化提供了数据基础。

完整SQL内容在附录中提供
三、目前的解决方案
1. Swappy方案
Swappy是Google GameSDK的一部分,这里是它的官方介绍:
https://developer.android.com/games/sdk/frame-pacing?hl=zh-cn
*上述网址需要使用VPN打开
它是大多数游戏引擎内置的方案,以Unity引擎为例直接设置一下生效就行。

此方案核心是通过两个EGL拓展来达成它的目的:
- 通过setPresentationTime,为Buffer设置一个时间戳,避免此Buffer被过快消耗掉。
回到之前我们的例子,如果这里设置了一个允许被消耗的时间戳,那么这里不会存在1个周期就消耗掉的情况:

- 利用EGL_KHR_fence_sync,可以追踪上一帧GPU处理完毕再将当前帧Swap进去。由于实际游戏运行过程中不会是平稳地每次33.3ms Swap一次,会有波动,此策略可以避免局部生产过快导致的BufferQueue堆积。
Hook了Swappy中的一些函数,将其标注到了Perfetto中以观察其运行过程:

当然,笔者在实际使用过程中,也是遇到了不少问题:
- 掉帧,常见于开60帧的情况,因为要等上一帧GPU结束,不同手机追踪到的GPU时间差异大,有的要10几ms,再加上Swappy是以周期为单位进行等待的,很容易延后的时间太多,最终导致帧率下降严重,所以建议60帧时不启用Swappy机制。
- 延迟明显,一方面是渲染线程进行Swap的平均时间是要延后很多的,另一个方面是主线程是先做逻辑Update,然后通过WaitForPendingPresent等待上一帧渲染线程结束后,再继续,从Update到最终呈现的链路多了一个流程,30帧时预估延迟会增加30~40ms左右,体感明显。
- 不稳定,不同机型效果差异很大,观察了一下发现有些机型setPresentationTime调用后,实际生效的时间并不精确,还有些机型60hz的屏幕刷新率但程序返回的值是59、58这些奇怪的值,可能会导致功能失效、锁帧之类的问题。
总的来说Swappy对画面抖动是有一定效果的,上述问题有些也好处理,只是此方案虽然开启简单,但决策前仍然要权衡、多机型实测评估。
2. 渲染线程同步方案
这是我在《王者荣耀》上发现的,他们使用了一种不同的方案来解决这个问题。

王者开30帧时的表现
此方案是通过让应用端画面生产节奏保持绝对的均匀,来间接让SurfaceFlinger消耗的节奏均匀,需要改一下引擎实现:
- 渲染线程每次Swap调用前留一个时间戳,如果下一次Swap前的间隔时间小于33ms,则Sleep补全时间后再Swap。另外主线程WaitForTargetFPS的逻辑要去掉,依赖渲染线程控制输出节奏。
- 还有个小细节,我通过逆向分析发现王者主线程等待上一个渲染线程结束的Sync点调整到了逻辑Update前,这样虽然降低了主线程、渲染线程的并行程度,但延迟上有优势,因为从逻辑Update到Swap中间没有WaitForPendingPresent。
这个办法简单易行,笔者实测是有效的,当然延迟还是不可避免会增加,30帧时预估延迟会增加16ms左右,仍然比Swappy少很多,算是一个平衡的方案,值得参考。
四、后续探索
很惭愧,目前对于这一问题,笔者尚未找到一个完全理想的解决方案。虽然“渲染线程同步”方案在实际测试中确实能够改善画面抖动,但仍存在不可忽视的局限:一方面,这一方案本质上是通过延长时间来达到输出节奏的均匀,导致整体延迟有所增加,另一方面,对于抖动的缓解效果其实也是有限的。
同时,Swappy库作为Google官方提供的开源方案,内部实现中有许多值得借鉴的点,它能推算出Vsync-sf信号点时间。所以笔者计划在现有“渲染线程同步”方案上进行进一步优化,结合推算出的Vsync-sf信号点时间信息来减少渲染线程Sleep的量,降低延迟,提升精准度。希望通过不断的迭代,能逐步逼近一个更加完美、兼顾效果与延迟的方案。
五、附录:Perfetto中量化统计画面抖动的SQL
WITH ValueChange AS (
SELECT
c.ts,
t.name,
c.value,
LAG(c.value) OVER (PARTITION BY c.track_id ORDER BY c.ts) AS previous_value,
process.name
FROM
counter AS c
JOIN process_counter_track AS t ON c.track_id = t.id
JOIN process ON t.upid = process.upid
WHERE
t.name LIKE "%SurfaceView%" and process.name LIKE "%SurfaceFlinger%"
),
ValueChange2 AS (
SELECT
ts,
name,
value
FROM
ValueChange
WHERE
value < previous_value
),
intervals AS (
SELECT
ts - LAG(ts) OVER (ORDER BY ts) AS interval_ns
FROM ValueChange2
),
intervals_ms AS (
SELECT
interval_ns / 1000000.0 AS interval_ms -- 将纳秒转换为毫秒
FROM intervals
WHERE interval_ns IS NOT NULL and interval_ns < 100000000
),
stats AS (
SELECT
COUNT(interval_ms) AS count_interval,
AVG(interval_ms) AS mean_interval,
AVG(interval_ms * interval_ms) AS mean_square_interval
FROM intervals_ms
)
SELECT
mean_interval AS avg_consume_interval_ms,
SQRT(mean_square_interval - mean_interval * mean_interval) AS stddev_consume_interval_ms
FROM stats;
这是侑虎科技第1894篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/jun-yan-76-80
再次感谢其乐陶陶的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)