1. 项目概述从基础光照到复杂光照的跨越如果你已经跟着《Unity Shader入门精要》走过了前两章掌握了基本的顶点/片元着色器结构和简单的漫反射、高光模型那么恭喜你你已经推开了Shader世界的第一扇门。但当你合上书想自己做一个看起来“像样”的材质时可能会立刻感到手足无措——为什么我的金属球看起来像塑料为什么场景里的物体没有影子为什么远处的物体和近处的物体光照强度看起来一样假这些问题都指向了我们即将深入的这个核心领域复杂光照。“复杂光照”这个标题听起来有点吓人但它并不是指我们要去发明什么新的物理定律。恰恰相反它指的是将那些在真实世界中本就存在、但在我们之前的基础学习中为了简化而被忽略的光照现象通过Shader技术一一模拟出来。这包括了光线如何在不同表面光滑的金属、粗糙的布料上以不同方式反射包括了物体之间如何相互遮挡光线形成阴影也包括了光线在空气中传播时发生的衰减。学习这部分内容就像是给你的渲染工具箱里添置了一套更专业、更精密的工具让你有能力去刻画一个更具深度和真实感的虚拟世界。本笔记将围绕《Unity Shader入门精要》中“复杂光照”相关的章节结合我多年在项目中的实际应用经验为你拆解其中几个最关键的技术模块标准光照模型Blinn-Phong vs. Phong、阴影技术Shadow Mapping、以及光照衰减Light Attenuation。我不会照本宣科地复述公式而是会重点解释每个技术“为什么”要这么做在Unity里“怎么”去实现以及在实际项目中可能会“踩到哪些坑”。无论你是想提升自己场景的视觉品质还是为面试准备渲染相关的八股文相信这篇结合了理论、代码与实战心得的笔记都能给你带来实实在在的帮助。2. 核心光照模型解析Blinn-Phong的登场与优势在基础篇中我们接触的通常是Phong光照模型它把镜面反射高光计算为视线方向V关于法线N的反射方向R与光源方向L的点积的幂次方。这个模型直观但在计算反射方向R时需要用到reflect函数在某些移动平台或低端GPU上这个计算开销是值得优化的点。2.1 Blinn-Phong模型的智慧Blinn-Phong模型是Phong模型的一个高效改进版本由Jim Blinn提出。它的核心智慧在于引入了一个新的中间向量半程向量 H。H是光线方向L和视线方向V的角平分线向量即H normalize(L V)。那么镜面反射光强就变成了法线N与半程向量H的点积的幂次方。从物理直觉上理解当表面完美地将光线反射到人眼时法线N恰好就是L和V的角平分线即与H同向。此时N与H点积为1高光最强。表面越粗糙反射光越分散N与H的夹角可能越大点积越小高光越弱。为什么说它更高效对比一下计算量Phong需计算反射方向R reflect(-L, N)然后计算dot(R, V)。reflect函数内部包含点积和乘法运算。Blinn-Phong只需计算H normalize(L V)然后计算dot(N, H)。normalize和加法运算通常比reflect更廉价。在大多数情况下尤其是当光源和相机距离物体都较远时即L和V可视为恒定Blinn-Phong 在视觉结果上与 Phong 非常接近但计算开销更小。这就是它成为许多实时渲染框架包括Unity内置的标准着色器在某些情况下的简化计算中常用模型的原因。2.2 在Shader中的实现对比让我们写一段代码来直观感受。假设我们已经有了世界空间下的法线worldNormal、光源方向worldLightDir、视线方向worldViewDir以及高光强度_Specular、高光指数_Gloss。Phong 镜面反射计算// 计算反射方向注意reflect函数要求入射方向指向交点所以我们传入 -L float3 reflectDir reflect(-worldLightDir, worldNormal); // 计算Phong高光 float specular pow(max(0, dot(reflectDir, worldViewDir)), _Gloss) * _Specular;Blinn-Phong 镜面反射计算// 计算半程向量 float3 halfDir normalize(worldLightDir worldViewDir); // 计算Blinn-Phong高光 float specular pow(max(0, dot(worldNormal, halfDir)), _Gloss) * _Specular;注意这里有一个非常重要的细节Blinn-Phong模型中的高光指数_Gloss与 Phong 模型中的_Gloss其数值范围代表的物理意义不同。因为dot(N, H)的值域特性要达到相似的视觉高光“锐利”程度Blinn-Phong 通常需要比 Phong 大得多的_Gloss值通常是2到4倍。这是一个常见的调参坑直接套用Phong的参数会让高光看起来非常“糊”。实操心得在移动端项目或对性能敏感的场景中我会优先尝试使用Blinn-Phong。但在艺术效果要求极高、需要精确控制高光形状例如模拟某些特殊的BRDF材质时Phong模型或更现代的基于物理的渲染PBR模型仍是必要的。理解两者的区别和联系是灵活运用的基础。3. 阴影技术深入Shadow Mapping的原理与实现细节没有阴影的世界是漂浮而不真实的。在实时渲染中Shadow Mapping阴影映射是应用最广泛的阴影技术。它的原理不复杂但实现起来细节颇多也是面试中高频出现的渲染考点。3.1 Shadow Mapping 的核心两步走Shadow Mapping 的本质是一种“从光源角度看谁被遮挡了”的算法。它分为两大步骤第一步深度图渲染从光源视角我们把相机摆放到光源的位置看向整个场景然后渲染一张“深度纹理图”。这张图里记录的不是颜色而是从该光源视角出发到达每个像素的最近物体的深度值Z值。可以把它想象成光源给场景拍的一张特殊的“地形高度图”。在Unity中这通常通过设置一个Light组件为Shadow类型并指定Shadow Map分辨率来完成Unity引擎会帮我们自动完成这张深度图的渲染。但如果要自己实现例如用于特殊效果的定制阴影就需要用到渲染到纹理Render to Texture, RTT技术使用一个Camera绑定到光源将其depthTextureMode设置为DepthTextureMode.Depth并将渲染结果存到一张RenderTexture中。第二步阴影比较从相机视角当我们从玩家相机视角正常渲染场景时对于屏幕上的每一个像素点即一个世界空间位置P我们需要做一次判断变换到光源空间将点P从世界坐标变换到第一步中那个光源相机的投影空间坐标得到其在该光源“深度图”上的对应纹理坐标 (u, v) 和深度值currentDepth。采样与比较用这个 (u, v) 去采样第一步生成的深度图得到之前存储的shadowMapDepth。判定阴影如果currentDepth shadowMapDepth bias说明点P在光源看来前面有更近的物体挡住了它那么它就应该处于阴影中。否则它被照亮。这里的bias深度偏移是一个至关重要的参数用于解决由深度图精度有限引起的“阴影痤疮”Shadow Acne问题。3.2 深度偏移Bias与阴影痤疮“阴影痤疮”是自阴影表面上出现的难看的条纹状阴影。其根源是从光源视角渲染深度图时深度值是以离散的纹素Texel为单位存储的。当从相机视角回算currentDepth时由于数值精度误差和插值同一个表面上的点其计算出的深度值可能略大于深度图中存储的对应值。这就导致currentDepth shadowMapDepth的条件意外成立使得本该被照亮的部分表面错误地判定为自己遮挡了自己产生了阴影条纹。解决方案就是引入一个小的偏移量bias常量Bias直接加上一个固定小值。简单但可能造成“彼得潘效应”阴影与物体本体分离。法线相关Bias根据表面法线与光源方向的夹角动态调整Bias。夹角越大表面越倾斜于光源潜在的精度问题越严重需要更大的Bias。公式常为bias max(0.05 * (1.0 - dot(N, L)), 0.005)。这种方法更精确能有效减少彼得潘效应。在Unity的Surface Shader或URP/HDRP管线中这些Bias计算通常由引擎内部处理并通过_ShadowBias等参数暴露给开发者调节。但在手动实现Shadow Map的Shader中必须仔细处理。常见问题排查阴影边缘锯齿严重Aliasing原因是Shadow Map分辨率不足。一个纹素覆盖了屏幕上的大片区域。解决方案提高Shadow Map分辨率使用PCFPercentage-Closer Filtering进行软阴影滤波。阴影远处闪烁或消失Cascade Shadow Maps, CSM对于方向光其照射范围无限大一张固定分辨率的深度图无法兼顾近处细节和远处覆盖。需要使用级联阴影映射CSM将视锥体按深度分成数段每段使用独立的、不同分辨率的Shadow Map。Unity的方向光阴影设置中就有“Cascade Count”和“Cascade Splits”的配置。透明物体不投射阴影默认的深度图渲染不写入半透明物体的深度。如果需要可能需要为透明物体单独编写一个Shadow Caster Pass并小心处理Alpha Test和Alpha Blend。4. 光照衰减与光源范围让光照具有空间感在基础光照中我们常常假设光源强度是恒定的无论物体离光源多远。这显然不符合物理现实。在复杂光照中我们必须引入光照衰减。4.1 衰减的物理模型与近似在物理上点光源的光照强度遵循平方反比定律Intensity I0 / (d * d)其中I0是光源初始强度d是距离。这意味着距离增加一倍光照强度减弱到四分之一。但在实时渲染中严格的平方反比衰减有两个问题1) 计算涉及除法开销相对大2) 强度衰减过快在距离光源较远但仍在视觉范围内时可能已经暗到看不见而场景中其他部分又需要照明。因此Unity以及许多其他引擎采用了一个基于范围的、可配置的衰减模型。通常使用一个二次多项式来近似atten 1.0 / (lightAttenCoef.x lightAttenCoef.y * d lightAttenCoef.z * d * d)其中lightAttenCoef是光源的衰减系数。对于点光源和聚光灯Unity会根据你在Light组件上设置的Range参数自动计算一组系数使得在Range处衰减为0在光源中心处衰减为1中间平滑过渡。4.2 在Shader中应用衰减在Forward Rendering前向渲染路径下我们通常在片元着色器中计算衰减。Unity为我们提供了一些内置函数和变量来简化这个过程。对于点光源/聚光灯在ForwardAdd Pass中处理// 计算世界空间下的距离 float3 lightVec _WorldSpaceLightPos0.xyz - worldPos; float distance length(lightVec); // 使用Unity内置宏计算衰减和光照方向 // UNITY_LIGHT_ATTENUATION 宏会综合考虑距离衰减、聚光灯角度衰减如果有和阴影衰减如果开启 UNITY_LIGHT_ATTENUATION(atten, i, worldPos); // 最终的光照颜色需要乘以衰减系数 float3 diffuse _LightColor0.rgb * albedo * saturate(dot(worldNormal, lightDir)) * atten; float3 specular ... * atten; // 高光部分同样要乘衰减关键点解析_WorldSpaceLightPos0对于平行光其.w分量为0前xyz代表方向对于点光源/聚光灯其.w分量为1前xyz代表世界空间位置。UNITY_LIGHT_ATTENUATION这是一个非常实用的宏。它会根据渲染路径和光源类型自动展开成正确的衰减计算代码并且已经包含了阴影衰减如果该片元在阴影中的话atten会被乘上阴影因子可能为0。这保证了我们计算的光照结果和阴影是统一的。衰减与阴影的顺序注意是先有光照衰减距离和角度再在这个基础上乘以阴影因子。逻辑是即使一个点没有被遮挡不在阴影中如果它离光源太远光照本身也应该很弱甚至没有。实操心得与性能考量 在移动端大量逐像素计算衰减和复杂光照可能是性能瓶颈。常见的优化策略包括使用烘焙光照Baked Lighting对于静态物体和静态光源将光照包括间接光和阴影提前计算并“烘焙”到光照贴图Lightmap或光照探针Light Probe中。运行时几乎零开销。简化衰减计算对于重要性不高的辅助光源可以使用线性衰减甚至常数衰减来代替二次衰减。善用光源范围Range严格设置光源的影响范围避免让Shader对远处根本看不见的物体进行无效的光照计算。Unity的裁剪系统会基于此进行优化。** deferred rendering**对于复杂动态光照场景延迟渲染Deferred Rendering路径将光照计算延迟到屏幕空间其开销与屏幕像素数和光源数相关而与场景几何复杂度解耦更适合大量光源的场景。但URP移动端默认是前向渲染需要根据项目需求权衡。5. 整合实战编写一个支持阴影和衰减的Blinn-Phong着色器理论说了这么多我们动手写一个完整的、支持平行光阴影和点光源衰减的Surface ShaderUnity的传统着色器语法易于理解流程并对比Built-in RP内置渲染管线中的一些关键点。5.1 着色器代码结构与解析Shader Custom/ComplexLightingBlinnPhong { Properties { _Color (Main Color, Color) (1,1,1,1) _MainTex (Base (RGB), 2D) white {} _Specular (Specular Color, Color) (1,1,1,1) _Gloss (Gloss, Range(1.0, 256)) 20 } SubShader { Tags { RenderTypeOpaque } // Pass 1: 基础平行光支持阴影投射与接收 Pass { Tags { LightModeForwardBase } // 标识此Pass处理主平行光和光照贴图等环境光 CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase // 编译平行光阴影相关变体 #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc // 包含阴影宏如SHADOW_COORDS, TRANSFER_SHADOW, SHADOW_ATTENUATION struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; SHADOW_COORDS(3) // 声明阴影坐标插值器使用TEXCOORD3 }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; fixed4 _Specular; float _Gloss; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; TRANSFER_SHADOW(o); // 计算并将阴影坐标从顶点传递到片元 return o; } fixed4 frag (v2f i) : SV_Target { // 准备向量 fixed3 worldNormal normalize(i.worldNormal); fixed3 worldLightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 fixed3 worldViewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 计算半程向量 (Blinn-Phong) fixed3 halfDir normalize(worldLightDir worldViewDir); // 采样纹理 fixed3 albedo tex2D(_MainTex, i.uv).rgb * _Color.rgb; // 环境光 fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; // 漫反射 (Lambert) fixed diff max(0, dot(worldNormal, worldLightDir)); fixed3 diffuse _LightColor0.rgb * albedo * diff; // 镜面反射 (Blinn-Phong) fixed nh max(0, dot(worldNormal, halfDir)); fixed spec pow(nh, _Gloss); fixed3 specular _LightColor0.rgb * _Specular.rgb * spec; // 计算阴影衰减 fixed shadow SHADOW_ATTENUATION(i); // 最终颜色 (平行光无距离衰减只有阴影衰减) fixed3 lighting ambient (diffuse specular) * shadow; return fixed4(lighting, 1.0); } ENDCG } // Pass 2: 附加逐像素光源点光源/聚光灯 Pass { Tags { LightModeForwardAdd } Blend One One // 叠加混合模式将附加光效果加到Base Pass的结果上 CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdadd // 编译附加光源点光/聚光相关变体 #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc // ... 结构体和变量定义与Base Pass类似此处省略以节省篇幅 ... v2f vert (appdata v) { v2f o; // ... 变换代码类似 ... // 注意附加光源Pass通常不需要计算阴影因为实时多光源阴影开销极大通常只为平行光开启。 // 所以这里没有TRANSFER_SHADOW return o; } fixed4 frag (v2f i) : SV_Target { // 准备向量 fixed3 worldNormal normalize(i.worldNormal); // 计算光源方向区分平行光和其他光源 #ifdef USING_DIRECTIONAL_LIGHT fixed3 worldLightDir normalize(_WorldSpaceLightPos0.xyz); #else fixed3 worldLightDir normalize(_WorldSpaceLightPos0.xyz - i.worldPos); #endif fixed3 worldViewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); fixed3 halfDir normalize(worldLightDir worldViewDir); fixed3 albedo tex2D(_MainTex, i.uv).rgb * _Color.rgb; // 漫反射 fixed diff max(0, dot(worldNormal, worldLightDir)); fixed3 diffuse _LightColor0.rgb * albedo * diff; // 镜面反射 fixed nh max(0, dot(worldNormal, halfDir)); fixed spec pow(nh, _Gloss); fixed3 specular _LightColor0.rgb * _Specular.rgb * spec; // 关键使用UNITY_LIGHT_ATTENUATION计算衰减包含距离衰减和聚光灯衰减 UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 附加光贡献 (漫反射 镜面反射) * 衰减 fixed3 addLighting (diffuse specular) * atten; return fixed4(addLighting, 1.0); } ENDCG } // Pass 3: 阴影投射Pass用于让物体能投射阴影 UsePass Legacy Shaders/VertexLit/SHADOWCASTER } FallBack Diffuse }5.2 代码关键点与避坑指南多Pass协作这个Shader包含三个Pass。ForwardBasePass处理最重要的平行光和环境光并接收阴影。ForwardAddPass以叠加Blend One One方式处理每个额外的逐像素光源。ShadowCasterPass这里直接复用内置的负责在渲染阴影贴图时将物体的深度信息写入深度图即投射阴影。缺少任何一个阴影功能都不完整。#pragma multi_compile这些编译指令至关重要。multi_compile_fwdbase和multi_compile_fwdadd会让Unity为不同的光照和阴影配置如是否开启阴影、光源类型生成多个着色器变体。如果不写阴影相关宏如SHADOW_ATTENUATION可能无法正确工作。阴影坐标的传递在Base Pass中SHADOW_COORDS(3)声明了一个用于插值阴影纹理坐标的变量。TRANSFER_SHADOW(i)在顶点着色器中计算这个坐标。SHADOW_ATTENUATION(i)在片元着色器中采样阴影贴图并返回衰减因子1.0表示完全照亮0.0表示完全在阴影中。这个过程封装了光源空间变换、深度比较和PCF滤波等复杂操作。衰减宏的便利性在Add Pass中UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos)一行代码就完成了根据光源类型平行/点/聚光计算距离衰减、聚光灯角度衰减的复杂工作极大地简化了代码。FallBack的重要性FallBack Diffuse指定了当当前SubShader不被支持时的备用着色器。更重要的是备用着色器中的ShadowCasterPass 会被当前着色器使用。这就是为什么我们代码中UsePass那一行可以生效的原因。没有正确的FallBack物体可能无法投射阴影。常见问题排查物体不投射阴影检查是否包含了ShadowCasterPass或者FallBack的着色器是否包含有效的ShadowCaster。检查物体的MeshRenderer的Cast Shadows属性是否开启。物体不接收阴影确保接收阴影的物体所在的Layer在光源的Shadow Culling Mask中。确保Base Pass正确编写了阴影接收逻辑使用了SHADOW_COORDS,TRANSFER_SHADOW,SHADOW_ATTENUATION。附加光源点光源没有效果检查点光源的Render Mode是否为Important逐像素渲染。Auto模式可能因为性能优化被降级为逐顶点或烘焙。检查Add Pass的LightMode是否为ForwardAdd以及混合模式是否正确。阴影边缘有锯齿或闪烁尝试提高光源的Shadow Resolution。在质量设置Quality Settings中启用Soft Shadows这通常会使用PCF滤波。对于方向光考虑使用Cascaded Shadow Maps并调整级联分割距离以适应你的场景视距。6. 从Built-in RP到URP复杂光照实现的变迁随着Unity现代渲染管线URP/HDRP的普及很多内置管线的知识需要做一次“映射”。理解其中的变化能帮你更快适应新的开发环境。6.1 核心概念的对齐着色器语言从Surface Shader/传统CG/HLSL转向HLSL在Shader Graph中可视化或直接编写.shader文件。Surface Shader的抽象层被移除你需要更直接地控制顶点/片元着色器、Pass结构和光照循环。光照流程内置管线中ForwardBase和ForwardAddPass的职责划分非常明确。在URP中光照是在一个单Pass前向渲染Single-Pass Forward中完成的。URP提供了一个可编程的渲染管线光照计算被封装在UniversalForwardPass中并通过Lighting.hlsl库函数如LightingLambert,LightingSpecular或更现代的BRDF函数来处理。阴影与衰减核心原理不变Shadow Mapping。但API变了。在URP Shader中你通过SHADOWMAP相关的宏和函数如SampleShadowmap来访问阴影。光照衰减和颜色信息通过Light结构体GetMainLight,GetAdditionalLight提供这些函数会自动处理光源范围、衰减和阴影。6.2 一个URP下的Blinn-Phong示例片段假设你正在编写一个URP的Unlit Shader或自定义Lit Shader在片元着色器中计算主平行光的光照可能如下所示// 在URP Shader中通常需要包含以下核心库 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl ... void frag(Varyings IN, out half4 outColor : SV_Target) { // 获取表面数据来自顶点着色器或纹理 float3 albedo ...; float3 normalWS normalize(IN.normalWS); float3 positionWS IN.positionWS; // 1. 获取主光源信息包含方向、颜色、距离衰减、阴影衰减 Light mainLight GetMainLight(TransformWorldToShadowCoord(positionWS)); // 传入阴影坐标 float3 lightColor mainLight.color; float3 lightDir mainLight.direction; float attenuation mainLight.distanceAttenuation * mainLight.shadowAttenuation; // 合并距离衰减和阴影衰减 // 2. 计算视线方向 float3 viewDirWS GetWorldSpaceNormalizeViewDir(positionWS); // 3. 计算半程向量 (Blinn-Phong) float3 halfDir normalize(lightDir viewDirWS); // 4. 光照计算 float NdotL saturate(dot(normalWS, lightDir)); float NdotH saturate(dot(normalWS, halfDir)); float gloss _Gloss; // 你的光滑度参数 float3 diffuse lightColor * albedo * NdotL; float3 specular lightColor * _Specular.rgb * pow(NdotH, gloss); // 5. 应用衰减包含了阴影 float3 directLighting (diffuse specular) * attenuation; // 6. 加上环境光/间接光这里简化处理 float3 ambient SampleSH(normalWS) * albedo; // 采样球谐光照作为环境光 outColor half4(ambient directLighting, 1.0); }迁移注意事项阴影坐标转换在URP中GetMainLight函数需要传入一个阴影坐标这个坐标需要通过TransformWorldToShadowCoord(positionWS)函数从世界空间位置转换而来。这替代了内置管线中的SHADOW_COORDS/TRANSFER_SHADOW流程。光照数据封装Light结构体整洁地封装了光源的所有信息包括阴影衰减(shadowAttenuation)和距离衰减(distanceAttenuation)直接相乘即可得到总衰减。渲染管线资产配置URP的光影质量如阴影分辨率、级联数量、软硬阴影不再完全由单个光源控制而是在URP Asset配置文件中进行全局设置并在每个光源上选择Soft或Hard阴影模式。从内置管线到URP是一个从“黑盒”到“更可控的白盒”的过程。虽然初期需要重新学习一套API但它带来了更好的性能、更清晰的架构和更强的跨平台一致性。理解复杂光照的基本原理是顺利过渡的基石。当你明白了Blinn-Phong、Shadow Mapping和光照衰减的本质后无论API如何封装你都能迅速找到对应的实现方式并有效地进行调试和优化。