GameTech/URP 渲染实战
CHAPTER 10第 10 章

逐行读懂 URP Lit

打开 URP 14.0.12 的源码,沿着一个像素的计算路径,从 Lit.shader 一路读到 BRDF.hlsl:每一步输入什么、输出什么、和第 9 章的哪条公式对应、在哪里做了近似、近似了多少。

本章你会学到
Lit.shader 的属性、Pass、关键字各自的用途
SurfaceData、InputData、BRDFData 三个结构体如何串起整条流程
UniversalFragmentPBR 逐行做了什么,Forward+ 下有什么不同
DirectBRDFSpecular 与标准 D·V·F 的关系,以及它偏差多大
环境光的三件事:球谐、反射探针的 mip 选择与环境 BRDF 近似
自己调用 UniversalFragmentPBR 写“最小 Lit”,并把光照拆成四项调试
阅读说明本章所有源码都摘自 Unity 2022.3.62f3 工程中 Library/PackageCache/com.unity.render-pipelines.universal@14.0.12/(下文简称 URP)和 com.unity.render-pipelines.core@14.0.12/(简称 core)。为节省篇幅只摘必要片段,省略处用 // ... 标出,原注释大多保留英文原文。其他版本的 URP 在细节上可能不同,以你工程里的实际源码为准。

10.1 全景:Lit 由哪些文件组成

在材质面板上选 “Universal Render Pipeline/Lit”,用到的代码分散在十几个文件里。按 include 关系画出来,主干是这样的:

Shaders/Lit.shader Shaders/LitInput.hlsl Shaders/LitForwardPass.hlsl Core.hlsl · SurfaceInput.hlsl CommonMaterial.hlsl · ParallaxMapping.hlsl DBuffer.hlsl(贴花) ShaderLibrary/Lighting.hlsl BRDF.hlsl GlobalIllumination.hlsl RealtimeLights.hlsl AmbientOcclusion.hlsl Debug 被上面这些文件再 include 的库: core/BSDF.hlsl(D_GGX、F_Schlick…) core/ImageBasedLighting.hlsl(mip 映射) core/EntityLighting.hlsl(SH、光照贴图) Shadows.hlsl(阴影采样)
图 10-1URP Lit 前向 Pass 的主要 include 关系。橙色框是本章重点:光照入口 Lighting.hlsl、BRDF 的 BRDF.hlsl、环境光的 GlobalIllumination.hlsl。

理解 Lit 的关键,是认清三个结构体,它们像接力棒一样传递数据:

  • SurfaceData(ShaderLibrary/SurfaceData.hlsl):“这个表面是什么材质”——反照率、金属度、光滑度、切线空间法线、自发光、AO、透明度。由 LitInput.hlsl 采样贴图填写。
  • InputData(ShaderLibrary/Input.hlsl):“这个像素在哪儿、朝哪儿”——世界坐标、世界法线、视线方向、阴影坐标、烘焙 GI、屏幕 UV。由 LitForwardPass.hlsl 根据插值器填写。
  • BRDFData(ShaderLibrary/BRDF.hlsl):“代入 BRDF 公式的预计算量”——漫反射颜色、F0、α、α² 等。由 InitializeBRDFData 从 SurfaceData 换算而来。
LitPassVertex Varyings(插值器) InitializeStandardLitSurfaceData采样贴图 InitializeInputData法线贴图、阴影坐标、SH SurfaceData InputData UniversalFragmentPBR(inputData, surfaceData) InitializeBRDFData→ BRDFData GetMainLight(含阴影)AO 因子 / ShadowMask GlobalIlluminationSH × 漫反射 + 探针 × EnvBRDF LightingPhysicallyBased(主光)(diffuse + specular·DirectBRDFSpecular)·radiance 附加光循环(Forward / Forward+)每盏灯同样调用 LightingPhysicallyBased CalculateFinalColor:GI + 主光 + 附加光 + 自发光
图 10-2一个像素的计算路径:顶点阶段准备插值器,片元阶段先填 SurfaceData 和 InputData,再交给 UniversalFragmentPBR 完成全部光照。

10.2 Lit.shader:属性、Pass 与关键字

Properties

Lit 的属性块很长,按用途可以分成几组(括号里是属性名):

分组属性说明
工作流_WorkflowMode1 = 金属度,0 = 镜面;只给 ShaderGUI 用,真正起作用的是关键字 _SPECULAR_SETUP
基础色_BaseMap、_BaseColor、_Cutoff带 [MainTexture]/[MainColor],脚本里的 material.mainTexture/color 指向它们
金属/光滑_Metallic、_MetallicGlossMap、_Smoothness、_SmoothnessTextureChannelMetallic 贴图 R = 金属度,A = 光滑度(或取自 Albedo 的 A)
镜面工作流_SpecColor、_SpecGlossMapRGB = F0
开关_SpecularHighlights、_EnvironmentReflections[ToggleOff]:取消勾选时开启 _SPECULARHIGHLIGHTS_OFF 等关键字
细节_BumpMap、_BumpScale、_ParallaxMap、_OcclusionMap、_EmissionColor、_Detail*AO 取贴图的 G 通道
渲染状态_Surface、_Blend、_Cull、_SrcBlend、_ZWrite…由 ShaderGUI 根据“不透明/透明”等选项写入,Pass 里以 Blend[_SrcBlend][_DstBlend] 形式引用
兼容_ClearCoatMask、_MainTex、_Color…为 SRP Batcher 布局一致或旧材质升级而保留,Lit 本身不用

值得注意的是 _ClearCoatMask/_ClearCoatSmoothness:Lit 不支持清漆,但 CBUFFER 里依然留着它们,注释写明是“SRP batching compatibility for Clear Coat”。Complex Lit 与 Lit 共用 LitInput.hlsl,保持相同的 CBUFFER 布局,代码就能共享。

七个 Pass

Pass 名LightMode用途主要文件
ForwardLitUniversalForwardForward / Forward+ 下的着色LitForwardPass.hlsl
ShadowCasterShadowCaster渲染阴影贴图ShadowCasterPass.hlsl
GBufferUniversalGBufferDeferred 路径写 G-Buffer(target 4.5,排除 GLES3/GLCore)LitGBufferPass.hlsl
DepthOnlyDepthOnly深度预渲染、Depth TextureDepthOnlyPass.hlsl
DepthNormalsDepthNormals输出法线纹理(SSAO 等用)LitDepthNormalsPass.hlsl
MetaMeta烘焙光照贴图时提取反照率和自发光LitMetaPass.hlsl
Universal2DUniversal2D在 2D Renderer 下的回退Universal2D.hlsl

URP 14 的 Lit 只有一个 SubShader,所有 Pass 默认 #pragma target 2.0(GBuffer 除外)。SubShader 的 Tags 里除了常见的 RenderPipeline、RenderType,还有一个 "UniversalMaterialType" = "Lit":Deferred 渲染时,它决定这个材质在 G-Buffer 里被标记为哪种光照模型(Lit / SimpleLit / Unlit),延迟光照 Pass 据此选择公式。

文件末尾还有两行:FallBack "Hidden/Universal Render Pipeline/FallbackError" 与 CustomEditor "UnityEditor.Rendering.Universal.ShaderGUI.LitShader"。前者意味着当所有 Pass 都无法在当前平台编译时,物体会显示成洋红色的错误材质,而不是悄悄退回某个 Built-in Shader——这就是 URP 里“粉色材质”的来源之一。后者指定了材质面板的 C# 类,它不仅负责画界面,还负责根据选项设置关键字、渲染队列和混合状态(_SrcBlend、_ZWrite 等)。ForwardLit Pass 开头的 Blend[_SrcBlend][_DstBlend], [_SrcBlendAlpha][_DstBlendAlpha]、ZWrite[_ZWrite]、Cull[_Cull] 都是从材质属性读取渲染状态的写法:同一个 Shader 不需要为不透明、透明、双面各写一份 Pass,切换时也不会产生新的 Shader 变体,只改变材质的状态。透明与混合的细节见第 16 章。

关键字:两类来源

ForwardLit Pass 的关键字可以分成两类。材质关键字用 shader_feature_local(或 _fragment 变体)声明,由材质决定、只打包用到的组合:

// 摘自 URP Shaders/Lit.shader(ForwardLit Pass)
#pragma shader_feature_local _NORMALMAP
#pragma shader_feature_local _PARALLAXMAP
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF
#pragma shader_feature_local _ _DETAIL_MULX2 _DETAIL_SCALED
#pragma shader_feature_local_fragment _SURFACE_TYPE_TRANSPARENT
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local_fragment _ _ALPHAPREMULTIPLY_ON _ALPHAMODULATE_ON
#pragma shader_feature_local_fragment _EMISSION
#pragma shader_feature_local_fragment _METALLICSPECGLOSSMAP
#pragma shader_feature_local_fragment _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
#pragma shader_feature_local_fragment _OCCLUSIONMAP
#pragma shader_feature_local_fragment _SPECULARHIGHLIGHTS_OFF
#pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
#pragma shader_feature_local_fragment _SPECULAR_SETUP

管线关键字用 multi_compile 声明,由 URP 在运行时根据管线设置和场景状态全局开关,所以所有组合都要编译:

// 摘自 URP Shaders/Lit.shader(ForwardLit Pass)
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3
#pragma multi_compile_fragment _ _LIGHT_COOKIES
#pragma multi_compile _ _LIGHT_LAYERS
#pragma multi_compile _ _FORWARD_PLUS
// ... 另有 LIGHTMAP_ON、DIRLIGHTMAP_COMBINED、SHADOWS_SHADOWMASK、LOD_FADE_CROSSFADE、
//     multi_compile_fog、DEBUG_DISPLAY、multi_compile_instancing 等

几个容易忽略的细节:

  • 后缀 _fragment 表示该关键字只影响片元着色器,顶点着色器不会因它产生额外变体,能显著减少编译量。
  • _local 表示关键字作用域是本 Shader,不占用全局关键字名额(Unity 2021.2 起关键字系统经过重做,全局关键字的数量限制已大幅放宽,但材质级的开关仍推荐用 local)。
  • Forward+ 不是一个独立的 Shader,而是 _FORWARD_PLUS 关键字。自己写的 Shader 如果漏掉这一行,在 Forward+ 渲染器下就收不到附加光——这是从 URP 12 迁移到 14 时最常见的“附加光不亮”原因。
  • SH 的计算位置由 EVALUATE_SH_MIXED/EVALUATE_SH_VERTEX 控制,对应 URP Asset 里的 SH Evaluation Mode。设置为 Auto 时,URP 在移动平台、XR 移动设备、Switch 上选择逐顶点,其余平台逐像素(见 UniversalRenderPipelineCore.cs 的 ShAutoDetect)。
常见坑:脚本创建的材质没有关键字材质关键字是 Lit 的 ShaderGUI(UnityEditor.Rendering.Universal.ShaderGUI.LitShader)在你修改面板时自动设置的。用代码 new Material(...) 并 SetTexture("_BumpMap", ...),法线贴图并不会生效,必须同时 EnableKeyword("_NORMALMAP");运行时切换自发光要 EnableKeyword("_EMISSION")。另外,shader_feature 的变体只有在构建时被某个材质引用到才会打包,运行时才开启的组合在真机上会退回到“最接近的已编译变体”,表现为效果缺失。

10.3 LitInput.hlsl:从贴图到 SurfaceData

LitInput.hlsl 先声明材质常量缓冲:

// 摘自 URP Shaders/LitInput.hlsl
// NOTE: Do not ifdef the properties here as SRP batcher can not handle different layouts.
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _DetailAlbedoMap_ST;
half4 _BaseColor;
half4 _SpecColor;
half4 _EmissionColor;
half _Cutoff;
half _Smoothness;
half _Metallic;
half _BumpScale;
// ... _Parallax、_OcclusionStrength、_ClearCoatMask、_ClearCoatSmoothness、
//     _DetailAlbedoMapScale、_DetailNormalMapScale、_Surface
CBUFFER_END

注释点明了 SRP Batcher 的硬性要求:同一个 Shader 的所有变体,UnityPerMaterial 的布局必须完全一致,所以属性不能放在 #ifdef 里。紧随其后的 UNITY_DOTS_INSTANCING_ENABLED 分支是给 Entities Graphics(DOTS)用的,普通项目可以略过。

然后是几个采样辅助函数。金属度和光滑度的来源取决于两个关键字:

