GameTech/URP 渲染实战

案例 5.1Lambert 漫反射入门

目标:写出 URP 里最简单的“受光” Shader——主光源的 Lambert 漫反射加球谐环境光,并理解那个无处不在的 N·L 到底是怎么来的。

原理:余弦定律

想象一束截面积为 A 的平行光。光束正对表面时,它照亮的面积就是 A;表面倾斜 θ 角后,同一束光被摊在 A / cos θ 的面积上。光的总能量没变,摊开的面积变大了,单位面积接收到的能量(辐照度,Irradiance)就按 cos θ 下降。这就是 Lambert 余弦定律。而 cos θ 恰好等于单位法线与单位光照方向的点积 N·L。

照亮宽度 = w 正对表面:θ = 0 照亮宽度 = w / cos θ N θ 倾斜 θ:能量被摊开
图 5-4余弦定律:同样宽度 w 的光束,倾斜照射时覆盖的面积变成 w / cos θ,单位面积得到的光按 cos θ 减少

一个理想的漫反射表面(Lambertian 表面)把接收到的光均匀地散射到上半球的所有方向,所以从任何角度看它的亮度都一样,只取决于它接收了多少光。物理上,它的 BRDF(双向反射分布函数,第 9 章细讲)是一个常数:

fd=ρπLo=ρπ⋅E⊥⋅max(0,N⋅L)

其中 ρ 是反照率(Albedo,也就是材质的“底色”,0~1),E⊥ 是光垂直入射时的辐照度。分母里的 π 来自“把能量均匀分到半球上”的积分,它保证漫反射出去的总能量不超过收到的能量。

Unity 里的 π 去哪了

如果你翻 URP 的 LightingLambert() 或 BRDF 代码,会发现漫反射写的是 albedo * lightColor * NdotL,没有除以 π。这不是错误,而是一个约定:Unity(以及多数游戏引擎)规定灯光的 Intensity 已经“预先乘了 π”,也就是说 light.color 代表的是 π·E⊥。这样美术把一盏 Intensity = 1 的白光照在白色表面上,正对的地方刚好输出 1,直观易调。代价是:凡是从论文里抄来的 BRDF 公式,都要整体乘以 π 才能和 Unity 的灯光配合。这一点在本章的高光归一化和第 9~11 章的 PBR 里会反复出现。

完整代码

Assets/Cookbook/Ch05/Lambert.shader
// 案例 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
        }
    }
}

关键代码讲解

Assets/Cookbook/Ch05/Lambert.shader(节选:frag)
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. 重新归一化法线。顶点法线在光栅化时被线性插值,两个单位向量的线性插值长度小于 1(在两向量夹角越大时越明显),不归一化的话三角形中间会偏暗。这一行在低模上尤其重要。
  2. radiance = color × distanceAttenuation。如 5.2 节所说,主光源也要乘 distanceAttenuation,否则灯光的 Culling Mask 在 Forward 路径下失效。
  3. saturate(dot(N, L))。背光面 N·L 为负,物理上意味着“光从背面来”,表面接收不到它,必须截断为 0。如果忘了截断,负值会把环境光也“减掉”,背光面会比纯环境光还暗,甚至出现负颜色。saturate 等价于 clamp(x, 0, 1),在大多数 GPU 上是免费的指令修饰符,比 max(0, x) 更推荐。
  4. 环境光。SampleSH(N) 返回法线方向上的环境辐照度(来自天空盒烘焙出的球谐系数),乘上反照率就是最简单的环境漫反射。把 _AmbientStrength 设为 0,背光面就会变成纯黑。

顶点着色器里用 TransformObjectToWorldNormal 而不是 TransformObjectToWorld 变换法线:法线必须用模型矩阵的逆转置来变换,才能在非等比缩放时保持与表面垂直。这个函数内部用的是 unity_WorldToObject 的转置,并默认做了归一化。

Shader 末尾的两个 Pass 来自 Ch05ShadowDepth.hlsl,它们让物体能把影子投到地面上、能被写进深度图。注意文件开头那一行额外的 include:

Assets/Cookbook/Ch05/Ch05ShadowDepth.hlsl
// 第 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
坑自己写 ShadowCaster 时只 include Shadows.hlsl 会编译失败,报 undeclared identifier 'LerpWhiteTo'。这个函数在 Core RP 的 CommonMaterial.hlsl 里,而 Shadows.hlsl 没有包含它。URP 的 Lit 不报错,只是因为 LitInput.hlsl 恰好先 include 了它。

效果

图 5-5左:白色反照率、无环境光,等于把 N·L 直接画出来;中:橙色反照率、无环境光,明暗交界线(Terminator)清晰,背光面纯黑;右:加上球谐环境光后,背光面被天空照亮

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

调参建议Lambert 唯一的参数就是反照率。现实中的材质反照率很少超过 0.9、也很少低于 0.03,纯白 (1,1,1) 的材质在光照下会显得“发光”。第 13 章会系统讨论合理的取值范围。