
这期发烫优化系列终于聊到硬骨头了。前面几篇我们拆过CPU主频、内存频率、GPU负载但真正扛起发热大旗的其实是两个平时不太起眼又逃不掉的东西纹理和后处理。这俩兄弟在设备内部的“搬运量”常年霸榜一个负责把贴图从内存搬到渲染单元一个负责把整张画面在多个中间缓冲之间来回倒腾哪个都不省电。如果你玩手游时觉得后盖发烫或者做渲染管线优化时发现GPU频率拉满但Shader计算明明不多大概率就是被这两个惯犯给坑了。这篇我会把原理、排查方法和优化思路串起来讲主要面向Unity/引擎开发者、图形程序员也想让对手机为什么发热感兴趣的玩家能看个明白。内容会有点长但每一条都是实际项目里能直接用上的经验。1. 先搞清楚发烫的根源GPU搬东西比算东西更费电1.1 带宽是怎么变成热量的现代手机的SoC里CPU和GPU共享同一块DRAM内存数据在DRAM、缓存、Shader核心之间搬运时每一次信号翻转都要消耗能量。你可以把内存带宽想象成一条很窄的公路车数据越多跑得越辛苦油耗自然越高。对应到芯片上就是DRAM控制器和总线的功耗快速上升最终反映为机身发热。很多开发者容易把发热归结为“Shader算得太狠了”但实际项目里算力和数据搬运的开销常常是分开看的。一个像素复杂计算再多只要数据都在寄存器或者缓存里功耗是可控的但一旦要从外部DRAM读一个8MB的纹理或者把整张1080p画面反复写成中间缓冲功耗立刻就不一样了。这也是为什么我常说发烫优化本质上是带宽优化。移动GPU和桌面GPU还不一样。桌面有独立显存和更暴力的缓存移动端则GPU和CPU共用DRAM可用带宽本身就紧张。再加上很多移动GPU采用的是TBR/TBDR架构比如Mali、Adreno、PowerVR它们对“帧缓冲”做了很多片上优化却没办法把“纹理读取”也全部塞进缓存。纹理数据一旦超出缓存大小就会产生DRAM访问这部分带宽消耗非常直接。1.2 纹理每一个像素都要从内存里把数据背过来一个典型的PBR渲染流程里模型表面至少要采样反照率Albedo、法线Normal、粗糙度/金属度Roughness/Metalness等多张纹理。Game Camera渲染一帧画面每个可见像素都可能要经过多次纹理采样。如果纹理没有mipmap、格式又是未压缩的RGBA8888那么即便物体离镜头非常远GPU也会老老实实地去读原始尺寸的贴图数据。我们随手算一笔账一张1024x1024的RGBA8888纹理大小是1024x1024x4 ≈ 4MB。假设一个1080p的场景只有一个全屏物体每个像素只采样一次这张纹理那一帧就要读大约4MB数据60帧就是240MB/s。听起来好像不多可真实场景里一个角色可能要采样5到8张纹理场景光照还要打阴影、SSAO、反射探针采样次数轻松翻几倍。再加上各向异性过滤会增加额外采样点纹理缓存miss率一高最终带宽可以轻松突破2-3GB/s。重点是这些数据不是“白读”的GPU读取纹理时的任何带宽消耗都要付电费。纹理尺寸越大、格式越原始、采样次数越多发热就越明显。很多项目发烫严重打开Profile一看Texture Read Bytes高得吓人原因就是这么简单。1.3 后处理把整张画面反复倒腾等于让GPU搬砖后处理又是另一座大山。你可能只开了Bloom泛光和抗锯齿觉得“特效不多”但后处理做的事情是把场景渲染到一张离屏RenderTargetRT然后对这张RT做一遍又一遍的全屏读取和写入。拿最常见的Bloom来拆解先要把场景RT降采样到小图然后做水平模糊、垂直模糊可能还要做几层金字塔模糊最后再和原图合成。每一步都意味着“读一整张RT → 写一整张RT”。以1080p RGBA8 RT为例一张RT大约8.3MB1920x1080x4一次“读写”就是16.6MB带宽。如果一条Bloom链有4个后处理Pass那一帧就是66MB60帧就是约4GB/s。如果RT是HDR FP16格式容量直接翻倍数字就更夸张。这就是为什么后处理被称为“搬运惯犯”的原因。它不像纹理那样是“按需取用”而是强制把整幅画面从DRAM里拖出来又写回去还经常反复多次。只要后处理链稍微长一点GPU带宽就被拖死温度跟着起飞。2. 纹理侧省带宽压缩、分级、缓存一起上2.1 纹理压缩是成本最低的第一步我见过太多项目为了省事直接让美术上传PNG/JPEG到Unity或者干脆用无压缩RGBA纹理作为贴图。这类做法放在十年前也就算了现在的移动项目再用就是纯纯的“带宽刺客”。解决思路很简单所有纹理进入GPU前都必须转换成GPU硬件支持的压缩格式。移动端目前最推荐的是ASTCAdaptive Scalable Texture Compression。它支持从4x4到12x12的block size压缩率灵活iOS和Android的主流GPU都支持。老一些的Android设备可能只支持ETC2所以发行前要确认目标机型下限。压缩格式的block size选择要分情况要求高的法线贴图和关键角色贴图用ASTC 4x4一般场景纹理、粗糙度、金属度这类低频信息用ASTC 6x6或者8x8如果纹理中大面积是纯色/渐变甚至能用10x10。从RGBA8888切到ASTC 6x6纹理数据量大约能降到原来的四分之一到五分之一带来的带宽收益非常直接。要注意的是切了压缩格式后一定要在引擎里走“压缩后预览”检查尤其观察边缘是否出现脏色块或明显色阶必要时针对少数高精度贴图单独用4x4。这里还有个常见误区UI纹理也别忘了压缩但UI通常不生成mipmap可以使用ASTC 4x4来保证文字和图标边缘清晰。如果UI图集一直用原始RGBA实测每帧的UI刷新也是一笔不小的带宽开销。2.2 mipmap让远处的物体不再扛着整张地图跑mipmap是纹理队列里最容易忽略但又特别重要的优化项。它的原理不难理解预先为一张纹理生成一系列长宽减半的小尺寸副本渲染时根据当前像素覆盖的纹理区域大小自动选择“合适尺寸”的mip层去采样。举例来说一张1024x1024的纹理如果某个物体在屏幕上只覆盖了16x16像素那GPU实际上只需要读16x16大小的mip层也就是约1KB的数据而不是从4MB的原始纹理里反复采样。mipmap本身会额外增加约33%的纹理存储空间但换来的带宽节省往往是数量级的。更重要的是mipmap能有效缓解远距离物体纹理闪烁和摩尔纹问题画质看起来也稳。开启mipmap在实际操作中有两个容易踩的坑。第一个是内存大增如果项目里所有大图都强制开mipmap纹理内存会增多尤其移动端内存本身就紧张。正确做法是3D场景贴图和地形材质打开mipmapUI、图集、动态RenderTexture不需要mipmap。第二个坑是加载方式如果用Unity的旧版AssetBundle要注意mipmap是否在导入设置里勾选否则打包后可能没生成完整mip链。我个人习惯是在管线的纹理导入阶段对“3D用途”和“UI用途”分别设置默认值避免美术手动勾错。2.3 控制采样次数和重复贴图除了格式和mipmap采样次数也是决定纹理带宽的关键变量。很多项目里同一个物体被多个灯光照亮每个灯光都触发一次纹理采样循环或者后处理里的SSAO、体积光等效果会对场景颜色纹理做多次采样。这些采样未必都是必要开销需要逐项排查。在实际优化时我建议先统计一下每个Pass的纹理采样次数一个物体如果被多个灯光重复绘制可以改用单Pass多光源的渲染方式如果阴影贴图和高精度纹理在远距离下没什么贡献可以按距离切换Shader变体。还有一个非常有效的优化是用纹理数组或图集把大量小纹理合并成一张大图减少纹理切换的缓存miss同时也方便引擎做批次合并。这里要特别提醒同一张纹理被多个材质引用并不等于带宽翻倍。GPU的纹理缓存会缓存最近访问的纹素只要多个材质在同一帧内连续访问同一张纹理命中率往往很高。真正的风险是“每帧全部换新纹理”一旦场景里塞了几百张独有纹理每个物体只采样自己的那张缓存不断missDRAM带宽就会爆炸。所以设计场景时优先重复使用纹理图集和材质变体而不是每个物件都配独立贴图。3. 后处理侧省带宽降分辨率、合并Pass、按需开关3.1 先拆后处理流程看清哪些Pass在全屏搬砖在做后处理优化前我习惯先用Frame Debugger把后处理链路整个拉开看看当前帧到底跑了几个全屏Pass、每张RT的尺寸和格式是什么。Unity里可以打开Window Analysis Rendering Debugger或者在Frame Debugger里逐个查看Draw Call。你会发现很多默认后处理链里隐藏着大量“Blit Blit Blit”。以Unity URP内置后处理为例一卷完整的后处理可能包括泛光Bloom的预滤波、多级降采样、逐级模糊、上采样合成、色调映射ToneMapping、环境光遮蔽SSAO、景深DOF、动态模糊Motion Blur等。其中Bloom和DOF尤其费带宽。常规做法是“看到什么特效就去调它的参数”但我的建议是先从“这个Pass真的非跑不可吗”开始问。比如移动端游戏的抗锯齿如果用FXAA通常一个Pass就够如果用TAA就需要额外的历史缓冲和运动向量重投影这两个选择对发热的影响完全不同。3.2 半分辨率是发热和画质之间的“黄金分割点”大多数后处理效果其实不太需要全分辨率。人眼对屏幕边缘的细节敏感度远低于中心区域对低速运动时的高频模糊也不敏感。Bloom、AO、DOF这类效果天然具备“低通滤波”属性先用半分辨率甚至四分之一分辨率去计算再在最终合成时放大视觉差异很小带宽却可以省下数倍。具体操作上Bloom一般做法是先把全分辨率场景RT降采样到1/4分辨率在低分辨率下提取亮部并做模糊最后以半分辨率甚至全分辨率合成回来。这样做对比度损失几乎看不出来纹理细节依旧由场景RT保留。DOF也可以把模糊半径放到半分辨率处理只需要在深度边界处适当处理避免前景物体出现明显的“糊边”或“挖空”。如果使用URP可以通过自定义后处理Volume组件把中间RT的RTHandle scale设为0.25或0.5如果自己写RenderFeature就直接通过Descriptor控制RT宽高不要每层都默认用全分辨率。需要注意半分辨率后处理常见的副作用是闪烁和线条断裂尤其在亮部边缘和细小遮挡处。解决方案是对降采样之前的输入做一次低通滤波比如从全分辨率缩小到半分辨率时使用4点双线性而不是简单点采样对有深度依赖的效果如DOF避免把整个Pass塞进低分辨率深度权重最好在全分辨率或半分辨率下计算再下采样。3.3 合并Pass和Framebuffer技巧少搬几次搬小一点后处理带宽消耗的大头是“Pass之间数据往返”。如果能减少中间RT的总量或者把多个Pass合并到一个Shader里就能显著降低搬运量。自己在写后处理Shader时可以把“采样多个输入 完成多个输出”写在一个Kernel里用Compute Shader一次搞定多级模糊或混合避免逐级Blit。移动端还要注意Render Pass的切换开销。TBR架构下频繁切换不同RT会让GPU在“片上Tile”和“DRAM回写”之间反复横跳。尽量把需要读写同一张RT的Pass放在同一个RenderPass里或者使用移动端Vulkan的SubPass/FrameBuffer Feedback机制让上一个Pass的输出能直接作为下一个Pass的输入而不用先写回DRAM再读出来。这条优化在Unity里主要靠Renderer Feature和CommandBuffer的规划在自定义引擎项目里则要贴近渲染硬件去做Pass调度。另外一个实用技巧是“按区域/按物体开启后处理”。整张屏幕并不是时时刻刻都需要后处理比如只有主角身上有特殊发光效果时可以用Stencil标记发光区域Bloom只处理标记区域。又或者场景中大部分时间没有烟雾和水中倒影就根据摄像机所在区域动态开关对应RenderFeature。改完这部分之后我可以负责任地讲帧率未必变高但发热体感会明显改善因为全屏级的数据往返少了一大截。4. 实测记录从一个发烫Demo到降温5℃4.1 量数据不靠手感靠Profiler我经手过的一个Unity手游Demo问题非常典型场景美术堆了大量PBR材质有很多4K贴图后处理默认开着Bloom、DOF、Motion Blur和ToneMapping在中端安卓机上跑30分钟就烫手。项目负责人第一反应是“Shader写得太重”但打开Profile一看Shader ALU占用只有35%而Texture Read带宽高达约12GB/sFramebuffer Write带宽约3.5GB/s。这个数据说明问题根本不在计算而在搬运。移动端做这类排查安卓上推荐用Android GPU InspectorAGI和Snapdragon ProfileriOS上可以用Xcode的GPU Frame Capture Metal System Trace。AGI能抓取GPU Counter中的Texture Read Bytes、Framebuffer Write Bytes、Shader Cycles等关键指标。如果没有厂商工具Unity里的Profiler Frame Debugger也能大致看出哪些Pass在频繁Blit整屏缓冲。重要的是先量化再动手否则很容易被视觉上“看起来很复杂”的效果带偏方向。4.2 优化改动和最终对比针对这个Demo我们做了两件事第一把所有非关键贴图从RGBA8888转成ASTC 6x6并确保3D材质全部开启mipmap关键金属材质里的Normal贴图单独保留ASTC 4x4避免细节损失。第二重写Bloom链原来4个全分辨率Pass先改成1/4分辨率提取亮部再用双层金字塔模糊最后半分辨率合成同时关掉了对玩法毫无影响的Motion Blur。优化后再抓一次数据Texture Read从12GB/s降到了6.1GB/s左右Framebuffer Write从3.5GB/s降到2.2GB/s。持续跑30分钟手机背面温度从之前的46℃降到43℃左右整机功耗表上大概从4.6W降到3.7W。玩家体感上“烫手”变成了“温热”。画面方面只有金属高光的轮廓在特定角度下能看出轻微区别调节Bloom阈值后基本不可感知。那次实践后我最大的体会是不要盲目追求全特效最高画质玩家优先感受到的是机器烫不烫、卡不卡、电掉得快不快。把纹理压缩和半分辨率后处理这两件事做好等于拿回了大量功耗预算这些预算完全可以再投入给更有价值的画面细节。4.3 后处理与纹理优化其实不只属于游戏说到“后处理”和“搬运量”我偶尔会串到别的领域。比如做AI目标检测时YOLO的NMS非极大值抑制和阈值过滤被称为“后处理流程”它同样要对海量候选框做全量遍历和排序判断不当会成为整个pipeline的性能瓶颈。再比如遥感领域的ENVI提取纹理特征三维重建的OpenMVS生成纹理贴图甚至数控加工里UG后处理判断4轴变化与Z轴回零规律本质上都是在“主计算结束后对结果做二次加工”的环节。这些领域看起来毫无关系但底层逻辑高度一致先想清楚“中间结果有多大、要写多少次、被读多少次”再决定是压缩、降采样还是用并行归纳减少遍历。所以说纹理和后处理是“搬运量最大的惯犯”这个结论放到更广的计算体系里也仍然成立。5. 常见问题、避坑清单与排障实录5.1 症状与原因对照表症状可能原因推荐处理手机一跑场景就烫但Shader复杂度看不出问题纹理格式未压缩、纹理尺寸过大、mipmap未开启转ASTC/ETC2开启mipmap检查纹理导入设置开启后处理特效后帧率骤降、发热明显后处理Pass过多、RT尺寸太大、全屏多次读写合并Pass、半分辨率处理、关闭非必要效果纹理转ASTC后出现色斑和边缘模糊block size过大或sRGB空间处理不当降低block size改用6x6或4x4检查sRGB/线性空间开了mipmap后内存暴涨对UI/图集也开了mipmap或场景纹理数量太多关闭UI的mipmap限制3D贴图总尺寸使用纹理流式加载半分辨率后处理边缘闪烁、出现断线降采样前未做低通滤波或深度相关效果分辨率不一致增加降采样前滤波对深度效果单独用半分辨率处理并修复深度权重温度还是压不住带宽也看了没有爆炸可能瓶颈在顶点数、DrawCall、CPU逻辑或GPU ALU用Profile分层定位不要只盯纹理和后处理5.2 几个新手容易忽略的细节纹理压缩之后发灰或发紫很多时候是颜色空间问题。线性空间的法线贴图如果按照sRGB采样结果会完全乱掉但某些压缩格式会默认修改颜色通道顺序需要做对应的重映射。比如常用的法线重映射会把蓝色通道从[-1,1]映射到[0,1]在Unity里表现为“Normal Map”勾选项千万不要漏。另一个细节是移动端尽量不要用BC系列压缩纹理比如BC1/BC3。BC压缩本来是给桌面显卡准备的虽然Visual效果不错但移动GPU对BC的支持非常差运行时可能直接解码到内存里带宽反而更大。检查一下Build后的纹理信息如果看到BC开头格式多半是导入配置没针对移动端做覆盖。还有后处理里的“高动态范围”也会吓一跳。HDR RT通常是FP16格式一个像素占8字节比RGBA8翻了一倍。如果后处理链在HDR空间里做了多遍模糊带宽数字会明显飙升。移动端没有绝对必要的话可以用“中小范围HDR 快速色调映射”策略半分辨率下做HDR特效最终合成时再处理成LDR发热会好很多。5.3 给团队和个人的一条可落地优化顺序如果你现在接手一个发烫严重的项目我可以给个保守但有效的顺序。第一步导出当前APK/IPA用Profiler抓一次GPU Counter确定Texture Read和Framebuffer Write两项的数值。第二步把所有3D纹理资源检查一遍是否ASTC、是否mipmap、是否过大的无意义贴图改完再抓一次数据。第三步打开后处理特效列表把Bloom和DOF先用半分辨率跑起来能关的就先关再看温度和帧率曲线变化。第四步如果温度还是不行才去检查Shader指令数、顶点数、光源数量和CPU主逻辑。一次只改一项记录前后温度和功耗变化不要同时把十个优化一起上。相信我每次只动一个变量你才能真正知道是哪个环节省下了热量。这样做也能避免团队里“我昨天优化也没问题”这种说不清的争端。说到底纹理和后处理这两个惯犯本质上都是“数据搬运量”问题。只要在项目里多花半天时间把搬运数据量化清楚再按压缩、降分辨率、合并Pass的顺序去处理发热问题通常会迎刃而解。如果你也被发烫优化折磨得头疼不妨先把这一帧要从DRAM搬多少纹理数据、后处理Pass要倒腾多少次全屏缓冲数一遍答案清晰之后该动哪里自然就清楚了。