逐行读懂 URP Lit
打开 URP 14.0.12 的源码,沿着一个像素的计算路径,从 Lit.shader 一路读到 BRDF.hlsl:每一步输入什么、输出什么、和第 9 章的哪条公式对应、在哪里做了近似、近似了多少。
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 关系画出来,主干是这样的:
理解 Lit 的关键,是认清三个结构体,它们像接力棒一样传递数据:
- SurfaceData(
ShaderLibrary/SurfaceData.hlsl):“这个表面是什么材质”——反照率、金属度、光滑度、切线空间法线、自发光、AO、透明度。由LitInput.hlsl采样贴图填写。 - InputData(
ShaderLibrary/Input.hlsl):“这个像素在哪儿、朝哪儿”——世界坐标、世界法线、视线方向、阴影坐标、烘焙 GI、屏幕 UV。由LitForwardPass.hlsl根据插值器填写。 - BRDFData(
ShaderLibrary/BRDF.hlsl):“代入 BRDF 公式的预计算量”——漫反射颜色、F0、α、α² 等。由InitializeBRDFData从 SurfaceData 换算而来。
10.2 Lit.shader:属性、Pass 与关键字
Properties
Lit 的属性块很长,按用途可以分成几组(括号里是属性名):
| 分组 | 属性 | 说明 |
|---|---|---|
| 工作流 | _WorkflowMode | 1 = 金属度,0 = 镜面;只给 ShaderGUI 用,真正起作用的是关键字 _SPECULAR_SETUP |
| 基础色 | _BaseMap、_BaseColor、_Cutoff | 带 [MainTexture]/[MainColor],脚本里的 material.mainTexture/color 指向它们 |
| 金属/光滑 | _Metallic、_MetallicGlossMap、_Smoothness、_SmoothnessTextureChannel | Metallic 贴图 R = 金属度,A = 光滑度(或取自 Albedo 的 A) |
| 镜面工作流 | _SpecColor、_SpecGlossMap | RGB = 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 | 用途 | 主要文件 |
|---|---|---|---|
| ForwardLit | UniversalForward | Forward / Forward+ 下的着色 | LitForwardPass.hlsl |
| ShadowCaster | ShadowCaster | 渲染阴影贴图 | ShadowCasterPass.hlsl |
| GBuffer | UniversalGBuffer | Deferred 路径写 G-Buffer(target 4.5,排除 GLES3/GLCore) | LitGBufferPass.hlsl |
| DepthOnly | DepthOnly | 深度预渲染、Depth Texture | DepthOnlyPass.hlsl |
| DepthNormals | DepthNormals | 输出法线纹理(SSAO 等用) | LitDepthNormalsPass.hlsl |
| Meta | Meta | 烘焙光照贴图时提取反照率和自发光 | LitMetaPass.hlsl |
| Universal2D | Universal2D | 在 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)。
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 它们即可。
#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 等省略):
// 材质关键字:有法线贴图才开(脚本创建材质时要自己 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
核心是两个“填表”函数。SurfaceData 按金属度工作流填写,specular 留 0:
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(没有光照贴图):
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; }
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; }
注意变量名必须叫 inputData——原因见上一节 Forward+ 的 LIGHT_LOOP_BEGIN。最后是三个辅助 Pass:
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 }

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,第四个分量预存了 。BRDFData 里的命名与第 9 章的对应关系是:perceptualRoughness = ,roughness = ,roughness2 = ,specular = ,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 把它指向正确的名字。)
和第 9 章对照,有三点值得说:
漫反射的能量扣除是“与角度无关”的。第 9 章说过,严格的做法是漫反射乘 ,随角度变化。URP 只乘一个常数 oneMinusReflectivity:电介质是 0.96,金属是 0。它保证了“金属没有漫反射”和“F0 大的材质漫反射少”,但掠射时漫反射没有相应减弱。这是一个省计算的近似,对电介质影响很小(F 在掠射前一直接近 0.04)。
镜面工作流的 reflectivity 取 F0 三通道最大值(GLES 2 上只取 R 通道以省指令)。所以镜面色越亮,漫反射越暗,自动保证 。这也是案例 9.8 中“红漫反射 + 蓝高光”仍然不会过曝的原因。
grazingTerm 是掠射时环境反射趋向的颜色,等于 saturate(smoothness + reflectivity)。理想情况下掠射反射率趋于 1(白色),但粗糙表面在掠射时由于遮蔽,实际反射远小于 1,于是 URP 用光滑度把它压低:完全粗糙的电介质,grazingTerm 只有 0.04,不会出现“粗糙物体边缘一圈亮边”。这个量只在环境光里使用(10.9 节)。
Macros.hlsl 定义 HALF_MIN = 6.103515625e-5(,half 最小正规数),HALF_MIN_SQRT = 0.0078125()。给 设下限 ,保证 不会在 half 中下溢为 0,GGX 的分子不为 0。对应的最小感知粗糙度约为 :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):
与第 9 章的 GGX 对比: 正是 。于是:
调用处会再乘 brdfData.specular(即 ),以及 lightColor * NdotL。按 9.4 节的约定,标准 Cook-Torrance 在这里应该是 。两相比较,URP 做的近似就是:
这个形式来自 Renaldas Zioma 在 SIGGRAPH 2015 “Moving Mobile Graphics” 课程中的《Optimizing PBR for Mobile》,其前身是 Kelemen 与 Szirmay-Kalos 的可见性近似 。可以这样理解各部分:
- :掠射时 变小,这一项变大,同时模拟了可见性项和菲涅尔项在掠射时的增大(源码注释称“Fresnel approximated with 1/LdotH”)。它与 、 无关,是“光源无关量少、指令少”的关键。
- :对粗糙度的修正。粗糙表面 Smith G 的遮蔽更强,这一项让粗糙时的高光变暗。
max(0.1h, LoH2):限制 最多放大 10 倍,防止逆光时爆亮。1.00001f: 很小且 时, 可能非常接近 0,加一点点避免除零。normalizationTerm与roughness2MinusOne:与灯光无关,InitializeBRDFData时算一次,循环里每盏灯省两条指令;4α+2写成一次乘加(MAD)。- half 精度平台上把结果钳到 1000 以内,防止 FP16 溢出(注释解释了 1000 的来历:给每盏灯留出一半的 half 范围)。
为什么要绕开 和 ,改用 ?标准可见性项里两个方向的 各需要一次开方,而且分母里的 、 在轮廓和明暗交界处趋于 0,在 half 精度下极易产生无穷大或噪点。 则有一个很好的性质:它等于 ,只要光和视线不是完全相反,它就不会接近 0,配合 max(0.1h, LoH2) 更是彻底排除了除零。代价是失去了“轮廓处遮蔽加剧”的信息——这也解释了案例 10.2 中 URP 的粗糙球边缘没有亮边。
另一个容易误读的地方是“F 去哪了”。URP 的公式里确实没有 这样的菲涅尔项,brdfData.specular 是常数 F0。直接光的菲涅尔增强完全靠 间接体现:逆光时 变小,高光变亮。对于 F0 = 0.04 的电介质,真实菲涅尔在掠射时可增大到二十多倍,而这里最多 10 倍,所以 URP 的逆光高光会比标准公式温和——第 9 章图 9-16 那种强烈的路面眩光,在 URP Lit 里会弱一些。
它偏差多大
近似的好坏要用数字说话。先看最简单的情形:视线和光都沿法线()。此时标准形式的 对任意 都恰好是 1/4(代入高度相关 Smith 可得),F = F0;URP 的近似则是 。两者之比:
(非常光滑)时是 2 倍,(感知粗糙度约 0.71)时恰好相等, 时只有 0.67 倍。也就是说:URP 的直接高光在光滑材质上偏亮,最多接近两倍;在很粗糙的材质上偏暗。下图把“视线沿法线、光源扫过入射平面”的整个高光波瓣画出来:
案例 10.2URP 高光 vs 标准 GGX:并排看进阶单独打开 ↗
目标:在同一个 Shader 里,用 URP 的 InitializeBRDFData + DirectBRDFSpecular 和 core 包的 D_GGX、V_SmithJointGGX、F_Schlick 分别计算主光的直接高光,用两排粗糙度递增的球对比。
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 带 ,所以结果再乘 与 URP 的约定对齐;V_SmithJointGGX 是 9.7 节的高度相关可见性项。两组用同一个 brdf.roughness,保证 相同。为了让暗淡的粗糙高光也看得见,F0 取 0.9,输出用 映射。

