☰
Unity ForwardBase光照Pass核心原理与实战解析
2026/10/15 4:02:28 网站建设 项目流程

1. 项目概述:为什么一个ForwardBase函数值得花一整篇来拆解?

在Unity Shader开发的实战现场,我见过太多人卡在同一个地方:改了几十遍光照计算,场景里物体就是不亮;或者明明写了法线贴图,模型表面却像一块没打磨的水泥板;更常见的是,换了个灯光类型,整个材质就彻底失色——不是过曝就是死黑。这些问题背后,十有八九不是贴图错了、法线反了,而是你根本没真正看懂Unity底层是怎么把“光”和“物”捏在一起的。而这个链条上最核心、最不可绕过的那个齿轮,就是ForwardBase Pass里的那个看似平淡无奇的frag函数。

“Unity StandardShader系列1——ForwardBase源码解析”,这个标题里没有炫技的词,没有新版本号,甚至没提URP或HDRP,但它直指Unity前向渲染(Forward Rendering)的命门。Standard Shader不是黑盒,它是一套被千锤百炼、经受过无数项目验证的工业级光照范式。而ForwardBase,正是这套范式中负责处理主方向光(Main Directional Light)+ 环境光(Ambient Light)+ 自发光(Emission)+ 全局光照探针(Light Probe)+ 反射探针(Reflection Probe)的第一道也是最重要的一道计算入口。它不是“其中一段代码”,它是整个光照管线的总控台——所有后续Pass(比如ForwardAdd处理点光/聚光)都建立在它的输出基础之上。

如果你正在用Built-in Render Pipeline做项目,无论是独立游戏、工业仿真还是建筑可视化,只要你的材质依赖Standard或基于它的自定义Shader,那么ForwardBase就是你必须亲手摸透的“地基”。它决定了你的模型在默认光照下是否可信,决定了美术给的PBR参数能否真实还原,也决定了你后续加自定义效果时,是事半功倍还是处处补丁。我带过的几个团队,新人上手Shader,第一个月的任务从来不是写新效果,而是把ForwardBase从头到尾手敲一遍、逐行注释、关掉每一项光源单独验证——三个月后,他们调试光照问题的速度比之前快了至少五倍。这不是玄学,是当你知道光子从哪里来、怎么打、打完怎么算,自然就不再靠“多调几个值试试看”。

这篇解析,不讲抽象理论,不堆数学公式,只带你钻进Unity官方发布的Standard.shader源码里,一行行看它怎么把_WorldSpaceLightPos0、_LightColor0、_LightDir这些宏和变量,变成屏幕上那一片真实的明暗过渡。你会看到,所谓“标准”,其实是无数个精妙取舍的结果:为什么环境光要分SH和直接光两路?为什么反射探针采样要用UNITY_SAMPLE_TEXCUBE_LOD而不是普通采样?为什么half3 diffuse = LightingLambert(...)这行调用背后藏着整整一个独立的光照函数文件?这些答案,不在文档里,而在你打开编辑器、定位到那几百行HLSL代码的瞬间。

2. 整体设计与思路拆解:ForwardBase不是“一个Pass”,而是一套协同作战的系统

2.1 它为何必须是第一个Pass?——渲染顺序即逻辑顺序

在Unity的Built-in管线中,ForwardBase Pass被强制标记为Tags { "LightMode" = "ForwardBase" },并且其RenderType被设为Opaque。这不是随意定的,而是由渲染队列(Render Queue)和光照Pass调度机制共同决定的硬性规则。简单说,ForwardBase必须是所有Opaque物体渲染流程中的第一个光照Pass,原因有三:

第一,数据初始化依赖。ForwardBase计算出的基础漫反射(diffuse)、高光(specular)和环境光(ambient)结果,会作为后续Pass(如ForwardAdd)的输入基础。比如,ForwardAdd处理额外点光源时,它叠加的不是原始颜色,而是叠加在ForwardBase输出的o.Albedo * o.Alpha之上的增量光照。如果ForwardBase没跑,后续Pass拿到的就是未定义的垃圾值。

