不必要的 RT 切换与 Resolve:手机 GPU 最贵的那笔隐形账单
2026/9/21 0:51:46 网站建设 项目流程

抓帧报告(1080p,训练场静止不动)

Draw Calls : 412 ← 还行 Triangles : 1.2 M ← 还行 Texture Read : 180 MB/s ← 还行 ──────────────────────────────── RenderPass : 23 ← ⚠️ Framebuffer Traffic : 1,840 MB/s ← 🔴🔴🔴

一个站着不动的画面,每秒往内存里搬了1.8 GB

这些流量不是纹理,不是顶点,不是任何"内容"。
它是同一块画布,被反复地从桌上收进仓库、又从仓库搬回桌上——
来回 23 趟。

而在手机上,"搬运"比"绘制"贵得多。


第一幕:一张便签和一个仓库

手机 GPU 和 PC GPU,是两种完全不同的生物

PC 显卡(IMR,立即模式渲染):显存带宽 500 GB/s 起步,有独立供电,有风扇。
它的策略很简单粗暴:Framebuffer 就放在显存里,随便读,随便写。

手机 GPU(TBR / TBDR,分块渲染):和 CPU 共享同一条 LPDDR 内存,总带宽约30~60 GB/s,还要和 CPU、ISP、显示控制器抢。

它的策略是

把屏幕切成一个个小方块(Tile),通常 16×16 或 32×32 像素 ↓ 每次只处理【一个 Tile】 ↓ 这个 Tile 的颜色/深度/模板,全部放在【片上 SRAM】里(Tile Memory) ↓ 整个 Tile 画完了,才一次性写回 DRAM

用一个比喻

类比速度能耗
Tile Memory(片上 SRAM)你手边的便签纸极快极低
System Memory(DRAM)走廊尽头的仓库高 100~1000 倍

🎯这是全篇的地基
在手机上,一次外部 DRAM 访问的能耗,约等于几百次片上运算。
(Arm、Imagination 在历年移动图形技术分享中反复强调的量级)

所以移动端渲染优化的第一原则不是"少算",而是:
“让数据待在便签上,别往仓库跑。”

一个 RenderPass 的一生

【Load】 从仓库把画布搬到便签上(或者直接在便签上清空) ↓ 【Render】 在便签上画画(画多少笔都不贵,因为都在片上) ↓ 【Store】 把便签上的结果搬回仓库

注意 Render 那一步在同一个 RenderPass 内部,你画 10 层还是 100 层 Overdraw,都不产生 DRAM 流量。全部在片上完成。

而 Load 和 Store,每一次都是实打实的全屏内存搬运。

算笔账(1080p)

Color RGBA8 : 1920 × 1080 × 4 B = 8.3 MB Depth D24S8 : 1920 × 1080 × 4 B = 8.3 MB ──────────────────────────────────────────── 一次 Store + Load 往返 = 33 MB × 60 fps = 约 2 GB/s

一次多余的 RT 切换,就是 2 GB/s。而你的总带宽预算只有几十 GB/s,还要分给 CPU 和纹理采样。

这就是序幕里那 1840 MB/s 的来源。


第二幕:两个被全世界忽略的枚举

Metal / Vulkan 在 API 层面把 Load 和 Store显式暴露了出来。这是移动图形 API 设计里最重要的一处细节,也是最少人正确配置的一处。

Load Action:这个 RenderPass 开始时,怎么拿到画布

干什么成本
Load从 DRAM 把上一帧/上一个 pass 的内容读回来🔴全屏读
Clear直接在片上填一个固定值🟢几乎免费
DontCare什么都不做,内容是垃圾🟢完全免费

Store Action:这个 RenderPass 结束时,结果怎么处理

干什么成本
Store写回 DRAM🔴全屏写
DontCare丢弃🟢完全免费
MultisampleResolve在片上把 MSAA 多采样合并成单采样,只写回结果🟢几乎免费
StoreAndMultisampleResolve两个都写🔴🔴最贵

由此产生的两条黄金规则

规则一:Clear永远优于Load

