1. 项目概述:为什么我们需要在Shader Graph里搞自定义光照?
如果你用Unity的URP管线做项目有一段时间了,大概率会遇到一个瓶颈:Shader Graph自带的那些光照节点,比如PBR Master或者Simple Lit,用起来是方便,但想实现一些“特别”的光照效果时,就感觉手脚被捆住了。比如,你想做一个风格化的角色,需要边缘光(Rim Light)的强度不仅受视角影响,还和角色当前的生命值挂钩;或者你想做一个科幻场景,让物体的高光反射带有扫描线动画;又或者,你想精确控制场景中每个点光源对某个特定材质的衰减方式。这些需求,用标准光照模型去调参数,往往事倍功半,甚至根本做不到。
这就是“自定义光照”(Custom Lighting)的价值所在。它把光照计算的“黑盒”打开,让你能亲手编写光照与材质表面交互的每一个步骤。而URP Shader Graph的Custom Lighting功能,则是把这个高级能力,用可视化的节点图方式呈现出来,大大降低了门槛。你不用从头去写一个完整的HLSL着色器,只需要在Shader Graph里,像搭积木一样,组合出你理想中的光照公式。
最近社区里讨论热度很高的“体积光”和“高亮”(Outline/Highlight)效果,其底层实现往往都绕不开对光照数据的精细操控。体积光需要处理光线在介质中的散射和衰减,高亮效果则需要独立于主光照之外,叠加一层特殊的边缘计算。这些,都是标准PBR流程难以优雅实现的,恰恰是Custom Lighting的用武之地。
所以,这篇指南的目标很明确:我们不只教你打开Custom Function节点写几行代码,而是要带你彻底理解URP Shader Graph自定义光照的整套工作流。从获取光源数据、编写光照模型函数,到处理阴影、环境光遮蔽(AO),最后整合成一个专业、高效且可复用的Shader Graph。无论你是想实现独特的艺术风格,还是解决特定的渲染技术难题,这套方法都能给你提供坚实的支撑。
2. 核心思路拆解:Shader Graph自定义光照的底层逻辑
在深入节点之前,我们必须先搞清楚Shader Graph的Custom Lighting和手写Shader的Custom Lighting在思路上有何异同。理解了这一点,你才能避免“照猫画虎”,真正灵活运用。
2.1 URP光照数据获取的“标准路径”
在手写URP着色器中,如Unity官方文档所示,我们通过#include "Lighting.hlsl",然后调用GetMainLight()、GetAdditionalLight()等函数来获取光源数据。这些函数返回一个Light结构体,里面包含了方向、颜色、距离衰减、阴影衰减等关键信息。
在Shader Graph里,我们无法直接#include头文件,但Shader Graph的底层本身就是生成HLSL代码。因此,自定义光照的核心,就是通过一个“自定义函数”(Custom Function)节点,把我们想用的HLSL代码“注入”到Shader Graph生成的代码中去。
这个自定义函数节点,就是连接Shader Graph可视化界面与底层HLSL代码世界的桥梁。你需要告诉它:函数名是什么、输入参数有哪些、输出结果是什么,以及最重要的——函数体(即HLSL代码)的内容。
2.2 从“表面着色器”到“片元着色器”的思维转变
使用Shader Graph的PBR Master节点时,你是在一个“表面着色器”(Surface Shader)的简化抽象层里工作。你提供颜色、法线、金属度、光滑度等参数,引擎帮你完成复杂的BRDF计算。
而当你启用Custom Lighting时,你实际上接管了最终颜色输出的计算。PBR Master节点会退居二线,它提供的输入(如Base Color, Normal)依然可用,但最终的光照混合、颜色输出,将由你连接的Custom Lighting节点完全决定。
这要求你的思维要从“配置参数”转变为“编写算法”。你需要思考:对于当前这个像素(片元),所有光源对它贡献的总和应该如何计算?漫反射怎么算?高光怎么算?环境光怎么加?这些都需要你明确定义。
2.3 模块化设计:分离数据获取与光照计算
一个健壮的自定义光照实现,应该遵循模块化原则。我建议将其拆分为至少两个核心部分:
光照数据获取模块:这是一个或多个Custom Function节点,专门负责调用URP的
GetMainLight、GetAdditionalLightsCount、GetAdditionalLight等函数,将获取到的光源数据(方向、颜色、衰减)整理并输出给后续节点。这个模块通常比较固定,可以做成一个可复用的子图(Sub Graph)。光照模型计算模块:这是真正体现你创意的地方。它接收来自上一个模块的光源数据,以及来自材质属性的表面信息(颜色、法线、粗糙度等),然后根据你定义的数学模型(如Lambert, Phong, Blinn-Phong,或更风格化的公式)计算出最终的光照颜色。这个模块也可以做成子图,方便在不同材质间切换不同的光照模型。
这种分离使得你的Shader Graph结构清晰,调试方便。当光照效果不对时,你可以先检查数据获取模块输出的光源颜色和方向是否正确,再排查光照模型的计算逻辑。
3. 实战第一步:构建可复用的光照数据获取子图
理论说再多不如动手做。我们首先来创建一个最核心的部件:一个能获取主光源和所有额外点光源数据的子图。
3.1 创建Custom Function节点与定义接口
在Shader Graph中新建一个子图(Sub Graph),命名为“URP_LightingData”。在空白处右键,选择Create Node->Custom Function。
选中这个Custom Function节点,在Graph Inspector面板中进行关键配置:
- Name: 可以命名为
GetMainLightData。 - Type: 选择String。这是最关键的一步,意味着我们将直接输入HLSL代码。
- Source: 在下方的大文本框中,粘贴我们的HLSL代码。这里我们先实现获取主光源:
void GetMainLightData_float(float3 WorldPos, out float3 Direction, out float3 Color, out float DistanceAtten, out float ShadowAtten) { #ifdef SHADERGRAPH_PREVIEW // 在Shader Graph预览窗口或未烘焙光照时,使用一个默认的顶光源 Direction = float3(0, 1, 0); Color = float3(1, 1, 1); DistanceAtten = 1.0; ShadowAtten = 1.0; #else // 包含URP光照库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // 获取主光源 Light mainLight = GetMainLight(); Direction = mainLight.direction; Color = mainLight.color; DistanceAtten = mainLight.distanceAttenuation; ShadowAtten = mainLight.shadowAttenuation; #endif }注意:这里有一个极其重要的技巧——使用
#ifdef SHADERGRAPH_PREVIEW。Shader Graph的预览窗口(Preview)通常没有完整的光照环境,直接调用GetMainLight()可能会失败或返回错误数据,导致你的节点图在预览中一片黑。这个预处理指令判断是否在预览中,如果是,则返回一套默认的、可视化的光照数据,让你在编辑时就能看到大致效果。
- Inputs: 添加一个输入端口,类型
Vector 3,名称WorldPos。这是片元的世界空间坐标,用于某些光照计算(虽然GetMainLight不需要,但为后续扩展预留)。 - Outputs: 添加四个输出端口:
Direction(Vector 3)Color(Vector 3)DistanceAtten(Float)ShadowAtten(Float)
配置好后,这个节点就有了一个输入和四个输出。将其输出端口连接到子图的输出节点上,并命好名(如Main Light Direction)。现在,你就有了一个能在任何Shader Graph中调用的、安全可靠的主光源数据获取器。
3.2 扩展:获取所有额外光源并循环计算
只有一个主光源显然不够。我们需要处理场景中的多个点光源、聚光灯。这需要用到循环。在Shader Graph中实现循环,需要一点技巧。
我们再创建一个Custom Function节点,命名为CalculateAdditionalLights。
void CalculateAdditionalLights_float(float3 WorldPos, float3 WorldNormal, float3 BaseColor, out float3 TotalAddLight) { TotalAddLight = float3(0, 0, 0); #ifdef SHADERGRAPH_PREVIEW // 预览模式下,简单模拟一个点光源 float3 previewLightPos = float3(0, 2, 0); float3 lightDir = normalize(previewLightPos - WorldPos); float diff = max(dot(WorldNormal, lightDir), 0.0); TotalAddLight = BaseColor * diff * float3(0.5, 0.5, 0.8); // 给点蓝色调 #else #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" int pixelLightCount = GetAdditionalLightsCount(); for (int i = 0; i < pixelLightCount; ++i) { Light light = GetAdditionalLight(i, WorldPos); // 简单的兰伯特漫反射计算 float3 lightDir = light.direction; float diff = max(dot(WorldNormal, lightDir), 0.0); float3 diffuse = light.color * diff * light.distanceAttenuation * light.shadowAttenuation; TotalAddLight += diffuse; } #endif }这个函数接收世界坐标、法线、基础颜色,遍历所有额外光源,为每个光源计算一个简单的漫反射贡献,并累加到TotalAddLight输出中。
实操心得:在Shader Graph的Custom Function里写循环是可行的,但要注意性能。
GetAdditionalLightsCount()返回的是影响当前像素的光源数量,由URP的每像素光照(Per-Pixel Lighting)设置控制。在移动平台或性能敏感场景,务必在URP Asset中限制“Per Object Light”的数量,并避免在循环内进行过于复杂的计算。
3.3 整合成完整的照明子图
现在,我们有了获取主光源和额外光源的两个函数。在一个主Shader Graph中,你可以这样连接:
- 使用Position节点的World输出,作为两个Custom Function的
WorldPos输入。 - 使用Normal节点的World输出,作为
CalculateAdditionalLights的WorldNormal输入。 - 将
GetMainLightData输出的Color、Direction、ShadowAtten,与你想要的表面法线(通常是Normal Vector节点输出)一起,输入到你自己的光照模型计算节点(比如另一个Custom Function,实现Phong模型)。 - 将
CalculateAdditionalLights输出的TotalAddLight,与主光源的计算结果相加。 - 最后,不要忘记加上环境光。可以使用Ambient节点,或者通过Custom Function调用
SampleSH9函数来获取球谐光照数据。
这样,一个支持多光源、包含阴影衰减的基础光照框架就搭建好了。你可以将这个复杂的连线网络,再次封装成一个名为“StandardCustomLighting”的子图,输入法线、颜色等,直接输出最终光照颜色,极大提升复用性。
4. 实现专业级光照效果:从兰伯特到风格化
有了可靠的数据管道,我们就可以专注于光照模型本身了。这才是体现“专业级”和“自定义”的地方。
4.1 基础模型复现:兰伯特与布林-冯
首先,我们实现一个经典的兰伯特漫反射模型,作为验证。创建一个名为LambertLighting的Custom Function。
void LambertLighting_float(float3 LightColor, float3 LightDir, float3 Normal, out float3 Out) { float NdotL = max(dot(normalize(Normal), normalize(LightDir)), 0.0); Out = LightColor * NdotL; }将主光源的Color、Direction和物体World Normal输入,输出就是兰伯特漫反射结果。简单,但足以验证整个链路是否通畅。
接下来,实现一个更常用的布林-冯高光模型。这需要视角方向。
void BlinnPhongLighting_float(float3 LightColor, float3 LightDir, float3 Normal, float3 ViewDir, float SpecularPower, float SpecularIntensity, out float3 DiffuseOut, out float3 SpecularOut) { // 漫反射 (Lambert) float NdotL = max(dot(normalize(Normal), normalize(LightDir)), 0.0); DiffuseOut = LightColor * NdotL; // 高光 (Blinn-Phong) float3 halfVec = normalize(normalize(LightDir) + normalize(ViewDir)); float NdotH = max(dot(normalize(Normal), halfVec), 0.0); SpecularOut = LightColor * pow(NdotH, SpecularPower) * SpecularIntensity; }这里我们输出了漫反射和高光两个部分。在Shader Graph中,你可以用View Direction节点获得视角方向。SpecularPower控制高光点的大小(幂次越高,高光点越小越锐利),SpecularIntensity控制高光强度。
4.2 进阶效果:边缘光与菲涅尔效应
边缘光(Rim Light)是增强物体轮廓、营造氛围的利器。其核心是菲涅尔效应:视线与表面法线夹角越大(即看边缘时),效果越强。
void RimLight_float(float3 Normal, float3 ViewDir, float RimPower, float RimIntensity, out float Rim) { float fresnel = 1.0 - saturate(dot(normalize(Normal), normalize(ViewDir))); Rim = pow(fresnel, RimPower) * RimIntensity; }将这个Rim因子乘以一个边缘光颜色,然后加到最终光照结果上。你可以用它来做卡通渲染的描边,或者科幻物体的能量外溢效果。
4.3 结合热词:实现“高亮”与“体积光”思路
高亮(Outline/Highlight):一种常见的实现方式是基于法线或深度的屏幕后处理。但在自定义光照层面,我们可以实现一种“内发光”式的高亮。例如,在计算光照时,额外判断表面某属性(如一张遮罩贴图的R通道),如果超过阈值,则让该区域的自发光强度剧增,或者使用一个完全独立于主光照的、饱和度高且不受阴影影响的二次光照计算。这需要你在光照计算逻辑中加入分支判断。
体积光(Volumetric Light):这在Shader Graph中通常通过后处理或粒子系统实现,但自定义光照可以辅助。例如,你可以让物体在特定视角和光照方向下,模拟光线穿过半透明介质的效果。这需要更复杂的光照模型,可能涉及对光源方向与视角方向的点积进行重映射,并叠加噪声纹理来模拟光线中的尘埃散射。你可以创建一个Custom Function,输入
WorldPos,LightDir,ViewDir,输出一个Scatter因子,用来调制最终颜色或透明度,模拟光线在物体内部的散射。
5. 阴影与环境光遮蔽的集成
没有阴影的光照是没有灵魂的。在自定义光照中集成阴影,是迈向专业级的关键一步。
5.1 正确应用阴影衰减
幸运的是,我们在第一步获取光源数据时,GetMainLight和GetAdditionalLight返回的Light结构体里,已经包含了shadowAttenuation(阴影衰减)这个字段。它的值在0到1之间,0表示完全在阴影中,1表示完全被照亮。
因此,在你的光照计算中,无论是漫反射还是高光,在乘以光源颜色light.color之后,一定要再乘以light.shadowAttenuation。对于主光源,我们已经从GetMainLightData节点获取了这个值。对于额外光源,在循环体内,light.shadowAttenuation也是可用的。
// 在额外光源循环内 float3 diffuse = light.color * NdotL * light.distanceAttenuation * light.shadowAttenuation; // 注意这里乘了阴影衰减注意事项:确保你的Shader Graph的Master节点设置中,Shadow Caster Pass是启用的。否则,物体无法投射阴影。同时,接受阴影需要物体材质包含正确的渲染标签(Tags)和深度写入,这些通常在创建URP Lit Shader Graph时已默认配置好,但如果你是从头创建Unlit Graph,则需要手动添加必要的Pass和设置。
5.2 集成屏幕空间环境光遮蔽
环境光遮蔽能极大地增强场景的立体感和真实感。URP提供了屏幕空间环境光遮蔽功能。要在自定义光照中使用它,我们需要获取当前像素的SSAO数据。
创建一个名为GetSSAO的Custom Function节点。
void GetSSAO_float(float2 ScreenUV, out float SSAO) { SSAO = 1.0; // 默认值 #ifndef SHADERGRAPH_PREVIEW #if defined(_SCREEN_SPACE_OCCLUSION) // 包含SSAO库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOcclusionTexture.hlsl" // 采样SSAO纹理 float2 uv = ScreenUV; #if UNITY_UV_STARTS_AT_TOP uv.y = 1 - uv.y; #endif SSAO = SampleAmbientOcclusion(uv); #endif #endif }这个函数首先检查是否启用了_SCREEN_SPACE_OCCLUSION关键字(在URP Renderer Asset中启用SSAO后会自动定义)。然后,它采样SSAO纹理。ScreenUV输入需要是归一化的屏幕空间坐标(0到1),可以通过Screen Position节点的Normalized输出获得。
在最终光照计算中,将漫反射和环境光部分乘以这个SSAO因子。通常高光部分不受AO影响。
float3 finalColor = (diffuse + ambient) * ssaoFactor + specular;6. 性能优化与常见问题排查
自定义光照功能强大,但滥用也会带来性能问题。以下是一些关键的优化点和排错技巧。
6.1 性能优化要点
- 光源数量管理:这是最大的性能杀手。在URP Asset的Lighting设置中,严格控制Per Object Lights的数量。在Custom Function的循环中,
GetAdditionalLightsCount()返回的就是这个值。对于移动端,建议设置为2-4个。 - 分支与预览宏:充分利用
#ifdef SHADERGRAPH_PREVIEW和#if defined(_SCREEN_SPACE_OCCLUSION)这类编译分支。预览代码要轻量,只在编辑器下编译;运行时功能只在对应功能启用时编译,避免不必要的计算和纹理采样。 - 避免复杂计算在循环内:光照模型的计算应尽可能高效。如果模型复杂,考虑将其部分计算结果(如基于法线的某种映射)提前到循环外计算。
- 使用子图复用:将光照数据获取、通用光照模型(如兰伯特)打包成子图。这不仅能保持主图整洁,Shader Graph在生成代码时也可能对重复使用的子图进行更好的优化。
6.2 常见问题排查表
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| Shader Graph预览一片黑 | 1. 未处理SHADERGRAPH_PREVIEW宏。2. 光源数据获取失败, Direction或Color为(0,0,0)。 | 1. 检查Custom Function代码,确保在#ifdef SHADERGRAPH_PREVIEW分支下提供了合理的默认值。2. 在预览中,尝试输出 Direction或Color到一个颜色端口,看是否为非零值。 |
| 物体不接收阴影 | 1. Master节点未启用Shadow Caster Pass。 2. 在光照计算中未乘以 shadowAttenuation。3. 光源未开启投射阴影。 | 1. 检查Graph Inspector中Master节点的“Shadow Caster”选项。 2. 检查光照计算代码,确保 light.shadowAttenuation被乘入。3. 在Unity场景中检查光源的“Cast Shadows”属性。 |
| 额外光源没效果 | 1.GetAdditionalLightsCount()返回0。2. 光源不在物体的每对象光照限制内。 3. 循环内计算错误,结果未累加。 | 1. 在函数中输出pixelLightCount到颜色,看是否大于0。2. 检查URP Asset的Per Object Lights设置,并确保点/聚光灯在物体附近且强度足够。 3. 检查 TotalAddLight变量是否在循环前正确初始化为0,并在循环内正确+=。 |
| 自定义高光异常闪烁或过亮 | 1. 视角方向或半角向量未归一化。 2. 高光幂次( SpecularPower)过低。3. 高光计算在阴影处未正确衰减。 | 1. 确保在计算点积前,对ViewDir、HalfVec等向量进行normalize。2. 适当提高 SpecularPower,如32、64、128。3. 确保高光计算也乘以了 light.shadowAttenuation(根据艺术需求决定)。 |
| SSAO不起作用 | 1. URP Renderer Asset中未启用SSAO。 2. 屏幕UV坐标获取错误。 3. 未定义 _SCREEN_SPACE_OCCLUSION宏。 | 1. 在URP Renderer Asset的Renderer Features中添加并配置“Screen Space Ambient Occlusion”。 2. 使用Screen Position节点的Normalized输出,并注意UV的Y方向(用宏处理)。 3. 确保材质使用的Shader变体包含SSAO。有时需要手动在材质上启用一个开关或检查Shader的编译变体。 |
6.3 调试技巧
当效果不符合预期时,不要盲目修改代码。学会使用Shader Graph的调试功能:
- 分步输出:将复杂的Custom Function拆开。例如,先单独输出
Light.Color,看颜色对不对;再输出NdotL,看漫反射因子范围是否在[0,1];最后再输出最终结果。 - 使用颜色可视化:将任何中间值(向量或标量)连接到Master节点的颜色输入上,可以直观地看到它的空间分布。这是调试Shader最强大的手段之一。
- 检查生成的代码:在Shader Graph的Graph Inspector底部,点击“Show Generated Code”,可以查看最终生成的HLSL代码。检查你的Custom Function代码是否被正确插入,宏定义是否生效。这对于排查复杂的编译问题非常有用。
掌握URP Shader Graph的自定义光照,相当于为你打开了图形渲染的一扇新大门。它不再是简单的参数调节,而是真正的创作。从获取数据到设计模型,从集成阴影到优化性能,这套流程需要反复练习和调试。我个人的经验是,从一个最简单的、只有主光源的兰伯特模型开始,确保每一步都正确,然后再逐步添加额外光源、高光、特效和优化。每成功实现一个效果,你对光照和着色的理解就会加深一层。最终,你将能够游刃有余地用可视化工具,创造出独一无二的视觉风格,解决那些标准着色器无法应对的渲染挑战。