第二,全局光照数据绑定。Unity的Light Probe和Reflection Probe数据,是在ForwardBase Pass中通过UnityGI结构体一次性打包传入的。这些数据是静态预计算的,占用显存且更新成本高,不可能每个Pass都重复绑定。ForwardBase作为首个光照Pass,承担了“一次加载、全局复用”的职责。你如果强行把Probe采样挪到ForwardAdd里,不仅性能暴跌,还会因采样坐标不一致导致光照闪烁。

第三,阴影投射锚点。主方向光(通常是太阳光)的阴影贴图(Shadow Map)采样,也是在ForwardBase中完成的。Unity通过SHADOW_COORDS(2)宏在顶点着色器中计算阴影坐标,并在片元着色器中用UNITY_TRANSFER_SHADOW和UNITY_APPLY_SHADOW完成深度比较。这个流程高度耦合于ForwardBase的顶点变换空间(从对象空间→世界空间→阴影裁剪空间),其他Pass若想用同一份阴影,必须复用这套坐标体系,否则阴影位置完全错乱。

提示:你可以用Frame Debugger(帧调试器)直观验证这一点。在任意Standard材质物体上触发渲染,展开其Draw Call列表,你会发现ForwardBase总是排在最前面,且其Shader Tag明确显示为“ForwardBase”。点开它,再点开“Shader Variables”,就能看到_WorldSpaceLightPos0、_LightColor0等关键变量已被正确注入——而ForwardAdd Pass里,这些变量要么为空,要么是另一组值。

2.2 它为何不叫“MainLight”而叫“ForwardBase”?——语义即架构

名字本身泄露了设计哲学。“ForwardBase”中的“Base”二字,精准概括了它的双重角色:既是基础光照(Basic Lighting)的提供者,也是前向渲染管线(Forward Pipeline)的基石。它不像Custom Pass那样可以随意增删,而是Unity Built-in管线协议的一部分,是引擎与Shader之间的一份契约。

这份契约规定了ForwardBase必须完成以下最小功能集:

  • 计算主方向光的Lambert漫反射 + Blinn-Phong高光
  • 混合环境光(含Light Probe的球谐函数SH系数)
  • 加入自发光(Emission)和基础Alpha混合
  • 采样并混合Reflection Probe(反射探针)
  • 输出最终的fixed4 frag(...) : SV_Target颜色

任何试图绕过ForwardBase、自己另起炉灶写“主光计算”的做法,在Built-in管线中都是危险的。因为Unity的光照系统(Lighting Window)、GI烘焙器、Probe更新器,全部默认ForwardBase是那个“权威计算者”。你写一个MyCustomBase,引擎不会自动把Probe数据塞给你,也不会帮你处理阴影,更不会在Lighting窗口里同步你的参数。这就是为什么,哪怕你要做一个极简的非PBR效果,最佳实践也是继承Standard Shader,然后重写ForwardBase里的LightingXXX函数,而不是另起一套。

2.3 它为何要拆成顶点+片元两段?——GPU并行计算的物理限制

ForwardBase的完整实现,横跨vert(顶点着色器)和frag(片元着色器)两个阶段,这不是为了代码好看,而是GPU硬件特性的刚性约束。

顶点着色器运行在每个顶点上,频率低(一个三角形3个顶点),但可执行复杂变换。ForwardBase的vert函数主要干三件事:

  1. 空间变换:将顶点从对象空间(Object Space)变换到世界空间(World Space),为后续光照计算提供统一坐标系;
  2. 法线变换:用UnityObjectToWorldNormal(v.normal)将法线从对象空间转到世界空间,并归一化(normalize),这是光照计算的前提;
  3. 插值准备:计算并输出供片元着色器插值使用的变量,如世界空间顶点位置(o.worldPos)、世界空间法线(o.worldNormal)、阴影坐标(o.shadowCoord)、以及Light Probe/Reflection Probe的采样坐标(o.probeHDR、o.reflUVW)。

而片元着色器运行在每个像素上,频率极高(一个三角形可能覆盖成百上千像素),但计算资源受限。ForwardBase的frag函数则专注在“每像素决策”:

  • 根据插值得到的世界法线和世界顶点位置,计算光照方向向量;
  • 执行Lambert点积(dot(worldNormal, lightDir))和Blinn-Phong高光计算(pow(dot(halfDir, worldNormal), _Gloss));
  • 采样Light Probe的球谐系数(ShadeSH9)和Reflection Probe的立方体贴图(UNITY_SAMPLE_TEXCUBE_LOD);
  • 将所有光照项(主光diffuse/specular + 环境光 + 自发光 + 反射)按物理意义加权混合。

