Unity动态环境光遮蔽实战:基于球谐函数与光照探针的实时AO方案

📅 2026/7/11 23:49:45
Unity动态环境光遮蔽实战:基于球谐函数与光照探针的实时AO方案
1. 项目概述与核心价值最近在优化一个Unity项目的美术表现时我又一次和动态环境光遮蔽Dynamic Ambient Occlusion较上了劲。静态烘焙的AO贴图固然效果好但场景里但凡有个会动的物体那烘焙好的阴影就完全对不上角色走到墙角下本该被遮挡的侧面依然亮堂堂的瞬间就“穿帮”了。为了解决这个动态物体的“光影正确性”问题我决定把Spherical Harmonics球谐函数简称SH这套方案再拿出来打磨一遍。这玩意儿在Unity里其实一直存在Light Probe光照探针的核心就是它但很多人可能只是用它来给动态物体“上个色”没深究过它其实能模拟出非常细腻、实时的环境光遮蔽效果。简单来说我们这个实战项目的目标就是不依赖预烘焙的AO贴图利用Unity内置的SH数据和一套自定义的着色器逻辑让动态物体比如角色、可移动道具也能根据周围环境的几何形状实时计算出柔和、正确的环境光遮蔽阴影。这不仅能显著提升动态物体的视觉体积感和真实感还能让静态和动态物体的光影融合得更自然。整个过程会涉及到对SH原理的通俗理解、Unity中Light Probe数据的获取与解析、在Shader中进行SH系数的重构成环境光信息以及最关键的一步——如何利用这些信息来模拟遮蔽效果。我会把从理论到代码实现的每一步都拆开揉碎特别是SH烘焙和采样环节那些容易踩坑的地方比如探针密度、采样范围、跨平台精度问题都会给出明确的避坑指南。2. Spherical Harmonics核心原理与Unity实现机制拆解2.1 为什么是球谐函数从环境光到“编码”与“解码”要理解SH在环境光遮蔽中的应用我们得先抛开复杂的数学公式想想我们要解决的根本问题一个物体表面某一点它到底“看到”了多少来自周围环境各个方向的光这是一个在球面空间上的函数输入是一个方向用球坐标θ和φ表示输出是该方向来的光照强度。直接存储和计算这个函数在每个方向上的值数据量是无穷大的完全不现实。SH提供了一种极其聪明的“压缩”方案。你可以把它想象成用一套标准的“乐高积木”基函数去拼凑出任意复杂的形状原始光照函数。这套“乐高积木”是预先定义好的、相互正交的意味着每块积木的功能独立不重叠。在图形学中我们通常只使用前3阶共9个系数L0, L1, L2或前2阶4个系数的SH基函数这已经能以很高的保真度重建出低频的环境光照信息比如柔和的天空光和大型物体的间接反射。在Unity的语境下这个过程分为两步编码烘焙Unity在烘焙光照时或者你手动放置Light Probe时会在探针位置向四面八方发射射线收集来自各个方向的光照颜色和强度信息然后通过数学积分将这些信息“投影”到那几块“乐高积木”上得到一组系数就是那9个或4个RGB向量。这组系数就是Light Probe里存储的SH系数。它本质上是该点周围环境光照的一个高度压缩的、低频的近似表达。解码渲染在Shader中渲染一个顶点时我们获取该顶点位置所对应的SH系数通过Unity内置函数如ShadeSH9或手动采样Light Probe然后用同样的那套“乐高积木”规则把这组系数重新组合重建成该顶点法线方向所接收到的环境光颜色。注意SH重建的光照是低频的。这意味着它无法表现锐利的阴影、高频的细节如树叶投下的细小光斑。但这恰恰是环境光遮蔽AO所需要的特性——AO本身就是一种低频的、柔和的阴影效果用来模拟物体缝隙、凹陷处因为难以接收到周围环境光而变暗的现象。SH的“模糊”特性在这里成了优点。2.2 Unity中SH数据的来源与采样流程详解在Unity中动态物体获取SH数据主要通过Light Probe Group光照探针组。这是整个流程的基石如果这里没设置好后面的Shader写得再漂亮也是白搭。1. 探针的放置策略与密度很多新手容易犯的错误是要么整个场景就放三五个探针要么密密麻麻铺满导致烘焙时间爆炸。一个实用的原则是探针的密度需要与你希望捕捉的环境光变化频率相匹配。开阔、平坦区域光照变化缓慢可以稀疏放置比如每5-10米一个。复杂几何结构附近如走廊转角、门窗洞口、雕塑底部这些地方光照方向和遮挡情况变化剧烈需要加密探针。我通常会在这些区域的交界处以1-2米的间隔额外放置探针。物体运动路径确保你的角色或动态物体的活动路径上有连续的探针覆盖避免在移动过程中出现光照的突兀跳变。2. 烘焙设置的关键参数在Window - Rendering - Lighting设置中找到Light Probe相关设置。Bounces: 对于大多数场景1次反弹足够。增加反弹次数能稍微改善颜色溢出Color Bleeding效果但会显著增加烘焙时间且对AO模拟的提升有限。Compression: 通常保持默认的“Normal”即可。“High”压缩率更高节省内存但可能引入轻微的颜色误差。在移动平台或需要严格控制包体大小时可以考虑。最重要的坑Probe Sampling中的“Use Direct Light”和“Use Indirect Light”。为了得到纯净的环境遮蔽信息我们通常只勾选“Use Indirect Light”。因为直接光Directional Light, Point Light等的阴影是实时计算的如果将其烘焙进探针动态物体在移动时其表面的直接光照阴影不会随之变化会导致视觉错误。环境光遮蔽关心的是间接光被周围环境表面反射多次后的光的可见性。3. Shader中的采样与重建Unity Surface Shader 或 URP/HDRP的Lit Shader内部已经集成了SH采样。但对于我们的自定义AO方案我们需要更底层的访问。主要使用以下函数ShadeSH9(float4 normal): 这是最常用的函数输入一个世界空间法线float4(normalWorld, 1)它内部会自动采样当前顶点位置关联的Light Probe并用前3阶SH9个系数重建出环境光颜色。它返回的是一个float3的RGB颜色。SampleSHPixel/SampleSHVertex: 在更现代的渲染管线中你可能需要这些函数在Fragment或Vertex Shader中显式采样。我们的核心思路是利用ShadeSH9重建出的环境光亮度或 luminance作为该方向“可见光”的度量。然后通过某种方式模拟“被遮挡”方向的光照衰减。3. 基于SH的动态环境光遮蔽Shader实现理论铺垫完毕现在进入实战环节。我们将编写一个Unlit Shader以便聚焦于AO计算逻辑它接收物体的顶点色Vertex Color的R通道作为基础的环境光遮蔽图Base AO可选然后叠加基于SH的实时动态AO。3.1 Shader框架与数据准备首先我们需要在Shader中声明必要的变量和包含文件。Shader Custom/DynamicSHAmbientOcclusion { Properties { _MainTex (Albedo (RGB), 2D) white {} _BaseAOMap (Base AO Map (R), 2D) white {} // 可选的静态AO贴图 _BaseAOStrength (Base AO Strength, Range(0, 1)) 0.5 _SHAOStrength (SH AO Strength, Range(0, 2)) 1.0 _SHAOPower (SH AO Power, Range(0.1, 5)) 1.5 // 用于调整遮蔽对比度 _SHSampleBias (SH Sample Bias, Range(-1, 1)) 0.0 // 采样偏移用于微调 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include UnityCG.cginc #include Lighting.cginc // 必须包含以使用ShadeSH9 struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; float4 color : COLOR; // 使用顶点色的R通道作为预绘AO }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; float4 vertexColor : TEXCOORD3; UNITY_FOG_COORDS(4) }; sampler2D _MainTex; sampler2D _BaseAOMap; float4 _MainTex_ST; float _BaseAOStrength; float _SHAOStrength; float _SHAOPower; float _SHSampleBias;这里我们定义了属性包括主纹理、基础AO贴图、以及控制SH AO强度和对比度的参数。_SHSampleBias是一个实用技巧后面会解释。顶点结构体appdata中我们读取了顶点色COLOR语义计划用它的R通道。3.2 顶点着色器准备世界空间数据顶点着色器的任务是将模型空间的数据转换到世界空间和裁剪空间并传递给片元着色器。v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); o.vertexColor v.color; // 传递顶点色 UNITY_TRANSFER_FOG(o, o.vertex); return o; }关键点在于计算了顶点的世界坐标 (worldPos) 和世界空间法线 (worldNormal)。ShadeSH9函数需要世界空间法线。顶点色也被原样传递。3.3 片元着色器核心SH AO计算逻辑这是最核心的部分。我们的目标是对于一个片元计算其法线方向“应该”接收到的环境光亮度再与其“实际可能”接收到的平均环境光亮度进行比较差值即为遮蔽程度。fixed4 frag (v2f i) : SV_Target { // 1. 采样基础颜色和基础AO fixed4 col tex2D(_MainTex, i.uv); fixed baseAO tex2D(_BaseAOMap, i.uv).r; baseAO lerp(1.0, baseAO, _BaseAOStrength); // 混合基础AO // 2. 核心SH AO计算 // 2.1 计算当前法线方向的环境光“理想”亮度 float3 ambientNormal normalize(i.worldNormal); float3 shColorNormal ShadeSH9(float4(ambientNormal, 1)); float luminanceNormal Luminance(shColorNormal); // 转换为亮度 // 2.2 计算一个“平均”或“偏下”方向的环境光“实际”亮度 // 方法A使用向下的偏向向量模拟被遮挡 float3 sampleBiasDir normalize(ambientNormal float3(0, _SHSampleBias, 0)); // 方法B更物理采样多个方向并取平均或最小值性能更高可考虑 // 这里我们先使用方法A简单有效。 float3 shColorBiased ShadeSH9(float4(sampleBiasDir, 1)); float luminanceBiased Luminance(shColorBiased); // 2.3 计算遮蔽因子 // 如果“实际”亮度比“理想”亮度低说明有遮蔽 float occlusionFactor saturate(luminanceBiased / (luminanceNormal 1e-5)); // 防止除零 // 应用幂函数调整对比度使暗部更暗或更柔和 occlusionFactor pow(occlusionFactor, _SHAOPower); // 反转并应用强度1表示完全遮蔽0表示无遮蔽 float shAO 1.0 - occlusionFactor; shAO saturate(shAO * _SHAOStrength); // 3. 合并AO float finalAO min(baseAO, (1.0 - shAO)); // 取两者中遮蔽更严重的 // 或者使用乘法叠加 float finalAO baseAO * (1.0 - shAO); // 4. 应用AO到最终颜色 col.rgb * finalAO; // 应用雾效 UNITY_APPLY_FOG(i.fogCoord, col); return col; } ENDCG } } FallBack Diffuse }代码逻辑解读与避坑点亮度转换ShadeSH9返回的是RGB颜色。但环境光遮蔽关心的是明暗而不是色相。所以我们用Luminance函数通常定义为dot(color, float3(0.2126, 0.7152, 0.0722))将其转换为一个标量亮度值。这是关键一步直接使用颜色比较会导致结果不稳定。“理想”与“实际”亮度luminanceNormal沿着表面法线方向看出去的环境光亮度。这可以理解为该点“完全暴露”时能接收到的最大环境光。luminanceBiased我们模拟一个“被部分遮挡”的方向的光照。这里提供了两种思路方法A代码中实现将法线向量向下或向某个固定方向偏移一点_SHSampleBias。因为遮蔽通常发生在凹陷、向下的面。这是一个经验性的、高效的近似。_SHSampleBias为0时采样方向就是法线自身此时shAO为0无遮蔽为负值时采样方向更朝上可能得到负遮蔽变亮可用于特殊效果。方法B更精确在半球空间内法线指向的那一侧采样多个方向例如用5个或9个固定向量计算这些方向的平均亮度或最低亮度。最低亮度更能反映严重的遮挡。但这需要在Shader中进行多次ShadeSH9调用性能开销大适合高端平台或离线渲染。遮蔽因子计算occlusionFactor luminanceBiased / luminanceNormal。这个比值越接近1说明“实际”光强接近“理想”光强遮蔽越轻比值越小遮蔽越严重。我们通过1.0 - occlusionFactor得到遮蔽强度shAO。幂函数调节pow(occlusionFactor, _SHAOPower)。这是一个非常实用的技巧。当_SHAOPower 1时比值小于1的部分遮蔽区域会被压得更暗比值接近1的部分明亮区域变化不大从而增加遮蔽的对比度让暗角更明显。当_SHAOPower 1时效果相反会让遮蔽过渡更柔和。你可以根据项目美术风格调整这个参数。AO合并如何将SH AO与可能存在的静态Base AO结合常见的有两种取最小值finalAO min(baseAO, (1.0 - shAO))。这是比较物理的哪个因素导致的遮蔽更严重就采用哪个。效果较强。相乘finalAO baseAO * (1.0 - shAO)。这是更常见的叠加方式效果相对柔和自然。我通常在项目中采用乘法。3.4 在Unity中的使用与调试材质设置将Shader赋给材质拖给需要动态AO的物体尤其是Skinned Mesh Renderer的物体。Light Probe代理体积LPPV对于大型物体如巨龙、公交车单个顶点采样一个探针可能不够。务必为这些物体的Renderer组件勾选“Use Light Probe Proxy Volumes”并在Lighting设置中启用LPPV。这会在物体体积内插值多个探针得到更准确的空间变化光照。调试视图在Scene视图的照明模式中选择“Baked Lightmaps”或“Baked Lightmap Directionality”可以直观看到Light Probe的分布和影响范围。确保你的动态物体在移动时其包围盒始终被探针覆盖。参数调节_SHAOStrength: 控制动态AO的总体强度。从0.5开始调节。_SHAOPower: 调节阴影的软硬。对于风格化卡通可以调高如2.0以得到更清晰的暗部对于写实风格保持在1.0-1.5之间。_SHSampleBias: 微调遮蔽的方向性。对于大多数地面物体设为-0.2到-0.5让采样方向更偏向地面模拟被地面遮挡效果通常不错。4. SH烘焙与采样过程中的常见问题与解决方案即使Shader写对了效果不对的情况也十有八九。问题往往出在数据源——Light Probe的烘焙和采样上。下面是我踩过坑后总结的排查清单。4.1 问题动态物体上的SH AO完全没有变化或变化错误可能原因1Light Probe未烘焙或未启用。检查确保Lighting窗口中的“Auto Generate”已关闭并手动点击了“Generate Lighting”。检查场景中的Light Probe Group组件其探针应为黄色已烘焙而非黑色未烘焙。解决正确烘焙光照。确保动态物体的Renderer组件上“Light Probes”选项设置为“Blend Probes”或“Use Proxy Volume”而不是“Off”。可能原因2探针密度不足或放置位置不当。现象物体移动时光照颜色发生阶跃式跳变而不是平滑过渡。解决按照第2.2节的原则加密探针特别是在光照变化剧烈的区域。使用“Light Probe Placement”工具可通过Package Manager安装可以辅助均匀放置探针。可能原因3Shader中世界空间法线计算错误。检查在Shader中输出worldNormal到颜色查看法线是否正确。法线应该是在世界空间下归一化的。解决确保使用UnityObjectToWorldNormal(v.normal)并且模型导入设置中“Normals”选项正确通常是“Calculate”或“Import”。4.2 问题SH AO效果过强或过弱不自然可能原因1SH系数不包含足够的遮蔽信息。分析如果Light Probe烘焙时场景本身就很“平”没有强烈的明暗对比区域那么SH系数中编码的亮度变化本身就小。解决确保场景中有明确的间接光光源和遮挡物。比如在墙角、家具下方放置一些能产生阴影的物体。检查光照烘焙设置确保“Indirect Intensity”不为0并且GI的“Bounce”至少为1让光能在场景中反弹产生明暗差异。可以尝试在Shader中增加一个对比度增强步骤比如对计算出的shAO使用smoothstep函数重新映射范围。可能原因2_SHAOStrength和_SHAOPower参数未调优。解决这是美术调节环节。准备一个测试场景包含一个在复杂光照环境下移动的球体或方块实时调节这两个参数观察遮蔽效果的强度和软硬边是否符合预期。4.3 问题在移动平台或VR上效果差或有性能问题可能原因1SH精度问题。现象在iOS/Android上SH光照可能出现色带或精度不足导致的噪点。解决在Player Settings中针对目标平台检查图形API是否支持足够的精度如OpenGL ES 3.0以上。在Shader中将涉及SH计算的变量精度声明为half或float避免使用fixed在某些平台上精度过低。例如half3 shColorNormal ShadeSH9(float4(ambientNormal, 1));。考虑降阶使用。如果效果要求不高可以尝试使用2阶SH4个系数而非3阶9个系数。Unity内部有ShadeSH3等函数但需要自己实现或查找相关代码。降阶能减少计算量和带宽。可能原因2多次采样导致性能开销。分析如果采用了“方法B”多方向采样每个片元多次调用ShadeSH9是昂贵的。解决优先使用方法A它只采样两次开销极小。如果必须用方法B考虑在顶点着色器中计算SH AO然后通过插值传递给片元着色器interpolator。因为AO是低频信息顶点级别的计算通常足够能极大节省片元着色器的开销。对于移动平台可以做一个开关在低画质下关闭动态SH AO回退到静态AO贴图或一个简单的顶点色AO。4.4 问题与实时阴影或直接光照结合时出现双重阴影可能原因SH AO模拟的是环境光间接光的遮蔽。如果直接光的实时阴影已经覆盖了某个区域SH AO又叠加了一次变暗就会过黑。解决在Shader的最终颜色混合阶段需要区分对待直接光照和间接光照。在URP/HDRP中通常有明确的光照循环。确保你的SH AO只乘在间接光照部分。在Built-in管线或自定义Shader中一个简单的策略是finalColor (directLighting * shadow) (indirectLighting * finalAO);。即直接光部分乘实时阴影间接光部分乘我们计算的SH AO。这需要你能在Shader中分离出直接光和间接光贡献。Unity的Standard Shader内部就是这样处理的。5. 进阶优化与扩展思路当基础功能稳定后可以考虑以下方向进一步提升效果和性能。5.1 结合屏幕空间环境光遮蔽SSAOSH AO是物体空间Object-Space的它只关心物体自身的几何和周围探针环境不知道屏幕上其他物体的遮挡。而SSAO是屏幕空间Screen-Space的能捕捉到物体之间的相互遮挡。最佳实践是结合两者SH AO负责物体自身凹陷、褶皱处的遮蔽如衣服褶皱、鼻孔、耳朵后以及由大型静态环境如房间造成的遮蔽。它稳定不随视角变化。SSAO负责处理物体与物体之间、物体与场景之间的接触阴影Contact Shadow如角色脚底与地面的接触处、杯子放在桌子上的阴影。它随视角变化可能有时效性问题如边缘闪烁但能提供宝贵的深度信息。在渲染管线中通常先计算SH AO并作为物体的一个属性例如输出到某个渲染目标或包含在GBuffer中然后在后处理阶段将SSAO的结果与SH AO的结果以某种方式如相乘混合。这能获得既稳定又细节丰富的AO效果。5.2 使用高阶SH或自定义基函数我们使用的是3阶SH这对于环境光遮蔽和低频光照已经足够好。但在一些极端追求质量的场合比如模拟有复杂图案的天空盒对物体的染色可以考虑使用更高阶的SH如4阶、5阶。但这会带来存储和计算成本的平方级增长n阶SH需要n²个系数需要谨慎评估。另一个前沿思路是使用可学习的、针对场景优化的基函数而非通用的SH基函数。通过预计算或神经网络找到一组能更高效表示当前场景光照的基函数可以用更少的系数达到更好的重建质量。但这属于预计算全局光照Precomputed GI或神经渲染的研究范畴了超出了大多数实时项目的需求。5.3 将SH AO烘焙进顶点色或纹理对于完全静态的物体或者动态但形态固定的物体如一棵随风摇摆但整体形状不变的树我们还有一种“作弊”优化方案预计算SH AO并烘焙。运行一个编辑器脚本在编辑模式下将物体放置于场景中其典型的位置。逐顶点计算对于物体的每个顶点获取其世界坐标和法线调用UnityEditor.Rendering.LightProbes.Tetrahedralize等相关API或直接利用场景中烘焙好的Light Probe数据模拟Shader中的SH AO计算逻辑算出一个AO值。存储结果将这个AO值写入顶点的颜色通道例如R通道或者烘焙到一张额外的顶点光照贴图Vertex Lightmap中。运行时在Shader中直接读取预烘焙的AO值无需任何实时SH计算。性能开销为零效果稳定。这种方法牺牲了动态性换来了极致性能非常适合背景建筑、固定植被等大量存在的静态物体。