// ❌ 最常见的错误:以为 Clear 是"浪费",于是关掉了// 结果 GPU 只能老老实实把上一帧的 8.3MB 读回来colorAttachment.loadAction=Load;// ✅ Clear 是在片上做的,它反而比 Load 便宜colorAttachment.loadAction=Clear;// ✅✅ 如果你的场景保证铺满全屏(天空盒/全屏背景),最优colorAttachment.loadAction=DontCare;

💡这条规则违反直觉,但在移动端是铁律
“不清屏"不是优化,是把成本从"片上填充"换成了"全屏 DRAM 读取”。

规则二:深度缓冲,99% 的情况应该DontCare

这是最常见、最昂贵、最容易修的浪费。

主 Pass 画完 → 深度缓冲被 Store 回 DRAM(8.3MB) → 然后……再也没有人读它 → 下一帧开头它被 Clear 掉

每秒白白搬运 0.5 GB,纯浪费。

depthAttachment.storeAction=DontCare;// 改一行,省 0.5 GB/s

什么时候不能 DontCare?只有两种:

  • 后面有 pass 要读深度(软粒子、SSAO、雾效、深度描边)
  • 分了多个 pass 但要共享深度(如不透明和半透明分开)

而即便如此,你也应该重新设计,让它们待在同一个 RenderPass 里(下一幕)。

一个价值巨大的推论:MSAA 在移动端几乎免费

PC 上 MSAA 4x 很贵,因为 framebuffer 变成 4 倍大,全在显存里进出。

但在 TBDR 上:多采样数据只存在于 Tile Memory 里MultisampleResolve在片上完成,写回 DRAM 的仍然是单采样结果

❌ storeAction = Store → 4× 数据全写回 DRAM(33 MB) ✅ storeAction = MultisampleResolve → 只写单采样(8.3 MB)

移动端 MSAA 4x 的正确成本是:多一点片上计算 + 多占一点 tile memory(可能让 tile 尺寸变小)。
带宽成本接近于零。

所以:在手机上,MSAA 往往比 FXAA/TAA 更划算——后者是全屏后处理,要多一次完整的 RT 往返。

⚠️唯一的代价:MSAA 会占用 tile memory,可能导致 GPU 把 tile 尺寸从 32×32 降到 16×16,binning 开销上升。所以要实测,不是无脑开。


第三幕:什么叫"不必要"

必要的切换:后一个 pass 需要随机采样前一个 pass 的整张图
比如高斯模糊——每个像素要读周围 9 个像素,它必须等整张图画完

不必要的切换:后一个 pass 只需要当前像素的前一个结果。

这个区别就是一切。

需要「整张图」 → 必须落 DRAM → 真的要切 RT 需要「同一个像素」→ 数据就在片上 → 【根本不该切】

而现实是:大量后处理只需要"同一个像素",却被写成了独立的 RenderPass。

效果真实需求常见实现该不该切
色调映射(Tonemap)当前像素独立 pass❌ 不该
颜色分级(LUT)当前像素独立 pass❌ 不该
暗角(Vignette)当前像素独立 pass❌ 不该
色差 / 胶片颗粒当前像素独立 pass❌ 不该
Bloom 模糊邻域 / 降采样独立 pass必须
景深邻域独立 pass✅ 必须
延迟光照当前像素的 GBuffer独立 pass可用 Subpass

第四幕:现代武器库——让数据留在便签上

三大厂商给了同一件事三个名字,本质完全一样:

API机制做什么
VulkanSubpass + Input Attachment多个 pass 在同一块 tile memory 上接力
MetalTile Shading / ImageblockMemoryless Texture同上,且 RT 可声明为"永不落 DRAM"
OpenGL ESFramebuffer FetchPixel Local Storage片元着色器直接读当前像素的颜色/深度

核心能力只有一句话

让下一个 pass 直接读取"当前像素"在 tile memory 里的值,中间结果永不写回 DRAM。

这让移动端延迟渲染成为可能

