Unity Shader入门:从原理到代码实现兰伯特漫反射光照模型

1. 项目概述:为什么我们需要深入理解兰伯特漫反射?

如果你在Unity3D里做过3D渲染,哪怕只是简单地拖拽过材质球,大概率都接触过“漫反射”这个概念。它决定了物体表面在光照下呈现出的基础颜色和明暗变化,是让一个模型从“纸片感”变得有体积感、有重量的第一步。而兰伯特(Lambert)模型,就是描述这种漫反射现象最经典、最基础的数学模型。

我刚开始学Shader那会儿,总觉得光照模型是图形学大神们才需要钻研的东西,Unity内置的Standard Shader已经足够好用。直到有一次,我需要为一个风格化的低多边形(Low Poly)游戏角色定制一个简单的卡通着色(Toon Shading)效果。我尝试着直接修改Standard Shader的参数,结果要么效果不对,要么性能开销巨大。最后,我不得不回过头来,从最基础的兰伯特漫反射开始,自己动手写一个片元着色器。那一刻我才真正明白,不理解这些基础光照模型,就像开车不懂发动机原理,平时可能没问题,一旦需要个性化调优或者排查诡异的光照Bug时,就会束手无策。

兰伯特模型的核心思想极其直观:表面上一个点接收到的光照强度,与光线入射方向和该点法线方向夹角的余弦值成正比。简单说,光线垂直照射时最亮,随着角度倾斜越来越暗,当光线与表面平行时,理论上就完全不受光了。这个模型由18世纪的科学家约翰·海因里希·兰伯特提出,因其物理上的合理性和计算上的简便性,成为了计算机图形学中漫反射光照的基石。

对于Unity开发者而言,深入理解兰伯特模型的价值远不止于“知道有这么个公式”:

  • 性能可控:在移动端或需要大量实例化渲染的场景中,一个自己实现的、精简的兰伯特着色器,远比功能庞大的PBR(基于物理的渲染)着色器高效。
  • 风格化渲染的起点:绝大多数非真实感渲染(NPR),如卡通渲染、水彩风格,其第一步都是在兰伯特模型的基础上进行“改造”,比如对连续的明暗变化进行离散化(色阶化)处理。
  • 调试能力的基石:当场景中出现不自然的黑斑、接缝处光照断裂或光照贴图(Lightmap)烘焙效果异常时,对漫反射原理的深入理解能帮你快速定位问题根源,而不是盲目地调整灯光参数。

所以,无论你是想优化渲染性能,还是追求特定的艺术风格,亦或是希望成为能解决深层问题的技术专家,从兰伯特漫反射这个“第一性原理”入手,都是必经之路。

2. 核心原理与数学模型拆解

兰伯特模型的数学表达非常简洁,但每一个变量背后都对应着渲染管线中的一个具体环节。理解这个公式,就等于理解了标准前向渲染中,一个像素颜色是如何被计算出来的第一步。

2.1 兰伯特定律的数学表达

兰伯特定律的经典公式如下:

漫反射光强 = 光源颜色 × 材质漫反射颜色 × max(0, dot(N, L))

我们来逐个拆解这个公式里的每一个部分:

  1. 光源颜色:这通常是一个RGB三维向量,例如(1.0, 1.0, 1.0)代表白色光。它定义了光源本身的颜色和强度。在Unity中,_LightColor0这个内置变量通常代表了当前处理的主平行光(如Directional Light)的颜色和强度。
  2. 材质漫反射颜色:这就是我们常说的物体“固有色”,在Shader中通常对应_Color属性或从主纹理(Albedo Texture)采样得到的颜色。它定义了物体表面本身对光的反射特性。
  3. max(0, dot(N, L)):这是整个模型的核心计算部分,决定了光照的明暗衰减。
    • N:表面法线向量(Normal Vector)。它是一个单位向量(长度为1),垂直于物体表面某一点。在顶点着色器中,我们通常从模型数据中读取顶点法线;在片元着色器中,为了得到更平滑的效果,我们会对顶点法线进行插值,或者直接使用法线纹理(Normal Map)来提供更精细的法线信息。
    • L:从表面点指向光源的方向向量。这里非常关键,L的方向是指向光源的。对于平行光(如太阳光),这个方向在整个场景中是恒定的。对于点光源或聚光灯,L需要通过光源世界坐标 - 表面点世界坐标来计算得出,并且需要归一化(Normalize)为单位向量。
    • dot(N, L):点积运算。两个单位向量的点积,在几何上等于它们夹角余弦值cosθ。当N和L方向完全一致(光线垂直入射)时,cosθ = 1,光照最强;当夹角为90度时,cosθ = 0;当夹角大于90度时,cosθ为负,意味着光线是从表面背后照射过来的。
    • max(0, ...):这个最大值函数将点积结果钳制在0到1之间。负值意味着背光面,我们直接将其亮度设为0,避免产生“负光”这种不符合物理直觉的效果。

