抓帧报告(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 | 机制 | 做什么 |
|---|---|---|
| Vulkan | Subpass + Input Attachment | 多个 pass 在同一块 tile memory 上接力 |
| Metal | Tile Shading / Imageblock、Memoryless Texture | 同上,且 RT 可声明为"永不落 DRAM" |
| OpenGL ES | Framebuffer Fetch、Pixel 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 RenderPassMetal 侧:
// 中间 RT 声明为 memoryless —— 它根本不会被分配 DRAMletdesc=MTLTextureDescriptor()desc.storageMode=.memoryless// ★ 只存在于 tile memory另一件重要的事:别在 RenderPass 中间打断它
以下操作会强制 GPU “flush tile”,把便签内容全部写回仓库:
| 操作 | 为什么致命 |
|---|---|
Texture.ReadPixels/AsyncGPUReadback | CPU 要读,必须落 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两个要点:
- Bloom 必须在低分辨率做。1/4 分辨率 = 1/16 的像素 = 1/16 的带宽。而 Bloom 本来就是糊的,肉眼看不出差别。
- 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 的 depth
DontCare
该项目选了 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 Debugger | Mali 的 tile 相关计数器、外部带宽 |
| RenderDoc | Pass 结构、附件配置(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 从"不可用"变"几乎免费" |
| ⭐⭐⭐⭐ | loadAction用Clear而非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 分钟,玩家手心里的那片滚烫。