
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.50.5线性变换到了[0, 1]范围。原本背光面dot(N,L)为负的值从0被提升到了一个大于0的正值。例如dot(N,L) -0.5经过变换后是(-0.5*0.50.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材质。最后将材质拖拽给场景中的球体和立方体。现在你的物体应该是粉红色的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的世界从这里打开亲手写下的每一行代码都会让你对屏幕上呈现的魔法多一分掌控和理解。