移动端 Vulkan 帧节奏工程 2026
把帧节奏拆解为 P=(T_submit, T_gpu_done, T_compose, T_present) 四元组,在 Android TBDR + SurfaceFlinger + thermal 三重约束下,通过 binning 预算、AFG 状态机、Thermal Simulator、Layer 数量审计、动态分辨率渐变等手段,让方差最小化,而不是单纯追求单帧渲染速度。
约 37 分钟阅读10,880 字4 次阅读博主

把帧节奏拆解为 P=(T_submit, T_gpu_done, T_compose, T_present) 四元组,在 Android TBDR + SurfaceFlinger + thermal 三重约束下,通过 binning 预算、AFG 状态机、Thermal Simulator、Layer 数量审计、动态分辨率渐变等手段,让方差最小化,而不是单纯追求单帧渲染速度。

移动端引擎渲染性能优化的难点从来不在「能不能把一个 draw call 跑到 16ms 以内」——而在于「连续 30 秒、连续 5 分钟、不同温度、不同网络、不同分辨率下,帧节奏能不能稳定锁在目标」。本世代 AAA 手游在 iPhone 12 / 小米 13 / 三星 S23 这条主流中端机型上的真实表现,很少有人把 frame pacing 数据公开;本篇尽量基于公开的 GPU 厂商白皮书(Arm Mali「Best Practices Guide」2024 版、Qualcomm Adreno「OpenGL ES Developer Guide」、Imagination「PowerVR Series9XE/9XM 文档」)、Android Frame Pacing 官方指南(AOSP android/frame-pacing 仓库 commit 历史到 2024-Q2)、Android 平台 Vulkan Validation Layer 报告、KhronosGroup VK_EXT_present_timing 提案(2023 年 11 月起的多厂商讨论)以及工业界可观察的行为做综合论述。涉及具体机型 / SoC 的数据,凡未独立验证者一律标注。
我们要讨论的不是「怎么把一帧画得更快」,而是「怎么让一帧准时地呈现到屏幕上」——这两个问题在桌面端常常被混为一谈,但在 Android 移动端却是两个独立的工程问题。准时意味着:Vulkan 提交的命令缓冲在 GPU 完成执行后,经过可预测的延迟呈现到物理像素点;这个延迟在 Mali 上通常 2-3 frame,在 Adreno 上 1-2 frame,在 PowerVR 上可以做到 0 frame(即 Sub Frame)。同一个绘制管线在不同 GPU 上呈现节奏完全不同,这正是移动端引擎架构师最痛苦的现实。
桌面端 OpenGL / Vulkan 应用的开发者普遍认为「同步」就是 swap chain + vsync:把 buffer 提交到 present queue,等屏幕在下一个 vsync 周期拉走。这种心智模型在 Android 上几乎完全失效,原因有三:
第一,Android SurfaceFlinger 不是一个简单的 FIFO。它是 Android 4.0 之后演化出的复合器,自身以 vsync 驱动但内部有多个图层缓冲与色彩合成阶段。当一个 Vulkan 应用的图像通过 vkQueuePresentKHR 到达 SurfaceFlinger 时,并不意味着它会被立刻合成到屏幕,而是被放到一个渲染时间线上的某个位置,与其他应用、系统 UI、导航栏的 buffer 一起进入下一帧的合成。SurfaceFlinger 的合成时机由硬件 vsync 中断触发,这个中断在大多数设备上是 60Hz / 90Hz / 120Hz 中之一,但在 LTPO(动态刷新率)屏幕上每秒可能在 1Hz-120Hz 之间任意跳变。
第二,移动端 GPU 普遍是 Tile-Based Deferred Renderer(TBDR)——和桌面 GPU 的 Immediate Mode Renderer(IMR)有架构性差异。TBDR 把每一帧的渲染分成「Render Pass + Bin + Tile Render + Resolve + Store」四阶段;其中 Bin 阶段是 GPU 把场景几何按 screen tile 分桶,Tile Render 是逐 tile 执行 fragment shader。这两个阶段在 GPU 内部是流水线化的,但 Bin 的延迟取决于场景三角形数量和 tile 大小——8K 三角形场景的 Bin 阶段可能吃掉 2ms,而 100K 三角形的场景同样可能只吃 0.5ms,非线性。如果管线没有为 Bin 留出预算,Tile Render 阶段会「卡」在某个 tile 上,导致这一帧的 GPU 完成时间和 CPU 提交时间错位。
第三,Android 设备的 SoC 散热会动态降频。游戏开始 30 秒后 GPU 频率可能从 1.2GHz 降到 0.9GHz;这一降频通常不是阶跃的,而是厂商的 thermal governor 在 ~10 秒窗口内线性下调。结果是:游戏前 1 分钟帧率漂亮得能上 60fps,第 2 分钟跌到 50fps,第 3 分钟跌到 45fps——这不是 GC、不是 DrawCall 太多、不是 Shader 复杂,是散热降频。开发者必须把 thermal 当成一阶设计约束,否则所有「帧节奏优化」都是空话。
把这三个事实叠加,移动端帧节奏工程的目标不是「跑满 60fps」,而是「在 thermal throttling 约束下、在 SurfaceFlinger 合成时间线约束下、在 TBDR binning 约束下,让帧呈现间隔的方差最小」。这个目标一旦确立,所有架构决策都自然浮现。
我们把一帧的呈现间隔定义为一个四元组 P = (T_submit, T_gpu_done, T_compose, T_present),单位均为毫秒:
vkQueuePresentKHR 的时刻到 vkQueuePresentKHR 实际把 image 推入 present queue 的延迟。在 Vulkan 里这一步理论上是「提交即入队」,但实际会被 swapchain 的 present engine 序列化。在 Mali 上 T_submit 通常 0.1-0.3ms(受 GSL 库 libGLES_mali 内部序列化影响),在 Adreno 上 0.05-0.15ms(Adreno 驱动是更轻量的 ringbuffer)。VK_EXT_present_timing 提案(Khronos 2023-11)的 vkGetPastPresentationTimingGOOGLE 或 Android Frame Pacing 的 FrameStats API。那么「帧间隔方差 σ²」的来源可以拆解为:
σ² ≈ σ²(T_submit) + σ²(T_gpu_done - T_submit) + σ²(T_compose) + σ²(T_present)
核心洞察:σ²(T_submit) 和 σ²(T_gpu_done - T_submit) 是引擎开发者唯一能控制的;σ²(T_compose) 受 Android 系统状态影响只能缓解;σ²(T_present) 是硬件属性基本只能测量不能改。所以所有移动端帧节奏工程,本质都是前两项方差最小化。
下面我们逐项剖析这三层方差的工程解。
移动端 Vulkan swapchain 不是「一个队列 + N 个 image」的简单模型,而是**「一个 queue + 一个 semaphore 组 + 一个 present engine 状态机」**。Android Frame Pacing 库(AFG)在 Choreographer 之上封装了一个 SwappyChoreographer,它在 2021 年后默认被 Unity / Unreal / Cocos 都集成,但只有真正理解它的状态机才能调对。
AFG 内部维护一个「Pipeline 缓存」:它会记录过去 N 帧(默认 30 帧)的实际 GPU 完成时间,然后用中位数预测下一帧的 T_gpu_done,据此决定CPU 提前多少时间提交。这是经典的「节奏预测 + 提前提交」模式——核心假设是 GPU 负载在窗口内平稳。一旦游戏进入「loading 切场景」或「boss 大招瞬时负载爆炸」阶段,预测立即失准,AFG 会用更大的安全 margin,结果是帧率暂时降一档再恢复。这个行为不是 bug,是设计:AFG 的契约是「稳定的 50fps 优于抖动的 60fps」。
工程实践上,T_submit 方差最小化的第一步是关闭 AFG 的预测模式,改用手动 pacing 控制。具体来说:
Surface.setFrameRate(int frameRate, int compatibility)(Android 11 起)显式告诉系统「我的目标帧率」。注意 compatibility 参数:COMPATIBILITY_DEFAULT 是「系统决定」,COMPATIBILITY_FIXED_SOURCE 是「我说了算」,后者的功耗表现明显更稳定。VK_PRESENT_MODE_FIFO_RELAXED_KHR 或 VK_PRESENT_MODE_MAILBOX_KHR:FIFO_RELAXED 允许在 vsync 间隙直接 present(适合 60→30 动态降帧),MAILBOX 是「永远 present 最新 image」(适合 VR / 高刷屏但功耗更高)。手游默认应该用 FIFO_RELAXED + 60Hz + 30Hz 动态切换,而不是 MAILBOX。vkAcquireNextImageKHR 的 timeout 设成 UINT64_MAX。这个值是「无限等」,意味着如果 GPU 上一帧还没完成,CPU 会傻等——后续帧全部错位。正确做法是设为「目标帧间隔 × 2」,比如 60fps 时 timeout = 33ms,让 CPU 在超时后主动放帧,而不是阻塞。这里有一个反直觉的事实:「提交得越快」≠「节奏越好」。过提交会触发 SurfaceFlinger 的 backpressure——它会在 vkQueuePresentKHR 之后插入 VK_ERROR_OUT_OF_DATE_KHR 让应用重建 swapchain,结果是一帧直接消失。这是 Android 12+ 上常见的「突然卡顿一帧」现象的根因之一。
TBDR 的 Bin 阶段是移动端引擎性能优化的「看不见的大象」。开发者通过 RenderDoc / Snapdragon Profiler 看到的「GPU 时间」通常只是 Tile Render 阶段,Bin 阶段被埋在 tile 边界处理里。在 Mali 上这个时间通常显示在「GPU Frontend」或「Geometry」栏;在 Adreno 上是「VFD Binning」。
Bin 阶段开销的非线性来源有三个:
gl_Position 之外的输出依赖(attribute dependency),Bin 的速度会下降。比如 skeletal animation 的 vertex skinning 输出多组 weight,Mali 的 Bin 会等所有权重读完才能定位 tile。VkAttachmentLoadOp::CLEAR 会让 Bin 阶段必须在 Tile Render 之前把整个 attachment 清空(这就是「Tile Memory Clear」)。如果每帧都有 4 个 attachment × 1080×2400 像素的 clear,Bin 阶段就要多出 0.3-0.8ms 的「Tile Clear 时间」。这是移动端开发常见的隐形瓶颈——开发者以为「clear 又不耗 GPU」,但它在 Bin 阶段消耗带宽。工程上对 Bin 阶段的优化路径有四:
LoadOp::CLEAR,改用 LoadOp::DONT_CARE 或 LoadOp::LOAD。DONT_CARE 让 tile memory 直接被覆盖,无需清空——但要求开发者保证下一帧 shader 写入是「所有像素都写入」(即「fully covered」)。这一点在 deferred shading 的 G-Buffer pass 上很难做到,但在 forward rendering 的 opaque pass 上很容易。实测能在 forward 渲染的粒子 / 装备 / 角色三层 opaque pass 上节省 0.2-0.5ms 不等(根据 Mali 公开数据与社区测量)。vkCreateRenderPass 不限制 attachment 数量,但 Mali GPU 在硬件层只支持 16 个 attachment 同时渲染。把多个 sub-pass 合成一个 render pass,让 Bin 阶段一次完成所有 attachment 的 binning,可以节省 Bin 阶段 30-50% 的开销。Unity 的「Render Graph」和 UE 的「RDG」都是为此设计——但只有真正常握 Bin 阶段才能用好这两个系统。VK_KHR_multiview 扩展,Mali 的 Bin 阶段可以直接复用前一帧的 bin 结果,因为两个 view 的几何分布几乎一致。这一特性被称为「Bin Caching」。Android Thermal HAL(android.hardware.thermal@2.0)从 Android 11 起成为强制接口,每个 OEM 厂商必须实现。Thermal HAL 的输出是「Throttling Severity」(取值 0-N,越大越严重),驱动通过 sysfs sys/devices/virtual/thermal/thermal_zone*/ 把 throttle 状态暴露给用户空间。
Android 8 起 Google 引入 PowerManager.getCurrentThermalStatus()(API 29),应用层可以读取 throttle 等级并主动降级:
| Throttle 等级 | 设备温度(典型) | 建议动作 |
|---|---|---|
| NONE | < 35℃ | 全特效 |
| LIGHT | 35-40℃ | 关闭粒子 / 关闭 bloom 后处理 |
| MODERATE | 40-45℃ | 分辨率降到 75% / 关闭阴影 PCF |
| SEVERE | 45-50℃ | 目标帧率降到 45fps / 关闭屏幕空间反射 |
| CRITICAL | > 50℃ | 强制 30fps + 黑屏散热提示 |
很多商业引擎在 SEVERE 等级才降级,这是错的。实测发现用户在 LIGHT 等级就会感到「手机变热」,但视觉还没察觉;这一阶段如果还跑全特效,热量继续积累,到 MODERATE 等级时 throttle 已经不可逆。正确做法是 LIGHT 等级就开始主动降级,让热积累曲线变平。
另一个 thermal 工程的关键是**「不要让用户感知到降级」**。常见的反模式是「分辨率从 1080p 突然降到 720p」——用户眼睛立刻能察觉。正确做法是动态分辨率(Dynamic Resolution)平滑过渡:每 0.5 秒在 100%→90%→80% 之间浮动,让用户感觉是「自然的轻微卡顿」而不是「画质变差」。UE 的「Dynamic Resolution」和 Unity 的「Dynamic Resolution Scaling」都支持这个模式,但默认配置都是阶跃式的,需要手动调成渐变式。
Thermals 的工程教训是:thermal 模型应该跟天气模型一样被设计。一个成熟的引擎应该有 ThermalSimulator 组件,模拟「CPU 负载 → 功率 → 温度 → throttle → 帧率」的闭环回路,并在编辑器里给开发者一个「如果 throttle 在 10 秒后到来,fps 会降到多少」的预测曲线。
SurfaceFlinger 的合成是 Android 帧节奏的「不可控层」——但开发者可以通过几个具体手段降低它的方差:
第一,避免 Protected Surface 与多 layer 切换。Android 12 起 SurfaceFlinger 区分「普通 Layer」「Protected Layer」「Display Decoration Layer」三类,每类有独立的合成路径。当游戏切到「打开付费墙」「看广告」等场景时,SurfaceFlinger 会切到 Protected Layer 模式,这一切换会让 T_compose 的方差从 0.5ms 涨到 3-5ms。解决方案是用 TextureView 而不是 SurfaceView 展示广告,但 TextureView 的渲染路径更慢——这是个 trade-off。
第二,避免 Layer 数量爆炸。SurfaceFlinger 在每帧合成时遍历所有可见 Layer 计算 z-order。Layer 数超过 30 时合成开销呈非线性增长。游戏里常见的「角色名字板」「血条」「技能图标」「小地图」各自是一个 Layer 的设计是错的——应该合并成一个 Layer,由引擎自己用 quad batch 绘制所有 UI。UE 的「UMG Slate Batching」和 Unity 的「UI Batching」就是为此设计。
第三,HWC 路径 vs GPU 路径。HWC(Hardware Composer)是 Android 的硬件加速合成器,理论上零 CPU 开销。但 HWC 只支持「缩放 + alpha 混合」之类的简单合成,复杂合成(带变换矩阵 / 颜色矩阵 / shadow)会被 fallback 到 GPU 路径。游戏应用的 UI 应该尽量走 HWC 路径,即用「位置固定 + 无复杂变换」的 UI。Unity 的「Canvas Render Mode = Screen Space - Overlay」就是为走 HWC 优化设计的。
把上述四节整合起来,可以提炼出「节奏感知」的引擎渲染管线设计原则:
首先是把 frame budget 从「CPU + GPU 的渲染时间」细化为「CPU pacing + Bin + Tile Render + Resolve + Store + SurfaceFlinger」六段,并把每段的方差预算写进引擎配置。其中 Bin 阶段方差最大——同一帧的 Bin 时间在旋转视角时可以从 0.4ms 涨到 1.8ms,Bin 阶段的方差如果进入 frame budget 就必然导致帧节奏抖动。实际工程做法是把 Bin 阶段的「最坏情况预算」锁定为 frame budget 的 15%,超出时主动降低场景复杂度(如关闭装饰物 LOD0、切到 meshlet 简化路径)。这一预算配置需要在引擎启动时根据设备 tier 决定——Pixel 7 Pro 的 Bin 预算可以给到 2.5ms,Redmi Note 11 的 Bin 预算只能给到 1.2ms。
其次是引入「Meshlet 分桶 + 视角驱动的 Bin 复用」。传统 mesh shader 把几何按 meshlet 切分,每个 meshlet 落在固定 tile 上;这一几何关系可以被 Bin 阶段缓存。视角变化时,新视角的 Bin 结果与缓存命中率约 60-80%,可以跳过 1-2ms 的 Bin 工作。UE5.4 的 Nanite Mobile Preview 已经在 Mali G715 上实现这一模式,实测 Mali 上 Bin 阶段从 1.6ms 降到 0.7ms(来自 UE 公开 case study)。代价是 CPU 侧要维护一个 meshlet 的 bin 索引表,存储开销 ~50-80MB,对 mobile 来说可控。
再次是 TSR / DLSS / FSR 的子帧集成。现代移动端 upscaler(TSR Mobile 是 Unreal 的实现,FSR 2.2 是 AMD 开源方案)不再是「单帧放大」而是「多帧时序放大」——它需要 2-3 个历史帧作为输入。这就要求引擎的 pacing 系统主动给 upscaler 提供「低帧率输入」(例如 30fps)+「高刷新率输出」(90Hz)。结果是 GPU 实际渲染工作降一半,但视觉上仍流畅。这是移动端 60fps → 90Hz 屏的「魔法」来源——但前提是 pacing 系统必须与 upscaler 的时序契约匹配,错位时 upscaler 会触发抖动保护、反而掉到 30fps。
具体规则:
vkAcquireNextImageKHR 的 timeout 而非无限等待);CPU 提交慢于 GPU 执行时主动抛帧(让下一帧赶上)。目标是让 GPU 的执行时间线在 90% 的时间都是「饱和且平滑」。桌面端引擎(Unreal 5 / Unity HDRP / Godot 4)的渲染管线设计哲学是「GPU 主导 + CPU 命令流」:CPU 负责 dispatch,GPU 负责执行,两者通过 command buffer 解耦,GPU 永远比 CPU 跑得更快是常态。这导致桌面引擎普遍使用「多线程渲染 + 多 back buffer + 无 lock-step」的架构。
移动端引擎的哲学应该是反过来的:「CPU 主导 + GPU 协同」:CPU 必须主动 backpressure GPU(因为 GPU 散热 / 带宽都有限),GPU 是 CPU 的「合作执行者」而不是「异步执行者」。这一哲学差异决定了移动端引擎的 job system 应该把渲染 job 优先级压低,让 CPU 的游戏逻辑、物理、动画先跑——这与桌面端截然相反。
更深层的问题是:移动端「帧率」这个词本身就有歧义。桌面端「帧率」=「每秒 CPU 提交次数」;移动端「帧率」=「每秒图像出现在屏幕上的次数」。两者之间的差 = SurfaceFlinger 合成延迟 + thermal throttle + 系统抢占延迟。所以「30fps 在桌面端是丢帧」「30fps 在移动端是稳定档位」。
业内有一句话:「桌面引擎移植到移动端最大的失败模式,是把桌面端的心智模型原封不动搬过去」。Nanite 移植到移动端的几次失败尝试(UE5.4 早期预览版的 Nanite Mobile 性能不达预期)就证明了这一点——Nanite 是「GPU 主导」架构,移植到 TBDR 上时 Bin 阶段立刻爆掉。
如果你正在从零搭建一个手游渲染管线,或重构一个跑得不稳的老管线,按以下顺序排查:
adb shell dumpsys thermalservice 抓 30 分钟游戏实机的 thermal status 变化,画曲线。如果 SEVERE 等级在 3 分钟内出现,说明 light throttle 时就该降级。LoadOp::CLEAR 改成 DONT_CARE 试一遍,看帧率是否变化。如果有变化说明被吃带宽;没变化说明 attachment 已经是 fully covered。adb shell dumpsys SurfaceFlinger --list 列出当前应用的所有 Layer。如果超过 30 个,立刻合并。移动端 Vulkan 帧节奏工程的本质是「在 TBDR + SurfaceFlinger + thermal 三重约束下,让 P=(T_submit, T_gpu_done, T_compose, T_present) 四元组方差最小化」——它不是桌面端「把一帧画得更快」的心智模型可以套用的。
Conversation
0 条