注意:这里有一个新手极易混淆的点。很多教程或代码中,L被表述为“光源方向”。严格来说,它应该是“表面点到光源的方向”。在Shader中计算时,务必确保向量方向正确,否则会导致整个光照计算完全错误,出现“该亮的地方暗,该暗的地方亮”的诡异情况。

2.2 在渲染管线中的位置与计算时机

理解公式后,我们需要知道它是在渲染管线的哪个阶段被执行的。这决定了我们如何获取数据以及计算的精度。

  • 顶点着色器 vs 片元着色器:兰伯特计算可以在顶点着色器(Vertex Shader)中完成,也可以在片元着色器(Fragment/Pixel Shader)中完成。
    • 顶点着色器计算(Gouraud Shading):在顶点上计算光照,然后将计算出的颜色值在三角形内部进行线性插值。优点是计算量小(顶点数远少于像素数),但缺点是当模型面数较低时,会出现明显的颜色块状过渡,无法体现精细的光影变化,特别是在镜面高光区域。
    • 片元着色器计算(Phong Shading):将法线、顶点位置等数据插值到每个像素(片元)上,然后在每个像素上独立进行光照计算。这是现代渲染中最常用的方式,能产生非常平滑、高质量的光照效果,也是我们接下来实现的重点。虽然计算量更大,但对于如今的GPU来说,这已是标准操作。

在Unity的标准前向渲染路径中,光照计算主要发生在片元着色器阶段。我们会将世界空间下的法线(N)和光源方向(L)传递到片元着色器中进行点积运算。

2.3 与其它光照模型的初步对比

了解兰伯特模型的局限性,能更好地知道何时该用它,何时需要更复杂的模型。

  • 与Phong模型对比:Phong光照模型在兰伯特漫反射的基础上,增加了一个镜面反射(Specular)分量。镜面反射模拟的是光滑表面上的高光亮点,其强度与观察方向(V)和反射方向(R)的夹角有关。兰伯特模型本身不包含任何高光信息,因此渲染出的物体看起来像是粗糙的石膏或粉笔,缺乏塑料、金属等光滑材质的“光泽感”。
  • 与Blinn-Phong模型对比:Blinn-Phong是Phong模型的一个优化变种。它引入了一个“半角向量”(H,即光线方向L和视线方向V的中间向量),用dot(N, H)来代替Phong模型中的dot(R, V)计算高光。这种计算方式在某些情况下更高效,且高光过渡更柔和、更自然。但它的基础漫反射部分,通常依然采用兰伯特模型。
  • 与PBR模型对比:基于物理的渲染(PBR)是一套更复杂、更接近真实物理的模型体系。它的漫反射部分虽然思想源于兰伯特,但通常会结合更精确的能量守恒定律(出射光能不超过入射光能),并使用像迪士尼原则的漫反射模型等更复杂的公式。PBR还严格区分了金属和非金属材质的反射行为。兰伯特模型可以看作是PBR在非金属、粗糙表面情况下的一个高度简化版本。

实操心得:不要盲目追求复杂模型。对于风格化渲染、移动端简单物件、或需要极高性能的场景,兰伯特漫反射往往是性价比最高的选择。它的效果纯粹、可控,且是理解所有后续复杂光照模型的“母语”。

3. 在Unity中实现兰伯特漫反射着色器

理论说得再多,不如动手写一遍。我们将从零开始,在Unity的ShaderLab框架下,编写一个完整的、支持单平行光的兰伯特漫反射着色器。我会采用片元着色器计算的方式,以获得最佳视觉效果。

3.1 着色器框架与属性定义

首先,我们创建一个新的Unlit Shader,并为其搭建基础框架。我们将把它改造成一个自定义的光照着色器。