// 摘自 URP Shaders/LitInput.hlsl
half4 SampleMetallicSpecGloss(float2 uv, half albedoAlpha)
{
    half4 specGloss;
#ifdef _METALLICSPECGLOSSMAP
    specGloss = half4(SAMPLE_METALLICSPECULAR(uv));
    #ifdef _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
        specGloss.a = albedoAlpha * _Smoothness;
    #else
        specGloss.a *= _Smoothness;
    #endif
#else // _METALLICSPECGLOSSMAP
    #if _SPECULAR_SETUP
        specGloss.rgb = _SpecColor.rgb;
    #else
        specGloss.rgb = _Metallic.rrr;
    #endif
    // ... 光滑度同理:取 albedoAlpha * _Smoothness 或 _Smoothness
#endif
    return specGloss;
}

有贴图时,_Smoothness 滑条变成贴图 A 通道的乘数;而 _Metallic 滑条在有贴图时完全不起作用(贴图的 R 通道直接当金属度)。这个不对称经常让人困惑:给了金属度贴图后再拖 Metallic 滑条没有反应,是正常的。AO 则通过 LerpWhiteTo(occ, _OcclusionStrength) 在 1 与贴图值之间插值,强度为 0 时等于没有 AO。

最后,InitializeStandardLitSurfaceData 把所有东西填进 SurfaceData:

// 摘自 URP Shaders/LitInput.hlsl
inline void InitializeStandardLitSurfaceData(float2 uv, out SurfaceData outSurfaceData)
{
    half4 albedoAlpha = SampleAlbedoAlpha(uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
    outSurfaceData.alpha = Alpha(albedoAlpha.a, _BaseColor, _Cutoff);   // 含 clip

    half4 specGloss = SampleMetallicSpecGloss(uv, albedoAlpha.a);
    outSurfaceData.albedo = albedoAlpha.rgb * _BaseColor.rgb;
    outSurfaceData.albedo = AlphaModulate(outSurfaceData.albedo, outSurfaceData.alpha);

#if _SPECULAR_SETUP
    outSurfaceData.metallic = half(1.0);
    outSurfaceData.specular = specGloss.rgb;
#else
    outSurfaceData.metallic = specGloss.r;
    outSurfaceData.specular = half3(0.0, 0.0, 0.0);
#endif

    outSurfaceData.smoothness = specGloss.a;
    outSurfaceData.normalTS = SampleNormal(uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
    outSurfaceData.occlusion = SampleOcclusion(uv);
    outSurfaceData.emission = SampleEmission(uv, _EmissionColor.rgb, TEXTURE2D_ARGS(_EmissionMap, sampler_EmissionMap));
    // ... 清漆(Lit 中恒为 0)与细节贴图
}

细节贴图(Detail Map)的处理也在这里。ApplyDetailAlbedo 把细节反照率乘 2 后与基础反照率相乘,所以细节贴图的中性值是 0.5 的灰(默认贴图正是 "linearGrey"),比它亮的地方提亮、暗的地方压暗;_DETAIL_SCALED 变体则先按 _DetailAlbedoMapScale 缩放偏离中性值的幅度。细节法线用 BlendNormalRNM(Reoriented Normal Mapping)与主法线混合,再按细节遮罩插值——源码里还留着一条 todo 注释,说更正确的做法应该插值旋转角而不是法线本身。第 7 章讨论过这几种法线混合方式的差别。

镜面工作流下 metallic 被设为 1——这不是说它是金属,而是一个“占位”,因为后面 InitializeBRDFData 在 _SPECULAR_SETUP 下根本不读 metallic。SurfaceData 的定义很短,注释写着它必须与 Shader Graph 的主节点一致——Shader Graph 生成的 Lit Shader 也把数据填进同一个结构,再调用同一个 UniversalFragmentPBR:

// 摘自 URP ShaderLibrary/SurfaceData.hlsl
// Must match Universal ShaderGraph master node
struct SurfaceData
{
    half3 albedo;
    half3 specular;
    half  metallic;
    half  smoothness;
    half3 normalTS;
    half3 emission;
    half  occlusion;
    half  alpha;
    half  clearCoatMask;
    half  clearCoatSmoothness;
};

10.4 LitForwardPass.hlsl:顶点阶段与 InputData

插值器 Varyings 里很多字段被 REQUIRES_* 宏控制。它们在文件开头和 Shadows.hlsl 中定义:

  • REQUIRES_WORLD_SPACE_TANGENT_INTERPOLATOR:有法线贴图或细节贴图时才传切线。
  • REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR:只有单级联主光阴影(_MAIN_LIGHT_SHADOWS)或屏幕空间阴影时,阴影坐标在顶点里算好再插值;用级联时必须逐像素选级联,所以在片元里算。
  • REQUIRES_WORLD_SPACE_POS_INTERPOLATOR:在 Shadows.hlsl 中被无条件定义,世界坐标总会传。

顶点函数 LitPassVertex 做的事情都很直白:GetVertexPositionInputs 得到各空间坐标,GetVertexNormalInputs 得到世界法线与切线;切线的 w 乘上 GetOddNegativeScale(),处理负缩放导致的镜像;没有光照贴图时用 OUTPUT_SH 宏在顶点里算 SH(是否真的在顶点算,取决于上一节的 EVALUATE_SH_*);雾因子在顶点里算好。

片元阶段的 InitializeInputData 是把插值器“翻译”成 InputData:

// 摘自 URP Shaders/LitForwardPass.hlsl(节选)
void InitializeInputData(Varyings input, half3 normalTS, out InputData inputData)
{
    inputData = (InputData)0;
    inputData.positionWS = input.positionWS;

    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
#if defined(_NORMALMAP) || defined(_DETAIL)
    float sgn = input.tangentWS.w;      // should be either +1 or -1
    float3 bitangent = sgn * cross(input.normalWS.xyz, input.tangentWS.xyz);
    half3x3 tangentToWorld = half3x3(input.tangentWS.xyz, bitangent.xyz, input.normalWS.xyz);
    inputData.normalWS = TransformTangentToWorld(normalTS, tangentToWorld);
#else
    inputData.normalWS = input.normalWS;
#endif
    inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS);
    inputData.viewDirectionWS = viewDirWS;

#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    inputData.shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS);
#else
    inputData.shadowCoord = float4(0, 0, 0, 0);
#endif
    // ... 雾、逐顶点附加光
    inputData.bakedGI = SAMPLE_GI(input.staticLightmapUV, input.vertexSH, inputData.normalWS);
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
    inputData.shadowMask = SAMPLE_SHADOWMASK(input.staticLightmapUV);
}

有两点值得注意。其一,GetWorldSpaceNormalizeViewDir 内部区分了透视与正交投影:正交相机下所有像素的视线方向相同,直接返回相机的前方向取反——所以用正交相机拍 PBR 物体,高光依然正确。其二,bakedGI(烘焙的间接漫反射)在这里就已经采样好了:有光照贴图就采光照贴图,否则用 SH。这意味着 InputData 不只是几何信息,还带着“这个像素收到的环境漫反射辐照度”。

片元函数本身很短:

// 摘自 URP Shaders/LitForwardPass.hlsl(节选)
void LitPassFragment(Varyings input, out half4 outColor : SV_Target0)
{
    // ... 视差映射 ApplyPerPixelDisplacement
    SurfaceData surfaceData;
    InitializeStandardLitSurfaceData(input.uv, surfaceData);
    // ... LOD Cross-Fade
    InputData inputData;
    InitializeInputData(input, surfaceData.normalTS, inputData);
    SETUP_DEBUG_TEXTURE_DATA(inputData, input.uv, _BaseMap);
#ifdef _DBUFFER
    ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData);
#endif
    half4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    color.a = OutputAlpha(color.a, IsSurfaceTypeTransparent(_Surface));
    outColor = color;
}

流程一目了然:采样材质 → 准备几何 → 贴花修改材质(DBuffer 贴花就是在这一步“改写” SurfaceData 的)→ 光照 → 雾 → 输出。真正的光照全部在 UniversalFragmentPBR 里。

10.5 UniversalFragmentPBR 逐行

UniversalFragmentPBR 是 URP 所有 PBR 类材质(Lit、Complex Lit、Shader Graph 的 Lit Target、粒子 Lit 等)共用的光照总入口,位于 ShaderLibrary/Lighting.hlsl。它接收前面准备好的两个结构体,返回最终颜色。下面的节选去掉了调试分支和清漆的细节,用圆圈数字标出了关键步骤,代码之后逐条解释。

// 摘自 URP ShaderLibrary/Lighting.hlsl(节选,省略 Debug 与清漆细节)
half4 UniversalFragmentPBR(InputData inputData, SurfaceData surfaceData)
{
    #if defined(_SPECULARHIGHLIGHTS_OFF)
    bool specularHighlightsOff = true;
    #else
    bool specularHighlightsOff = false;
    #endif
    BRDFData brdfData;
    // NOTE: can modify "surfaceData"...
    InitializeBRDFData(surfaceData, brdfData);                         // ①

    // ... DEBUG_DISPLAY:CanDebugOverrideOutputColor
    BRDFData brdfDataClearCoat = CreateClearCoatBRDFData(surfaceData, brdfData);
    half4 shadowMask = CalculateShadowMask(inputData);                 // ②
    AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
    uint meshRenderingLayers = GetMeshRenderingLayer();
    Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);   // ③

    MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI);   // ④
    LightingData lightingData = CreateLightingData(inputData, surfaceData);

    lightingData.giColor = GlobalIllumination(brdfData, brdfDataClearCoat, surfaceData.clearCoatMask,
                                              inputData.bakedGI, aoFactor.indirectAmbientOcclusion, inputData.positionWS,
                                              inputData.normalWS, inputData.viewDirectionWS, inputData.normalizedScreenSpaceUV);   // ⑤
    // ... _LIGHT_LAYERS 检查
    lightingData.mainLightColor = LightingPhysicallyBased(brdfData, brdfDataClearCoat, mainLight,
                                              inputData.normalWS, inputData.viewDirectionWS,
                                              surfaceData.clearCoatMask, specularHighlightsOff);   // ⑥
    #if defined(_ADDITIONAL_LIGHTS)
    uint pixelLightCount = GetAdditionalLightsCount();
    #if USE_FORWARD_PLUS
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        lightingData.additionalLightsColor += LightingPhysicallyBased(/* 同上 */);
    }
    #endif
    LIGHT_LOOP_BEGIN(pixelLightCount)                                    // ⑦
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        lightingData.additionalLightsColor += LightingPhysicallyBased(/* 同上 */);
    LIGHT_LOOP_END
    #endif

    #if defined(_ADDITIONAL_LIGHTS_VERTEX)
    lightingData.vertexLightingColor += inputData.vertexLighting * brdfData.diffuse;
    #endif
    return CalculateFinalColor(lightingData, surfaceData.alpha);       // ⑧
}

按编号逐段看:

① InitializeBRDFData:把 SurfaceData 换算成 BRDFData,10.6 节详解。注释“can modify surfaceData”指的是预乘透明(_ALPHAPREMULTIPLY_ON)时会修改 alpha 相关的量。

② ShadowMask 与 AO 因子:CalculateShadowMask 在使用 Shadowmask 光照贴图时读烘焙阴影,否则读光照探针的遮挡 unity_ProbesOcclusion。CreateAmbientOcclusionFactor 把 SSAO 结果与材质 AO 组合成两个数:indirectAmbientOcclusion = min(ssao, 材质AO) 用于间接光,directAmbientOcclusion = lerp(1, ssao, 直接光强度) 用于直接光。也就是说,材质的 AO 贴图只影响间接光,这符合物理:AO 描述的是环境光被遮挡的程度,而直接光的遮挡应当由阴影负责。SSAO 的“Direct Lighting Strength”参数则允许 SSAO 也稍微压暗直接光,属于艺术控制。

③ GetMainLight:返回一个 Light 结构体:方向、颜色(已乘强度,线性空间)、距离衰减、阴影衰减、光照层掩码。阴影衰减在这里通过 MainLightShadow 采样阴影贴图得到;开了 SSAO 时,光的颜色还会乘上 directAmbientOcclusion。

Light 结构体里的四个衰减量

Light 有 direction、color、distanceAttenuation、shadowAttenuation、layerMask 五个字段。主光是方向光,distanceAttenuation 通常为 1(传统 Forward 下它取自 unity_LightData.z,物体被灯的 Culling Mask 排除时为 0,这是“剔除”而不是距离衰减)。附加光的 distanceAttenuation 则包含了距离衰减与聚光灯的角度衰减(第 6 章讲过 URP 的平滑距离衰减公式),shadowAttenuation 是阴影贴图的采样结果。LightingPhysicallyBased 把两个衰减相乘后才乘 𝐧·𝐥,所以对 BRDF 来说,一盏灯无论是哪种类型,最终都被归一成“一个方向 + 一个已衰减的颜色”。这正是 9.3 节“精确光源求和”在代码里的形态。

