1. 项目概述:为什么Lambert漫反射是Shader入门的“第一道坎”?
如果你刚开始接触Unity Shader编程,面对各种光照模型和复杂的数学公式感到无从下手,那么从Lambert漫反射模型开始,绝对是最明智的选择。我见过太多新手一上来就想搞懂PBR(基于物理的渲染)或者各种酷炫的后处理效果,结果在复杂的BRDF方程和能量守恒概念面前败下阵来,最终失去了学习的兴趣。Lambert模型不一样,它简单、直观,是理解光线与物体表面交互最基础的物理模型,堪称图形学里的“Hello World”。
简单来说,Lambert漫反射描述的是理想漫反射表面的光照现象。想象一下一个粗糙的石膏像或者一张无光的A4纸,光线照上去后,会均匀地向四面八方散射,因此从任何角度看过去,它的亮度都是一样的。这个模型的核心公式极其简洁:最终颜色 = 光源颜色 * 材质漫反射颜色 * max(0, dot(表面法线, 光源方向))。这里的dot就是点积运算,它计算的是法线与光线方向夹角的余弦值。夹角越小(光线越正对表面),余弦值越接近1,表面就越亮;夹角大于90度时,余弦值为负,我们通过max(0, ...)将其截断为0,表示背光面完全不受该光源影响。
这个项目,我们将不依赖Unity表面着色器(Surface Shader)的“黑箱”,也不直接使用内置的Lambert光照模型函数,而是从零开始,用两种最经典的方式——逐顶点光照和逐像素光照——来手动实现它。你会清晰地看到,同样的光照模型,仅仅因为计算发生的位置不同(是在每个顶点上算一次,还是在每个屏幕像素上算一次),最终呈现的视觉效果和性能开销会有天壤之别。文末附上的完整代码对比,能让你一目了然地抓住两种实现的核心差异。无论你是想解决模型在低光下显得“平板”的问题,还是想为后续学习Blinn-Phong高光、卡通渲染打下坚实基础,这次“手把手”的实战都能给你带来最直接的收获。
2. 核心原理与设计思路拆解:逐顶点与逐像素的本质区别
在动手写代码之前,我们必须把“逐顶点”和“逐像素”这两个概念彻底吃透。这不仅仅是代码写在哪个函数里的区别,更决定了着色器的视觉质量和运行效率,是Shader编程中最重要的设计决策之一。
2.1 光照计算发生在哪里?
这是最根本的区别。你可以把三维模型理解成一个由许多三角形(面片)构成的网格,三角形的三个角就是顶点。
- 逐顶点光照:光照计算发生在顶点着色器中。程序会遍历模型的每一个顶点,根据该顶点的位置、法线等信息,计算出该顶点受到光照后的颜色。然后,这个颜色值会从顶点着色器输出,在光栅化阶段(即把三角形画到屏幕上的过程)中,由GPU自动在三角形的三个顶点颜色之间进行线性插值,生成三角形内部每个像素的颜色。
- 逐像素光照:光照计算发生在片元着色器(也叫像素着色器)中。顶点着色器只负责顶点的基本变换(如从模型空间转到世界空间)和传递必要的数据(如法线、光照方向)。当三角形被光栅化后,对于覆盖的每一个屏幕像素,片元着色器都会被执行一次,并利用插值后的法线、光照方向等数据,独立计算该像素的光照颜色。
2.2 视觉差异的根源:插值 vs 独立计算
正是上述流程的差异,导致了截然不同的视觉效果。
- 逐顶点的“棱角感”:由于颜色是在顶点上计算的,三角形内部像素的颜色只是顶点颜色的简单混合。如果模型面数较少(顶点稀疏),那么在三角形内部的大片区域,颜色变化会非常平缓甚至不变,而在三角形边缘(顶点处)则可能发生突变。这就会导致模型表面出现明显的“色块”或“棱角”,尤其是在低多边形模型上,光照过渡非常不自然。你可以想象一个低精度的球体,它的表面是由许多小平片组成的,逐顶点光照会让每个小平片内部颜色一致,整个球体看起来就像个多面体。
- 逐像素的“平滑感”:因为每个像素都独立计算了一次光照,即使模型面数很低,只要法线信息是连续插值的(通常我们使用顶点法线,并在片元着色器中获取插值后的法线),那么计算出的光照强度就会在每个像素上精细变化。这样得到的渲染结果非常平滑,能很好地表现曲面的光影过渡,视觉质量高得多。
2.3 性能开销的权衡:计算频率的博弈
性能是另一个关键的考量维度。
- 逐顶点开销低:一个模型的顶点数量通常是有限的,即使是上万个顶点的复杂模型,其顶点数也远少于它最终在屏幕上覆盖的像素数(尤其是近处特写的物体)。在顶点着色器中计算光照,计算次数等于顶点数,相对较少。
- 逐像素开销高:片元着色器的执行次数等于该物体覆盖的屏幕像素数量。一个1080p的屏幕就有超过200万个像素。如果模型占据了屏幕的一半,那么片元着色器就要执行上百万次!每次执行都要进行点积、乘法等运算。因此,逐像素光照的计算压力要大得多。
设计思路总结:我们的实现方案就是围绕这个核心矛盾展开的。我们将编写两个独立的Shader,一个采用逐顶点计算策略,追求极致性能,适用于对光影效果要求不高或受性能严格约束的场景(如大量重复的远景物体)。另一个采用逐像素计算策略,追求视觉质量,适用于场景中的主角、主要道具等需要精细表现的物体。通过对比这两种实现,你能深刻理解Shader编程中“质量与性能”的永恒权衡。
3. 环境准备与基础代码框架
在开始实现具体的光照模型前,我们需要搭建一个最基础的、无光照的Shader框架。这个框架负责完成顶点从模型空间到裁剪空间的变换,是任何Vertex/Fragment Shader的起点。
3.1 创建Shader与材质
首先在Unity项目中,创建一个新的Standard Surface Shader并修改,或者直接创建Unlit Shader来从头编写。这里我们选择从Unlit Shader开始,以获得最大的控制权。将其命名为LambertPerVertex和LambertPerPixel。
一个最基础的顶点/片元着色器结构如下:
Shader "Custom/LambertBase" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); // 核心变换:模型空间->裁剪空间 o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } }这段代码没有任何光照,它只是把纹理和颜色显示出来。UnityObjectToClipPos是Unity内置的宏,它等价于mul(UNITY_MATRIX_MVP, v.vertex),负责将顶点从模型空间转换到裁剪空间,这是顶点着色器最核心的任务。
3.2 关键数据结构:appdata 与 v2f
理解appdata(顶点着色器输入)和v2f(顶点着色器输出,片元着色器输入)的结构体是写Shader的基本功。
- appdata:定义了从模型网格中读取哪些数据。
POSITION是顶点位置,TEXCOORD0是第一套纹理坐标,这些都是模型自带的。为了实现光照,我们后面必须在这里加入NORMAL(法线)语义。 - v2f:定义了从顶点着色器传递给片元着色器的数据。
SV_POSITION是经过变换后的裁剪空间坐标,必须要有。其他的,如纹理坐标、法线、光照方向等,都需要在这里声明,并在顶点着色器中赋值。
注意:从顶点着色器到片元着色器的数据传递是通过插值器完成的。即使你在顶点着色器中把法线归一化(长度为1)了,经过插值后,传入片元着色器的法线长度很可能不再是1。因此,在逐像素光照的片元着色器中,必须对传入的法线重新进行归一化(使用
normalize函数),这是一个非常高频的坑点。
4. 逐顶点光照Lambert实现详解
现在我们开始实现第一个版本:在顶点着色器中完成Lambert光照计算。
4.1 修改数据结构与属性
首先,我们需要让Shader能获取到法线信息,并定义一个颜色属性来表示物体的固有色。
Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Diffuse Color", Color) = (1,1,1,1) // 物体漫反射颜色 }接着,修改结构体,在appdata中加入法线,在v2f中加入一个变量来存储计算好的顶点颜色。
struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 加入法线 float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; fixed3 diffuse : COLOR; // 新增:用于存储顶点计算出的漫反射颜色 };4.2 顶点着色器中的光照计算
核心逻辑全部在顶点着色器vert函数中。
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // --- 逐顶点光照计算开始 --- // 1. 将法线从模型空间转换到世界空间,并归一化 float3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 注意:这里用unity_WorldToObject的逆转置矩阵来变换法线,等同于mul(transpose(unity_WorldToObject), v.normal),这是处理非均匀缩放的正确方法。UnityCG.cginc中通常提供了UnityObjectToWorldNormal宏来简化。 // 2. 获取世界空间下的光源方向(假设主要平行光) float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); // _WorldSpaceLightPos0是Unity内置变量,对于平行光,其xyz分量表示方向,w分量为0。 // 3. 计算Lambert漫反射系数 float lambert = max(0, dot(worldNormal, worldLightDir)); // 4. 结合光源颜色和物体颜色,得到最终顶点颜色 // _LightColor0是Unity内置变量,表示当前处理的光源颜色。 o.diffuse = _LightColor0.rgb * _Color.rgb * lambert; // --- 逐顶点光照计算结束 --- return o; }这里使用了_WorldSpaceLightPos0和_LightColor0这两个Unity内置变量,它们代表了场景中第一个(通常是主要的)平行光的方向和颜色。在实际项目中,为了兼容多光源和更复杂的情况,可能需要编写多Pass或使用光照衰减纹理,但作为Lambert入门,理解单平行光就足够了。
4.3 片元着色器输出
片元着色器变得非常简单,只需要输出插值后的颜色即可。可以混合纹理。
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // 将纹理颜色与计算好的漫反射光颜色相乘 col.rgb *= i.diffuse; return col; }4.4 效果与局限性分析
将此Shader赋给一个低多边形模型(比如一个Sphere只设置少量分段),你会看到明显的光照色块。球体看起来不像光滑的球,而像一个多面体。这是因为颜色在顶点计算后,在面片内部只是线性渐变,无法体现精细的光影变化。
逐顶点光照的优缺点与适用场景:
- 优点:计算量小,性能极高。在功能机时代或对性能要求极度苛刻的场景(如海量单位渲染的RTS游戏)中,仍有其用武之地。
- 缺点:视觉质量差,尤其在低模上棱角感明显。无法支持法线贴图(因为法线贴图需要逐像素的法线信息)。
- 适用场景:远景物体、大量重复的植被/粒子、移动平台性能瓶颈时的备选方案。
5. 逐像素光照Lambert实现详解
接下来,我们实现视觉质量更好的逐像素版本。核心思想是将计算从顶点着色器推迟到片元着色器。
5.1 修改数据结构传递插值数据
我们不再在v2f中传递计算好的颜色,而是传递世界空间下的法线和光照方向,让片元着色器自己去算。
struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; // 传递世界空间法线 float3 worldLightDir : TEXCOORD2; // 传递世界空间光源方向 };5.2 顶点着色器准备数据
顶点着色器负责进行坐标变换和计算世界空间下的向量,但不进行点积计算。
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 准备数据:变换法线到世界空间 // 使用Unity内置宏,它自动处理了逆转置矩阵,更安全便捷。 o.worldNormal = UnityObjectToWorldNormal(v.normal); // 准备数据:获取世界空间光源方向 // 对于平行光,_WorldSpaceLightPos0.xyz即方向。这里先计算到顶点的向量,但对于平行光,所有顶点方向相同。 // 更严谨的写法是:o.worldLightDir = normalize(_WorldSpaceLightPos0.xyz - worldPos.xyz); 对于平行光,worldPos部分无效。 // 我们使用简化写法,直接取方向。 o.worldLightDir = normalize(_WorldSpaceLightPos0.xyz); return o; }5.3 片元着色器执行核心计算
所有光照计算的精华,现在都集中在片元着色器中。
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // --- 逐像素光照计算开始 --- // 关键步骤:必须重新归一化!插值后的向量长度可能不是1。 float3 worldNormal = normalize(i.worldNormal); float3 worldLightDir = normalize(i.worldLightDir); // 虽然顶点已归一化,但插值后仍需归一化 // 计算Lambert系数 float lambert = max(0, dot(worldNormal, worldLightDir)); // 计算最终漫反射颜色 fixed3 diffuseColor = _LightColor0.rgb * _Color.rgb * lambert; // --- 逐像素光照计算结束 --- col.rgb *= diffuseColor; return col; }5.4 效果提升与性能考量
使用同一个低多边形球体应用此Shader,你会立刻看到平滑、连续的光照过渡,球体看起来非常圆润。这就是逐像素计算带来的质变。
逐像素光照的优缺点与适用场景:
- 优点:视觉质量极高,光照平滑,能完美支持法线贴图、凹凸贴图等需要逐像素细节的技术。
- 缺点:计算量大,片元着色器的负担重,是渲染管线中常见的性能瓶颈。
- 适用场景:场景中的主角、主要道具、UI特效、以及任何需要高质量视觉表现的物体。这是现代游戏中最主流的光照计算方式。
实操心得:在实际项目中,我们几乎总是使用逐像素光照。除非经过Profiler严格分析,确定某处是GPU的片元着色器瓶颈,并且物体确实适合用逐顶点近似(如非常远的山体),否则不要轻易使用逐顶点光照。视觉质量的优先级通常高于那一点性能提升。
6. 完整代码对比与关键差异解析
为了方便你整体理解和对比,我将两个版本的简化核心代码并置如下。删除了纹理采样部分以突出光照计算逻辑。
逐顶点光照 Lambert Shader 核心代码:
// 顶点着色器输出结构 struct v2f { ... fixed3 diffuse : COLOR; // 颜色在顶点算好 }; v2f vert (appdata v) { v2f o; ... // 所有计算在顶点完成 float3 worldNormal = UnityObjectToWorldNormal(v.normal); float3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); float lambert = max(0, dot(worldNormal, worldLightDir)); o.diffuse = _LightColor0.rgb * _Color.rgb * lambert; // 赋值结果 return o; } fixed4 frag (v2f i) : SV_Target { ... // 片元着色器直接使用插值后的颜色 col.rgb *= i.diffuse; return col; }逐像素光照 Lambert Shader 核心代码:
// 顶点着色器输出结构 struct v2f { ... float3 worldNormal : TEXCOORD1; // 传递向量 float3 worldLightDir : TEXCOORD2; }; v2f vert (appdata v) { v2f o; ... // 只准备数据,不计算光照 o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldLightDir = normalize(_WorldSpaceLightPos0.xyz); // 传递方向 return o; } fixed4 frag (v2f i) : SV_Target { ... // 所有计算在片元完成 float3 worldNormal = normalize(i.worldNormal); // 必须重新归一化! float3 worldLightDir = normalize(i.worldLightDir); float lambert = max(0, dot(worldNormal, worldLightDir)); fixed3 diffuseColor = _LightColor0.rgb * _Color.rgb * lambert; col.rgb *= diffuseColor; return col; }关键差异总结表:
| 特性 | 逐顶点光照 | 逐像素光照 |
|---|---|---|
| 计算位置 | 顶点着色器 | 片元着色器 |
| 计算频率 | 每顶点一次 | 每像素一次 |
| 视觉质量 | 低,有棱角感 | 高,平滑连续 |
| 性能开销 | 低 | 高 |
| 数据结构 | 传递计算好的颜色(COLOR) | 传递向量(TEXCOORD),需在片元重新归一化 |
| 支持法线贴图 | 否 | 是 |
| 典型应用 | 性能优先的远景/大量物体 | 质量优先的主角/近景物体 |
7. 常见问题、优化技巧与扩展思路
在实际应用这两个基础Shader时,你肯定会遇到各种问题。这里我总结了一些常见坑点和优化技巧。
7.1 常见问题排查
模型全黑或光照方向不对
- 检查法线:确保模型导入设置中“法线”计算正确。可以在材质中使用“法线”显示模式查看。
- 检查光源:场景中必须有启用的平行光。
_WorldSpaceLightPos0和_LightColor0来自前向渲染路径中的主平行光。 - 检查向量空间:确保法线和光源方向在同一空间(通常是世界空间)下计算点积。一个在模型空间,一个在世界空间,结果肯定是错的。
背光面全黑,没有环境光
- 这是经典Lambert模型的特性。可以通过添加环境光项来改善。在片元着色器中,最终颜色可以改为:
finalColor = UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb + diffuseColor。UNITY_LIGHTMODEL_AMBIENT是Unity内置的环境光颜色。
- 这是经典Lambert模型的特性。可以通过添加环境光项来改善。在片元着色器中,最终颜色可以改为:
逐像素版本下,模型边缘有锯齿或闪烁
- 这可能是由插值器精度引起的。在结构体
v2f中,将float3类型的worldNormal和worldLightDir改为float3的half3(如果不需要高精度),或在某些平台上使用fixed3(但注意范围限制),可以减少插值器的负担,有时能缓解问题。更根本的解决方法是确保模型法线本身没有问题。
- 这可能是由插值器精度引起的。在结构体
Shader不接收阴影或不能投射阴影
- 我们实现的是最基本的光照Pass。要支持阴影,需要额外的Pass,如
ShadowCaster。一个快速的方法是使用Fallback "Diffuse"或Fallback "Standard",让Unity在需要时使用内置的Diffuse或Standard Shader中的阴影Pass来兜底。
- 我们实现的是最基本的光照Pass。要支持阴影,需要额外的Pass,如
7.2 性能优化技巧
谨慎使用
normalize:normalize操作涉及开方和除法,开销较大。在顶点着色器中,如果确定向量来自归一化后的数据(如模型法线本身是归一化的,且模型变换只包含旋转,不含缩放),有时可以省略。但在片元着色器中,对插值后的向量必须进行归一化。这是质量与性能的权衡,不能省。使用半精度变量:在移动平台或性能敏感处,将
float替换为half,将float3替换为half3,可以显著提升性能。fixed类型精度更低,只适用于颜色和0-1之间的值。减少插值器数量:从顶点到片元传递的数据越多,插值开销越大。思考是否所有数据都是必需的。例如,如果不需要纹理,就可以去掉uv坐标。
7.3 扩展思路:迈向更真实的光照
掌握了基础的Lambert模型后,你可以以此为跳板,探索更丰富的光照效果:
半兰伯特(Half Lambert):Valve在《半条命2》中推广的技术。将Lambert系数从
[0,1]映射到[0.5,1],公式为:halfLambert = dot(normal, lightDir) * 0.5 + 0.5;。这能避免背光面完全死黑,让暗部也有细节,常用于卡通渲染或风格化表现。高光反射(Blinn-Phong):在漫反射基础上加入高光项。需要计算视线方向、反射方向或半角向量。这是实现塑料、金属等光滑表面光泽感的下一步。
基于图像的光照(IBL)与环境光遮蔽(AO):使用立方体贴图(Cubemap)来模拟复杂的环境光照,并结合AO贴图来增强缝隙、角落处的阴影真实感,这是实现高质量静态物体的关键。
法线贴图(Normal Mapping):在逐像素光照的框架下,用一张纹理来存储每个像素的“法线”信息,替换插值得到的顶点法线。这能在不增加模型面数的情况下,表现出极其丰富的表面细节(如砖墙的缝隙、雕刻的花纹)。这是将你的着色器从“玩具”升级到“实用”的必经之路。
实现这些扩展的关键,都在于你能否理解并熟练运用在本次Lambert实战中学到的核心方法:在正确的着色器阶段(顶点或片元)获取并处理正确的数据(法线、光线、视线),然后通过恰当的数学公式(点积、幂运算等)计算出想要的光照分量。当你能够手动实现Lambert时,就意味着你已经拿到了打开真实感渲染世界大门的钥匙。