Shader光照模型实战:从Lambert到Blinn-Phong的Unity URP实现与调试
1. 项目概述:从“读书笔记”到“实战手册”
看到“Shader入门精要读书笔记 - 第七章”这个标题,很多正在学习Shader编程的朋友可能会心一笑。这通常意味着我们正处在学习Shader的一个关键分水岭上。前几章可能还在讲渲染管线、数学基础、基础语法,而第七章,在很多经典的Shader教材中,往往开始触及那些让物体真正“活”起来、让画面产生质变的核心技术:光照与阴影。
我自己在最初学习Shader时,对着书本上的光照模型公式和代码示例,常常有种“看懂了,但没完全懂”的感觉。公式推导很清晰,代码也能抄下来运行,但一旦想自己修改参数、调整效果,或者应用到自己的模型上,就立刻抓瞎。问题出在哪里?我认为是缺少了从“理论推导”到“工程实践”的桥梁,缺少了对那些隐藏在代码背后的“设计意图”和“调试技巧”的理解。
因此,这篇笔记的目的,不仅仅是复述《Shader入门精要》第七章的内容,更是结合我多年在Unity URP、Shader Graph以及一些特效制作中的实际踩坑经验,把这一章拆解、嚼碎,补充上那些书本上来不及细说,但对实际工作至关重要的细节。我们会重点探讨Unity URP(Universal Render Pipeline)环境下,如何理解和实现基础光照模型,并会触及体积光、高亮、模糊等热词背后的核心思路。无论你是想理解URP的Lit Shader,还是想在Amplify Shader Editor或Cocos Creator中实现特定效果,希望这篇超过5000字的深度解析能成为你手边可靠的“实战参考手册”。
2. 核心思路拆解:光照模型为何是Shader的基石
2.1 为什么从第七章开始变得困难
在进入具体内容前,我们先聊聊为什么很多人在学习Shader时,会在光照模型这一章感到吃力。前几章的顶点/片元着色器、语义、基础数学,更像是学习一门新语言的“语法”和“单词”。而第七章的光照模型,则是要求你用这些单词和语法,去写一首描写“光如何与物体互动”的诗歌。这中间有一个巨大的思维跳跃:从理解机制到创造效果。
光照模型(Lighting Model)的本质,是一套数学规则,用于计算在特定光线条件下,物体表面某一点最终呈现的颜色值。这个计算过程,在片元着色器(Fragment Shader)中完成。书本上通常会从最经典的兰伯特(Lambert)漫反射模型和冯氏(Phong)或布林-冯(Blinn-Phong)镜面反射模型讲起。理解这两个模型,是理解一切现代PBR(基于物理的渲染)光照的起点。
2.2 核心光照模型:漫反射与镜面反射的“分工协作”
我们可以把物体表面的颜色想象成由两部分组成:漫反射(Diffuse)和镜面反射(Specular)。
漫反射(Lambert):这是光线撞击物体表面后,向四面八方均匀散射的部分。它决定了物体的“固有色”在光照下能有多亮。它的核心计算只依赖于两个向量的夹角:表面法线(Normal)和光线方向(Light Direction)。夹角越小(光线越正射表面),漫反射越亮;夹角越大(光线越 grazing),漫反射越暗。计算公式很简单:
漫反射颜色 = 光源颜色 * 表面漫反射颜色 * max(0, dot(法线, 光线方向))。这里的dot是点积,max(0, ...)确保了背面不会接收到光照。镜面反射(Phong/Blinn-Phong):这是光线在光滑表面产生的高光亮点。它决定了物体看起来有多“光滑”或“湿润”。它的计算依赖于三个向量:视线方向(View Direction)、光线方向、以及反射方向(Phong)或半角向量(Blinn-Phong)。Blinn-Phong模型因为计算更高效、高光更自然,在实践中更常用。其核心是计算半角向量
H = normalize(光线方向 + 视线方向),然后看这个半角向量与法线的接近程度:镜面反射强度 = pow(max(0, dot(法线, H)), 光泽度)。pow是幂函数,那个“光泽度”参数(通常叫Glossiness或Shininess)控制着高光点的大小和锐利程度,值越大,高光点越小越集中。
注意:书本上的公式是纯净的。但在Unity URP等现代渲染管线中,光源颜色、衰减、阴影等信息都是由引擎在“光照循环”中提供给Shader的。我们写的Shader代码,更像是一个“加工厂”,接收这些原材料(光照数据、材质属性),然后按照我们的模型配方输出颜色。理解这一点,才能看懂URP Shader Library中那些复杂的
Lighting.hlsl文件。
2.3 从理论到实践的桥梁:着色频率
这是书本可能一笔带过,但实战中极其重要的概念:在哪个阶段计算光照?
- 逐顶点光照(Per-vertex):在顶点着色器中计算光照,然后在片元间插值结果。效率高,但精度低,在低多边形模型上会有明显的棱状光斑(马赫带)。
- 逐片元光照(Per-fragment / Per-pixel):在片元着色器中为每个像素单独计算光照。效果细腻平滑,是现代游戏的标准做法,也是我们主要学习的方式。
在Unity URP的Shader中,我们几乎总是在片元着色器阶段进行完整的光照计算。这意味着我们需要在片元着色器中获取插值后的法线、世界坐标等数据,然后调用URP封装好的光照函数(如LightingLambert、LightingSpecular)或自己实现。
3. 关键细节解析:那些书本上不提的“坑”与技巧
3.1 法线向量的“空间”陷阱
这是新手最容易栽跟头的地方。在Shader中,向量必须在同一个坐标空间下进行运算,点积才有意义。通常,光照计算在世界空间(World Space)或切线空间(Tangent Space)进行。
- 世界空间计算:需要将顶点法线(通常是模型空间或切线空间)转换到世界空间。在URP中,你可以在顶点着色器中使用
TransformObjectToWorldNormal()函数来转换法线。关键点:如果模型有非均匀缩放(Scale的x, y, z值不同),直接这样转换会出错,需要使用法线矩阵(Normal Matrix)的逆转置来校正。幸运的是,Unity的封装函数通常帮我们处理了这一点。 - 切线空间计算:常用于法线贴图。此时,光线方向和视线方向需要从世界空间转换到每个像素的切线空间。这需要用到从顶点着色器传递下来的切线空间矩阵(TBN矩阵)。
实操心得:在写自定义光照Shader时,我习惯先在片元着色器里把用到的所有关键向量(法线、光线、视线)打印出来(比如通过
return float4(法线.xyz*0.5+0.5, 1.0)可视化)。确保它们的方向看起来是合理的(比如法线应该大致从表面指向外),这是排查光照计算错误的第一步。
3.2 能量守恒与线性空间
书本上的基础模型往往不考虑能量守恒,但这是一个重要的物理概念:表面反射的总光能(漫反射+镜面反射)不应超过入射光能。在简单的Lambert+Phong模型中,我们通过经验值来平衡两者。而在更先进的PBR模型中,能量守恒是核心约束之一。
另一个关键是线性空间(Linear Space)渲染。颜色值在物理世界中的叠加是线性的,但我们的显示器和人眼感知是非线性的(Gamma空间)。现代渲染管线(如URP默认)会在Shader计算中使用线性空间颜色,最后输出时再转换回Gamma空间。这意味着:
- 你从纹理中采样颜色时,如果纹理是sRGB格式(如大部分颜色贴图),需要声明
SAMPLER(sampler_MainTex); TEXTURE2D(_MainTex);并使用SAMPLE_TEXTURE2D宏,引擎会自动进行sRGB到线性的转换(如果管线设置正确)。 - 你在Shader中混合、计算颜色时,是在线性空间下进行的,这能让光照和阴影的过渡更自然。
- 最后输出到帧缓冲时,管线会进行线性到sRGB的转换。
如果你发现自己的Shader颜色看起来“发灰”或对比度不对,检查纹理导入设置和颜色空间是首要步骤。
3.3 与环境光的结合
基础光照模型通常只考虑直接光(Directional Light, Point Light等)。但真实世界中,光线会在场景中多次反弹,形成无处不在的环境光(Ambient)。在Shader中,环境光通常以一个简单的常量颜色或通过环境光探头(Light Probe)来提供。
一个最简单的实现是:最终颜色 = 环境光颜色 * 表面漫反射颜色 + 直接光漫反射 + 直接光镜面反射。在Unity中,环境光颜色可以通过SHADERGRAPH_SAMPLE_SCENE_INDIRECT_LIGHTING(Shader Graph)或SampleSH函数(HLSL代码)来获取更精确的基于球谐函数的间接光照。
4. 在Unity URP中实现基础光照:手写HLSL与Shader Graph对比
4.1 手写HLSL代码实现
我们以一个最简单的、支持单盏平行光的Blinn-Phong模型为例,在URP中创建一个Unlit Shader,并修改其片元着色器。
// 首先,在Properties块定义材质属性 Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Gloss ("Gloss", Range(1, 256)) = 20 _SpecularColor ("Specular Color", Color) = (1,1,1,1) } // 在顶点着色器输出结构体(v2f或Attributes->Varyings)中,确保包含: // float3 worldNormal : TEXCOORD1; // float3 worldPos : TEXCOORD2; // 在片元着色器中: half4 frag (Varyings IN) : SV_Target { // 采样纹理和颜色 half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _Color; // 获取表面法线(确保是归一化的世界空间法线) float3 normalWS = normalize(IN.worldNormal); // 获取视线方向(从表面指向摄像机) float3 viewDirWS = normalize(_WorldSpaceCameraPos.xyz - IN.worldPos.xyz); // 获取主平行光数据(URP内置变量和函数) Light mainLight = GetMainLight(); float3 lightDirWS = normalize(mainLight.direction); float3 lightColor = mainLight.color; // 1. 计算兰伯特漫反射 float lambert = max(0.0, dot(normalWS, lightDirWS)); float3 diffuse = lightColor * albedo.rgb * lambert; // 2. 计算Blinn-Phong镜面反射 float3 halfDir = normalize(lightDirWS + viewDirWS); float specularTerm = pow(max(0.0, dot(normalWS, halfDir)), _Gloss); float3 specular = lightColor * _SpecularColor.rgb * specularTerm; // 3. 简单环境光(这里用Unity内置的球谐环境光) float3 ambient = SampleSH(normalWS) * albedo.rgb; // 4. 组合最终颜色(这里没有考虑能量守恒,简单相加) float3 finalColor = ambient + diffuse + specular; return half4(finalColor, albedo.a); }这段代码省略了阴影、多光源处理等复杂部分,但清晰地展示了在片元着色器中组织光照计算的完整流程。GetMainLight()和SampleSH()是URP Shader Library提供的工具函数,简化了我们的工作。
4.2 使用Shader Graph可视化构建
对于不熟悉代码的艺术家或想快速原型的设计师,Shader Graph是绝佳工具。实现同样的Blinn-Phong模型:
- 创建节点:创建
Texture 2D节点连接Albedo,Color节点用于着色,Sample Texture 2D节点采样纹理。 - 法线与向量:使用
Normal Vector节点获取世界空间法线,View Direction节点获取视线方向。 - 光照数据:使用
Main Light Direction和Main Light Color节点获取主光信息。 - 计算漫反射:使用
Dot Product节点计算法线和光方向的点积,通过Max节点钳制到0,再与光源颜色、表面颜色相乘。 - 计算镜面反射:使用
Add节点将光方向和视线方向相加,Normalize节点归一化得到半角向量。再用Dot Product与法线点积,Power节点进行光泽度次幂计算,最后与光源颜色、高光颜色相乘。 - 环境光:使用
Ambient节点。 - 组合:使用
Add节点将环境光、漫反射、镜面反射结果相加,输出到Master Stack的Base Color。
Shader Graph的优势是直观,每一步计算都可视化,调整参数实时反馈。但它的底层仍然是生成HLSL代码。理解上面的代码逻辑,能让你在Shader Graph中布线时更有目的性,而不是盲目连接节点。
4.3 与URP Lit Shader的关联
你可能会问,URP自带的Lit Shader已经非常强大,为什么还要自己写?自己实现基础模型的意义在于:
- 深度理解:拆解黑盒,知道Lit Shader那些光滑度、金属度参数最终是如何影响计算的。
- 定制需求:当你有特殊的光照效果需求(比如卡通渲染、特殊高光形状)时,必须从基础模型开始改造。
- 性能优化:对于极简风格的移动端游戏,一个自定义的、只包含必要计算的基础光照Shader,性能远优于全功能的PBR Lit Shader。
5. 常见问题排查与实战技巧实录
即使理解了原理,动手实现时还是会遇到各种妖魔鬼怪。下面是我总结的一些典型问题及排查思路。
5.1 问题一:模型一片漆黑或光照方向不对
- 检查法线:这是最高频的问题。首先在Shader中可视化法线(
return float4(normalWS*0.5+0.5, 1.0))。如果模型显示为统一的颜色(如纯蓝),说明法线数据没有正确传递或转换。检查:- 模型导入设置是否勾选了“Read/Write Enabled”和正确的法线模式(通常为Import或Calculate)。
- 顶点着色器中是否进行了正确的法线空间转换。
- 片元着色器中是否对插值后的法线进行了
normalize(插值会导致向量长度不为1)。
- 检查光源:确认场景中确实存在光源(平行光、点光等),并且其强度(Intensity)不为0。在Shader中打印光源方向或颜色看看。
- 检查向量计算:确保
lightDirWS是从表面指向光源的方向。对于平行光,引擎提供的光方向通常是从光源指向表面,所以计算点积时可能需要取反或直接用。需要查阅具体引擎的API说明。
5.2 问题二:高光(Specular)不出现或位置奇怪
- 检查视线向量:确保
viewDirWS是从表面指向摄像机的。公式是摄像机世界坐标 - 表面点世界坐标。同样需要normalize。 - 检查半角向量:确认
halfDir = normalize(lightDir + viewDir)计算正确。 - 调整光泽度(Gloss/Shininess):这个值非常敏感。尝试从较小的值(如2-10)开始调试,你会看到一个巨大而柔和的高光。然后逐渐增大(32, 64, 128),观察高光如何变小变锐利。
- 检查高光颜色:确保
_SpecularColor不是黑色,且光源颜色有值。
5.3 问题三:背面(Backface)也被照亮
这是因为我们在计算漫反射时使用了max(0, dot(N, L))。这本身是正确的,但有时我们希望背面完全不受光(例如,单面叶片)。这时可以在顶点或片元着色器中,根据dot(法线, 视线)的正负来判断是正面还是背面,并对背面直接输出一个固定颜色或丢弃像素。
// 在片元着色器开头加入背面剔除 float facing = dot(normalWS, viewDirWS); if (facing < 0.0) { // 这里是背面,可以返回一个暗色或完全透明 return half4(0,0,0,0); // 或者 discard; }5.4 问题四:性能优化小贴士
- 精度选择:在移动端,尽量使用
half(半精度浮点数)来代替float,特别是在存储颜色、向量等数据时。但位置、法线等关键几何信息建议仍用float以保证精度。 - 减少计算:如果某些计算在顶点着色器和片元着色器中结果差异不大(比如对于光滑物体,视线方向在单个三角形内变化不大),可以将其移到顶点着色器计算,利用插值来节省片元着色器的计算量。
- 善用内置函数:像
pow(x, y)这样的函数开销较大。如果y是常数,可以考虑使用查找表(LUT)或近似计算。不过,对于现代GPU,单次pow的开销通常可以接受,在优化后期再考虑这类微观优化。 - 避免分支:GPU不喜欢
if语句,尤其是在片元着色器中。尽量使用step()、lerp()等函数来替代条件判断。例如,上面的背面剔除,可以用clip(facing)或alpha = facing > 0 ? 1 : 0;来替代if。
6. 从基础光照走向高级效果:体积光、高亮与模糊
掌握了基础光照模型,你就有了理解更高级效果的钥匙。让我们结合热词,看看它们是如何构建在这些基础之上的。
6.1 Unity URP 体积光(God Rays)的核心思路
体积光,或者说“上帝之光”,其核心是光线在参与介质(如空气中的尘埃、雾气)中的散射。在屏幕后处理中实现的一种常见方法是光线步进(Raymarching)。
- 原理:从屏幕上的每个像素,向光源方向(通常是平行光方向)发射一条射线。
- 采样:沿着这条射线,以固定步长前进。在每一个步进点上,采样深度纹理(Depth Texture)来判断该点是否被场景中的物体遮挡。
- 累积:如果未被遮挡,则认为该点处于光路中,根据其到光源的距离、到摄像机的距离等因素,计算一个亮度贡献值,并累积到最终像素颜色上。
- 效果:距离光源越近、光路中未被遮挡的部分越长,累积的亮度就越高,从而形成从光源边缘向外逐渐衰减的光束效果。
在URP中实现,你需要:
- 编写一个全屏后处理Shader。
- 通过
_CameraDepthTexture获取深度信息。 - 在Shader中实现光线步进循环。
- 将结果与原始图像混合。
这远远超出了基础光照模型的范畴,但它用到的向量运算(光线方向、步进)、纹理采样、颜色混合等,都是你在学习第七章时打下的基础。
6.2 Unity 中高亮(Outline)效果的常见实现
高亮(外轮廓)效果通常有三种实现方式,都与表面法线和视线有关:
- 基于法线延拓(最常用):在顶点着色器中,沿着顶点法线方向,将顶点位置向外稍微挤出(
vertex.xyz += normal * _OutlineWidth)。然后用一个纯色Shader渲染这个挤出的模型,再渲染原模型盖在上面。这种方法性能好,效果稳定。 - 基于边缘检测(后处理):使用Sobel等算子对屏幕空间的法线纹理或深度纹理进行卷积,检测法线或深度变化剧烈的边缘,然后给这些边缘上色。这种方法能获得更精确的轮廓,但开销较大。
- 基于视线与法线夹角(Silhouette):在片元着色器中,计算
dot(法线, 视线)。当这个值接近0(即表面边缘与视线近乎平行)时,输出轮廓色。这种方法简单,但轮廓粗细不均匀,且容易受模型细节影响。
6.3 Shader Graph 扰动与地表效果
“扰动”通常指通过一张噪声纹理(Noise Texture)或过程化噪声函数(如Perlin Noise, Simplex Noise),来随时间修改UV坐标或顶点位置,从而产生波动、扭曲、火焰等动态效果。
- UV扰动:在采样主纹理之前,先采样一张噪声纹理,用其RGB值(通常取其中一个通道)乘以一个强度系数,加到原始的UV坐标上。
float2 distortedUV = originalUV + noise.rg * _DistortionStrength;然后用distortedUV去采样主纹理。这常用于实现热浪扭曲、水面折射等。 - 顶点位置扰动:在顶点着色器中,根据世界坐标或UV采样噪声,然后沿法线方向或某个特定方向偏移顶点位置。
vertex.xyz += normal * noise.r * _Amplitude;这可以用来实现旗帜飘动、草地摇摆等顶点动画。
“地表效果”通常是一个综合应用,可能包含:
- 多层纹理混合:根据高度或斜率,混合沙地、草地、岩石等不同纹理。
- 法线贴图:使用高精度的法线贴图来增加地表细节。
- 视差映射(Parallax Mapping)或视差遮蔽映射(Parallax Occlusion Mapping):通过高度图在采样时偏移UV,模拟简单的表面凹凸,增强立体感。
- 三平面映射(Triplanar Mapping):解决陡峭悬崖面纹理拉伸的问题。
这些高级效果,无一不是建立在你对纹理采样、坐标空间、向量运算(第七章的核心)的牢固掌握之上。
7. 学习路径建议与资源推荐
读完第七章并动手实践后,你应该已经能够理解一个简单Shader的完整光照计算流程,并能排查大部分基础问题。接下来的学习路径可以这样规划:
- 深入URP管线:仔细阅读Unity官方文档中关于URP Shader Library的部分,特别是
Lighting.hlsl和Input.hlsl,了解引擎是如何封装复杂的光照、阴影、光照贴图等功能的。尝试创建一个自定义的URP Lit Shader,并逐步替换其中的光照函数,看看会发生什么。 - 学习PBR理论:兰伯特和Blinn-Phong是经验模型,而PBR(基于物理的渲染)是更接近物理真实的模型。学习微表面理论(Microfacet Theory)、BRDF(双向反射分布函数)、能量守恒、菲涅尔效应、法线分布函数等概念。推荐阅读《Real-Time Rendering》和《Physically Based Rendering: From Theory To Implementation》。
- 掌握Shader Graph/Amplify:即使你是程序员,可视化工具也能极大提升原型验证和艺术协作的效率。学习如何用节点实现复杂的数学运算和逻辑控制。
- 研究特定效果:选择一个你感兴趣的效果(如毛发、皮肤、水体、体积云),去GitHub、ShaderToy或Asset Store找开源项目或案例,拆解它的实现。这是提升最快的方式。
- 关注性能:学习如何使用Unity的Frame Debugger、GPU Profiler工具来分析Shader的性能瓶颈,了解常见的优化技巧,如批处理、LOD、Shader变体管理等。
最后,Shader学习是一场马拉松,不是百米冲刺。它需要你不断地在理论(数学、物理、图形学API)和实践(写代码、调参数、看效果)之间来回穿梭。每一次调通一个效果,每一次理解一个之前模糊的概念,都是实实在在的进步。别怕那些复杂的公式和代码,从第七章这个坚实的起点出发,把每一个效果都亲手实现一遍,你会在不知不觉中发现自己已经能驾驭很多曾经觉得神秘莫测的视觉魔法了。
