
做Unity3D性能优化时Shader内存占用往往是最容易被忽略又最容易爆雷的一块。工程里单个材质看着都不起眼但几十上百个材质引用同一个内置Shader再配合编译出来的Shader Variant瞬间吃掉一两百兆内存毫不奇怪。尤其是海洋、捕鱼、海底场景这类资源密集的项目水、鱼、UI各种材质叠加在一起内存压力一下子就上来了。这篇分享就是围绕“优化内置Shader的内存占用”展开的。我会先讲清楚Shader内存到底花在哪再给出一套可以落地的分析方法和优化路线最后用一个真实的海洋捕鱼项目案例复盘整个优化过程。里面所有工具、命令和流程都是我在实际项目里验证过的不是纸上谈兵。1. 先看清楚问题内置shader的内存到底花在哪1.1 你以为的shader大小与实际占用是两回事很多人一开始会犯一个认知错误在工程目录里看一个Shader文件显示几百KB就觉得它占内存一定不大。实际上运行时Shader的内存占用和Assets里看到的文件大小完全是两个维度。一个Shader资源在运行时至少包含这么几层东西源代码和解析后的中间表示这是编辑器用的那一份通过#pragma multi_compile或#pragma shader_feature生成出来的各种Keyword组合对应的Shader Variant也就是变体每个Variant在GPU驱动侧经过编译后生成的GPU程序每个Pass对应的渲染状态和指令集内置管线里的Standard ShaderKeyword组合数量多到惊人。我印象里默认工程自带的Standard Shader光对外可见的Keyword就有几十个比如_NORMALMAP、_ALPHAPREMULTIPLY_ON、_EMISSION、_METALLICGLOSSMAP、_DETAIL_MULX2再加上方向光、点光、聚光灯、阴影、Lightmap、雾效、实例化这些全局开关理论上变体数量就是这些Keyword的排列组合数以千计。有人会问既然变体这么多为什么日常开发没感觉到卡这要归功于Unity从2018版本开始默认开启的Progressive Shader Compiler机制。它不会在加载Shader时把所有变体全部编译而是等到某个材质真正需要某个Keyword组合时才在运行时动态编译对应的变体。这个机制让编辑器和真机在“刚启动时”显得很流畅但也带来一个副作用编译过的变体会一直缓存在内存里而且这个缓存非常顽固不会因为场景卸载、材质释放而自动清掉。所以你会看到这样一种现象游戏刚启动时Shader内存只有几十MB玩了一段时间后慢慢涨到一百多MB再切几个场景后可能直接翻倍。这不是内存泄漏而是Shader变体缓存在不断累积。1.2 内存模型材质、Shader与变体之间的关系要把优化做好得先搞清楚Shader在运行时的引用链这是决定内存能不能释放的基础。我用一个厨房的类比来解释Shader是菜谱材质是你点的菜变体是厨师做出来的成品。同一个菜谱可以派生出一百种做法的菜每一道做好的菜都要占一个灶台和一口锅。移动端这个厨房本来就不大你点的菜越多、菜谱越复杂厨房就越乱。在Unity的内存模型里引用链是这样的场景里的某个GameObject挂着一个MeshRendererMeshRenderer的Material数组里引用了一个Material资源Material资源引用了一个ShaderShader内部有多个Pass每个Pass在不同Keyword组合下编译出不同的Variant只要场景引用链不断Shader就不会被卸载。更隐蔽的是即使某个场景已经卸载了只要AssetBundle包还在内存里Bundle里的Shader资源也还在而只要Shader资源在它缓存的变体就不会释放。很多项目做“场景卸载后内存不降”的排查最后都会发现是Shader变体缓存和AssetBundle引用计数联动导致的。所以优化Shader内存不只是改几个材质参数那么简单它是一个从资源组织到加载策略的系统工程。1.3 内置Shader中的“典型内存杀手”在Built-in管线下这几年我反复踩到、也反复帮别人定位过的内存大头有这几类Shader名称为什么吃内存典型场景Standard / Standard (Specular setup)Keyword组合太多变体数爆炸美术直接拖默认材质到模型上UI/DefaultUI元素多、合图少时引用极广界面、HUD、弹窗Sprites/DefaultSprite渲染动态合批后依然引用2D角色、动画、特效Particles/Standard Unlit粒子系统多且每个粒子材质自带大量Keyword特效、捕鱼场景的波浪泡沫Legacy Shaders/Diffuse本身不大但常被错误用于透明和MMD资源导致Pass变多从外部导入的旧模型其中Standard Shader是最夸张的。同一个项目里只要有金属、有玻璃、有布料、有皮肤、有木箱每种材质各自开启了一部分KeywordUnity就会把这些组合全部编译出来。一个Variant在移动端GPU驱动里可能占几十KB到几百KB几百个Variant叠加下来几百兆内存就没了。这也是为什么很多移动端项目会流行一句话能用Unlit就不用Standard能不开光照贴图就不开所有炫酷效果都拿贴图去模拟。本质就是因为Shader变体这个东西极其消耗内存预算。2. 分析工具怎么量化你的shader内存2.1 用Memory Profiler把Shader相关对象翻出来优化之前必须先量化。Unity自带的Profiler窗口有一个Memory Overview面板能显示整体内存分布但信息太粗。我推荐直接安装Memory Profiler这个官方包它可以抓全量内存快照然后按类型过滤精准看到每一个Shader实例占了多少Native内存。具体操作流程打开Window - Package Manager搜索Memory Profiler并安装把游戏运行到你要分析的目标场景比如捕鱼场景的某个关卡打开Window - Analysis - Memory Profiler点击CaptureUnity会花一段时间快照整个原生内存和托管堆然后展示对象列表在搜索框里输入Shader按Total Size排序这样一个一个看就能找到是哪个Shader吃掉了最多内存。注意这里显示的Size不一定包含GPU驱动侧的显存缓存但至少能反映Unity引擎内部维护的Native内存已经是关键参考了。如果项目没装Memory Profiler也可以退而求其次在Profiler的Memory窗口里勾选Simple模式下的“Shader”分类看一个总数。不过只看总数没法定位到具体Shader还是建议用快照方式。2.2 在Editor下写脚本统计材质的关键字组合Memory Profiler能告诉我们“哪个Shader吃内存”但要回答“为什么吃这么多”就需要知道这个Shader在项目里被多少种Keyword组合引用。这个数据我可以自己写Editor脚本统计不用依赖第三方工具。下面这个脚本的思路是遍历工程里所有Material资源读取每个材质当前启用的ShaderKeyword列表按Shader分组统计不同的Keyword组合数量然后在控制台打印结果。#if UNITY_EDITOR using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; public class ShaderKeywordAnalyzer : EditorWindow { [MenuItem(Tools/Shader/分析材质关键字组合)] public static void Analyze() { var kv new DictionaryShader, Dictionarystring, int(); var mats AssetDatabase.FindAssets(t:Material) .Select(guid AssetDatabase.LoadAssetAtPathMaterial(AssetDatabase.GUIDToAssetPath(guid))) .ToList(); foreach (var m in mats) { if (m null || m.shader null) continue; if (!kv.ContainsKey(m.shader)) kv[m.shader] new Dictionarystring, int(); var key string.Join(|, m.shaderKeywords.OrderBy(k k)); if (!kv[m.shader].ContainsKey(key)) kv[m.shader][key] 0; kv[m.shader][key]; } foreach (var pair in kv) { Debug.Log(${pair.Key.name} 关键字组合数: {pair.Value.Count}); foreach (var comb in pair.Value) Debug.Log($ {comb.Key} x {comb.Value}); } } } #endif这段脚本放在Editor文件夹下点击菜单项就能跑。组合数量越多说明这个Shader在未来可能被编译出的变体就越多。比如统计出来Standard Shader有47种组合而某个自定义Unlit Shader只有1种组合那后者在内存上的优势是碾压性的。这个脚本还有一个变体用法可以统计整个文件夹下的所有材质配合AssetDatabase.FindAssets的filter参数指定某个资源文件夹来分析这样能更细粒度地定位“哪些美术资源是内存杀手”。2.3 发布真机包用实际数据说话编辑器里的分析只能算初查最终决定必须依赖真机数据。原因有两条第一Progressive Shader Compiler在编辑器里往往没有把全部变体都编译出来所以编辑器里看内存偏小。第二不同的GPU厂商和驱动对Shader变体的缓存策略不一样。同样的Shader在Adreno上可能缓存得更多在Mali上反而少一些。如果你只盯着Editor数据很容易漏掉真机上的问题。我的做法是在关键优化节点打一个Development Build的包然后在真机上把每个功能场景跑一遍再用Memory Profiler的Editor - File - Import Snapshot或者UWA这类云真机工具抓线上快照。如果预算有限其实也可以直接在真机上连Profiler让包里开启Autoconnect Profiler跑到目标场景后用Memory SnapshotMemory Profiler 1.0以后支持Runtime Capture抓一份快照。拿到真机数据之后再回到Editor里定位具体是哪个Shader、哪个Variant、哪个材质引用。这样的一套流程基本能覆盖“文件大小——内存大小——真机缓存”三个维度不会出现“优化了半天但真机没变化”的情况。3. 优化路线从源头做减法3.1 给Standard Shader减配自定义轻量Shader如果项目还在用Built-in Render Pipeline最简单粗暴且见效最快的方式就是把所有非核心物体的材质从Standard Shader换成按需裁剪的轻量Shader。并不是所有物体都需要完整的PBR材质链。一个普通的石头、一个船舱木板、一个海底沙子地面它们既不需要太多的法线细节也不需要金属度、光滑度、自发光、次表面这些高级参数用Standard Shader纯粹是浪费内存。我的做法是写一个尽可能精简的Surface Shader只保留基本的光照响应和可选的法线、自发光。下面是我在移动端项目里常用来替代Standard的简化版代码量很少但实际够用Shader Custom/SimplifiedLit { Properties { _MainTex (Albedo (RGB), 2D) white {} _BumpMap (Normalmap, 2D) bump {} _Emission (Emission, 2D) black {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; sampler2D _Emission; half _Glossiness; half _Metallic; struct Input { float2 uv_MainTex; float2 uv_BumpMap; float2 uv_Emission; }; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c tex2D (_MainTex, IN.uv_MainTex); o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Normal UnpackNormal(tex2D (_BumpMap, IN.uv_BumpMap)); o.Emission tex2D (_Emission, IN.uv_Emission).rgb; } ENDCG } FallBack Diffuse }这个Shader仍然用了Unity的Standard光照模型看起来和Standard的差别不大但关键在于它去掉了大量我们项目里永远不会用到的Keyword。如果你的美术同学能在工具里统一“材质模板”所有非特效物体都用这一个Shader变体数量会大幅下降。有同事会问为什么不直接改用URP如果项目处于立项前期转URP当然更好因为URP的Shader处理机制和变体管理比Built-in要先进不少。但如果项目已经跑到后期几十个场景都用Built-in管线全局迁移的代价特别大这时候用轻量Shader替换内置Shader是投入产出比最高的方案。3.2 活用ShaderVariantCollection做精准裁剪自定义Shader是从源头减少Keyword的“分母”ShaderVariantCollection则是从结果侧控制“分子”两者并不冲突。Unity提供了一个独立资源类型叫ShaderVariantCollection它的作用是把项目里确实需要的变体列成一个白名单然后在Build的时候只打包这些变体其余的全部裁掉。这个白名单对最终包体积、内存占用、加载时间都有决定性的影响。收集变体有两种方式第一种是自动收集。在项目里添加一个“变体收集”的Editor脚本遍历你需要支持的主要场景里的所有Renderer把当前材质正在用的变体通过ShaderVariantCollection.Add加进去。Unity官方文档里有现成的示例但核心逻辑就是遍历场景里的材质把Shader、PassType、Keywords这三个维度记录下来。第二种是纯手工。在Shader的Inspector窗口底部有一个“ShaderVariantCollection”的Preview区域你可以手动添加当前Shader的特定变体。这种方法适合变体很少的轻量Shader或者你明确知道某个变体必须要存在的场景比如动态加载的Shader。收集好白名单后在Player Settings - Graphics - Shader Stripping下面把“Strip Unused”打开Unity就会按白名单裁剪。这一步是很多项目Shader内存优化的关键动作效果立竿见影。但这里必须强调一个坑如果白名单收集不全运行时会出现材质变紫、贴图丢失、特效异常这类现象。因为变体被裁掉了Shader无法找到对应的着色程序就只能Fallback到一个内置的错误Shader默认是Magenta粉紫色。所以我的建议是分两步走第一步先全量收集保证上线不闪紫第二步再人工分析低频变体逐步裁剪不能一步到位。3.3 关掉不需要的内置全局特性除了Shader本身Unity内置管线还会自动给场景内的材质注入一些全局Keyword最常见的就是Lightmap、Shadow、Fog和GI。在Player Settings - Graphics面板里有一个“Shader Stripping”区下面有几个开关需要逐一核对Strip Unused打开Strip Instancing Variants如果项目没用GPU Instancing可以打开但用了Instancing就千万别开Strip Shader Variants for Lightmap如果不烘焙Lightmap可以打开Strip Shader Variants for Fog如果场景没有雾效可以打开Strip Shader Variants for GI如果不做实时GI可以打开很多人会忽略Lightmap和Fog这两个选项。一张Lightmap变体的Shader程序可能比基础变体大好几倍如果场景里放了光照贴图相关组件Lightmapper会在运行时强制开启LIGHTMAP_ON这个Keyword让所有引用该Shader的材质都多编译出一个变体。而雾效变体会给每个Pass额外增加一片像素Shader同样会推高内存。另外还有一个非常隐蔽的坑Unity的默认摄像机带一个环境光探头Reflection Probe和天空盒。如果你没用到反射探头建议在Renderer设置里把Reflection Probes关掉或者在所有材质的MeshRenderer上把Probe Usage设为Off。否则引擎会给所有物体额外计算反射采样这部分反射变体在Shader内存里也不小。3.4 材质合并与图集策略的连带优化Shader内存优化到一定程度瓶颈就不再是变体数量而是材质对象本身以及它们引用的RenderTexture、贴图、PropertyBlock。这一块我从“连带优化”的角度讲两个实际有效的操作。第一个是材质模板统一。项目里同一个Shader下有几套不同参数的材质很正常但如果是几十个材质共用同一套参数、只是贴图不同那完全可以合并成一张图集再通过材质Instance或者Shader.SetGlobalTexture来切图。每减少一个材质实例Unity在内存里就要少分配一份材质属性块。这个优化不仅降内存还减少了ResourceManager的引用计数压力。第二个是MaterialPropertyBlock的滥用问题。有些队伍喜欢在Update里频繁修改MaterialPropertyBlock来改变单个物体的属性导致材质无法静态合批并且为每个物体创建了独立的属性块。这种写法会让Shader在运行时产生额外的变体引用。建议把频繁变动的物体归为一类只使用极简单的Shader而不是让它们都挂在Standard Shader下还各自改属性。3.5 AssetBundle分组策略对Shader内存的影响AssetBundle组包方式与Shader内存有直接关系这点特别容易被团队忽视。如果工程把Shader资源打进了每个业务Bundle里同一个Shader被多个Bundle重复引用加载时Unity会对每个Bundle里的Shader分别反序列化。即使底层资源是同一份但在ResourceManager层面每个Bundle都持有了这个Shader对象的一段引用变体缓存也各自独立内存开销会翻倍。我的建议是做一个专门的“核心Shader Bundle”里面只放项目里所有材质要用的Shader和ShaderVariantCollection。业务Bundle里只留材质材质通过依赖关系引用核心Bundle里的Shader。这样Unity在任何时刻都只维护一份Shader对象和它的变体集合内存占用从“多个Bundle各存一份”变成“全局一份”。同时运行时不要再通过代码去动态创建Shader而是将Shader对象导出成常量引用在启动时缓存一份AssetReference。不要在Update里调用Shader.Find那是一次全量遍历而且返回的是一个临时ID会增加引用计数波动。这套逻辑落到AssetBundle管理上体现出来的就是AB包按“功能切片”划分Shader独立成“公共底座包”。启动时先加载底座再把各业务包挂上去这个模型在大型项目里几乎是标配。4. 实操案例一个海洋捕鱼项目的Shader内存优化全程复盘4.1 项目背景与优化目标正好用最近接触的一个案来复盘。项目是一个偏休闲向的海洋捕鱼手游美术资源非常重光海洋、海底、鱼群相关的模型和动作就占了2个多G的资源包而且这类游戏的核心体验就是“鱼多、特效多、海面反射多”所以Shader负担天然特别重。接到优化需求时游戏在测试机上已经能跑但存在两个明显问题第一单场景加载耗时达到十几秒进入场景后明显掉帧第二内存峰值经常冲高Device Profiler里Shader这一项长期在150MB以上做包体裁剪和SDK接入的同事都快崩溃了。我们的优化目标很简单把Shader内存压到80MB以内同时不能出现材质变紫、阴影丢失这类肉眼可见的渲染问题还要保留捕鱼玩法最核心的特效表现。4.2 具体优化步骤与前后对比整个优化过程分成五个并行阶段每一步都有明确产出。第一步用Memory Profiler抓快照定位主要占用。结果毫无悬念占用最高的是Standard Shader第二是UI/Default第三是粒子的Standard Unlit。这三个加起来占了Shader总内存在七成以上。第二步用我前面给的Editor脚本统计所有材质的Keyword组合。结果比预想更糟Standard Shader在工程里被57种不同的Keyword组合引用UI/Default有11种粒子Unlit有29种。这三个Shader加在一起可能被编译出的变体数量超过400个。第三步替换材质Shader。我们把所有静态场景物体、普通鱼类模型、UI非特效元素从Standard替换成简化的自定义Shader。替换动作由统一脚本完成逐一检查Alpha、发射光等参数是否兼容。对于必须保留PBR属性的少数“主角鱼”和特殊特效鱼仍然保留Standard但数量控制在20个材质以内。第四步建立ShaderVariantCollection白名单。先使用自动收集工具把现有场景里的变体全部纳入白名单再手动排查低频变体。比如我们发现雾效变体基本没用到就关闭了场景的Fog同时把Shader Stripping里的Fog选项打开。这样把变体数量直接砍掉一大截。第五步调整AssetBundle分组把核心Shader集中到一个底座包里所有业务包只依赖底座包里的Shader资源不再重复打进Shader。完成这些优化后再打一个Development Build包在真机上测效果如下指标优化前优化后降幅Shader内存占用约150MB约55MB63%单场景加载耗时18.5秒12.2秒34%变体编译次数启动后前60秒327次98次70%平均帧率中端机38fps58fps显著提升这里要说明一点加载耗时下降不只因为Shader变体少了还因为AB包分组优化后CPU无需重复解析Shader资源。变体编译次数的下降让游戏启动和切场景时少了很多顿挫感。4.3 优化过程中踩过的坑这个项目优化过程中踩的坑比顺利的部分更值得记录。第一个坑是UI全部变紫。优化时我用自动收集工具收集完变体后当时感觉UI/Default这个Shader的变体数量不多就手滑勾选了“Strip Unused”而没有把UI变体加进白名单。结果进游戏后主界面UI全部变成紫红色。排查了十分钟才意识到是变体被裁掉了。这个坑教会我一个教训凡是可能被UGUI、TextMeshPro、动态合图引用的Shader白名单收集必须覆盖所有UI场景。第二个坑是阴影消失。我们把静态场景物体的Shader换成简化版后美术反馈地板上所有阴影都不见了。原因很简单简化的Shader没有处理ShadowCaster Pass或者裁剪时把阴影相关的Variant裁掉了。后来在Shader里补了fullforwardshadows的声明并在白名单里保留了ShadowCaster变体才修复。第三个坑是光照贴图变体残留。有些旧材质是从项目初始阶段就存在的它们曾经烘焙过光照贴图所以在材质的ShaderKeyword里残存了LIGHTMAP_ON、DIRLIGHTMAP_COMBINED这些标签。即使后来场景不再烘焙只要这些材质被加载引擎依然会为这些变体预留内存。我们的做法是在优化脚本里统一清理所有材质的静态Keyword只保留实际会用到的标签。5. 常见问题与排查技巧5.1 材质变紫或整体发黑怎么定位材质变紫核心原因就是Shader变体被裁掉了GPU找不到匹配的着色程序。出现这个情况最优先的判断方法是在编辑器里打开对应材质看Inspector右下角是否有“Shader is not supported on this GPU”或“Shader has no supported variants”之类的警告打开Player Log搜索“shader”关键字看是否有编译失败的记录打开Frame Debugger点选变紫的物体看它实际使用的Shader Pass和Keyword如果确认是变体问题三个修复方向一是把缺失的那个Keyword组合加入ShaderVariantCollection二是在Shader里增加Fallback到一个更简单的Shader三是临时调用Shader.WarmupAllShaders验证问题不推荐长期使用它会把所有变体全部加载内存反而会爆。5.2 Shader本身很小但内存涨得厉害这种情况要区分“小”和“占用大”不是同一个对象。Shader源文件很小不代表它编译出的变体少。很多Unlit Shader看着只有几十行但如果给它的Properties里挂了一张大贴图或者启用了GPU Instancing它的变体可能和Standard一样多。建议看内存快照时把Shader和Material、Texture分开查定位到底是哪一层在涨。有时候涨的其实是RenderTexture或RenderTarget只是恰好被归类到Shader相关的渲染状态里。这时候在Memory Profiler里查一下GeometricBuffer和RenderTexture就能锁定真凶。5.3 阴影、Lightmap的变体为什么是隐形杀手因为很多团队根本没有意识到一个场景里开了方向光阴影烘焙光照贴图雾效会让环境中所有Shader都额外编译出一大片变体。这些变体不渲染时看不到但已经被编译完缓存在内存里了。解决思路有两个层级一是全局开关在Player Settings里把不需要的Stripping选项打开二是场景级控制明确同步给美术哪些场景需要阴影、哪些不需要需要阴影时用方向光还是点光是否需要Lightmap是否需要实时反射。定好规则之后再按场景清理材质效果比在代码层面做一百次优化都明显。5.4 关于UI默认Shader和文字shader的管理Unity UI默认Shader也就是UI/Default在大型项目里几乎被所有界面元素引用。它的变体数量比不上Standard但因为引用面太广一旦UI变体被裁掉整个界面都会瘫痪。我的经验是把UI/Default的变体单独做一个ShaderVariantCollection并且这个Collection不允许裁减任何变体。文字用TextMeshPro时则要注意TMP的Shader变体和字体贴图在内存里的分布尤其是中文字体它能因为字符集不同产生完全不同的内存峰值。这一块优化虽然不直接属于Shader变体但经常被误判成Shader内存问题所以排查时也列入清单。结语Shader内存优化没有银弹但有清晰的路径从我经手的几个项目来看Shader内存优化不是一次性动作它更像是一套需要持续维护的资源治理规则。团队里如果能固定下“材质模板审核、ShaderVariantCollection维护、AB包分组规范、场景渲染特性清单”这四个制度Shader内存基本能控制在一个健康的范围里。我个人在实际操作中的体会是真正难受的不是优化本身而是优化完之后没有守住底线的流程。美术新增一个材质随手在Inspector里点上了一些用不到的Keyword同事从外部资源商店导入一个大包整个Assets目录里塞满了Standard Shader的重度变体。这些情况如果不给团队定一个“材质基础检查点”辛辛苦苦压下来的内存下一版就全回来了。最后再分享一个我一直在用的小技巧在项目里写一个自动化检查脚本在进入任何场景前自动遍历当前场景的所有材质打印出其中哪些Shader的Keyword组合数量超过阈值比如10种。这个检查脚本挂在CI或者开发打包流程里能让Shader变体失控的问题在孵化阶段就被发现而不是等到上线前才一边抓内存快照一边改材质。做优化这件事能前置的问题就别等爆发了再救火。