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 { RenderTypeOpaque } 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 { RenderTypeOpaque } 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.50.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 ShaderShader编译错误。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.50.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让一个模型从平淡无奇变得充满立体感时那种成就感就是学习图形编程最大的乐趣之一。