④ MixRealtimeAndBakedGI:只在“光照贴图 + Subtractive 混合光照”模式下起作用:光照贴图里已经烘进了主光,实时阴影落下时要从光照贴图里减去主光的估计值,否则阴影里会有双重光照。

⑤ GlobalIllumination:间接光(环境漫反射 + 环境反射),10.9 节详解。

⑥ 主光:调用 LightingPhysicallyBased,即直接光的 BRDF × 辐照度,10.8 节详解。

⑦ 附加光:传统 Forward 路径下,GetAdditionalLightsCount() 返回影响当前物体的灯数(逐物体的光源列表,受 URP Asset 中“每物体灯数上限”约束),LIGHT_LOOP_BEGIN 就是一个普通 for 循环。Forward+ 下完全不同:

// 摘自 URP ShaderLibrary/Core.hlsl
#if defined(_FORWARD_PLUS)
#define _ADDITIONAL_LIGHTS 1
#undef _ADDITIONAL_LIGHTS_VERTEX
#define USE_FORWARD_PLUS 1
#else
#define USE_FORWARD_PLUS 0
#endif

// 摘自 URP ShaderLibrary/RealtimeLights.hlsl
#if USE_FORWARD_PLUS
    #define LIGHT_LOOP_BEGIN(lightCount) { \
    uint lightIndex; \
    ClusterIterator _urp_internal_clusterIterator = ClusterInit(inputData.normalizedScreenSpaceUV, inputData.positionWS, 0); \
    [loop] while (ClusterNext(_urp_internal_clusterIterator, lightIndex)) { \
        lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT; \
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
    #define LIGHT_LOOP_END } }
#endif

Forward+ 下 _ADDITIONAL_LIGHTS 被强制定义(管线并不会去开这个关键字,见 ForwardLights.cs 里 !m_UseForwardPlus 的条件),循环变成按屏幕分块(cluster)迭代光源列表,lightCount 参数根本没被使用。附加的方向光不进分块列表,所以前面单独有一个 for 循环处理它们。还有一个隐藏约定:宏里直接写了 inputData.normalizedScreenSpaceUV 和 inputData.positionWS——如果你在自己的 Shader 里把 InputData 变量起名为 d 或 input,Forward+ 变体会编译失败。

⑧ CalculateFinalColor:把 LightingData 的五个分量加起来:

// 摘自 URP ShaderLibrary/Lighting.hlsl(节选,去掉了 Debug 开关判断)
half3 CalculateLightingColor(LightingData lightingData, half3 albedo)
{
    half3 lightingColor = 0;
    lightingColor += lightingData.giColor;
    lightingColor += lightingData.mainLightColor;
    lightingColor += lightingData.additionalLightsColor;
    lightingColor += lightingData.vertexLightingColor;
    lightingColor *= albedo;          // PBR 路径传入的 albedo 是 1,不改变结果
    lightingColor += lightingData.emissionColor;
    return lightingColor;
}

各分量本身已经乘过 BRDF,所以 PBR 路径调用 CalculateFinalColor(lightingData, alpha) 时传入的 albedo 是 1。每个累加都包在 IsLightingFeatureEnabled(...) 里,这是 Rendering Debugger 的“Lighting Features”过滤功能:编辑器里可以单独关掉 GI、主光、附加光、自发光来调试——在 DEBUG_DISPLAY 未定义的变体中这些判断恒为真,会被编译器优化掉。

案例 10.1最小 Lit:自己喂数据给 UniversalFragmentPBR进阶单独打开 ↗

目标:写一个只有一百多行的 Lit Shader:自己填 SurfaceData 和 InputData,直接调用 URP 的 UniversalFragmentPBR,光照结果与 URP Lit 一致;同时提供 ShadowCaster、DepthOnly、DepthNormals Pass,并保持 SRP Batcher 兼容。

思路:三个以上的 Pass 要共享同一份 UnityPerMaterial,最稳妥的做法是把 CBUFFER 写进一个 hlsl,所有 Pass 都 include 它。阴影和深度 Pass 不必手写:URP 的 ShadowCasterPass.hlsl、DepthOnlyPass.hlsl 只依赖 _BaseMap、_BaseColor、_Cutoff 这几个名字(_ALPHATEST_ON 时才用到),先 include 我们的输入文件再 include 它们即可。

Assets/Cookbook/Ch10/MinimalLitInput.hlsl
#ifndef COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED
#define COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED

// “最小 Lit”的材质输入。三个 Pass(ForwardLit / ShadowCaster / DepthOnly)共用这一份,
// 保证 UnityPerMaterial 布局完全一致 —— 这是 SRP Batcher 兼容的前提。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// SurfaceInput.hlsl 声明了 _BaseMap / _BumpMap / _EmissionMap 以及 SampleAlbedoAlpha、SampleNormal、Alpha 等辅助函数,
// URP 的 ShadowCasterPass.hlsl、DepthOnlyPass.hlsl 也依赖这些名字。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half4  _EmissionColor;
    half   _Cutoff;        // ShadowCasterPass / DepthOnlyPass 在 _ALPHATEST_ON 时会用到
    half   _Metallic;
    half   _Smoothness;
    half   _BumpScale;
CBUFFER_END

#endif
#ifndef COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED
#define COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED

// “最小 Lit”的材质输入。(GLSL 版,对应 Ch10/MinimalLitInput.hlsl)
// 三个 Pass(ForwardLit / ShadowCaster / DepthOnly)共用这一份,
// 保证 UnityPerMaterial 布局完全一致 —— 这是 SRP Batcher 兼容的前提。
// HLSL 版 include 了 SurfaceInput.hlsl(声明 _BaseMap / _BumpMap / _EmissionMap 以及 SampleAlbedoAlpha、SampleNormal、Alpha);
// GLSL 没有 TEXTURE2D_ARGS 这种“贴图 + 采样器”参数宏,下面直接写出这几个函数,采样固定的那张贴图。

uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform vec4  _EmissionColor;
uniform float _Cutoff;        // ShadowCasterPass / DepthOnlyPass 在 _ALPHATEST_ON 时会用到
uniform float _Metallic;
uniform float _Smoothness;
uniform float _BumpScale;

// ---- SurfaceInput.hlsl ----
uniform sampler2D _BaseMap;
uniform sampler2D _BumpMap;
uniform sampler2D _EmissionMap;

vec4 SampleAlbedoAlpha(vec2 uv) { return texture(_BaseMap, uv); }

float Alpha(float albedoAlpha, vec4 color, float cutoff)
{
    float alpha = albedoAlpha * color.a;
#ifdef _ALPHATEST_ON
    if (alpha - cutoff < 0.0) discard;
#endif
    return alpha;
}

vec3 SampleNormal(vec2 uv, float scale)
{
#ifdef _NORMALMAP
    return UnpackNormalScale(texture(_BumpMap, uv), scale);
#else
    return vec3(0.0, 0.0, 1.0);
#endif
}

#endif

ForwardLit Pass 的关键字从 Lit 里挑出实时光照真正需要的部分(光照贴图、Light Cookie、Rendering Layers、DBuffer、Debug Display 等省略):

Assets/Cookbook/Ch10/MinimalLit.shader(节选:keywords)
// 材质关键字:有法线贴图才开(脚本创建材质时要自己 EnableKeyword)
#pragma shader_feature_local _NORMALMAP
// 管线关键字:与 URP 14 Lit.shader 保持同名,管线会按设置自动打开
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _FORWARD_PLUS
#pragma multi_compile_fog
#pragma multi_compile_instancing
// 材质关键字:有法线贴图才开(脚本创建材质时要自己 EnableKeyword)
#pragma shader_feature_local _NORMALMAP
// 管线关键字(主光阴影、附加光、软阴影、SSAO、雾):网页版引擎始终提供,不用声明;
// 反射探针混合/盒体投影、Forward+、GPU Instancing 网页版没有

核心是两个“填表”函数。SurfaceData 按金属度工作流填写,specular 留 0:

Assets/Cookbook/Ch10/MinimalLit.shader(节选:surface)
SurfaceData BuildSurfaceData(float2 uv)
{
    SurfaceData s = (SurfaceData)0;
    half4 albedoAlpha = SampleAlbedoAlpha(uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
    s.albedo     = albedoAlpha.rgb * _BaseColor.rgb;
    s.alpha      = 1.0;                      // 不透明
    s.metallic   = _Metallic;                // 金属度工作流:specular 留 0
    s.specular   = 0;
    s.smoothness = _Smoothness;
    s.normalTS   = SampleNormal(uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
    s.occlusion  = 1.0;
    s.emission   = _EmissionColor.rgb;
    s.clearCoatMask = 0;
    s.clearCoatSmoothness = 0;
    return s;
}
SurfaceData BuildSurfaceData(vec2 uv)
{
    SurfaceData s = InitSurfaceData();
    vec4 albedoAlpha = SampleAlbedoAlpha(uv);
    s.albedo     = albedoAlpha.rgb * _BaseColor.rgb;
    s.alpha      = 1.0;                      // 不透明
    s.metallic   = _Metallic;                // 金属度工作流:specular 留 0
    s.specular   = vec3(0.0);
    s.smoothness = _Smoothness;
    s.normalTS   = SampleNormal(uv, _BumpScale);
    s.occlusion  = 1.0;
    s.emission   = _EmissionColor.rgb;
    s.clearCoatMask = 0.0;
    s.clearCoatSmoothness = 0.0;
    return s;
}

InputData 的写法与 InitializeInputData 一致,有三处简化:阴影坐标总是逐像素计算(省掉一个插值器,代价是每像素一次矩阵乘法);环境漫反射直接逐像素 SampleSH;ShadowMask 设为全 1(没有光照贴图):

Assets/Cookbook/Ch10/MinimalLit.shader(节选:inputdata)
InputData BuildInputData(Varyings i, half3 normalTS)
{
    InputData d = (InputData)0;
    d.positionWS = i.positionWS;
    d.positionCS = i.positionCS;

#if defined(_NORMALMAP)
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    d.tangentToWorld = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    d.normalWS = TransformTangentToWorld(normalTS, d.tangentToWorld);
#else
    d.normalWS = i.normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

    d.shadowCoord = TransformWorldToShadowCoord(i.positionWS);   // 逐像素算级联,省一个插值器
    d.fogCoord = i.fogFactor;
    d.vertexLighting = 0;
    d.bakedGI = SampleSH(d.normalWS);                            // 无光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    d.shadowMask = half4(1, 1, 1, 1);
    return d;
}
InputData BuildInputData(vec3 normalTS)
{
    InputData d = InitInputData();
    d.positionWS = positionWS;
    d.positionCS = gl_FragCoord;

#if defined(_NORMALMAP)
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    d.tangentToWorld = mat3(tangentWS.xyz, bitangent, normalWS);   // GLSL 的 mat3 按列构造:列 = T、B、N
    d.normalWS = TransformTangentToWorld(normalTS, d.tangentToWorld);
#else
    d.normalWS = normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);

    d.shadowCoord = TransformWorldToShadowCoord(positionWS);   // 逐像素算级联,省一个插值器
    d.fogCoord = fogFactor;
    d.vertexLighting = vec3(0.0);
    d.bakedGI = SampleSH(d.normalWS);                          // 无光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    d.shadowMask = vec4(1.0);
    return d;
}
Assets/Cookbook/Ch10/MinimalLit.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    SurfaceData surfaceData = BuildSurfaceData(i.uv);
    InputData inputData = BuildInputData(i, surfaceData.normalTS);

    half4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    return color;
}
void main()
{
    SurfaceData surfaceData = BuildSurfaceData(uv);
    InputData inputData = BuildInputData(surfaceData.normalTS);

    vec4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    fragColor = color;
}

注意变量名必须叫 inputData——原因见上一节 Forward+ 的 LIGHT_LOOP_BEGIN。最后是三个辅助 Pass:

Assets/Cookbook/Ch10/MinimalLit.shader(节选:shadowpass)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex ShadowPassVertex
    #pragma fragment ShadowPassFragment
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // 先 include 自己的输入(CBUFFER 与 ForwardLit 相同),再直接复用 URP 的阴影投射 Pass
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
    ENDHLSL
}

Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex DepthOnlyVertex
    #pragma fragment DepthOnlyFragment
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
    ENDHLSL
}