❌ 传统延迟渲染(移动端灾难) GBuffer Pass → Store 4 张 RT 到 DRAM(33 MB) Lighting Pass → Load 4 张 RT 回来(33 MB) = 每帧 66 MB,60fps = 4 GB/s 💀 ✅ Subpass 延迟渲染 GBuffer Subpass → 结果留在 tile memory Lighting Subpass → 直接从 tile memory 读(Input Attachment) GBuffer 声明为 Memoryless / DontCare = 只有最终颜色写回 DRAM(8.3 MB)

这就是为什么 Unity URP 在 Android/iOS 上支持延迟渲染时,必须依赖 Native RenderPass API。

Unity 侧的接口

// URP 的 ScriptableRenderPasspublicoverridevoidConfigure(CommandBuffercmd,RenderTextureDescriptordesc){ConfigureInputAttachments(gbufferHandles);// 声明"我从 tile 里读"ConfigureClear(ClearFlag.None,Color.clear);}// 并在 Project Settings 中开启 Native RenderPass

Metal 侧

// 中间 RT 声明为 memoryless —— 它根本不会被分配 DRAMletdesc=MTLTextureDescriptor()desc.storageMode=.memoryless// ★ 只存在于 tile memory

另一件重要的事:别在 RenderPass 中间打断它

以下操作会强制 GPU “flush tile”,把便签内容全部写回仓库:

操作为什么致命
Texture.ReadPixels/AsyncGPUReadbackCPU 要读,必须落 DRAM
中途切换 RT 再切回来两次完整往返
对刚画完的 RT 立刻采样必须等它完整落盘
Compute Shader 插在两个 pass 之间打断 tile 流水
GL.Clear在 pass 中间调用语义冲突,可能触发 flush

⚠️Unity 里的一个经典坑Camera.targetTexture频繁赋值/置空、或在OnRenderImage里多次Blit——
每一次 Blit,都是一次完整的 RenderPass(Load + Store)。


第五幕:射击游戏里的六个战场

战场一:那个从来没人读的深度缓冲

现场:序幕里的 23 个 RenderPass,第一刀就砍在这里。

抓帧发现:主几何 pass 的depthAttachment.storeAction = Store而整条后处理链里,没有任何一个 shader 采样深度。

为什么会这样:Unity 默认的_CameraDepthTexture开关,只要有任何一个组件(哪怕是一个禁用的软粒子)曾经请求过深度,它就会被保留。

修法

// URP Renderer Asset// Depth Texture: ☐ 关闭(确认无人使用后)// 或在自定义 pass 里显式指定cmd.SetRenderTarget(colorRT,depthRT,RenderBufferLoadAction.Clear,RenderBufferStoreAction.DontCare);// ★

收益−0.5 GB/s,功耗 −0.28 W。改一行配置。

💡顺带一条:如果你确实需要深度(软粒子),也不要用全精度 D24S8
很多效果用R16F 的线性深度就够了,带宽直接减半。

战场二:七趟后处理,合成两趟

改造前的 pass 链

① Main Geometry → Store (8.3 MB) ② Bloom Prefilter → Load + Store ③ Blur Horizontal → Load + Store ④ Blur Vertical → Load + Store ⑤ Bloom Composite → Load + Store ⑥ Tonemap → Load + Store ⑦ Vignette + Grain → Load + Store ──────────────────────────────────── 7 个 RenderPass,约 13 次全屏往返

关键洞察⑥⑦ 只需要"当前像素",②~⑤ 才真的需要邻域。

改造后

① Main Geometry → Store ② Bloom(1/4 分辨率,降采样链在小图上完成) ③ ★ Uber Pass:Bloom 合成 + Tonemap + LUT + Vignette + Grain —— 全部塞进一个 shader,一趟搞定 ──────────────────────────────────── 3 个 RenderPass

两个要点

  1. Bloom 必须在低分辨率做。1/4 分辨率 = 1/16 的像素 = 1/16 的带宽。而 Bloom 本来就是糊的,肉眼看不出差别。
  2. Uber Shader 用multi_compile做变体,而不是运行时 if——关掉的效果应该在编译期就消失
