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

📅 2026/7/12 0:08:44 👁️ 阅读次数 📝 编程学习
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的语境下,这个过程分为两步:

  1. 编码(烘焙):Unity在烘焙光照时(或者你手动放置Light Probe时),会在探针位置向四面八方发射射线,收集来自各个方向的光照颜色和强度信息,然后通过数学积分,将这些信息“投影”到那几块“乐高积木”上,得到一组系数(就是那9个或4个RGB向量)。这组系数,就是Light Probe里存储的SH系数。它本质上是该点周围环境光照的一个高度压缩的、低频的近似表达。
  2. 解码(渲染):在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阶SH(9个系数)重建出环境光颜色。它返回的是一个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 { "RenderType"="Opaque" } 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" }

代码逻辑解读与避坑点:

  1. 亮度转换ShadeSH9返回的是RGB颜色。但环境光遮蔽关心的是明暗,而不是色相。所以我们用Luminance函数(通常定义为dot(color, float3(0.2126, 0.7152, 0.0722)))将其转换为一个标量亮度值。这是关键一步,直接使用颜色比较会导致结果不稳定。

  2. “理想”与“实际”亮度

    • luminanceNormal:沿着表面法线方向看出去的环境光亮度。这可以理解为该点“完全暴露”时能接收到的最大环境光。
    • luminanceBiased:我们模拟一个“被部分遮挡”的方向的光照。这里提供了两种思路:
      • 方法A(代码中实现):将法线向量向下(或向某个固定方向)偏移一点(_SHSampleBias)。因为遮蔽通常发生在凹陷、向下的面。这是一个经验性的、高效的近似。_SHSampleBias为0时,采样方向就是法线自身,此时shAO为0(无遮蔽);为负值时,采样方向更朝上,可能得到负遮蔽(变亮),可用于特殊效果。
      • 方法B(更精确):在半球空间内(法线指向的那一侧)采样多个方向(例如用5个或9个固定向量),计算这些方向的平均亮度或最低亮度。最低亮度更能反映严重的遮挡。但这需要在Shader中进行多次ShadeSH9调用,性能开销大,适合高端平台或离线渲染。
  3. 遮蔽因子计算occlusionFactor = luminanceBiased / luminanceNormal。这个比值越接近1,说明“实际”光强接近“理想”光强,遮蔽越轻;比值越小,遮蔽越严重。我们通过1.0 - occlusionFactor得到遮蔽强度shAO

  4. 幂函数调节pow(occlusionFactor, _SHAOPower)。这是一个非常实用的技巧。当_SHAOPower > 1时,比值小于1的部分(遮蔽区域)会被压得更暗,比值接近1的部分(明亮区域)变化不大,从而增加遮蔽的对比度,让暗角更明显。当_SHAOPower < 1时,效果相反,会让遮蔽过渡更柔和。你可以根据项目美术风格调整这个参数。

  5. AO合并:如何将SH AO与可能存在的静态Base AO结合?常见的有两种:

    • 取最小值finalAO = min(baseAO, (1.0 - shAO))。这是比较物理的,哪个因素导致的遮蔽更严重,就采用哪个。效果较强。
    • 相乘finalAO = baseAO * (1.0 - shAO)。这是更常见的叠加方式,效果相对柔和自然。我通常在项目中采用乘法。

3.4 在Unity中的使用与调试

  1. 材质设置:将Shader赋给材质,拖给需要动态AO的物体(尤其是Skinned Mesh Renderer的物体)。
  2. Light Probe代理体积(LPPV):对于大型物体(如巨龙、公交车),单个顶点采样一个探针可能不够。务必为这些物体的Renderer组件勾选“Use Light Probe Proxy Volumes”,并在Lighting设置中启用LPPV。这会在物体体积内插值多个探针,得到更准确的空间变化光照。
  3. 调试视图:在Scene视图的照明模式中,选择“Baked Lightmaps”或“Baked Lightmap Directionality”可以直观看到Light Probe的分布和影响范围。确保你的动态物体在移动时,其包围盒始终被探针覆盖。
  4. 参数调节
    • _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完全没有变化或变化错误

  • 可能原因1:Light 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安装)可以辅助均匀放置探针。
  • 可能原因3:Shader中世界空间法线计算错误。

    • 检查:在Shader中输出worldNormal到颜色,查看法线是否正确。法线应该是在世界空间下归一化的。
    • 解决:确保使用UnityObjectToWorldNormal(v.normal),并且模型导入设置中“Normals”选项正确(通常是“Calculate”或“Import”)。