Pass
{
    // 开了 SSAO(Depth Normals 模式)或其他需要 _CameraNormalsTexture 的功能时会用到
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex DepthNormalsVertex
    #pragma fragment DepthNormalsFragment
    #pragma shader_feature_local _NORMALMAP
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0

    GLSLPROGRAM
    // 先 include 自己的输入(uniform 与 ForwardLit 相同),再复用 URP 的阴影投射 Pass(ShadowCasterPass.hlsl 的 GLSL 写法)
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()   // ShadowPassVertex
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        gl_Position = GetShadowPositionHClip(positionOS.xyz, normalOS);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // ShadowPassFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}

Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R

    GLSLPROGRAM
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()   // DepthOnlyVertex
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        gl_Position = TransformObjectToHClip(positionOS.xyz);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // DepthOnlyFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
        fragColor = vec4(gl_FragCoord.z);
    }
    #endif
    ENDGLSL
}

Pass
{
    // 开了 SSAO(Depth Normals 模式)或其他需要 _CameraNormalsTexture 的功能时会用到
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On

    GLSLPROGRAM
    #pragma shader_feature_local _NORMALMAP
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    VARYING vec3 normalWS;
    VARYING vec4 tangentWS;
    #ifdef VERTEX
    void main()   // DepthNormalsVertex(LitDepthNormalsPass.hlsl)
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        VertexNormalInputs n = GetVertexNormalInputs(normalOS, tangentOS);
        normalWS = n.normalWS;
        tangentWS = vec4(n.tangentWS, tangentOS.w * GetOddNegativeScale());
        gl_Position = TransformObjectToHClip(positionOS.xyz);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // DepthNormalsFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
    #ifdef _NORMALMAP
        vec3 nTS = SampleNormal(uv, _BumpScale);
        vec3 b = tangentWS.w * cross(normalWS, tangentWS.xyz);
        vec3 n = normalize(mat3(tangentWS.xyz, b, normalWS) * nTS);
    #else
        vec3 n = normalize(normalWS);
    #endif
        fragColor = vec4(n, 0.0);
    }
    #endif
    ENDGLSL
}
图 10-3左:URP Lit;右:本案例的 MinimalLit。同一场景(方向光 + 橙色点光 + 程序化天空),金属球、塑料球、带法线贴图的砖块和地面都换成了对应 Shader,结果几乎逐像素一致,阴影也正确投射与接收。
扩展方向想加 Mask Map,就在 BuildSurfaceData 里采样一张贴图,把 R、G、A 分别填进 metallic、occlusion、smoothness;想支持光照贴图,就照 Lit 加上 LIGHTMAP_ON 等关键字、DECLARE_LIGHTMAP_OR_SH 插值器和 SAMPLE_GI,再补一个 Meta Pass。Shader Graph 的 Lit Target 生成的代码,本质上也是这个结构。
常见坑① 不要用 UsePass "Universal Render Pipeline/Lit/ShadowCaster" 偷懒:被引用 Pass 的 CBUFFER 是 Lit 的布局,与你的 ForwardLit 不同,整个 Shader 会失去 SRP Batcher 兼容性(材质 Inspector 顶部会提示原因)。② 漏掉 _FORWARD_PLUS,Forward+ 渲染器下附加光不亮。③ 漏掉 _MAIN_LIGHT_SHADOWS_SCREEN 不会报错,但开启屏幕空间阴影(Screen Space Shadows Renderer Feature)后会选到“无阴影”变体——本案例为简洁省略了它,需要时照 Lit 补上,并像 GetShadowCoord 那样为它计算屏幕坐标。

10.6 BRDF.hlsl:InitializeBRDFData

先看 BRDFData 的字段和一个常量:

// 摘自 URP ShaderLibrary/BRDF.hlsl
#define kDielectricSpec half4(0.04, 0.04, 0.04, 1.0 - 0.04) // standard dielectric reflectivity coef at incident angle (= 4%)

struct BRDFData
{
    half3 albedo;
    half3 diffuse;
    half3 specular;
    half reflectivity;
    half perceptualRoughness;
    half roughness;
    half roughness2;
    half grazingTerm;

    // We save some light invariant BRDF terms so we don't have to recompute
    // them in the light loop. Take a look at DirectBRDF function for detailed explaination.
    half normalizationTerm;     // roughness * 4.0 + 2.0
    half roughness2MinusOne;    // roughness^2 - 1.0
};

kDielectricSpec 就是第 9 章的电介质 F0 = 0.04,第四个分量预存了 1−0.04=0.96。BRDFData 里的命名与第 9 章的对应关系是:perceptualRoughness = r,roughness = α=r2,roughness2 = α2,specular = F0,diffuse = 漫反射颜色。最后两个字段是与灯光无关的预计算量,留到下一节。

// 摘自 URP ShaderLibrary/BRDF.hlsl
half OneMinusReflectivityMetallic(half metallic)
{
    // We'll need oneMinusReflectivity, so
    //   1-reflectivity = 1-lerp(dielectricSpec, 1, metallic) = lerp(1-dielectricSpec, 0, metallic)
    // store (1-dielectricSpec) in kDielectricSpec.a, then
    //   1-reflectivity = lerp(alpha, 0, metallic) = alpha + metallic*(0 - alpha) =
    //                  = alpha - metallic * alpha
    half oneMinusDielectricSpec = kDielectricSpec.a;
    return oneMinusDielectricSpec - metallic * oneMinusDielectricSpec;
}

inline void InitializeBRDFData(half3 albedo, half metallic, half3 specular, half smoothness, inout half alpha, out BRDFData outBRDFData)
{
#ifdef _SPECULAR_SETUP
    half reflectivity = ReflectivitySpecular(specular);      // max(specular.r, g, b)
    half oneMinusReflectivity = half(1.0) - reflectivity;
    half3 brdfDiffuse = albedo * oneMinusReflectivity;
    half3 brdfSpecular = specular;
#else
    half oneMinusReflectivity = OneMinusReflectivityMetallic(metallic);
    half reflectivity = half(1.0) - oneMinusReflectivity;
    half3 brdfDiffuse = albedo * oneMinusReflectivity;
    half3 brdfSpecular = lerp(kDieletricSpec.rgb, albedo, metallic);
#endif
    InitializeBRDFDataDirect(albedo, brdfDiffuse, brdfSpecular, reflectivity, oneMinusReflectivity, smoothness, alpha, outBRDFData);
}

inline void InitializeBRDFDataDirect(half3 albedo, half3 diffuse, half3 specular, half reflectivity, half oneMinusReflectivity, half smoothness, inout half alpha, out BRDFData outBRDFData)
{
    outBRDFData = (BRDFData)0;
    outBRDFData.albedo = albedo;
    outBRDFData.diffuse = diffuse;
    outBRDFData.specular = specular;
    outBRDFData.reflectivity = reflectivity;

    outBRDFData.perceptualRoughness = PerceptualSmoothnessToPerceptualRoughness(smoothness);
    outBRDFData.roughness           = max(PerceptualRoughnessToRoughness(outBRDFData.perceptualRoughness), HALF_MIN_SQRT);
    outBRDFData.roughness2          = max(outBRDFData.roughness * outBRDFData.roughness, HALF_MIN);
    outBRDFData.grazingTerm         = saturate(smoothness + reflectivity);
    outBRDFData.normalizationTerm   = outBRDFData.roughness * half(4.0) + half(2.0);
    outBRDFData.roughness2MinusOne  = outBRDFData.roughness2 - half(1.0);
    // ... _ALPHAPREMULTIPLY_ON 时 diffuse *= alpha
}