// UberPost.shader half4 frag(v2f i) : SV_Target { half3 c = SAMPLE(_MainTex, i.uv).rgb; #if _BLOOM c += SAMPLE(_BloomTex, i.uv).rgb * _BloomIntensity; #endif #if _TONEMAP c = ACESFilm(c); #endif #if _LUT c = ApplyLut(c, _LutTex); #endif #if _VIGNETTE c *= Vignette(i.uv); #endif return half4(c, 1); }

收益:帧缓冲流量1840 → 620 MB/s,GPU 功耗−0.9 W

战场三:狙击镜的画中画

需求:8 倍镜开镜时,镜片里显示一个独立视角的画面。

朴素实现(灾难)

scopeCamera.targetTexture=scopeRT;// 1080p 全分辨率scopeCamera.Render();// 完整的一遍场景渲染 + RT 往返// 主相机采样 scopeRT 贴到镜片上

成本:多一整套 RenderPass + 33 MB 往返,且全场景重新绘制

四项优化

措施说明收益
① 降分辨率镜片在屏幕上只占约 1/5 面积 → RT 用 512×512带宽 −85%
② 剔除半径收缩镜内只画 300m 内 + 目标层,不画地形细节DrawCall −60%
③ 不开镜时彻底禁用scopeCamera.enabled = false不是targetTexture = null未开镜时零成本
④ storeAction 只保留 color镜片 RT 的 depthDontCare−4 MB/帧

⚠️ 一个必须守住的红线镜内的准星、弹道提示,必须在主 pass 的原生分辨率上绘制,不能跟着 RT 降分辨率。竞技公平性不能让步。

实测:开镜时的额外功耗从+1.4 W 降到 +0.25 W

战场四:透视描边(队友/敌人轮廓)

需求:队友被墙挡住时显示蓝色轮廓,这是团队射击游戏的标配。

常见实现

① 把角色渲染到一张 Mask RT(Stencil 或 R8) → RT 切换 #1 ② 对 Mask 做边缘检测/膨胀 → RT 切换 #2 ③ 合成回主画面 → RT 切换 #3

三次全屏往返,为了几条线。

更好的方案(按推荐度)

方案 A:Stencil + 同 Pass 内完成(最优)

主 Pass 内: ① 角色正常渲染时写入 Stencil = 1 ② 紧接着用"背面外扩 + Stencil NotEqual 1 + ZTest Always"画一遍轮廓 → 全程在同一个 RenderPass 里,零 RT 切换

方案 B:Framebuffer Fetch
在片元着色器里直接读当前 tile 的颜色/模板,做本地边缘检测——不需要整张图

方案 C:如果必须用 Mask RT

  • Mask 用R8 格式(不是 RGBA8),带宽 1/4
  • Mask 用1/2 分辨率(轮廓略粗,反而更显眼)
  • Mask 的 depthDontCare

该项目选了 A:RT 切换 3 → 0,−0.6 W

战场五:MSAA 的 storeAction 写错了

现象:开了 MSAA 4x,帧率掉 40%——远超预期。

抓帧一看

colorAttachment.storeAction = Store // ❌

后果:4 倍采样的数据全部写回了 DRAM。

8.3 MB × 4 = 33 MB 每帧,只 color 一项 60fps = 2 GB/s 的纯浪费

修法一行

colorAttachment.storeAction=MultisampleResolve;// 片上解析,只写单采样depthAttachment.storeAction=DontCare;// MSAA depth 永远不该 store

结果:MSAA 4x 的性能损失从40% 降到 6%

🔥这是本篇最值得记住的一条
在移动端,MSAA 不贵——"错误配置的 MSAA"才贵。
而且贵到让人误以为"手机不该开 MSAA",于是转去用 FXAA/TAA——那反倒是一次真实的全屏 RT 往返。

战场六:UI 的三宗罪

罪一:UI 渲到独立 RT 做整体淡入

// ❌ 为了一个 0.3 秒的淡入动画,每帧多一次全屏 RT 往返uiCamera.targetTexture=uiRT;

