URP自定义光照模型:从Lambert到Blinn-Phong的完整实现指南
1. 项目概述为什么要在URP里折腾光照模型如果你是从Unity内置渲染管线Built-in或者直接上手URPUniversal Render Pipeline的新手可能会觉得光照这事儿Unity不是都帮你搞定了吗拖个Directional Light调调颜色和强度场景就亮起来了。确实对于很多常规项目URP默认的PBR基于物理的渲染光照已经足够优秀和高效。但当你需要实现一些特定的艺术风格比如卡通渲染、风格化场景、优化移动端性能或者仅仅是深入理解图形渲染到底在底层做了什么时亲手实现一个基础光照模型就成了必经之路。这不仅仅是“造轮子”而是一次彻底“拆轮子”的学习过程。URP作为可编程渲染管线为我们提供了Shader Graph和HLSL代码两种主要途径来定制着色器。通过实现经典的光照模型如兰伯特Lambert和冯氏Phong/Blinn-Phong我们能清晰地看到光线如何与物体表面的法线交互漫反射和镜面反射是如何计算出来的以及如何将计算结果与URP的渲染架构如Lighting.hlsl库、渲染队列、Pass结构无缝整合。理解这些你就能从“Unity光照的使用者”转变为“光照效果的定义者”无论是为了性能裁剪不必要的计算还是为了实现独一无二的视觉风格都拥有了主动权。2. 核心思路与URP框架适配在动手写代码之前我们必须先理清在URP中实现自定义光照的核心思路这与在内置管线中写Surface Shader有显著不同。2.1 从Built-in到URP的思维转变在内置管线中我们常使用Surface Shader它是一套高级抽象帮你生成了处理光照、阴影、全局光照的复杂代码。但在URP中官方更推荐使用Shader Graph可视化或直接编写HLSL代码的Unlit Shader模板进行扩展。URP采用了一种更显式、更模块化的渲染流程。我们的核心任务可以分解为获取光照信息在URP中我们不再直接访问_LightColor0等内置变量。取而代之的是通过Lighting.hlsl库中的函数如GetMainLight()和GetAdditionalLights()来获取结构化的光照数据位置、方向、颜色、衰减等。计算光照贡献在片元着色器Fragment Shader中根据获取的光照数据、表面法线、视角方向、物体材质属性漫反射颜色、高光强度、粗糙度等运用光照模型公式进行计算。组合输出颜色将计算出的漫反射、镜面反射等分量与自发光、环境光等相加得到最终的片元颜色。URP通常要求将结果输出到SurfaceData结构体并最终写入BRDFData但对于基础学习我们可以简化直接输出到half4颜色。2.2 方案选型Shader Graph vs HLSL代码Shader Graph可视化着色器编辑器优点直观无需记忆语法通过节点连接即可实现逻辑非常适合快速原型设计、美术人员参与以及实现不涉及复杂循环或条件分支的效果。缺点对于需要精细控制、复杂算法或深度定制光照模型的情况节点图可能变得庞大且难以维护。一些底层API或自定义函数调用可能不如代码直接。适用场景实现标准的兰伯特、Blinn-Phong模型完全可行URP提供了Sample Main Light、Dot Product、Power等节点可以方便地组装。HLSL代码手写着色器优点完全的控制权最高性能可以方便地复用代码、定义复杂函数、集成第三方库。是深入学习图形学和进行高级定制的不二之选。缺点需要HLSL语法基础调试相对复杂。适用场景本项目的重点。我们将通过手写代码来透彻理解每一个步骤这能为日后使用Shader Graph打下坚实的理论基础。本项目选择HLSL代码方案因为它能最直接地揭示原理。我们将从一个最基础的、不依赖URP复杂光照函数的“简化版”开始再升级到“标准版”以理解URP的集成方式。3. 环境准备与基础着色器创建在开始编码前确保你的环境已就绪。3.1 项目设置与URP配置创建或打开一个Unity项目建议使用2021 LTS或更新版本。通过Window Package Manager确保已安装Universal RP包。如果未安装请找到并安装它。在项目窗口中右键点击Assets Create Rendering Universal Render Pipeline Pipeline Asset (Forward Renderer)。这会同时创建一个URP资产和一个渲染器资产。进入Project Settings Graphics将Scriptable Render Pipeline Settings项拖入你刚创建的URP资产。进入Project Settings Quality为每个质量等级同样指定该URP资产。3.2 创建自定义Unlit着色器由于我们要完全自定义光照计算所以从最干净的模板开始在项目窗口中右键Create Shader Universal Render Pipeline Unlit Shader。将其命名为CustomLitShader。双击这个新建的.shader文件用代码编辑器如VSCode, Rider打开。你会看到一个结构清晰的Unlit Shader模板它已经包含了URP所需的HLSLINCLUDE块和Shader核心结构。这个模板包含了顶点着色器Vertex和片元着色器Fragment的基本框架以及用于存储顶点数据的结构体Attributes和用于在顶点与片元着色器之间传递数据的结构体Varyings。我们将在此基础上进行修改。4. 核心算法解析从兰伯特到Blinn-Phong接下来我们深入两个最经典的光照模型数学核心。这是理解所有现代PBR光照模型的基础。4.1 兰伯特漫反射模型光照的基石兰伯特模型描述了理想漫反射表面的光照行为即光线在所有方向均匀散射。其核心公式非常简单Diffuse LightColor * Albedo * max(0, dot(N, L))LightColor入射光的颜色和强度。Albedo物体表面的基础颜色纹理采样或固定色。N表面法线向量归一化。L从表面指向光源的方向向量归一化。dot(N, L)计算法线与光线方向夹角的余弦值。当夹角大于90度点积为负时表示光线从背面照射我们通过max(0, ...)将其贡献置零。在HLSL中的实现关键点// 在片元着色器中 float3 normalWS normalize(input.normalWS); // 世界空间法线 float3 lightDir normalize(_MainLightPosition.xyz); // 主光源方向 float NdotL max(0.0, dot(normalWS, lightDir)); float3 diffuse _MainLightColor.rgb * _BaseColor.rgb * NdotL;这里_MainLightPosition是URP提供的一个内置变量float4其前三个分量.xyz是光源方向对于平行光第四个分量.w在某些情况下用于区分光源类型。_MainLightColor同样是URP提供的。注意法线变换从模型空间到世界空间的法线变换不能简单地使用顶点变换矩阵。如果模型存在非均匀缩放必须使用法线专用矩阵即模型矩阵的逆转置矩阵。在URP的Unlit Shader模板中通常通过TransformObjectToWorldNormal(v.vertexNormalOS)函数来处理这是最安全的方式。4.2 冯氏与Blinn-Phong镜面反射模型增添高光漫反射让物体有体积感而镜面反射则增添了光泽和质感。冯氏模型是经典模型而Blinn-Phong是其一个优化变体计算更快且高光更柔和自然。冯氏模型 (Phong)Specular LightColor * SpecularColor * pow(max(0, dot(R, V)), _Gloss)R反射光线向量可通过reflect(-L, N)计算。V从表面指向摄像机的视角向量。dot(R, V)反射方向与视角方向的接近程度。越接近1说明观察者正好看到反射光高光越强。_Gloss光泽度反光度pow函数的指数。值越大高光点越小、越锐利。Blinn-Phong模型 (推荐)Specular LightColor * SpecularColor * pow(max(0, dot(N, H)), _Gloss)H半角向量即光线方向L与视角方向V的中间向量normalize(L V)。dot(N, H)法线与半角向量的接近程度。这个计算比冯氏模型的dot(R, V)计算量更小且在实际观察中高光过渡更自然。在HLSL中实现Blinn-Phongfloat3 viewDirWS normalize(_WorldSpaceCameraPos.xyz - positionWS); // 计算视角方向 float3 halfVec normalize(lightDir viewDirWS); // 计算半角向量 float NdotH max(0.0, dot(normalWS, halfVec)); float specularIntensity pow(NdotH, _Gloss * _Gloss); // 通常用_Gloss的平方来扩大范围便于调节 float3 specular _MainLightColor.rgb * _SpecularColor.rgb * specularIntensity;为什么选择Blinn-Phong在性能上计算H和一次dot(N,H)通常比计算R和一次dot(R,V)更高效。在视觉效果上Blinn-Phong的高光在边缘处衰减更平滑避免了Phong模型在某些角度下高光突然消失的“断层”感更适合在移动平台或性能敏感的场景中使用。5. 分步实现构建完整的自定义光照着色器现在我们将理论付诸实践一步步构建着色器。5.1 步骤一定义着色器属性与变量在Shader块内的Properties中定义我们需要的材质属性这些会在材质检视器中显示。Properties { [MainTexture] _BaseMap(Albedo (RGB), 2D) white {} [MainColor] _BaseColor(Base Color, Color) (1,1,1,1) _SpecularColor(Specular Color, Color) (1,1,1,1) _Gloss(Gloss, Range(1.0, 256.0)) 32.0 }在HLSL代码块中通常在CBUFFER_START(UnityPerMaterial)里声明与之对应的变量以便在着色器程序中使用。TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); float4 _BaseColor; float4 _SpecularColor; float _Gloss;5.2 步骤二修改数据结构与顶点着色器我们需要在Varyings结构体中增加世界空间法线和世界空间位置的信息以便在片元着色器中使用。struct Varyings { float4 positionCS : SV_POSITION; // 裁剪空间位置 float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 新增世界空间位置 float3 normalWS : TEXCOORD2; // 新增世界空间法线 };在顶点着色器中我们需要计算这些新增的值。Varyings Vertex(Attributes input) { Varyings output; VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS); output.positionCS vertexInput.positionCS; output.positionWS vertexInput.positionWS; output.normalWS normalInput.normalWS; // 使用URP函数安全转换法线 output.uv TRANSFORM_TEX(input.texcoord, _BaseMap); return output; }这里使用了URP提供的工具函数GetVertexPositionInputs和GetVertexNormalInputs它们能正确处理各种坐标空间转换比自己写矩阵乘法更可靠。5.3 步骤三实现片元着色器与光照计算这是最核心的部分。我们将整合兰伯特漫反射和Blinn-Phong镜面反射。half4 Fragment(Varyings input) : SV_Target { // 采样纹理和颜色 half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; // 准备向量数据确保归一化 float3 normalWS normalize(input.normalWS); float3 viewDirWS normalize(_WorldSpaceCameraPos.xyz - input.positionWS); // 获取主光源数据URP标准方式 Light mainLight GetMainLight(); float3 lightDir normalize(mainLight.direction); // 1. 计算兰伯特漫反射 float NdotL max(0.0, dot(normalWS, lightDir)); float3 diffuse mainLight.color * albedo.rgb * NdotL; // 2. 计算Blinn-Phong镜面反射 float3 halfVec normalize(lightDir viewDirWS); float NdotH max(0.0, dot(normalWS, halfVec)); float specularIntensity pow(NdotH, _Gloss); float3 specular mainLight.color * _SpecularColor.rgb * specularIntensity; // 3. 组合最终颜色简单叠加暂不考虑环境光 float3 finalColor diffuse specular; return half4(finalColor, albedo.a); }GetMainLight()函数是URPLighting.hlsl库的核心它返回一个Light结构体包含了方向、颜色、衰减、距离等信息比直接使用内置变量更现代、更强大。5.4 步骤四添加环境光与色调目前我们的着色器在没有直射光的地方会完全变黑。在真实世界中还有来自天空和周围物体的间接光照即环境光。URP通过SampleSH函数来采样球谐光照数据这是一种高效存储低频环境光的方法。// 在片元着色器中计算完直接光照后添加环境光 float3 ambient SampleSH(normalWS); // 采样球谐光照 float3 finalColor ambient * albedo.rgb diffuse specular; // 环境光通常与表面颜色相乘此外为了更好的艺术控制我们可以在最后添加一个简单的色调调整// 可选简单的颜色校正 finalColor finalColor / (finalColor float3(1.0, 1.0, 1.0)); // 色调映射近似Reinhard简化版 finalColor pow(finalColor, 1.0/2.2); // Gamma校正假设工作在Linear空间输出到sRGB空间6. 进阶支持多光源与性能考量一个完整的光照模型不能只支持一个主光源。URP的Forward Renderer支持多个逐像素光源。6.1 集成多光源光照循环URP提供了GetAdditionalLightsCount()和GetAdditionalLight函数来遍历其他光源。我们需要修改片元着色器// 在计算完主光源后初始化最终颜色 float3 finalColor ambient * albedo.rgb diffuse specular; // 主光源贡献 // 获取额外光源数量 uint pixelLightCount GetAdditionalLightsCount(); // 循环遍历所有额外光源 for (uint lightIndex 0; lightIndex pixelLightCount; lightIndex) { Light addLight GetAdditionalLight(lightIndex, input.positionWS); // 需要世界位置来计算衰减 // 跳过亮度为0的光源如已衰减至不可见 if (addLight.distanceAttenuation * addLight.shadowAttenuation 0.0) continue; float3 addLightDir normalize(addLight.direction); // 计算该光源的漫反射 float add_NdotL max(0.0, dot(normalWS, addLightDir)); float3 add_diffuse addLight.color * albedo.rgb * add_NdotL * addLight.distanceAttenuation * addLight.shadowAttenuation; // 计算该光源的镜面反射 float3 add_halfVec normalize(addLightDir viewDirWS); float add_NdotH max(0.0, dot(normalWS, add_halfVec)); float add_specularIntensity pow(add_NdotH, _Gloss); float3 add_specular addLight.color * _SpecularColor.rgb * add_specularIntensity * addLight.distanceAttenuation * addLight.shadowAttenuation; // 累加贡献 finalColor add_diffuse add_specular; }这里的关键是GetAdditionalLight函数它需要当前片元的世界坐标input.positionWS来计算光源的距离衰减。distanceAttenuation和shadowAttenuation分别代表了距离衰减和阴影衰减因子我们将它们乘到光照贡献上。6.2 性能优化实践在移动端或低端设备上逐像素的多光源循环是性能杀手。以下是一些核心优化策略光源剔除与限制在URP Asset中设置Per Object Limit每物体最大光源数和Per Additional Light的Shadow Resolution。通常将每物体光源数限制在2-4个。在脚本中动态启用/禁用光源或根据玩家距离调整光源强度和质量。着色器变体与关键字使用#pragma multi_compile指令来编译不同光源数量的着色器变体。例如可以编译一个只有主光源的变体和一个支持少量额外光源的变体根据项目设置选择。使用#ifdef等条件编译为不需要镜面反射的物体如粗糙墙面编译一个更简单的“漫反射 only”版本。#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS计算优化避免不必要的归一化确保向量在传入关键函数前已归一化避免在循环内重复归一化。简化公式对于性能极度敏感的场景可以考虑用更简单的计算近似高光甚至去掉镜面反射。使用半精度在支持平台上使用half或min16float代替float来存储颜色和向量可以减少带宽和计算量。但要注意精度损失通常用于颜色和低精度数据位置和法线仍需float。实操心得性能分析与调试善用Unity的Frame Debugger和RenderDoc工具。Frame Debugger可以清晰地看到每个Draw Call使用的着色器变体、Pass和渲染状态。RenderDoc可以抓取一帧完整的渲染流水线查看每个片元的具体计算数值是调试复杂光照计算的神器。当你发现帧率下降时先用这些工具定位是哪个物体、哪个Pass、哪个计算消耗最大。7. 常见问题、调试技巧与效果优化即使代码逻辑正确在实际操作中仍会遇到各种问题。这里记录一些典型坑点和解决思路。7.1 高频问题排查清单问题现象可能原因排查步骤与解决方案模型一片漆黑1. 法线数据错误。2. 光源方向或颜色获取失败。3. 着色器未编译或材质球未正确赋值。1. 在片元着色器中将normalWS直接作为颜色输出 (return float4(normalWS * 0.5 0.5, 1.0))检查法线贴图是否正常应是彩色球状。2. 输出_MainLightColor和_MainLightPosition的值到颜色检查是否非零。3. 检查Unity编辑器Console窗口是否有着色器编译错误。检查材质球是否使用了正确的着色器。高光位置不对或形状奇怪1. 视角向量V计算错误。2. 半角向量H或反射向量R计算错误。3. 光泽度_Gloss值不合适。1. 输出viewDirWS作为颜色移动摄像机观察颜色变化是否平滑连续。2. 分别输出NdotH或dot(R, V)的值映射到0-1范围检查高光区域是否在预期位置。3. 尝试将_Gloss调小如8-32观察高光范围变化。额外光源不起作用1. 光源不在物体的“每物体光源”列表内。2. 着色器未启用多光源变体。3. 光源强度或范围太小。1. 确保光源类型是Pixel模式非Vertex且距离物体足够近。在Frame Debugger中查看该物体的光照列表。2. 检查着色器是否包含了#pragma multi_compile _ _ADDITIONAL_LIGHTS等指令。3. 检查光源的Range和Intensity。在片元着色器中输出addLight.distanceAttenuation查看衰减。模型边缘有锯齿状或闪烁1. 法线没有正确归一化。2. 在顶点着色器中对变量进行了线性插值导致片元阶段值不再归一化。1.这是最常见原因务必在片元着色器中对从Varyings结构体传入的normalWS和viewDirWS等向量重新进行normalize()操作。顶点着色器输出的单位向量经过插值后长度不一定是1。在移动设备上性能差1. 逐像素光源过多。2. 着色器计算过于复杂。3. 过度绘制严重。1. 使用URP的Per Object Limit严格控制光源数量。考虑将部分光源烘焙到光照贴图中。2. 使用Shader LOD为远处物体使用简化版着色器。用half精度。3. 优化场景减少透明物体重叠使用遮挡剔除。7.2 调试与可视化技巧分通道输出在开发阶段可以临时修改片元着色器的返回语句将中间计算值如NdotL,specularIntensity,attenuation直接作为颜色输出可以非常直观地看到每个分量的贡献范围和强度。// 调试输出漫反射强度 return float4(NdotL.xxx, 1.0); // 调试输出高光强度 return float4(specularIntensity.xxx, 1.0);使用Unity内置的调试视图在Scene视图的左上角下拉菜单中可以选择Shading Mode为Albedo,Normal,Lighting等这有助于你从宏观上判断场景的光照和法线信息是否正确。自定义调试GUI可以写一个简单的MonoBehaviour脚本挂在摄像机或某个管理器上用OnGUI函数在游戏窗口实时显示一些关键变量如当前光源数、主要参数方便运行时调整。7.3 效果优化与扩展思路当基础模型运行良好后可以考虑以下方向进行深化引入法线贴图将normalWS的计算改为从法线贴图Normal Map中采样。这需要处理切线空间到世界空间的转换涉及TBN矩阵切线、副切线、法线矩阵的构建。实现阴影URP中可以通过SHADOWS_MAIN_LIGHT和SHADOWS_ADDITIONAL_LIGHTS等关键字在GetMainLight和GetAdditionalLight函数中传入阴影坐标来获取shadowAttenuation。这需要正确设置光源的阴影投射和物体的阴影接收。向PBR进阶将Blinn-Phong镜面反射模型替换为更物理的Cook-Torrance或GGX微表面模型。这需要引入粗糙度Roughness、金属度Metallic等参数并使用更复杂的BRDF双向反射分布函数计算。风格化渲染基于此框架可以轻松实现卡通渲染。例如将NdotL的连续值通过一个阈值函数如smoothstep离散化为几个色阶实现卡通着色用一张噪波贴图或基于视角/法线的计算来生成高光形状实现非真实感的高光。实现一个基础光照模型就像是掌握了渲染的“语法”。从此你可以阅读更复杂的着色器代码理解Shader Graph每个节点背后的数学并最终创造出属于你自己的视觉语言。这个过程可能会遇到不少挫折但每一次问题的解决都会让你对实时图形学的理解加深一层。我个人的体会是不要只停留在复制代码多尝试修改参数、拆分输出中间结果甚至“破坏”它再看现象这种主动探索带来的理解远比被动阅读要深刻得多。