案例 5.1Lambert 漫反射入门
目标:写出 URP 里最简单的“受光” Shader——主光源的 Lambert 漫反射加球谐环境光,并理解那个无处不在的 N·L 到底是怎么来的。
原理:余弦定律
想象一束截面积为 A 的平行光。光束正对表面时,它照亮的面积就是 A;表面倾斜 θ 角后,同一束光被摊在 A / cos θ 的面积上。光的总能量没变,摊开的面积变大了,单位面积接收到的能量(辐照度,Irradiance)就按 cos θ 下降。这就是 Lambert 余弦定律。而 cos θ 恰好等于单位法线与单位光照方向的点积 N·L。
一个理想的漫反射表面(Lambertian 表面)把接收到的光均匀地散射到上半球的所有方向,所以从任何角度看它的亮度都一样,只取决于它接收了多少光。物理上,它的 BRDF(双向反射分布函数,第 9 章细讲)是一个常数:
其中 ρ 是反照率(Albedo,也就是材质的“底色”,0~1),E⊥ 是光垂直入射时的辐照度。分母里的 π 来自“把能量均匀分到半球上”的积分,它保证漫反射出去的总能量不超过收到的能量。
Unity 里的 π 去哪了
如果你翻 URP 的 LightingLambert() 或 BRDF 代码,会发现漫反射写的是 albedo * lightColor * NdotL,没有除以 π。这不是错误,而是一个约定:Unity(以及多数游戏引擎)规定灯光的 Intensity 已经“预先乘了 π”,也就是说 light.color 代表的是 π·E⊥。这样美术把一盏 Intensity = 1 的白光照在白色表面上,正对的地方刚好输出 1,直观易调。代价是:凡是从论文里抄来的 BRDF 公式,都要整体乘以 π 才能和 Unity 的灯光配合。这一点在本章的高光归一化和第 9~11 章的 PBR 里会反复出现。
完整代码
// 案例 5.1 Lambert 漫反射:只计算主光源的 N·L,再加一点球谐环境光 Shader "Cookbook/Ch05/Lambert" { Properties { [MainColor] _BaseColor("Base Color", Color) = (0.85, 0.5, 0.25, 1) _AmbientStrength("Ambient Strength", Range(0, 1)) = 1 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half _AmbientStrength; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float3 normalWS : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); // 法线要用“逆转置矩阵”变换,TransformObjectToWorldNormal 已经处理了非等比缩放 output.normalWS = TransformObjectToWorldNormal(input.normalOS); return output; } half4 frag(Varyings input) : SV_Target { // 插值后的法线长度不再是 1,必须重新归一化 half3 N = normalize(input.normalWS); Light mainLight = GetMainLight(); // direction 指向光源;color 已经乘过 Intensity,是线性空间的 HDR 值 half3 radiance = mainLight.color * mainLight.distanceAttenuation; // 余弦定律:背光面 N·L < 0,必须截断到 0,否则会“吸收”别的光 half NdotL = saturate(dot(N, mainLight.direction)); half3 diffuse = _BaseColor.rgb * radiance * NdotL; // 环境光:来自天空的球谐光照,第 8 章细讲。没有它,背光面会是纯黑 half3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength; return half4(diffuse + ambient, 1); } ENDHLSL } // ---- 投影与深度:本章先照抄,原理见第 6 章 ---- Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex ShadowDepthVert #pragma fragment ShadowDepthFrag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch05/Ch05ShadowDepth.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex DepthOnlyVert #pragma fragment DepthOnlyFrag #include "Assets/Cookbook/Ch05/Ch05ShadowDepth.hlsl" ENDHLSL } } }
// 案例 5.1 Lambert 漫反射:只计算主光源的 N·L,再加一点球谐环境光(GLSL 版,对应 Ch05/Lambert.shader) Shader "Cookbook/Ch05/Lambert" { SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseColor; uniform float _AmbientStrength; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM VARYING vec3 normalWS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); // 法线要用“逆转置矩阵”变换,TransformObjectToWorldNormal 已经处理了非等比缩放 normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { // 插值后的法线长度不再是 1,必须重新归一化 vec3 N = normalize(normalWS); Light mainLight = GetMainLight(); // direction 指向光源;color 已经乘过 Intensity,是线性空间的 HDR 值 vec3 radiance = mainLight.color * mainLight.distanceAttenuation; // 余弦定律:背光面 N·L < 0,必须截断到 0,否则会“吸收”别的光 float NdotL = saturate(dot(N, mainLight.direction)); vec3 diffuse = _BaseColor.rgb * radiance * NdotL; // 环境光:来自天空的球谐光照,第 8 章细讲。没有它,背光面会是纯黑 vec3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength; fragColor = vec4(diffuse + ambient, 1.0); } #endif ENDGLSL } // ---- 投影与深度:本章先照抄,原理见第 6 章 ---- Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #define SHADOW_PASS #include "Ch05/Ch05ShadowDepth.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define DEPTH_PASS #include "Ch05/Ch05ShadowDepth.glsl" ENDGLSL } } }
关键代码讲解
half4 frag(Varyings input) : SV_Target { // 插值后的法线长度不再是 1,必须重新归一化 half3 N = normalize(input.normalWS); Light mainLight = GetMainLight(); // direction 指向光源;color 已经乘过 Intensity,是线性空间的 HDR 值 half3 radiance = mainLight.color * mainLight.distanceAttenuation; // 余弦定律:背光面 N·L < 0,必须截断到 0,否则会“吸收”别的光 half NdotL = saturate(dot(N, mainLight.direction)); half3 diffuse = _BaseColor.rgb * radiance * NdotL; // 环境光:来自天空的球谐光照,第 8 章细讲。没有它,背光面会是纯黑 half3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength; return half4(diffuse + ambient, 1); }
// 案例 5.1 Lambert 漫反射:只计算主光源的 N·L,再加一点球谐环境光(GLSL 版,对应 Ch05/Lambert.shader) Shader "Cookbook/Ch05/Lambert" { SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseColor; uniform float _AmbientStrength; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM VARYING vec3 normalWS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); // 法线要用“逆转置矩阵”变换,TransformObjectToWorldNormal 已经处理了非等比缩放 normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { // 插值后的法线长度不再是 1,必须重新归一化 vec3 N = normalize(normalWS); Light mainLight = GetMainLight(); // direction 指向光源;color 已经乘过 Intensity,是线性空间的 HDR 值 vec3 radiance = mainLight.color * mainLight.distanceAttenuation; // 余弦定律:背光面 N·L < 0,必须截断到 0,否则会“吸收”别的光 float NdotL = saturate(dot(N, mainLight.direction)); vec3 diffuse = _BaseColor.rgb * radiance * NdotL; // 环境光:来自天空的球谐光照,第 8 章细讲。没有它,背光面会是纯黑 vec3 ambient = _BaseColor.rgb * SampleSH(N) * _AmbientStrength; fragColor = vec4(diffuse + ambient, 1.0); } #endif ENDGLSL } // ---- 投影与深度:本章先照抄,原理见第 6 章 ---- Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #define SHADOW_PASS #include "Ch05/Ch05ShadowDepth.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define DEPTH_PASS #include "Ch05/Ch05ShadowDepth.glsl" ENDGLSL } } }
- 重新归一化法线。顶点法线在光栅化时被线性插值,两个单位向量的线性插值长度小于 1(在两向量夹角越大时越明显),不归一化的话三角形中间会偏暗。这一行在低模上尤其重要。
- radiance = color × distanceAttenuation。如 5.2 节所说,主光源也要乘
distanceAttenuation,否则灯光的 Culling Mask 在 Forward 路径下失效。 - saturate(dot(N, L))。背光面 N·L 为负,物理上意味着“光从背面来”,表面接收不到它,必须截断为 0。如果忘了截断,负值会把环境光也“减掉”,背光面会比纯环境光还暗,甚至出现负颜色。
saturate等价于clamp(x, 0, 1),在大多数 GPU 上是免费的指令修饰符,比max(0, x)更推荐。 - 环境光。
SampleSH(N)返回法线方向上的环境辐照度(来自天空盒烘焙出的球谐系数),乘上反照率就是最简单的环境漫反射。把_AmbientStrength设为 0,背光面就会变成纯黑。
顶点着色器里用 TransformObjectToWorldNormal 而不是 TransformObjectToWorld 变换法线:法线必须用模型矩阵的逆转置来变换,才能在非等比缩放时保持与表面垂直。这个函数内部用的是 unity_WorldToObject 的转置,并默认做了归一化。
Shader 末尾的两个 Pass 来自 Ch05ShadowDepth.hlsl,它们让物体能把影子投到地面上、能被写进深度图。注意文件开头那一行额外的 include:
// 第 5 章所有 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元函数。 // 本章只关心“表面怎么被照亮”,这两个 Pass 只负责让物体能投影、能写进深度图, // 它们的原理(ApplyShadowBias、_CASTING_PUNCTUAL_LIGHT_SHADOW 等)在第 6 章细讲。 #ifndef COOKBOOK_CH05_SHADOW_DEPTH_INCLUDED #define COOKBOOK_CH05_SHADOW_DEPTH_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // Shadows.hlsl 用到了 LerpWhiteTo(),它定义在 CommonMaterial.hlsl 里,但 Shadows.hlsl 自己没有 include。 // URP 自带的 Lit 之所以不报错,是因为 LitInput.hlsl 先 include 了它。自己写 ShadowCaster 时要补上。 #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" // 由 URP 的阴影 Pass 在绘制每个投射光源前设置 float3 _LightDirection; float3 _LightPosition;
// 第 5 章所有 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元代码(GLSL 版)。 // 原理(ApplyShadowBias、点光源投影等)在第 6 章细讲。 // 用法:在 Pass 的 GLSLPROGRAM 里先 #define SHADOW_PASS 或 DEPTH_PASS,再 #include 本文件。 #ifdef SHADOW_PASS #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 normalWS = TransformObjectToWorldNormal(normalOS); // 点光源/聚光灯:方向从顶点指向灯;方向光:所有顶点同一方向(_CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform) vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w); gl_Position = positionCS; } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } // ColorMask 0,只写深度 #endif #endif #ifdef DEPTH_PASS #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(gl_FragCoord.z); } // 与 URP DepthOnlyPass 一致:深度写进 R 通道 #endif #endif
Shadows.hlsl 会编译失败,报 undeclared identifier 'LerpWhiteTo'。这个函数在 Core RP 的 CommonMaterial.hlsl 里,而 Shadows.hlsl 没有包含它。URP 的 Lit 不报错,只是因为 LitInput.hlsl 恰好先 include 了它。效果

左图就是 N·L 本身的可视化:受光最强的点正对光源,越往边缘越暗,交界线处降到 0。中图的硬交界线和纯黑的背光面,是“只有直接光”的典型样子,在太空场景之外很少见。右图加了环境光以后,背光面有了来自天空的蓝色调,整体立刻可信多了,但也因此显得平——这正是下面几个案例要解决的问题。