1. 项目概述:为什么Lambert漫反射是Shader入门的“第一道坎”?
如果你刚开始接触Unity Shader编程,面对各种光照模型和复杂的数学公式感到无从下手,那么从Lambert漫反射模型开始,绝对是最高效、最扎实的路径。我见过太多新手一上来就想复现PBR(基于物理的渲染)或者各种酷炫的后处理效果,结果在基础概念上栽了跟头,代码跑不通,信心也备受打击。Lambert模型,这个在计算机图形学中历史悠久的经典漫反射模型,恰恰是理解光照计算核心逻辑的完美起点。它剥离了复杂的高光、环境光遮蔽和菲涅尔效应,只关注一个最本质的问题:一个表面接收到光线后,会向各个方向均匀地反射多少光?
这个项目标题“从逐顶点到逐像素”更是点出了Shader学习中的一个关键进阶过程。在Unity中,或者说在任何实时渲染管线里,光照计算可以在两个主要阶段进行:顶点着色器(Vertex Shader)和片元着色器(Fragment Shader,在Unity中常称为Fragment Shader)。选择在哪个阶段计算,直接决定了渲染效果的视觉质量和运行性能,这也是Shader优化中永恒的权衡话题。通过亲手实现并对比这两种方式,你能直观地感受到“顶点”和“像素”这两个层级在渲染流水线中的不同角色,理解为什么有些效果必须用逐像素来实现,而有些情况下逐顶点则是更优解。
所以,这篇内容不仅仅是给出一段可以“复制粘贴”的代码。我会带你从光照的基本物理概念出发,推导出Lambert模型的数学公式,然后分别在Unity ShaderLab的框架下,实现逐顶点和逐像素的Lambert光照。最后,我们会将两套代码并排对比,从视觉效果、性能开销和应用场景三个维度进行深度剖析,让你不仅“会写”,更“懂为什么这么写”。无论你是想为自己的独立游戏增添基础光影,还是为面试夯实图形学基础,这次“手把手”的实战都能让你收获满满。
2. 核心原理拆解:光、法线与点积
在动手写代码之前,我们必须把Lambert漫反射的“地基”打牢。它的核心思想非常直观:一个表面看起来有多亮,取决于它有多“正对”光源。想象一下用手电筒照射一个粗糙的墙面,当光束垂直照射时,墙面最亮;当光束几乎平行擦过墙面时,墙面就几乎看不见了。Lambert模型就是用数学来量化这个“正对”的程度。
2.1 关键向量:光源方向与表面法线
这里涉及两个核心向量:
- 表面法线(Normal) :这是一个垂直于模型表面某一点的单位向量(长度为1)。它定义了表面的“朝向”。在3D模型中,每个顶点都存储着自己的法线信息。
- 光源方向(Light Direction) :这是一个从模型表面某一点指向光源的单位向量。它定义了光是从哪个方向照过来的。
注意 :在Shader中,我们通常使用从 表面指向光源 的向量,这与物理上光线的传播方向相反,但计算起来更为方便。很多光照计算都遵循这个约定。
2.2 核心计算:点积与夹角的余弦值
如何衡量“正对”程度?答案就是两个向量夹角的余弦值(cosθ)。当两个向量方向完全一致(夹角0°)时,余弦值为1,代表完全正对,最亮;当两个向量垂直(夹角90°)时,余弦值为0,代表光线擦边而过,无漫反射贡献;当夹角大于90°时,余弦值为负,意味着光源在表面的背面,此时我们通常将亮度截断为0,因为背面不应该被照亮。
在向量运算中,两个单位向量的点积(Dot Product)结果正好等于它们夹角的余弦值。因此,Lambert漫反射的光照强度
diffuse
可以用一个极其简洁的公式表示:
diffuse = max(0, dot(N, L))
其中:
-
N是归一化后的表面法线向量。 -
L是归一化后的、从表面指向光源的方向向量。 -
dot(N, L)计算点积。 -
max(0, ...)函数将负值钳制为0,确保背面不被照亮。
这就是Lambert模型的全部数学核心。后续所有代码,无论是逐顶点还是逐像素,都是围绕如何获取
N
和
L
,并在正确的阶段执行这个
max(dot(N, L), 0)
计算而展开的。
2.3 光照模型构成:环境光与漫反射光
在实际场景中,一个物体不会只有被直接光源照亮的部分是可见的,那些背对光源的部分通常也不会完全漆黑一片,这是因为存在间接光照,即光线在场景中多次反弹的结果。在简单的实时光照模型中,我们用一个常量—— 环境光(Ambient) 来近似模拟这种间接光照效果。
因此,一个完整的、基础的逐顶点/逐像素光照Shader的输出颜色通常由两部分组成:
最终颜色 = 环境光颜色 + 漫反射颜色 * 光源颜色 * 材质基础颜色
我们会将这个公式贯彻到接下来的代码实现中。
3. 逐顶点光照实现:效率优先的经典方案
逐顶点光照,顾名思义,就是在顶点着色器中对每个顶点进行光照计算。计算完成后,每个顶点会得到一个颜色值,而顶点之间像素的颜色,则由显卡通过 插值 来自动生成。这种方式的优点是计算量小,因为模型的顶点数通常远少于像素数。但缺点也很明显:光照细节粗糙,在模型面数较少或需要表现细腻光影变化时,会出现明显的“马赫带”(颜色断层)现象。
3.1 Shader结构与环境准备
首先,我们在Unity中创建一个新的Unlit Shader,命名为
Lambert_PerVertex
。完整的Shader代码结构如下,我会分段进行详细解读:
Shader "Custom/Lambert_PerVertex"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
_Ambient ("Ambient", Range(0, 1)) = 0.1
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog
#include "UnityCG.cginc"
#include "Lighting.cginc" // 关键!包含光照相关变量和函数
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
fixed3 diff : COLOR0; // 关键!将计算好的漫反射颜色从顶点着色器传递到片元着色器
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
fixed _Ambient;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// --- 核心光照计算开始 ---
// 1. 将法线从模型空间转换到世界空间
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
// 2. 归一化世界空间法线(通常UnityObjectToWorldNormal已处理,但确保无误)
worldNormal = normalize(worldNormal);
// 3. 获取世界空间下的光源方向(平行光)
float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
// 4. 计算Lambert漫反射强度
fixed lambert = max(0, dot(worldNormal, worldLightDir));
// 5. 结合光源颜色和材质颜色,得到漫反射颜色
o.diff = _LightColor0.rgb * _Color.rgb * lambert;
// --- 核心光照计算结束 ---
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
// 片元着色器非常简单:采样纹理,并与插值后的顶点颜色(漫反射+环境光)混合
fixed3 lighting = i.diff + unity_AmbientSky * _Ambient;
col.rgb *= lighting;
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
FallBack "Diffuse"
}
3.2 代码关键点解析与实操心得
-
包含文件
Lighting.cginc:这是Unity为我们封装好的光照计算头文件。它自动定义了关键变量,如_WorldSpaceLightPos0(主平行光的位置/方向)和_LightColor0(主平行光的颜色)。自己手动去获取这些信息非常麻烦,直接包含这个文件是标准做法。 -
数据结构
v2f:注意我们定义了一个fixed3 diff : COLOR0的成员。COLOR0是一个语义,表示这个数据将被用作颜色并在顶点之间进行插值。我们将光照计算结果存储在这里,从顶点着色器传递给片元着色器。 -
顶点着色器中的计算 :
-
UnityObjectToWorldNormal(v.normal):这是一个Unity内置函数,用于将法线从模型空间转换到世界空间,并处理非均匀缩放可能带来的问题。 永远不要 自己用模型矩阵去乘法线,必须使用这个专用函数或类似的mul(v.normal, (float3x3)unity_WorldToObject)。 -
_WorldSpaceLightPos0.xyz:对于平行光,其.w分量为0,.xyz直接代表世界空间下的光方向。我们需要将其归一化。 -
计算出的
lambert因子与光源颜色、材质颜色相乘,得到最终的漫反射颜色o.diff。
-
-
片元着色器的简化 :在
frag函数中,我们几乎没做计算。只是对纹理进行采样,然后将采样结果与从顶点着色器传递过来并已由硬件插值好的i.diff(漫反射)以及环境光unity_AmbientSky相加混合。环境光我们通过属性_Ambient来控制强度。
实操心得:关于法线变换的坑 这是新手最容易出错的地方之一。法线是方向向量,不能直接用变换顶点的矩阵(如
unity_ObjectToWorld)来变换。因为如果模型存在非均匀缩放(Scale的x, y, z值不同),直接变换会导致法线不再垂直于表面。UnityObjectToWorldNormal内部使用了逆转置矩阵来处理这个问题,务必使用它。
3.3 效果与局限性分析
将Shader赋给一个材质球并应用到模型上(比如一个Sphere),在场景中放置一个Directional Light。你会看到模型有了基础的明暗变化。但是,尝试以下操作,逐顶点光照的局限就暴露无遗:
- 使用一个低面数的模型(比如一个Cube或一个粗糙的圆柱)。
- 仔细观察光影交界处,你会看到颜色是“一块一块”变化的,而不是平滑的渐变。这是因为光照只在顶点计算,面内部的颜色是线性插值得到的,无法体现曲面上的连续变化。
- 在模型上使用法线贴图来增加表面细节?对不起,逐顶点光照无能为力,因为法线贴图带来的法线变化是逐像素的。
因此,逐顶点光照适用于对性能极度敏感、且模型本身较光滑(顶点密度高)或对光影细节要求不高的移动端场景。它是性能与质量天平上偏向性能的一端。
4. 逐像素光照实现:追求视觉精度的现代方案
为了解决逐顶点光照的粗糙问题,我们必须将光照计算推迟到片元着色器中进行。这就是逐像素光照。每个像素(更准确地说,是每个片元)都会独立计算一次光照,这意味着我们可以使用更精细的数据(如从法线贴图采样的法线),得到平滑、高质量的光影渐变。
4.1 Shader结构重构与数据传递
我们创建另一个Shader,命名为
Lambert_PerPixel
。整体结构与逐顶点版本相似,但数据传递和计算位置发生了根本变化。
Shader "Custom/Lambert_PerPixel"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
_Ambient ("Ambient", Range(0,1)) = 0.1
// 可以轻松地在这里添加一个 _BumpMap 属性,用于法线贴图
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog
#include "UnityCG.cginc"
#include "Lighting.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD1; // 关键!传递世界空间法线
float3 worldPos : TEXCOORD2; // 关键!传递世界空间顶点位置
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
fixed _Ambient;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// 顶点着色器的任务变为:准备数据,而非计算光照
// 1. 计算世界空间法线并传递
o.worldNormal = UnityObjectToWorldNormal(v.normal);
// 2. 计算世界空间顶点位置并传递
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// --- 核心光照计算现在在片元着色器中进行 ---
// 1. 重新归一化插值后的法线(非常重要!)
float3 worldNormal = normalize(i.worldNormal);
// 2. 计算世界空间下的光源方向
// 对于平行光:_WorldSpaceLightPos0.xyz 即方向
float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
// 如果是点光源,则需要计算: normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
// 3. 计算Lambert漫反射强度
fixed lambert = max(0, dot(worldNormal, worldLightDir));
// 4. 组合最终光照颜色
fixed3 diffuseColor = _LightColor0.rgb * _Color.rgb * lambert;
fixed3 ambientColor = unity_AmbientSky * _Ambient;
fixed3 finalLighting = diffuseColor + ambientColor;
// 5. 采样纹理并与光照混合
fixed4 col = tex2D(_MainTex, i.uv);
col.rgb *= finalLighting;
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
FallBack "Diffuse"
}
4.2 代码关键点解析与性能权衡
-
数据传递的变化 :在
v2f结构中,我们不再传递计算好的颜色 (diff),而是传递用于计算光照的原始数据:世界空间法线 (worldNormal) 和世界空间位置 (worldPos)。这些数据会在顶点之间插值后传入片元着色器。 -
顶点着色器的简化 :
vert函数现在只负责坐标变换和数据准备,计算负担很轻。 -
片元着色器的核心地位 :所有光照计算移到了
frag函数中。注意第一步normalize(i.worldNormal)至关重要 。经过插值后的向量长度不一定是1,必须重新归一化,否则点积计算会出错。 -
光源类型的通用性 :代码中注释提到了点光源的计算方式。逐像素光照可以更灵活地支持多种光源类型,因为每个像素都可以独立计算自己到光源的方向和距离衰减。
实操心得:插值后归一化是铁律 这是逐像素光照的另一个常见坑。从顶点着色器输出的
worldNormal是单位向量,但经过光栅化插值到每个片元时,这些向量的长度会变(不再是1)。如果你忘记在片元着色器中normalize,会导致光照在面部中心变暗等异常现象。 只要在顶点着色器输出向量,在片元着色器使用前,先normalize,这应该成为肌肉记忆。
4.3 效果提升与扩展性
使用同一个低面数模型应用逐像素光照Shader,你会立刻看到区别:光影过渡变得非常平滑,即使是一个立方体,其面上的光照也是连续渐变的,彻底消除了“马赫带”。
更重要的是,逐像素光照为更多高级效果打开了大门。例如,要加法线贴图,你只需要:
-
在Properties中添加
_BumpMap和_BumpScale。 -
在顶点着色器中计算并传递世界空间切线
worldTangent和副切线worldBitangent,构建TBN矩阵。 -
在片元着色器中,从
_BumpMap采样,解压得到切线空间法线,然后用TBN矩阵将其转换到世界空间,替换掉插值得到的worldNormal进行光照计算。 这个过程在逐顶点光照中是无法实现的,因为法线贴图的信息需要逐像素读取。
5. 完整代码对比与深度性能场景分析
现在,我们将两套代码的核心部分并置,进行一场全方位的“PK”。
5.1 代码结构对比表
| 特性 | 逐顶点光照 (Per-Vertex) | 逐像素光照 (Per-Pixel) |
|---|---|---|
| 计算发生阶段 | 顶点着色器 (Vertex Shader) | 片元着色器 (Fragment Shader) |
| 传递的数据 |
计算好的漫反射颜色 (
fixed3 diff
)
|
世界空间法线、位置等原始数据 (
float3 worldNormal
,
worldPos
)
|
| 片元着色器工作 | 简单混合纹理与插值后的颜色 | 进行完整的归一化、点积等光照计算 |
| 光影质量 | 粗糙,有颜色断层,不适合低模 | 平滑,高质量渐变,适合任何模型 |
| 支持法线贴图 | 不支持 (信息在顶点间丢失) | 天然支持 (可逐像素采样并计算) |
| 性能开销 | 较低 (计算次数=顶点数) | 较高 (计算次数=像素数,即屏幕填充率) |
| 计算精度 | 颜色经插值,可能精度损失 | 原始数据插值,计算精度高 |
| 典型应用场景 | 移动端简单物体、远景LOD模型、粒子系统 | PC/主机游戏、移动端主角/重要物体、任何需要高质量光影或法线贴图的场合 |
5.2 性能开销的量化感知
性能是选择哪种方案的决定性因素之一。顶点数和像素数(更准确说是片元数,即最终被绘制的像素数量,受Overdraw影响)决定了计算量。
-
一个10万面的复杂角色模型
,在全高清屏幕(约200万像素)下渲染。如果该角色只占据屏幕中间1/4区域(约50万像素)。
-
逐顶点计算:约10万次
dot运算。 -
逐像素计算:约50万次
dot运算 + 50万次normalize运算。 - 结论 :在此例中,逐像素的计算量是逐顶点的5倍以上。
-
逐顶点计算:约10万次
-
一个1000面的简单道具
,但它在屏幕上被一个半透明的UI大面积覆盖,导致GPU需要为它绘制很多次(深度复杂)。
- 逐顶点计算:稳定1000次运算。
- 逐像素计算:运算量会随着Overdraw倍数急剧增加。
- 结论 :在高Overdraw情况下,逐像素开销会成倍放大。
因此,一个常见的优化策略是 “按需分配” :
- 对于场景中远处的建筑、背景山体、小草丛等,使用逐顶点光照甚至更简单的无光照Shader。
- 对于主角、NPC、武器、近处的重要道具,使用逐像素光照以保证视觉质量。
- 在Unity的URP/HDRP中,这套选择逻辑被整合进了Shader变体(Shader Variants)和渲染管线设置中,但底层原理相通。
5.3 常见问题排查与实战技巧实录
在实际编写和调试这两种Shader时,你肯定会遇到一些典型问题。这里我记录了几个“踩坑”实录和解决方法。
问题1:模型一片漆黑,没有光照效果。
-
排查步骤
:
-
检查场景中是否有激活的Directional Light
。这是
_WorldSpaceLightPos0和_LightColor0的数据来源。 -
检查Shader中是否包含了
#include "Lighting.cginc"。没有它,相关变量未定义。 -
检查法线变换
。在顶点着色器中,务必使用
UnityObjectToWorldNormal,而不是mul(unity_ObjectToWorld, v.normal)。可以在片元着色器中用return fixed4(i.worldNormal * 0.5 + 0.5, 1.0);将法线可视化,检查其是否正确(正常应呈现平滑的色彩渐变)。 -
检查向量归一化
。在逐像素Shader中,确认对
i.worldNormal和worldLightDir都进行了normalize。
-
检查场景中是否有激活的Directional Light
。这是
问题2:光影方向不对,或者旋转模型时光影不跟着动。
- 原因 :这几乎可以肯定是空间转换错误。光照计算 必须在同一坐标系下进行 。我们选择的是世界空间。
-
解决
:确保法线 (
worldNormal) 和光源方向 (worldLightDir) 都是在世界空间下计算和归一化的。光源方向_WorldSpaceLightPos0本身就是在世界空间定义的。法线必须通过正确的函数转换到世界空间。
问题3:模型背面(背对光源的面)也被照亮了。
-
原因
:忘记了使用
max(0, dot(...))来钳制点积结果。当法线与光源方向夹角大于90度时,点积为负,如果不处理,负值会参与颜色计算,导致背面变亮(通常是奇怪的暗色)。 -
解决
:在计算
lambert因子时,务必加上max(0, ...)。
问题4:想用点光源或聚光灯,怎么办?
-
逐像素方案修改
:
-
光源方向计算需改为:
float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos); -
还需要考虑衰减。Unity在
Lighting.cginc中提供了UnityWorldSpaceLightDir(i.worldPos)函数,它可以自动根据光源类型(平行光/点光/聚光)返回正确的方向,并可通过UNITY_LIGHT_ATTENUATION宏来获取衰减。这是更规范的做法。
// 使用Unity内置函数获取方向 float3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos); // 计算衰减(需要配合正确的渲染路径和阴影宏) UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); fixed lambert = max(0, dot(worldNormal, worldLightDir)); fixed3 diffuseColor = _LightColor0.rgb * _Color.rgb * lambert * atten;-
注意
:使用这些高级功能通常需要Shader包含更多宏、使用正确的光照模式(如
#pragma multi_compile_fwdadd)并设置合适的渲染路径(如ForwardBase)。
-
光源方向计算需改为:
个人实战技巧:调试利器——颜色输出法
当Shader效果不符合预期时,最直接的调试方法就是将中间变量输出为颜色。例如,怀疑法线有问题,就在片元着色器中
return fixed4(worldNormal * 0.5 + 0.5, 1.0);
。怀疑点积结果,就
return fixed4(lambert, lambert, lambert, 1.0);
。通过观察屏幕上的颜色,你能快速定位是哪个环节的计算出了错。这是图形编程中最常用、最有效的调试手段,没有之一。
从逐顶点到逐像素,不仅仅是代码位置的移动,更是渲染思维从“粗略近似”到“精细模拟”的跃迁。理解了这个过程,你就掌握了实时光照最基础的调控阀门。下次当你需要为一个移动端游戏优化Shader时,你会清楚地知道,对于那片远处的森林,或许可以大胆地使用逐顶点光照;而当你要刻画主角盔甲上精美的浮雕时,逐像素光照配合法线贴图才是唯一的选择。这种根据需求做出精准权衡的能力,正是资深图形程序员的价值所在。希望这两套对比鲜明的代码,能成为你Shader工具箱里最称手的第一对利器。

651

被折叠的 条评论
为什么被折叠?