与图 10-5 的数字一致:前两列 URP 的亮核更大更亮;第四、五列下排更亮,而且在右下方明暗交界线附近有一圈明显的亮边——那是标准公式里 在掠射时增大、Schlick 的 F 增大共同造成的,而 URP 的 被钳在 10 倍以内,没有这圈亮边。哪一个“更对”?从能量上看是下排(下一个案例验证);从观感上看,粗糙物体边缘这圈亮边在实际场景中常被认为“不干净”,URP 的近似反而更讨美术喜欢。这就是工程上的取舍。
案例 10.3白炉测试:URP 的直接高光守恒吗高阶单独打开 ↗
目标:用第 9 章案例 9.7 的方法,对 URP 的 DirectBRDFSpecular 做白炉测试(F0 = 1),与标准 GGX 对比。
采样仍按 GGX 的 进行,被积函数换成 URP 的公式。注意 DirectBRDFSpecular 按 Unity 约定已经乘了 ,积分前要除回去,还原成真正的 BRDF:
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); }

结论很清楚:在 F0 = 1、粗糙度较低时,URP 的直接高光近似会反射出比入射更多的能量(约 1.8 倍),违反能量守恒;在粗糙度较高时又比标准公式更暗。实际材质的 F0 远小于 1(电介质 0.04),这个“多出来的能量”绝对值很小,加上直接高光只占画面的一小部分,所以日常使用中不易察觉。但如果你在做材质校准、或者把 URP Lit 的结果和离线渲染器、HDRP、第 11 章手写的标准 PBR 并排比较,就会发现光滑材质的直接高光亮度有系统性差异——原因就在这里。
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 节的公式 ,几乎是逐字翻译:radiance 就是 再乘上距离衰减与阴影;brdf 就是括号里的部分。变量名叫 radiance 其实不太准确——按辐射度量学,它是“除以 π 之后的辐照度”,乘上 BRDF 后才是出射辐射率。
specularHighlightsOff 有两层保护:材质关键字 _SPECULARHIGHLIGHTS_OFF 在编译期直接去掉高光代码;同时保留一个运行时的 bool 分支,供 Shader Graph 等调用者在不切换变体的情况下关闭高光。[branch] 提示编译器生成真正的分支而不是两边都算。
清漆(Clear Coat)只在 Complex Lit 中启用,它把第二层 BRDF 的高光叠在底层之上,并按 衰减底层——第 12 章细讲。
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:
这条曲线是对“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 的第二个和)只取决于 、粗糙度和 ,通常预计算成一张二维查找表(LUT),形式为 。URP 没有用 LUT,而是用一个解析式代替:
- :Schlick 菲涅尔的变体,用 4 次方而非 5 次方(GlobalIllumination.hlsl 清漆部分的注释也写明了“pow4 instead of pow5”)。
- grazingTerm:掠射时趋向的值,光滑电介质接近 1,粗糙电介质接近 0.04——用光滑度近似了“粗糙表面掠射时被遮蔽”。
- surfaceReduction :粗糙度越高整体越暗,近似单次散射 GGX 的能量损失( 时减半)。
它和真正的积分差多少?下图是 F0 = 0.04 的电介质,参考值用第 9 章的 GGX + 高度相关 Smith + Schlick 数值积分得到:
正视角度下几乎完美,偏差集中在掠射区域: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:
// 与 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:
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); }

