当前位置: 首页 > news >正文

Unity Shader入门:从零实现Lambert漫反射与逐顶点/逐像素光照对比

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开始,以获得最大的控制权。将其命名为LambertPerVertexLambertPerPixel

一个最基础的顶点/片元着色器结构如下:

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 常见问题排查

  1. 模型全黑或光照方向不对

    • 检查法线:确保模型导入设置中“法线”计算正确。可以在材质中使用“法线”显示模式查看。
    • 检查光源:场景中必须有启用的平行光。_WorldSpaceLightPos0_LightColor0来自前向渲染路径中的主平行光。
    • 检查向量空间:确保法线和光源方向在同一空间(通常是世界空间)下计算点积。一个在模型空间,一个在世界空间,结果肯定是错的。
  2. 背光面全黑,没有环境光

    • 这是经典Lambert模型的特性。可以通过添加环境光项来改善。在片元着色器中,最终颜色可以改为:finalColor = UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb + diffuseColorUNITY_LIGHTMODEL_AMBIENT是Unity内置的环境光颜色。
  3. 逐像素版本下,模型边缘有锯齿或闪烁

    • 这可能是由插值器精度引起的。在结构体v2f中,将float3类型的worldNormalworldLightDir改为float3half3(如果不需要高精度),或在某些平台上使用fixed3(但注意范围限制),可以减少插值器的负担,有时能缓解问题。更根本的解决方法是确保模型法线本身没有问题。
  4. Shader不接收阴影或不能投射阴影

    • 我们实现的是最基本的光照Pass。要支持阴影,需要额外的Pass,如ShadowCaster。一个快速的方法是使用Fallback "Diffuse"Fallback "Standard",让Unity在需要时使用内置的Diffuse或Standard Shader中的阴影Pass来兜底。

7.2 性能优化技巧

  1. 谨慎使用normalizenormalize操作涉及开方和除法,开销较大。在顶点着色器中,如果确定向量来自归一化后的数据(如模型法线本身是归一化的,且模型变换只包含旋转,不含缩放),有时可以省略。但在片元着色器中,对插值后的向量必须进行归一化。这是质量与性能的权衡,不能省。

  2. 使用半精度变量:在移动平台或性能敏感处,将float替换为half,将float3替换为half3,可以显著提升性能。fixed类型精度更低,只适用于颜色和0-1之间的值。

  3. 减少插值器数量:从顶点到片元传递的数据越多,插值开销越大。思考是否所有数据都是必需的。例如,如果不需要纹理,就可以去掉uv坐标。

7.3 扩展思路:迈向更真实的光照

掌握了基础的Lambert模型后,你可以以此为跳板,探索更丰富的光照效果:

  1. 半兰伯特(Half Lambert):Valve在《半条命2》中推广的技术。将Lambert系数从[0,1]映射到[0.5,1],公式为:halfLambert = dot(normal, lightDir) * 0.5 + 0.5;。这能避免背光面完全死黑,让暗部也有细节,常用于卡通渲染或风格化表现。

  2. 高光反射(Blinn-Phong):在漫反射基础上加入高光项。需要计算视线方向、反射方向或半角向量。这是实现塑料、金属等光滑表面光泽感的下一步。

  3. 基于图像的光照(IBL)与环境光遮蔽(AO):使用立方体贴图(Cubemap)来模拟复杂的环境光照,并结合AO贴图来增强缝隙、角落处的阴影真实感,这是实现高质量静态物体的关键。

  4. 法线贴图(Normal Mapping):在逐像素光照的框架下,用一张纹理来存储每个像素的“法线”信息,替换插值得到的顶点法线。这能在不增加模型面数的情况下,表现出极其丰富的表面细节(如砖墙的缝隙、雕刻的花纹)。这是将你的着色器从“玩具”升级到“实用”的必经之路。

实现这些扩展的关键,都在于你能否理解并熟练运用在本次Lambert实战中学到的核心方法:在正确的着色器阶段(顶点或片元)获取并处理正确的数据(法线、光线、视线),然后通过恰当的数学公式(点积、幂运算等)计算出想要的光照分量。当你能够手动实现Lambert时,就意味着你已经拿到了打开真实感渲染世界大门的钥匙。

http://www.jsqmd.com/news/1345231/

相关文章:

  • 2026年国内收费透明的泰州注销代办公司靠谱选择参考**汇总 - 米諾
  • 2026年河间整装优选指南:雅丝奈家居定制 - 热点品牌推荐
  • 太原团队会议住宿预订哪家酒店更合适?团队一站式酒店对比 - 小橘甄选
  • 【铜川市】2026CPPM采购经理报考指南|正规机构甄选产业适配全攻略 - 中采供培
  • ZBrush细节优化与拓扑重构实战技巧
  • 2026年 非标电气自动化设备厂家/控制箱源头工厂:安装维修保养一体化服务实力解析 - 优企名品
  • 使用github搭建图床
  • 邯郸防火包厂家哪家好、橡塑板厂家推荐怎么选不踩坑|2026避坑指南 - mobible
  • 福州合同纠纷进入精细博弈期,乐星星律师资质齐全、诚信可靠口碑好,如何破解合同纠纷胜败局? - 专业优选推荐榜
  • C++ vector迭代器失效原理与解决方案:从STL源码到工程实践
  • 26.4美元/小时:人形机器人替代人工的“盈亏线”,被宇树率先跨过了
  • ZooKeeper网络分区问题解析与解决方案
  • 宿州泗县家装实地测评:深耕十余年本土品牌泗县好先生装饰深度解析 - 国麟测评
  • 深入解析MyBatis三级缓存:从原理到实战,规避性能陷阱与数据一致性问题
  • 大连广鹿岛海岛民宿发展分析:沉浸式旅居成新趋势 - 国麟测评
  • 机器狗训练平台中六自由度运动模拟系统的关键技术研究
  • #2026吉安市板凳伯伯盖浇饭实力企业加盟怎么选板凳伯伯现炒浇头面 - 热点品牌推荐
  • AbMole 小讲堂丨RMC-7977:RAS抑制剂,在肿瘤信号网络与耐药机制研究中的应用
  • Claude Code子智能体实战:构建可自我迭代的AI编程工作流
  • 西安折扣卡系统源码实战指南:从开发到部署全流程
  • 前端无剪辑拆卡实战:用代码还原设计稿,告别手动切图
  • 每日热门skill-10万行PPT只需一句话?这个被腾讯官方的Skill,正在重新定义AI办公的天花板
  • 选机构不踩坑:广州外国人来华工作许可证公司口碑好本地测评与对比全攻略 - 米諾
  • 北京大兴网站建设公司哪家好?揭秘那些不藏私心的选企指南与避坑真相
  • 大模型工业应用实战:三层反幻觉防御与RAG技术解析
  • 2026室内设计行业现状盘点,装修痛点解析与实用落地干货分享 - 国麟测评
  • 西安聚合CPS优惠券系统源码实战部署指南与开发流程详解
  • Java 数组操作
  • 微服务安全:Sentinel黑白名单与来源控制实战
  • 2026装饰装修行业避坑指南:本土整装如何选更靠谱 - 国麟测评