(源码里 kDieletricSpec 少了一个字母 c,是历史拼写,Deprecated.hlsl 用 #define kDieletricSpec kDielectricSpec 把它指向正确的名字。)

albedo metallic m smoothness s 1−reflectivity = 0.96(1−m) diffuse = albedo · 0.96(1−m) specular = lerp(0.04, albedo, m) grazingTerm = sat(s + reflectivity) perceptualRoughness r = 1−s roughness α = max(r², 2⁻⁷) roughness2 α² = max(α², 2⁻¹⁴) normalizationTerm = 4α + 2roughness2MinusOne = α² − 1
图 10-4金属度工作流下 InitializeBRDFData 的派生关系。橙色框是 BRDFData 中供光照函数使用的字段。

和第 9 章对照,有三点值得说:

漫反射的能量扣除是“与角度无关”的。第 9 章说过,严格的做法是漫反射乘 (1−F(𝐯·𝐡)),随角度变化。URP 只乘一个常数 oneMinusReflectivity:电介质是 0.96,金属是 0。它保证了“金属没有漫反射”和“F0 大的材质漫反射少”,但掠射时漫反射没有相应减弱。这是一个省计算的近似,对电介质影响很小(F 在掠射前一直接近 0.04)。

镜面工作流的 reflectivity 取 F0 三通道最大值(GLES 2 上只取 R 通道以省指令)。所以镜面色越亮,漫反射越暗,自动保证 diffuse+F0≤1。这也是案例 9.8 中“红漫反射 + 蓝高光”仍然不会过曝的原因。

grazingTerm 是掠射时环境反射趋向的颜色,等于 saturate(smoothness + reflectivity)。理想情况下掠射反射率趋于 1(白色),但粗糙表面在掠射时由于遮蔽,实际反射远小于 1,于是 URP 用光滑度把它压低:完全粗糙的电介质,grazingTerm 只有 0.04,不会出现“粗糙物体边缘一圈亮边”。这个量只在环境光里使用(10.9 节)。

深入:HALF_MIN_SQRT 与 HALF_MINcore 的 Macros.hlsl 定义 HALF_MIN = 6.103515625e-5(2−14,half 最小正规数),HALF_MIN_SQRT = 0.0078125(2−7)。给 α 设下限 2−7,保证 α2≥2−14 不会在 half 中下溢为 0,GGX 的分子不为 0。对应的最小感知粗糙度约为 2−7≈0.088:Smoothness 填 0.92 以上,高光的形状就不再变化了。

10.7 DirectBRDFSpecular:它到底近似了什么

这是整个 Lit 里最值得细读的一个函数:

// 摘自 URP ShaderLibrary/BRDF.hlsl
// Computes the scalar specular term for Minimalist CookTorrance BRDF
// NOTE: needs to be multiplied with reflectance f0, i.e. specular color to complete
half DirectBRDFSpecular(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS)
{
    float3 lightDirectionWSFloat3 = float3(lightDirectionWS);
    float3 halfDir = SafeNormalize(lightDirectionWSFloat3 + float3(viewDirectionWS));

    float NoH = saturate(dot(float3(normalWS), halfDir));
    half LoH = half(saturate(dot(lightDirectionWSFloat3, halfDir)));

    // GGX Distribution multiplied by combined approximation of Visibility and Fresnel
    // BRDFspec = (D * V * F) / 4.0
    // D = roughness^2 / ( NoH^2 * (roughness^2 - 1) + 1 )^2
    // V * F = 1.0 / ( LoH^2 * (roughness + 0.5) )
    // See "Optimizing PBR for Mobile" from Siggraph 2015 moving mobile graphics course
    // https://community.arm.com/events/1155

    // Final BRDFspec = roughness^2 / ( NoH^2 * (roughness^2 - 1) + 1 )^2 * (LoH^2 * (roughness + 0.5) * 4.0)
    // We further optimize a few light invariant terms
    // brdfData.normalizationTerm = (roughness + 0.5) * 4.0 rewritten as roughness * 4.0 + 2.0 to a fit a MAD.
    float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f;

    half LoH2 = LoH * LoH;
    half specularTerm = brdfData.roughness2 / ((d * d) * max(0.1h, LoH2) * brdfData.normalizationTerm);

#if REAL_IS_HALF
    specularTerm = specularTerm - HALF_MIN;
    specularTerm = clamp(specularTerm, 0.0, 1000.0); // Prevent FP16 overflow on mobiles
#endif
    return specularTerm;
}

把代码翻译成公式(α 即 roughness):

specularTerm=α2d2·max(0.1, (𝐥·𝐡)2)·(4α+2),d=(𝐧·𝐡)2(α2−1)+1

与第 9 章的 GGX 对比:α2/d2 正是 πDGGX。于是:

specularTerm=πDGGX·14(𝐥·𝐡)2(α+0.5)

调用处会再乘 brdfData.specular(即 F0),以及 lightColor * NdotL。按 9.4 节的约定,标准 Cook-Torrance 在这里应该是 πDVF。两相比较,URP 做的近似就是:

V(𝐥,𝐯)F(𝐯,𝐡)≈F04(𝐥·𝐡)2(α+0.5)

这个形式来自 Renaldas Zioma 在 SIGGRAPH 2015 “Moving Mobile Graphics” 课程中的《Optimizing PBR for Mobile》,其前身是 Kelemen 与 Szirmay-Kalos 的可见性近似 V≈1/(4(𝐥·𝐡)2)。可以这样理解各部分:

  • 1/(𝐥·𝐡)2:掠射时 𝐥·𝐡 变小,这一项变大,同时模拟了可见性项和菲涅尔项在掠射时的增大(源码注释称“Fresnel approximated with 1/LdotH”)。它与 𝐧·𝐥、𝐧·𝐯 无关,是“光源无关量少、指令少”的关键。
  • 1/(α+0.5):对粗糙度的修正。粗糙表面 Smith G 的遮蔽更强,这一项让粗糙时的高光变暗。
  • max(0.1h, LoH2):限制 1/(𝐥·𝐡)2 最多放大 10 倍,防止逆光时爆亮。
  • 1.00001f:α 很小且 𝐧·𝐡=1 时,d 可能非常接近 0,加一点点避免除零。
  • normalizationTerm 与 roughness2MinusOne:与灯光无关,InitializeBRDFData 时算一次,循环里每盏灯省两条指令;4α+2 写成一次乘加(MAD)。
  • half 精度平台上把结果钳到 1000 以内,防止 FP16 溢出(注释解释了 1000 的来历:给每盏灯留出一半的 half 范围)。

为什么要绕开 𝐧·𝐥 和 𝐧·𝐯,改用 𝐥·𝐡?标准可见性项里两个方向的 Λ 各需要一次开方,而且分母里的 𝐧·𝐥、𝐧·𝐯 在轮廓和明暗交界处趋于 0,在 half 精度下极易产生无穷大或噪点。𝐥·𝐡 则有一个很好的性质:它等于 cos(θlv/2),只要光和视线不是完全相反,它就不会接近 0,配合 max(0.1h, LoH2) 更是彻底排除了除零。代价是失去了“轮廓处遮蔽加剧”的信息——这也解释了案例 10.2 中 URP 的粗糙球边缘没有亮边。

另一个容易误读的地方是“F 去哪了”。URP 的公式里确实没有 (1−𝐯·𝐡)5 这样的菲涅尔项,brdfData.specular 是常数 F0。直接光的菲涅尔增强完全靠 1/(𝐥·𝐡)2 间接体现:逆光时 𝐥·𝐡 变小,高光变亮。对于 F0 = 0.04 的电介质,真实菲涅尔在掠射时可增大到二十多倍,而这里最多 10 倍,所以 URP 的逆光高光会比标准公式温和——第 9 章图 9-16 那种强烈的路面眩光,在 URP Lit 里会弱一些。

它偏差多大

近似的好坏要用数字说话。先看最简单的情形:视线和光都沿法线(𝐥=𝐯=𝐧)。此时标准形式的 V 对任意 α 都恰好是 1/4(代入高度相关 Smith 可得),F = F0;URP 的近似则是 F0/(4(α+0.5))。两者之比:

URP标准=1α+0.5

α→0(非常光滑)时是 2 倍,α=0.5(感知粗糙度约 0.71)时恰好相等,α=1 时只有 0.67 倍。也就是说:URP 的直接高光在光滑材质上偏亮,最多接近两倍;在很粗糙的材质上偏暗。下图把“视线沿法线、光源扫过入射平面”的整个高光波瓣画出来:

感知粗糙度 0.3 0 0.5 1 1.5 2 2.5 0 30 60 90 光线与法线夹角(度),视线沿法线 镜面出射(π·f·cosθ, F0=0.04) 标准 GGX·Smith·Schlick URP DirectBRDFSpecular 感知粗糙度 0.7 0 0.03 0.06 0.09 0.12 0 30 60 90 光线与法线夹角(度),视线沿法线 标准 GGX·Smith·Schlick URP DirectBRDFSpecular
图 10-5视线沿法线时,镜面出射(F0 = 0.04,已乘 π 与 cosθ)随光线角度的变化。左:感知粗糙度 0.3,URP 的峰值约为标准公式的 1.7 倍;右:感知粗糙度 0.7,两者几乎重合。数据由本书的 Python 脚本按两组公式直接计算。

案例 10.2URP 高光 vs 标准 GGX:并排看进阶单独打开 ↗

目标:在同一个 Shader 里,用 URP 的 InitializeBRDFData + DirectBRDFSpecular 和 core 包的 D_GGX、V_SmithJointGGX、F_Schlick 分别计算主光的直接高光,用两排粗糙度递增的球对比。

Assets/Cookbook/Ch10/SpecCompare.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    Light light = GetMainLight();
    float3 L = light.direction;
    float NoL = saturate(dot(N, L));

    // 用 URP 自己的初始化:金属度 1、albedo = F0,于是 brdf.specular = F0、brdf.diffuse = 0
    half alpha = 1;
    BRDFData brdf;
    InitializeBRDFData(_F0.rgb, 1.0, 0, _Smoothness, alpha, brdf);

    float3 spec;   // 已经乘了 π 的“Unity 约定”镜面项,乘 lightColor·NoL 即出射辐射率
    if (_Mode < 0.5)
    {
        spec = brdf.specular * DirectBRDFSpecular(brdf, N, L, V);
    }
    else
    {
        float3 H = SafeNormalize(L + V);
        float NoV = max(dot(N, V), 1e-4), NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));
        float a = brdf.roughness;                                   // α,与 URP 用同一个值
        float DV = D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);    // core/BSDF.hlsl
        spec = PI * DV * F_Schlick(_F0.rgb, VoH);
    }

    float3 c = spec * light.color * NoL;
    return half4(1.0 - exp(-c * _Exposure), 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light light = GetMainLight();
    vec3 L = light.direction;
    float NoL = saturate(dot(N, L));

    // 用 URP 自己的初始化:金属度 1、albedo = F0,于是 brdf.specular = F0、brdf.diffuse = 0
    float alpha = 1.0;
    BRDFData brdf;
    InitializeBRDFData(_F0.rgb, 1.0, vec3(0.0), _Smoothness, alpha, brdf);

    vec3 spec;   // 已经乘了 π 的“Unity 约定”镜面项,乘 lightColor·NoL 即出射辐射率
    if (_Mode < 0.5)
    {
        spec = brdf.specular * DirectBRDFSpecular(brdf, N, L, V);
    }
    else
    {
        vec3 H = SafeNormalize(L + V);
        float NoV = max(dot(N, V), 1e-4), NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));
        float a = brdf.roughness;                                   // α,与 URP 用同一个值
        float DV = D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);    // core/BSDF.hlsl
        spec = PI * DV * F_Schlick(_F0.rgb, VoH);
    }

    vec3 c = spec * light.color * NoL;
    fragColor = vec4(1.0 - exp(-c * _Exposure), 1.0);
}

参照组完全使用 core 包现成的标准函数:D_GGX 带 1/π,所以结果再乘 π 与 URP 的约定对齐;V_SmithJointGGX 是 9.7 节的高度相关可见性项。两组用同一个 brdf.roughness,保证 α 相同。为了让暗淡的粗糙高光也看得见,F0 取 0.9,输出用 1−e−x 映射。

图 10-6只输出主光的直接高光。上排:URP DirectBRDFSpecular;下排:标准 GGX · Smith(高度相关)· Schlick。感知粗糙度 0.1 → 0.9(Smoothness 0.9 → 0.1)。

与图 10-5 的数字一致:前两列 URP 的亮核更大更亮;第四、五列下排更亮,而且在右下方明暗交界线附近有一圈明显的亮边——那是标准公式里 V 在掠射时增大、Schlick 的 F 增大共同造成的,而 URP 的 1/(𝐥·𝐡)2 被钳在 10 倍以内,没有这圈亮边。哪一个“更对”?从能量上看是下排(下一个案例验证);从观感上看,粗糙物体边缘这圈亮边在实际场景中常被认为“不干净”,URP 的近似反而更讨美术喜欢。这就是工程上的取舍。

案例 10.3白炉测试:URP 的直接高光守恒吗高阶单独打开 ↗

目标:用第 9 章案例 9.7 的方法,对 URP 的 DirectBRDFSpecular 做白炉测试(F0 = 1),与标准 GGX 对比。

采样仍按 GGX 的 D(𝐡)(𝐧·𝐡) 进行,被积函数换成 URP 的公式。注意 DirectBRDFSpecular 按 Unity 约定已经乘了 π,积分前要除回去,还原成真正的 BRDF:

Assets/Cookbook/Ch10/SpecFurnace.shader(节选:estimate)
half4 frag(Varyings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float NoV = max(dot(N, V), 1e-4);

    half alpha = 1;
    BRDFData brdf;
    InitializeBRDFData(half3(1, 1, 1), 1.0, 0, _Smoothness, alpha, brdf);   // F0 = 1
    float a = brdf.roughness;

    float3 up = abs(N.y) < 0.999 ? float3(0, 1, 0) : float3(1, 0, 0);
    float3 T = normalize(cross(up, N)), B = cross(N, T);

    uint count = (uint)_SampleCount;
    float sum = 0;
    for (uint k = 0; k < count; k++)
    {
        float2 xi = float2((k + 0.5) / count, RadicalInverse(k));
        float cosT = sqrt((1 - xi.x) / (1 + (a * a - 1) * xi.x));
        float sinT = sqrt(1 - cosT * cosT), phi = TWO_PI * xi.y;
        float3 H = T * (sinT * cos(phi)) + B * (sinT * sin(phi)) + N * cosT;
        float3 L = reflect(-V, H);
        float NoL = dot(N, L);
        if (NoL <= 0) continue;
        float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));

        float pdf = D_GGX(NoH, a) * NoH / (4 * VoH);                       // 按 GGX 抽样的概率密度
        float f = (_Mode < 0.5)
            ? DirectBRDFSpecular(brdf, N, L, V) * INV_PI                   // 去掉 Unity 约定里的 π,还原成 BRDF
            : D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);                // F = 1
        sum += f * NoL / max(pdf, 1e-6);
    }
    float E = sum / count;
    return half4(_EnvColor.rgb * E, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    float NoV = max(dot(N, V), 1e-4);

    float alpha = 1.0;
    BRDFData brdf;
    InitializeBRDFData(vec3(1.0), 1.0, vec3(0.0), _Smoothness, alpha, brdf);   // F0 = 1
    float a = brdf.roughness;

    vec3 up = abs(N.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
    vec3 T = normalize(cross(up, N)), B = cross(N, T);

    uint count = uint(_SampleCount);
    float sum = 0.0;
    for (uint k = 0u; k < count; k++)
    {
        vec2 xi = vec2((float(k) + 0.5) / float(count), RadicalInverse(k));
        float cosT = sqrt((1.0 - xi.x) / (1.0 + (a * a - 1.0) * xi.x));
        float sinT = sqrt(1.0 - cosT * cosT), phi = TWO_PI * xi.y;
        vec3 H = T * (sinT * cos(phi)) + B * (sinT * sin(phi)) + N * cosT;
        vec3 L = reflect(-V, H);
        float NoL = dot(N, L);
        if (NoL <= 0.0) continue;
        float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H));

        float pdf = D_GGX(NoH, a) * NoH / (4.0 * VoH);                     // 按 GGX 抽样的概率密度
        float f = (_Mode < 0.5)
            ? DirectBRDFSpecular(brdf, N, L, V) * INV_PI                   // 去掉 Unity 约定里的 π,还原成 BRDF
            : D_GGX(NoH, a) * V_SmithJointGGX(NoL, NoV, a);                // F = 1
        sum += f * NoL / max(pdf, 1e-6);
    }
    float E = sum / float(count);
    fragColor = vec4(_EnvColor.rgb * E, 1.0);
}
图 10-7白炉测试(F0 = 1,环境亮度与背景相同)。上排:URP 近似;下排:标准 GGX。感知粗糙度 0.1 → 0.9。下排光滑的球完全融入背景;上排光滑的球明显比背景亮,边缘却有一圈暗边。
0 0.5 1 1.5 2 0 0.2 0.4 0.6 0.8 1 观察方向余弦 μ = n·v 方向反照率 E(F0 = 1) 标准 GGX,粗糙度 0.2 URP 近似,粗糙度 0.2 标准 GGX,粗糙度 0.5 URP 近似,粗糙度 0.5 标准 GGX,粗糙度 0.8 URP 近似,粗糙度 0.8
图 10-8方向反照率 E 随观察角的变化(F0 = 1,数值积分)。实线:标准 GGX,始终 ≤ 1;虚线:URP 近似,感知粗糙度 0.2 时在大部分角度约为 1.8,掠射时迅速跌落。