这种分工,本质是把“一次计算、多次复用”的任务(如空间变换)放在顶点端,把“多次计算、每次不同”的任务(如像素级光照)放在片元端,最大限度榨取GPU并行能力。我曾见过有开发者把ShadeSH9调用直接写在vert里,结果发现模型边缘出现严重色块——因为球谐函数需要在片元级进行平滑插值,顶点级计算后线性插值会破坏其频域特性。

3. 核心细节解析与实操要点:从宏定义到数学本质

3.1#include "UnityCG.cginc"与#include "Lighting.cginc"——两座不可逾越的桥梁

ForwardBase的源码开头,必然有这两行#include。它们不是装饰,而是整个光照计算的基础设施。

UnityCG.cginc提供了Unity引擎层的基础工具集:

  • UnityObjectToWorldNormal(float3 v):将法线从对象空间转换到世界空间。它内部使用unity_WorldToObject的逆矩阵转置(即unity_WorldToObject的前三行前三列的转置),这是法线变换的正确方式(普通顶点用unity_ObjectToWorld,法线必须用其转置逆矩阵,否则缩放会导致法线长度失真)。
  • UnityObjectToWorldDir(float3 v):将方向向量(如切线、副切线)从对象空间转到世界空间,用的是纯旋转部分(忽略位移),避免方向被错误平移。
  • UnityWorldToClipPos(float4 v):世界空间到裁剪空间的最终变换,是vert函数里o.pos = UnityWorldToClipPos(o.worldPos)的核心。

而Lighting.cginc则是光照逻辑的中枢:

  • LightingLambert:标准Lambert漫反射函数,输入世界法线、光照方向、光照颜色,输出漫反射颜色。其内部实现就是saturate(dot(worldNormal, lightDir)) * _LightColor0.rgb,但封装后支持多光源叠加和Gamma校正开关。
  • LightingBlinnPhong:Blinn-Phong高光模型,输入世界法线、视角方向、光照方向、高光颜色、高光强度(_SpecColor, _Shininess),输出高光颜色。关键在于halfDir = normalize(lightDir + viewDir),这比Phong模型的reflect更高效且视觉更自然。
  • ShadeSH9:9阶球谐函数(Spherical Harmonics)环境光计算函数。它接收世界空间法线和一组12个float的SH系数(unity_SHAr,unity_SHAg,unity_SHAb等),输出一个half3环境光颜色。SH9能以极低存储(12个float)近似全方向环境光,是Light Probe的核心技术。

注意:ShadeSH9的参数顺序极易出错。它要求法线是单位向量,且SH系数必须是Unity GI系统烘焙生成的unity_SH*变量。如果你手动传入一组随机float,结果会是完全不可预测的噪点。实测中,我曾因忘记对o.worldNormal做normalize,导致ShadeSH9输出一片惨白——因为未归一化的法线点积结果超出[-1,1]范围,SH函数内部饱和处理失效。

3.2_WorldSpaceLightPos0与_LightColor0——主光的“身份证”与“能量包”

在ForwardBase的frag函数里,这两个内置变量是光照计算的起点:

  • _WorldSpaceLightPos0:这是一个float4。当它是方向光时,.xyz是世界空间中指向光源的方向向量(注意:是从物体指向光源,所以实际光照方向是-lightPos.xyz),.w为0.0;当它是点光或聚光时,.xyz是光源在世界空间的位置,.w为1.0。ForwardBase默认按方向光处理,所以代码中常见float3 lightDir = normalize(_WorldSpaceLightPos0.xyz)。
  • _LightColor0:这是一个half4,包含主光源的颜色(rgb)和强度(a)。它不是美术在Inspector里调的Light.color原始值,而是经过Unity光照系统处理后的最终值:已乘以光源强度(Light.intensity)、已应用颜色温度(Light.colorTemperature)、已考虑衰减(对点光/聚光)并被裁剪到合理范围。这意味着,你不需要、也不应该在Shader里再乘一遍_LightIntensity——它已经在那里了。

