1. 项目概述从“照亮”到“理解”在Unity的世界里我们总想让自己的场景看起来更“对味”。一个粗糙的模型配上合适的光照质感能瞬间提升几个档次反之一个精致的模型如果光照不对看起来也会像塑料玩具。很多朋友初学Shader时一上来就想搞PBR基于物理的渲染、SSS次表面散射这些高级玩意儿结果往往一头雾水写出来的效果七零八落。问题出在哪地基没打牢。这个“地基”就是基础光照模型。所谓光照模型简单说就是一套数学公式用来计算物体表面某一点在特定光照条件下的颜色。它定义了光如何与材质互动。UnityShader中的基础光照模型通常指的是漫反射和镜面反射这两大核心物理现象的简化模拟。完整掌握它意味着你不仅能“抄”一段代码让物体亮起来更能理解每一行代码背后的物理意义和数学逻辑知道为什么这么写以及如何调整参数来达到你想要的艺术效果。这就像学画画你得先理解光影的规律才能画出立体感而不是单纯地涂颜色。这篇文章我会带你从零开始手把手拆解Unity中最经典的基础光照模型实现。我们不依赖Unity内置的Surface Shader模板虽然它很方便而是从最底层的顶点/片元着色器Vertex/Fragment Shader写起把每一个向量、每一个点乘、每一个计算步骤都掰开揉碎讲清楚。目标是让你看完之后能独立写出一个完整、正确且可灵活调控的基础光照Shader并对光照计算建立起清晰的直觉。2. 光照模型的核心原理拆解光与物质的对话在深入代码之前我们必须先搞懂光照模型在模拟什么。现实中的光照极其复杂计算机图形学做了大量简化。基础光照模型通常由几个独立的部分叠加而成最经典的是兰伯特Lambert漫反射模型和冯氏Phong或布林-冯Blinn-Phong镜面反射模型。2.1 漫反射为什么我们能看见物体的颜色漫反射是光照射到粗糙表面后向各个方向均匀散射的现象。我们之所以能看见不发光物体的颜色主要就是漫反射的功劳。它的核心特点是反射光的方向与光源方向无关只与物体表面的法线方向有关。兰伯特余弦定律是它的数学基础表面一点接收到的光照强度与光源方向向量和该点法线向量夹角的余弦值成正比。简单说光直射时最亮斜射时变暗与法线垂直时完全照不到。在Shader中我们如何计算首先需要几个关键向量表面法线Normalfloat3 worldNormal描述了表面的朝向。光源方向Light Directionfloat3 lightDir从表面点指向光源的向量。注意在Unity中对于平行光如Directional Light我们通常使用从光源指向表面的向量所以计算时需要取反。光照颜色与强度float3 _LightColor0.rgbUnity内置变量表示第一个逐像素光源的颜色已包含强度。漫反射颜色的计算公式为float3 diffuse _LightColor0.rgb * albedo.rgb * max(0, dot(worldNormal, lightDir));这里albedo是物体表面固有的颜色纹理采样或固有色。dot(worldNormal, lightDir)就是计算余弦值max(0, ...)确保了结果不为负背面不受光。注意这里有一个初学者极易混淆的点——向量空间。法线向量和光源方向向量必须在同一个坐标空间下进行点乘计算否则结果毫无意义。通常我们选择在世界空间World Space或切线空间Tangent Space进行计算。本文为了清晰将统一使用世界空间。2.2 镜面反射高光是怎么来的镜面反射模拟的是光滑表面如金属、塑料上的高光现象。光在光滑表面发生镜面反射我们只在反射方向附近才能看到强烈的亮斑。基础模型用两个经典公式来近似Phong和Blinn-Phong。Phong模型的计算依赖于三个向量视线方向View Direction、反射光方向Reflection Direction和光源方向。高光强度与视线方向与反射光方向夹角的余弦值的N次方成正比。N是光泽度Shininess值越大高光点越小、越锐利。Blinn-Phong模型是Phong的改进版它引入了一个中间向量半角向量Half Vector即视线方向与光源方向的角平分线方向。然后计算法线与半角向量的点乘。它在计算上更高效且在高光形态上更接近一些物理测量结果成为更常用的选择。Blinn-Phong镜面反射的计算公式为float3 specular _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);其中halfDir normalize(lightDir viewDir)_SpecularColor是高光颜色_Gloss是光泽度系数。2.3 环境光阴影里的物体不是全黑的仅有直接光照漫反射镜面反射的物体背光面会是一片死黑这不真实。现实中光线会在场景中多次反弹照亮阴影区域。我们用环境光Ambient来近似模拟这种间接光照效果。在Unity中环境光颜色可以通过UNITY_LIGHTMODEL_AMBIENT这个内置变量获取它反映了在Window-Rendering-Lighting设置中配置的环境光。计算非常简单float3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * albedo.rgb;最终物体表面某一点的最终颜色就是这三者的叠加float3 finalColor ambient diffuse specular;这就是我们常说的ADSAmbient, Diffuse, Specular光照模型。3. 构建完整的Unlit Shader框架在实现光照之前我们需要一个干净的、可编程的Shader框架。Unity的Standard Surface Shader帮我们隐藏了很多细节但为了彻底理解我们从最简单的“无光照”Shader开始改造。3.1 创建基础Shader结构首先在Unity中创建一个新的Shader文件命名为“BasicLightingModel”。清空默认内容我们从最基础的属性块、SubShader和Pass开始。Shader Custom/BasicLightingModel { Properties { // 物体表面基础颜色或纹理 _MainTex (Albedo (RGB), 2D) white {} _Color (Color Tint, Color) (1,1,1,1) // 镜面反射相关属性 _SpecularColor (Specular Color, Color) (1,1,1,1) _Gloss (Gloss, Range(8.0, 256)) 20 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { // 指定该Pass的光照模式为前向渲染 Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag // 确保支持阴影并接收光照衰减对于非平行光 #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc // 包含阴影相关宏 #include AutoLight.cginc // 定义与Properties块对应的变量 sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放和偏移 fixed4 _Color; fixed4 _SpecularColor; float _Gloss; // 顶点着色器的输入结构 struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; // 顶点着色器输出到片元着色器的结构 struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; // 声明一个内置的阴影坐标变量用于计算阴影 SHADOW_COORDS(3) }; // 顶点着色器 v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 顶点变换到裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理缩放偏移 o.worldNormal UnityObjectToWorldNormal(v.normal); // 法线转到世界空间 o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 顶点位置转到世界空间 TRANSFER_SHADOW(o); // 计算并传递阴影坐标 return o; } // 片元着色器暂时留空后续填充光照计算 fixed4 frag (v2f i) : SV_Target { // 光照计算将在这里进行 return fixed4(0,0,0,1); } ENDCG } } FallBack Diffuse // 指定一个备用的Shader }这个框架做了几件关键事定义了材质属性固有色、高光颜色、光泽度。使用了ForwardBase光照模式这个Pass用于渲染第一个逐像素平行光通常是主方向光并计算环境光和光照贴图。包含了必要的头文件Lighting.cginc提供了光照计算所需的函数和变量如_LightColor0AutoLight.cginc提供了阴影相关的宏。在顶点着色器中完成了关键数据准备将法线和顶点位置转换到世界空间这是后续所有光照计算的基础。同时计算了阴影坐标。定义了v2f结构用于在顶点和片元着色器之间传递数据包括UV、世界空间法线、世界空间位置和阴影坐标。实操心得UnityObjectToWorldNormal这个函数内部已经帮我们处理了法线矩阵即模型变换矩阵的逆转置矩阵这是保证法线在非均匀缩放下依然正确的关键。自己手动计算很容易出错所以强烈建议使用这个内置函数。4. 实现完整ADS光照计算现在我们在片元着色器frag函数中填充完整的漫反射、镜面反射和环境光计算。4.1 准备计算所需向量所有计算都在世界空间进行因此我们需要基于v2f结构传入的数据构造出几个核心向量。fixed4 frag (v2f i) : SV_Target { // 1. 采样纹理获取基础颜色 fixed4 albedo tex2D(_MainTex, i.uv) * _Color; // 2. 获取环境光 fixed3 ambient UNITY_LIGHTMODEL_AMBIENT.rgb * albedo.rgb; // 3. 准备世界空间向量 // 世界空间法线并归一化插值后可能不再是单位长度 fixed3 worldNormal normalize(i.worldNormal); // 世界空间光源方向对于ForwardBase中的平行光 // _WorldSpaceLightPos0.xyz是平行光的方向从光源指向表面我们需要表面指向光源的向量所以取反。 fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 世界空间视线方向从表面点指向相机 fixed3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 4. 计算漫反射 (Lambert) fixed NdotL max(0, dot(worldNormal, lightDir)); fixed3 diffuse _LightColor0.rgb * albedo.rgb * NdotL; // 5. 计算镜面反射 (Blinn-Phong) // 计算半角向量 fixed3 halfDir normalize(lightDir viewDir); fixed NdotH max(0, dot(worldNormal, halfDir)); fixed3 specular _LightColor0.rgb * _SpecularColor.rgb * pow(NdotH, _Gloss); // 6. 计算阴影衰减 fixed shadow SHADOW_ATTENUATION(i); // 7. 组合最终颜色环境光不受阴影影响 fixed3 finalColor ambient (diffuse specular) * shadow; return fixed4(finalColor, albedo.a); }这段代码是基础光照模型的核心。我们来拆解几个关键点向量归一化normalize()操作至关重要。因为从顶点着色器传到片元着色器的向量经过了插值长度很可能不是1。而点乘计算依赖于向量的方向而非长度所以必须重新归一化。光源方向_WorldSpaceLightPos0这个内置变量的含义取决于光源类型。对于平行光它的.xyz分量代表方向从光源指向表面.w分量为0。对于点光源或聚光灯它的.xyz代表光源位置.w为1。在我们的ForwardBase Pass中它默认是主平行光。阴影计算SHADOW_ATTENUATION(i)宏会根据之前计算的阴影坐标i采样阴影贴图返回一个0到1之间的衰减因子。我们将它应用到直接光照漫反射镜面反射部分这样背光面就能产生正确的阴影。环境光独立环境光模拟的是间接光照它不受直接光源的阴影影响所以单独相加。4.2 添加对点光源/聚光灯的支持上面的Shader只处理了平行光。在实际场景中我们还需要点光源和聚光灯。这通常通过添加额外的Pass并设置LightMode为ForwardAdd来实现。ForwardAdd Pass会为每个额外的逐像素光执行一次并以混合叠加的方式贡献到最终颜色。在SubShader中在第一个Pass之后添加第二个PassPass { Tags { LightModeForwardAdd } Blend One One // 启用叠加混合将本Pass的结果加到上一个Pass的结果上 CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdadd // 编译指令支持点光源和聚光灯 #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc // ... 属性变量定义与v2f结构体与第一个Pass相同 ... v2f vert (appdata v) { // ... 顶点变换代码与第一个Pass几乎相同 ... // 注意ForwardAdd Pass中环境光不计算阴影处理也可能不同通常不处理 } fixed4 frag (v2f i) : SV_Target { // 采样纹理 fixed4 albedo tex2D(_MainTex, i.uv) * _Color; // 准备向量 fixed3 worldNormal normalize(i.worldNormal); fixed3 worldPos i.worldPos; // 关键区别光源方向的计算 #ifdef USING_DIRECTIONAL_LIGHT // 如果是平行光 fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz); #else // 如果是点光源或聚光灯_WorldSpaceLightPos0.xyz是光源位置 fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz - worldPos); #endif fixed3 viewDir normalize(_WorldSpaceCameraPos.xyz - worldPos); // 计算漫反射 fixed NdotL max(0, dot(worldNormal, lightDir)); fixed3 diffuse _LightColor0.rgb * albedo.rgb * NdotL; // 计算镜面反射 (Blinn-Phong) fixed3 halfDir normalize(lightDir viewDir); fixed NdotH max(0, dot(worldNormal, halfDir)); fixed3 specular _LightColor0.rgb * _SpecularColor.rgb * pow(NdotH, _Gloss); // 关键区别光照衰减 #ifdef USING_DIRECTIONAL_LIGHT fixed atten 1.0; // 平行光无衰减 #else // 对于点光源/聚光灯使用内置函数计算衰减和聚光灯效果 // UNITY_LIGHT_ATTENUATION 宏会计算衰减并处理阴影如果开启 UNITY_LIGHT_ATTENUATION(atten, i, worldPos); #endif // 最终颜色 (漫反射 镜面反射) * 衰减 fixed3 finalColor (diffuse specular) * atten; return fixed4(finalColor, albedo.a); } ENDCG }这个ForwardAdd Pass有几个要点Blend One One这是混合模式让本Pass的计算结果与之前Pass的结果相加实现多光源叠加。#pragma multi_compile_fwdadd这个编译指令让Unity为不同的光源类型平行光、点光、聚光灯生成不同的Shader变体。光源方向分支判断通过#ifdef USING_DIRECTIONAL_LIGHT来区分平行光和其他光源正确计算lightDir。光照衰减使用UNITY_LIGHT_ATTENUATION这个强大的宏它自动根据光源类型计算衰减距离衰减和聚光灯锥形衰减并且如果该光源投射阴影它也会处理阴影衰减。这比手动计算平方反比衰减要方便和准确得多。现在你的Shader已经可以同时处理一个主平行光带阴影和环境光和多个额外的点光源/聚光灯了。5. 参数调试与艺术化控制写Shader不是纯数学更是艺术创作。基础光照模型中的几个参数就是你的调色板。5.1 核心参数解析_Color 与 _MainTex定义了物体的“本色”。这是漫反射计算的基础。使用纹理可以让表面细节更丰富。_SpecularColor高光的颜色。对于非金属材质通常设为白色或浅灰色。对于金属可以设置为接近漫反射颜色的亮色以模拟金属特有的高光色彩。_Gloss光泽度即Phong/Blinn-Phong公式中的指数。这个值对视觉效果影响巨大。低值如8-32高光区域大而模糊模拟粗糙表面如石膏、布料。高值如128-256高光区域小而锐利模拟光滑表面如抛光金属、瓷器。在Inspector面板中滑动_Gloss滑块你可以直观地看到物体从“磨砂”到“镜面”的变化。5.2 进阶技巧在片元着色器中进行法线贴图上面的例子使用了顶点法线细节有限。为了表现砖墙的凹凸、皮肤的褶皱等微小细节我们需要法线贴图。法线贴图存储了每个像素点上的法线方向扰动信息。首先在Properties中添加法线贴图属性_BumpMap (Normal Map, 2D) bump {} _BumpScale (Bump Scale, Range(-2.0, 2.0)) 1.0然后修改数据结构将切线信息从顶点传入struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; // 新增切线 float2 uv : TEXCOORD0; }; struct v2f { ... float3 worldPos : TEXCOORD2; // 构建一个从切线空间到世界空间的变换矩阵需要三个向量 float3 tspace0 : TEXCOORD3; // tangent.world float3 tspace1 : TEXCOORD4; // bitangent.world float3 tspace2 : TEXCOORD5; // normal.world ... };在顶点着色器中构建这个变换矩阵v2f vert (appdata v) { ... // 计算世界空间下的法线、切线和副切线双切线 fixed3 worldNormal UnityObjectToWorldNormal(v.normal); fixed3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); fixed3 worldBitangent cross(worldNormal, worldTangent) * v.tangent.w; // 注意叉乘顺序和tangent.w // 将三个轴按列排列构成切线-世界矩阵 o.tspace0 fixed3(worldTangent.x, worldBitangent.x, worldNormal.x); o.tspace1 fixed3(worldTangent.y, worldBitangent.y, worldNormal.y); o.tspace2 fixed3(worldTangent.z, worldBitangent.z, worldNormal.z); ... }最后在片元着色器中采样法线贴图并转换到世界空间fixed4 frag (v2f i) : SV_Target { ... // 从法线贴图采样范围从[0,1]映射到[-1,1]并应用缩放 fixed3 tangentNormal UnpackNormal(tex2D(_BumpMap, i.uv)); tangentNormal.xy * _BumpScale; tangentNormal.z sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 将切线空间法线变换到世界空间 fixed3 worldNormal; worldNormal.x dot(i.tspace0, tangentNormal); worldNormal.y dot(i.tspace1, tangentNormal); worldNormal.z dot(i.tspace2, tangentNormal); worldNormal normalize(worldNormal); // 后续的光照计算全部使用这个新的 worldNormal ... }通过法线贴图即使是一个低多边形模型也能呈现出丰富的高光细节让光照效果立刻提升一个档次。6. 常见问题与性能优化实战自己动手实现一遍肯定会遇到各种问题。这里我总结几个最常见的坑和排查思路。6.1 问题排查清单问题现象可能原因排查步骤物体全黑1. 光照模式错误。2. 向量计算错误如法线、光源方向。3. 颜色值全为0。1. 检查Pass的Tags{LightMode...}是否正确。2. 在片元着色器中使用return fixed4(worldNormal * 0.5 0.5, 1);可视化法线检查是否为彩色球。3. 检查_LightColor0和albedo的值。高光位置不对或形状奇怪1. 视线方向或半角向量计算错误。2. 光泽度_Gloss值不合适。3. 向量未归一化。1. 分别可视化viewDir和halfDir。2. 调整_Gloss值观察变化是否符合预期。3. 确认所有参与点乘的向量都已normalize。背面也有光照漫反射计算未使用max(0, dot(...))。确保NdotL被限制在0以上。阴影不显示1. 未正确包含阴影宏和头文件。2. 未在v2f中声明SHADOW_COORDS和调用TRANSFER_SHADOW。3. 光源未开启阴影投射。1. 检查#include AutoLight.cginc和宏的使用。2. 确保SHADOW_COORDS的参数是下一个可用的TEXCOORD序号。3. 在Unity光源组件中检查“Shadow Type”。点光源/聚光灯无效1. 未添加ForwardAdd Pass。2. ForwardAdd Pass的混合模式错误。3. 光源的Render Mode不是Important。1. 确认有第二个Pass且LightMode为ForwardAdd。2. 确认有Blend One One。3. 将点光源的Render Mode设为Important确保它是逐像素光。6.2 性能优化要点基础光照模型本身计算量不大但良好的习惯对编写复杂Shader至关重要。精度选择在CG/HLSL中float32位精度高half16位中等fixed11位低。对于颜色和范围在0-1的值优先使用fixed。对于法线、位置等使用half或float。在移动平台合理使用低精度能显著提升性能。减少不必要的计算例如环境光如果不随表面变化可以在顶点着色器中计算。对于远处物体可以考虑使用更简化的光照模型。善用Fallback在Shader的末尾指定一个Fallback如Fallback Diffuse。这样当你的Shader在某些特性如阴影上编译失败或不支持时Unity会使用备用的、功能完整的Shader来渲染避免物体消失。变体管理我们使用的#pragma multi_compile_fwdbase和#pragma multi_compile_fwdadd会产生多个Shader变体。变体过多会增长编译时间和包体。对于确定不用的功能例如你的游戏肯定不用雾效可以使用#pragma skip_variants来跳过相关变体的生成。6.3 从基础到进阶的路径当你完全掌握了这个基础模型后你就拥有了探索更高级渲染技术的“入场券”。你可以尝试边缘光Rim Light基于法线与视线夹角在物体边缘添加发光效果。卡通着色Cel Shading将漫反射的连续值进行阶梯化step或smoothstep函数实现卡通感。PBR的漫反射部分用更物理的迪士尼漫反射模型或Lambert的改进版代替简单的兰伯特模型。在片元着色器中计算光照我们目前是在片元着色器计算逐像素光照效果细腻。你也可以尝试在顶点着色器计算逐顶点光照Gouraud Shading性能更高但可能有棱角感。光照模型是Shader学习的基石它连接着抽象的数学公式和屏幕上鲜活的视觉感受。我建议你不要停留在阅读而是打开Unity新建一个场景创建一个球体和平面把这个Shader一步步敲进去然后疯狂地拖拽光源、调整参数、更换纹理和法线贴图观察每一个变化。遇到问题就回到代码里用return fixed4(someVariable, 1);的方式把中间变量可视化出来调试。这个过程里积累的“手感”和直觉是任何教程都无法直接给你的。当你看到自己写出的Shader对光影变化做出准确反应时那种成就感就是继续深入图形学这个迷人领域的最佳动力。