结论很清楚:在 F0 = 1、粗糙度较低时,URP 的直接高光近似会反射出比入射更多的能量(约 1.8 倍),违反能量守恒;在粗糙度较高时又比标准公式更暗。实际材质的 F0 远小于 1(电介质 0.04),这个“多出来的能量”绝对值很小,加上直接高光只占画面的一小部分,所以日常使用中不易察觉。但如果你在做材质校准、或者把 URP Lit 的结果和离线渲染器、HDRP、第 11 章手写的标准 PBR 并排比较,就会发现光滑材质的直接高光亮度有系统性差异——原因就在这里。

提示如果项目确实需要更标准的直接高光,不必改 URP 源码:像案例 10.1 那样写自己的 Lit,在光照循环里把 DirectBRDFSpecular 替换成 DV_SmithJointGGX(NoH, NoL, NoV, roughness) * F_Schlick(...) * PI 即可(core 已提供 D 与 V 合并优化后的 DV_SmithJointGGX)。代价是每盏灯多一个平方根和若干乘法,移动端要权衡。

10.8 LightingPhysicallyBased:直接光的入口

// 摘自 URP ShaderLibrary/Lighting.hlsl(节选)
half3 LightingPhysicallyBased(BRDFData brdfData, BRDFData brdfDataClearCoat,
    half3 lightColor, half3 lightDirectionWS, half lightAttenuation,
    half3 normalWS, half3 viewDirectionWS,
    half clearCoatMask, bool specularHighlightsOff)
{
    half NdotL = saturate(dot(normalWS, lightDirectionWS));
    half3 radiance = lightColor * (lightAttenuation * NdotL);

    half3 brdf = brdfData.diffuse;
#ifndef _SPECULARHIGHLIGHTS_OFF
    [branch] if (!specularHighlightsOff)
    {
        brdf += brdfData.specular * DirectBRDFSpecular(brdfData, normalWS, lightDirectionWS, viewDirectionWS);
#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
        // ... 清漆层再算一次 DirectBRDFSpecular,用 Pow4(1-NoV) 的菲涅尔与底层混合
#endif
    }
#endif
    return brdf * radiance;
}

对照第 9 章 9.4 节的公式 Lo=(𝐜diff+πfs)𝐜light(𝐧·𝐥),几乎是逐字翻译:radiance 就是 𝐜light(𝐧·𝐥) 再乘上距离衰减与阴影;brdf 就是括号里的部分。变量名叫 radiance 其实不太准确——按辐射度量学,它是“除以 π 之后的辐照度”,乘上 BRDF 后才是出射辐射率。

specularHighlightsOff 有两层保护:材质关键字 _SPECULARHIGHLIGHTS_OFF 在编译期直接去掉高光代码;同时保留一个运行时的 bool 分支,供 Shader Graph 等调用者在不切换变体的情况下关闭高光。[branch] 提示编译器生成真正的分支而不是两边都算。

清漆(Clear Coat)只在 Complex Lit 中启用,它把第二层 BRDF 的高光叠在底层之上,并按 1−coatFresnel·mask 衰减底层——第 12 章细讲。

源码里的一个小问题Lighting.hlsl 里有一个“向后兼容”的重载 LightingPhysicallyBased(BRDFData, half3 lightColor, half3 lightDirectionWS, half lightAttenuation, half3 normalWS, half3 viewDirectionWS, bool specularHighlightsOff),它内部写的是 return LightingPhysicallyBased(brdfData, light, viewDirectionWS, specularHighlightsOff, specularHighlightsOff);——把视线方向当成了法线、把 bool 当成了视线方向传下去。在 14.0.12 中调用这个重载会得到错误的光照。自己写 Shader 时,请使用接收 Light 结构体的版本。

10.9 GlobalIllumination.hlsl:环境光

间接光的入口函数:

// 摘自 URP ShaderLibrary/GlobalIllumination.hlsl(节选)
half3 GlobalIllumination(BRDFData brdfData, BRDFData brdfDataClearCoat, float clearCoatMask,
    half3 bakedGI, half occlusion, float3 positionWS,
    half3 normalWS, half3 viewDirectionWS, float2 normalizedScreenSpaceUV)
{
    half3 reflectVector = reflect(-viewDirectionWS, normalWS);
    half NoV = saturate(dot(normalWS, viewDirectionWS));
    half fresnelTerm = Pow4(1.0 - NoV);

    half3 indirectDiffuse = bakedGI;
    half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdfData.perceptualRoughness, 1.0h, normalizedScreenSpaceUV);

    half3 color = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);
    // ... 清漆
    return color * occlusion;
}

结构与第 9 章说的 IBL 完全对应:漫反射用预积分的辐照度 bakedGI(已在 InputData 里采样好),镜面用反射向量查反射探针,最后两者都乘 AO。下面逐个看。

间接漫反射:SH 与光照贴图

bakedGI 来自 SAMPLE_GI 宏:有光照贴图时展开为 SampleLightmap,否则展开为 SampleSHPixel。后者根据 EVALUATE_SH_* 决定在顶点算还是在像素算,或者“混合”——L2 项在顶点算、L0/L1 项在像素算。SH 系数 unity_SHAr 等由引擎为每个物体从光照探针或环境光插值得到。SH 存储的已经是“辐照度 / π”形式(与 Unity 的灯光约定一致),乘上 brdfData.diffuse 就是间接漫反射。SH 与光照贴图的细节见第 8 章。

间接镜面:选 mip

// 摘自 URP ShaderLibrary/GlobalIllumination.hlsl(非 Forward+、未开探针混合的路径)
half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness, half occlusion, float2 normalizedScreenSpaceUV)
{
#if !defined(_ENVIRONMENTREFLECTIONS_OFF)
    half3 irradiance;
#if defined(_REFLECTION_PROBE_BLENDING) || USE_FORWARD_PLUS
    irradiance = CalculateIrradianceFromReflectionProbes(reflectVector, positionWS, perceptualRoughness, normalizedScreenSpaceUV);
#else
#ifdef _REFLECTION_PROBE_BOX_PROJECTION
    reflectVector = BoxProjectedCubemapDirection(reflectVector, positionWS, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax);
#endif
    half mip = PerceptualRoughnessToMipmapLevel(perceptualRoughness);
    half4 encodedIrradiance = half4(SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVector, mip));
    irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
#endif
    return irradiance * occlusion;
#else
    return _GlossyEnvironmentColor.rgb * occlusion;
#endif
}

// 摘自 core ShaderLibrary/ImageBasedLighting.hlsl
#define UNITY_SPECCUBE_LOD_STEPS 6     // This is actuall the last mip index, we generate 7 mips of convolution
real PerceptualRoughnessToMipmapLevel(real perceptualRoughness, uint maxMipLevel)
{
    perceptualRoughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness);
    return perceptualRoughness * maxMipLevel;
}

反射探针的 cubemap 在烘焙时就按粗糙度做了预卷积,mip 0 是镜面、mip 6 最模糊。运行时根据感知粗糙度选 mip:

mip=6·r(1.7−0.7r)
0 1 2 3 4 5 6 0 0.2 0.4 0.6 0.8 1 感知粗糙度 r 采样的 mip 级别 线性映射 6·r(参照) URP:6 · r(1.7 − 0.7r)
图 10-9感知粗糙度到反射探针 mip 级别的映射。曲线在中低粗糙度处比线性映射更快地变模糊,r = 0.5 时已到 mip 4。

这条曲线是对“GGX 波瓣宽度 ↔ mip 级别对应的模糊半径”的经验拟合,不是精确换算(同一文件里还有一个按 𝐧·𝐫 修正的“accurate”版本,注释自己也写着 TODO: optimize,URP 没有使用)。实践中的含义是:反射探针的分辨率与卷积质量,直接决定中高粗糙度材质的环境反射质量,128 的探针在 mip 4 只剩 8×8。

Forward+ 或开启 “Probe Blending” 时走 CalculateIrradianceFromReflectionProbes。非 Forward+ 版本按重要性和包围盒体积在两个探针间按权重混合;Forward+ 版本则把所有探针打包进一张 2D 图集(八面体映射 PackNormalOctQuadEncode),按分块列表遍历可见探针,并手动在两个 mip 之间插值。两者在探针权重不足 1 时,都会用剩余权重混合天空盒反射 _GlossyEnvironmentCubeMap。

两个辅助函数也值得一提。DecodeHDREnvironment 处理反射探针的 HDR 编码:在不支持浮点 cubemap 的平台上,探针可能以 RGBM 等格式存储,unity_SpecCube0_HDR 里存着解码参数;在支持的平台上它基本只是乘一个强度系数(Lighting 窗口里的 Intensity Multiplier)。BoxProjectedCubemapDirection 实现盒体投影:探针的 cubemap 是在探针中心拍的,物体离中心越远,直接用反射向量查表的视差越大;盒体投影把反射射线与探针的包围盒求交,用交点相对探针中心的方向去查表,室内场景的地板、墙面反射因此能“对得上”。探针位置的 w 分量大于 0 表示该探针勾选了 Box Projection,函数里的 if (cubemapPositionWS.w > 0.0f) 就是在检查这个开关。

EnvironmentBRDF:split-sum 的解析近似

// 摘自 URP ShaderLibrary/BRDF.hlsl
half3 EnvironmentBRDFSpecular(BRDFData brdfData, half fresnelTerm)
{
    float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0);
    return half3(surfaceReduction * lerp(brdfData.specular, brdfData.grazingTerm, fresnelTerm));
}

half3 EnvironmentBRDF(BRDFData brdfData, half3 indirectDiffuse, half3 indirectSpecular, half fresnelTerm)
{
    half3 c = indirectDiffuse * brdfData.diffuse;
    c += indirectSpecular * EnvironmentBRDFSpecular(brdfData, fresnelTerm);
    return c;
}

在完整的 IBL 里,环境镜面反射是“预卷积的环境光”乘以“BRDF 在半球上的积分”。后者(Karis 称为 split-sum 的第二个和)只取决于 𝐧·𝐯、粗糙度和 F0,通常预计算成一张二维查找表(LUT),形式为 F0·A+B。URP 没有用 LUT,而是用一个解析式代替:

Espec≈1α2+1lerp(F0, grazingTerm, (1−𝐧·𝐯)4)
  • (1−𝐧·𝐯)4:Schlick 菲涅尔的变体,用 4 次方而非 5 次方(GlobalIllumination.hlsl 清漆部分的注释也写明了“pow4 instead of pow5”)。
  • grazingTerm:掠射时趋向的值,光滑电介质接近 1,粗糙电介质接近 0.04——用光滑度近似了“粗糙表面掠射时被遮蔽”。
  • surfaceReduction 1/(α2+1):粗糙度越高整体越暗,近似单次散射 GGX 的能量损失(α=1 时减半)。

它和真正的积分差多少?下图是 F0 = 0.04 的电介质,参考值用第 9 章的 GGX + 高度相关 Smith + Schlick 数值积分得到:

0 0.2 0.4 0.6 0.8 0 0.2 0.4 0.6 0.8 1 μ = n·v 镜面环境反射比例(F0 = 0.04) 积分参考,粗糙度 0.2 URP 解析近似,粗糙度 0.2 积分参考,粗糙度 0.5 URP 解析近似,粗糙度 0.5 积分参考,粗糙度 0.8 URP 解析近似,粗糙度 0.8
图 10-10环境镜面反射比例随 n·v 的变化(F0 = 0.04)。实线:数值积分的参考值;虚线:URP 的 EnvironmentBRDFSpecular。正视时两者几乎一致;掠射时 URP 偏高,粗糙度 0.5 时在 n·v = 0.2 处约为参考值的 1.8 倍。

正视角度下几乎完美,偏差集中在掠射区域:URP 让中等粗糙度的电介质边缘反射偏亮。这与 10.7 节直接光的情况相反(那里粗糙时偏暗),两者的误差来源不同,不会相互抵消。好在掠射区域只占屏幕上物体轮廓的窄窄一圈。第 11 章会改用程序化 LUT 实现更准确的版本,可以和 Lit 并排对比。

最后一个细节:GlobalIllumination 返回前整体乘 occlusion,也就是材质 AO 与 SSAO 的较小值。AO 同时作用于间接漫反射和间接镜面——严格说镜面遮蔽应该有专门的“Specular Occlusion”计算(HDRP 有),URP 简化成了同一个值。

案例 10.4把 Lit 的光照拆成四项进阶单独打开 ↗

目标:按 UniversalFragmentPBR 的流程重算一遍,但把直接漫反射、直接高光、间接漫反射、间接高光分开累加,可以单独输出任何一项;再验证四项之和与原函数完全一致。

