1. 项目概述:为什么是球谐光照?
在Unity里做项目,尤其是涉及到开放世界、大场景或者对性能有要求的移动端项目,环境光的处理一直是个让人头疼的问题。传统的实时光照计算量太大,一个动态光源就能让帧率掉一大截;而完全烘焙的静态光照又失去了动态物体的交互感。这时候,球谐光照(Spherical Harmonics Lighting)就成了一个绝佳的折中方案。
简单来说,你可以把球谐光照理解成一种“超级压缩包”。它能把场景中复杂的环境光照信息(比如来自天空盒、间接光、远处光源的柔和光线)压缩成几个简单的系数。在运行时,动态物体只需要用这几个系数,配合一个非常简单的数学公式,就能还原出相当逼真的环境光照效果,而且计算开销几乎可以忽略不计。这也就是我们常说的“环境光烘焙”的核心——把光“预计算”并“轻量化存储”起来。
我之所以花时间研究这个,是因为最近在做一个需要支持大量同屏角色的手游项目。角色是动态的,但场景光照是固定的。如果每个角色都用实时光照去计算环境光,GPU根本扛不住。而用球谐光照,我只需要在编辑器里烘焙一次场景,运行时所有角色都能共享同一套高质量的环境光信息,帧率稳稳的。这个“5分钟搞定”的标题,指的就是从零开始,在Unity里配置、烘焙并应用球谐光照到Shader上的核心流程。只要你跟着步骤走,真的很快就能看到效果。
2. 核心原理与Unity管线中的位置
在深入代码之前,我们得先搞明白球谐光照在Unity渲染管线里到底扮演什么角色,以及它为什么能这么快。
2.1 球谐函数的“降维打击”
想象一下,你要用手机拍一张风景照,但存储空间只允许你保存9个数字。你怎么用这9个数字尽可能还原天空的颜色和明暗变化?球谐函数做的就是类似的事情。它是一种数学工具,可以将定义在球面(比如一个物体表面接收的来自各个方向的光照)上的复杂函数,近似分解为一系列基函数的线性组合。在Unity中,通常使用3阶球谐,也就是9个系数(对应红、绿、蓝三个颜色通道,共27个浮点数)来存储光照信息。
这种近似的妙处在于,它特别擅长捕捉低频信息——也就是那些缓慢变化、没有尖锐边缘的光照,比如柔和的天空光或墙壁的间接反射光。而这恰恰是环境光的主要特征。对于高频的细节(比如清晰的阴影边缘、镜面高光),球谐光照无能为力,但这部分通常由其他技术(如阴影贴图、屏幕空间反射)来处理。
2.2 Unity的烘焙流程与数据流向
Unity的全局光照(GI)系统是球谐光照数据的生产者。当你勾选场景中的静态物体(Static),并选择烘焙光照贴图(Baked Global Illumination)时,Unity的烘焙器(Enlighten 或 Progressive)不仅会计算光照贴图,还会顺带计算整个场景的球谐光照数据。
这个计算过程是离线的,所以可以非常复杂和精确。烘焙器会采样场景中无数个探针点,计算该点来自半球所有方向的光照,然后将这些信息编码成球谐系数。最终,这些系数会被统一存储和管理。在运行时,对于场景中的任何一个点(尤其是动态物体),Unity的渲染引擎可以通过插值附近探针的数据,或者直接使用场景的平均球谐数据,来快速获取该点的环境光SH系数。
我们的Shader,就是这些数据的消费者。Shader中内置了还原球谐光照的函数,我们只需要把Unity传递进来的系数用起来即可。整个数据流是:烘焙器计算 -> Unity引擎存储管理 -> 渲染时传递给Shader -> Shader中解码还原。理解了这条管线,写代码时就不会迷茫了。
3. 完整Shader代码实现与逐行解析
理论说再多不如一行代码。下面是一个完整的、支持球谐光照的Unlit Shader,它结构清晰,包含了必要的注释。我会把关键部分拆开详细讲。
Shader "Custom/SH_Unlit" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 关键:确保支持光照贴图和球谐光照 #pragma multi_compile LIGHTMAP_ON LIGHTMAP_OFF #pragma multi_compile _ UNITY_SHOULD_SAMPLE_SH #pragma multi_compile _ _FORWARD_PLUS #include "UnityCG.cginc" // 关键:包含Unity内置的照明相关函数和变量 #include "Lighting.cginc" #include "AutoLight.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; // 法线对于球谐光照采样很重要 }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; // 如果使用了光照贴图,TEXCOORD3可能用于存储光照贴图UV #ifdef LIGHTMAP_ON float2 lmap : TEXCOORD3; #endif }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 将法线和顶点坐标转换到世界空间,用于后续的光照计算 o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 光照贴图UV转换 #ifdef LIGHTMAP_ON o.lmap = v.uv * unity_LightmapST.xy + unity_LightmapST.zw; #endif return o; } fixed4 frag (v2f i) : SV_Target { // 采样主纹理 fixed4 col = tex2D(_MainTex, i.uv) * _Color; // ---------------- 核心:球谐光照计算 ---------------- // 初始化环境光颜色 fixed3 ambient = fixed3(0,0,0); // 判断是否应该采样球谐光照 // UNITY_SHOULD_SAMPLE_SH 是一个由Unity根据场景和渲染路径定义的宏 #ifdef UNITY_SHOULD_SAMPLE_SH // 判断是否使用了光照贴图。如果用了光照贴图,通常光照信息已包含在贴图中,可能不需要额外的SH。 // 但有时为了更好的效果,可以混合使用。这里我们简单处理:有光照贴图就不用SH。 #if !defined(LIGHTMAP_ON) // 关键函数:ShadeSH9 // 它接受一个世界空间法线向量,并返回基于该法线方向的球谐光照颜色。 // unity_SHAr, unity_SHAg, unity_SHAb 等是Unity通过内置变量传递进来的球谐系数。 ambient = ShadeSH9(float4(i.worldNormal, 1.0)); #endif #endif // 可选:将环境光与纹理颜色相乘(模拟光照影响) col.rgb *= ambient; // 也可以选择相加(模拟自发光+环境光),取决于你想要的效果 // col.rgb += ambient * 0.5; return col; } ENDCG } } FallBack "Diffuse" }3.1 关键代码块深度剖析
1. 预处理指令 (#pragma multi_compile):这几行是Shader变体管理的核心。LIGHTMAP_ON和LIGHTMAP_OFF决定了Shader是否处理光照贴图。UNITY_SHOULD_SAMPLE_SH是Unity告诉我们在当前渲染配置下是否需要进行球谐采样。_FORWARD_PLUS是针对前向+渲染路径的。这些指令确保了Shader能在不同的项目设置(如是否启用实时GI、使用哪种渲染路径)下正确编译和工作,避免出现“粉色”材质(表示Shader编译错误)。
2.ShadeSH9函数:这是Unity CGInclude文件 (UnityCG.cginc或Lighting.cginc) 中封装好的函数。它的内部实现其实就是一套标准的球谐函数重建公式。你传入一个归一化的世界空间法线(float4(normal, 1.0)),它利用内置的unity_SHAr、unity_SHAg等7个float4变量(存储了27个系数),计算出该法线方向应接收的环境光颜色。对于移动平台,Unity可能会使用更低阶(如2阶)的近似来优化,但函数接口保持一致。
3. 环境光混合模式:代码中使用了乘法col.rgb *= ambient;。这是一种最常见的物理近似,意味着物体表面的纹理颜色会被环境光照亮。假设环境光是淡蓝色(来自天空),那么白色的纹理会呈现淡蓝色,红色的纹理会变成暗紫色(红 * 蓝)。你也可以尝试加法col.rgb += ambient;,这会让物体看起来像是在自发亮的基础上叠加了环境光,适合用于霓虹灯、全息投影等效果。具体用哪种,需要根据美术需求调整。
3.2 实操中的定制化扩展
基础版本虽然能用,但往往不够。在实际项目中,我通常会做这些扩展:
- 强度控制:增加一个
_SHIntensity属性,让美术可以调节环境光的强弱:ambient = lerp(fixed3(1,1,1), ambient, _SHIntensity);。 - 与直接光结合:在ForwardBase渲染路径下,你可以在计算完球谐光照后,再加上主平行光(
_WorldSpaceLightPos0和_LightColor0)的漫反射贡献,实现动态物体既受环境光影响,也产生实时的方向光阴影和高光。 - 探针混合:对于大型动态物体(如一条龙),其不同部位可能处于不同的光照环境(如头部在阳光下,尾巴在阴影里)。这时,简单的顶点法线采样就不够了。你需要编写更复杂的逻辑,在物体空间内采样多个点,分别计算SH光照后再进行混合,或者使用Unity的Light Probe Proxy Volume(LPPV)组件。
4. Unity编辑器内的五分钟配置流程
有了Shader,下一步就是在Unity里把它用起来。这个过程非常直观。
4.1 场景与光照设置
首先,打开你的Unity场景(版本建议2019.4 LTS或更新版本,管线兼容性更好)。
- 标记静态物体:选中所有构成场景环境的模型(地形、建筑、静态摆设),在检查器(Inspector)右上角,勾选“Static”复选框。这一步是告诉光照烘焙系统,这些物体的位置和形状不会变,可以参与光照计算。
- 配置光照窗口:菜单栏 -> Window -> Rendering -> Lighting (或者在新版中叫 Lighting Settings)。会打开光照设置窗口。
- 选择烘焙模式:在“Environment”区域,确保“Source”是天空盒或渐变天空,这将是环境光的主要来源。在“Realtime Lighting”区域,如果你不需要实时光照变化,可以取消勾选(以节省性能)。重点在“Baked Global Illumination”区域,确保它是勾选状态。
- 关键设置:在“Lightmapping Settings”中,选择烘焙器(Bakery、Progressive CPU/GPU)。对于快速测试,“Progressive CPU”兼容性最好。将“Lightmap Resolution”暂时调低,比如设为20,可以极大加快首次烘焙速度。“Compressed”选项可以勾选以节省磁盘空间。“Ambient Occlusion”可以根据需要勾选,它会增强缝隙和角落的阴影细节。
- 生成光照探针:在层次结构(Hierarchy)中右键 -> Light -> Light Probe Group。Unity会自动生成一组黄色的探针球。将它们大致均匀地布置在动态物体可能活动的区域。一个常见错误是探针覆盖不全,导致动态物体移动到没有探针的地方时,环境光突然变黑或出错。
4.2 烘焙与材质应用
- 开始烘焙:在Lighting窗口底部,点击“Generate Lighting”按钮。Unity会开始计算。根据场景复杂度和分辨率,首次烘焙可能需要几十秒到几分钟。看到进度条走完,并且场景中的静态物体有了光照贴图纹理,就成功了。
- 创建并应用材质:在项目窗口(Project)中右键 -> Create -> Material,命名为“SH_Material”。将其Shader选择为我们刚才创建的“Custom/SH_Unlit”。将这个材质球拖拽给你的动态物体(比如一个角色模型)。
- 立即查看效果:进入播放模式(Play),操控你的动态物体在场景中移动。你应该能看到它的颜色随着环境光的变化而柔和地改变。如果物体是纯色,效果会更明显。可以尝试关闭场景中的直接光源,观察物体是否依然被天空盒的光照亮。
注意:有时烘焙后球谐光照效果不明显。请检查:1. 场景是否有足够强的环境光源(如明亮的天空盒)?2. 动态物体的Shader是否正确地包含了
Lighting.cginc并调用了ShadeSH9?3. 在Player Settings -> Other Settings中,是否勾选了“Auto Graphics API”并确保有合适的图形API(如OpenGL ES 3.0或Vulkan)?在部分低端API上,SH功能可能受限。
5. 性能分析与移动端适配要点
球谐光照最大的卖点就是性能优异,但为了在移动端万无一失,我们还得深挖一下。
5.1 性能开销量化
在Fragment Shader中,一次ShadeSH9调用的开销大致相当于几次到十几次的标量乘加运算(MAD)。具体来说,3阶球谐的重建需要大约9次点乘和一系列乘加。这个计算量在现代移动GPU(即使是Adreno 506或Mali-G71这个级别)上,每像素一次的代价也是微乎其微的,通常远小于一次纹理采样。
真正的性能考量在于带宽和变体管理。Unity传递给Shader的球谐系数(unity_SHAr等)是存储在常量缓冲区(Constant Buffer)中的。这些数据每个渲染批次(Batch)只需要设置一次,然后该批次内的所有顶点和像素共享。因此,Draw Call的数量是比像素计算更重要的性能指标。这也是为什么我们要尽量使用GPU Instancing来合批渲染使用同一材质和SH光照的动态物体。
5.2 移动端特定优化策略
- 精度选择:在移动端,尽量使用
half或fixed精度。在Fragment Shader中,可以将法线和中间计算结果声明为half3。ShadeSH9函数内部已经为移动端做了优化,但确保你传递给它的法线是归一化的,避免在低精度下产生误差。half3 worldNormal = normalize(i.worldNormal); // 使用half精度 ambient = ShadeSH9(float4(worldNormal, 1.0)); - 禁用不必要的特性:如果你的动态物体完全不需要光照贴图,可以在Shader中移除
#pragma multi_compile LIGHTMAP_ON LIGHTMAP_OFF和相关代码,减少生成的Shader变体数量,缩短编译时间并减小游戏包体。 - 使用顶点着色器计算:对于顶点数不多、模型表面比较平滑的物体(如石头、 barrels),一个更极致的优化是将球谐光照的计算从片元着色器移到顶点着色器。在顶点着色器中计算每个顶点的环境光颜色,然后在片元着色器中进行线性插值。这会将计算量从“每像素”降低到“每顶点”,质量稍有损失(在棱角处可能会有色差),但性能提升显著。
// 在v2f结构体中增加 fixed3 shColor : TEXCOORD4; // 在vert函数中计算 v2f vert (appdata v) { ... #ifdef UNITY_SHOULD_SAMPLE_SH #if !defined(LIGHTMAP_ON) o.shColor = ShadeSH9(float4(o.worldNormal, 1.0)); #endif #endif ... } // 在frag函数中直接使用i.shColor - 测试与验证:务必在目标真机上进行测试。使用Unity的Frame Debugger和Profiler(GPU模块)来确认SH光照是否被正确应用,以及其GPU耗时是否在预期范围内。观察在低端设备上,从明处跑到暗处,光照过渡是否平滑,有无突兀的跳变。
6. 常见问题排查与进阶技巧
即使按照教程一步步来,也难免会遇到一些坑。这里我整理了几个最常见的问题和解决方法。
6.1 问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 动态物体一片漆黑 | 1. 场景未烘焙或烘焙失败。 2. Shader中未包含 Lighting.cginc或未调用ShadeSH9。3. 动态物体的Layer被排除在光照计算之外。 | 1. 检查Lighting窗口,确认已成功生成光照数据(无错误提示)。 2. 检查Shader代码,确保相关头文件和函数调用正确。 3. 在Lighting窗口的“Object”页签下,检查“Lightmap Static”物体的Layer设置,确保动态物体所在的Layer未被排除。 |
| 动态物体颜色异常(如发紫) | 1. 球谐系数未正确传递(Shader变体错误)。 2. 法线数据错误(模型导入设置或顶点着色器计算有误)。 | 1. 确保Shader使用了正确的#pragma multi_compile指令。尝试创建一个最简单的Standard Shader材质球赋予物体,如果颜色正常,则问题在你的自定义Shader。2. 在Shader中输出世界法线(如 return float4(i.worldNormal*0.5+0.5, 1);)查看是否正常。检查模型导入设置中的“Normals”选项是否为“Calculate”或“Import”。 |
| 光照效果不更新 | 1. 场景光照修改后未重新烘焙。 2. 使用了实时GI,但强度或颜色未在运行时更新。 | 1. 修改天空盒、光源颜色强度后,必须点击“Generate Lighting”重新烘焙。 2. 对于实时GI,确保在代码中或通过Post-Processing Volume触发了光照更新。对于纯烘焙的SH,运行时无法改变。 |
| 移动端上效果闪烁或断崖式变化 | 1. 光照探针(Light Probe)分布太稀疏。 2. 物体移动跨越了探针组边界,插值不连续。 | 1. 在动态物体活动区域更密集地放置Light Probe。 2. 使用Light Probe Proxy Volume(LPPV)组件来处理大型动态物体,它能提供物体内部的三维插值。 |
6.2 进阶技巧:与后处理与自定义渲染管线集成
- 在后处理中访问SH数据:有时我们想在后处理脚本中获取当前屏幕位置的环境光颜色,用于屏幕空间反射(SSR)或环境光遮蔽(SSAO)的调制。可以通过
Shader.Global级别的属性来访问。在C#脚本中,你可以使用Shader.GetGlobalVectorArray来获取unity_SHAr等数组,但操作较为复杂。更常用的方法是,如果需要,可以自己将关键的SH系数通过CommandBuffer.SetGlobalVector传递到后处理Shader。 - 在URP/HDRP中使用:在可编程渲染管线中,原理相通,但接口略有不同。在URP中,球谐光照数据通常通过
UnityIndirect结构体或SampleSHPixel函数来获取。你需要包含Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl,并查阅URP的Lit Shader源码来学习正确的调用方式。HDRP的处理则更加模块化和复杂。 - 烘焙质量与艺术控制:默认的烘焙结果可能不符合美术预期。可以在Lighting设置中调整“Environment Lighting”下的“Ambient Mode”为“Gradient”或“Color”,手动控制环境光的顶部/中部/底部颜色。对于室内场景,这比单纯依赖天空盒更可控。此外,使用“Lighting Data Asset”可以保存和复用烘焙结果,方便版本管理和团队协作。
最后,我个人最深刻的一个体会是:球谐光照是一个“基础设施”级别的技术。它本身不产生炫酷的特效,但它是构建一个光照统一、性能优良的游戏世界的基石。当你处理好它之后,美术同学在调整场景氛围时会更加得心应手,程序同学在优化渲染性能时也少了一个后顾之忧。花这“5分钟”把它集成到你的项目管线里,绝对是笔划算的投资。