
1. 项目概述从“照亮”到“真实”的跨越在图形渲染的世界里我们常常会陷入一个误区只要把模型建得足够精细把贴图画得足够逼真画面自然就真实了。但真正让一个3D场景“活”起来让观者产生身临其境感觉的往往不是那些高模和4K贴图而是光。更准确地说是光在场景中如何传播、反弹、相互作用最终塑造出物体形态和色彩的过程。这就是“全局光照”要解决的核心问题。它不是一个具体的功能开关而是一整套模拟真实世界光能传递的物理模型和算法集合。简单来说全局光照就是计算场景中所有光线包括从光源直接发出的以及经过物体表面多次反射、折射后间接到达的对最终画面贡献的过程。一个只计算直接光照的场景阴影会是一片死黑背光面会完全看不见物体之间也没有色彩相互“染色”的现象看起来就像舞台剧的布景缺乏体积感和空间氛围。而引入了全局光照后光线会“聪明”地在场景中弹跳照亮那些光源无法直射的角落将红色墙壁的暖意“染”到旁边的白色沙发上让整个场景的光影关系变得柔和、统一且富有层次。这个项目就是探讨如何通过编写Shader在实时渲染的苛刻性能限制下去逼近和实现这种复杂的光照效果。它不是简单地调用引擎的某个API而是深入到光照计算的底层理解并实现光与物质交互的数学表达。2. 全局光照的核心原理与实现路径拆解2.1 直接光照 vs. 间接光照光线的两次生命要理解全局光照必须首先拆解它的两大组成部分直接光照和间接光照。这是理解所有后续技术方案的基石。直接光照的计算相对直观。它回答的问题是“从光源如太阳、灯泡直接发出的光线是如何照亮并影响当前这个像素点的” 这个过程主要涉及几个经典的光照模型比如兰伯特漫反射Lambertian Diffuse和冯氏高光Phong Specular。在Shader中我们通常会拿到光源的方向、颜色、强度以及物体表面的法线、视角方向、材质属性漫反射颜色、高光强度、粗糙度等然后通过一系列点乘、幂运算来计算出最终的颜色。它的计算是局部的只关心光源和当前表面的关系不关心场景中其他物体的存在。因此直接光照下的场景明暗对比强烈但缺乏“环境感”物体像是漂浮在真空中。间接光照则是全局光照的灵魂所在。它计算的是那些并非直接从光源而来而是经过场景中其他物体一次或多次反弹后才到达当前表面的光线。例如阳光照到红色的地毯上地毯将一部分红光反射到旁边的白色墙脚这部分红光就是墙脚接收到的间接光照。间接光照带来了色彩渗透Color Bleeding、柔和阴影Soft Shadows和环境遮蔽Ambient Occlusion等关键视觉效果。它的计算是全局的需要考虑整个场景的几何与材质信息因此计算复杂度呈指数级增长。在实时渲染领域完全精确地模拟每一次光线反弹即路径追踪目前仍无法实现。因此工程师们发展出了多种“作弊”或“预计算”的方案来近似间接光照的效果这也是我们实现Shader时需要做出的核心选择。2.2 主流实时全局光照技术方案选型面对实时性的挑战业界主要有以下几种技术路径每种都有其适用的场景和Shader实现上的侧重点。1. 基于光照贴图Lightmap的烘焙方案这是最经典、性能消耗最低的方案。其思路是将静态场景不会移动的物体和光源的全局光照结果预先计算好并存储在一张或多张纹理即光照贴图中。在运行时Shader只需要根据模型的UV去采样这张贴图就能获得包含了复杂间接光照信息的颜色值再与动态物体的直接光照进行混合。Shader实现要点在片元着色器中除了采样漫反射贴图Albedo还需要采样第二套UVLightmap UV对应的光照贴图。通常光照贴图存储的信息可能是光照颜色也可能是更复杂的辐照度信息。关键点在于如何将烘焙的静态光照与实时的动态光照如角色手中的手电筒自然融合避免出现“发光”的静态物体或者光照不匹配的接缝。常用的技巧是使用“光照探针”为动态物体提供来自静态环境的间接光。2. 基于辐照度探针Irradiance Volume/Probe的动态方案为了给动态物体提供全局光照引入了光照探针。开发者可以在场景中预先放置一系列探针点每个探针会捕获其所在位置来自各个方向的环境光信息通常编码为球谐函数系数。当动态物体移动时Shader会根据其世界坐标在附近的几个探针之间进行插值获取当前应接收的间接环境光照。Shader实现要点在顶点或片元着色器中需要计算当前像素的世界坐标。然后调用引擎提供的函数如UnitySH9或自己实现球谐函数解码根据插值后的球谐系数重建出当前法线方向所接收的间接辐照度。这个结果通常作为漫反射间接光的基础色。它的优点是动态、性能中等但探针的分辨率决定了细节程度且无法处理物体自身遮挡产生的精细阴影。3. 基于屏幕空间的环境光遮蔽SSAO与反射SSR这是一类“取巧”但效果显著的后期处理技术。它们不依赖于预计算而是利用当前帧已渲染出的深度缓冲Depth Buffer和法线缓冲Normal Buffer信息在屏幕空间即最终成像的2D像素网格中进行近似计算。SSAO通过分析当前像素点周围深度值的突变来近似估算该点被周围几何体遮挡的程度从而让缝隙、角落变暗增强物体的体积感和场景的层次感。在Shader中实现一个简单的SSAO通常需要在片元着色器里以当前像素为中心在一个半球空间内随机采样多个点将这些采样点的深度值与深度缓冲中的实际深度进行比较统计被遮挡的比例。SSR根据当前像素的法线和视角方向反射出一条光线然后在屏幕空间的深度缓冲中步进Ray Marching寻找与场景几何的相交点并用相交点处的颜色作为反射内容。它能实现非常逼真的镜面反射和光滑表面的环境反射但计算量大且只能反射屏幕内已有的内容。4. 现代混合方案光照烘焙 实时全局光照如Unity的Enlighten/Progressive Lightmapper, UE4的Lumen现代引擎倾向于采用混合架构。基础、静态的漫反射间接光通过高精度的烘焙光照贴图提供动态的间接光、高光反射和细节补充则通过实时光照探针、屏幕空间技术甚至硬件加速的光线追踪如DXR、Vulkan Ray Tracing来实时更新。我们的Shader需要具备兼容和混合多种光照来源的能力。选择哪种方案甚至混合使用哪些方案取决于你的项目类型移动端/PC/主机、艺术风格写实/卡通、性能预算和动态程度。没有银弹只有权衡。3. 实现一个基础的烘焙全局光照Shader让我们从一个最基础、最实用的案例开始实现一个支持烘焙光照贴图的Shader。我们将使用Unity的ShaderLab和HLSL/Cg语言来编写但其原理通用。3.1 Shader框架与属性定义首先我们定义一个Surface Shader这是Unity中用于处理光照的一种高级抽象能简化与引擎光照系统的交互。Shader Custom/BakedGILit { Properties { _MainTex (Albedo (RGB), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 // 注意我们不需要在这里定义Lightmap相关的属性Unity会通过内置变量处理。 } SubShader { Tags { RenderTypeOpaque } LOD 200 // 这个Pass用于渲染到光照贴图烘焙时和主摄像机运行时 CGPROGRAM // 关键指令fullforwardshadows 确保阴影参与烘焙lightmap 启用光照贴图支持。 #pragma surface surf Standard fullforwardshadows // 启用光照贴图变体。LIGHTMAP_ON 是关键它会在编译时生成支持光照贴图的Shader变体。 #pragma multi_compile LIGHTMAP_ON LIGHTMAP_OFF #pragma target 3.0 sampler2D _MainTex; half _Glossiness; half _Metallic; struct Input { float2 uv_MainTex; // 声明一个变量用于接收第二套UV光照贴图UV。变量名必须是 uv2 或者以 uv2_ 开头。 float2 uv2_Lightmap; }; void surf (Input IN, inout SurfaceOutputStandard o) { // 基础颜色来自漫反射贴图 fixed4 c tex2D (_MainTex, IN.uv_MainTex); o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; } ENDCG } FallBack Diffuse }关键点解析#pragma multi_compile LIGHTMAP_ON LIGHTMAP_OFF这是Shader变体编译指令。Unity会根据场景中物体是否标记为“Static”并拥有光照贴图自动选择使用LIGHTMAP_ON有光照贴图或LIGHTMAP_OFF无光照贴图的变体。这保证了Shader的灵活性。float2 uv2_Lightmap在Input结构体中这个变量会自动被Unity填充为模型第二套UV通道的数据也就是我们展好的光照贴图UV。变量名uv2是Unity内置的语义识别。在这个基础的surf函数中我们还没有使用光照贴图数据。接下来我们需要修改光照函数来混合它。3.2 混合烘焙光照与实时直接光照标准的Surface Shader使用LightingStandard等内置光照模型。为了混合烘焙光我们需要自定义光照函数或者更简单——在后处理阶段进行叠加。但更符合Surface Shader哲学的做法是利用Unity提供的全局光照数据。实际上对于支持光照贴图的Surface ShaderUnity的SurfaceOutputStandard结构体在与引擎的Standard光照模型配合时已经自动处理了烘焙间接光的叠加。引擎在渲染时会判断如果该物体使用了烘焙光照贴图则其接收的间接光gi.indirect数据就来源于光照贴图采样而非实时光照探针。我们验证一下。将材质球赋予一个标记为Static的模型在Window - Rendering - Lighting Settings中配置好光照贴图参数并烘焙。你会发现即使场景中没有实时光源物体也被照亮了且光照信息来自烘焙结果。那么如果我们想在手动的片元着色器非Surface Shader中实现该怎么做呢核心是访问Unity Shader的内置变量unity_Lightmap和unity_LightmapST。// 在CGPROGRAM开始后声明对光照贴图的支持 #ifdef LIGHTMAP_ON // unity_Lightmap 是包含烘焙光照数据可能是RGBM编码的纹理。 // unity_LightmapST 是缩放和平移向量用于将模型UV转换到光照贴图纹理空间。 sampler2D unity_Lightmap; float4 unity_LightmapST; #endif // 在片元着色器中 float4 frag (v2f i) : SV_Target { // ... 计算基础颜色、法线等 ... float3 albedo tex2D(_MainTex, i.uv).rgb; // 初始化光照颜色为0 float3 lightColor float3(0,0,0); // 1. 计算实时直接光照例如一个平行光 float3 normalWS normalize(i.normalWorld); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 假设第一个平行光 float NdotL max(0, dot(normalWS, lightDir)); lightColor _LightColor0.rgb * NdotL; // 加入直接光贡献 // 2. 加入烘焙的间接光照 #ifdef LIGHTMAP_ON // 将模型的光照贴图UV从[0,1]范围转换到光照贴图纹理的实际坐标范围 float2 lightmapUV i.uv2.xy * unity_LightmapST.xy unity_LightmapST.zw; // 解码光照贴图。Unity默认使用RGBM编码来存储HDR光照信息。 float4 decodedLightmap tex2D(unity_Lightmap, lightmapUV); // DecodeLightmap 是UnityCG.cginc中提供的函数用于将RGBM解码回线性空间的HDR颜色。 float3 bakedIndirect DecodeLightmap(decodedLightmap); // 将烘焙的间接光加到最终光照中。通常间接光会乘以albedo因为它是反射光。 lightColor bakedIndirect * albedo; #endif // 最终颜色 物体颜色 * 总光照 float3 finalColor albedo * lightColor; return float4(finalColor, 1.0); }注意事项编码与解码为了节省存储空间光照贴图通常不是存储原始的线性颜色。Unity旧版使用双LDR两张RGB贴图新版默认使用单张RGBM编码的贴图。DecodeLightmap函数帮我们处理了这些细节务必使用它而不是直接使用tex2D采样的颜色。UV转换unity_LightmapST是一个float4其xy分量是缩放scalezw分量是偏移translation。转换公式为实际UV 模型UV * scale translation。忘记这个转换是导致光照贴图采样错位显示为错误的色块的最常见原因。动态与静态光照混合在上面的简单示例中我们直接将烘焙间接光与实时直接光相加。在更复杂的PBR流程中间接光贡献应分为漫反射间接光来自光照贴图或探针和高光间接光通常来自环境贴图或SSR并分别与材质的BRDF公式结合。Unity的Standard Shader内部就是这样处理的。4. 进阶在Shader中集成屏幕空间环境光遮蔽烘焙光照贴图解决了静态间接漫反射但缺乏动态的、高细节的遮挡阴影。SSAO可以很好地补充这一点。我们将实现一个简化的、后处理风格的SSAO但将其原理整合进我们的主着色器进行说明。在实际项目中SSAO通常作为独立的后期处理效果实现。4.1 SSAO的基本算法原理SSAO的核心思想是对于屏幕上的每一个像素对应世界空间中的一个点P在其法线方向指向的半球空间内随机采样一系列点Q_i。然后将采样点Q_i投影回屏幕空间获取其对应的深度值depth(Q_i)并与Q_i在视图空间中的实际深度z(Q_i)进行比较。如果depth(Q_i)比z(Q_i)小即更靠近摄像机说明Q_i点被场景中的其他几何体遮挡了。统计被遮挡的采样点比例这个比例就近似表示了点P受环境遮蔽的程度因子occlusion值在0完全不被遮蔽到1完全被遮蔽之间。4.2 在片元着色器中实现简易SSAO假设我们已经在着色器中获取了当前像素的视图空间位置viewPos和法线viewNormal以及整个屏幕的深度纹理_CameraDepthTexture和对应的视图空间到屏幕空间的投影矩阵参数_CameraProjectionParams等。// 在Properties中声明参数 _SampleRadius (SSAO Sample Radius, Range(0.1, 2.0)) 0.5 _SampleCount (SSAO Sample Count, Range(4, 64)) 16 _OcclusionPower (Occlusion Power, Range(0.1, 4.0)) 1.5 // 在片元着色器中 float CalculateSSAO(float3 viewPos, float3 viewNormal) { float occlusion 0.0; float radius _SampleRadius; // 采样半径单位通常是视图空间或世界空间 // 使用一个简单的伪随机函数生成采样方向。实际项目中会使用预生成的噪声纹理。 for (int i 0; i _SampleCount; i) { // 1. 生成一个随机的半球方向向量偏向法线方向 float3 randomVec float3( frac(sin(i * 746.3) * 43758.5453) * 2.0 - 1.0, frac(sin(i * 547.7) * 62583.6453) * 2.0 - 1.0, frac(sin(i * 312.2) * 58328.3453) * 2.0 - 1.0 ); randomVec normalize(randomVec); // 确保随机方向在法线指向的半球内 if (dot(randomVec, viewNormal) 0.0) randomVec -randomVec; // 2. 计算采样点在视图空间的位置 float3 sampleViewPos viewPos randomVec * radius; // 3. 将采样点从视图空间投影到屏幕空间NDC float4 sampleClipPos mul(UNITY_MATRIX_P, float4(sampleViewPos, 1.0)); float2 sampleScreenUV (sampleClipPos.xy / sampleClipPos.w) * 0.5 0.5; // 注意需要处理平台差异DX和OpenGL的屏幕UV原点可能不同Unity提供了宏 UnityStereoTransformScreenSpaceTex // 4. 从深度纹理中读取采样点屏幕位置对应的深度 float sampledDepth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampleScreenUV)); // 5. 比较实际深度与采样点深度 float rangeCheck smoothstep(0.0, 1.0, radius / abs(viewPos.z - sampledDepth)); // 如果采样点深度比实际采样位置深度更近说明被遮挡 if (sampledDepth sampleViewPos.z) { occlusion 1.0 * rangeCheck; // 用rangeCheck来平滑边缘 } } occlusion 1.0 - (occlusion / _SampleCount); // 通过一个幂运算来调整遮蔽效果的对比度 occlusion pow(occlusion, _OcclusionPower); return occlusion; } // 在最终的片元着色器主函数中 float ssaoFactor CalculateSSAO(viewPosition, viewNormal); finalColor.rgb * ssaoFactor; // 用SSAO因子来变暗最终颜色实操心得与避坑指南性能是关键循环采样_SampleCount次在片元着色器中是极其昂贵的操作。上述代码仅为原理演示。绝对不要在移动平台或每个物体Shader中这样实现完整的SSAO。正确的做法是使用一个全屏后处理Shader只执行一次并利用屏幕空间的连贯性进行优化如使用降采样、模糊。噪声与带状瑕疵使用纯随机数会产生难看的带状噪声。标准做法是使用一张小的如4x4随机旋转纹理Random Rotation Texture来提供随机的采样方向并在采样时根据屏幕像素位置进行偏移这能产生更均匀的噪声便于后续模糊。深度比较的精度问题直接比较深度值在边缘处会产生硬边。通常需要引入一个“范围检查”和“平滑”函数如上面代码中的smoothstep和rangeCheck让遮蔽效果在深度差异较大时逐渐减弱避免出现明显的“光环”瑕疵。法线的重要性采样必须限制在法线指向的半球内否则会错误地计算背面方向的遮挡导致平面如地板中间出现不自然的变暗。确保你的法线信息是视图空间下的并且归一化。5. 常见问题排查与Shader调试技巧在编写和调试全局光照相关的Shader时你会遇到各种诡异的现象。下面是一些常见问题的排查思路和实用调试技巧。5.1 光照贴图显示为纯白、纯黑或错乱色块这是新手最常遇到的问题。纯白通常意味着光照贴图被成功采样但强度过高。检查烘焙设置中的光照强度Intensity和曝光Exposure。在Shader中检查是否使用了DecodeLightmap进行正确的解码。直接使用tex2D采样的值可能是编码后的并非实际光照强度。纯黑首先确认物体是否被标记为Static或Contribute GI并且参与了光照烘焙Lightmap Static。在Unity Lighting窗口烘焙后检查该物体的Lightmap Index和Scale in Lightmap是否有效。在Shader中检查LIGHTMAP_ON变体是否被正确编译可以通过#pragma shader_feature LIGHTMAP_ON并在代码中用#ifdef检查以及uv2坐标是否正确传递。错乱色块/拉伸这几乎可以肯定是UV问题。检查模型导入设置中的“Generate Lightmap UVs”是否勾选或者美术提供的第二套UVUV2是否展得合理有无重叠或过度拉伸。在Shader中确认使用了unity_LightmapST对UV进行了正确的变换。一个简单的调试方法是在片元着色器中直接返回float4(i.uv2, 0, 1)作为颜色查看UV2是否在0-1范围内连续、无跳跃。5.2 动态物体与静态场景光照不融合出现“发光”或“割裂”动态物体如角色需要从静态场景中“借”光。使用光照探针这是标准解决方案。在场景中放置足够密度的Light Probe Group。确保动态物体的Renderer组件上勾选了“Use Light Probes”。在Shader中对于Surface Shader引擎会自动处理。对于自定义顶点/片元着色器你需要计算球谐光照。Unity提供了ShadeSH9函数它需要世界空间法线作为输入。// 在顶点着色器中计算球谐光照性能更好逐顶点 o.vertexSH ShadeSH9(float4(normalWorld, 1)); // 或在片元着色器中计算逐像素质量更高 float3 shColor ShadeSH9(float4(normalWorld, 1)); finalColor albedo * shColor; // 作为间接漫反射光加入光照探针效果不明显检查探针的放置密度和位置。探针应该覆盖动态物体的所有活动区域。在墙角、桌下等明暗变化剧烈的地方需要更密集的探针。同时烘焙光照时静态物体的光照信息必须“烘焙”到探针中在Lighting窗口的Light Probe设置中确认。反射探针对于光滑的金属或镜面物体还需要反射探针Reflection Probe来提供高质量的间接高光反射。其使用方式与光照探针类似Shader中通过采样unity_SpecCube0等环境立方体贴图来实现。5.3 SSAO效果闪烁、性能低下或边缘粗糙闪烁每帧使用的随机采样模式如果不变会导致静态场景产生静态噪声图案。但如果每帧变化又会导致画面闪烁。解决方案是使用时间抖动Temporal Reprojection技术将当前帧的SSAO结果与上一帧的历史缓冲区进行混合既能平滑噪声又能保留细节。但这属于高级后处理技术实现复杂。性能低下如前所述全屏、高采样次数的SSAO是性能杀手。务必在低分辨率如半分辨率或四分之一分辨率下进行计算。使用可分离的模糊如双边模糊来平滑噪声这比增加采样次数更高效。根据平台调整采样半径_SampleRadius和采样次数_SampleCount。移动端可能只用4-8个采样点和一个较大的模糊半径。边缘粗糙“毛边”这是深度不连续处如物体边缘的典型问题。双边模糊Bilateral Blur是解决此问题的标准方案。它在模糊时不仅考虑像素的空间距离还考虑深度或法线的相似性从而在保留物体边缘的同时平滑内部区域的噪声。在实现模糊Pass时需要同时采样颜色缓冲和深度/法线缓冲。5.4 Shader变体爆炸与编译优化当你使用#pragma multi_compile或#pragma shader_feature来支持多种光照模式如平行光、点光源、烘焙光、实时光等时会产生大量的Shader变体。一个复杂的Shader可能有成千上万个变体导致构建时间变长、内存占用增加。策略性使用变体仔细评估哪些功能是互斥的、哪些是平台必需的。使用multi_compile用于需要所有变体的情况如不同光源类型使用shader_feature用于仅在材质中启用时才编译的情况如某些特效开关。使用Shader LOD为Shader设置不同的LOD级别并为低级别编写简化版本。在性能不足的设备上自动切换到低LOD的变体。剔除无用变体在Unity的Graphics Settings中可以设置项目不需要的变体例如如果你的项目是移动端可以剔除桌面级的高精度变体。调试Shader时一个非常有效的方法是将中间变量可视化。例如将法线、深度、光照贴图UV、SSAO因子等输出为颜色。在Unity中你可以创建一个简单的Unlit Shader在片元着色器中return float4(normalWorld * 0.5 0.5, 1.0);来查看世界法线。这能帮你快速定位问题是出在数据输入、计算过程还是最终合成阶段。