这里有个经典陷阱:很多新手以为_LightColor0.a是Alpha通道,想用来控制透明度。其实.a是光源的强度缩放因子(intensity multiplier),在方向光下恒为1.0,在点光下会随距离衰减而变小。把它用在Alpha混合上,会导致物体在强光下变透明、弱光下变不透明,完全违背设计意图。

3.3UNITY_TRANSFER_SHADOW与UNITY_APPLY_SHADOW——阴影不是“画上去”的,而是“比出来”的

ForwardBase的阴影实现,是Unity封装最深、也最容易误解的部分。它不涉及任何纹理采样代码,而是通过两个宏完成:

UNITY_TRANSFER_SHADOW(o):这是一个顶点着色器宏,作用是将顶点的世界空间位置o.worldPos,通过阴影投影矩阵(_LightMatrix0)变换到阴影贴图的UV坐标系,并存储在o.shadowCoord中。这个坐标是float4,其xy是标准化设备坐标(NDC)的uv,z是深度值,w是齐次除法的w分量。

UNITY_APPLY_SHADOW(i):这是一个片元着色器宏,作用是用i.shadowCoord对阴影贴图(_ShadowMapTexture)进行采样,并将采样得到的深度值与i.shadowCoord.z / i.shadowCoord.w(即当前像素的深度)进行比较。如果当前深度大于贴图深度,说明该像素被遮挡,返回0.0(全阴影);否则返回1.0(全亮)。最终结果是一个float,即阴影遮罩(shadow mask)。

关键点在于:阴影不是一张“黑色图片”贴在物体上,而是一个实时的深度比较过程。这也是为什么阴影边缘可以有柔和过渡(PCF滤波)——UNITY_APPLY_SHADOW内部会根据_ShadowBias参数,在采样时进行多点偏移和平均,模拟软阴影效果。如果你在frag里直接tex2D(_ShadowMapTexture, i.shadowCoord.xy),你得到的只是深度图本身,不是阴影。

实操心得:调试阴影时,不要先看最终颜色,而是把UNITY_APPLY_SHADOW(i)的结果直接输出为return fixed4(shadow, shadow, shadow, 1);。如果看到的是一片纯白,说明shadowCoord没传对(检查vert里是否漏了UNITY_TRANSFER_SHADOW);如果是一片纯黑,说明深度比较永远失败(检查_LightMatrix0是否为空,或光源是否开启了Cast Shadows);如果边缘锯齿严重,说明_ShadowBias太小,需要在Light组件里调大Bias值。

3.4UnityGI与UnityGIInput——全局光照的“数据快递员”

ForwardBase之所以能同时处理Direct Light(直接光)、Light Probe(光照探针)和Reflection Probe(反射探针),全靠UnityGI这个结构体。它不是一个函数,而是一个数据容器,在frag函数开始时被构造:

UnityGIInput d; d.light = light; d.worldPos = worldPos; d.worldViewDir = worldViewDir; d.atten = atten; d.halfDir = halfDir; UnityGI gI; UNITY_INITIALIZE_OUTPUT(UnityGI, gI); gI = UnityGlobalIllumination(d, o.Occlusion, o.Normal);

这段代码的含义是:

  • d是一个输入结构体,打包了当前像素的所有必要信息:光照数据(light,来自_LightColor0等)、世界位置、视线方向、衰减系数、半角向量;
  • o.Occlusion是环境光遮蔽(AO)值,通常来自Occlusion贴图或Shader Graph节点;
  • o.Normal是世界法线;
  • UnityGlobalIllumination是一个宏,它会根据编译宏(如ENABLE_LIGHT_PROBE_SH、ENABLE_REFLECTION_PROBES)自动选择调用UnityGI_Direct(仅直接光)、UnityGI_Indirect(间接光)或UnityGI_All(全部)。

UnityGI结构体最终输出三个关键字段:

  • gI.light: 直接光照(主方向光)的贡献,已包含阴影;
  • gI.indirect.diffuse: 间接漫反射(来自Light Probe的SH9计算);
  • gI.indirect.specular: 间接高光(来自Reflection Probe的立方体贴图采样)。