这组图把第 9 章的理论“摊开”在眼前:
- 直接漫反射里金球是纯黑的——金属没有漫反射;地面上可以看到橙色点光的光斑和清晰的阴影。
- 直接高光非常稀疏:两盏灯各在球上留下一个小亮点,粗糙的砖块和地面几乎没有高光。对于点状光源,镜面反射只是“灯的倒影”。
- 间接漫反射是天空 SH 给出的柔和蓝灰色,没有阴影——这正是“环境光没有方向性遮挡”的问题,需要 AO 或烘焙来弥补。
- 间接高光里金球映出了天空与地平线,红色塑料球只在边缘有淡淡的反射(F0 = 0.04,掠射时增大)。金属的“质感”几乎全部来自这一项:没有反射探针(或天空盒),金属就会发黑。

_Component = 0,调用 URP 原版 UniversalFragmentPBR;右:_Component = 5,四项分量之和加自发光。两者一致,说明拆分没有遗漏。四张分量图还可以拿来做定量判断。例如想知道“环境光占了多大比重”,把间接两项和直接两项分别输出,用 Frame Debugger 或截图取色比较同一位置的数值;想知道法线贴图对高光的影响,对比砖块在直接高光与直接漫反射中的细节——前者对法线扰动敏感得多,因为 GGX 的波瓣比余弦函数窄得多。这些判断在调光照、做材质规范时都很常用。
CalculateLightingColor 里的 IsLightingFeatureEnabled),但它不区分漫反射和高光。本案例的 Shader 粒度更细,排查“为什么这个金属这么暗”“高光是灯给的还是环境给的”时很好用。在 Forward+ 下注意保留 inputData 这个变量名。10.10 能量账本:oneMinusReflectivity 做了什么、没做什么
把 URP Lit 的完整计算写成一个式子(单盏灯,忽略阴影与衰减):
其中 。这本账在以下几处是“记对了”的:
- 金属(m = 1)的漫反射严格为 0,所有颜色都进入 F0。
- 漫反射与镜面反射率之和不超过 1:电介质 0.96 + 0.04,金属 0 + F0。
- 环境镜面里 surfaceReduction 和 grazingTerm 让粗糙材质的环境反射变暗,大致模拟了单次散射的能量损失。
以下几处是“记得粗”的:
- 漫反射没有随角度扣除 :掠射时镜面反射增强,漫反射却不减少,总量会略超。
- 直接高光的 近似,在光滑、高 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 对比:
| Lit | Simple Lit | |
|---|---|---|
| 高光模型 | GGX + V·F 近似,能量大致守恒 | ,(2~2048),未归一化 |
| 菲涅尔 | 有(直接光近似 + 环境光 Pow4) | 无 |
| 环境反射 | 反射探针 + EnvironmentBRDF | 无(bakedGI 只乘 albedo,即只有环境漫反射) |
| 参数 | Metallic / Smoothness | Specular Color(RGB),光滑度存在 _SpecColor.a |
| 高光开关 | 默认开,可关 | 需要关键字 _SPECULAR_COLOR 或 _SPECGLOSSMAP |
Simple Lit 的高光指数是 ,滑条从 0 到 1 对应 2 到 2048。高光强度未归一化:粗糙时高光又大又亮,光滑时又小又暗——与 PBR 恰好相反。
案例 10.5Simple Lit 与 Lit 对比入门单独打开 ↗
目标:同一个蓝色、同一组光滑度,看两种模型的差别。
脚本创建 Simple Lit 材质时要注意两件事:开启 _SPECULAR_COLOR 关键字(材质面板里 “Specular Highlights” 勾选时由 ShaderGUI 设置),以及把光滑度写进 _SpecColor 的 alpha——ShaderGUI 会把 _Smoothness 拷贝过去,而 SampleSpecularSmoothness 实际读的是 _SpecColor.a。

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 为同一个暖橙色。
[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; }

