Unity Shader入门:从零手写Lambert漫反射光照模型
1. 项目概述:为什么从Lambert漫反射开始?
如果你刚开始接触Unity Shader编程,面对那些复杂的PBR、次表面散射、屏幕空间反射,是不是感觉头都大了?别急,几乎所有图形学大神的起点,都和我们一样,是从一个看似简单的“漫反射”开始的。而Lambert漫反射模型,就是这个起点上最经典、最核心的一块基石。它描述的是光线照射到粗糙表面后,向各个方向均匀散射的现象,是构成物体基础明暗关系的根本。
我之所以选择带大家“从零手写”一个Lambert Shader,而不是直接用Unity内置的Standard Shader,原因很简单:知其然,更要知其所以然。Unity的封装固然方便,但它像是一个黑盒,你调参数时心里是没底的。只有亲手把光照计算的每一个步骤写出来,你才能真正理解“法线”、“光线方向”、“点积”这些概念在屏幕上究竟是如何影响一个像素的亮度的。这个过程,是打通你Shader任督二脉的关键。
这次实战,我们不只写一个Shader。我会带你实现三种不同精度的Lambert模型:逐顶点光照、逐像素光照和半兰伯特模型,并附上完整的代码对比。你会清晰地看到,从顶点到像素,计算精度如何带来视觉效果的飞跃;而半兰伯特这个小小的“作弊”技巧,又是如何巧妙地解决了传统Lambert在背光面死黑的问题。无论你是想面试突击,还是夯实基础,这篇都能给你最直接的“弹药”。
2. 核心原理拆解:Lambert模型到底在算什么?
在动手写代码之前,我们必须把Lambert模型的核心公式和物理(或者说,近似物理)意义吃透。否则,代码就只是一堆看不懂的符号。
2.1 兰伯特定律与点积运算
兰伯特定律的公式非常简单:漫反射光强 = 光源颜色 * 材质漫反射颜色 * max(0, dot(N, L))。
我们来拆解这个公式里的每一个变量:
- 光源颜色 (
_LightColor0):就是场景中平行光(Directional Light)的颜色和强度。在Shader中,Unity通过内置变量_LightColor0提供给我们。 - 材质漫反射颜色 (
_Diffuse):这是我们自己定义的一个颜色属性,表示物体表面固有的颜色。比如一个红色的塑料球,这个值就是红色。 max(0, dot(N, L)):这是整个计算的核心,也叫作兰伯特因子。N(Normal):是当前点的单位法线向量。它垂直于物体表面,指向外部。法线决定了表面的朝向。L(Light Dir):是从当前点指向光源的单位向量。对于平行光,场景中所有点的L方向都是一样的。dot(N, L):点积运算。两个单位向量的点积,等于它们夹角的余弦值cosθ。当法线直接朝向光源时(夹角0°),cosθ = 1,最亮;当夹角90°时,cosθ = 0;当夹角大于90°(即背对光源)时,cosθ为负值。max(0, ...):因为光强不能为负,所以我们用max函数将点积结果钳制在0到1之间。这就是为什么物体的背面(dot(N,L)<0)会完全变黑。
注意:这里有一个初学者极易混淆的点。
L的方向是从着色点指向光源,而不是从光源指向着色点。很多光照模型图示画的是光线方向,但计算时需要用其反方向。Unity的_WorldSpaceLightPos0对于平行光,直接提供的就是指向光源的方向(即我们需要的L),这一点比较友好,但自己推导时务必清楚。
2.2 逐顶点与逐像素光照的本质区别
这是影响Shader性能和效果的关键抉择,理解其本质比记住写法更重要。
逐顶点光照 (Per-vertex Lighting):
- 计算发生在哪里:在顶点着色器 (
vertex shader) 中。 - 如何计算:对模型的每个顶点,根据该顶点的法线和光照方向,计算出该顶点的颜色(或光照强度)。
- 结果如何应用:计算出的顶点颜色,会在光栅化阶段,在由顶点构成的三角形内部进行线性插值,产生平滑过渡的颜色。
- 优点:计算量小,性能高。因为模型的顶点数远少于像素数。
- 缺点:效果粗糙。当模型本身顶点数很少(如低模)时,插值无法表现精细的光照变化,会出现明显的“马赫带”(颜色断层)或棱角感。高光等精细效果无法实现。
- 计算发生在哪里:在顶点着色器 (
逐像素光照 (Per-pixel Lighting):
- 计算发生在哪里:在片元着色器 (
fragment shader) 中。 - 如何计算:光栅化后,对每个像素(即片元),都单独进行一次完整的法线、光线方向、点积计算。
- 结果如何应用:每个像素都有自己独立计算出的颜色,无需依赖顶点的插值。
- 优点:效果极其细腻、准确。能完美表现平滑曲面上的光照渐变,也是实现法线贴图、高光、复杂材质的前提。
- 缺点:计算量大,每个像素都要算一遍,对性能有影响(但对于现代GPU,简单的逐像素光照开销很小)。
- 计算发生在哪里:在片元着色器 (
一个生活化的类比:想象你要给一个石膏像上色。
- 逐顶点就像你只在雕像的几个关键高点(鼻尖、眉骨、颧骨)涂上颜色,然后让颜料自己顺着石膏表面流下去混合。如果雕像细节少,流下去的颜色就会不均匀,块状感明显。
- 逐像素就像你拿着极细的笔,耐心地给雕像表面的每一个微小颗粒都单独上色。效果自然无比精细,但工作量巨大。
2.3 半兰伯特模型的“作弊”艺术
传统Lambert的max(0, dot(N,L))导致背光面一片死黑,这在风格化渲染或需要更多细节的场合并不好看。Valve公司在《半条命2》中推广的半兰伯特 (Half Lambert)模型,就是一个经典的、经验性的“作弊”方案。
它的公式是:漫反射光强 = 光源颜色 * 材质漫反射颜色 * (dot(N, L) * 0.5 + 0.5)。
这个变换非常巧妙:
- 将点积结果从
[-1, 1]的范围,通过*0.5+0.5线性变换到了[0, 1]范围。 - 原本背光面(
dot(N,L)为负)的值,从0被提升到了一个大于0的正值。例如,dot(N,L) = -0.5,经过变换后是(-0.5*0.5+0.5)=0.25。 - 原本向光面(
dot(N,L)为正)的值,也被整体压缩了对比度,但依然保持从0.5到1.0的变化。
效果:彻底消除了死黑区域,背光面也能看到微弱的轮廓和细节,整体画面更柔和、更卡通化。它没有任何物理依据,纯粹是为了视觉美感而生的“黑科技”。
3. 环境准备与基础Shader结构搭建
理论清楚了,我们开始动手。首先确保你有一个干净的Unity项目(我使用的是2022.3 LTS版本,但2018以上版本均可)。
3.1 创建测试场景与材质
- 新建场景:创建一个空场景,删除默认的
Directional Light,我们稍后自己创建以便控制。 - 创建测试物体:在场景中创建一个
Sphere(球体)和一个Cube(立方体)。球体最能体现光照渐变,立方体则能清晰展示顶点与像素光照的区别。 - 创建平行光:
GameObject -> Light -> Directional Light。将其Rotation调整到例如(50, -30, 0),让它斜着照射物体,产生明显的明暗面。 - 创建材质与Shader:
- 在Project窗口右键,
Create -> Material,命名为Mat_LambertPerVertex。 - 再次右键,
Create -> Shader -> Unlit Shader,命名为LambertPerVertex。为什么不选Standard Surface Shader?因为我们要从零开始,Standard Shader封装太多,不利于学习。Unlit Shader是一张白纸。 - 将
LambertPerVertexShader拖拽赋值给Mat_LambertPerVertex材质。 - 最后,将材质拖拽给场景中的球体和立方体。
- 在Project窗口右键,
现在你的物体应该是粉红色的(Unlit Shader默认输出颜色),因为我们还没写任何光照代码。
3.2 理解Unlit Shader模板
双击打开LambertPerVertex.shader,你会看到Unity生成的模板代码。我们一步步把它改造成我们的Lambert Shader。
首先,修改Shader命名行,给它一个清晰的路径:
Shader "MyShaders/Lambert/PerVertex"这样它就会在材质Shader下拉菜单的MyShaders/Lambert目录下找到。
在Properties块中,我们需要定义材质可调节的属性:
Properties { _MainTex ("Texture", 2D) = "white" {} // 主纹理,可选 _Diffuse ("Diffuse Color", Color) = (1, 1, 1, 1) // 新增:漫反射颜色 }_Diffuse就是我们公式中的材质漫反射颜色。
接下来是关键的结构体。模板里通常有appdata(顶点着色器输入)和v2f(顶点着色器输出,片元着色器输入)。我们需要根据计算需要传递数据。
4. 逐顶点光照Lambert实现详解
我们先实现最简单、性能最好的逐顶点光照版本。
4.1 数据结构与变量传递
我们需要在顶点着色器中计算光照,然后将计算好的颜色传递给片元着色器。
修改v2f结构体,增加一个变量来存储计算后的颜色:
struct v2f { float4 vertex : SV_POSITION; // 裁剪空间顶点位置,必须 float2 uv : TEXCOORD0; // 纹理坐标 float3 color : TEXCOORD1; // 新增:用于传递顶点计算出的颜色(RGB) };这里我们用TEXCOORD1这个语义来存储颜色。TEXCOORD系列语义本质上是用于传递高精度数据的寄存器,不仅可以传纹理坐标,也可以传颜色、向量等。
4.2 顶点着色器:光照计算的核心
顶点着色器的任务是:将模型顶点变换到裁剪空间,并在此顶点位置计算光照颜色。
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); // 标准顶点变换 o.uv = v.uv; // --- 逐顶点光照计算开始 --- // 1. 将法线从模型空间变换到世界空间,并归一化 float3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 注意:这里用unity_WorldToObject的逆转置矩阵来变换法线,以正确处理缩放。更严谨的写法是: // float3 worldNormal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz); // 2. 获取世界空间下的光照方向(平行光) float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); // 3. 计算兰伯特因子:max(0, N·L) float lambertFactor = max(0.0, dot(worldNormal, worldLightDir)); // 4. 计算最终漫反射颜色:光源颜色 * 材质漫反射色 * 兰伯特因子 float3 diffuseColor = _LightColor0.rgb * _Diffuse.rgb * lambertFactor; // 5. 加入环境光,避免完全黑暗 float3 ambientColor = UNITY_LIGHTMODEL_AMBIENT.rgb * _Diffuse.rgb; // 6. 将最终颜色赋值给输出结构体 o.color = diffuseColor + ambientColor; // --- 逐顶点光照计算结束 --- return o; }关键点解析:
- 法线变换:
v.normal是模型空间下的法线。光照计算通常在世界空间进行,所以需要变换。使用unity_WorldToObject矩阵的逆转置(这里简写为mul(v.normal, (float3x3)unity_WorldToObject))是为了保证在模型存在非均匀缩放时,法线依然垂直于表面。 - 光源方向:
_WorldSpaceLightPos0是Unity内置变量。对于平行光,其.xyz分量就是世界空间下的光照方向向量。 - 环境光:
UNITY_LIGHTMODEL_AMBIENT是Unity内置的环境光颜色。即使兰伯特因子为0(背光面),环境光也能保证物体不是全黑的,这是真实感的基础。
4.3 片元着色器:简单的颜色输出
由于光照已经在顶点算好并插值,片元着色器变得非常简单:
fixed4 frag (v2f i) : SV_Target { // 直接从插值后的顶点颜色输出 return fixed4(i.color, 1.0); }将Shader保存,回到Unity查看球体。你应该能看到一个具有基础明暗渐变,但渐变层次不够细腻的球体。尤其是球体边缘到暗部的过渡,可能能看到一些棱状或条带状的瑕疵,这就是逐顶点插值的局限性。
5. 逐像素光照Lambert实现与对比
现在,我们来升级到效果更好的逐像素光照。新建一个Shader,命名为LambertPerPixel。
5.1 数据传递策略的转变
对于逐像素光照,核心思想是:将计算所需的所有原始数据(法线、光线方向)从顶点着色器传递到片元着色器,在片元着色器中进行计算。
因此,我们的v2f结构体需要传递的是向量,而不是最终颜色。
struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; // 传递世界空间法线 float3 worldPos : TEXCOORD2; // 传递世界空间顶点位置(用于某些光照模型,这里备用) };5.2 顶点着色器:只做变换与传递
顶点着色器不再进行光照计算,只负责坐标变换和数据准备。
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; // 变换法线到世界空间 o.worldNormal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz); // 变换顶点位置到世界空间(为后续可能的光照计算准备) o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; }5.3 片元着色器:成为计算的主战场
所有复杂的计算都移到了这里:
fixed4 frag (v2f i) : SV_Target { // 1. 重新归一化插值后的法线(非常重要!) float3 worldNormal = normalize(i.worldNormal); // 2. 获取光照方向(平行光) float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); // 3. 计算兰伯特因子 float lambertFactor = max(0.0, dot(worldNormal, worldLightDir)); // 4. 计算漫反射光 float3 diffuseColor = _LightColor0.rgb * _Diffuse.rgb * lambertFactor; // 5. 加入环境光 float3 ambientColor = UNITY_LIGHTMODEL_AMBIENT.rgb * _Diffuse.rgb; // 6. 合并输出 float3 finalColor = diffuseColor + ambientColor; return fixed4(finalColor, 1.0); }关键点解析:
normalize(i.worldNormal):这是逐像素光照效果正确的灵魂所在。在顶点着色器中,我们传递的是每个顶点的单位法线。但在光栅化插值过程中,插值得到的向量长度不一定是1。如果不重新归一化,dot运算的结果就是错误的,会导致奇怪的光照条纹。这个操作是逐像素光照的固定开销,也是其计算量大于逐顶点的原因之一。
创建一个新材质Mat_LambertPerPixel并使用这个Shader,赋给另一个球体或立方体。与逐顶点版本并排对比,你会发现逐像素版本的球体表面光照过渡极其平滑,没有任何棱状感。立方体的每个面上,光照也是均匀变化的,而逐顶点版本在立方体面上可能会因为顶点颜色插值而产生不均匀的渐变。
5.4 性能与效果权衡的实操心得
在实际项目中,如何选择?
- 移动端低模或性能瓶颈场景:可以考虑使用逐顶点光照。例如,远处的大量植被、碎石等小物件,玩家看不清细节,逐顶点足以蒙混过关,能节省可观的GPU开销。
- 绝大多数情况:无脑使用逐像素光照。现代GPU处理简单的逐像素Lambert绰绰有余。它带来的视觉提升是质的飞跃,而且是使用法线贴图等高级技术的基础。
- 一个常见的优化技巧:对于静态物体,可以考虑使用光照贴图 (Lightmap)来“烘焙”光照信息,这样在运行时无论是逐顶点还是逐像素,都只需要采样贴图,完全没有实时光照计算开销,效果还最好。但这属于离线方案,适用于静态场景。
6. 半兰伯特模型实现与视觉优化
半兰伯特是对传统Lambert的视觉增强,实现起来非常简单。我们基于逐像素光照的Shader来修改。
新建ShaderLambertHalfPerPixel,或者直接在LambertPerPixel的基础上修改片元着色器中的计算部分。
找到计算lambertFactor的那一行:
float lambertFactor = max(0.0, dot(worldNormal, worldLightDir));将其替换为半兰伯特公式:
float halfLambertFactor = dot(worldNormal, worldLightDir) * 0.5 + 0.5; // 注意:半兰伯特通常不再使用max(0, ...),因为变换后范围已在[0,1]后续使用halfLambertFactor代替lambertFactor进行计算。
创建一个新材质应用此Shader,与标准逐像素版本对比。旋转物体到背光面,你会立刻发现区别:标准Lambert的背光面是漆黑一片(只有环境光),而半兰伯特模型下,背光面依然能保持一定的亮度和形体轮廓。
应用场景:
- 卡通渲染 (Cel-shading / Toon Shading):半兰伯特是创造那种“无死黑”卡通效果的常用基础。后续可以通过
step或smoothstep函数对halfLambertFactor进行离散化,形成色阶。 - 风格化渲染:任何希望画面更明亮、阴影更柔和、细节更丰富的非写实风格项目。
- 补救低质量模型:对于一些法线信息不佳或模型面数很低的物体,半兰伯特可以减轻光照瑕疵。
注意:半兰伯特破坏了物理准确性,它让背光面也“接收”到了本不应接收的直射光。在追求物理真实感的PBR渲染流程中,一般不使用它。它纯粹是一个为了视觉美感服务的“把戏”。
7. 完整代码对比与总结分析
为了方便你横向对比和理解,我将三个核心Shader的关键代码片段整理如下:
| 特性 | 逐顶点光照 (PerVertex) | 逐像素光照 (PerPixel) | 半兰伯特逐像素 (HalfPerPixel) |
|---|---|---|---|
| 计算位置 | 顶点着色器 (vert) | 片元着色器 (frag) | 片元着色器 (frag) |
| 传递数据 | 计算后的颜色float3 color | 世界法线worldNormal, 世界位置worldPos | 同逐像素光照 |
| 顶点着色器核心 | 完成全部光照计算 | 仅变换和传递法线、位置 | 同逐像素光照 |
| 片元着色器核心 | 直接输出插值颜色 | 归一化法线,计算光照 | 归一化法线,计算半兰伯特光照 |
| 关键代码行 | o.color = diffuseColor + ambientColor; | float3 worldNormal = normalize(i.worldNormal);lambertFactor = max(0.0, dot(...)); | halfLambertFactor = dot(...) * 0.5 + 0.5; |
| 视觉效果 | 渐变粗糙,可能有棱状感 | 渐变极其平滑,物理准确 | 平滑,无死黑,背光面更亮 |
| 性能开销 | 低(计算次数=顶点数) | 中(计算次数=像素数,需重归一化) | 同逐像素光照 |
| 主要用途 | 移动端低模、远处物件、性能优先 | 绝大多数场景的标配 | 卡通渲染、风格化、需要背光细节 |
给新手的最终建议:
- 学习路径:严格按照逐顶点 -> 逐像素 -> 半兰伯特的顺序实践一遍,亲手体会数据流和计算位置的变化。这是理解Shader流水线的绝佳案例。
- 项目首选:在新项目中,对于需要动态光照的物体,默认使用逐像素光照的Lambert模型。它是你构建更复杂光照模型(如Blinn-Phong高光)的基石。
- 调试技巧:在编写光照Shader时,经常将中间变量(如
lambertFactor,worldNormal)直接作为颜色输出,可以直观地看到计算是否正确。例如return fixed4(lambertFactor, lambertFactor, lambertFactor, 1.0);来查看明暗分布。 - 下一步探索:掌握了Lambert之后,你可以尝试:
- 在片元着色器中加入纹理采样 (
tex2D(_MainTex, i.uv)),将纹理颜色与光照颜色相乘。 - 实现Blinn-Phong模型,加入高光反射 (
specular) 项。 - 学习使用法线贴图 (
Normal Map),用worldNormal替换为从法线贴图中采样并变换后的切线空间法线,实现惊人的细节表现。
- 在片元着色器中加入纹理采样 (
光照Shader的世界从这里打开,亲手写下的每一行代码,都会让你对屏幕上呈现的魔法多一分掌控和理解。
