Unity Shader入门:从逐顶点到逐像素的Lambert漫反射光照实现

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 关键向量:光源方向与表面法线

这里涉及两个核心向量:

  1. 表面法线(Normal) :这是一个垂直于模型表面某一点的单位向量(长度为1)。它定义了表面的“朝向”。在3D模型中,每个顶点都存储着自己的法线信息。
  2. 光源方向(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 代码关键点解析与实操心得

  1. 包含文件 Lighting.cginc :这是Unity为我们封装好的光照计算头文件。它自动定义了关键变量,如 _WorldSpaceLightPos0 (主平行光的位置/方向)和 _LightColor0 (主平行光的颜色)。自己手动去获取这些信息非常麻烦,直接包含这个文件是标准做法。

  2. 数据结构 v2f :注意我们定义了一个 fixed3 diff : COLOR0 的成员。 COLOR0 是一个语义,表示这个数据将被用作颜色并在顶点之间进行插值。我们将光照计算结果存储在这里,从顶点着色器传递给片元着色器。

  3. 顶点着色器中的计算

    • UnityObjectToWorldNormal(v.normal) :这是一个Unity内置函数,用于将法线从模型空间转换到世界空间,并处理非均匀缩放可能带来的问题。 永远不要 自己用模型矩阵去乘法线,必须使用这个专用函数或类似的 mul(v.normal, (float3x3)unity_WorldToObject)
    • _WorldSpaceLightPos0.xyz :对于平行光,其 .w 分量为0, .xyz 直接代表世界空间下的光方向。我们需要将其归一化。
    • 计算出的 lambert 因子与光源颜色、材质颜色相乘,得到最终的漫反射颜色 o.diff
  4. 片元着色器的简化 :在 frag 函数中,我们几乎没做计算。只是对纹理进行采样,然后将采样结果与从顶点着色器传递过来并已由硬件插值好的 i.diff (漫反射)以及环境光 unity_AmbientSky 相加混合。环境光我们通过属性 _Ambient 来控制强度。

实操心得:关于法线变换的坑 这是新手最容易出错的地方之一。法线是方向向量,不能直接用变换顶点的矩阵(如 unity_ObjectToWorld )来变换。因为如果模型存在非均匀缩放(Scale的x, y, z值不同),直接变换会导致法线不再垂直于表面。 UnityObjectToWorldNormal 内部使用了逆转置矩阵来处理这个问题,务必使用它。

3.3 效果与局限性分析

将Shader赋给一个材质球并应用到模型上(比如一个Sphere),在场景中放置一个Directional Light。你会看到模型有了基础的明暗变化。但是,尝试以下操作,逐顶点光照的局限就暴露无遗:

  1. 使用一个低面数的模型(比如一个Cube或一个粗糙的圆柱)。
  2. 仔细观察光影交界处,你会看到颜色是“一块一块”变化的,而不是平滑的渐变。这是因为光照只在顶点计算,面内部的颜色是线性插值得到的,无法体现曲面上的连续变化。
  3. 在模型上使用法线贴图来增加表面细节?对不起,逐顶点光照无能为力,因为法线贴图带来的法线变化是逐像素的。

因此,逐顶点光照适用于对性能极度敏感、且模型本身较光滑(顶点密度高)或对光影细节要求不高的移动端场景。它是性能与质量天平上偏向性能的一端。

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 代码关键点解析与性能权衡

  1. 数据传递的变化 :在 v2f 结构中,我们不再传递计算好的颜色 ( diff ),而是传递用于计算光照的原始数据:世界空间法线 ( worldNormal ) 和世界空间位置 ( worldPos )。这些数据会在顶点之间插值后传入片元着色器。

  2. 顶点着色器的简化 vert 函数现在只负责坐标变换和数据准备,计算负担很轻。

  3. 片元着色器的核心地位 :所有光照计算移到了 frag 函数中。注意第一步 normalize(i.worldNormal) 至关重要 。经过插值后的向量长度不一定是1,必须重新归一化,否则点积计算会出错。

  4. 光源类型的通用性 :代码中注释提到了点光源的计算方式。逐像素光照可以更灵活地支持多种光源类型,因为每个像素都可以独立计算自己到光源的方向和距离衰减。

实操心得:插值后归一化是铁律 这是逐像素光照的另一个常见坑。从顶点着色器输出的 worldNormal 是单位向量,但经过光栅化插值到每个片元时,这些向量的长度会变(不再是1)。如果你忘记在片元着色器中 normalize ,会导致光照在面部中心变暗等异常现象。 只要在顶点着色器输出向量,在片元着色器使用前,先 normalize ,这应该成为肌肉记忆。

4.3 效果提升与扩展性

使用同一个低面数模型应用逐像素光照Shader,你会立刻看到区别:光影过渡变得非常平滑,即使是一个立方体,其面上的光照也是连续渐变的,彻底消除了“马赫带”。

更重要的是,逐像素光照为更多高级效果打开了大门。例如,要加法线贴图,你只需要:

  1. 在Properties中添加 _BumpMap _BumpScale
  2. 在顶点着色器中计算并传递世界空间切线 worldTangent 和副切线 worldBitangent ,构建TBN矩阵。
  3. 在片元着色器中,从 _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倍以上。
  • 一个1000面的简单道具 ,但它在屏幕上被一个半透明的UI大面积覆盖,导致GPU需要为它绘制很多次(深度复杂)。
    • 逐顶点计算:稳定1000次运算。
    • 逐像素计算:运算量会随着Overdraw倍数急剧增加。
    • 结论 :在高Overdraw情况下,逐像素开销会成倍放大。

因此,一个常见的优化策略是 “按需分配”

  • 对于场景中远处的建筑、背景山体、小草丛等,使用逐顶点光照甚至更简单的无光照Shader。
  • 对于主角、NPC、武器、近处的重要道具,使用逐像素光照以保证视觉质量。
  • 在Unity的URP/HDRP中,这套选择逻辑被整合进了Shader变体(Shader Variants)和渲染管线设置中,但底层原理相通。

5.3 常见问题排查与实战技巧实录

在实际编写和调试这两种Shader时,你肯定会遇到一些典型问题。这里我记录了几个“踩坑”实录和解决方法。

问题1:模型一片漆黑,没有光照效果。

  • 排查步骤
    1. 检查场景中是否有激活的Directional Light 。这是 _WorldSpaceLightPos0 _LightColor0 的数据来源。
    2. 检查Shader中是否包含了 #include "Lighting.cginc" 。没有它,相关变量未定义。
    3. 检查法线变换 。在顶点着色器中,务必使用 UnityObjectToWorldNormal ,而不是 mul(unity_ObjectToWorld, v.normal) 。可以在片元着色器中用 return fixed4(i.worldNormal * 0.5 + 0.5, 1.0); 将法线可视化,检查其是否正确(正常应呈现平滑的色彩渐变)。
    4. 检查向量归一化 。在逐像素Shader中,确认对 i.worldNormal worldLightDir 都进行了 normalize

问题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工具箱里最称手的第一对利器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值