URP自定义光照模型:从Lambert到Blinn-Phong的完整实现指南
1. 项目概述:为什么要在URP里折腾光照模型?
如果你是从Unity内置渲染管线(Built-in)或者直接上手URP(Universal 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-Phong:
float3 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个。 - 在脚本中动态启用/禁用光源,或根据玩家距离调整光源强度和质量。
- 在URP Asset中,设置
着色器变体与关键字:
- 使用
#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每个节点背后的数学,并最终创造出属于你自己的视觉语言。这个过程可能会遇到不少挫折,但每一次问题的解决,都会让你对实时图形学的理解加深一层。我个人的体会是,不要只停留在复制代码,多尝试修改参数、拆分输出中间结果,甚至“破坏”它再看现象,这种主动探索带来的理解远比被动阅读要深刻得多。