修法:淡入用CanvasGroup.alpha(顶点色,零成本),只在真正需要"整体后处理"时才用 RT,且用完立刻释放。

罪二:小地图每帧全量重绘

// ❌ 60Hz 渲染一张 256×256 的俯视图// ✅ 降到 10Hz + 只在玩家移动超过阈值时更新if(Time.time-lastMinimapUpdate>0.1f&&moved>2f){RenderMinimap();}

收益:小地图成本 −83%。

罪三:血条/伤害数字触发的多余 Canvas 重建

Unity 的Canvas 重建(Rebuild)虽然不是 RT 切换,但它会打断渲染批次,间接增加 pass 数量。

✅ 动态元素(血条、伤害数字、准星)放【独立 Canvas】 ✅ 静态元素(背景、边框)放另一个 Canvas,永不 dirty

终幕:怎么看、怎么改

怎么看

工具看什么
Xcode GPU Capture最直观:直接显示每个 RenderPass 的 Load/Store Action 和"Memoryless"标记
Snapdragon Profiler(Adreno)Read Total (Bytes)/Write Total (Bytes)、RenderPass 数、是否走 binning mode
Arm Streamline / Mali Graphics DebuggerMali 的 tile 相关计数器、外部带宽
RenderDocPass 结构、附件配置(PC/Android 通用)
Unity Frame Debugger快速看有多少次Blit和 SetRenderTarget

一个 5 秒判断法

Framebuffer 流量 ÷ (宽 × 高 × 4B × fps) ≈ 全屏往返次数

这个数字应该 ≤ 4。超过 8,基本可以断定有大量不必要的切换。

怎么改(按性价比排序)

优先级动作典型收益
⭐⭐⭐⭐⭐DepthstoreAction = DontCare−0.5 GB/s,改一行
⭐⭐⭐⭐⭐后处理合并成 Uber Pass−1 GB/s
⭐⭐⭐⭐⭐MSAA 用MultisampleResolve让 MSAA 从"不可用"变"几乎免费"
⭐⭐⭐⭐loadActionClear而非Load−0.5 GB/s,反直觉但重要
⭐⭐⭐⭐Bloom / 粒子 / 模糊在 1/4 分辨率做−0.8 GB/s
⭐⭐⭐⭐描边用 Stencil 同 pass 完成省 2~3 次往返
⭐⭐⭐Subpass / Framebuffer Fetch(延迟渲染必需)让移动端延迟渲染可行
⭐⭐⭐中间 RT 用memoryless(Metal)完全不分配 DRAM
⭐⭐⭐降低非关键 RT 的分辨率与格式(R8 / R16F)按比例
⭐⭐小地图、镜像等降频渲染按比例

绝对不要做的五件事

  • ❌ 在 RenderPass 中间ReadPixels/ 回读 GPU
  • ❌ 对刚渲染完的 RT 立刻采样(强制 flush)
  • ❌ 为了"省一次 Clear"而用Load
  • ❌ 每帧创建/销毁 RenderTexture(改用 RTHandle / 临时 RT 池)
  • ❌ 用Store保存一个没人读的 attachment

收尾

回到序幕那 23 个 RenderPass。

它们每一个看起来都"合理"——一个效果,一个 pass,代码干净,职责清晰。
在 PC 上,这样写完全没问题。

但在手机上,每一次"切换"都不是一个 API 调用,而是一次搬家。

把 8 MB 的画布从便签收进仓库, 走到仓库门口,再把它搬回来, 只为了在上面多画一笔。

做 23 次。每秒 60 遍。

所以移动端渲染优化,最核心的思维转变是这一句:

不要问"我画了多少东西",
要问"我的画布,来回跑了几趟仓库"。

Draw Call 是台面上的账,人人都在看。
而帧缓冲流量,是那笔躺在账本背面、却真正决定你手机温度的隐形账单。

它不体现在 Profiler 的任何一个醒目数字里。

它只体现在——第 12 分钟,玩家手心里的那片滚烫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询