沿着矩阵读,能把本章所有源码对上号:
- 第一行(电介质):颜色来自漫反射,高光与环境反射都是无色的;光滑度越高,亮核越小越亮,同时出现天空的倒影(grazingTerm 增大,边缘反射增强)。
- 最后一行(金属):漫反射为 0,颜色全在高光和反射里;光滑的金属球几乎就是一面染色的镜子,映出天空与地平线。左下角的粗糙金属显得暗而“脏”,这是 surfaceReduction 与单次散射能量损失共同的结果。
- 中间三行:金属度 0.25~0.75 的材质物理上并不存在,它们只是两端的线性混合——漫反射按 衰减,F0 从 0.04 向底色插值。看起来像“蒙了一层灰的金属”,这也是为什么美术规范一般要求金属度贴图尽量取 0 或 1。
- 最右一列:光滑度 1 时 被钳到 ,太阳在球上只剩一个针尖般的亮点,但它仍然存在且极亮(未做色调映射时截断为白)。
把矩阵当作“光照环境检查卡”
这张图还有一个实用价值:当你怀疑某个场景的光照设置有问题时,把同样的 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):
| 目标 | RGB | A |
|---|---|---|
| GBuffer0 | albedo(sRGB 目标) | 材质标志位(镜面工作流、关闭高光、不接收阴影等) |
| GBuffer1 | 金属度工作流:R = reflectivity;镜面工作流:F0 | occlusion |
| GBuffer2 | 编码后的世界法线 | smoothness |
| GBuffer3 | GI + 自发光(即后续累加光照的缓冲) | — |
金属度工作流只存了一个 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 计算上。
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= ,再乘 F0:D 是精确的 GGX,V·F 合并成一个移动端近似;光滑时偏亮(最多约 2 倍),粗糙时偏暗。- 环境光:SH/光照贴图给漫反射;反射探针按 选 mip;
EnvironmentBRDFSpecular用 与 Pow4 菲涅尔解析地近似 split-sum。 - 材质 AO 只影响间接光;金属的观感主要来自间接高光(反射探针)。
- Simple Lit 是未归一化的 Blinn-Phong,无菲涅尔、无环境反射,便宜但不是 PBR。
练习
- 在
MinimalLit中加入 Mask Map(R 金属度、G AO、A 光滑度)支持,并用_Core/Textures/paintedmetal_*三张贴图拍一张与 URP Lit 的对比图。 - 修改
LitBreakdown,增加两个输出:“主光阴影衰减”和“SSAO 的 indirectAmbientOcclusion”,在开启 SSAO Renderer Feature 的场景里观察它们。 - 在
SpecCompare里增加第三种模式:把 URP 的 近似换成 Kelemen 原始形式 (去掉 ),看看 这个修正带来了什么。 - 阅读
LitGBufferPass.hlsl和UnityGBuffer.hlsl,找出 Deferred 路径把 BRDFData 的哪些量写进了 G-Buffer、光照又是在哪个 Shader 里计算的。它和 Forward 路径的结果在什么情况下会不同? - 用 Frame Debugger 查看一个 Lit 材质的 ForwardLit Pass 实际启用了哪些关键字,并解释每一个是由谁开启的。