4.2 问题:SH AO效果过强或过弱,不自然

  • 可能原因1:SH系数不包含足够的遮蔽信息。

    • 分析:如果Light Probe烘焙时,场景本身就很“平”,没有强烈的明暗对比区域,那么SH系数中编码的亮度变化本身就小。
    • 解决
      1. 确保场景中有明确的间接光光源和遮挡物。比如,在墙角、家具下方放置一些能产生阴影的物体。
      2. 检查光照烘焙设置,确保“Indirect Intensity”不为0,并且GI的“Bounce”至少为1,让光能在场景中反弹,产生明暗差异。
      3. 可以尝试在Shader中增加一个对比度增强步骤,比如对计算出的shAO使用smoothstep函数重新映射范围。
  • 可能原因2:_SHAOStrength_SHAOPower参数未调优。

    • 解决:这是美术调节环节。准备一个测试场景,包含一个在复杂光照环境下移动的球体或方块,实时调节这两个参数,观察遮蔽效果的强度和软硬边是否符合预期。

4.3 问题:在移动平台或VR上效果差或有性能问题

  • 可能原因1:SH精度问题。

    • 现象:在iOS/Android上,SH光照可能出现色带或精度不足导致的噪点。
    • 解决
      1. 在Player Settings中,针对目标平台,检查图形API是否支持足够的精度(如OpenGL ES 3.0以上)。
      2. 在Shader中,将涉及SH计算的变量精度声明为halffloat,避免使用fixed(在某些平台上精度过低)。例如:half3 shColorNormal = ShadeSH9(float4(ambientNormal, 1));
      3. 考虑降阶使用。如果效果要求不高,可以尝试使用2阶SH(4个系数)而非3阶(9个系数)。Unity内部有ShadeSH3等函数,但需要自己实现或查找相关代码。降阶能减少计算量和带宽。
  • 可能原因2:多次采样导致性能开销。

    • 分析:如果采用了“方法B”(多方向采样),每个片元多次调用ShadeSH9是昂贵的。
    • 解决
      1. 优先使用方法A,它只采样两次,开销极小。
      2. 如果必须用方法B,考虑在顶点着色器中计算SH AO,然后通过插值传递给片元着色器(interpolator)。因为AO是低频信息,顶点级别的计算通常足够,能极大节省片元着色器的开销。
      3. 对于移动平台,可以做一个开关,在低画质下关闭动态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 结合屏幕空间环境光遮蔽(SSAO)

SH AO是物体空间(Object-Space)的,它只关心物体自身的几何和周围探针环境,不知道屏幕上其他物体的遮挡。而SSAO是屏幕空间(Screen-Space)的,能捕捉到物体之间的相互遮挡。

最佳实践是结合两者

  1. SH AO:负责物体自身凹陷、褶皱处的遮蔽(如衣服褶皱、鼻孔、耳朵后),以及由大型静态环境(如房间)造成的遮蔽。它稳定,不随视角变化。
  2. 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并烘焙

  1. 运行一个编辑器脚本:在编辑模式下,将物体放置于场景中其典型的位置。
  2. 逐顶点计算:对于物体的每个顶点,获取其世界坐标和法线,调用UnityEditor.Rendering.LightProbes.Tetrahedralize等相关API(或直接利用场景中烘焙好的Light Probe数据),模拟Shader中的SH AO计算逻辑,算出一个AO值。
  3. 存储结果:将这个AO值写入顶点的颜色通道(例如R通道),或者烘焙到一张额外的顶点光照贴图(Vertex Lightmap)中。
  4. 运行时:在Shader中,直接读取预烘焙的AO值,无需任何实时SH计算。性能开销为零,效果稳定。

这种方法牺牲了动态性,换来了极致性能,非常适合背景建筑、固定植被等大量存在的静态物体。