Shader "Custom/LambertDiffuse" { Properties { // 定义在材质面板中可见、可调节的属性 _Color ("Main Color", Color) = (1,1,1,1) // 材质固有色 _MainTex ("Main Texture", 2D) = "white" {} // 主纹理 } SubShader { Tags { "RenderType"="Opaque" } // 标签,定义渲染类型为不透明物体 Pass { // 这个Pass将用于渲染物体的主要颜色和光照 Tags { "LightMode"="ForwardBase" } // 关键!告诉Unity此Pass用于前向渲染的基础通道(处理主平行光和环境光) CGPROGRAM #pragma vertex vert // 指定顶点着色器函数名 #pragma fragment frag // 指定片元着色器函数名 // 确保包含Unity的光照和标准变量库 #include "UnityCG.cginc" #include "Lighting.cginc" // 包含此文件才能使用_LightColor0等光照变量 // 定义与Properties块对应的变量,用于CG代码中访问 fixed4 _Color; sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放和偏移参数,命名必须为“纹理名_ST” // 输入到顶点着色器的数据结构(从模型数据获取) struct appdata { float4 vertex : POSITION; // 顶点位置 float3 normal : NORMAL; // 顶点法线 float2 uv : TEXCOORD0; // 第一套UV坐标 }; // 顶点着色器输出到片元着色器的数据结构 struct v2f { float4 pos : SV_POSITION; // 裁剪空间中的顶点位置(必须) float2 uv : TEXCOORD0; // 传递UV坐标 float3 worldNormal : TEXCOORD1; // 世界空间下的法线 float3 worldPos : TEXCOORD2; // 世界空间下的顶点位置 }; // 顶点着色器函数 v2f vert (appdata v) { v2f o; // 将顶点从模型空间转换到裁剪空间(用于最终屏幕渲染) o.pos = UnityObjectToClipPos(v.vertex); // 应用纹理的缩放和偏移变换 o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 将法线从模型空间转换到世界空间,并归一化 // unity_WorldToObject是模型到世界矩阵的逆转置矩阵的3x3部分,用于正确变换法线(处理非均匀缩放) o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 将顶点位置从模型空间转换到世界空间 o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; } // 片元着色器函数 fixed4 frag (v2f i) : SV_Target { // 光照计算将在这里进行 // 暂时返回一个固定颜色,后续填充 return fixed4(1,0,0,1); // 红色 } ENDCG } } FallBack "Diffuse" // 如果当前SubShader不兼容,则回退到Unity内置的Diffuse着色器 }

这段代码搭建了一个标准的着色器骨架。LightMode="ForwardBase"标签至关重要,它使得这个Pass能够接收到Unity渲染引擎提供的主平行光信息(_LightColor0,_WorldSpaceLightPos0)。

3.2 核心光照计算代码实现

现在,我们在片元着色器frag函数中实现兰伯特模型的计算。

fixed4 frag (v2f i) : SV_Target { // 1. 采样纹理颜色,并与材质颜色混合,得到基础漫反射颜色 fixed4 texColor = tex2D(_MainTex, i.uv); fixed3 albedo = texColor.rgb * _Color.rgb; // 2. 获取世界空间下的法线(已在顶点着色器归一化,此处通常无需再次归一化,但为了稳健可以加上) float3 worldNormal = normalize(i.worldNormal); // 3. 计算光源方向L // _WorldSpaceLightPos0是光源的位置信息。 // 对于平行光,其.w分量为0,前xyz分量直接代表光的方向(从光源指向表面)。 // 我们需要的是从表面指向光源的方向,所以对于平行光,L = -_WorldSpaceLightPos0.xyz float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); // 注意:因为我们的L需要是从表面指向光源,而平行光的方向是光源指向表面,所以取反。 // 对于点光源,计算方式不同,这里我们只处理平行光。 float3 L = normalize(-lightDir); // 从表面点指向光源的方向 // 4. 计算兰伯特系数 (N·L) // 使用saturate函数,它等价于clamp(x, 0, 1),确保结果在[0,1]区间,比max(0, dot(...))更常用。 float lambert = saturate(dot(worldNormal, L)); // 5. 结合光源颜色和环境光 fixed3 lightColor = _LightColor0.rgb; // 主平行光的颜色和强度 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb; // 从场景设置中获取环境光颜色 // 6. 最终颜色计算:环境光 + 兰伯特漫反射光 fixed3 diffuse = ambient + lightColor * albedo * lambert; // 7. 输出最终颜色 return fixed4(diffuse, texColor.a); // 保留纹理的透明度通道 }

将这段代码替换掉之前返回红色的frag函数,你的第一个自定义兰伯特着色器就完成了!将它赋给一个材质球,并拖到场景中的模型上,调整场景中的平行光方向和颜色,就能看到经典的漫反射光照效果。

关键点解析与避坑指南

  1. 法线变换mul(v.normal, (float3x3)unity_WorldToObject)是法线从模型空间到世界空间的正确变换方式。如果模型有非均匀缩放(Scale的x,y,z值不同),直接使用unity_ObjectToWorld矩阵变换法线会导致法线方向错误,进而导致光照撕裂。unity_WorldToObjectunity_ObjectToWorld的逆矩阵的转置,专门用于处理法线变换。
  2. 光源方向计算:对于平行光,_WorldSpaceLightPos0.xyz已经是方向,但其方向定义是从光源指向世界原点。我们需要的L从表面点指向光源,所以需要取反。这是最常出错的地方之一。
  3. saturate的使用saturate是CG/HLSL中的内置函数,比max(0, ...)表达更简洁,且在某些硬件上可能有优化。
  4. 环境光UNITY_LIGHTMODEL_AMBIENT提供了场景中设置的环境光颜色。即使没有直接光照,物体也不会完全漆黑,这符合现实世界的情况。

3.3 增加纹理与颜色支持

我们的着色器已经支持了纹理(_MainTex)和颜色(_Color)。在计算albedo(反照率)时,我们将纹理采样结果与材质颜色相乘。这是一种常见的做法,材质颜色可以用来整体调节纹理的色调。

你可以尝试:

  • 导入一张纹理图片,将其赋给材质的_MainTex属性。
  • 调整_Color,比如设置为淡蓝色,观察整个物体如何在纹理基础上叠加一层色调,同时依然保持正确的光影关系。

4. 高级话题与性能优化实践

实现基础版本只是第一步。在实际项目中,我们总会遇到更复杂的需求和性能瓶颈。下面我们来探讨几个进阶方向。

4.1 支持点光源与多光源渲染

我们之前的着色器只处理了主平行光(ForwardBase Pass)。要支持点光源或额外的平行光,需要使用额外的Pass,并设置LightMode="ForwardAdd"。Unity的前向渲染路径会为每个逐像素光源执行一次Additive Pass。

// 在SubShader中添加第二个Pass Pass { Tags { "LightMode"="ForwardAdd" } // 用于额外的逐像素光源 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" // 可能需要用于光源衰减 // ... 属性、结构体、顶点着色器与之前类似 ... fixed4 frag (v2f i) : SV_Target { // 重新计算albedo fixed4 texColor = tex2D(_MainTex, i.uv); fixed3 albedo = texColor.rgb * _Color.rgb; float3 worldNormal = normalize(i.worldNormal); float3 worldPos = i.worldPos; // 计算光源方向,这里需要根据光源类型处理 #ifdef USING_DIRECTIONAL_LIGHT // 如果是平行光 float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 L = normalize(-lightDir); float atten = 1.0; // 平行光无衰减 #else // 如果是点光源或聚光灯 float3 lightDir = normalize(_WorldSpaceLightPos0.xyz - worldPos); float3 L = lightDir; // 此时lightDir已经是从表面指向光源 // 计算衰减,可以使用Unity内置宏或自己计算距离衰减 float distance = length(_WorldSpaceLightPos0.xyz - worldPos); atten = 1.0 / (1.0 + 0.1 * distance + 0.01 * distance * distance); // 一个简单的衰减公式 #endif float lambert = saturate(dot(worldNormal, L)); fixed3 lightColor = _LightColor0.rgb; // 注意:在ForwardAdd Pass中,通常不加入环境光,只计算该光源的贡献 fixed3 diffuse = lightColor * albedo * lambert * atten; return fixed4(diffuse, texColor.a); } ENDCG }

这样,你的着色器就能正确地与Unity场景中的多个点光源、聚光灯一起工作了。Blend One One是加法混合,确保多个光源的效果能够叠加。

4.2 法线贴图的应用

基础的法线来自模型的顶点数据,细节有限。为了在低多边形模型上呈现高模般的表面细节(如砖缝、划痕、织物纹理),我们需要使用法线贴图(Normal Map)。

法线贴图是一张RGB纹理,其每个像素的RGB值对应着一个切线空间下的法线向量(x, y, z)。我们需要在着色器中对其进行解码,并用它来扰动顶点法线。

  1. 添加属性:在Properties块中添加_BumpMap ("Normal Map", 2D) = "bump" {}_BumpScale ("Normal Scale", Float) = 1.0
  2. 修改输入/输出结构:需要将切线信息从顶点着色器传递到片元着色器。
    struct appdata_tan { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; // 新增:切线 float2 texcoord : TEXCOORD0; }; struct v2f { ... // 其他成员 float3 tspace0 : TEXCOORD3; // 切线空间矩阵的第一行 float3 tspace1 : TEXCOORD4; // 第二行 float3 tspace2 : TEXCOORD5; // 第三行(即世界法线) };
  3. 在顶点着色器中构建切线空间矩阵
    v2f vert (appdata_tan v) { ... // 计算世界空间下的法线和切线 float3 worldNormal = UnityObjectToWorldNormal(v.normal); float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); // 计算副切线(从法线和切线的叉积得出,并乘以切线的.w分量决定方向) float3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; // 构建从切线空间到世界空间的旋转矩阵 o.tspace0 = float3(worldTangent.x, worldBinormal.x, worldNormal.x); o.tspace1 = float3(worldTangent.y, worldBinormal.y, worldNormal.y); o.tspace2 = float3(worldTangent.z, worldBinormal.z, worldNormal.z); ... return o; }
  4. 在片元着色器中采样并应用法线贴图
    fixed4 frag (v2f i) : SV_Target { ... // 从法线贴图采样,并解码到[-1, 1]范围 fixed4 packedNormal = tex2D(_BumpMap, i.uv); float3 tangentNormal; // 使用UnpackNormal函数(在UnityCG.cginc中)解码,它会自动处理DXT5nm等压缩格式 tangentNormal = UnpackNormal(packedNormal); tangentNormal.xy *= _BumpScale; // 缩放法线贴图的强度 tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 重新计算Z分量 tangentNormal = normalize(tangentNormal); // 将切线空间法线转换到世界空间 float3 worldNormal; worldNormal.x = dot(i.tspace0, tangentNormal); worldNormal.y = dot(i.tspace1, tangentNormal); worldNormal.z = dot(i.tspace2, tangentNormal); worldNormal = normalize(worldNormal); // 使用扰动后的世界空间法线worldNormal进行后续的兰伯特计算 float lambert = saturate(dot(worldNormal, L)); ... }

引入法线贴图后,即使是一个简单的平面,也能通过纹理呈现出丰富的凹凸细节和光影变化,极大地提升了视觉丰富度。

4.3 性能优化考量

在移动平台或大型场景中,着色器的性能至关重要。

  1. 计算精度优化:在CG/HLSL中,数据类型有fixed(低精度,通常11位,用于颜色)、half(半精度,16位浮点)、float(全精度,32位)。对于颜色计算(albedo, lightColor)和简单的点积结果(lambert),使用fixedhalf通常足够,且能提升运算速度。但对于位置、法线、以及涉及复杂数学运算的中间变量,仍需使用float以保证精度。
  2. 分支语句慎用:GPU擅长并行处理相同指令流,不擅长处理分支(if-else)。在着色器中,特别是片元着色器内,应尽量避免动态分支。像saturate(dot(N, L))这样的操作,GPU可以高效地并行处理成千上万个像素。
  3. 简化计算:对于某些不需要法线贴图的静态物体,可以考虑在顶点着色器计算光照(Gouraud Shading),虽然质量稍低,但能显著减少片元着色器的计算量。也可以考虑使用光照贴图(Lightmap)来烘焙静态物体的漫反射光照,运行时完全不需要进行实时光照计算。
  4. Shader变体管理:使用#pragma multi_compileshader_feature会产生多个Shader变体。变体过多会导致编译时间增长和内存占用上升。需要合理规划,例如将支持法线贴图、支持细节纹理等特性通过开关控制,并为不同的性能档次设备准备不同的Shader版本。

实操心得:性能优化没有银弹。最好的方法是使用Unity的Frame Debugger和GPU Profiling工具(如RenderDoc)实际测量。先保证功能正确,再针对瓶颈进行优化。对于移动端,一个只包含基础兰伯特漫反射和单张纹理的Shader,其性能通常是非常优秀的,是保证流畅帧率的可靠选择。

5. 常见问题排查与调试技巧

即使理解了原理和代码,在实际开发中依然会遇到各种诡异的问题。下面是我在项目中积累的一些常见问题及其排查思路。

5.1 模型表面出现不规则的黑色条纹或斑块

这是新手实现自定义光照着色器时最常遇到的问题之一。

  • 可能原因1:法线数据问题
    • 检查:在片元着色器中,将worldNormal可视化输出(return fixed4(worldNormal * 0.5 + 0.5, 1.0))。正常的法线可视化应该是一个平滑的、从蓝色到其他颜色的渐变球体。如果出现混乱的色块或条纹,说明法线数据本身有问题。
    • 解决
      1. 确认模型导入设置中“Normals”选项是否正确(通常是“Calculate”或“Import”)。
      2. 在顶点着色器中,确保对变换后的法线进行了normalize操作。
      3. 如果使用了法线贴图,检查切线空间矩阵计算是否正确,以及法线贴图纹理的导入设置是否为“Normal map”。
  • 可能原因2:非均匀缩放导致法线变换错误
    • 检查:观察问题是否只发生在被非均匀缩放(如Scale为(2,1,1))的模型上。
    • 解决:务必使用UnityObjectToWorldNormal(v.normal)这个内置宏,或者像我们之前那样使用mul(v.normal, (float3x3)unity_WorldToObject)。绝对不要用mul(unity_ObjectToWorld, float4(v.normal, 0.0)).xyz来变换法线。
  • 可能原因3:光源方向计算错误
    • 检查:将光源方向L可视化(return fixed4(L * 0.5 + 0.5, 1.0))。对于平行光,它应该是一个恒定的颜色。如果出现渐变,说明计算有误。
    • 解决:再次核对L的计算公式。对于平行光,确保是normalize(-_WorldSpaceLightPos0.xyz)

5.2 背光面完全漆黑,没有环境光

  • 检查:在片元着色器中,分别输出ambient和漫反射部分lightColor * albedo * lambert的值。
  • 解决
    1. 确认场景中是否设置了环境光(Window > Rendering > Lighting > Environment)。
    2. 确认着色器代码中是否包含了UNITY_LIGHTMODEL_AMBIENT
    3. 确保你的Pass的Tags里包含了"LightMode"="ForwardBase",因为环境光信息通常只在Base Pass中提供。

5.3 使用法线贴图后,光照方向看起来是错的

  • 检查:首先关闭法线贴图,使用顶点法线,看光照是否正确。如果正确,问题就出在切线空间计算或法线贴图采样上。
  • 解决
    1. 切线方向:确认模型是否有正确的切线信息。可以在3D建模软件中检查并重新计算切线。
    2. 副切线计算cross(worldNormal, worldTangent) * v.tangent.w中的v.tangent.w非常重要,它决定了副切线的方向(用于处理镜像UV)。通常为+1或-1。
    3. 法线贴图格式:在Unity中,将法线贴图纹理的“Texture Type”设置为“Normal map”,这样Unity会自动将其标记为sRGB(非线性)关闭,并使用正确的解码函数(UnpackNormal)。
    4. 可视化切线空间:可以分别输出worldTangent,worldBinormal,worldNormal来检查构建的矩阵是否正确。

5.4 着色器在移动设备上编译失败或运行缓慢

  • 检查:在Unity编辑器的Shader编译日志中查看是否有错误或警告。使用较简单的Shader进行对比测试。
  • 解决
    1. 精度:将不必要的float改为halffixed
    2. 指令数:简化数学运算。例如,1.0 / (distance * distance)pow(distance, -2.0)可能更高效。
    3. 纹理采样:减少纹理采样次数。如果不需要法线贴图,就提供一个纯蓝色的默认法线贴图或者使用Shader变体将其彻底关闭。
    4. 使用内置函数和宏:Unity提供了大量优化过的内置函数(如UnityObjectToWorldNormal,UnpackNormal)和宏,应优先使用它们。

5.5 光照效果与Unity内置Standard Shader差异很大

这是正常现象。Standard Shader是复杂的PBR着色器,包含了金属度、粗糙度、多种光照模型、全局光照、屏幕空间反射等大量特性。我们的自定义兰伯特着色器是一个极其简化的版本。

  • 对比目的:不应追求完全一致,而应理解差异来源。例如,Standard Shader的漫反射部分可能更复杂(考虑了能量守恒、次表面散射近似等),并且其环境光部分可能来自更复杂的全局光照系统。
  • 调试方法:可以尝试将Standard Shader的渲染模式设为“Opaque”,关闭所有光滑度、金属度、自发光等效果,只保留Albedo颜色,然后在相同的平行光环境下与我们的着色器对比。这样能更纯粹地比较漫反射部分的差异。

掌握这些排查技巧,意味着你不仅能实现功能,更能驾驭它。当出现问题时,系统性地检查数据(法线、光源方向、颜色值)、检查配置(Shader标签、纹理设置)、检查计算(每一步的输入输出),通过可视化中间变量来定位问题环节,这是图形编程的必备能力。