
抓幀報告1080p訓練場靜止不動Draw Calls : 412 ← 還行 Triangles : 1.2 M ← 還行 Texture Read : 180 MB/s ← 還行 ──────────────────────────────── RenderPass : 23 ← ?? Framebuffer Traffic : 1,840 MB/s ← 一個站著不動的畫面每秒往內(nèi)存里搬了1.8 GB。這些流量不是紋理不是頂點不是任何內(nèi)容。它是同一塊畫布被反復地從桌上收進倉庫、又從倉庫搬回桌上——來回 23 趟。而在手機上搬運比繪制貴得多。第一幕一張便簽和一個倉庫手機 GPU 和 PC GPU是兩種完全不同的生物PC 顯卡IMR立即模式渲染顯存帶寬 500 GB/s 起步有獨立供電有風扇。它的策略很簡單粗暴Framebuffer 就放在顯存里隨便讀隨便寫。手機 GPUTBR / TBDR分塊渲染和 CPU 共享同一條 LPDDR 內(nèi)存總帶寬約30~60 GB/s還要和 CPU、ISP、顯示控制器搶。它的策略是把屏幕切成一個個小方塊Tile通常 16×16 或 32×32 像素 ↓ 每次只處理【一個 Tile】 ↓ 這個 Tile 的顏色/深度/模板全部放在【片上 SRAM】里Tile Memory ↓ 整個 Tile 畫完了才一次性寫回 DRAM用一個比喻類比速度能耗Tile Memory片上 SRAM你手邊的便簽紙極快極低System MemoryDRAM走廊盡頭的倉庫慢高 100~1000 倍這是全篇的地基在手機上一次外部 DRAM 訪問的能耗約等于幾百次片上運算。Arm、Imagination 在歷年移動圖形技術(shù)分享中反復強調(diào)的量級所以移動端渲染優(yōu)化的第一原則不是少算而是“讓數(shù)據(jù)待在便簽上別往倉庫跑。”一個 RenderPass 的一生【Load】 從倉庫把畫布搬到便簽上或者直接在便簽上清空 ↓ 【Render】 在便簽上畫畫畫多少筆都不貴因為都在片上 ↓ 【Store】 把便簽上的結(jié)果搬回倉庫注意 Render 那一步在同一個 RenderPass 內(nèi)部你畫 10 層還是 100 層 Overdraw都不產(chǎn)生 DRAM 流量。全部在片上完成。而 Load 和 Store每一次都是實打?qū)嵉娜羶?nèi)存搬運。算筆賬1080pColor 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。而你的總帶寬預(yù)算只有幾十 GB/s還要分給 CPU 和紋理采樣。這就是序幕里那 1840 MB/s 的來源。第二幕兩個被全世界忽略的枚舉Metal / Vulkan 在 API 層面把 Load 和 Store顯式暴露了出來。這是移動圖形 API 設(shè)計里最重要的一處細節(jié)也是最少人正確配置的一處。Load Action這個 RenderPass 開始時怎么拿到畫布值干什么成本Load從 DRAM 把上一幀/上一個 pass 的內(nèi)容讀回來全屏讀Clear直接在片上填一個固定值幾乎免費DontCare什么都不做內(nèi)容是垃圾完全免費Store Action這個 RenderPass 結(jié)束時結(jié)果怎么處理值干什么成本Store寫回 DRAM全屏寫DontCare丟棄完全免費MultisampleResolve在片上把 MSAA 多采樣合并成單采樣只寫回結(jié)果幾乎免費StoreAndMultisampleResolve兩個都寫最貴由此產(chǎn)生的兩條黃金規(guī)則規(guī)則一Clear永遠優(yōu)于Load。// ? 最常見的錯誤以為 Clear 是浪費于是關(guān)掉了// 結(jié)果 GPU 只能老老實實把上一幀的 8.3MB 讀回來colorAttachment.loadActionLoad;// ? Clear 是在片上做的它反而比 Load 便宜colorAttachment.loadActionClear;// ?? 如果你的場景保證鋪滿全屏天空盒/全屏背景最優(yōu)colorAttachment.loadActionDontCare;這條規(guī)則違反直覺但在移動端是鐵律“不清屏不是優(yōu)化是把成本從片上填充換成了全屏 DRAM 讀取”。規(guī)則二深度緩沖99% 的情況應(yīng)該DontCare。這是最常見、最昂貴、最容易修的浪費。主 Pass 畫完 → 深度緩沖被 Store 回 DRAM8.3MB → 然后……再也沒有人讀它 → 下一幀開頭它被 Clear 掉每秒白白搬運 0.5 GB純浪費。depthAttachment.storeActionDontCare;// 改一行省 0.5 GB/s什么時候不能 DontCare只有兩種后面有 pass 要讀深度軟粒子、SSAO、霧效、深度描邊分了多個 pass 但要共享深度如不透明和半透明分開而即便如此你也應(yīng)該重新設(shè)計讓它們待在同一個 RenderPass 里下一幕。一個價值巨大的推論MSAA 在移動端幾乎免費PC 上 MSAA 4x 很貴因為 framebuffer 變成 4 倍大全在顯存里進出。但在 TBDR 上多采樣數(shù)據(jù)只存在于 Tile Memory 里MultisampleResolve在片上完成寫回 DRAM 的仍然是單采樣結(jié)果。? storeAction Store → 4× 數(shù)據(jù)全寫回 DRAM33 MB ? storeAction MultisampleResolve → 只寫單采樣8.3 MB移動端 MSAA 4x 的正確成本是多一點片上計算 多占一點 tile memory可能讓 tile 尺寸變小。帶寬成本接近于零。所以在手機上MSAA 往往比 FXAA/TAA 更劃算——后者是全屏后處理要多一次完整的 RT 往返。??唯一的代價MSAA 會占用 tile memory可能導致 GPU 把 tile 尺寸從 32×32 降到 16×16binning 開銷上升。所以要實測不是無腦開。第三幕什么叫不必要必要的切換后一個 pass 需要隨機采樣前一個 pass 的整張圖。比如高斯模糊——每個像素要讀周圍 9 個像素它必須等整張圖畫完。不必要的切換后一個 pass 只需要當前像素的前一個結(jié)果。這個區(qū)別就是一切。需要「整張圖」 → 必須落 DRAM → 真的要切 RT 需要「同一個像素」→ 數(shù)據(jù)就在片上 → 【根本不該切】而現(xiàn)實是大量后處理只需要同一個像素卻被寫成了獨立的 RenderPass。效果真實需求常見實現(xiàn)該不該切色調(diào)映射Tonemap當前像素獨立 pass? 不該顏色分級LUT當前像素獨立 pass? 不該暗角Vignette當前像素獨立 pass? 不該色差 / 膠片顆粒當前像素獨立 pass? 不該Bloom 模糊鄰域 / 降采樣獨立 pass?必須景深鄰域獨立 pass? 必須延遲光照當前像素的 GBuffer獨立 pass?可用 Subpass第四幕現(xiàn)代武器庫——讓數(shù)據(jù)留在便簽上三大廠商給了同一件事三個名字本質(zhì)完全一樣API機制做什么VulkanSubpass Input Attachment多個 pass 在同一塊 tile memory 上接力MetalTile Shading / Imageblock、Memoryless Texture同上且 RT 可聲明為永不落 DRAMOpenGL ESFramebuffer Fetch、Pixel Local Storage片元著色器直接讀當前像素的顏色/深度核心能力只有一句話讓下一個 pass 直接讀取當前像素在 tile memory 里的值中間結(jié)果永不寫回 DRAM。這讓移動端延遲渲染成為可能? 傳統(tǒng)延遲渲染移動端災(zāi)難 GBuffer Pass → Store 4 張 RT 到 DRAM33 MB Lighting Pass → Load 4 張 RT 回來33 MB 每幀 66 MB60fps 4 GB/s ? Subpass 延遲渲染 GBuffer Subpass → 結(jié)果留在 tile memory Lighting Subpass → 直接從 tile memory 讀Input Attachment GBuffer 聲明為 Memoryless / DontCare 只有最終顏色寫回 DRAM8.3 MB這就是為什么 Unity URP 在 Android/iOS 上支持延遲渲染時必須依賴 Native RenderPass API。Unity 側(cè)的接口// URP 的 ScriptableRenderPasspublicoverridevoidConfigure(CommandBuffercmd,RenderTextureDescriptordesc){ConfigureInputAttachments(gbufferHandles);// 聲明我從 tile 里讀ConfigureClear(ClearFlag.None,Color.clear);}// 并在 Project Settings 中開啟 Native RenderPassMetal 側(cè)// 中間 RT 聲明為 memoryless —— 它根本不會被分配 DRAMletdescMTLTextureDescriptor()desc.storageMode.memoryless// ★ 只存在于 tile memory另一件重要的事別在 RenderPass 中間打斷它以下操作會強制 GPU “flush tile”把便簽內(nèi)容全部寫回倉庫操作為什么致命Texture.ReadPixels/AsyncGPUReadbackCPU 要讀必須落 DRAM中途切換 RT 再切回來兩次完整往返對剛畫完的 RT 立刻采樣必須等它完整落盤Compute Shader 插在兩個 pass 之間打斷 tile 流水GL.Clear在 pass 中間調(diào)用語義沖突可能觸發(fā) flush??Unity 里的一個經(jīng)典坑Camera.targetTexture頻繁賦值/置空、或在OnRenderImage里多次Blit——每一次 Blit都是一次完整的 RenderPassLoad Store。第五幕射擊游戲里的六個戰(zhàn)場戰(zhàn)場一那個從來沒人讀的深度緩沖現(xiàn)場序幕里的 23 個 RenderPass第一刀就砍在這里。抓幀發(fā)現(xiàn)主幾何 pass 的depthAttachment.storeAction Store。而整條后處理鏈里沒有任何一個 shader 采樣深度。為什么會這樣Unity 默認的_CameraDepthTexture開關(guān)只要有任何一個組件哪怕是一個禁用的軟粒子曾經(jīng)請求過深度它就會被保留。修法// URP Renderer Asset// Depth Texture: ? 關(guān)閉確認無人使用后// 或在自定義 pass 里顯式指定cmd.SetRenderTarget(colorRT,depthRT,RenderBufferLoadAction.Clear,RenderBufferStoreAction.DontCare);// ★收益?0.5 GB/s功耗 ?0.28 W。改一行配置。順帶一條如果你確實需要深度軟粒子也不要用全精度 D24S8。很多效果用R16F 的線性深度就夠了帶寬直接減半。戰(zhàn)場二七趟后處理合成兩趟改造前的 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 次全屏往返關(guān)鍵洞察⑥⑦ 只需要當前像素②~⑤ 才真的需要鄰域。改造后① Main Geometry → Store ② Bloom1/4 分辨率降采樣鏈在小圖上完成 ③ ★ Uber PassBloom 合成 Tonemap LUT Vignette Grain —— 全部塞進一個 shader一趟搞定 ──────────────────────────────────── 3 個 RenderPass兩個要點Bloom 必須在低分辨率做。1/4 分辨率 1/16 的像素 1/16 的帶寬。而 Bloom 本來就是糊的肉眼看不出差別。Uber Shader 用multi_compile做變體而不是運行時 if——關(guān)掉的效果應(yīng)該在編譯期就消失。// 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/sGPU 功耗?0.9 W。戰(zhàn)場三狙擊鏡的畫中畫需求8 倍鏡開鏡時鏡片里顯示一個獨立視角的畫面。樸素實現(xiàn)災(zāi)難scopeCamera.targetTexturescopeRT;// 1080p 全分辨率scopeCamera.Render();// 完整的一遍場景渲染 RT 往返// 主相機采樣 scopeRT 貼到鏡片上成本多一整套 RenderPass 33 MB 往返且全場景重新繪制。四項優(yōu)化措施說明收益① 降分辨率鏡片在屏幕上只占約 1/5 面積 → RT 用 512×512帶寬 ?85%② 剔除半徑收縮鏡內(nèi)只畫 300m 內(nèi) 目標層不畫地形細節(jié)DrawCall ?60%③ 不開鏡時徹底禁用scopeCamera.enabled false不是targetTexture null未開鏡時零成本④ storeAction 只保留 color鏡片 RT 的 depthDontCare?4 MB/幀?? 一個必須守住的紅線鏡內(nèi)的準星、彈道提示必須在主 pass 的原生分辨率上繪制不能跟著 RT 降分辨率。競技公平性不能讓步。實測開鏡時的額外功耗從1.4 W 降到 0.25 W。戰(zhàn)場四透視描邊隊友/敵人輪廓需求隊友被墻擋住時顯示藍色輪廓這是團隊射擊游戲的標配。常見實現(xiàn)① 把角色渲染到一張 Mask RTStencil 或 R8 → RT 切換 #1 ② 對 Mask 做邊緣檢測/膨脹 → RT 切換 #2 ③ 合成回主畫面 → RT 切換 #3三次全屏往返為了幾條線。更好的方案按推薦度方案 AStencil 同 Pass 內(nèi)完成最優(yōu)主 Pass 內(nèi) ① 角色正常渲染時寫入 Stencil 1 ② 緊接著用背面外擴 Stencil NotEqual 1 ZTest Always畫一遍輪廓 → 全程在同一個 RenderPass 里零 RT 切換方案 BFramebuffer Fetch在片元著色器里直接讀當前 tile 的顏色/模板做本地邊緣檢測——不需要整張圖。方案 C如果必須用 Mask RTMask 用R8 格式不是 RGBA8帶寬 1/4Mask 用1/2 分辨率輪廓略粗反而更顯眼Mask 的 depthDontCare該項目選了 ART 切換 3 → 0?0.6 W。戰(zhàn)場五MSAA 的 storeAction 寫錯了現(xiàn)象開了 MSAA 4x幀率掉 40%——遠超預(yù)期。抓幀一看colorAttachment.storeAction Store // ?后果4 倍采樣的數(shù)據(jù)全部寫回了 DRAM。8.3 MB × 4 33 MB 每幀只 color 一項 60fps 2 GB/s 的純浪費修法一行colorAttachment.storeActionMultisampleResolve;// 片上解析只寫單采樣depthAttachment.storeActionDontCare;// MSAA depth 永遠不該 store結(jié)果MSAA 4x 的性能損失從40% 降到 6%。這是本篇最值得記住的一條在移動端MSAA 不貴——錯誤配置的 MSAA才貴。而且貴到讓人誤以為手機不該開 MSAA于是轉(zhuǎn)去用 FXAA/TAA——那反倒是一次真實的全屏 RT 往返。戰(zhàn)場六UI 的三宗罪罪一UI 渲到獨立 RT 做整體淡入// ? 為了一個 0.3 秒的淡入動畫每幀多一次全屏 RT 往返uiCamera.targetTextureuiRT;修法淡入用CanvasGroup.alpha頂點色零成本只在真正需要整體后處理時才用 RT且用完立刻釋放。罪二小地圖每幀全量重繪// ? 60Hz 渲染一張 256×256 的俯視圖// ? 降到 10Hz 只在玩家移動超過閾值時更新if(Time.time-lastMinimapUpdate0.1fmoved2f){RenderMinimap();}收益小地圖成本 ?83%。罪三血條/傷害數(shù)字觸發(fā)的多余 Canvas 重建Unity 的Canvas 重建Rebuild雖然不是 RT 切換但它會打斷渲染批次間接增加 pass 數(shù)量。? 動態(tài)元素血條、傷害數(shù)字、準星放【獨立 Canvas】 ? 靜態(tài)元素背景、邊框放另一個 Canvas永不 dirty終幕怎么看、怎么改怎么看工具看什么Xcode GPU Capture最直觀直接顯示每個 RenderPass 的 Load/Store Action 和Memoryless標記Snapdragon ProfilerAdrenoRead Total (Bytes)/Write Total (Bytes)、RenderPass 數(shù)、是否走 binning modeArm Streamline / Mali Graphics DebuggerMali 的 tile 相關(guān)計數(shù)器、外部帶寬RenderDocPass 結(jié)構(gòu)、附件配置PC/Android 通用Unity Frame Debugger快速看有多少次Blit和 SetRenderTarget一個 5 秒判斷法Framebuffer 流量 ÷ (寬 × 高 × 4B × fps) ≈ 全屏往返次數(shù)這個數(shù)字應(yīng)該 ≤ 4。超過 8基本可以斷定有大量不必要的切換。怎么改按性價比排序優(yōu)先級動作典型收益?????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 用memorylessMetal完全不分配 DRAM???降低非關(guān)鍵 RT 的分辨率與格式R8 / R16F按比例??小地圖、鏡像等降頻渲染按比例絕對不要做的五件事? 在 RenderPass 中間ReadPixels/ 回讀 GPU? 對剛渲染完的 RT 立刻采樣強制 flush? 為了省一次 Clear而用Load? 每幀創(chuàng)建/銷毀 RenderTexture改用 RTHandle / 臨時 RT 池? 用Store保存一個沒人讀的 attachment收尾回到序幕那 23 個 RenderPass。它們每一個看起來都合理——一個效果一個 pass代碼干凈職責清晰。在 PC 上這樣寫完全沒問題。但在手機上每一次切換都不是一個 API 調(diào)用而是一次搬家。把 8 MB 的畫布從便簽收進倉庫 走到倉庫門口再把它搬回來 只為了在上面多畫一筆。做 23 次。每秒 60 遍。所以移動端渲染優(yōu)化最核心的思維轉(zhuǎn)變是這一句不要問我畫了多少東西要問我的畫布來回跑了幾趟倉庫。Draw Call 是臺面上的賬人人都在看。而幀緩沖流量是那筆躺在賬本背面、卻真正決定你手機溫度的隱形賬單。它不體現(xiàn)在 Profiler 的任何一個醒目數(shù)字里。它只體現(xiàn)在——第 12 分鐘玩家手心里的那片滾燙。