1. 项目缘起与整体优化思路拆解1.1 为什么要动光照模式这块硬骨头做VR项目的人都有一个共识帧率就是命。普通PC游戏掉到45帧玩家可能忍一忍就过去了但VR头显里一旦掉到72帧以下眩晕感立刻上来用户摘头显的速度比你优化代码的速度还快。我手上这个项目是一个中低复杂度的室内场景漫游类VR应用目标平台是移动端一体机GPU是移动芯片发热和功耗的墙就横在那里。项目早期为了画面效果场景里用了大量实时阴影和混合光照光照模式选的是Shadowmask。Shadowmask的好处大家都知道远处物体用烘焙阴影贴图近处物体用实时阴影兼顾质量和性能。但这个“兼顾”是有代价的——它需要一张额外的阴影遮罩贴图显存占用上去了而且Shader里要处理Shadowmask的采样逻辑片元着色器的指令数也跟着涨。在PC上这些开销不算什么但在移动端一体机上每一点开销都会被放大。我实测过在同一个场景里Shadowmask模式下GPU耗时比Subtractive模式高出约30%到40%这个差距在复杂场景里更明显。所以这次优化的核心目标很明确把光照模式从Shadowmask切换到Subtractive同时通过材质分级策略来弥补Subtractive在动态物体阴影表现上的不足。1.2 Subtractive模式到底省在哪里先把这个事情说清楚不然后面的操作都是盲人摸象。Unity的Subtractive光照模式本质上是一种“极简”的烘焙光照方案。它的核心逻辑是场景中所有静态物体的光照全部烘焙到Lightmap里动态物体只接受一个主方向光的实时直接光照不接收任何烘焙的间接光也不接收实时阴影。对比一下三种模式的差异光照模式静态物体阴影动态物体阴影动态物体接收烘焙GI额外贴图Shader复杂度Baked Indirect烘焙实时是无中Shadowmask烘焙实时实时是Shadowmask贴图高Subtractive烘焙无实时阴影否无低从表格能看出来Subtractive砍掉的东西最多没有Shadowmask贴图没有动态物体的实时阴影动态物体也不接收烘焙的间接光。砍得越狠省得越多。省在三个地方显存少一张贴图、带宽少一次纹理采样、ALU少一堆光照计算指令。但问题也来了动态物体没有实时阴影在VR里会显得“飘”缺乏落地感。这就是为什么必须配合材质分级策略——把场景里的物体按重要程度分类重要的动态物体用高质量Shader手动补阴影不重要的用最简Shader甚至纯烘焙。1.3 材质分级策略的整体设计材质分级不是简单地“好的用贵Shader差的用便宜Shader”而是要根据物体在场景中的视觉权重、运动状态、与用户的距离来综合判断。我的分级方案是这样的S级用户交互的核心物体比如手持道具、近处可抓取的物件。这类物体用自定义Shader手动实现一个简单的平面阴影投射保证有落地感。A级中距离的动态物体比如场景中缓慢移动的NPC或漂浮物。用Unity标准Shader的Subtractive变体接受主方向光的实时直接光照不投射阴影。B级远距离或几乎不动的物体全部标记为Static完全依赖烘焙Lightmap用最简的Unlit Shader加Lightmap采样。C级纯装饰性物体比如远处的背景板、粒子特效。直接用Unlit纯色或简单纹理不参与任何光照计算。这个分级方案的核心逻辑是把GPU算力花在用户真正会盯着看的地方其他地方能省则省。VR用户的视野被头显限制视野外的物体渲染优先级可以降到最低。注意材质分级不是一劳永逸的场景迭代后要重新评估。我吃过亏早期分到B级的某个物体后来被策划改成了可交互的结果用户拿起来发现没有阴影反馈很不好。2. 从Shadowmask迁移到Subtractive的实操细节2.1 迁移前的场景光照资产盘点切换光照模式不是点一下按钮就完事之前烘焙好的Lightmap全部作废需要重新烘焙。所以在动手之前我先做了一轮资产盘点把场景里所有跟光照相关的东西列了个清单场景中有多少盏实时光源分别是什么类型Directional、Point、Spot哪些物体标记了Static哪些是动态的当前Lightmap的分辨率和数量是多少有没有用Light ProbeLight Probe Group的位置是否合理反射探针Reflection Probe的情况如何这个盘点很重要因为Subtractive模式下实时光源的数量对性能影响很大。每多一盏实时光源所有动态物体的Shader就要多算一次光照。我的原则是Subtractive模式下场景中最多保留一盏实时光源就是主方向光。其他光源全部改为Baked模式只影响静态物体。盘点的时候我发现场景里有三盏实时光源一盏主方向光、两盏点光源用来照亮角落。这两盏点光源在Shadowmask模式下问题不大但在Subtractive模式下会让动态物体的Shader开销翻倍。所以我把它们改成了Baked然后在Lightmap里手动提亮了对应区域。2.2 光照面板的关键参数设置打开Window Rendering Lighting面板切换到Scene标签页。这里有几个关键参数需要调整Lighting Mode从Shadowmask改为Subtractive。这个下拉框在面板顶部改完之后Unity会提示需要重新烘焙。Mixed Lighting下面的Baked Global Illumination要勾上Realtime Global Illumination关掉。Subtractive模式不支持Realtime GI这个选项会自动变灰。Lightmapper选择Progressive CPU还是GPU取决于你的机器。GPU Lightmapper快很多但需要显卡支持。我用的是一台带独立显卡的工作站GPU烘焙大概比CPU快5到8倍。但要注意GPU烘焙对显存要求高场景大的话可能爆显存这时候要适当降低Lightmap Resolution。Lightmap Resolution这个参数很关键。Shadowmask模式下因为阴影是分开的Lightmap分辨率可以低一点。但Subtractive模式下所有光照信息都在Lightmap里分辨率太低会出现明显的块状感。我的经验值是移动端VR项目每单位面积平方米的Lightmap像素数控制在8到12之间。具体怎么算假设场景地面面积是100平方米Lightmap Resolution设为10那么需要的Lightmap总像素大约是1000x1000也就是一张1024的贴图。如果场景更大就要分多张Lightmap或者降低分辨率。Lightmap Padding建议设为2到4个像素防止相邻UV岛之间出现渗色。Ambient Occlusion打开参数保持默认即可。Subtractive模式下AO是烘焙到Lightmap里的对提升画面层次感很有帮助。2.3 重新烘焙与验证流程参数设置好之后点击Generate Lighting按钮开始烘焙。烘焙时间取决于场景复杂度和Lightmap分辨率我这个场景大概花了15分钟。烘焙完成后不要急着进VR测试先在Scene视图里检查几件事第一看Lightmap有没有明显的接缝或黑斑。如果有通常是UV重叠或者Lightmap Padding不够。第二检查动态物体在场景中的表现。Subtractive模式下动态物体不接收烘焙阴影所以如果动态物体站在一个原本有阴影的角落里它会显得特别亮跟周围环境格格不入。这时候要么调整主方向光的角度让阴影覆盖更合理要么给这个动态物体单独处理。第三用Frame Debugger看一下渲染流程。确认Shadowmask贴图已经不再被采样Shader的变体也切换到了Subtractive版本。实操心得烘焙之前先把场景里所有动态物体隐藏掉只烘焙静态部分。烘焙完成后再把动态物体显示出来这样能更清楚地看到动态物体和烘焙环境的融合程度。2.4 动态物体阴影的补偿方案Subtractive模式下动态物体没有实时阴影这是最大的痛点。我的补偿方案分两层第一层是Light Probe。在场景中动态物体可能出现的区域密集布置Light Probe Group。Light Probe会采样烘焙的间接光信息让动态物体的颜色和亮度跟周围环境匹配。Light Probe的密度要足够高尤其是在光照变化剧烈的区域比如窗户旁边、灯光附近。第二层是手动阴影。对于S级和A级的动态物体我在它们的Shader里加了一个简单的平面阴影投射。原理很简单在物体下方生成一个半透明的黑色圆形或方形面片根据物体与地面的距离调整透明度和大小。这个方案很土但在VR里效果出奇地好因为用户对阴影的精确度要求不高只要有“落地感”就行。具体实现是在Shader里加一个Pass渲染一个朝向地面的面片用物体的世界坐标和地面高度计算面片的位置和缩放。这个Pass的渲染开销极低比实时阴影便宜太多了。3. 材质分级的具体实现与Shader配置3.1 分级标准的量化指标材质分级不能凭感觉我定了一套量化指标等级判定条件Shader方案阴影处理适用物体S级用户可交互距离2米自定义Shader手动平面阴影手持道具、近处可抓取物A级动态距离2-10米Standard Subtractive变体无阴影Light Probe移动NPC、漂浮物B级静态距离15米Lightmap Unlit烘焙阴影墙壁、家具、地面C级静态或动态距离15米Unlit纯色/纹理无背景板、远景装饰距离阈值不是固定的要根据场景尺度调整。我这个场景是一个室内空间15米已经是从房间一头到另一头的距离了。如果是室外场景阈值要放大。3.2 S级物体的自定义Shader实现S级物体的Shader是我自己写的核心思路是接受主方向光的实时直接光照加上一个手动阴影面片不参与任何烘焙光照。Shader的关键代码结构如下Shader Custom/VR_Subtractive_S_Tier { Properties { _MainTex (Albedo, 2D) white {} _Color (Color, Color) (1,1,1,1) _ShadowColor (Shadow Color, Color) (0,0,0,0.5) _ShadowSize (Shadow Size, Float) 1.0 } SubShader { Tags { RenderTypeOpaque LightModeForwardBase } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; }; sampler2D _MainTex; float4 _MainTex_ST; float4 _Color; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 albedo tex2D(_MainTex, i.uv) * _Color; float3 normal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float ndotl saturate(dot(normal, lightDir)); float3 lighting _LightColor0.rgb * ndotl unity_AmbientSky.rgb; return fixed4(albedo.rgb * lighting, albedo.a); } ENDCG } // 手动阴影Pass Pass { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; float _ShadowSize; float4 _ShadowColor; v2f vert (appdata v) { v2f o; // 将面片压扁到地面高度 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; worldPos.y 0.01; // 地面高度加一点偏移防止Z-fighting o.pos mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv v.vertex.xz * _ShadowSize; return o; } fixed4 frag (v2f i) : SV_Target { float dist length(i.uv); float alpha saturate(1.0 - dist) * _ShadowColor.a; return fixed4(_ShadowColor.rgb, alpha); } ENDCG } } }这个Shader的阴影Pass很简单把物体的顶点投影到地面高度根据距离中心的远近计算透明度形成一个软阴影效果。_ShadowSize控制阴影大小_ShadowColor控制颜色和透明度。注意事项这个手动阴影方案只适用于平坦地面。如果地面有起伏阴影面片会穿模。我的做法是在场景里铺一层不可见的碰撞体用射线检测获取物体正下方的地面高度然后动态调整阴影面片的高度。这个逻辑放在C#脚本里每帧更新一次。3.3 A级物体的Shader配置A级物体直接用Unity Standard Shader但要做一些调整。在材质面板里把Shader的Keywords设置好关闭_EMISSION相关Keyword如果不用自发光确保_LIGHTMAP_ON没有开启A级物体不采样Lightmap开启_LIGHT_PROBES_ON让物体接受Light Probe关闭_SHADOWS_SCREEN和_RECEIVE_SHADOWS_OFF相关Keyword这些Keyword的设置可以在Shader Variant Collection里预定义避免运行时编译卡顿。VR项目最怕的就是运行时Shader编译导致的卡顿所以一定要提前把所有变体都编译好。在Graphics Settings里把Shader Stripping的选项设置好去掉不需要的变体。比如Subtractive模式下所有跟Shadowmask相关的变体都可以剥掉能减少不少包体和内存占用。3.4 B级和C级物体的处理B级物体全部标记为Static用Unity自带的Mobile/Diffuse Shader或者自己写一个极简的Lightmap Shader。核心就是采样Lightmap加上主纹理颜色不做任何实时光照计算。C级物体更简单直接用Unlit Shader连Lightmap都不采样。远处的背景板、天空盒、粒子特效都属于这一类。这些物体在VR里因为距离远、视角偏用户根本不会注意到光照细节省下来的GPU算力用在S级物体上更划算。实操心得B级和C级物体的材质可以合并Draw Call。把使用相同Shader和相同纹理的物体标记为StaticUnity的Static Batching会自动合并它们。我实测下来合并后Draw Call从200多降到了80左右GPU耗时直接降了15%。4. 性能对比与实测数据4.1 优化前后的帧率对比我在同一台移动端一体机上做了对比测试场景相同视角相同用Unity Profiler和Oculus Performance HUD采集数据。指标Shadowmask模式Subtractive材质分级变化平均帧率68 fps82 fps20.6%GPU耗时11.2 ms7.8 ms-30.4%CPU耗时4.5 ms4.2 ms-6.7%Draw Call21085-59.5%显存占用380 MB310 MB-18.4%发热温度42°C38°C-4°C帧率从68提升到82已经稳定在72帧以上了眩晕感明显降低。GPU耗时降了30%多这是最核心的收益。Draw Call降了将近60%主要是因为材质分级后很多物体合并了。显存占用降了70MB主要是省掉了Shadowmask贴图。4.2 画质损失的评估与补偿性能提升是有代价的Subtractive模式下画质确实有损失。主要损失在三个方面第一动态物体的阴影没了。这个通过手动阴影面片补偿了大部分但在复杂光照环境下还是能看出差异。第二动态物体不接收烘焙间接光。这个通过Light Probe补偿效果能到Shadowmask的八成左右。第三Lightmap分辨率如果降得太低静态物体的光照细节会丢失。我的做法是只对S级和A级物体附近的区域保持高分辨率远处区域降低分辨率。画质损失在VR里其实没那么明显因为头显的透镜本身就有畸变和色散用户对画面细节的敏感度比在显示器上低很多。我让几个同事试过不告诉他们切换了光照模式只有一个人说“感觉阴影好像淡了一点”其他人根本没注意到。4.3 不同场景规模下的适用性这套方案不是万能的场景规模不同效果差异很大。小型场景单房间面积50平方米Subtractive材质分级的收益非常明显因为动态物体少Light Probe容易覆盖手动阴影也好处理。中型场景多房间面积50-200平方米收益依然明显但Light Probe的布置要更密集烘焙时间也会增加。建议分区域烘焙不要一次性烘整个场景。大型场景室外或大面积室内面积200平方米Subtractive的劣势开始显现因为动态物体多Light Probe覆盖不全手动阴影的工作量也大。这种情况下可能要考虑混合方案近处用Subtractive远处用Baked Indirect中间用Shadowmask过渡。但混合方案的管理成本很高要慎重。注意事项场景规模不是唯一指标动态物体的数量和运动范围同样重要。如果一个大型场景里几乎没有动态物体Subtractive依然是最优解。5. 常见问题与排查技巧实录5.1 烘焙后动态物体颜色异常这是Subtractive模式下最常见的问题。动态物体在烘焙环境里显得特别亮或者特别暗跟周围格格不入。原因通常是Light Probe没有覆盖到或者Light Probe的位置不对。Light Probe采样的间接光信息跟周围环境不匹配导致动态物体的环境光颜色偏差。排查方法在Scene视图里打开Light Probe可视化Window Rendering Lighting Scene Light Probe Visualization看看动态物体所在位置的Light Probe密度是否足够。如果Light Probe之间的间距太大中间区域的采样精度就不够。解决方法在动态物体活动区域加密Light Probe。我的经验是每2到3米放一个Light Probe光照变化剧烈的地方比如窗户边、灯光下每1米放一个。5.2 手动阴影面片穿模或闪烁手动阴影面片是S级物体的补偿方案但实现不好会出现穿模或闪烁。穿模的原因是阴影面片的高度固定而地面有起伏。解决方法是用射线检测动态获取地面高度每帧更新面片位置。闪烁的原因是Z-fighting阴影面片和地面在同一深度。解决方法是在面片高度上加一个小的偏移量比如0.01米。如果还有闪烁可以调整相机的近裁剪面增大深度缓冲的精度。5.3 Shader变体编译导致的卡顿VR项目最怕运行时Shader编译卡顿。Subtractive模式下Shader变体数量比Shadowmask少但如果不做预编译第一次遇到新变体时还是会卡。解决方法在Project Settings Graphics Shader Loading里把Shader Variant Collection设置好。把所有可能用到的变体提前编译。另外在场景加载时用一个隐藏的相机快速渲染一遍所有材质触发Shader编译避免在用户交互时卡顿。5.4 Lightmap接缝和黑斑重新烘焙后Lightmap可能出现接缝或黑斑。接缝通常是UV岛之间的Padding不够黑斑可能是UV重叠或者Lightmap分辨率太低。排查方法在Lighting面板里打开Lightmap Visualization查看Lightmap的UV布局。如果UV岛之间距离太近增大Padding。如果UV重叠需要调整模型的UV展开。解决方法对于接缝增大Lightmap Padding到4像素以上。对于黑斑检查模型的UV是否有重叠或者提高Lightmap Resolution。如果模型是导入的可以在导入设置里勾选Generate Lightmap UVs让Unity自动生成UV。5.5 常见问题速查表问题现象可能原因排查方法解决方案动态物体颜色异常Light Probe未覆盖可视化Light Probe加密Light Probe阴影面片穿模地面高度变化射线检测地面动态更新面片高度阴影面片闪烁Z-fighting检查深度偏移增加高度偏移量Shader编译卡顿变体未预编译Profiler查看编译耗时预编译Shader变体Lightmap接缝Padding不足查看UV布局增大PaddingLightmap黑斑UV重叠或分辨率低查看UV布局调整UV或提高分辨率帧率不升反降实时光源过多检查光源数量改为Baked模式烘焙时间过长分辨率过高检查Lightmap设置降低分辨率或分区域烘焙实操心得每次修改光照设置后一定要用Frame Debugger看一遍渲染流程。我遇到过好几次明明改了设置但帧率没变化的情况最后发现是某个材质的Shader没有正确切换还在用旧的Shadowmask变体。Frame Debugger能直接看到每个Draw Call用的Shader和Keyword一目了然。6. 材质分级策略的扩展与迭代6.1 动态分级根据距离实时切换静态的材质分级有个问题用户走近一个B级物体时它的画质就不够了。所以我在项目后期加了一套动态分级系统。核心逻辑是每个物体有一个初始分级但根据用户头显的位置实时调整。当用户距离物体小于阈值时物体升级到更高的材质等级距离远了再降回去。实现方式是用C#脚本管理一个物体列表每帧计算物体与相机的距离根据距离切换材质的Shader。切换Shader会有开销所以不能每帧都切要加一个缓冲区间。比如距离小于2米升到S级大于3米才降回A级中间1米是缓冲带防止频繁切换。这套系统在中小型场景里效果很好但在大型场景里管理成本太高因为物体数量多每帧计算距离的CPU开销也不小。我的建议是只对S级和A级物体做动态分级B级和C级保持静态。6.2 基于视觉重要性的分级调整距离不是唯一指标视觉重要性同样重要。用户正对着的物体即使距离远一点也应该用高质量Shader用户视野边缘的物体即使距离近也可以用低质量Shader。实现方式是用相机的视锥体判断物体是否在视野内再结合视线方向计算一个“视觉权重”。权重高的物体升级权重低的降级。这个方案听起来很美好但实际实现起来比较复杂而且VR头显的视野是动态的用户头部一直在动视觉权重的计算频率要很高。我试过一版CPU开销增加了2ms左右在移动端有点吃不消。后来简化成只用距离判断效果也够用了。6.3 后续可扩展的方向这套方案还有几个可以继续优化的方向第一用GPU Instancing合并更多Draw Call。S级物体虽然Shader复杂但如果多个S级物体用同一个材质可以用GPU Instancing批量渲染减少Draw Call。第二用LODLevel of Detail配合材质分级。远处的物体不仅用低质量Shader还可以用低面数模型进一步降低GPU开销。第三用Occlusion Culling剔除被遮挡的物体。VR场景里用户视角受限很多物体其实看不到剔除掉能省不少算力。第四用Single Pass Instanced渲染模式。这是VR专用的优化能把左右眼的渲染合并到一个Pass里GPU开销直接减半。这个功能在较新的Unity版本里已经支持了强烈建议开启。我在实际项目里把Single Pass Instanced和材质分级结合使用GPU耗时从7.8ms进一步降到了5.2ms帧率稳定在90帧。这个组合方案在移动端VR项目里基本是标配了。最后分享一个小技巧材质分级的分级标准不要写死在代码里用ScriptableObject做成配置文件。这样策划和美术也能参与调整不用每次改分级都要程序重新编译。我吃过这个亏早期分级阈值写死在代码里策划想调一下距离阈值都要找我效率很低。后来改成配置文件策划自己就能调省了我不少事。