Unity URP光照模型实战:从Blinn-Phong到PBR原理与Shader实现
1. 项目概述为什么我们需要深入理解光照模型在Unity里做渲染开发尤其是用URP管线你肯定遇到过这样的场景辛辛苦苦建了个模型材质也调了但打上光之后要么看起来像塑料玩具要么暗部死黑一片完全没有“体积感”和“质感”。问题出在哪很多时候根源就在于你用的“光照模型”不对或者说你根本不清楚当前用的光照模型到底在算什么。“光照模型”听起来很学术其实你可以把它理解成一套“数学配方”。它告诉GPU给定一个点上的法线方向、光照方向、视线方向以及表面的颜色、粗糙度等属性应该如何计算出这个点最终呈现给屏幕的颜色。不同的“配方”做出来的“菜”天差地别。Phong模型算出来的高光边缘锐利像老式塑料Blinn-Phong的高光更柔和、更真实是很多手游的标配而基于物理的渲染PBR模型则能模拟金属、布料、皮肤等复杂材质在不同光照下的真实反应。所以这个项目标题“【光照】[光照模型]分类与UnityURP中的实现”直指核心第一我们要系统性地搞清楚主流光照模型有哪些各自的特点和适用场景是什么第二我们要在Unity的URP管线里亲手把它们实现出来从Shader代码层面理解其运作机制。这不是一个简单的API调用教程而是一次从理论到实践、从原理到代码的深度穿越。无论你是想优化现有项目的画面表现还是为面试准备扎实的图形学基础或是单纯对“光”如何被计算感到好奇这次梳理都能给你带来实实在在的收获。2. 核心光照模型分类与原理深度拆解在动手写代码之前我们必须打好理论基础。光照模型主要分为两大类经验模型和基于物理的模型。它们的核心区别在于设计哲学经验模型追求的是“看起来对”通过一些简化的公式快速模拟光照效果而物理模型追求的是“物理上对”其公式基于真实的物理规律能保证能量守恒在不同光照环境下表现一致。2.1 经典经验模型Lambert, Phong, Blinn-Phong让我们从最简单的开始这也是理解复杂模型的基础。Lambert漫反射模型这是所有光照计算的起点。它描述了理想漫反射表面如粗糙的墙面、粉笔是如何反射光线的。其核心是兰伯特定律反射光的强度与表面法线和光源方向夹角的余弦值成正比。用Shader术语说就是计算法线向量N和光线方向向量L的点积Dot Product。点积结果在[-1, 1]之间我们通常取最大值max(dot(N, L), 0)来避免背光面产生负值光照。它的计算结果就是一个平滑的、从明到暗的渐变完全没有高光非常适合表现布料、石材等无光泽表面。Phong镜面反射模型Phong模型在Lambert的基础上增加了对镜面高光的模拟。它认为高光的强度取决于反射光线向量R与视线向量V的夹角。夹角越小说明你看向的方向越接近光线的完美反射方向高光就应该越强。计算公式中有一个关键参数高光指数Shininess。这个指数越大高光范围就越集中、越锐利看起来像光滑的塑料或湿滑的表面指数越小高光就越分散、越柔和。Phong模型的高光在边缘处有时会显得不自然计算反射向量R也需要额外的运算。Blinn-Phong模型这是对Phong模型的一个著名优化也是早期Unity内置管线和许多移动游戏的默认选择。Blinn-Phong不再计算反射向量R而是引入了一个新的中间向量半程向量H。H是光线方向L和视线方向V的角平分线向量(LV)/2 然后归一化。然后它计算法线N与半程向量H的点积并用高光指数进行幂运算。从数学上看Blinn-Phong用N·H替代了Phong的R·V但两者在视觉上非常接近。为什么说它是优化因为在多数情况下计算H比计算R更廉价特别是当光源和相机位置固定时H可以提前计算或近似并且Blinn-Phong的高光在边缘处过渡更平滑视觉上更柔和、更自然避免了Phong模型可能出现的“断崖式”高光消失。注意虽然Blinn-Phong比Phong更优但它依然是一个经验模型。它不遵守能量守恒漫反射和高光反射的光加起来可能超过入射光并且其高光形状在非常低的光泽度下会变得很奇怪。但在性能敏感或风格化渲染中它依然是一个极佳的选择。2.2 现代基于物理渲染模型PBR的核心BRDF随着硬件发展基于物理的渲染PBR已成为业界主流URP/HDRP管线也内置了对PBR的支持。PBR不是一个单一的公式而是一套遵循物理规律如能量守恒、微表面理论的框架。其核心是双向反射分布函数BRDF它描述了入射光从某个方向进来后从另一个方向反射出去的比例。在实践层面我们通常接触的是两个经典的BRDF模型组合漫反射部分常用Lambert或更精确的Disney Diffuse模型镜面反射部分则主要由法线分布函数D、几何函数G、菲涅尔方程F三项组成这就是著名的Cook-Torrance BRDF模型。法线分布函数D描述微表面法线的统计分布决定了高光的形状和锐利程度。例如GGX/Trowbridge-Reitz分布能产生边缘拖尾的、柔和的高光非常适合模拟金属、光滑塑料等材质。几何函数G描述了微表面的自遮挡现象即微表面的凸起会遮挡入射光或反射光。它让物体在掠射角grazing angle观看时高光会变暗更符合现实。菲涅尔方程F描述了反射率随着视线与法线夹角变化的规律。在掠射角几乎所有材质包括非金属的反射率都会接近1。菲涅尔效应是PBR材质看起来“真实”的关键尤其是金属材质在边缘处强烈的镜面反射。PBR模型通常使用金属度/粗糙度工作流。你为材质提供基础色Albedo、金属度Metallic、粗糙度Roughness。系统会根据这些参数动态地计算漫反射和镜面反射的贡献。金属度越高漫反射越弱因为金属吸收大部分折射光镜面反射越强且颜色受基础色影响粗糙度则直接影响高光的模糊程度和范围。理解这些分类和原理就像拿到了不同菜系的食谱。接下来我们就要在Unity URP的厨房里亲手把这些“菜”做出来。3. Unity URP管线下的Shader架构与光照接口在Unity的旧版内置渲染管线中你可以写一个完整的Surface ShaderUnity会自动帮你生成大量的光照处理代码。但在URP通用渲染管线中范式发生了根本变化。URP推崇的是更轻量、更可控的Shader Graph和HLSL代码手写Shader。我们要实现自定义光照模型必须理解URP提供的扩展机制。URP的核心是一个预构建的渲染流程它包含一个重要的概念Lighting.hlsl库。这个库提供了URP计算光照所需的所有函数和数据结构。对于我们自定义光照模型最关键的是理解UniversalFragmentPBR函数以及如何覆写它。默认情况下URP的PBR Lit Shader会调用这个函数来计算最终颜色。我们的策略是创建一个自定义的URP Lit Shader并替换其内部的光照计算函数。具体来说我们需要关注Shader中的#pragma surface指令吗不在URP中我们通常不写Surface Shader。相反我们通过自定义的着色器变体和光照计算函数来实现。实现路径通常有两种完全手写HLSL Shader从最简单的Unlit Shader模板开始自己定义顶点/片元着色器在片元着色器中完全手动实现光照计算。这种方式最灵活适合学习原理。修改URP Lit Shader核心库复制URP内置的Lit.shader和相关的HLSL库文件如Lighting.hlsl,BRDF.hlsl到你的项目然后修改其中的关键函数例如DirectBDRF函数将PBR计算替换为你自己的Phong或Blinn-Phong模型。这种方式能更好地集成URP的阴影、光照贴图、多光源等复杂功能。对于初学者我强烈建议从第一种方法开始它能让你清晰地看到数据流动。我们接下来的实操也将以这条路径为主。URP中的光照数据获取无论哪种方式你都需要获取当前片元的光照信息。URP通过Light.hlsl库和URPShaderVariables.cginc提供了这些数据。关键步骤包括在Shader中引入必要的库文件#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl使用GetMainLight()函数获取主方向光信息颜色、方向、距离衰减等。使用GetAdditionalLights()在循环中处理额外的逐像素光源。从Input结构体中获取世界空间法线、视线方向、世界位置等。理解了URP的“游戏规则”后我们就可以开始动手搭建第一个自定义光照的Shader了。4. 实战在URP中实现Blinn-Phong光照模型我们选择Blinn-Phong作为第一个实战案例因为它结构清晰效果经典是理解光照计算的最佳切入点。我们将创建一个全新的Unlit Shader并为其注入Blinn-Phong的灵魂。4.1 创建基础Shader框架首先在Unity中创建一个新的Shader文件命名为“CustomURP_BlinnPhong.shader”。删除模板内容我们从最基础的结构开始搭建。Shader Custom/URP_BlinnPhong { Properties { // 定义材质属性在Inspector面板中显示 _BaseColor (Base Color, Color) (1, 1, 1, 1) _BaseMap (Base Map, 2D) white {} _SpecularColor (Specular Color, Color) (1, 1, 1, 1) _Gloss (Gloss, Range(8, 256)) 20 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } // 必须的Tags告诉Unity这是URP Shader Pass { Name ForwardLit Tags { LightModeUniversalForward } // LightMode必须是UniversalForward才能被URP前向渲染路径调用 HLSLPROGRAM // 定义Shader程序开始使用HLSL语言 #pragma vertex vert #pragma fragment frag // 指定顶点和片元着色器函数名 // 引入URP核心库 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // 定义与Properties块对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); float4 _BaseMap_ST; // 用于纹理的缩放和偏移 CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _SpecularColor; float _Gloss; CBUFFER_END // 定义从顶点着色器传递到片元着色器的数据结构 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float2 uv : TEXCOORD2; };这个框架定义了Shader的基本属性、渲染标签和数据结构。CBUFFER_START(UnityPerMaterial)是URP中声明材质属性变量的标准方式有利于GPU批处理。4.2 编写顶点着色器与数据准备顶点着色器的任务是将模型顶点从物体空间变换到齐次裁剪空间并计算一些在世界空间中后续会用到的数据。Varyings vert (Attributes IN) { Varyings OUT; // 将顶点位置从物体空间变换到裁剪空间 VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; // 将法线从物体空间变换到世界空间并归一化 VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.normalWS normalInputs.normalWS; // 计算世界空间顶点位置 OUT.positionWS positionInputs.positionWS; // 处理纹理UV支持_ST中的缩放和偏移 OUT.uv TRANSFORM_TEX(IN.texcoord, _BaseMap); return OUT; }这里使用了URP库函数GetVertexPositionInputs和GetVertexNormalInputs它们封装了变换矩阵的处理比直接使用mul(unity_ObjectToWorld, ...)更安全、更规范。4.3 实现片元着色器与Blinn-Phong计算这是最核心的部分。在片元着色器中我们将获取光照信息并应用Blinn-Phong公式。float4 frag (Varyings IN) : SV_Target { // 1. 采样纹理和颜色 float4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 2. 准备向量数据全部归一化 float3 N normalize(IN.normalWS); // 世界空间法线 float3 V normalize(GetWorldSpaceViewDir(IN.positionWS)); // 指向相机的视线方向 // GetWorldSpaceViewDir是URP内置函数计算方便 // 3. 获取主光源信息 Light mainLight GetMainLight(); float3 L normalize(mainLight.direction); // 光源方向从表面指向光源 float3 lightColor mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; // 乘上衰减和阴影衰减让光照更真实 // 4. 计算Lambert漫反射 float NdotL max(dot(N, L), 0.0); float3 diffuse lightColor * albedo.rgb * NdotL; // 5. 计算Blinn-Phong镜面反射 // 计算半程向量H float3 H normalize(L V); float NdotH max(dot(N, H), 0.0); // 使用高光指数进行幂运算控制高光范围 float specularIntensity pow(NdotH, _Gloss); float3 specular lightColor * _SpecularColor.rgb * specularIntensity; // 6. 组合最终颜色简单叠加不遵守能量守恒 float3 finalColor diffuse specular; // 7. 可选添加简单的环境光 float3 ambient SampleSH(N) * albedo.rgb * 0.1; finalColor ambient; return float4(finalColor, albedo.a); } ENDHLSL } } FallBack Universal Render Pipeline/Lit }代码关键点解析向量归一化normalize是必须的否则点积计算会出错。这是新手最常见的错误之一。GetMainLight()这个URP函数返回一个Light结构体包含了当前片元的主方向光的所有信息包括颜色、方向、衰减和阴影信息。直接使用它比手动管理光源要方便可靠得多。衰减与阴影lightColor的计算中乘上了distanceAttenuation距离衰减和shadowAttenuation阴影衰减。这行代码让你的Shader立刻拥有了接受阴影的能力这是集成URP管线功能的好处。半程向量HH normalize(L V)是Blinn-Phong的灵魂。注意是LV而不是V-L因为方向向量通常定义为指向光源或相机。高光指数_Gloss这个值通常设置在8到256之间。值越小高光越“散”越柔和类似粗糙表面值越大高光越“聚”越锐利类似光滑表面。环境光SampleSH(N)是URP中采样球谐光照Spherical Harmonics的函数用于获取场景中的间接漫反射光照即环境光。这里乘了一个系数0.1避免环境光过亮。将这个Shader赋给一个材质并拖到场景中的物体上调整_Gloss和_SpecularColor你就能看到典型的Blinn-Phong光照效果了。相比于内置的PBR Lit它的高光更“干净”风格化更强。5. 进阶在URP框架内集成自定义PBR光照模型实现了经验模型后我们挑战更高阶的修改URP内置的PBR模型。我们的目标不是从头写一个完整的Cook-Torrance BRDF而是理解URP的PBR计算流程并尝试替换其中的核心部分。这里我们演示如何用简化版的Blinn-Phong高光替换掉复杂的GGX高光计算。思路我们不直接修改URP的包内文件而是通过自定义的Shader库文件进行覆写。这是更安全、更可维护的做法。创建自定义的BRDF库文件在项目中创建一个新的HLSL文件例如CustomBRDF.hlsl。复制并修改关键函数从URP的BRDF.hlsl文件中找到DirectBRDF函数或相关的高光计算函数将其复制到你的CustomBRDF.hlsl中并修改其镜面反射计算部分。在Shader中引用自定义库在你的自定义Lit Shader中通过#include YourPath/CustomBRDF.hlsl来包含你的库并确保它在URP的Lighting.hlsl之前被包含这样你的函数定义就会覆盖原有的函数。以下是CustomBRDF.hlsl中一个极度简化的示例用Blinn-Phong替换了默认的BRDF镜面反射计算// CustomBRDF.hlsl #ifndef CUSTOM_BRDF_INCLUDED #define CUSTOM_BRDF_INCLUDED // 定义一个非常简化的“BRDF”计算实际上只是Blinn-Phong half3 CustomSpecularTerm(half3 specColor, half roughness, half3 normal, half3 viewDir, half3 lightDir) { // 忽略粗糙度对高光形状的复杂影响直接用Gloss映射 half gloss 1.0 - roughness; half shininess exp2(10 * gloss 1); // 将[0,1]的roughness映射到一个较大的高光指数范围 half3 halfDir SafeNormalize(lightDir viewDir); half NdotH saturate(dot(normal, halfDir)); half specular pow(NdotH, shininess); return specColor * specular; } // 一个覆盖用的DirectBRDF函数简化版仅用于演示覆盖机制 half3 CustomDirectBRDF(BRDFData brdfData, half3 normalWS, half3 viewDirectionWS, half3 lightDirectionWS) { // 漫反射部分我们沿用Lambert half3 diffuse brdfData.diffuse; // 镜面反射部分使用我们自定义的Blinn-Phong half3 specular CustomSpecularTerm(brdfData.specular, brdfData.roughness, normalWS, viewDirectionWS, lightDirectionWS); return diffuse specular; } #endif然后在你的Shader文件中调整include顺序// 在你的Shader的Pass中 HLSLPROGRAM ... // 先包含你自己的自定义BRDF定义函数 #include Assets/Shaders/CustomBRDF.hlsl // 再包含URP的Lighting库此时URP库中调用DirectBRDF时链接到的就是你自定义的版本 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl ... ENDHLSL重要提示这是一个高度简化的演示用于说明覆盖机制。真实的URP的DirectBRDF函数要复杂得多它处理了能量守恒、多光源、IBL等。直接这样简单覆盖会破坏PBR的许多特性。在生产环境中你需要深入理解URP的BRDF.hlsl和Lighting.hlsl结构进行更精细和兼容的修改。此方法主要用于研究和深度定制。6. 性能考量、调试与常见问题排查当你实现了自己的光照模型后性能和正确性是必须关注的两大问题。6.1 性能优化要点分支与指令数在Shader中if语句和循环for,while可能导致GPU线程分化严重影响性能。尽量使用step(),lerp(),saturate()等函数来替代简单的条件判断。向量运算开销normalize(),pow()是相对昂贵的操作。确保只对需要归一化的向量操作一次并将结果复用。对于pow(NdotH, gloss)如果gloss是常量可以尝试用查找表LUT来近似。多光源处理URP的GetAdditionalLights()会在片元着色器中循环。在移动平台要严格控制逐像素光源的数量通常不超过2-4个。对于大量小光源考虑使用延迟渲染或光照贴图。精度选择在移动端合理使用half中精度和fixed低精度来代替float高精度可以显著提升性能。例如颜色值、纹理坐标用half通常就足够了。6.2 Shader调试技巧Shader调试不像普通代码那样可以单步执行需要一些特殊手段颜色输出调试法这是最常用的方法。将你想查看的中间变量如法线N、光线方向L、点积结果NdotL等直接作为颜色输出。例如return float4(N * 0.5 0.5, 1.0);可以将法线可视化因为法线分量在[-1,1]需要映射到[0,1]才能显示为颜色。Unity Frame Debugger这是神器。Window - Analysis - Frame Debugger。它可以让你暂停游戏并一步步查看每一个Draw Call的渲染状态和输出结果精确定位是哪个Shader、哪个Pass出了问题。RenderDoc更强大的第三方图形调试器。可以捕获一帧完整的GPU调用序列查看每一个纹理、缓冲区的状态是解决复杂渲染问题的终极工具。6.3 常见问题与解决方案实录以下是我在实际项目中踩过的一些坑和解决方法问题现象可能原因排查与解决方案模型一片漆黑无光照1. 法线数据错误或未归一化。2. 光源方向计算错误方向反了。3. Shader未正确编译或未应用到材质。1. 在片元着色器输出float4(N, 1.0)可视化法线检查是否为彩色正常或纯蓝0,0,1。2. 输出float4(L, 1.0)可视化光线方向。确保L是从表面指向光源。检查GetMainLight().direction是否正确。3. 在Frame Debugger中查看该物体的Shader是否是你自定义的。检查控制台有无Shader编译错误。高光位置奇怪或闪烁1. 半程向量H或视线向量V未归一化。2. 变量精度不足在计算H时出现误差。1. 确保H normalize(L V)中的L和V在相加前已是归一化向量或者对(LV)整体进行归一化。2. 尝试将所有相关向量N, L, V, H的计算和中间变量声明为float3而非half3排除精度问题。背光面也有高光未对点积结果进行max(..., 0.0)或saturate(...)操作。检查计算NdotL和NdotH的代码确保它们被限制在[0, 1]范围内。背光面NdotL应为0不应产生任何漫反射和高光。在URP中接收不到阴影1. Shader的LightMode不是UniversalForward。2. 未正确应用阴影衰减。1. 确认Pass的Tags里有LightModeUniversalForward。2. 确保在计算光照颜色时乘上了mainLight.shadowAttenuation。可以输出这个值可视化return float4(shadowAttenuation.xxx, 1.0)来检查阴影图是否被正确采样。自定义PBR模型效果异常1. 自定义的BRDF函数签名或返回类型与URP内部调用不匹配。2. 能量不守恒导致画面过亮或过暗。1. 仔细比对URP原版BRDF.hlsl中的函数声明确保完全一致。使用#ifdef和#pragma message调试宏定义是否生效。2. 简化模型时确保漫反射和高光部分的总和不超过1。可以尝试在最后乘一个经验系数来调整整体亮度。一个实操心得当你对光照计算不确定时回归到最基础的Lambert模型。先确保你能得到一个正确的、平滑的漫反射渐变。然后再一步步加上高光、环境光等其他成分。每加一步就测试一次这样能最快地定位问题所在。光照Shader调试是一个“大胆假设小心验证”的过程耐心和系统性的排查比盲目尝试更重要。