移动端环境光渲染优化:球谐光照(SH)原理、Unity实现与性能调优 📅 2026/8/2 7:29:41 1. 项目概述与核心价值最近在做一个移动端的开放世界项目美术同学把场景搭得特别漂亮各种动态光源和复杂材质都用上了在编辑器里跑起来效果杠杠的。结果一打包到手机上帧率直接掉到二十几发热还特别严重。排查了一圈发现环境光Ambient Light和全局光照Global Illumination相关的计算是性能大头。尤其是那些需要实时计算、动态变化的间接光照在移动端的GPU上简直就是“帧率杀手”。相信很多做移动端3D项目的朋友都遇到过类似的问题想要好的画面又得保住帧率两头为难。这时候球谐光照Spherical Harmonics 简称SH就该登场了。这玩意儿不是什么新东西但在移动端优化环境光渲染上它绝对是一把被严重低估的“瑞士军刀”。简单来说SH是一种数学工具它能把一个球面上复杂的光照信息比如来自天空盒的环境光、场景的间接反射光压缩成几个简单的系数。在运行时我们不需要再去采样复杂的高动态范围图像HDR或者做昂贵的光线追踪只需要用这几个系数结合物体的法线方向就能快速计算出当前点接收到的环境光颜色和强度。它的核心价值就体现在“预计算”和“运行时轻量”这两个词上。我们把复杂的光照信息“烘焙”成SH系数存在内存里。在手机运行时只需要做几次向量点乘和加法开销极低却能换来非常柔和的、方向性的环境光效果告别那种平板、虚假的均匀环境光。这对于移动端上追求画面表现又受限于性能的项目比如开放大世界、高品质的AR应用或者需要动态昼夜变化的手游提升是立竿见影的。接下来我就结合自己趟过的坑详细拆解一下在Unity里怎么把SH用起来以及怎么避开那些新手和老手都可能栽进去的陷阱。2. 球谐光照SH核心原理与移动端适配性拆解在深入代码之前我们必须先搞明白SH到底是怎么工作的以及为什么它特别适合移动端。如果你只关心怎么用Unity的API这部分可以快速浏览但理解原理能帮你更好地调试和解决那些玄学问题。2.1 SH的数学直觉从“函数拟合”到“光照编码”你可以把SH想象成一种高级的“压缩算法”。我们想记录的场景环境光照本质上是一个定义在球面各个方向上的函数给定一个方向比如物体表面法线指向的方向函数就返回从这个方向来的光照颜色和强度。这个函数通常很复杂像一张高精度的HDR环境贴图。SH提供了一组标准的“基础波形”基函数就像傅里叶变换里的正弦波和余弦波。这些基函数在球面上有固定的形状有的在顶部亮底部暗类似一个帽子有的在左右两侧亮度相反类似一个哑铃。SH光照的核心思想是我们用这些标准波形的不同组合加权求和去尽可能逼近原始那个复杂的光照函数。这个“组合的权重”就是我们最终要存储的SH系数。通常我们使用3阶Order 3的SH这意味着我们需要9个基函数对于RGB三个颜色通道就是9*327个系数来近似光照。为什么是3阶这是一个经典的权衡2阶4个基函数太粗糙捕捉不到足够的方向性细节比如来自侧上方的主要光源4阶16个基函数虽然更精确但系数数量暴增运行时计算量也增加而带来的视觉提升在移动端的小屏幕上往往不明显。3阶SH在视觉质量和性能开销上取得了最佳平衡因此成为游戏行业的实际标准。在Unity中一个3阶SH系数通常用一个UnitySHAr、UnitySHAg、UnitySHAb、UnitySHBr…这样的7个float4向量来表示具体结构后面会讲它们共同编码了场景中某一点或某一区域来自各个方向的环境光信息。2.2 移动端为什么爱SH性能优势量化分析我们来算一笔账对比几种常见的环境光方案Uniform Ambient均匀环境光最简单就是一个全局颜色。开销为0但效果也最假物体没有体积感。Skybox/Environment Probe采样使用反射探针或直接采样天空盒纹理。这需要一次或多次纹理采样可能还需要进行HDR解码和滤波。在复杂的PBR着色器中这会增加显存带宽和ALU开销尤其是在低端移动GPU上纹理采样是主要瓶颈之一。实时光照追踪如Vulkan/GLES的Ray Query效果最好但移动端目前基本无法承受其性能开销。球谐光照SH运行时计算是什么对于每个像素假设我们已经有了预计算好的SH系数存储在常量缓冲区或顶点数据中计算光照的伪代码大致是// 将法线n编码到SH基函数值这组值是固定的可以预计算或查表 float4 shAr, shAg, shAb, shBr, shBg, shBb, shC; // ... 从Uniform或顶点数据中获取SH系数 ... // 核心计算一系列点乘和加法 half3 x1, x2, x3; // 这部分计算非常轻量通常10条指令以内 half3 ambient shAr.rgb * x1.r shAg.rgb * x1.g shAb.rgb * x1.b shBr.rgb * x2.r shBg.rgb * x2.g shBb.rgb * x2.b shC.rgb * x3;可以看到完全没有纹理采样全部是标量和向量的乘加运算MAD这是GPU最擅长、开销最低的操作。实测在Adreno 6系列或Mali-G7x系列的GPU上使用SH替代一张环境贴图采样帧时间能有5%-10%的提升同时发热和功耗也会有所改善。更重要的是SH数据量很小。一个3阶SH Probe9个系数 * RGB * float精度也就108字节。你可以把整个场景划分成网格在每个格点存储一套SH系数运行时根据物体位置进行插值从而实现动态变化的、高质量的环境光。这种“光照场”技术正是很多3A手游实现动态全局光照的基石。3. Unity中SH光照的完整工作流与实操理解了原理我们来看在Unity里怎么把它用起来。Unity引擎其实已经为我们封装好了大部分功能我们需要做的是正确地触发、获取和使用SH数据。3.1 数据来源烘焙、探针与动态生成SH系数不是凭空产生的它们需要从一个高质量的光照表示中“烘焙”出来。在Unity中主要有三种来源Lightmapping光照贴图烘焙过程这是最常用、最自动化的方式。当你在Window - Rendering - Lighting Settings中开启Baked Global Illumination并进行光照烘焙时Unity不仅会生成光照贴图还会为场景中的光照探针组Light Probe Group计算SH系数。这些系数被编码到光照探针数据中。确保你的场景中放置了足够密集的光照探针特别是在光照变化剧烈的区域如墙角、门窗附近。反射探针Reflection Probe的衍生反射探针捕获的是全方向的HDR环境立方体贴图。Unity可以在运行时从这个立方体贴图动态生成一套SH系数。这是实现动态物体如角色、车辆接收动态环境光的关键。在反射探针的Inspector面板中确保Type设置为Baked或Custom并且勾选了相关的烘焙选项它就会在烘焙时生成SH数据。手动编码与设置对于特殊需求比如从程序化生成的天空盒中提取SH或者通过网络同步光照环境你可以通过C#脚本调用RenderSettings.ambientProbe或LightProbes.GetInterpolatedProbe来获取和设置SphericalHarmonicsL2对象。这是一个高级用法后面会结合动态昼夜变化来举例。关键避坑点1探针密度与烘焙质量很多新手以为放了几个探针就能有好的效果结果发现物体上的环境光斑驳不堪。SH插值依赖于探针之间的平滑过渡。规则是探针的间距应该小于场景中光照发生显著变化的最小距离。比如在一面被窗户照亮、另一面是阴影的墙附近探针需要更密集。一个实用的技巧是在复杂区域手动添加探针并使用探针组的“Visualize Light Probes”功能来检查覆盖情况。3.2 在Shader中接收与使用SHUnity通过一系列内置的Uniform变量将当前渲染物体所对应的SH系数传递给Shader。我们不需要自己传递只需要在Shader中正确声明和使用它们。对于表面着色器Surface Shader或内置渲染管线过程几乎是全自动的。你只需要在Input结构体中声明SH数据并在surf函数中使用ShadeSH9或ShadeSHPerPixel函数。// 表面着色器示例 struct Input { float3 worldNormal; // 需要世界空间法线 INTERNAL_DATA // 内部数据用于支持法线贴图等 }; void surf (Input IN, inout SurfaceOutputStandard o) { // ... 其他材质计算 ... // 计算基于SH的环境光贡献 half3 shColor ShadeSH9(float4(IN.worldNormal, 1.0)); // 将SH环境光加到最终输出上通常与直接光照结果相加 o.Emission shColor * o.Albedo; // 一种简单的叠加方式更复杂的PBR需要结合BRDF // 或者影响环境光遮蔽(AO)后的间接光 o.IndirectDiffuse shColor; }对于更可控的URPUniversal Render Pipeline或自定义Shader我们需要明确地包含核心的SH计算函数和声明Uniform。在URP中通常通过包含Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl来获取相关函数。// URP 片元着色器示例 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Varyings { float3 normalWS : TEXCOORD1; // ... }; half4 frag(Varyings IN) : SV_Target { // 构建InputData结构体用于URP的通用光照函数 InputData inputData (InputData)0; inputData.normalWS normalize(IN.normalWS); // ... 填充其他InputData字段 ... // URP提供了CalculateSH函数来获取SH环境光 half3 bakedGI SampleSHPixel(inputData.normalWS, inputData.positionWS); // 或者使用更通用的方法 // half3 bakedGI SampleSH(inputData.normalWS); // 在计算间接光时使用它 half3 indirectDiffuse bakedGI * _MainTex.rgb; // ... 结合直接光照进行计算 ... }关键避坑点2法线向量归一化传给ShadeSH9或SampleSH的法线必须是归一化的世界空间法线。如果法线没有归一化点积计算会出错导致SH光照强度异常可能出现奇怪的色块或过暗/过亮。在顶点着色器中计算并传递给片元着色器的法线务必在片元着色器中再次进行归一化因为顶点插值会破坏其单位长度。3.3 动态物体的SH插值Light Probe Proxy Volume (LPPV)对于静态物体Unity使用其所在位置最近的光照探针的SH数据。但对于动态物体标记为Dynamic的GameObject如果它在一个放置了光照探针组的区域内移动Unity会自动在相邻的探针间进行插值为物体提供平滑变化的环境光。这是默认且最常用的行为。然而对于体积很大的动态物体比如一辆大巴车、一个巨型BOSS只用一个点通常是物体的原点或包围盒中心来采样SH是不准确的。车头和车尾所处的光照环境可能完全不同。这时就需要Light Probe Proxy Volume (LPPV)。LPPV可以理解为在动态物体内部创建一个3D的探针网格。Unity会为这个网格的每个角点计算插值后的SH系数。在渲染该物体的每个像素时会根据像素在物体局部空间内的位置在这个3D网格中进行三次线性插值获取更精确的SH数据。启用步骤给动态物体添加Light Probe Proxy Volume组件。在组件上设置Resolution网格分辨率如3x3x3和Bounds Mode通常用Automatic Local根据Renderer的包围盒自动计算。确保该物体所在的区域有足够密集的光照探针覆盖。启用LPPV后物体会在局部空间内拥有更细腻的环境光变化尤其是对于大型动态物体效果提升非常明显。关键避坑点3LPPV的性能与精度权衡LPPV的网格分辨率如3x3x3会显著影响性能。每个额外的采样点都意味着更多的插值计算和可能的数据获取开销。不要盲目使用高分辨率。对于大多数中型物体3x3x2或3x3x3已经足够。在移动端务必在真机上测试开启LPPV后的性能变化。一个经验法则是只有当你明显看到大型动态物体表面环境光不连续、出现明显色块时才考虑启用LPPV并从最低分辨率开始测试。4. 实战进阶实现动态昼夜环境光切换静态烘焙的SH很美但我们的世界是动态的。如何让SH环境光随着时间比如从正午到黄昏平滑变化这就需要我们动态地混合两套或多套SH系数。核心思路是我们预先烘焙好不同时间点的光照探针数据例如中午一套SH系数黄昏一套SH系数。在运行时根据游戏内时间对这两套系数进行线性插值然后将插值后的结果设置给渲染环境。步骤详解烘焙多套光照数据在Unity编辑器中调整主方向光太阳的角度、颜色和强度以及天空盒材质模拟出“中午”的光照状态。确保场景中光照探针组已放置好。在Lighting窗口执行一次Bake烘焙。烘焙完成后将整个光照探针组Light Probe Group保存为一个Prefab命名为“Probes_Noon”。同理调整光照和天空盒到“黄昏”状态再次烘焙并将新的光照探针组保存为Prefab“Probes_Dusk”。注意每次烘焙前最好先删除场景中现有的光照探针组再从Prefab实例化一个新的避免数据混淆。运行时插值与设置在游戏运行时我们无法直接修改已烘焙到场景中的探针数据。但我们可以通过脚本动态计算插值后的SH系数并将其赋给RenderSettings.ambientProbe。这个全局变量会影响所有使用SH的物体。我们需要从两个Prefab中提取出SphericalHarmonicsL2对象。一种方法是将这些系数预先导出为数组存储在ScriptableObject或配置文件中。更动态的方法是在运行时实例化两个隐藏的GameObject分别挂载“Probes_Noon”和“Probes_Dusk”的Light Probe Group组件然后从LightmapSettings.lightProbes属性中读取系数但这种方法管理起来较复杂。// 简化示例代码展示插值逻辑 using UnityEngine; using UnityEngine.Rendering; public class DynamicSHManager : MonoBehaviour { // 假设我们已通过某种方式加载了这两套系数 public SphericalHarmonicsL2 shProbesNoon; public SphericalHarmonicsL2 shProbesDusk; [Range(0f, 1f)] public float timeOfDayLerp 0.5f; // 0中午 1黄昏 void Update() { // 插值SH系数 SphericalHarmonicsL2 interpolatedSH new SphericalHarmonicsL2(); for (int i 0; i 3; i) // RGB通道 { for (int j 0; j 9; j) // 9个系数 { // 对每个系数进行线性插值 interpolatedSH[i, j] Mathf.Lerp(shProbesNoon[i, j], shProbesDusk[i, j], timeOfDayLerp); } } // 将插值后的SH设置给全局环境光探针 RenderSettings.ambientProbe interpolatedSH; // 强制更新所有受影响物体的光照 DynamicGI.UpdateEnvironment(); } }调用DynamicGI.UpdateEnvironment()会触发Unity更新动态物体的GI全局光照数据这是一个相对较耗的操作切忌每帧调用。在实际项目中你需要根据时间变化的频率进行节流比如每0.5秒或当timeOfDayLerp变化超过某个阈值时才更新一次。关键避坑点4SH系数插值的有效性SH系数是浮点数直接进行线性插值在数学上是合理的但视觉上的线性插值不一定等于物理上的线性过渡。特别是当两套光照的色温和方向差异极大时比如从冷色调的月光切换到暖色调的日光简单的Lerp可能会导致中间状态出现不自然的灰色或色彩饱和度丢失。对于要求极高的项目可能需要烘焙更多中间状态的光照探针如黎明、上午、下午或者探索在球谐域内更复杂的混合算法但这已属于高级课题。对于大多数移动端项目线性插值加上适当的时间平滑如使用Mathf.SmoothStep已经能获得足够好的效果。5. 移动端专属优化策略与深度避坑指南将SH应用到移动端除了通用技巧还有一些针对移动平台硬件的特殊优化和陷阱需要警惕。5.1 精度与带宽优化Half vs FloatSH系数在Unity内部通常以float精度存储。但在移动端GPU上片元着色器中的大量float计算可能成为瓶颈尤其是对于低端设备。我们可以考虑将SH系数从float精度转换为half精度。如何操作在Shader中Unity传递给我们的SH系数Uniform变量如unity_SHAr是float4类型。我们可以在Shader的开头将它们重新声明为half精度或者在使用时进行强制转换。但要注意降低精度可能会带来细微的颜色偏差和banding色带问题特别是在低光照区域。// 在URP Shader中可以尝试在片元着色器入口处转换 half4 shAr (half4)unity_SHAr; half4 shAg (half4)unity_SHAg; // ... 使用half精度的系数进行计算 ...一个更安全、更常见的优化是确保SH计算在顶点着色器中进行而非片元着色器。这就是ShadeSH9和ShadeSHPerVertex函数的区别。ShadeSHPerVertex在顶点着色器中计算SH光照然后将结果作为颜色变量插值到片元。这能极大减少片元着色器的计算量但代价是光照在三角形内部是线性插值的对于大三角形或法线变化剧烈的表面可能会丢失细节。移动端上对于远处物体或非主角的物体使用顶点级别的SH通常是性能收益远大于视觉损失的。5.2 纹理烘焙与SH的冲突管理移动端项目常常会使用烘焙光照贴图Lightmap来存储静态物体的直接和间接光照。这里有一个关键的协同与冲突问题光照贴图里已经包含了烘焙的间接光信息而SH也提供了间接光信息。如果两者同时启用会导致物体被照亮两次结果过曝。正确的设置流程在Unity的Lighting设置中确保你的混合光照模式Mixed Lighting Mode设置正确。对于完全静态的物体使用Baked Indirect模式此时光照贴图包含间接光动态物体和SH用于补充。对于参与烘焙的静态物体Static在它们的材质Shader中通常应该禁用或忽略SH贡献因为间接光已经烘焙到光照贴图里了。Unity的标准着色器Standard Shader或URP Lit Shader内部会自动处理这个逻辑如果检测到物体有有效的光照贴图UV就会减少或忽略SH计算。对于动态物体Dynamic或仅接受实时光照的静态物体它们没有光照贴图SH就是它们接收环境间接光的唯一或主要来源。确保这些物体的材质能够正确接收SH。关键避坑点5烘焙后SH效果“消失”或“错误”这是最常见的问题之一。烘焙完光照贴图后发现场景中的动态物体变黑了或者静态物体的环境光很奇怪。排查步骤检查Light Probe Group烘焙后确保场景中的Light Probe Group没有被意外禁用或删除。它的Gizmo应该显示为许多黄色的小球。检查动态物体设置确认动态物体的MeshRenderer组件上Contribute Global Illumination和Receive Global Illumination设置是否正确。通常动态物体不贡献GI但需要接收GI来自光照探针。检查Shader Feature如果你使用自定义Shader确保它包含了接收和计算SH光照所需的代码分支和变体Shader Variants。有时烘焙后Unity会使用不同的Shader变体可能遗漏了SH相关的计算。使用Frame Debugger这是最强大的调试工具。在Game视图下打开Window - Analysis - Frame Debugger。逐帧、逐个Draw Call查看渲染状态。找到渲染你目标物体的那个Draw Call查看其渲染状态Render State。检查传递给Shader的Uniform变量特别是unity_SHAr等SH相关变量看它们的值是否非零、是否合理。同时查看该物体的光照模式Lightmode是否正确。5.3 性能分析与调试工具优化离不开测量。在移动端优化SH你需要以下工具Unity Profiler (GPU)在真机连接调试时使用Profiler的GPU模块观察使用SH的Shader Pass的耗时。对比关闭SH如用固定颜色替代时的耗时可以量化SH带来的开销。RenderDoc 或 Snapdragon Profiler这些图形调试器可以捕获一帧完整的渲染调用。你可以精确地看到SH系数是如何被传入Shader的以及在片元着色器中执行SH计算的具体指令数和耗时。这对于诊断精度转换带来的问题或复杂的插值错误至关重要。Unity Frame Debugger如上所述用于调试SH数据是否正确传递和启用。自定义调试视图在Shader中创建一个调试模式将计算出的SH颜色直接输出为片元颜色return half4(bakedGI, 1.0);。这样可以直观地在游戏画面中看到SH环境光的分布和强度快速定位问题区域是数据问题还是计算问题。6. 常见问题排查速查表与终极技巧最后我把项目中遇到的那些“坑”和解决方案浓缩成一张表方便大家快速对照排查。问题现象可能原因排查步骤与解决方案动态物体上没有环境光一片漆黑1. 动态物体所在区域没有放置光照探针。2. 动态物体的MeshRenderer未启用“Receive Global Illumination”。3. Shader中未正确编写SH光照计算代码。1. 检查场景Light Probe Group的覆盖范围在物体位置添加探针。2. 勾选MeshRenderer上的“Receive Global Illumination”。3. 使用Frame Debugger检查SH系数Uniform是否被绑定在Shader中输出SH颜色调试。SH环境光出现不正常的色块或条纹1. 法线向量未在片元着色器中归一化。2. 光照探针密度不足SH系数在空间上插值不连续。3. 大型动态物体未使用LPPV仅用单点采样。1. 在片元着色器中对worldNormal进行normalize()操作。2. 增加问题区域的光照探针密度。3. 为大型动态物体添加并配置Light Probe Proxy Volume组件。烘焙光照后SH效果减弱或消失静态物体的间接光已烘焙进光照贴图SH被自动抑制以防止重复照明。这是正常现象。检查动态物体是否正常。如需为静态物体补充动态SH如昼夜变化需使用脚本动态覆盖RenderSettings.ambientProbe并理解这会与烘焙光照叠加。移动设备上使用SH后出现明显色带SH系数在Shader计算中精度不足如误用half精度或插值计算在低精度下出现误差。1. 确保关键计算特别是系数与法线点积使用float精度。2. 检查是否在顶点着色器计算SH并插值到片元ShadeSHPerVertex对于细节丰富的表面尝试切换到片元着色器计算ShadeSH9。3. 尝试对SH系数进行轻微的抖动Dithering以平滑色带。启用SH后GPU耗时反而增加1. 错误地在所有物体上使用片元级SH计算。2. 使用了过高阶数如4阶的SH。3. 频繁调用DynamicGI.UpdateEnvironment()更新SH数据。1. 对远处、非重点物体使用顶点级SHShadeSHPerVertex。2. 坚持使用3阶SH。3. 对动态SH更新进行节流避免每帧调用。自定义Shader中SH计算错误1. 未包含必要的Unity内置HLSL文件或声明。2. 错误地手动实现了SH计算函数。1. 确保在Shader中包含了UnityCG.cginc或URP的Lighting.hlsl并使用了ShadeSH9等内置函数。2. 除非有极特殊需求否则不要自己重写SH计算直接使用Unity提供的、经过高度优化的内置函数。终极技巧分层混合策略对于超大型的移动端开放世界全部使用高密度光照探针是不现实的。一个有效的策略是分层混合远景和背景使用一套低分辨率稀疏的全局光照探针甚至可以用一个统一的RenderSettings.ambientProbe。中景玩家活动主要区域使用中等密度的光照探针组确保视觉核心区域的光照质量。近景和交互物体对关键动态物体主角、NPC使用LPPV或者在其周围布置更高密度的探针。 通过这种分层处理可以在有限的性能预算内将最高的视觉保真度集中在玩家最关注的地方。球谐光照不是一个“用了就万事大吉”的黑盒而是一个需要理解、调试和精心配置的强大工具。它在移动端环境光渲染上的性价比极高但需要你清楚地知道数据从何而来、如何计算、以及如何与现有的光照体系协同工作。希望这篇从原理到踩坑的完整指南能帮你把项目的画面和性能再提升一个档次。