思路:三个 Pass 的 CBUFFER 放进 SubShader 级的 HLSLINCLUDE 块,它会被拼接到每个 Pass 的最前面,比单独的 include 文件更紧凑。直接光部分抄写 LightingPhysicallyBased,只是把 brdf.diffuse 和 brdf.specular * DirectBRDFSpecular(...) 分别乘 radiance:

Assets/Cookbook/Ch10/LitBreakdown.shader(节选:direct)
// 与 LightingPhysicallyBased 相同的计算,只是把漫反射和高光分开累加
void AccumulateDirect(BRDFData brdf, Light light, InputData inputData, inout half3 diffuse, inout half3 specular)
{
    half NdotL = saturate(dot(inputData.normalWS, light.direction));
    half3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL);
    diffuse  += brdf.diffuse * radiance;
    specular += brdf.specular * DirectBRDFSpecular(brdf, inputData.normalWS, light.direction, inputData.viewDirectionWS) * radiance;
}
// 与 LightingPhysicallyBased 相同的计算,只是把漫反射和高光分开累加
void AccumulateDirect(BRDFData brdf, Light light, InputData inputData, inout vec3 diffuse, inout vec3 specular)
{
    float NdotL = saturate(dot(inputData.normalWS, light.direction));
    vec3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL);
    diffuse  += brdf.diffuse * radiance;
    specular += brdf.specular * DirectBRDFSpecular(brdf, inputData.normalWS, light.direction, inputData.viewDirectionWS) * radiance;
}

主体照着 UniversalFragmentPBR 与 GlobalIllumination 的顺序写,函数全部来自 URP:

Assets/Cookbook/Ch10/LitBreakdown.shader(节选:breakdown)
    BRDFData brdf;
    InitializeBRDFData(surfaceData, brdf);
    half4 shadowMask = CalculateShadowMask(inputData);
    AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
    Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);
    MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI);

    // ---- 间接光:与 GlobalIllumination() 相同,只是拆成两项 ----
    half3 reflectVector = reflect(-inputData.viewDirectionWS, inputData.normalWS);
    half NoV = saturate(dot(inputData.normalWS, inputData.viewDirectionWS));
    half fresnelTerm = Pow4(1.0 - NoV);
    half3 envSpecular = GlossyEnvironmentReflection(reflectVector, inputData.positionWS,
                            brdf.perceptualRoughness, 1.0h, inputData.normalizedScreenSpaceUV);
    half3 indirectDiffuse  = inputData.bakedGI * brdf.diffuse * aoFactor.indirectAmbientOcclusion;
    half3 indirectSpecular = envSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm) * aoFactor.indirectAmbientOcclusion;

    // ---- 直接光:主光 + 附加光 ----
    half3 directDiffuse = 0, directSpecular = 0;
    AccumulateDirect(brdf, mainLight, inputData, directDiffuse, directSpecular);

#if defined(_ADDITIONAL_LIGHTS)
    uint pixelLightCount = GetAdditionalLightsCount();
#if USE_FORWARD_PLUS
    // Forward+ 下,附加的“方向光”不进分簇列表,要单独循环
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        AccumulateDirect(brdf, light, inputData, directDiffuse, directSpecular);
    }
#endif
    // 注意:Forward+ 版本的 LIGHT_LOOP_BEGIN 宏内部直接引用名为 inputData 的变量
    LIGHT_LOOP_BEGIN(pixelLightCount)
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
        AccumulateDirect(brdf, light, inputData, directDiffuse, directSpecular);
    LIGHT_LOOP_END
#endif
BRDFData brdf;
InitializeBRDFData(surfaceData.albedo, surfaceData.metallic, surfaceData.specular, surfaceData.smoothness, surfaceData.alpha, brdf);
vec4 shadowMask = vec4(1.0);   // CalculateShadowMask:没有 Shadowmask 时是 1
AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);
// MixRealtimeAndBakedGI:没有光照贴图时什么都不做

// ---- 间接光:与 GlobalIllumination() 相同,只是拆成两项 ----
vec3 reflectVector = reflect(-inputData.viewDirectionWS, inputData.normalWS);
float NoV = saturate(dot(inputData.normalWS, inputData.viewDirectionWS));
float fresnelTerm = Pow4(1.0 - NoV);
vec3 envSpecular = GlossyEnvironmentReflection(reflectVector, inputData.positionWS,
                        brdf.perceptualRoughness, 1.0);
vec3 indirectDiffuse  = inputData.bakedGI * brdf.diffuse * aoFactor.indirectAmbientOcclusion;
vec3 indirectSpecular = envSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm) * aoFactor.indirectAmbientOcclusion;

// ---- 直接光:主光 + 附加光 ----
vec3 directDiffuse = vec3(0.0), directSpecular = vec3(0.0);
AccumulateDirect(brdf, mainLight, inputData, directDiffuse, directSpecular);

// 网页版只有 Forward:附加光就是普通循环(HLSL 版的 Forward+ 分支与 LIGHT_LOOP_BEGIN 宏不需要)
int pixelLightCount = GetAdditionalLightsCount();
for (int lightIndex = 0; lightIndex < pixelLightCount; lightIndex++)
{
    Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
    AccumulateDirect(brdf, light, inputData, directDiffuse, directSpecular);
}
图 10-11从左到右:直接漫反射、直接高光、间接漫反射(SH)、间接高光(反射探针 × EnvironmentBRDF)、URP 原函数的完整结果。场景同图 10-3。

这组图把第 9 章的理论“摊开”在眼前:

  • 直接漫反射里金球是纯黑的——金属没有漫反射;地面上可以看到橙色点光的光斑和清晰的阴影。
  • 直接高光非常稀疏:两盏灯各在球上留下一个小亮点,粗糙的砖块和地面几乎没有高光。对于点状光源,镜面反射只是“灯的倒影”。
  • 间接漫反射是天空 SH 给出的柔和蓝灰色,没有阴影——这正是“环境光没有方向性遮挡”的问题,需要 AO 或烘焙来弥补。
  • 间接高光里金球映出了天空与地平线,红色塑料球只在边缘有淡淡的反射(F0 = 0.04,掠射时增大)。金属的“质感”几乎全部来自这一项:没有反射探针(或天空盒),金属就会发黑。
图 10-12左:_Component = 0,调用 URP 原版 UniversalFragmentPBR;右:_Component = 5,四项分量之和加自发光。两者一致,说明拆分没有遗漏。

四张分量图还可以拿来做定量判断。例如想知道“环境光占了多大比重”,把间接两项和直接两项分别输出,用 Frame Debugger 或截图取色比较同一位置的数值;想知道法线贴图对高光的影响,对比砖块在直接高光与直接漫反射中的细节——前者对法线扰动敏感得多,因为 GGX 的波瓣比余弦函数窄得多。这些判断在调光照、做材质规范时都很常用。

调试技巧URP 自带的 Rendering Debugger(Window › Analysis › Rendering Debugger)的 Lighting 页也能单独关闭 GI、主光、附加光等(对应 CalculateLightingColor 里的 IsLightingFeatureEnabled),但它不区分漫反射和高光。本案例的 Shader 粒度更细,排查“为什么这个金属这么暗”“高光是灯给的还是环境给的”时很好用。在 Forward+ 下注意保留 inputData 这个变量名。

10.10 能量账本:oneMinusReflectivity 做了什么、没做什么

把 URP Lit 的完整计算写成一个式子(单盏灯,忽略阴影与衰减):

Lo=𝐜diff𝐜light(𝐧·𝐥)⏟直接漫反射+F0SURP𝐜light(𝐧·𝐥)⏟直接高光+𝐜diffESHao⏟间接漫反射+LprobeEspecao⏟间接高光+Le

其中 𝐜diff=albedo·0.96(1−m)。这本账在以下几处是“记对了”的:

  • 金属(m = 1)的漫反射严格为 0,所有颜色都进入 F0。
  • 漫反射与镜面反射率之和不超过 1:电介质 0.96 + 0.04,金属 0 + F0。
  • 环境镜面里 surfaceReduction 和 grazingTerm 让粗糙材质的环境反射变暗,大致模拟了单次散射的能量损失。

以下几处是“记得粗”的:

  • 漫反射没有随角度扣除 F:掠射时镜面反射增强,漫反射却不减少,总量会略超。
  • 直接高光的 V·F 近似,在光滑、高 F0 时总反射可超过 1(案例 10.3)。
  • 没有多次散射补偿:粗糙金属偏暗,这一点与“单次散射 GGX”的参考实现一致,但与真实材料不一致。
  • AO 同时作用于间接漫反射与间接镜面,没有专门的镜面遮蔽。

这些近似都有明确的动机:URP 要在移动端以 target 2.0 跑,每盏灯的指令数是硬约束。理解了误差在哪儿,你就知道什么时候可以放心用 Lit,什么时候需要第 11 章那样的自定义 PBR。

10.11 Simple Lit:UniversalFragmentBlinnPhong

URP 还自带一个更便宜的光照模型 Simple Lit,它不是 PBR,而是 Blinn-Phong。片元函数最后调用的是 UniversalFragmentBlinnPhong,单盏灯的计算是:

// 摘自 URP ShaderLibrary/Lighting.hlsl
half3 CalculateBlinnPhong(Light light, InputData inputData, SurfaceData surfaceData)
{
    half3 attenuatedLightColor = light.color * (light.distanceAttenuation * light.shadowAttenuation);
    half3 lightDiffuseColor = LightingLambert(attenuatedLightColor, light.direction, inputData.normalWS);

    half3 lightSpecularColor = half3(0,0,0);
    #if defined(_SPECGLOSSMAP) || defined(_SPECULAR_COLOR)
    half smoothness = exp2(10 * surfaceData.smoothness + 1);
    lightSpecularColor += LightingSpecular(attenuatedLightColor, light.direction, inputData.normalWS, inputData.viewDirectionWS, half4(surfaceData.specular, 1), smoothness);
    #endif
    // ... 预乘透明时漫反射乘 alpha
    return lightDiffuseColor * surfaceData.albedo + lightSpecularColor;
}

与 Lit 对比:

LitSimple Lit
高光模型GGX + V·F 近似,能量大致守恒(𝐧·𝐡)n,n=210s+1(2~2048),未归一化
菲涅尔有(直接光近似 + 环境光 Pow4)无
环境反射反射探针 + EnvironmentBRDF无(bakedGI 只乘 albedo,即只有环境漫反射)
参数Metallic / SmoothnessSpecular Color(RGB),光滑度存在 _SpecColor.a
高光开关默认开,可关需要关键字 _SPECULAR_COLOR 或 _SPECGLOSSMAP

Simple Lit 的高光指数是 210s+1,滑条从 0 到 1 对应 2 到 2048。高光强度未归一化:粗糙时高光又大又亮,光滑时又小又暗——与 PBR 恰好相反。

案例 10.5Simple Lit 与 Lit 对比入门单独打开 ↗

目标:同一个蓝色、同一组光滑度,看两种模型的差别。

脚本创建 Simple Lit 材质时要注意两件事:开启 _SPECULAR_COLOR 关键字(材质面板里 “Specular Highlights” 勾选时由 ShaderGUI 设置),以及把光滑度写进 _SpecColor 的 alpha——ShaderGUI 会把 _Smoothness 拷贝过去,而 SampleSpecularSmoothness 实际读的是 _SpecColor.a。

图 10-13上排 Simple Lit(Specular Color 0.5 灰),下排 Lit(Metallic 0),光滑度 0.9 → 0.1。Simple Lit 的高光随粗糙度变大、变亮,没有环境反射;Lit 的光滑球映出天空中的太阳(第二个小亮点)和地平线,粗糙后高光消失在漫反射里。

Simple Lit 适合对性能极端敏感、又不需要材质在不同光照下保持一致的场合:低端移动设备的远景、风格化的场景、大量的植被。它的片元成本明显低于 Lit——没有反射探针采样、没有 BRDFData 初始化。但不要在同一画面里混用两种模型表现同类材质,它们对光照的“反应”不同,换个光照环境就会露馅。

案例 10.6金属度 × 光滑度矩阵入门单独打开 ↗

目标:用 URP Lit 拍一张 5×5 的参数矩阵,作为理解与调参的速查图。行:金属度 0、0.25、0.5、0.75、1(从上到下);列:光滑度 0、0.25、0.5、0.75、1(从左到右);BaseColor 为同一个暖橙色。

