Unity光照Shader实战:从Blinn-Phong原理到URP自定义实现与优化
1. 项目概述:为什么Unity光照Shader是每个开发者必须啃下的硬骨头
如果你在Unity里做过3D项目,尤其是那些对画面有点追求的项目,那你一定绕不开“光照”和“Shader”这两个词。它们俩凑一块儿,就成了“光照Shader”——这玩意儿可以说是图形渲染的灵魂,也是新手最容易懵圈、老手最能秀肌肉的地方。我见过太多项目,模型建得挺精致,贴图画得也漂亮,但一打上光,要么死黑一片,要么塑料感十足,问题十有八九出在对光照Shader的理解和实现上。这不仅仅是让画面“变亮”,而是决定了物体如何与虚拟世界中的光线互动,从而呈现出质感、体积感和真实感。
简单来说,Unity光照Shader就是一套指令,告诉GPU:“当一束光(无论是太阳、灯泡还是火把)照到这个模型的某个像素上时,你应该如何计算它的最终颜色。” 这个计算过程,融合了物体的基础颜色(漫反射)、高光反射、环境光遮蔽、法线细节等一大堆信息。Unity为了方便我们,内置了像Standard(标准)、URP Lit(通用渲染管线光照)这样的现成Shader,它们功能强大,开箱即用。但当你需要实现某种独特的材质(比如丝绸的顺滑光泽、潮湿路面的菲涅尔反射、X光透视效果),或者进行极致的性能优化时,编写自己的光照Shader就成了必经之路。
这个过程适合谁呢?首先是那些不满足于内置材质球,想要创造独特视觉风格的TA(技术美术)和图形程序员。其次是对渲染性能有苛刻要求的移动端或VR/AR开发者,自定义Shader往往是优化Draw Call和填充率的关键。最后,即便是 gameplay 程序员,理解光照Shader的基本原理,也能让你更好地与美术沟通,更合理地设置场景灯光,避免出现“为什么我的角色在阴影里像块黑板?”这种尴尬。
2. 核心原理拆解:光照模型与渲染管线是如何协同工作的
在动手写代码之前,我们必须把脑袋里的概念理清楚。光照Shader不是玄学,它的背后是一套严密的数学模型和渲染流程。
2.1 光照模型:从物理到感知的简化
我们不需要完全模拟真实世界的光子行为(那是路径追踪的活儿),而是使用一些经典且高效的光照模型来“欺骗”眼睛。最核心的两个是兰伯特(Lambert)漫反射和冯氏(Phong)或Blinn-Phong高光反射。
漫反射模拟的是光线撞击粗糙表面后向各个方向均匀散射的现象。兰伯特模型的公式很简单:漫反射颜色 = 光源颜色 * 材质漫反射颜色 * max(0, dot(表面法线, 光源方向))。这里的dot是点积运算,它计算的是法线与光线方向夹角的余弦值。当光线垂直照射时(夹角0度,余弦为1),最亮;当光线平行于表面时(夹角90度,余弦为0),完全黑暗。这就是为什么球体在单一方向光下会呈现从亮到暗的平滑过渡。
注意:
max(0, ...)这个操作至关重要,它确保了背光面不会因为点积为负值而产生“负光”这种不符合物理的情况。这是很多新手自己实现时容易遗漏的细节。
高光反射模拟的是光滑表面上的亮斑(高光)。冯氏模型计算反射光方向与观察方向(从表面点到摄像机的向量)的夹角。Blinn-Phong是它的一个优化变体,它引入了一个“半角向量”(光线方向与观察方向的中间向量),然后计算法线与半角向量的点积。Blinn-Phong计算量更小,且高光区域在观察角度变化时表现更自然,因此在实际中(包括Unity内置Shader)使用更广泛。公式近似为:高光颜色 = 光源颜色 * 材质高光颜色 * pow(max(0, dot(法线, 半角向量)), 光泽度)。pow函数和光泽度参数共同控制高光点的大小和锐利程度,光泽度越高,高光点越小越集中。
现代基于物理的渲染(PBR)模型,如Unity的Standard Shader所使用的,比这些更复杂。它引入了金属度(Metallic)、光滑度(Smoothness)等参数,并使用更精确的微表面理论(如Cook-Torrance BRDF)来计算光照,使得材质在不同光照环境下能保持能量守恒(不会过亮或过暗),效果更加真实。但兰伯特和Blinn-Phong依然是理解所有复杂模型的基础。
2.2 Unity渲染管线中的Shader:从Built-in到URP/HDRP
Unity的渲染管线经历了重大演变,这对我们写Shader有直接影响。
内置渲染管线(Built-in)是Unity的传统管线,它提供了一套固定的渲染流程。在这个管线里,我们写的是Surface Shader或Vertex/Fragment Shader。Surface Shader是Unity提供的一种高级抽象,它帮你处理了多光源光照的复杂性,你只需要在surf函数中编写表面属性(如Albedo, Normal, Emission等),Unity会自动生成处理光照的顶点和片段着色器代码。这对于实现标准光照非常方便,但灵活性相对较低,生成的代码可能比较臃肿。
通用渲染管线(URP)和高清渲染管线(HDRP)是可编程渲染管线(SRP)的具体实现。它们允许你更精细地控制整个渲染流程。在URP中,传统的Surface Shader写法不再被推荐。取而代之的是,你编写的是URP Lit Shader或使用Shader Graph(可视化着色器编辑器)。
- URP Lit Shader:你需要继承一个预定义的
Lit着色器,并重写相关函数。URP使用一个名为UniversalFragmentPBR或UniversalFragmentBlinnPhong的核心函数来计算光照,这个函数在后台已经实现了PBR或Blinn-Phong模型。你的工作主要是准备正确的输入数据(如法线、基础色、金属度、粗糙度等)并传递给这个函数。 - Shader Graph:这是一个革命性的工具,它让你通过拖拽节点、连接线的方式来构建Shader,无需直接编写HLSL代码。对于实现复杂的光照效果(比如基于距离的渐变、纹理混合影响高光等),Shader Graph非常直观高效。它本质上是在帮你生成URP兼容的Shader代码。
选择哪种方式?如果你是图形编程新手,或者项目需要快速迭代各种视觉效果,Shader Graph是首选。如果你需要实现极其特殊、性能至上的定制化光照计算,或者想深入理解底层原理,那么直接编写URP Lit Shader代码是必要的。对于新项目,除非有特殊历史原因,否则强烈建议直接基于URP开始,它是Unity现在和未来的重点。
3. 实战:从零实现一个自定义的URP Blinn-Phong光照Shader
理论说再多不如动手写一遍。我们跳过复杂的PBR,先实现一个经典的Blinn-Phong光照模型,这能帮你建立起最清晰的认识。我们将使用URP的代码式Shader写法。
3.1 创建Shader与基本结构
首先,在Unity项目中创建一个新的Shader文件,命名为“CustomBlinnPhong.shader”。一个URP Shader的基本骨架如下:
Shader "Custom/CustomBlinnPhong" { Properties { // 材质面板上显示的属性 _BaseColor ("Base Color", Color) = (1,1,1,1) _BaseMap ("Base Map (RGB)", 2D) = "white" {} _SpecularColor ("Specular Color", Color) = (1,1,1,1) _Gloss ("Gloss", Range(8, 256)) = 20 _BumpMap ("Normal Map", 2D) = "bump" {} _BumpScale ("Bump Scale", Float) = 1.0 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _SHADOWS_SOFT #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // 定义与Properties块对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecularColor; float _Gloss; float _BumpScale; CBUFFER_END // 顶点着色器输入结构 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; }; // 顶点着色器输出/片段着色器输入结构(Varyings) struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float3 tangentWS : TEXCOORD3; float3 bitangentWS : TEXCOORD4; }; // 顶点着色器 Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS = positionInputs.positionCS; OUT.positionWS = positionInputs.positionWS; VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS, IN.tangentOS); OUT.normalWS = normalInputs.normalWS; OUT.tangentWS = normalInputs.tangentWS; OUT.bitangentWS = normalInputs.bitangentWS; OUT.uv = TRANSFORM_TEX(IN.texcoord, _BaseMap); return OUT; } // 片段(像素)着色器 half4 frag(Varyings IN) : SV_Target { // 光照计算将在这里进行 return half4(1,1,1,1); // 临时返回白色 } ENDHLSL } } }这个骨架包含了URP Shader的所有必要部分:属性定义、SubShader和Pass标签(指明这是URP管线下的前向渲染通道)、HLSL代码块、以及顶点和片段着色器的空壳。#include引入了URP的核心和光照库,它们提供了大量工具函数,如GetVertexPositionInputs和GetMainLight。
3.2 实现核心光照计算
现在,我们来填充最关键的部分——frag函数。我们将实现支持单盏主平行光(如Directional Light)的Blinn-Phong模型。
half4 frag(Varyings IN) : SV_Target { // 1. 采样纹理和颜色 half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 2. 构建切线空间到世界空间的矩阵,并应用法线贴图 float3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, IN.uv), _BumpScale); float3x3 TBN = float3x3(IN.tangentWS, IN.bitangentWS, IN.normalWS); float3 normalWS = normalize(mul(normalTS, TBN)); // 最终的世界空间法线 // 3. 获取主光源信息 Light mainLight = GetMainLight(TransformWorldToShadowCoord(IN.positionWS)); // 这个函数同时处理了阴影坐标转换 float3 lightDir = normalize(mainLight.direction); float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - IN.positionWS); float3 halfDir = normalize(lightDir + viewDir); // Blinn-Phong 半角向量 // 4. 计算漫反射 (Lambert) float NdotL = max(0, dot(normalWS, lightDir)); half3 diffuse = mainLight.color * albedo.rgb * NdotL; // 5. 计算高光反射 (Blinn-Phong) float NdotH = max(0, dot(normalWS, halfDir)); float specularPow = pow(NdotH, _Gloss); half3 specular = mainLight.color * _SpecularColor.rgb * specularPow; // 6. 环境光(简单模拟) half3 ambient = half3(unity_SHAr.w, unity_SHAg.w, unity_SHAb.w) * albedo.rgb * 0.1; // 7. 组合最终颜色(并加入阴影衰减) half3 finalColor = (diffuse + specular) * mainLight.shadowAttenuation + ambient; // 8. 处理附加光源(简化的逐顶点光照,性能更好) #ifdef _ADDITIONAL_LIGHTS uint pixelLightCount = GetAdditionalLightsCount(); for (uint lightIndex = 0; lightIndex < pixelLightCount; ++lightIndex) { Light addLight = GetAdditionalLight(lightIndex, IN.positionWS); float3 addLightDir = normalize(addLight.direction); float3 addHalfDir = normalize(addLightDir + viewDir); float add_NdotL = max(0, dot(normalWS, addLightDir)); float add_NdotH = max(0, dot(normalWS, addHalfDir)); float addSpecularPow = pow(add_NdotH, _Gloss); half3 addDiffuse = addLight.color * albedo.rgb * add_NdotL; half3 addSpecular = addLight.color * _SpecularColor.rgb * addSpecularPow; finalColor += (addDiffuse + addSpecular) * addLight.shadowAttenuation; } #endif return half4(finalColor, albedo.a); }让我们拆解一下这个函数的关键步骤:
- 纹理采样:获取模型表面的基础颜色。
- 法线变换:这是实现细节凹凸感的关键。我们从法线贴图(
_BumpMap)中读取切线空间下的法线向量,然后使用由切线(Tangent)、副切线(Bitangent)和法线(Normal)构成的TBN矩阵,将其转换到世界空间。UnpackNormalScale函数负责解码压缩的法线贴图纹理并应用强度缩放(_BumpScale)。 - 获取光源:
GetMainLight是URP光照库的核心函数,它返回一个Light结构体,包含了方向、颜色、距离衰减和阴影衰减。注意我们传入了TransformWorldToShadowCoord(IN.positionWS),这自动完成了世界空间位置到阴影贴图坐标的转换,为后续计算阴影做好了准备。 - 漫反射计算:经典的兰伯特模型,
NdotL就是法线与光线方向的点积。 - 高光计算:Blinn-Phong模型,使用法线与半角向量的点积
NdotH,并用pow函数和_Gloss参数控制高光形状。 - 环境光:这里用了一个简化的方式,采样了Unity球谐光照(SH)数据中的一个近似环境项。在实际项目中,你可能会使用天空盒或光照探针来提供更准确的环境光。
- 组合与阴影:将漫反射和高光相加,然后乘以
mainLight.shadowAttenuation。这个值在阴影内接近0,在完全受光处为1,在软阴影边缘介于两者之间。这是实现阴影最关键的一步,很多自定义Shader忘记乘这个因子,导致物体不受阴影影响。 - 附加光源循环:通过
#ifdef _ADDITIONAL_LIGHTS和GetAdditionalLight函数,我们遍历场景中影响该像素的其他实时光源(点光源、聚光灯),并以类似方式累加它们的光照贡献。注意这里为了性能,对附加光源没有使用半角向量重新计算,而是用了更简化的模型,在实际要求高的场景需要调整。
将这个完整的frag函数替换到之前的Shader骨架中,你就得到了一个功能完整的自定义Blinn-Phong光照Shader。把它赋给一个材质球,调整_Gloss(控制高光大小)和_SpecularColor,你就能看到明显的光照效果变化。
4. 进阶技巧与性能优化实战心得
实现基础功能只是第一步。要让Shader真正可用、好用,还得考虑大量细节和性能问题。下面是我在项目中积累的一些关键心得。
4.1 阴影接收与投射:让物体真正融入场景
我们上面实现的Shader可以接收阴影了(通过mainLight.shadowAttenuation),但它还不会投射阴影。要让其他物体在地板或墙上留下这个物体的阴影,需要额外的Pass。
在URP中,阴影投射通常由管线自动处理,但前提是你的Shader中必须包含一个ShadowCasterPass。最简单的方法是在你的SubShader里复制一个URP内置Lit Shader的ShadowCaster Pass。通常,你可以在项目的Packages/目录下找到Universal Render Pipeline/Shaders/中的Lit.shader,将其ShadowCasterPass复制到你的Shader中。这个Pass的代码相对固定,主要工作是使用物体的顶点位置和法线信息,将顶点变换到灯光裁剪空间,以生成深度图。
实操心得:对于绝大多数不透明物体,直接复用URP Lit的ShadowCaster Pass是完全可行且推荐的。除非你的物体有特殊的顶点动画(如草、布料),否则不需要自己重写。一个常见的坑是,自定义Shader如果没有ShadowCaster Pass,在移动端可能会因为深度写入问题导致奇怪的渲染错误,而不仅仅是没阴影。
4.2 Shader变体(Variant)管理与编译膨胀
这是Unity Shader开发中最隐蔽的性能陷阱之一。上面代码中的#pragma multi_compile指令,每一个都在告诉Unity:“请为这个Shader生成多种可能的版本(变体)”。例如,_MAIN_LIGHT_SHADOWS会生成有阴影和无阴影两个变体。多个multi_compile组合起来,变体数量会呈指数级增长。一个看似简单的Shader,最终可能有几十甚至上百个变体,导致:
- 构建时间变长:Unity需要编译所有变体。
- 包体增大:每个变体都是一份独立的Shader代码,会被打包进去。
- 运行时内存占用增加:GPU需要存储这些变体。
如何管理?
- 精确声明:只声明你确实需要的特性。例如,如果你的游戏全是室内场景,没有方向光阴影,可以考虑移除
_MAIN_LIGHT_SHADOWS_CASCADE。 - 使用
shader_feature替代multi_compile:shader_feature只会在材质球实际使用了某个关键字时,才编译对应的变体。而multi_compile会编译所有变体。将材质面板上的开关属性与shader_feature关联是更好的做法。 - 关注变体数量:在Unity编辑器中,点击Shader文件,在Inspector面板底部可以看到“Compiled code”和“Current variant count”。定期检查,如果发现变体数量异常多(比如超过100),就要回顾一下你的
#pragma指令了。 - 利用
#pragma skip_variants:可以跳过一些你确定用不到的变体组合。
4.3 针对移动平台(Android/iOS)的优化策略
移动设备的GPU带宽和算力有限,Shader优化至关重要。
- 精度优化:在移动平台上,尽量使用
half(半精度浮点数,16位)来代替float(全精度,32位)进行颜色和向量计算。对于位置坐标等需要高精度的数据,再用float。例如,上面代码中的albedo,diffuse,specular都可以声明为half3。 - 减少纹理采样:纹理采样是耗能大户。尽量避免在片段着色器中进行条件判断式的纹理采样。如果可能,将多个贴图(如金属度、粗糙度、环境光遮蔽)打包到一张贴图的RGBA不同通道中,这样一次采样就能获取多个参数。
- 简化光照计算:
- 对于附加光源,可以考虑使用更简化的模型,或者限制每个像素接受的光源数量(例如,只计算最近的两个附加光)。
- 考虑使用“顶点光照”(
PerVertex)模式来处理附加光,而不是在片段着色器中进行逐像素计算。这可以通过修改LightModeTag和光照计算位置来实现。
- 避免复杂数学运算:
pow,sin,cos等函数开销较大。对于pow操作,如果指数是固定的,可以尝试用几次乘法来近似。或者,对于一些视觉效果,可以用查找纹理(Lookup Texture, LUT)来替代实时计算。 - 利用GPU Instancing:对于大量使用相同材质和Shader的物体(如场景中的草、树、石子),开启GPU Instancing可以极大减少Draw Call。在Shader的
Properties块上方添加[PerRendererData]标签,并在Pass中启用#pragma multi_compile_instancing和#include相应的Instancing库文件即可。
4.4 调试与问题排查技巧
Shader出错时,屏幕可能一片粉红(Missing Shader)、一片黑或显示异常。如何定位问题?
- 使用Frame Debugger:Unity的Frame Debugger(窗口 -> 分析 -> Frame Debugger)是神器。它可以暂停游戏,让你一帧一帧地查看每个Draw Call的渲染状态,包括使用的Shader、Pass、渲染目标、纹理绑定等。如果某个物体没渲染出来,在这里看它是否被正确绘制,使用的Shader是否正确。
- 简化测试:当Shader效果不对时,创建一个最简化的版本。例如,在
frag函数中直接返回float4(IN.normalWS * 0.5 + 0.5, 1.0)来可视化法线,或者返回float4(IN.uv, 0, 1)来检查UV。这能快速判断是数据输入的问题,还是光照计算本身的问题。 - 检查控制台警告:Unity控制台会输出Shader编译警告和错误。例如,“variable ‘xxx’ used without having been completely initialized” 这类警告往往预示着严重的逻辑错误。
- 善用
clip()函数:在片段着色器中,你可以使用clip(value)。如果value小于0,当前像素就会被直接丢弃(变成完全透明)。你可以用这个来可视化某个中间计算值(比如clip(NdotL - 0.5)来只显示受光大于0.5的部分),辅助调试。 - 平台差异:在PC上运行正常的Shader,在手机上可能出问题。最常见的是精度问题(
half精度不足导致 banding 或颜色错误)和纹理压缩格式不支持。务必在真机上进行测试。
5. 从自定义代码走向Shader Graph:可视化光照艺术
对于不习惯直接写代码的美术同学或快速原型开发,Shader Graph是完美的解决方案。它的核心思想是“节点化编程”。
5.1 在Shader Graph中构建Blinn-Phong
创建一个新的Shader Graph,你会看到一个空白的画布。核心节点包括:
- PBR Master或Unlit Master:这是输出节点,定义了Shader的最终输出(颜色、法线、深度等)。对于光照,我们通常使用PBR Master,但为了演示,我们可以先用Unlit Master来手动连接我们的光照结果。
- Sample Texture 2D:纹理采样节点。
- Normal From Texture:从法线贴图生成法线向量。
- Transform Node:用于在不同空间(物体、世界、视图、切线)之间转换向量。
- Dot Product:点积计算节点。
- Power:幂运算节点(用于
pow)。 - Multiply/Add:乘法和加法节点。
构建流程在视觉上非常直观:
- 用
Sample Texture 2D节点采样基础色和法线贴图。 - 将法线贴图连接到
Normal From Texture节点,输出连接到Transform节点(从切线空间转到世界空间)。 - 使用
Position和Camera节点获取世界空间位置和摄像机位置,相减并归一化得到View Direction。 - 使用
Light节点(在URP中)获取主光源的方向和颜色。 - 将光源方向与观察方向相加并归一化,得到
Half Vector。 - 用法线分别与光源方向、半角向量做
Dot Product。 - 将法线与光源方向的点积结果(
NdotL)与光源颜色、基础色相乘,得到漫反射。 - 将法线与半角向量的点积结果(
NdotH)输入Power节点,指数参数用Gloss控制,然后与光源颜色、高光颜色相乘,得到高光。 - 将漫反射和高光相加,输出到
Unlit Master节点的Color端口。
这个过程就像搭积木,逻辑清晰可见。Shader Graph会自动帮你处理变体、渲染管线兼容性等底层细节,你只需要关注视觉效果本身。
5.2 Shader Graph的优势与局限
优势:
- 快速迭代:调整参数、连接节点立刻能在材质预览中看到效果,无需编译等待(或等待时间极短)。
- 易于协作:美术人员可以独立创建和调整复杂的Shader效果,降低了对程序员的依赖。
- 降低门槛:无需掌握HLSL语法,通过理解节点功能就能构建出强大的效果。
- 内置优化:Graph会自动进行一些优化,比如合并相同的计算节点。
局限:
- 性能黑盒:你无法精确控制生成的HLSL代码。有时Graph可能会生成不够优化的代码,对于性能临界点,仍需程序员介入检查或手写代码。
- 功能限制:一些非常底层的硬件特性或特殊的渲染技巧,可能无法通过现有节点实现,或者实现起来非常复杂。
- 变体管理:虽然Graph帮你管理了大部分,但对于超大型项目,变体爆炸问题依然存在,且通过Graph界面进行精细控制不如代码直接。
我的建议是:将Shader Graph作为首选工具,用于实现90%的视觉效果。将手写Shader作为备用方案,用于解决Graph无法实现的特殊需求、进行极限性能优化,或者作为学习底层原理的途径。两者结合,才是Unity Shader开发的正确姿势。
6. 常见问题与排查实录:那些年我踩过的坑
即使理解了原理,实际开发中还是会遇到各种稀奇古怪的问题。这里记录几个高频问题及其解决方案。
问题一:法线贴图看起来是“反的”或者效果很奇怪。
- 原因:法线贴图的绿色通道(G)方向可能不正确。Unity中,法线贴图通常被标记为“Normal map”类型,这会触发特殊的导入设置。如果导入设置不对,或者贴图本身是在其他软件(如Photoshop)中用不同约定生成的,就会出问题。
- 排查:在材质球上,将法线贴图临时替换为一个纯蓝色的贴图(RGB: 128, 128, 255)。这代表法线指向正Z方向(模型表面正前方)。如果此时光照效果是“平的”或凹陷的,说明法线基础方向就有问题。
- 解决:
- 在Unity Project面板中选中法线贴图,在Inspector中确保“Texture Type”为“Normal map”,并勾选“Create from Grayscale”(如果不是从高度图转换的话)。点击“Apply”。
- 在Shader中,使用正确的解压函数。对于标记为Normal map的贴图,应使用
UnpackNormal或UnpackNormalScale。对于RGBA格式的DXT5nm压缩法线贴图,Unity有特定的解压方式。 - 如果问题依旧,尝试在采样法线后,对y分量取反:
normalTS.g = 1.0 - normalTS.g;。这可以翻转切线空间下的Y轴方向。
问题二:物体在移动或旋转时,高光闪烁或抖动。
- 原因:最常见的原因是在顶点着色器中计算了依赖于视图或光源的方向向量,并传递给片段着色器。由于顶点着色器是逐顶点执行的,传递到片段着色器时会经过插值,导致方向向量不再是单位向量(长度不为1)。而
dot点积运算对向量长度很敏感。 - 解决:永远在片段着色器中进行归一化(normalize)。即使你在顶点着色器输出了归一化的向量,经过插值后它也不再是单位向量。正确的做法是:在顶点着色器中传递世界空间位置、法线、切线等“位置”信息,在片段着色器中,用插值后的位置重新计算
viewDir和lightDir,并进行归一化。对于halfDir,也应在片段着色器中计算并归一化。
问题三:自定义Shader的物体不参与后处理(如Bloom, Color Grading)。
- 原因:URP的后处理通常作用于摄像机的渲染目标。如果你的Shader的渲染路径、混合模式(Blend Mode)或渲染队列(Queue)设置不正确,可能导致物体没有被绘制到正确的渲染目标上。
- 排查:首先检查Shader的
Tags中的"LightMode"="UniversalForward",这是URP前向渲染的正确模式。其次,检查材质的渲染队列是否在透明物体队列(如“Transparent”)之前,大多数后处理是在所有不透明物体渲染完成后才应用的。 - 解决:确保Shader的Pass使用了正确的LightMode。对于不透明物体,使用
"RenderType"="Opaque"和Queue="Geometry"。如果确实需要特殊处理,可能需要编写一个专门用于后处理抓取的Pass,或者修改URP的Renderer Feature。
问题四:在VR(如Pico 4)或AR中,Shader效果不对或性能极差。
- 原因:VR/AR需要单帧渲染两次(左眼和右眼),对性能要求极高。一些在PC上可行的复杂计算在移动VR上可能成为瓶颈。此外,VR对镜面反射的准确性、抗锯齿等有更高要求。
- 优化方向:
- 大幅简化光照模型:考虑使用更轻量级的模型,甚至是对光照贴图(Lightmap)和光照探针(Light Probe)的依赖。
- 禁用或简化实时阴影:实时阴影在VR中开销巨大。可以考虑使用烘焙阴影或屏幕空间阴影(如果平台支持)。
- 使用VR专用的单通道立体渲染:URP支持单通道立体渲染(Single-Pass Instanced),这可以显著减少CPU开销。确保你的Shader兼容实例化(Instancing)。
- 谨慎使用高光:复杂的Blinn-Phong或PBR高光计算可以考虑用更简单的环境贴图反射(Cubemap)来近似。
- 测试,测试,再测试:必须在目标VR设备上进行性能剖析(Profiling),定位真正的瓶颈。Unity的XR插件通常提供了针对特定平台的优化工具和最佳实践文档。
光照Shader的实现是一个从理解物理原理,到掌握引擎工具,再到应对实际项目挑战的完整链条。它没有绝对的“标准答案”,只有针对特定场景、特定平台的“最优解”。从实现一个简单的Blinn-Phong开始,逐步深入到PBR、复杂的光照函数、性能优化技巧,这个过程本身就是对实时图形学最生动的学习。当你看到自己写的Shader让一个粗糙的模型在虚拟世界中呈现出逼真的金属、湿润的皮革或柔软的布料质感时,那种成就感是无可替代的。记住,多写,多调,多踩坑,每一次屏幕上的异常颜色,都是通往图形高手之路的宝贵经验。