1. 项目概述:从基础光照到复杂光影的跨越
在Unity Shader的学习道路上,当你掌握了基础的光照模型,能够为一个立方体或球体打上简单的漫反射和高光后,往往会遇到一个现实问题:真实世界的光影远比Lambert和Blinn-Phong模型复杂得多。物体表面并非总是光滑的,光线也并非总是来自一个方向。这正是《Unity Shader入门精要》第9章“更复杂的光照”第二部分要解决的核心议题。这一部分通常不会停留在单一光源和理想材质的层面,而是深入到多光源处理、更真实的阴影技术以及复杂表面光照特性的模拟。
对于许多从入门迈向进阶的开发者而言,这一章是一个关键的分水岭。它意味着你的Shader将从“玩具Demo”级别,进化到能够处理实际游戏场景中复杂光照交互的“生产级”工具。无论是场景中同时存在的太阳、路灯、火把,还是物体表面细微的粗糙度、各向异性高光,或是角色在动态光源下投射的柔和阴影,都需要依靠这一阶段的知识来构建。理解并实现这些更复杂的光照效果,是提升游戏视觉品质和沉浸感不可或缺的一环。接下来,我将结合多年的图形开发经验,为你拆解这其中涉及的核心技术点、实现思路以及那些容易踩坑的细节。
2. 核心光照模型进阶:超越Blinn-Phong
在基础篇中,Blinn-Phong模型是我们的得力干将,但它存在明显的物理不准确性,例如能量不守恒、高光范围在掠射角下异常等问题。为了追求更高质量的画面,我们必须引入更先进的模型。
2.1 微表面理论与Cook-Torrance BRDF
现代基于物理的渲染(PBR)核心是微表面理论。该理论认为,宏观上看似平坦的表面,在微观上是由无数个朝向随机的小镜面组成的。这些微表面的法线分布决定了宏观表面的粗糙度。Cook-Torrance BRDF模型正是基于此理论,它将反射光分为漫反射和镜面反射两部分,并给出了更物理的数学描述。
其镜面反射部分通常由三个函数乘积构成:法线分布函数(D):描述微表面法线朝向的分布,决定高光的大小和形状。常见的有Beckmann、GGX(Trowbridge-Reitz)。GGX因其在边缘处产生的长尾拖影效果更接近真实材质而广为使用。几何函数(G):描述微表面间相互遮挡(阴影和遮蔽)对光线的影响,确保在粗糙表面或掠射角下高光会衰减。常见的有Smith、Schlick-GGX。菲涅尔方程(F):描述光线在不同入射角下,被反射的比率。Schlick近似是常用的高效实现。
在Shader中实现时,我们需要在片元着色器中计算这些函数。例如,GGX法线分布函数的实现代码可能如下:
float D_GGX(float NdotH, float roughness) { float a = roughness * roughness; float a2 = a * a; float NdotH2 = NdotH * NdotH; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return a2 / max(denom, 0.0000001); // 防止除零 }注意:粗糙度(roughness)参数通常需要从0到1映射到实际计算用的α(0到无穷),平方操作
a = roughness*roughness是一种常见映射,能使得粗糙度在感知上更线性。
2.2 迪士尼原则与UE4的PBR模型
完全物理正确有时并不等于“艺术正确”或“性能友好”。迪士尼动画工作室提出了一套实用的PBR原则,其核心是“直观而非物理参数”。例如,它的模型使用“金属度(Metallic)”、“粗糙度(Roughness)”、“次表面散射(Subsurface)”、“清漆(Clearcoat)”等艺术家容易理解的参数。
Unreal Engine 4的PBR模型很大程度上借鉴了迪士尼原则,并做了优化。在Unity中实现类似效果,我们通常需要编写一个Surface Shader或URP/Lit Shader的变体,在着色器中读取这些贴图(如Metallic贴图、Roughness贴图或合并的RGBA贴图),并按照迪士尼/UE4的公式组合BRDF项。一个关键技巧是,漫反射部分通常使用Lambert或Disney Diffuse,而镜面反射则使用基于GGX的Cook-Torrance模型。金属度参数用于在金属(几乎无漫反射,镜面反射颜色为底色)和非金属(有漫反射,镜面反射为白色)之间进行插值。
2.3 各向异性光照模拟
Blinn-Phong或标准的GGX高光都是各向同性的,即高光形状是圆形的。但对于拉丝金属、头发、CD光盘等表面,其高光会呈现椭圆形或条状,这就是各向异性。模拟各向异性的关键在于改变法线分布函数。
一种常见方法是在切线空间下,修改半角向量H的推导或NDF的计算。我们不仅需要法线(Normal),还需要一个额外的方向向量,通常是切线(Tangent)或副切线(Binormal),来定义表面的各向异性方向。例如,我们可以使用一个“各向异性(Anisotropy)”强度参数,在切线方向和副切线方向应用不同的粗糙度值,从而让高光在一个方向上拉伸。
在Shader中,你可能需要这样计算修改后的半角向量:
float3 H = normalize(V + L); // 将H从世界空间转换到切线空间 float3 H_tangent = mul(tangentToWorld, H); // 根据各向异性强度,对H_tangent的xy分量进行缩放 H_tangent.xy *= float2(1.0 - anisotropy, 1.0 + anisotropy) * 0.5; H_tangent = normalize(H_tangent); // 再将修改后的H转换回世界空间用于后续计算 H = mul(tangentToWorld_Inverse, H_tangent);实现各向异性效果对纹理坐标的方向很敏感,需要确保模型的UV展开或切线方向符合艺术家的预期。
3. 多光源渲染策略与Unity的渲染路径
单一方向光很容易处理,但真实场景充满多个点光源、聚光灯。如何高效、正确地将多个光源的光照贡献叠加起来,是Shader必须面对的挑战。Unity提供了不同的渲染路径来处理这个问题。
3.1 前向渲染路径详解
前向渲染是Unity的默认渲染路径。其原理是:对于场景中的每一个物体,遍历所有影响该物体的光源,为每个光源计算一次光照,并在片元着色器中叠加结果。但这在光源很多时性能开销巨大(O(物体数*光源数))。
因此Unity采用了“逐像素光”和“逐顶点光/球谐光照”的混合策略:
- 最重要的光源(如平行光):被当作“逐像素光”处理,进行完整的光照计算(包括阴影、Cook-Torrance BRDF等),效果最好。
- 其他光源:根据距离和强度,可能被降级为“逐顶点光”(在顶点着色器计算光照,在片元间插值)或更高效的“球谐光照”(用于模拟非常远或很多微弱的环境光)。你可以在片元着色器中通过
_LightColor0等内置变量访问逐像素光数据,而通过ShadeSH9或unity_LightColor等访问其他光源的近似贡献。
在Surface Shader中,你可以通过#pragma surface surf Standard fullforwardshadows指令来使用前向渲染,并通过Tags { “LightMode” = “ForwardBase” }和“LightMode” = “ForwardAdd”来区分基础通道(处理环境光+主逐像素光)和附加通道(处理其他逐像素光,混合模式为Additive)。编写自定义前向渲染Shader时,需要在附加通道的Pass中正确设置混合模式为Blend One One,以实现光强的叠加。
3.2 延迟渲染路径原理与适配
延迟渲染是为了解决前向渲染中光源数量与物体数量相乘的性能瓶颈而生的。它分为两个主要阶段:
- 几何通道(G-Buffer阶段):遍历所有不透明物体,但不计算光照。而是将物体的表面信息(位置、法线、漫反射颜色、高光参数等)渲染到多个屏幕空间的缓冲区中,合称为G-Buffer。
- 光照通道:遍历所有光源。对于每个光源(通常是点光源或聚光灯),根据其影响范围(一个球体或锥体),计算该区域内每个像素的光照。因为此时像素对应的表面信息(从G-Buffer中读取)是已知的,所以光照计算复杂度变为O(屏幕像素数*光源数),与场景复杂度解耦,非常适合大量小型光源的场景。
在Unity中,你需要使用#pragma target 3.0(或更高)并设置Tags { “LightMode” = “Deferred” }来编写一个延迟渲染Shader。你的Surface函数输出的数据将被填充到G-Buffer中。Unity内置了标准的延迟着色器,自定义时你需要了解G-Buffer的每个RT存储的内容格式(例如,ARGB32存储漫反射颜色和遮挡,ARGB2101010存储法线和光滑度等)。
实操心得:延迟渲染对透明物体的处理是弱项,通常透明物体仍需用前向渲染来绘制。此外,抗锯齿(如MSAA)在延迟渲染中实现起来更复杂,通常使用后处理抗锯齿(如FXAA、TAA)。如果你的项目是移动端,需要谨慎评估带宽开销,因为G-Buffer会占用大量显存带宽。
3.3 可编程渲染管线中的多光源管理
在URP或HDRP中,多光源处理被进一步抽象和优化。以URP为例,它采用了一种改进的前向渲染方式(有时称为“前向+”),通过光源剔除和分块(Tile-based)优化来提升多光源性能。
在编写URP Lit Shader时,你通常不需要像内置管线那样手动处理多个Pass。URP会在一个Pass内,通过Light结构体数组将影响当前像素的可见光源数据传递进来。你只需要在片元着色器中循环遍历inputData.additionalLightsCount,并累加每个additionalLights[i]的贡献即可。URP后台会自动处理光源的剔除、强度衰减和光照烘焙混合。
// URP Shader示例片段 Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask); float3 color = LightingPhysicallyBased(mainLight, normalWS, viewDirWS, surfaceData); uint pixelLightCount = GetAdditionalLightsCount(); for (uint lightIndex = 0; lightIndex < pixelLightCount; ++lightIndex) { Light light = GetAdditionalLight(lightIndex, positionWS, shadowMask); color += LightingPhysicallyBased(light, normalWS, viewDirWS, surfaceData); }这种方式的代码更简洁,且性能由渲染管线底层保障。你需要熟悉URP的Lighting.hlsl库函数,并正确设置材质的Receive Shadows和Additional Lights属性。
4. 阴影技术的深度解析
没有阴影的光照是没有灵魂的。阴影赋予了物体空间感和重量感。从简单的硬阴影到逼真的软阴影,其实现复杂度天差地别。
4.1 阴影映射原理与实现步骤
阴影映射是实时渲染中最主流的阴影技术。其核心思想是:从光源的视角“看”一遍场景,记录下距离光源最近的深度值,生成一张“阴影贴图”。然后在摄像机渲染场景时,将像素点转换回光源的视角,比较该点的深度值与阴影贴图中记录的深度值。如果该点深度大于记录值(说明它在阴影中),则不被该光源照亮。
在Unity Shader中实现接收阴影,关键步骤如下:
- 阴影投射:需要一个专门的ShadowCaster Pass(或使用
#pragma addshadow指令),将物体的顶点变换到光源的裁剪空间,并输出深度。这个Pass通常非常简单。 - 阴影接收:在计算光照的主Pass中,需要计算当前片元在光源空间下的坐标,然后使用
SHADOW_ATTENUATION宏或UnitySampleShadowmap函数来采样阴影贴图,获得阴影衰减因子(0到1之间)。 - 空间变换:最易出错的一环。需要将像素的世界坐标,通过光源的投影矩阵(
unity_WorldToShadow数组中的矩阵)变换到阴影贴图空间(通常是齐次裁剪空间,其xy坐标用于采样,z坐标用于深度比较)。在Surface Shader中,你可以使用INTERNAL_DATA结构体和WorldToShadowCoord函数来简化这个过程。
// 在surf函数中计算阴影 SurfaceOutputStandard o; // ... 填充o的其他属性 ... float3 worldPos = i.worldPos; float4 shadowCoord = TransformWorldToShadowCoord(worldPos); // 关键变换 float shadow = MainLightRealtimeShadow(shadowCoord); // URP中获取主光源实时阴影 o.Albedo *= shadow; // 将阴影应用到漫反射颜色上4.2 阴影失真、锯齿与优化方案
直接实现的阴影映射问题很多:
- 阴影失真(Shadow Acne):由于深度贴图分辨率和精度限制,物体表面会错误地判断为自己遮挡自己,产生条纹状失真。解决方案是加入一个微小的偏移量
shadowBias,在深度比较前将接收者的深度稍微推离光源一点。Unity提供了UnityApplyLinearShadowBias函数或材质上的Bias参数。 - 彼得潘现象(Peter Panning):如果Bias过大,阴影会与物体本体分离,看起来物体像是飘在空中。需要在Bias和失真之间取得平衡。有时可以使用斜率缩放偏差(Slope-Scaled Bias),让斜坡上的Bias更大。
- 锯齿(Aliasing):由于阴影贴图像素有限,在边缘会产生明显的锯齿。PCF(百分比渐进过滤)是最基础的抗锯齿方法,它不仅采样一个点,而是采样周围多个点(如3x3),然后取平均作为阴影衰减值,从而实现边缘柔化。在Shader中,你可以使用
SampleShadowmap_PCF等函数。// 简单的PCF采样示例(概念) float shadow = 0.0; float2 texelSize = 1.0 / _ShadowMapTexture_TexelSize.xy; for(int x = -1; x <= 1; ++x) { for(int y = -1; y <= 1; ++y) { float2 offset = float2(x, y) * texelSize; shadow += SampleShadowMap(shadowCoord + float4(offset, 0, 0)); } } shadow /= 9.0;
4.3 级联阴影映射与软阴影技术
对于大型开放世界,单一分辨率的阴影贴图无法兼顾近处细节和远处覆盖。级联阴影映射(CSM)将摄像机的视锥体沿深度方向分成数个区域(如近、中、远),每个区域使用一张独立渲染的、分辨率不同的阴影贴图。渲染时,根据像素的深度决定使用哪一级的阴影贴图。Unity的平行光阴影质量设置中的“Cascades”选项就是控制这个。在Shader中,你需要使用UnityComputeShadowCoord或类似函数,它会自动处理级联的选择和坐标变换。
更高级的软阴影技术如VSM(方差阴影贴图)、ESM(指数阴影贴图),它们存储的不是单一深度,而是深度的分布信息(如均值和方差),从而能够生成更平滑、物理上更准确的半影(Penumbra)区域。这些技术计算开销更大,但效果也更好,常用于高端PC或主机平台。在URP/HDRP中,可以通过管线设置选择不同的阴影过滤算法。
5. 环境光照与全局光照初步
除了直接光源,物体还会受到来自周围环境的间接光照影响,这是营造真实感的关键。
5.1 环境贴图与反射探针
天空盒或场景中放置的反射探针,可以捕获周围环境的颜色信息,生成一张立方体贴图(Cubemap)。在Shader中,我们根据表面法线和视线方向计算反射向量(或为了粗糙表面进行模糊处理的采样向量),然后采样这张环境贴图,作为镜面反射的间接光部分。
在Unity中,你可以通过UNITY_SAMPLE_TEXCUBE宏和unity_SpecCube0等内置变量来采样环境贴图。对于粗糙表面,需要对Cubemap的Mipmap进行采样,越粗糙的材质,采样的Mip层级越高(图像越模糊)。这可以通过UNITY_SAMPLE_TEXCUBE_LOD或texCUBElod函数实现。
float3 reflectVector = reflect(-viewDir, normalWS); float mip = roughness * UNITY_SPECCUBE_LOD_STEPS; // 根据粗糙度计算Mip层级 float4 envSample = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVector, mip); float3 envColor = DecodeHDR(envSample, unity_SpecCube0_HDR); // 解码HDR信息5.2 光照探针与球谐光照
对于动态物体,无法实时计算复杂的静态几何体间的间接光反弹。Unity使用光照探针(Light Probes)来解决。光照探针在场景空间中采样烘焙好的间接光照信息,并将其编码为球谐函数(Spherical Harmonics)系数。球谐函数是一种用少量系数(如9个float3,对应L2级SH)就能在球面上近似表示一个复杂函数(此处是光照)的数学工具。
在Shader的顶点着色器中,我们可以通过ShadeSH9函数,传入顶点的世界空间法线,来快速计算出该点受到的来自各个方向的、柔和的间接漫反射光照。这个计算开销极低,效果却很好,是移动端动态物体接受间接光的标准方案。
// 通常在顶点着色器中计算,传递到片元 float3 shColor = ShadeSH9(float4(normalWS, 1.0)); o.vertexSH = shColor; // 传递给片元着色器5.3 屏幕空间环境光遮蔽
屏幕空间环境光遮蔽(SSAO)是一种后处理效果,用于模拟物体缝隙、角落因环境光被遮挡而产生的变暗效果,能极大地增强场景的立体感和真实感。其原理是:在当前视角的深度/法线缓冲区中,对于每个像素,在其周围的半球空间内随机采样多个点,检查这些采样点是否被场景几何体遮挡(通过比较采样点的深度与深度缓冲区中的深度),被遮挡的采样点越多,该像素就越暗。
在Shader中实现SSAO通常是一个单独的后处理Pass。你需要获取摄像机的深度+法线纹理(_CameraDepthNormalsTexture),然后编写一个片元着色器,进行上述的采样和遮挡计算。现代实现如HBAO(水平基准环境光遮蔽)效果更好,但原理相通。Unity的URP和HDRP内置了高质量的SSAO效果,通常无需自己从头实现,但理解其原理对于调试和自定义效果至关重要。
6. 高级光照特效实战
掌握了核心模型和框架后,我们可以实现一些提升画面表现力的高级特效。
6.1 次表面散射模拟
次表面散射(SSS)是光线穿透半透明材质表面,在内部散射后从另一点射出的现象,常见于皮肤、玉石、蜡、牛奶等。实时渲染中常用近似模拟。
一种简单但有效的屏幕空间方法是:
- 将需要SSS的物体渲染到一张单独的纹理中,存储其世界空间位置和厚度(或使用背面挤压的Pass估算厚度)。
- 在后处理中,对这张纹理进行模糊(特别是高斯模糊或径向模糊),模拟光线的扩散。
- 将模糊后的结果与原始颜色以某种方式(如相加、相乘)混合回主画面。皮肤渲染中常使用“曲率”贴图来调节不同部位(如鼻翼、耳廓)的散射强度和颜色。
更物理的方法是在Shader中直接使用扩散剖面函数,但计算量较大。在片元着色器中,可以近似地用一张预积分的查找贴图,根据dot(N, L)和曲率来查询散射颜色。
6.2 头发与毛发渲染
头发渲染是各向异性光照、高光模型和透明排序的集大成者。经典的Kajiya-Kay毛发光照模型使用两个高光瓣:一个主高光(Primary Specular)沿发丝方向拉伸(各向异性),一个次级高光(Secondary Specular)用于模拟头发内层的光泽。
实现时,通常需要切线方向(发丝方向)。在Shader中,计算两个基于切线方向而非法线方向的镜面反射项。此外,头发具有透光性,需要模拟“透射光”,即背光时头发边缘的亮边效果。这可以通过计算pow(saturate(-dot(N, V)), transmissionPower)等来实现。头发的抗锯齿(AA)和深度排序(Alpha Blending)也是巨大的挑战,通常使用“发卡”(Hair Cards)这种面片集合来模拟单根发丝。
6.3 体积光与上帝之光
体积光(God Rays)是光线穿过遮挡物(如云层、窗户)缝隙时,由于空气中微粒的散射而形成的光柱效果。一种常见的屏幕空间实现方法是:
- 将光源(如平行光)深度图或屏幕空间遮挡信息渲染到一张低分辨率纹理中。
- 从光源屏幕位置向当前像素屏幕位置进行光线步进(Ray Marching),每次步进采样遮挡纹理,累计被遮挡的次数。
- 根据累计的遮挡值,应用一个衰减和颜色函数,生成光柱的亮度。最后将结果以“相加”(Additive)混合模式叠加到原画面上。
这个效果非常耗费性能,步进次数和采样数需要谨慎控制。通常采用降分辨率渲染、动态调整步进距离等优化。在URP中,可以通过编写一个Renderer Feature来实现此效果。
7. 性能优化与调试技巧
复杂的光照意味着高昂的性能代价。在追求效果的同时,必须时刻关注性能。
7.1 Shader复杂度分析与优化点
使用Unity的Frame Debugger或RenderDoc等工具,分析你的Shader在GPU上的耗时。关注以下几点:
- 纹理采样次数:减少不必要的纹理采样,合并贴图(如将金属度、粗糙度、遮挡度打包到一张贴图的RGB通道)。
- 复杂数学运算:
sin,cos,pow,exp等函数开销大。考虑使用近似函数、查找表(LUT)或在顶点着色器预计算。 - 分支与循环:GPU不擅长分支预测,应尽量避免在片元着色器中使用动态分支。如果循环次数固定且较少,可以展开。
- 精度选择:在移动端,对颜色、向量等使用
half或fixed精度(如果支持),对位置等使用float精度。 - 光照计算范围:对于点光源/聚光灯,尽早计算衰减并判断是否在有效范围内,如果衰减接近0,则跳过后续复杂的光照计算。
7.2 针对移动平台的简化策略
移动平台GPU算力和带宽有限,必须做大量简化:
- 光照模型:使用简化的PBR模型,甚至回退到Blinn-Phong。关闭或简化菲涅尔效应、几何遮挡函数。
- 光源数量:严格限制逐像素光源数量(如URP中可设置为1-2个)。大量使用烘焙光照和光照探针。
- 阴影:使用低分辨率阴影贴图、简单的PCF(如2x2),甚至只让主角和主要物体投射/接收阴影。考虑使用预烘焙的静态阴影贴图。
- 环境光:使用低阶(如L1)的球谐光照,或干脆用一个常量环境色代替。
- 后处理:SSAO、体积光等效果在低端机上直接关闭。
7.3 Shader调试与可视化工具
调试Shader是门艺术。除了打Log(在Shader中不现实),常用方法有:
- 颜色调试法:将中间变量(如法线、深度、阴影值、光照强度)映射为颜色输出到屏幕,直观查看其范围和对错。例如,
return float4(normalWS * 0.5 + 0.5, 1.0);来可视化法线。 - Unity的Frame Debugger:可以查看每个Draw Call的渲染状态、使用的Shader和属性,以及渲染目标的内容,是定位渲染问题的利器。
- 自定义编辑器脚本:为你的Shader编写一个自定义的Material Inspector,添加一些滑块或按钮,可以动态调节参数并立即看到效果,方便艺术调整。
- RenderDoc:功能强大的独立图形调试器,可以捕获一帧完整的渲染过程,查看每一个渲染Pass、每一次纹理采样、每一个像素的详细数值,是解决复杂渲染问题的终极工具。学会使用RenderDoc的“Texture Viewer”和“Pipeline State”视图,能让你对GPU正在做的事情了如指掌。
从理解微表面理论到实现多光源阴影,从搭建PBR模型到优化移动端性能,这条通往“更复杂光照”的道路充满了挑战,但也正是这些挑战,让实时渲染的世界如此迷人。掌握这些知识后,你将不再只是Shader代码的搬运工,而是能够创造独特视觉风格、为项目解决具体图形问题的核心开发者。记住,最好的学习方式永远是动手:从一个简单的Plane开始,实现一个完整的光照模型,然后逐步添加阴影、多光源、环境光,并在这个过程中不断用调试工具观察和验证。当你看到自己写出的Shader在屏幕上呈现出真实而动人的光影时,那种成就感是无与伦比的。