Assets/Cookbook/Ch10/Editor/Ch10Shots.cs(节选:matrix)
[BookShot("ch10-matrix", W = 900, H = 820)]
static void Matrix(Shot s)
{
    // 行:金属度 0 → 1(从上到下);列:光滑度 0 → 1(从左到右)
    var baseCol = new Color(0.95f, 0.64f, 0.38f);
    s.Background(new Color(0.3f, 0.31f, 0.33f));
    for (int r = 0; r < 5; r++)
        for (int c = 0; c < 5; c++)
            s.Sphere(s.Lit(baseCol, r / 4f, c / 4f), new Vector3((c - 2) * 1.12f, (2 - r) * 1.12f, 0), 1f);
    OrthoRows(s, 2.95f);
    s.SunDir(35, 30, 1.3f);
    s.Sun.shadows = LightShadows.None;
}
图 10-14URP Lit 的金属度(行,上 0 → 下 1)× 光滑度(列,左 0 → 右 1)。正交相机,一盏方向光加程序化天空的环境光与反射。

沿着矩阵读,能把本章所有源码对上号:

  • 第一行(电介质):颜色来自漫反射,高光与环境反射都是无色的;光滑度越高,亮核越小越亮,同时出现天空的倒影(grazingTerm 增大,边缘反射增强)。
  • 最后一行(金属):漫反射为 0,颜色全在高光和反射里;光滑的金属球几乎就是一面染色的镜子,映出天空与地平线。左下角的粗糙金属显得暗而“脏”,这是 surfaceReduction 与单次散射能量损失共同的结果。
  • 中间三行:金属度 0.25~0.75 的材质物理上并不存在,它们只是两端的线性混合——漫反射按 (1−m) 衰减,F0 从 0.04 向底色插值。看起来像“蒙了一层灰的金属”,这也是为什么美术规范一般要求金属度贴图尽量取 0 或 1。
  • 最右一列:光滑度 1 时 α 被钳到 2−7,太阳在球上只剩一个针尖般的亮点,但它仍然存在且极亮(未做色调映射时截断为白)。

把矩阵当作“光照环境检查卡”

这张图还有一个实用价值:当你怀疑某个场景的光照设置有问题时,把同样的 25 个球放进去拍一张,和这里的标准图比较。如果金属一行整体发黑,多半是反射探针缺失或强度过低;如果电介质一行的高光边缘泛白、整体发灰,可能是环境光(SH)太强;如果光滑的球上看不到任何倒影,检查材质的 Environment Reflections 开关与 URP Asset 的反射探针设置;如果所有球都过曝成一片,检查是否开启了 HDR 与色调映射。很多“材质不对”的问题,其实是“光照环境不对”,第 13 章会把这种检查流程化。

10.12 其余 Pass:阴影、深度、Meta 与 Deferred

ForwardLit 之外的几个 Pass 代码量都不大,但各有一两个容易被忽略的设计。

ShadowCaster:法线偏移在顶点里做

ShadowCasterPass.hlsl 的顶点函数把顶点沿法线和光照方向推一点再变换到裁剪空间(ApplyShadowBias),这就是 Light 组件和 URP Asset 上 Depth Bias / Normal Bias 的实现位置。方向光用全局的 _LightDirection,点光和聚光则用 _LightPosition 逐顶点算方向,由关键字 _CASTING_PUNCTUAL_LIGHT_SHADOW 区分。最后还会把裁剪空间 z 钳到近平面(UNITY_NEAR_CLIP_VALUE),防止投射体在光源近平面之外被裁掉造成“阴影破洞”(俗称 Shadow Pancaking)。片元函数只在 Alpha Test 时做一次 clip,ColorMask 为 0,不输出颜色。阴影原理与偏移的细节在第 6 章已经展开。

DepthOnly 与 DepthNormals

DepthOnly 用于深度预渲染(Depth Prepass)和生成 _CameraDepthTexture;DepthNormals 在需要 _CameraNormalsTexture 时运行(例如 SSAO 选 Depth Normals 来源、或某些 Renderer Feature 请求了法线)。注意 DepthNormals 也要采样法线贴图,所以 _NORMALMAP 必须在这个 Pass 里同样声明——否则 SSAO 看到的是没有凹凸的几何法线,与 ForwardLit 的法线不一致。案例 10.1 的 MinimalLit 为此专门补了这个 Pass。

Meta:烘焙时金属也要“反弹”光

// 摘自 URP Shaders/LitMetaPass.hlsl
half4 UniversalFragmentMetaLit(Varyings input) : SV_Target
{
    SurfaceData surfaceData;
    InitializeStandardLitSurfaceData(input.uv, surfaceData);

    BRDFData brdfData;
    InitializeBRDFData(surfaceData.albedo, surfaceData.metallic, surfaceData.specular, surfaceData.smoothness, surfaceData.alpha, brdfData);

    MetaInput metaInput;
    metaInput.Albedo = brdfData.diffuse + brdfData.specular * brdfData.roughness * 0.5;
    metaInput.Emission = surfaceData.emission;
    return UniversalFragmentMeta(input, metaInput);
}

Meta Pass 只在烘焙光照贴图时由 Lightmapper 调用,告诉它“这个表面把多少光漫反射出去、自己发多少光”。烘焙器只模拟漫反射的光能传递,于是 URP 用 diffuse + specular × α × 0.5 作为等效反照率:电介质基本就是漫反射颜色;金属漫反射为 0,但越粗糙的金属越像漫反射体,就按粗糙度补一部分带颜色的反弹光。这是一个经验处理,目的是让粗糙金属在烘焙场景中不至于完全“吞光”。得到的结果还会经过 Lighting 窗口中 Albedo Boost 参数的调整(UnityMetaFragment 里的 unity_OneOverOutputBoost)。

GBuffer:Deferred 路径把 Lit 拆成两半

Deferred 渲染时,Lit 的 GBuffer Pass 只做一半工作:初始化 BRDFData,计算 GI + 自发光,然后把材质信息打包写进 G-Buffer;每盏灯的直接光照则在之后的全屏或光源体积 Pass(Shaders/Utils/StencilDeferred.shader)中,从 G-Buffer 解出 BRDFData,再调用同一个 LightingPhysicallyBased。G-Buffer 布局(摘自 UnityGBuffer.hlsl 的 BRDFDataToGbuffer):

目标RGBA
GBuffer0albedo(sRGB 目标)材质标志位(镜面工作流、关闭高光、不接收阴影等)
GBuffer1金属度工作流:R = reflectivity;镜面工作流:F0occlusion
GBuffer2编码后的世界法线smoothness
GBuffer3GI + 自发光(即后续累加光照的缓冲)—

金属度工作流只存了一个 reflectivity 标量,读回时再由 albedo 与它反推出 diffuse 和 specular,所以与 Forward 路径的结果在数学上一致。差别主要在三处:GBuffer Pass 的 GI 只乘材质 AO(SSAO 在之后才生成,由光照 Pass 另行应用);法线经过编码有精度损失(可选八面体编码 _GBUFFER_NORMALS_OCT 改善);透明物体仍然走 Forward。了解这一点后,看到 Deferred 和 Forward 截图有细微差异就不必惊讶。

10.13 性能与移动端:Lit 贵在哪里

读完源码,可以对 Lit 每个像素的开销有一个结构化的认识(不同 GPU 的绝对数字差别很大,下面只讨论相对构成,具体请用 Profiler、Xcode GPU Capture 或 RenderDoc 在目标设备上实测):

  • 固定开销:采样 BaseMap、法线贴图、可选的 Metallic/AO/自发光贴图;InitializeBRDFData;一次 SH 求值(或顶点 SH 插值);一次反射探针的三线性采样(开启探针混合或 Forward+ 时可能是两次以上);主光阴影的 PCF 采样(软阴影质量越高采样越多)。
  • 每盏附加光:一次 GetAdditionalLight(衰减、聚光角、可能的阴影采样和 Cookie)加一次 LightingPhysicallyBased。DirectBRDFSpecular 被精心压缩成十来条算术指令,正是为了让这部分随灯数线性增长时仍可承受。
  • 可以关掉的部分:材质上的 Specular Highlights 与 Environment Reflections 开关会编译出没有对应代码的变体;URP Asset 里关掉附加光阴影、降低软阴影质量、把 SH 计算设为逐顶点,都直接减少片元指令。
  • 精度:URP 大量使用 half。桌面 GPU 上 half 通常按 float 执行,在移动 GPU 上才真正是 16 位。这就是 REAL_IS_HALF 分支里那些钳制存在的原因,也是“编辑器里正常、手机上高光出现黑点或白点”的常见根源——问题往往出在自己写的、没有做钳制的 half 计算上。
和 Built-in 管线的差异Built-in 的 Standard Shader 按平台档位选择 BRDF1/BRDF2/BRDF3 三种实现:BRDF1 使用 Disney 漫反射与 GGX + Smith 联合可见性,BRDF2 就是本章看到的“Minimalist CookTorrance”近似。URP 的 Lit 在所有平台上统一使用相当于 BRDF2 的近似和 Lambert 漫反射,因此从 Built-in 高画质档位迁移过来时,光滑材质的高光强度、粗糙材质的边缘亮度都会有可见差异,通常需要重新校对灯光强度或材质参数。

10.14 Shader Graph、Complex Lit 与本章的关系

Shader Graph 的 URP Lit Target 生成的 Shader,片元阶段最终也是把节点结果填入 SurfaceData(字段与 Fragment 输出块的 Base Color、Metallic、Smoothness、Normal、Emission、Ambient Occlusion 一一对应),用自己生成的代码填 InputData,然后调用同一个 UniversalFragmentPBR。所以本章关于 BRDF 的所有结论——高光近似、环境 BRDF、AO 只影响间接光——同样适用于 Shader Graph 做出的 Lit 材质。Complex Lit 则在 Lit 基础上打开 _CLEARCOAT/_CLEARCOATMAP,让 CreateClearCoatBRDFData、LightingPhysicallyBased 与 GlobalIllumination 中那些在 Lit 里被跳过的清漆分支真正生效,第 12 章会结合源码展开。换句话说,读懂这一章的几个文件,就读懂了 URP 所有“PBR 类”材质的光照核心。

本章小结

  • Lit 的数据通过三个结构体接力:SurfaceData(材质)→ InputData(几何与烘焙 GI)→ BRDFData(BRDF 预计算量)。
  • 材质关键字由 ShaderGUI 设置,脚本创建材质要自己 EnableKeyword;管线关键字由 URP 设置,自写 Shader 必须声明(尤其 _FORWARD_PLUS)。
  • UniversalFragmentPBR = InitializeBRDFData + 阴影/AO + GlobalIllumination + 主光 + 附加光 + 自发光;Forward+ 下光源循环按屏幕分块迭代,宏依赖 inputData 变量名。
  • 金属度工作流:diffuse = albedo·0.96(1−m),specular = lerp(0.04, albedo, m),α = (1−s)²,并有 half 精度下限。
  • DirectBRDFSpecular = πDGGX/(4(𝐥·𝐡)2(α+0.5)),再乘 F0:D 是精确的 GGX,V·F 合并成一个移动端近似;光滑时偏亮(最多约 2 倍),粗糙时偏暗。
  • 环境光:SH/光照贴图给漫反射;反射探针按 6r(1.7−0.7r) 选 mip;EnvironmentBRDFSpecular 用 1/(α2+1) 与 Pow4 菲涅尔解析地近似 split-sum。
  • 材质 AO 只影响间接光;金属的观感主要来自间接高光(反射探针)。
  • Simple Lit 是未归一化的 Blinn-Phong,无菲涅尔、无环境反射,便宜但不是 PBR。

练习

  1. 在 MinimalLit 中加入 Mask Map(R 金属度、G AO、A 光滑度)支持,并用 _Core/Textures/paintedmetal_* 三张贴图拍一张与 URP Lit 的对比图。
  2. 修改 LitBreakdown,增加两个输出:“主光阴影衰减”和“SSAO 的 indirectAmbientOcclusion”,在开启 SSAO Renderer Feature 的场景里观察它们。
  3. 在 SpecCompare 里增加第三种模式:把 URP 的 V·F 近似换成 Kelemen 原始形式 F0/(4(𝐥·𝐡)2)(去掉 α+0.5),看看 α+0.5 这个修正带来了什么。
  4. 阅读 LitGBufferPass.hlsl 和 UnityGBuffer.hlsl,找出 Deferred 路径把 BRDFData 的哪些量写进了 G-Buffer、光照又是在哪个 Shader 里计算的。它和 Forward 路径的结果在什么情况下会不同?
  5. 用 Frame Debugger 查看一个 Lit 材质的 ForwardLit Pass 实际启用了哪些关键字,并解释每一个是由谁开启的。