Unity前向渲染与多光源Shader实战:从平行光到聚光灯的完整实现
1. 项目概述:从“照亮”到“塑造”的进阶之路
刚接触Unity Shader那会儿,觉得能写个漫反射让模型亮起来,就算入门了。直到开始做稍微复杂点的场景,比如一个同时有手电筒、路灯和全局环境光的室内,问题就来了:为什么我的角色在阳光下和在手电筒下,看起来质感完全不对?为什么有些光源下的阴影特别“脏”?这些困惑,其实都指向了光照计算中两个最核心、也最容易被新手忽略的基石:光源类型的精确区分与前向渲染路径的底层逻辑。今天要聊的,就是如何从“照亮模型”的初级阶段,跃升到“用不同类型的光源精准塑造视觉感受”的实战层面。这不仅是技术实现,更是理解游戏画面如何“呼吸”的关键。
对于任何想在Unity中实现高质量实时画面的开发者来说,无论是独立游戏还是移动端应用,吃透光源类型和前向渲染,就等于掌握了画面表现的“语法”。它决定了你的场景在不同光照条件下的视觉一致性、性能开销以及最终的艺术表现力。我们将从最基础的平行光、点光源、聚光灯的特性与Shader实现差异讲起,然后深入到Unity前向渲染路径(Forward Rendering)的内部机制,看看Unity是如何智能地管理多光源,以及我们如何在Shader中配合这一机制,写出既高效又高质量的光照代码。理解这些,你就能自己调试出那些“看起来不对劲”的光影,甚至为特定艺术风格定制光照方案。
2. 光源类型深度解析:不只是亮度的区别
在Shader的世界里,光源远不止是场景中的一个发光体。每一种光源类型,都对应着一套独特的数学描述和渲染逻辑。混淆它们,你的光照计算就会失之毫厘,谬以千里。
2.1 平行光:无限远的太阳
平行光是最简单,也最特殊的一种。它没有位置,只有方向。在Shader中,我们通常这样定义和计算:
// 在Shader的Properties或通过脚本传递 uniform float3 _WorldSpaceLightPos0; // 对于平行光,.xyz是方向(已取反),.w为0 uniform float4 _LightColor0; // 光源颜色和强度 // 计算光照方向(到光源的方向) float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); // 对于平行光,lightDir是常量,与顶点位置无关它的核心特点是:
- 方向恒定:场景中所有物体接收到的光线方向是一致的,这极大地简化了计算。
- 无衰减:理论上,平行光强度不随距离改变,就像真实的太阳光(在大气层外)。
- 主要用途:模拟日光、月光等全局性方向光源。它是场景的主光源,通常负责投射最清晰的主阴影。
注意:在Unity的前向渲染中,最亮的那一个平行光会被当作“主平行光”(Main Directional Light),享受最全面的光照计算和阴影投射。其他平行光则可能被降级处理。
2.2 点光源:空间中的灯泡
点光源模拟的是从一个点向所有方向均匀发光的灯泡。它的计算引入了距离衰减,这是与平行光最本质的区别。
计算光照方向与衰减的典型代码如下:
// 假设通过脚本将点光源位置传递到Shader float3 _PointLightPosition; float _PointLightRange; // 光源影响范围 // 在片段着色器中计算 float3 fragWorldPos = mul(unity_ObjectToWorld, float4(i.pos, 1.0)).xyz; float3 toLight = _PointLightPosition - fragWorldPos; float distance = length(toLight); float3 lightDir = normalize(toLight); // 关键:距离衰减计算。Unity内置了多种衰减函数,这里是一个简化的平滑衰减 float attenuation = saturate(1.0 - distance / _PointLightRange); attenuation *= attenuation; // 或使用更平滑的二次衰减这里有几个关键点:
- 光照方向:需要从当前片段的世界坐标指向光源位置,因此每个片段都不同。
- 衰减函数:
attenuation是核心。简单的线性衰减(1 - distance/range)会有明显的边界感。通常使用二次衰减(attenuation * attenuation)或Unity内置的Unity_SoftShadow相关函数来获得更平滑自然的过渡。 - 范围:
_PointLightRange定义了光源的影响半径,超出范围的片段将不受该光源影响,这是重要的性能优化手段。
2.3 聚光灯:舞台的追光
聚光灯是最复杂的光源,它结合了点光源的位置属性和平行光的方向属性,并增加了角度衰减(也叫聚光灯锥角)。
其计算步骤更多:
float3 _SpotLightPosition; float3 _SpotLightDirection; // 聚光灯照射方向 float _SpotLightRange; float _SpotLightAngle; // 聚光灯锥角(弧度或角度,需统一) float _SpotLightInnerCone; // 内锥角,完全亮度区域 float3 toLight = _SpotLightPosition - fragWorldPos; float distance = length(toLight); float3 lightDir = normalize(toLight); // 1. 距离衰减(同点光源) float distanceAtt = saturate(1.0 - distance / _SpotLightRange); distanceAtt *= distanceAtt; // 2. 角度衰减:计算光线方向与聚光灯方向的夹角 float cosTheta = dot(lightDir, normalize(-_SpotLightDirection)); // 注意方向取反 float spotEffect = smoothstep(cos(_SpotLightAngle * 0.5), cos(_SpotLightInnerCone * 0.5), cosTheta); // 3. 综合衰减 float attenuation = distanceAtt * spotEffect;角度衰减的逻辑:dot(lightDir, -lightDirection)得到的是夹角的余弦值。我们通过smoothstep函数,在内锥角(_SpotLightInnerCone)内,衰减为1(完全亮度);从内锥角到外锥角(_SpotLightAngle),亮度平滑衰减到0。这创造了一个有柔和边缘的光锥。
2.4 光源类型选择的实战考量
理解了数学原理,在实际项目中如何选择?
- 性能:平行光最省,点光源次之,聚光灯最耗。因为聚光灯每个片段都需要计算两次衰减(距离+角度)和一次方向点乘。
- 视觉需求:
- 需要均匀的全局照明(如户外) ->平行光。
- 需要局部、有体积感的光源(如灯泡、爆炸) ->点光源。注意调整衰减参数,避免“硬边”。
- 需要方向性强的局部照明(如手电筒、车灯、舞台灯) ->聚光灯。精心调整内外锥角,内锥角决定硬光斑大小,外锥角决定柔光晕范围。
- 一个常见误区:试图用点光源模拟聚光灯效果。虽然可以通过在Shader中手动添加方向性衰减来近似,但无法获得聚光灯的投影锥形阴影(如果需要的话),且计算控制不如原生聚光灯精确。
3. 前向渲染路径核心机制剖析
知道了怎么算单个光源,但一个场景动辄几十上百个光源,GPU难道要为每个物体、每个片段都计算所有光源吗?当然不是。这就是渲染路径要解决的问题。前向渲染(Forward Rendering)是Unity的默认渲染路径,它的核心思想是:逐物体、逐片段地处理光照,但对光源进行重要性分级,区别对待。
3.1 前向渲染的基本流程
对于场景中的每个物体,前向渲染大致遵循以下步骤:
- 设置渲染状态:绑定物体的材质(Shader)、纹理、网格等。
- 执行顶点着色器:处理顶点变换、计算顶点光照等(如果使用顶点光照)。
- 执行片段(像素)着色器:这是核心。对于屏幕上的每一个像素(对应物体的一个片段):
- Unity会根据一套规则,决定哪些光源需要对该片段进行完整的逐像素光照计算。
- 对选中的光源,依次计算其漫反射、高光等贡献,并累加到最终颜色中。
- 混合与输出:将计算好的像素颜色与帧缓冲区中已有的颜色进行混合(处理透明度等),然后输出。
3.2 Unity的光源处理模式:Quality vs Performance
Unity不会对每个物体计算所有光源。它根据光源的强度和与物体的距离,将光源分为三类:
| 光源模式 | 处理方式 | 性能开销 | 适用场景 |
|---|---|---|---|
| 逐顶点 (Vertex) | 光照计算在顶点着色器中进行,结果在片段间插值。 | 最低 | 强度很低的次要光源,或对性能极度敏感的移动端低端机。效果粗糙,无逐像素高光。 |
| 逐像素 (Pixel) | 光照计算在片段着色器中进行,精度最高。 | 高 | 最重要的几个光源(如主平行光、靠近玩家的关键点光源)。支持完整的法线贴图、高光反射、阴影。 |
| 球谐光照 (Spherical Harmonics, SH) | 用一组预计算的系数来模拟低频环境光照和远处光源。 | 极低(预计算) | 处理大量的、微弱的、远处的影响整体环境的光源(如天空盒间接光、很多微弱的填充光)。只能表现非常平滑的光照变化。 |
Unity的自动分配规则(可在Project Settings -> Graphics中调整):
- 最亮的那个平行光永远是逐像素处理的(主光源)。
- 然后,Unity会有一个“逐像素光源数量”预算(例如,默认是4个)。它会尝试将场景中最重要的几个光源(通过强度和距离综合判断)标记为逐像素光源。
- 剩余的光源,如果其渲染模式(Render Mode)被设置为“Important”,Unity会尽力将其作为逐像素光源处理(可能挤掉其他光源)。如果设置为“Not Important”,则很可能被降级为逐顶点或球谐光照处理。
- 再剩下的、非常微弱或很远的光源,其漫反射部分会被烘焙到球谐光照中。
3.3 在Shader中配合前向渲染:ForwardBase与ForwardAdd
这是理解前向渲染Shader编写的关键。一个标准的多光源前向渲染Shader通常包含两个独立的Pass:
第一个Pass: ForwardBase Pass
Pass { Tags { "LightMode"="ForwardBase" } // 关键标签 // ... 渲染状态设置 ... CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase // 关键编译指令,用于包含主光源阴影等宏 // 这个Pass只处理: // 1. 主平行光(逐像素) // 2. 环境光(unity_AmbientSky) // 3. 光源的SH(球谐光照)部分 // 4. 烘焙光照贴图(如果存在) // 它**不**处理其他额外的逐像素光源。 ENDCG }这个Pass负责渲染一次“基础光照”,包含了最重要的环境信息和主光源。#pragma multi_compile_fwdbase指令会让Unity根据场景情况(是否有阴影、是否使用光照贴图等)自动生成多个Shader变体。
第二个Pass: ForwardAdd Pass
Pass { Tags { "LightMode"="ForwardAdd" } // 关键标签 Blend One One // 关键混合模式!将光照结果叠加到ForwardBase的结果上 ZWrite Off // 通常关闭深度写入,避免深度冲突 // ... 渲染状态设置 ... CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdadd // 关键编译指令,用于处理不同类型(平行/点/聚)的附加光源 // 这个Pass会被为**每个**需要逐像素处理的附加光源调用一次! // 每次调用只计算当前这一个光源的贡献。 // 通过 Blend One One 进行累加。 ENDCG }这是前向渲染多光源的魔法所在。Tags {“LightMode”=“ForwardAdd”}告诉Unity这个Pass用于附加光源。Blend One One是灵魂,它使用加法混合,使得每个附加光源的计算结果都能叠加到之前的颜色上。ZWrite Off是因为深度信息在Base Pass已经写入,这里只需要叠加颜色。
一个极其重要的陷阱:如果你在ForwardAdd Pass里错误地计算了环境光或主光源,或者没有使用正确的混合模式,会导致光照被重复计算或覆盖,画面严重过曝或错误。
4. 实战:编写一个支持多光源的前向渲染Shader
理论说得再多,不如动手写一遍。我们来搭建一个支持平行光、点光源、聚光灯,并遵循Unity前向渲染规范的基础Blinn-Phong着色器。
4.1 基础结构定义与Properties
首先,定义Shader的基本属性和子着色器标签。
Shader "Custom/ForwardRenderingExample" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Color Tint", Color) = (1,1,1,1) _Gloss ("Gloss", Range(8.0, 256)) = 20 _SpecularIntensity ("Specular Intensity", Range(0,1)) = 0.5 } SubShader { // 前向渲染需要的不透明物体Tags Tags { "RenderType"="Opaque" } LOD 100 // Pass 1: ForwardBase Pass // Pass 2: ForwardAdd Pass (见下文) } FallBack "Specular" // 重要的回退Shader,用于阴影投射等 }4.2 实现ForwardBase Pass
这个Pass的顶点/片段着色器主要处理主平行光。
// ForwardBase Pass Pass { Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag_base // 注意函数名,与Add Pass区分 #pragma multi_compile_fwdbase #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" // 为了阴影宏 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; float _Gloss; float _SpecularIntensity; 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_base (v2f i) : SV_Target { // 准备向量 float3 worldNormal = normalize(i.worldNormal); float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); // 主光源方向 float3 worldViewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float3 halfDir = normalize(worldLightDir + worldViewDir); // 采样纹理 fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; // 计算主平行光漫反射 (Lambert) fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir)); // 计算主平行光高光 (Blinn-Phong) fixed3 specular = _LightColor0.rgb * _SpecularIntensity * pow(max(0, dot(worldNormal, halfDir)), _Gloss); // 计算阴影衰减 fixed shadow = SHADOW_ATTENUATION(i); // 环境光(Unity内置) fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; // 最终颜色 = 环境光 + (漫反射+高光) * 阴影 fixed3 color = ambient + (diffuse + specular) * shadow; return fixed4(color, 1.0); } ENDCG }这个Pass计算了主平行光(带阴影)、环境光,并输出了基础颜色。注意#pragma multi_compile_fwdbase和#include “AutoLight.cginc”以及SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION这一套阴影宏的使用,它们是Unity前向渲染中处理主光源阴影的标准方式。
4.3 实现ForwardAdd Pass
Add Pass的结构与Base Pass类似,但关键区别在于光照计算部分需要通用化以处理三种光源,并且混合模式不同。
// ForwardAdd Pass Pass { Tags { "LightMode"="ForwardAdd" } Blend One One // 加法混合! ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag_add // 不同的片段函数 #pragma multi_compile_fwdadd // 关键:为点光源、聚光灯生成变体 #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" // 使用与Base Pass相同的v2f结构 struct v2f { ... }; // 同上,可复用 v2f vert (appdata v) { ... } // 同上,但注意不需要计算阴影坐标(附加光源阴影更复杂,通常简化处理) // 属性变量需要再次声明(或在CGINCLUDE中共享) sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Gloss; float _SpecularIntensity; // 一个通用的计算光照衰减和方向的函数 void CalculateLightInfo_Add(float3 worldPos, out float3 lightDir, out fixed attenuation) { // 对于平行光 #ifdef USING_DIRECTIONAL_LIGHT lightDir = normalize(_WorldSpaceLightPos0.xyz); attenuation = 1.0; // 平行光无衰减 #else // 对于点光源或聚光灯 float3 toLight = _WorldSpaceLightPos0.xyz - worldPos; lightDir = normalize(toLight); float distance = length(toLight); // 使用Unity内置的衰减纹理或计算 #if defined (POINT) // 点光源衰减计算(简化版,实际可用纹理查找) attenuation = saturate(1.0 - distance / _LightPositionRange.w); // _LightPositionRange.w存储范围 attenuation *= attenuation; #elif defined (SPOT) // 聚光灯衰减计算(简化版) attenuation = saturate(1.0 - distance / _LightPositionRange.w); attenuation *= attenuation; float cosTheta = dot(lightDir, normalize(_WorldSpaceLightPos0.xyz - _LightPositionRange.xyz)); // 注意方向 float spotEffect = smoothstep(_LightSpotData.x, _LightSpotData.y, cosTheta); // _LightSpotData存储角度参数 attenuation *= spotEffect; #endif #endif } fixed4 frag_add (v2f i) : SV_Target { // 准备向量(视角方向等) float3 worldNormal = normalize(i.worldNormal); float3 worldViewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 采样纹理(漫反射颜色) fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; // 计算当前附加光源的方向和衰减 float3 lightDir; fixed atten; CalculateLightInfo_Add(i.worldPos, lightDir, atten); // 如果衰减为0(在光源范围外),提前终止计算,节省性能 if (atten <= 0) discard; float3 halfDir = normalize(lightDir + worldViewDir); // 计算当前光源的漫反射 fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, lightDir)) * atten; // 计算当前光源的高光 fixed3 specular = _LightColor0.rgb * _SpecularIntensity * pow(max(0, dot(worldNormal, halfDir)), _Gloss) * atten; // 注意:Add Pass 不计算环境光!也不计算主光源阴影(通常简化处理,或使用其他方案)。 // 只返回当前附加光源的贡献,通过 Blend One One 叠加到Base Pass的结果上。 return fixed4((diffuse + specular), 1.0); } ENDCG }这个Pass是精髓。#pragma multi_compile_fwdadd指令会自动帮我们处理平行光、点光源、聚光灯的不同变体。CalculateLightInfo_Add函数利用Unity内置的宏和变量(如_WorldSpaceLightPos0,_LightPositionRange,_LightSpotData)来统一处理三种光源的方向和衰减计算。frag_add函数只计算当前这个附加光源的贡献,并通过Blend One One叠加到画面中。
5. 常见问题、性能陷阱与调试技巧
即使理解了原理,在实际使用前向渲染和多光源时,依然会踩很多坑。下面是一些实录的问题和解决方案。
5.1 光源贡献异常:过曝、过暗或颜色错误
- 问题描述:场景中加了几个点光源后,物体亮得发白(过曝),或者某些光源似乎没起作用。
- 排查思路:
- 检查混合模式:确认ForwardAdd Pass的
Blend指令是One One(加法混合),而不是SrcAlpha OneMinusSrcAlpha(透明混合)或其他。 - 检查衰减计算:在Add Pass的片段着色器中,输出
atten值到颜色,观察衰减是否平滑,范围是否正确。点光源/聚光灯的衰减计算错误是导致贡献异常的常见原因。 - 检查光源强度:在Unity编辑器中将光源的Intensity(强度)调低试试。前向渲染中,多个光源叠加很容易导致HDR颜色值超出1.0,如果没有开启HDR或Tonemapping,就会过曝。
- 检查Shader变体:在Frame Debugger中查看绘制调用,确认物体在渲染附加光源时,使用的Shader变体是否正确(如POINT, SPOT)。
- 检查混合模式:确认ForwardAdd Pass的
5.2 性能突然下降
- 问题描述:当场景光源增多时,帧率显著下降。
- 排查与优化:
- 使用Frame Debugger或RenderDoc:这是最强大的工具。查看每一帧的绘制调用(Draw Calls)。你会看到,每个受多个逐像素光源影响的物体,都会被绘制多次(1次Base + N次Add)。这是前向渲染多光源的主要性能瓶颈。
- 控制逐像素光源数量:在
Edit -> Project Settings -> Graphics -> Tier Settings中,降低“Pixel Light Count”。或者,在代码中动态设置QualitySettings.pixelLightCount。 - 善用光源模式(Render Mode):将不重要的、只是提供氛围的填充光设置为“Not Important”,让Unity将其处理为逐顶点光或SH光。
- 使用光照剪裁(Light Culling):确保点光源和聚光灯设置了合理的
Range。超出范围的物体不会被该光源影响,从而避免不必要的Add Pass渲染。 - 考虑烘焙(Baked)或混合(Mixed)光照:对于静态物体和静态光源,使用光照贴图可以完全消除实时光照计算。这是提升性能最有效的手段之一。
5.3 阴影问题:附加光源没有阴影或阴影错误
- 问题描述:主平行光阴影正常,但点光源或聚光灯没有阴影,或者阴影异常。
- 原因与解决方案:
- 默认限制:在Unity的前向渲染路径中,默认只有主平行光可以投射实时阴影(Cascaded Shadow Maps)。这是出于性能考虑。
- 为附加光源启用阴影:
- 将光源的
Shadow Type设置为Soft Shadows或Hard Shadows。 - 在Shader的ForwardAdd Pass中,处理阴影极其复杂且性能开销大,通常需要自定义阴影贴图(如使用
UNITY_PASS_SHADOWCASTER)。对于新手,一个更实用的建议是:如果非常需要附加光源的阴影,考虑使用延迟渲染(Deferred Rendering)路径。延迟渲染天然支持多光源阴影,但有其自身的限制(如不支持真正的透明物体和MSAA)。 - 折中方案:使用屏幕空间阴影(Screen Space Shadows)或一些简化后的体积阴影(Fake Shadow)来近似表现附加光源的阴影效果。
- 将光源的
5.4 Shader变体爆炸与编译优化
- 问题描述:使用了
#pragma multi_compile_fwdbase和#pragma multi_compile_fwdadd后,Shader编译时间变长,构建后体积变大。 - 理解与应对:
- 变体是什么:这些
multi_compile指令会根据不同的关键字(如DIRECTIONAL,POINT,SPOT,SHADOWS_SCREEN等)生成多个版本的Shader代码。一个简单的Shader可能会编译出几十甚至上百个变体。 - 优化策略:
- 精确控制:使用
#pragma multi_compile代替#pragma multi_compile_fwdbase,并只列出你真正需要的变体组合。例如,如果你的游戏不需要聚光灯阴影,就不要包含相关关键字。 - 使用Shader变体收集工具:在Unity Editor中,通过
Window -> Analysis -> Shader Variant Collection可以查看和保存项目实际用到的变体,并在构建时只包含这些,能有效减少构建大小。 - 关注Shader编译日志:在Editor Log中搜索“
Shader variant log level = all shaders”相关的信息,可以了解变体生成的情况。虽然这个日志级别很详细,但在开发阶段有助于理解变体爆炸的源头。
- 精确控制:使用
- 变体是什么:这些
理解光源类型和前向渲染,就像是拿到了控制场景光影的遥控器。你知道每个按钮(平行光、点光源、聚光灯)是干什么的,也知道机器(前向渲染管线)是如何响应你按下这些按钮的。这让你从被动的“试参数”变成了主动的“设计光照”。下次当你的场景光照看起来不协调时,你不会再盲目地调整强度和颜色,而是会思考:这个光源应该用哪种类型?它应该被当作逐像素光源还是降级处理?它的衰减范围是否合理?我的Shader是否正确地处理了Base和Add Pass?这些问题的答案,都藏在这套光照系统的运行机制里。掌握了它,你离写出具有专业级画面表现力的Shader就更近了一大步。