这才是ForwardBase真正“全局”的地方:它把Direct、Indirect、Diffuse、Specular全部揉在一个结构体里,让你在后续混合时,可以清晰地看到每一部分的权重。比如,half3 lighting = gI.light.diffuse + gI.indirect.diffuse + emission;这行代码,一眼就能看出最终漫反射=直接光漫反射+间接光漫反射+自发光。

4. 实操过程与核心环节实现:手把手还原ForwardBase的每一行

4.1 从零构建一个最小可行ForwardBase Pass

我们不直接修改Standard.shader(它太庞大),而是新建一个SimpleForwardBase.shader,只保留ForwardBase的核心骨架。以下是完整、可运行的代码(已去除所有无关分支,仅保留方向光路径):

Shader "Custom/SimpleForwardBase" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 _EmissionColor ("Emission Color", Color) = (0,0,0,1) } SubShader { Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" } LOD 200 Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #pragma multi_compile_fog #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; SHADOW_COORDS(3) // 为阴影坐标预留TEXCOORD3 UNITY_FOG_COORDS(4) }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; half _Glossiness; half _Metallic; fixed4 _EmissionColor; 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); // 为雾效传递坐标 UNITY_TRANSFER_FOG(o,o.pos); return o; } fixed4 frag (v2f i) : SV_Target { // 1. 基础采样 fixed4 albedo = tex2D(_MainTex, i.uv) * _Color; half3 worldNormal = normalize(i.worldNormal); half3 worldPos = i.worldPos; half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - worldPos); // 2. 主方向光计算 half3 lightDir = normalize(_WorldSpaceLightPos0.xyz); half3 lightColor = _LightColor0.rgb; // 3. 漫反射 (Lambert) half diff = max(0.0, dot(worldNormal, lightDir)); half3 diffuse = diff * lightColor; // 4. 高光 (Blinn-Phong) half3 halfDir = normalize(lightDir + viewDir); half spec = pow(max(0.0, dot(worldNormal, halfDir)), _Glossiness * 128.0); half3 specular = spec * _LightColor0.rgb * _Color.rgb; // 5. 环境光 (简化版,仅直接环境光) half3 ambient = ShadeSH9(half4(worldNormal, 1.0)); // 6. 自发光 half3 emission = _EmissionColor.rgb; // 7. 阴影 half shadow = SHADOW_ATTENUATION(i); // 8. 最终混合 half3 color = albedo.rgb * (diffuse + ambient) + specular + emission; color *= shadow; // 应用阴影 // 9. 雾效 UNITY_APPLY_FOG(i.fogCoord, color); return fixed4(color, albedo.a); } ENDCG } } FallBack "Diffuse" }

这段代码的关键实操点:

  • #pragma multi_compile_fwdbase:这是启用ForwardBase所有特性的开关。没有它,_WorldSpaceLightPos0等变量不会被注入,SHADOW_ATTENUATION宏也无法工作。
  • SHADOW_COORDS(3)与TRANSFER_SHADOW(o):SHADOW_COORDS(3)声明o.shadowCoord将占用TEXCOORD3,TRANSFER_SHADOW则负责填充它。数字3必须严格匹配,否则坐标错位。
  • ShadeSH9(half4(worldNormal, 1.0)):注意ShadeSH9的输入是half4,第四个分量必须是1.0,这是SH函数的约定。
  • shadow变量的应用位置:它必须在所有光照计算之后、最终color赋值之前乘上。如果提前乘在diffuse上,会导致环境光也被错误遮挡。

4.2 参数计算的物理意义与美术协作逻辑

ForwardBase里的每一个参数,都不是孤立的,而是与Unity编辑器中的美术设置一一映射:

  • _Glossiness(光滑度):对应Inspector中Material的Smoothness滑块。它的值范围是[0,1],但在Blinn-Phong计算中,pow(..., _Glossiness * 128.0)将其映射到[0,128]的指数范围。128.0是经验值,能让0.5的滑块对应中等锐利的高光。美术调0.2,意味着高光非常弥散(类似哑光塑料);调0.8,则高光尖锐集中(类似抛光金属)。

  • _Metallic(金属性):在Standard Shader中,它控制材质是介电质(Dielectric,如塑料、陶瓷)还是导体(Conductor,如金属)。金属性为0时,高光颜色=_LightColor0(白色高光);金属性为1时,高光颜色=albedo(金属本色高光)。但在我们的简化版中,暂未引入金属流,所以_Metallic未被使用。如果你想加入,需要替换specular计算为specular = spec * lerp(_LightColor0.rgb, albedo.rgb, _Metallic)。

  • _EmissionColor(自发光):它不参与任何光照计算,而是直接加到最终颜色上。美术用它来表现屏幕、霓虹灯、熔岩等自身发光的表面。注意,它不受阴影影响——一个自发光的物体,在阴影里依然会发亮,这符合物理直觉。

  • Occlusion(环境光遮蔽):虽然我们的简化版没用到,但在完整Standard中,o.Occlusion来自Occlusion贴图(R通道),值为0~1。它被乘在ambient上,让模型凹陷处(如缝隙、孔洞)环境光更暗,极大增强立体感。美术制作AO贴图时,就是在告诉Shader:“这里不容易被环境光照到”。

4.3 关键步骤的逐行调试技巧

调试ForwardBase,不能只看最终画面,要像外科医生一样,一层层剥离:

Step 1:验证基础几何与UV

  • 注释掉所有光照代码,只留return tex2D(_MainTex, i.uv);
  • 确保贴图正确拉伸,无扭曲、无翻转。如果出错,检查TRANSFORM_TEX(v.uv, _MainTex)和_MainTex_ST是否被正确传入。

Step 2:验证法线方向

  • 注释掉光照,只输出return fixed4(worldNormal * 0.5 + 0.5, 1);(归一化法线映射到[0,1]范围)
  • 正常应看到蓝(Z轴朝前)、红(X轴朝右)、绿(Y轴朝上)的渐变。如果全是黑,说明worldNormal为0,检查UnityObjectToWorldNormal是否被正确调用。

Step 3:验证主光方向

  • 注释掉环境光、阴影、高光,只留return fixed4(diff * lightColor, 1);
  • 旋转场景主方向光,观察模型明暗面是否随灯光移动。如果不动,检查_WorldSpaceLightPos0是否为0(可能是Light未开启或未设为主光源)。

Step 4:验证阴影

  • 如前所述,输出shadow值。正常应是0~1的灰度图,阴影区为黑,非阴影区为白。

Step 5:验证环境光

  • 关闭所有实时光源(把Directional Light的Intensity设为0),只留return fixed4(ambient, 1);
  • 此时模型应呈现均匀的、柔和的底色(由Light Probe决定)。如果一片死黑,说明Probe未烘焙或ShadeSH9输入错误。

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 “我的模型在ForwardBase里是黑的,但ForwardAdd里却有光!”——光照模式错配

现象:场景里只有一个Directional Light,模型完全不亮(纯黑),但当你添加一个Point Light后,模型上突然出现了点光源的高光,而主光区域依然死黑。

根因:LightMode标签错配。你的Shader Pass被错误地标记为"LightMode" = "ForwardAdd",而非"ForwardBase"。ForwardAdd只处理额外光源,它不接收_WorldSpaceLightPos0和_LightColor0,这两个变量在ForwardAdd里是未定义的(值为0),所以主光计算结果为0。

排查:

  • 在Shader Inspector里,点击“Compile and show code”,搜索LightMode,确认Pass的Tag确实是"ForwardBase"。
  • 在Frame Debugger中,确认该Draw Call的Shader Tag显示为ForwardBase,而不是ForwardAdd或Always。

修复:确保Pass的Tags块中,"LightMode" = "ForwardBase"是唯一且正确的。

5.2 “法线贴图一开,模型就闪烁、撕裂!”——TBN矩阵未归一化

现象:启用法线贴图后,模型表面出现高频噪点、条纹或随视角剧烈闪烁。

根因:法线贴图采样后得到的切线空间法线(tex2D(_BumpMap, i.uv).rgb * 2 - 1),在变换到世界空间时,其长度未被归一化。由于插值和纹理压缩,采样出的法线向量长度往往偏离1.0,当它被用于dot(worldNormal, lightDir)时,点积结果会超出[-1,1]范围,saturate函数将其钳制为0或1,造成块状色阶。

排查:

  • 临时将worldNormal输出为颜色:return fixed4(normalize(worldNormal) * 0.5 + 0.5, 1);。如果看到的不是平滑渐变,而是明显色块,说明法线向量长度不一。

修复:在frag函数中,对任何来自纹理的法线,必须立即归一化:

half3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv)); half3 worldNormal = normalize( tangentNormal.x * i.tangentWorld + tangentNormal.y * i.bitangentWorld + tangentNormal.z * i.normalWorld );

