Unity Shader入门:Lambert漫反射光照模型原理与实现详解
1. 项目概述:从“紫薯球”到真实感的第一步
如果你刚开始接触Unity Shader编程,或者写出来的模型总像一个颜色均匀的“紫薯球”(Unity默认的粉色错误材质),那么恭喜你,这篇内容就是为你准备的。我们今天要聊的Lambert漫反射光照模型,可以说是所有3D图形学光照的基石。它解决的问题非常直接:为什么一个物体在光线下会有明暗变化?在没有光照计算时,模型只是一个纯色的几何体,毫无立体感和真实感。而Lambert模型,就是用一行看似简单的数学公式,回答了“物体表面接收到多少光能量”这个核心问题,从而让模型“活”起来。
我见过很多新手朋友,一上来就想挑战复杂的卡通渲染、水体效果,结果在基础光照这里就卡住了,模型不是一片死黑就是一片惨白。其实,把Lambert理解透彻,你不仅能让模型拥有基础的体积感,更能真正理解光线、法线、视角这些核心概念是如何在Shader中协作的。这就像学武功先扎马步,马步稳了,后续的复杂招式(如高光反射、边缘光、PBR)才有根基。
本文的目标非常明确:手把手带你从零写一个完整的Lambert漫反射Shader。我不会只扔给你一段代码,而是会拆解每一个步骤背后的“为什么”,包括如何获取光线方向、为什么需要法线、saturate函数的作用、以及最终颜色是如何混合的。最后,我还会提供一份“标准答案”代码,并与一个常见的“错误实现”进行对比,让你一眼就能看出那些不易察觉的坑点。无论你是想入门Shader编程的程序员,还是想深入理解光照原理的TA(技术美术),这篇内容都能给你带来实实在在的收获。
2. 核心原理拆解:光、法线与余弦定律
在动手写代码之前,我们必须先搞清楚Lambert模型到底在计算什么。它的核心思想源于一个物理现象:一个表面接收到的光强,取决于光线入射方向与该表面法线方向的夹角。
2.1 视觉化理解:从台灯照桌面说起
想象一下夜晚的书桌,你有一盏台灯。当灯垂直照射在桌面上时,桌面最亮,因为所有光能都“砸”在了桌面上。如果你慢慢倾斜台灯,让光线以一个角度斜着照射桌面,你会发现桌面变暗了。当光线几乎平行于桌面时,桌面就几乎接收不到任何光线,变得很暗。
在这个例子里:
- 桌面就是我们的模型表面。
- 光线方向是从台灯指向桌面的方向向量。
- 桌面法线是垂直于桌面向上的向量。
- 夹角就是光线方向与法线方向之间的角度。
Lambert定律用数学语言描述就是:漫反射光强 = 光源颜色 × 漫反射颜色 × max(0, cos(θ))。其中,θ就是光线方向与法线方向的夹角。cos(θ)在图形学中,可以通过两个归一化(长度为1)向量的点积(Dot Product)来高效计算。
注意:这里使用了
max(0, ...)。这是因为当夹角大于90度时(即光线从表面背面照射),cos(θ)为负值,这没有物理意义(表面不可能接收负的光照)。所以我们用max函数将其钳制在0到1之间,确保背面是黑的。
2.2 关键组件与数据流
要实现这个计算,在Shader中我们需要准备以下几样东西,它们通常通过不同的渠道从Unity引擎传递进来:
- 表面法线 (Normal):描述每个顶点或像素所面对的方向。通常存储在模型的顶点数据中,或者由法线贴图提供。
- 光线方向 (Light Direction):从当前着色点指向光源的向量。在Unity的简化光照模型(如Surface Shader或片元着色器中),我们常使用
_WorldSpaceLightPos0这个内置变量。对于平行光(如太阳光),这个变量的xyz分量就是光线的世界空间方向。 - 光源颜色 (Light Color):光源的颜色和强度。通常通过
_LightColor0内置变量获取。 - 表面颜色 (Albedo):物体表面本来的颜色,也就是我们在材质球上设置的“Main Tex”或“_Color”。
Shader的工作,就是在顶点着色器或片元着色器中,将这些数据从各自的空间(模型空间、世界空间)转换到一个统一的空间(通常是世界空间),然后进行点积计算,最后混合颜色。
2.3 为什么选择Lambert作为入门?
因为它足够简单,又足够本质。简单在于其公式清晰,实现代码短小精悍。本质在于,它揭示了光照计算最核心的交互:方向与强度。理解了它,你就能轻松扩展到Half Lambert(用于卡通渲染提亮暗部)、Oren-Nayar(更粗糙表面的漫反射)等更复杂的模型。很多初学者写Shader效果不对,八成问题都出在空间转换或向量处理上,而练习Lambert正是排查这些问题的最佳场景。
3. 手把手实现:一个完整的Unity Shader框架
接下来,我们从一个最基本的Shader框架开始,逐步添加Lambert光照计算。我将使用Unity的Surface Shader来编写,因为它帮我们处理了许多底层的光照通路和阴影细节,让我们能更专注于光照模型本身。
3.1 搭建Shader基础结构
首先,创建一个新的Unlit Shader(或Surface Shader),并搭建起最基础的属性块、子着色器和结构体。
Shader "Custom/MyLambert" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 CGPROGRAM // 声明使用Surface Shader,并指定我们自定义的光照函数为“Lambert” #pragma surface surf MyLambert #include "UnityCG.cginc" #include "Lighting.cginc" sampler2D _MainTex; fixed4 _Color; // 输入结构体,Surface Shader要求 struct Input { float2 uv_MainTex; }; // 表面函数,负责填充SurfaceOutput结构 void surf (Input IN, inout SurfaceOutput o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; // 将采样到的颜色赋给表面输出的漫反射颜色 o.Alpha = c.a; } // !!!我们的自定义光照函数将在这里实现 !!! // 下一节会详细填充 ENDCG } FallBack "Diffuse" }代码解析与注意事项:
#pragma surface surf MyLambert:这是关键指令。它告诉Unity,我们使用surf函数处理表面颜色,使用名为MyLambert的函数处理光照计算。SurfaceOutput结构体:这是Surface Shader的标准输出。我们主要在surf函数中填充它的Albedo(漫反射颜色)和Alpha(透明度)。光照计算函数会读取这个结构体中的数据。#include "Lighting.cginc":这个头文件至关重要,它包含了_LightColor0、_WorldSpaceLightPos0等内置光照变量的声明,以及一些工具函数。
3.2 实现核心的Lambert光照函数
现在,我们来编写最核心的部分——MyLambert光照函数。这个函数会被Unity在每个需要计算光照的像素上调用。
// 自定义的漫反射光照函数 // 参数:s - 表面信息(包含了我们在surf中设置的Albedo, Normal等) // lightDir - 光源方向(Unity会自动从世界空间转换到切线空间?不!这里有个大坑!) // atten - 光源衰减值 half4 LightingMyLambert (SurfaceOutput s, half3 lightDir, half atten) { // 1. 对法线进行归一化(确保是单位向量) half3 normal = normalize(s.Normal); // 2. 对光线方向进行归一化 // **关键点**:Surface Shader传入的lightDir默认是在“切线空间”下的! // 对于Lambert计算,我们需要在世界空间或表面局部空间进行点积。 // 但Unity的Surface Shader为了效率,默认使用切线空间光照。 // 我们的法线s.Normal也是在切线空间下的,所以它们可以直接计算。 half3 lightDirNorm = normalize(lightDir); // 3. 计算Lambert系数:法线与光线方向的点积,并钳制到[0,1] // 这就是cos(θ) half NdotL = dot(normal, lightDirNorm); // 使用saturate代替max(0, NdotL),效果相同,但可能更优化 half lambert = saturate(NdotL); // 4. 计算最终颜色:表面颜色 * 光源颜色 * Lambert系数 * 衰减 half4 c; c.rgb = s.Albedo * _LightColor0.rgb * lambert * atten; c.a = s.Alpha; // 通常直接使用表面透明度 return c; }这里是第一个“实操心得”和“大坑”预警:
注意:空间一致性是Shader调试的第一要义!很多新手写的Lambert效果全黑或全亮,问题就出在这里。在Surface Shader的自定义光照函数中,Unity默认将
lightDir和s.Normal都处理到了切线空间(Tangent Space)。这意味着,lightDir已经不是我们直观理解的世界空间光线方向了,而是相对于每个顶点法线、副法线、切线的局部方向。这样做的好处是,在计算法线贴图时非常高效。因此,在这个上下文中,我们直接对它们进行归一化和点积操作是正确的。如果你错误地尝试将lightDir转换到世界空间,而法线还在切线空间,点积结果就会完全错误。
3.3 在表面函数中传递法线
你可能注意到,上面的surf函数只设置了o.Albedo,那法线o.Normal从哪里来?默认情况下,Unity会使用模型的顶点法线。但如果我们需要使用法线贴图,就需要在surf函数中对其进行采样和转换。
// 在Properties块中添加法线贴图属性 Properties { ... _BumpMap ("Normal Map", 2D) = "bump" {} _BumpScale ("Bump Scale", Float) = 1.0 } // 在CGPROGRAM中声明变量 sampler2D _BumpMap; float _BumpScale; // 修改surf函数 void surf (Input IN, inout SurfaceOutput o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; o.Alpha = c.a; // 从法线贴图获取切线空间法线 // UnpackNormal函数会解码贴图(通常是压缩格式)并应用_BumpScale half4 normalMap = tex2D(_BumpMap, IN.uv_MainTex); o.Normal = UnpackNormal(normalMap); o.Normal.xy *= _BumpScale; o.Normal = normalize(o.Normal); }添加法线贴图后,你的Lambert光照将立刻呈现出丰富的表面细节,这是单纯顶点法线无法比拟的。这也是为什么理解切线空间如此重要的原因。
4. 完整代码与“经典错误”对比
现在,让我们整合一个完整的、带法线贴图支持的Lambert Shader代码,并把它和一个常见的错误实现放在一起对比。
4.1 正确实现的完整代码
Shader "Custom/MyLambertComplete" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _BumpMap ("Normal Map", 2D) = "bump" {} _BumpScale ("Normal Scale", Range(0,2)) = 1.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM // 使用nolightmap指令禁用光照贴图以简化,vertex:vert用于可能的顶点变换 #pragma surface surf MyLambert exclude_path:prepass nolightmap noforwardadd #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; fixed4 _Color; float _BumpScale; struct Input { float2 uv_MainTex; float2 uv_BumpMap; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; o.Alpha = c.a; // 处理法线贴图 half4 normalMap = tex2D(_BumpMap, IN.uv_BumpMap); o.Normal = UnpackNormal(normalMap); o.Normal.xy *= _BumpScale; o.Normal = normalize(o.Normal); } // 核心Lambert光照函数 half4 LightingMyLambert (SurfaceOutput s, half3 lightDir, half atten) { // 关键:lightDir和s.Normal已在同一空间(切线空间),直接计算 half NdotL = dot(normalize(s.Normal), normalize(lightDir)); half lambert = saturate(NdotL); half4 c; c.rgb = s.Albedo * _LightColor0.rgb * lambert * atten; c.a = s.Alpha; return c; } ENDCG } FallBack "Legacy Shaders/Diffuse" }4.2 常见错误实现与对比分析
下面是一个典型的错误实现,它错误地理解了光线方向的空间:
// 错误示例:错误的空间转换 half4 LightingMyLambertWrong (SurfaceOutput s, half3 lightDir, half atten) { // 错误1:试图将切线空间的lightDir转换到世界空间,但转换矩阵缺失 // half3 worldLightDir = mul((float3x3)unity_WorldToObject, lightDir); // 完全错误的思路 // 错误2(更常见):直接使用_WorldSpaceLightPos0,但未考虑其类型(点光源vs平行光) half3 worldLightDir = _WorldSpaceLightPos0.xyz; // 对于平行光,这是方向 // 假设s.Normal是世界空间法线(实际上在Surface Shader默认光照函数中,它是切线空间的) half3 worldNormal = s.Normal; // 这里s.Normal是切线空间的,直接使用错误! half NdotL = dot(normalize(worldNormal), normalize(worldLightDir)); half lambert = saturate(NdotL); half4 c; c.rgb = s.Albedo * _LightColor0.rgb * lambert * atten; c.a = s.Alpha; return c; }对比表格:问题根源与现象
| 对比项 | 正确实现 | 错误实现 |
|---|---|---|
光线方向 (lightDir) | 接受Unity传入的切线空间光线方向。 | 错误地使用_WorldSpaceLightPos0(世界空间)或进行错误的空间转换。 |
法线方向 (s.Normal) | 使用在surf中赋值的切线空间法线(来自法线贴图或顶点)。 | 误以为_WorldSpaceLightPos0是世界空间方向,直接与切线空间法线计算。 |
| 点积计算空间 | 切线空间。两者空间一致,计算正确。 | 空间混乱。一个向量在世界空间,另一个在切线空间,点积无意义。 |
| 渲染结果 | 光照随模型表面法线正确变化,支持法线贴图细节。 | 通常表现为整个模型 uniform 的明暗(无立体感),或完全错误的黑白区域,旋转模型时光照怪异跳动。 |
| 问题本质 | 理解了Surface Shader光照函数的约定和切线空间的用途。 | 对渲染管线中数据流和空间转换理解不足,生搬硬套公式。 |
我的调试心得:当你的Lambert Shader效果不对时,第一反应应该是检查空间。一个快速的调试方法是:在光照函数中,将NdotL的结果直接作为颜色输出(return half4(lambert, lambert, lambert, 1);)。如果看到的是从黑到白平滑过渡的球体,说明计算基本正确。如果看到的是大块均匀的色块或奇怪的图案,那几乎可以肯定是空间不匹配。
5. 扩展与优化:从基础Lambert出发
掌握了基础的Lambert实现后,你可以通过一些简单的修改,实现不同的视觉效果,这能帮你更深入地理解光照计算。
5.1 实现Half-Lambert(阀门社经典技法)
Valve在《半条命2》中推广的Half-Lambert技术,用于提亮暗部,避免背光面完全死黑,在卡通风格或需要更明亮视觉的游戏中很常见。
half4 LightingMyHalfLambert (SurfaceOutput s, half3 lightDir, half atten) { half NdotL = dot(normalize(s.Normal), normalize(lightDir)); // 核心变化:将[-1, 1]的NdotL映射到[0, 1],并进行幂次调整 half halfLambert = NdotL * 0.5 + 0.5; // 映射到[0,1] // 可以再加一个乘方来控制对比度,例如 pow(halfLambert, 2) half diffuse = pow(halfLambert, 2); half4 c; c.rgb = s.Albedo * _LightColor0.rgb * diffuse * atten; c.a = s.Alpha; return c; }原理:标准Lambert的NdotL范围是[-1,1],经过saturate后变成[0,1]。Half-Lambert先通过*0.5+0.5将其线性映射到[0,1](这样背面也有0.5的基础值),再通过一个pow函数来调整明暗对比度。值越大,暗部越亮,对比度越低。
5.2 添加环境光与自发光
真实世界的光照不仅仅是直接光。为了模型在阴影中不至于全黑,我们需要添加环境光(Ambient)和自发光(Emission)。
void surf (Input IN, inout SurfaceOutput o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; // 自发光:可以来自一个单独的贴图或颜色 // fixed3 emission = tex2D(_EmissionMap, IN.uv_MainTex).rgb * _EmissionColor; // o.Emission = emission; o.Alpha = c.a; // ... 法线处理 } half4 LightingMyLambertWithAmbient (SurfaceOutput s, half3 lightDir, half atten) { half NdotL = dot(normalize(s.Normal), normalize(lightDir)); half lambert = saturate(NdotL); half4 c; // 直接漫反射光 fixed3 directDiffuse = s.Albedo * _LightColor0.rgb * lambert * atten; // 环境光(使用Unity内置的全局环境光变量) fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * s.Albedo; // 最终颜色 = 直接光 + 环境光 + 自发光 c.rgb = directDiffuse + ambient + s.Emission; c.a = s.Alpha; return c; }UNITY_LIGHTMODEL_AMBIENT是Unity提供的场景环境光颜色。自发光o.Emission则在表面函数中设置,它会直接叠加到最终颜色上,不受光照影响。
5.3 性能优化小贴士
- 避免冗余计算:
normalize操作相对昂贵。如果确信传入的lightDir和s.Normal已经是归一化后的(在Surface Shader的某些上下文中可能是),可以省略。但为了代码的健壮性,通常保留。在移动端,可以评估是否在顶点着色器中进行计算。 - 使用half精度:对于颜色和光照计算,使用
half(半精度浮点数)而非float,在支持它的平台上可以提升性能并减少带宽占用。 - 慎用分支:Shader中的
if语句可能导致性能下降。像saturate(NdotL)这样的内在函数通常比max(0, NdotL)更优化,也避免了潜在的分支。 - 利用Fallback:在SubShader末尾指定
Fallback "Diffuse"或Fallback "Legacy Shaders/VertexLit",可以在当前Shader不支持的老旧显卡上有一个基本的回退效果,避免出现“粉红错误”。
6. 常见问题排查与实战技巧
即使理解了原理,实际编写和调试Shader时还是会遇到各种问题。这里我整理了一份从入门到进阶常见的问题清单和解决方法。
6.1 问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 模型显示为统一的粉色(Missing Shader) | Shader编译错误。 | 1. 检查控制台错误信息。2. 检查CGPROGRAM/ENDCG是否配对。3. 检查#pragma指令是否正确。4. 检查属性变量名是否在CG代码中声明。 |
| 模型一片纯黑或纯白,无光照变化 | 光照计算未生效或结果被钳制。 | 1. 检查光照函数名是否与#pragma声明一致。2.将lambert系数直接输出为颜色,查看是否为渐变。3. 检查_LightColor0是否获取到光源(确认场景有启用的平行光)。 |
| 光照方向不随相机或模型旋转变化 | 光线方向向量空间错误。 | 1.确认使用的是Surface Shader默认传入的切线空间lightDir。2. 如果自定义,确保正确从世界空间转换到目标空间。 |
| 法线贴图无效,模型看起来是光滑的 | 法线数据未正确传递或采样。 | 1. 检查surf函数中是否对o.Normal进行了赋值。2. 检查法线贴图导入设置是否为“Normal map”。3. 检查UnpackNormal函数是否被正确调用。 |
| 背面(背对光源的面)全黑,无环境光 | 未添加环境光项。 | 在最终颜色计算中,加上UNITY_LIGHTMODEL_AMBIENT.rgb * s.Albedo。 |
| 在移动设备上效果异常或性能差 | 使用了高精度计算或不支持的语法。 | 1. 尝试将float改为half或fixed。2. 检查是否使用了复杂的数学函数(如pow,sin)。3. 使用#pragma target 2.0或3.0限制特性集。 |
6.2 调试技巧:可视化中间变量
这是Shader调试中最强大的技巧。当你不知道哪个环节出错时,把中间计算的结果直接作为颜色输出。
// 在光照函数中临时调试 half4 LightingMyLambertDebug (SurfaceOutput s, half3 lightDir, half atten) { half3 normLightDir = normalize(lightDir); half3 normNormal = normalize(s.Normal); // 调试1:直接显示法线(切线空间法线是归一化的,各分量在[-1,1],映射到[0,1]显示) // return half4(normNormal * 0.5 + 0.5, 1.0); // 调试2:直接显示光线方向 // return half4(normLightDir * 0.5 + 0.5, 1.0); // 调试3:显示NdotL(范围[-1,1],映射到[0,1]) half NdotL = dot(normNormal, normLightDir); // return half4(NdotL, NdotL, NdotL, 1); // 灰度图显示 return half4(NdotL*0.5+0.5, 0, 0, 1); // 红色通道显示 // ... 正常计算 }- 显示为彩色球:如果法线可视化是一个从中心向四周平滑过渡的彩色球,说明法线数据基本正确。
- 显示为纯色或怪异图案:说明向量数据有问题,可能是空间错误或未归一化。
6.3 从Built-in RP转向URP/HDRP
如果你在使用Universal RP (URP) 或 High Definition RP (HDRP),Surface Shader不再适用。你需要编写HLSL文件并挂载到URP的Lit或Unlit Shader Graph,或者直接编写Custom Lit Shader。
核心思路转变:
- 光照循环由引擎管理:URP使用前向渲染器,光源循环在引擎侧完成,Shader只需处理单个主光+附加光的计算。
- 使用URP函数库:包含
GetMainLight()、GetAdditionalLight()等函数来获取光源数据。 - 数据结构不同:需要熟悉
Varyings、SurfaceData、InputData等URP标准结构体。
一个URP下简化的片元着色器Lambert计算片段可能如下所示(需在完整的Shader框架中):
// 在URP的片元着色器函数中 Light mainLight = GetMainLight(inputData.shadowCoord); // 获取主光信息,包含方向、颜色、衰减 float3 lightDir = mainLight.direction; float3 lightColor = mainLight.color; float NdotL = saturate(dot(normalize(normalWS), normalize(lightDir))); float3 diffuse = albedo * lightColor * NdotL * mainLight.distanceAttenuation * mainLight.shadowAttenuation; // 还需要加上环境光等迁移到SRP(可编程渲染管线)是进阶的必经之路,但理解Lambert的原理是通用的,变化的只是API和数据获取方式。
写一个正确的Lambert漫反射Shader,就像学会了烹饪中的“炒”这个基本技法。它本身就能做出可口的菜肴(基础光照),同时也是“红烧”、“爆炒”等复杂技法(如PBR、各向异性)的根基。我强烈建议你在实现成功后,多尝试调整参数:改变_BumpScale观察凹凸强度,修改光源颜色,甚至尝试去掉saturate看看负数NdotL的效果。这种动手实验带来的直观感受,比读十篇理论文章都深刻。当你看到自己写的Shader让一个模型从平淡无奇变得充满立体感时,那种成就感就是学习图形编程最大的乐趣之一。