注意,这里i.tangentWorld等必须在vert中正确计算并插值。

5.3 “Light Probe的环境光颜色怪怪的,像蒙了一层灰!”——Gamma与Linear色彩空间混淆

现象:在Linear色彩空间项目中,Light Probe烘焙出的环境光显得过暗、发灰,缺乏活力。

根因:ShadeSH9函数返回的是Linear空间的颜色,但如果你的项目设置为Gamma空间,Unity会在最终输出时进行Gamma校正。然而,ShadeSH9的系数是为Linear空间烘焙的,直接在校正后的Gamma空间使用,会导致颜色失真。

排查:

  • 检查Edit > Project Settings > Player > Other Settings > Color Space,确认是Linear还是Gamma。
  • 如果是Linear,确保所有贴图(尤其是Light Probe的unity_SH*变量)都以Linear方式读取。

修复:在frag函数中,对ShadeSH9的结果进行手动Gamma校正(仅在Gamma空间项目中需要):

#if !defined(UNITY_COLORSPACE_GAMMA) half3 ambient = ShadeSH9(half4(worldNormal, 1.0)); #else half3 ambient = GammaToLinearSpace(ShadeSH9(half4(worldNormal, 1.0))); #endif

不过,强烈建议项目全程使用Linear色彩空间,这是现代渲染的标准。

5.4 “Reflection Probe采样结果是纯黑!”——探针未激活或坐标错误

现象:启用了Reflection Probe,也设置了Blend Distance,但模型反射始终是黑色。

根因:两个常见原因。一是Probe本身未被激活(Enable Box Projection未勾选,或Box Size为0);二是frag中采样坐标i.reflUVW未被正确计算和传递。

排查:

  • 在Scene视图中,选中Reflection Probe,确认其Box Size不为0,且Mode为Baked或Realtime。
  • 在vert函数中,检查是否调用了UNITY_TRANSFER_REFLECTION(i, worldPos),并在v2f结构体中声明了float3 reflUVW : TEXCOORD5;。

修复:确保v2f结构体中有float3 reflUVW : TEXCOORD5;,且vert中:

o.reflUVW = reflect(-viewDir, worldNormal); o.reflUVW = mul(unity_WorldToObject, float4(o.reflUVW, 0.0)).xyz;

然后在frag中:

half3 reflection = UNITY_SAMPLE_TEXCUBE_LOD(unity_ReflectionTex, i.reflUVW, 0).rgb;

5.5 “为什么我的自定义ForwardBase比Standard Shader还慢?”——过度计算与冗余分支

现象:替换了Standard Shader的ForwardBase,性能反而下降,GPU耗时增加。

根因:Standard Shader经过极致优化,而自定义版本常犯两个错误:一是做了不必要的计算(如在vert里计算viewDir),二是开启了大量编译分支(#pragma multi_compile过多)。

优化技巧:

  • 延迟计算:viewDir只需在frag中计算一次,不要在vert里算好再插值(插值会损失精度且浪费带宽)。
  • 精简宏:#pragma multi_compile_fwdbase已足够,不要额外加#pragma multi_compile __ LIGHTMAP_ON DYNAMICLIGHTMAP_ON,除非你真需要这些功能。
  • 合并操作:diffuse和specular的lightColor乘法,可以合并为lightColor * (diff + spec * _SpecColor.rgb),减少一次纹理采样。

我踩过的最大坑:在frag里反复调用UnityObjectToWorldNormal(v.normal)。这个函数内部有矩阵乘法,每帧每像素执行一次,开销巨大。正确做法是:在vert里计算一次,插值到frag,frag里只做normalize。

6.

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询