ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

URP渲染管线中法线贴图的正确使用与实现详解

2026/8/4 17:32:11 拓冰建站 浏览量
URP渲染管线中法线贴图的正确使用与实现详解

1. 项目概述:为什么URP下的法线贴图值得单独拎出来讲

如果你是从Unity内置渲染管线或者标准渲染管线(Built-in/Standard)转过来的开发者,第一次在URP(Universal Render Pipeline)里用法线贴图,大概率会踩坑。最常见的情况就是,你从资源商店下载了一个精美的模型,拖进场景,材质球上法线贴图也贴好了,但模型表面看起来就是平的,或者光照反射的方向完全不对,感觉像是法线贴图“失效”了。这不是你的错觉,也不是Bug,而是URP为了追求更高的跨平台性能和更简洁的着色器架构,在底层做了一些“约定”,需要我们开发者去主动适配。

法线贴图是提升模型细节、用低模模拟高模表面凹凸质感的核心技术。在URP中,它的实现逻辑和标准管线有显著区别。标准管线里,你可能只需要在材质上勾选“Bump Mapping”或者拖入一张法线贴图就完事了,背后的切线空间转换、UnpackNormal等操作都被封装好了。但在URP里,这套“黑盒”被打开了。URP的Lit Shader Graph或者手写Shader,要求你显式地处理法线贴图的采样、解码以及从切线空间到世界空间的转换。这既是挑战,也是机会——你获得了对渲染流程更精细的控制权,但同时也必须理解每一步在做什么。

这篇教程就是来解决这个“断层”的。我会从一张法线贴图资源开始,带你完整走一遍在URP中让它正确生效的流程。无论你是使用URP自带的Lit材质球配合Shader Graph进行可视化连线,还是直接编写URP兼容的HLSL代码,核心原理都是一致的。我们会深入纹理导入设置、着色器中的采样解码、空间变换矩阵的构建这些细节,并解释每一步背后的数学和性能考量。目标是让你不仅能在项目里用上法线贴图,更能明白为什么这么做,以及当效果不对时,该从哪个环节去排查。

2. 核心原理与管线差异解析

2.1 法线贴图是什么:从高度到方向

首先我们得统一认知:法线贴图(Normal Map)存储的不是颜色或高度,而是法线向量。模型表面每个像素点都有一个垂直于该点切平面的向量,称为法线。光照计算(如漫反射、镜面反射)的核心就是光线方向与法线方向的夹角。默认情况下,一个低多边形模型顶点的法线是平滑插值的,表面看起来光滑但缺乏细节。

法线贴图的作用,就是用一个RGB纹理来“篡改”这个插值后的法线方向。通常,R通道对应法线向量的X分量(切线方向),G通道对应Y分量(副切线/副法线方向),B通道对应Z分量(法线方向)。由于法线是单位向量(长度为1),且Z分量通常为正,所以一张未经压缩的法线贴图看起来是偏蓝紫色的,因为(0.5, 0.5, 1.0)转换到RGB颜色空间就是淡蓝色。

这里有个关键点:纹理颜色值范围是[0, 1],而向量的分量范围是[-1, 1]。所以,在存储时需要进行一次映射:vector = (color * 2) - 1。反过来,从纹理中读取法线数据时,就需要进行解码:normal = (texel * 2) - 1。这个解码操作,就是URP中需要我们手动完成的第一步。

2.2 URP与Built-in管线的核心差异

在旧版Built-in管线中,当你为Standard Shader指定一张法线贴图时,Unity编辑器会自动帮你做很多事情:

  1. 纹理类型标记:它会建议你将纹理类型设置为“Normal map”。
  2. 平台压缩:根据目标平台(如PC用DXT5nm,移动端用ASTC),自动应用最适合法线向量的压缩格式。
  3. 着色器黑盒:在Shader内部,一个简单的UnpackNormal(tex2D(_BumpMap, uv))调用,就完成了采样、解码、以及可选的基于高度的细节混合等一系列操作。

URP的设计哲学是“轻量”与“明确”。它移除了许多自动化的、但可能带来性能开销或不够灵活的封装:

  1. 分离的职责:URP不假设你一定会用法线贴图。因此,你需要自己在Shader中声明纹理属性、采样器,并显式调用采样函数。
  2. 明确的空间转换:URP的很多光照函数(如LightingPhysicallyBased)期望输入的是世界空间法线。而法线贴图通常存储在切线空间。因此,你必须自己构建从切线空间到世界空间的变换矩阵(TBN矩阵),并用它来转换法线。
  3. 解码函数:URP提供了UnpackNormalUnpackNormalRGB等函数,但它们的使用方式更“原始”,你需要根据纹理的压缩格式来正确调用。

这种差异带来的直接好处是性能可控和灵活性。你可以为不同的材质选择不同的法线压缩格式,或者实现自定义的解包逻辑。但代价就是,入门门槛提高了,需要理解整个数据流。

注意:URP中有一个常见的误解是“法线贴图不工作”。其实90%的情况,问题都出在以下两点:一是纹理导入设置不对,导致Shader读到了错误的数据;二是忘记进行切线空间到世界空间的转换,直接把解码后的切线空间法线丢给了光照计算。

2.3 法线贴图的压缩格式与选择

纹理导入设置是第一个拦路虎。在Project窗口选中你的法线贴图,在Inspector面板中:

  • Texture Type:必须设置为Normal map。这个设置至关重要,它不仅仅是一个标签。当选择此类型时,Unity会:
    • 将纹理的sRGB(颜色空间)选项关闭。因为法线数据是线性数据,不是颜色信息,不需要进行伽马校正。
    • 提供一个Create from Grayscale的选项,可以从高度图(灰度图)生成法线贴图。
    • 最重要的是,它会根据你选择的平台,应用特定的、针对法线向量优化的压缩格式
  • 平台覆盖设置
    • PC, Mac & Linux Standalone:默认(并推荐)使用BC5 (DX11)格式(在Unity中显示为“BC5”或“DXT5nm”的变体)。BC5格式专门用于存储两个通道的数据(这里是X和Y),能非常高精度地保存法线的水平方向,Z分量通过计算z = sqrt(1 - x² - y²)推导得出。这是桌面平台质量和性能的最佳平衡。
    • Android/iOS:推荐使用ASTC 4x4ASTC 6x6块压缩。ASTC格式在移动端上对法线贴图的压缩质量很好。如果设备不支持ASTC,回退到ETC2格式(需要OpenGL ES 3.0或以上)。切勿在移动端使用未经压缩的RGB格式,这会极大增加内存带宽和功耗。
    • WebGL:通常使用DXT5(对于支持DXT的浏览器)或ETC2作为通用格式。

如果你在这里设置错误,比如将Texture Type留为Default,那么Shader中即使解码代码正确,读进来的向量值也是错的,因为压缩算法可能破坏了向量数据的连续性。

3. 在Shader Graph中实现法线贴图

对于大多数不直接写Shader的开发者,Shader Graph是URP中制作材质的主要工具。它的可视化连线方式,让法线贴图的实现过程变得非常清晰。

3.1 创建Lit Shader Graph与基础设置

首先,在Project窗口中右键 -> Create -> Shader Graph -> URP -> Lit Shader Graph。我将它命名为“URP_NormalMap_Example”。

双击打开Shader Graph编辑器。默认的Master Stack(主堆栈)已经包含了URP Lit所需的绝大部分输入。我们重点关注两个节点:Fragment Stage(片段阶段)中的Normal输入和Base Color输入。

  1. 添加法线贴图属性:在Blackboard(黑板)区域,点击“+”号添加一个属性。将属性类型设为Texture2D,名称改为_NormalMap。在它的节点设置里,将Reference设置为_NormalMap(这是Shader中对应的变量名)。这一步相当于在Shader代码中声明了sampler2D _NormalMap;
  2. 采样纹理:从Blackboard中将_NormalMap属性拖到工作区,会自动生成一个Sample Texture 2D节点与之连接。这个节点的UV输入,我们通常连接一个UV节点(选择模型的默认纹理坐标集,通常是UV0)。为了支持平铺和偏移,更常见的做法是使用Tiling And Offset节点,其UV输入连接UV节点,其Tiling和Offset输入则连接两个新的Vector2属性(_NormalMap_ST.xy_NormalMap_ST.zw),这样可以独立控制法线贴图的缩放和位移。

3.2 解码与空间转换:核心连线逻辑

这是最关键的一步。从Sample Texture 2D节点输出的RGBA颜色,需要经过解码和空间变换才能提供给Master Stack的Normal输入口。

  1. 解码法线数据:在Shader Graph中搜索Normal From Texture节点。这是一个功能强大的集成节点。将Sample Texture 2D节点的输出(RGBA)连接到它的Texture输入口。

    • Space:这里必须选择Tangent。这告诉节点,输入的纹理数据是切线空间下的法线。
    • 默认设置:保持Strength为1,如果希望法线效果更强或更弱,可以调整此值(原理是扰动XY分量,再重新归一化)。
    • 这个节点内部帮我们完成了UnpackNormal和解码操作。它的输出就是一个切线空间下的法线向量(Vector3)。
  2. 切线空间到世界空间的转换:Master Stack的Normal输入口期望的是世界空间法线。因此,我们需要将切线空间法线转换过去。

    • 搜索Transform节点。将它的In输入连接到Normal From Texture节点的输出。
    • 将Transform节点的From设置为Tangent SpaceTo设置为World Space
    • 这个节点需要额外的信息来构建TBN矩阵。它自动从顶点阶段获取了必要的TangentBitangent(或叫副切线)和Normal向量。这正是Shader Graph方便的地方,它隐藏了构建矩阵的复杂代码。
    • Transform节点的输出,就是最终我们需要的世界空间法线。
  3. 连接至Master Stack:将Transform节点的输出,直接连接到Master Stack上Fragment阶段的Normal输入口。

至此,一个最基本的、支持法线贴图的Lit Shader Graph就完成了。你可以将生成的Shader赋给一个材质球,然后将法线贴图拖入材质球的_NormalMap属性槽中。在场景中,你应该能看到模型表面根据法线贴图产生了正确的凹凸光照反应。

3.3 常见问题与Shader Graph调试

如果效果不对,比如模型全黑、全亮或凹凸方向相反,可以按以下步骤排查:

  • 问题一:模型看起来一片平坦,像没加法线贴图

    • 检查纹理导入设置:这是最常见的原因。务必确认法线贴图的Texture Type是“Normal map”。可以临时将压缩格式Override为“None”,看是否恢复正常,以排除压缩问题。
    • 检查UV连接:确保Sample Texture 2D节点的UV输入是正确的。可以连接一个Time节点做个简单的滚动动画,看纹理是否在动,以测试UV通路。
    • 检查Normal From Texture节点:确认Space选项是“Tangent”而不是“Object”或“World”。
  • 问题二:凹凸效果存在,但光照方向完全错误,该凹的地方凸起

    • 检查法线贴图绿色(G)通道:切线空间法线贴图的G通道控制着“上/下”方向。有时由于制作软件(如3ds Max, Maya)的坐标系(Y-Up还是Z-Up)与Unity(Y-Up)不同,会导致G通道需要反转。在Shader Graph中,你可以在Normal From Texture节点后接一个Multiply节点,乘以Vector3(1, -1, 1),来反转Y分量。更一劳永逸的方法是在纹理导入设置中,勾选Bump Map区域下的Flip Green Channel(翻转绿色通道)。
  • 问题三:模型接缝处出现难看的深色或浅色条纹

    • 这是切线空间法线贴图的固有缺陷。当模型的切线在接缝处不连续时(这是硬边的常见情况),转换到世界空间的法线也会不连续,导致光照断裂。
    • 解决方案:对于硬边很多的模型(如机械、建筑),考虑使用对象空间法线贴图。但这种贴图不能旋转模型,通用性差。在Shader Graph中,如果使用对象空间法线贴图,Normal From Texture节点的Space应选“Object”,并且不需要经过Transform节点,可以直接连接到Master Stack的Normal口(因为Master Stack的Normal口默认期望世界空间,而对象空间需要再乘上模型矩阵,这里情况较特殊,通常需要自定义处理)。更实用的方案是,在建模时尽量优化UV布局,减少不必要的硬边,或者接受一定程度的接缝瑕疵。

Shader Graph提供了强大的预览功能。你可以右键点击任何节点,选择“Create Preview Node”,将该节点的输出可视化到一个球体或平面上,这对于调试法线向量是否正确非常有用。

4. 手写URP HLSL Shader实现

对于需要极致性能控制或实现特殊效果(如视差映射、曲面细分置换)的情况,手写Shader是必须的。我们来拆解一个支持法线贴图的基础URP Surface Shader结构。

4.1 Shader结构与属性定义

首先,创建一个新的Unlit Shader文件,然后修改为URP Lit的基本框架。核心在于包含正确的HLSL文件和处理TBN矩阵。

Shader "Custom/URP NormalMap Lit" { Properties { _BaseMap ("Base Texture", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1, 1, 1, 1) // 法线贴图属性 _BumpMap ("Normal Map", 2D) = "bump" {} // “bump”是内置的淡蓝色纹理 _BumpScale ("Normal Scale", Float) = 1.0 // 控制法线强度 _Smoothness ("Smoothness", Range(0, 1)) = 0.5 _Metallic ("Metallic", Range(0, 1)) = 0.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_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _SHADOWS_SOFT // 包含URP核心库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl" // 定义与Properties块对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; // 包含纹理的平铺和偏移:xy是Tiling, zw是Offset half4 _BaseColor; half _BumpScale; half _Smoothness; half _Metallic; CBUFFER_END // 顶点输入结构体:必须包含切线信息 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; // 切线,.w分量有时会用来存储副切线方向 float2 texcoord : TEXCOORD0; }; // 顶点输出/片段输入结构体 struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 为了构建TBN矩阵,我们需要将法线、切线从物体空间变换到世界空间 half3 normalWS : TEXCOORD2; half3 tangentWS : TEXCOORD3; half3 bitangentWS : TEXCOORD4; };

这里的关键是Attributes中包含了TANGENT语义,以及Varyings中我们声明了tangentWSbitangentWS。副切线(Bitangent)不需要从顶点输入,它可以通过法线和切线的叉积计算得到:bitangent = cross(normal, tangent) * tangent.w。这里的tangent.w(通常为+1或-1)用于处理手性(镜像UV)。

4.2 顶点着色器:构建TBN矩阵的基础

顶点着色器的任务是将顶点位置变换到裁剪空间,并计算世界空间下的法线、切线和副切线。

Varyings vert(Attributes input) { Varyings output; // 顶点位置变换 VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); output.positionCS = vertexInput.positionCS; output.positionWS = vertexInput.positionWS; // 纹理坐标变换(应用平铺和偏移) output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); // 法线和切线从物体空间变换到世界空间 VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS); output.normalWS = normalInput.normalWS; output.tangentWS = normalInput.tangentWS; output.bitangentWS = normalInput.bitangentWS; return output; }

GetVertexNormalInputs是URP ShaderLibrary提供的一个非常方便的函数。它接收物体空间法线和切线(带.w分量),并返回世界空间下的法线、切线和副切线。这省去了我们手动乘以模型矩阵、处理旋转和缩放的麻烦。

4.3 片段着色器:采样、解码与光照计算

片段着色器是核心,在这里我们采样法线贴图,构建TBN矩阵,转换法线,然后进行PBR光照计算。

half4 frag(Varyings input) : SV_Target { // 1. 采样基础颜色 half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; // 2. 采样并解码法线贴图 half4 packedNormal = SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv); // 使用URP内置函数解码法线。它会根据纹理导入设置(如DXT5nm)进行正确的解码。 half3 tangentSpaceNormal = UnpackNormalScale(packedNormal, _BumpScale); // UnpackNormalScale 已经处理了_BumpScale(通过缩放xy,重算z)。 // 3. 构建切线空间到世界空间的旋转矩阵 (TBN矩阵) // 确保向量是正交且单位化的(虽然GetVertexNormalInputs通常已经处理了,但再归一化一次更安全) half3 normalWS = normalize(input.normalWS); half3 tangentWS = normalize(input.tangentWS); half3 bitangentWS = normalize(input.bitangentWS); // 构建3x3矩阵。这里将TBN向量的xyz作为矩阵的行(或列,取决于乘法顺序,关键是保持一致)。 // 常见的乘法顺序是:worldNormal = mul(tangentSpaceNormal, float3x3(tangentWS, bitangentWS, normalWS)); // 但更清晰的方式是显式写出: float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS); TBN = transpose(TBN); // 转置矩阵,因为我们构建时是行向量,但mul函数默认是列优先。 // 4. 将切线空间法线转换到世界空间 half3 worldNormal = normalize(mul(tangentSpaceNormal, TBN)); // 5. 准备表面数据,用于URP光照函数 InputData inputData; ZERO_INITIALIZE(InputData, inputData); // 清空结构体 inputData.positionWS = input.positionWS; inputData.normalWS = worldNormal; // 使用我们计算出的世界空间法线! inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS); inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS); // 阴影坐标 SurfaceData surfaceData; ZERO_INITIALIZE(SurfaceData, surfaceData); surfaceData.albedo = baseColor.rgb; surfaceData.metallic = _Metallic; surfaceData.smoothness = _Smoothness; surfaceData.alpha = baseColor.a; surfaceData.normalTS = tangentSpaceNormal; // 一些高级效果可能需要这个 // 6. 调用URP主光照函数 half4 color = UniversalFragmentPBR(inputData, surfaceData); return color; } ENDHLSL } } FallBack "Universal Render Pipeline/Lit" }

这段代码的要点:

  • UnpackNormalScale:这是URP中解码法线贴图的推荐函数。它自动处理了不同平台压缩格式(如DXT5nm)的解码,并且通过_BumpScale参数可以方便地调整法线强度。
  • TBN矩阵的构建与乘法顺序:这是最容易出错的地方。切线空间法线(x, y, z)分别对应切线、副切线、法线方向。因此,世界空间法线 =x * tangentWS + y * bitangentWS + z * normalWS。用矩阵乘法表示就是worldNormal = mul(tangentSpaceNormal, float3x3(tangentWS, bitangentWS, normalWS))。注意,这里tangentSpaceNormal是一个行向量,与由TBN基向量作为组成的矩阵相乘。在HLSL中,mul(行向量, 矩阵)正是这个含义。如果构建矩阵时把基向量作为列,则需要转置矩阵或改变乘法顺序。
  • 光照函数UniversalFragmentPBR是URP内置的基于物理的渲染函数。我们只需要正确准备好InputData(包含世界位置、世界法线、视线方向)和SurfaceData(包含反照率、金属度、粗糙度等),它就会为我们处理所有直接光、间接光、阴影的计算,非常方便。

4.4 手写Shader的调试技巧

手写Shader出错时,调试不如Shader Graph直观。以下是一些实用技巧:

  • 使用颜色输出调试:在return color;之前,可以临时用return half4(worldNormal * 0.5 + 0.5, 1.0);来可视化世界法线。正常的法线应该是一个彩色的、平滑变化的球体。如果出现大片纯色(如纯蓝),说明法线没有正确变化,可能是TBN矩阵构建错误或法线贴图未正确采样。
  • 逐步简化:如果效果不对,先注释掉法线贴图部分,直接使用input.normalWS作为光照法线,看基础光照是否正确。然后逐步加入法线采样、解码、转换,每一步都输出中间结果检查。
  • 检查变量名和语义:确保AttributesVaryings结构体中的语义(: POSITION, : NORMAL, : TANGENT)与模型导入时提供的数据匹配。模型必须有切线信息。
  • 注意空间一致性:确保所有向量在参与计算前都在同一个空间(通常是世界空间)。GetVertexNormalInputs返回的是世界空间向量,UnpackNormalScale返回的是切线空间向量,这是正确的。

5. 性能优化与高级应用

当法线贴图正确工作后,我们可以从性能和效果层面进行更深层次的优化和扩展。

5.1 性能优化要点

  1. 纹理压缩与Mipmaps:如前所述,为法线贴图选择正确的压缩格式是节省内存带宽的关键。同时,确保开启Mipmaps。对于小型或中距离物体,低Mip级别的法线贴图细节损失对视觉影响远小于颜色贴图,但能显著提升缓存命中率。
  2. 精度选择:在移动端或VR项目中,需要考虑精度。half类型(16位浮点数)对于法线向量和颜色计算通常足够,并且比float更快。在片段着色器中,将worldNormaltangentSpaceNormal等变量声明为half3
  3. 分支优化:避免在片段着色器中使用动态分支(if语句)来处理是否使用法线贴图。更好的做法是使用Shader变体。在Properties中定义一个_NORMAL_MAP开关,然后在CGPROGRAM/HLSLPROGRAM中使用#pragma shader_feature _NORMAL_MAP。这样,Unity会为启用和未启用法线贴图的情况分别编译两个版本的Shader,运行时根据材质设置调用对应的版本,没有分支开销。
  4. 避免不必要的计算:如果模型是静态的(不旋转缩放),且光照环境简单,可以考虑在预处理时将法线贴图转换到世界空间或对象空间,省去运行时每像素的矩阵乘法。但这会牺牲灵活性。

5.2 与细节贴图(Detail Normal Map)混合

为了在近距离观察时增加表面细节,通常会使用第二张细节法线贴图。混合两张法线贴图需要特别小心,因为直接线性混合两个法线向量会破坏其单位长度。

正确做法是:

  1. 分别解码两张切线空间法线贴图(normalA,normalB)。
  2. normalB的xy分量(细节)叠加到normalA的xy分量上。通常细节法线的强度(_DetailBumpScale)很小(如0.1-0.3)。
    half3 blendedNormal; blendedNormal.xy = normalA.xy * _BumpScale + normalB.xy * _DetailBumpScale; blendedNormal.z = sqrt(1 - saturate(dot(blendedNormal.xy, blendedNormal.xy))); // 重新计算Z分量以保证单位长度
  3. 使用混合后的blendedNormal进行后续的TBN变换。

在Shader Graph中,可以使用Blend Normals节点来完成这个操作,它内部实现了上述的重归一化逻辑。

5.3 视差映射(Parallax Mapping)与法线贴图结合

视差映射通过高度图在采样时偏移UV坐标,模拟简单的深度感,与法线贴图结合能极大增强立体感。实现要点:

  1. 高度图采样:需要一张灰度图作为高度图(Height Map)。
  2. UV偏移计算:在片段着色器中,根据视线方向在切线空间的分量(viewDirTS)和高度值,计算UV偏移量。基础版本是视差偏移映射(Parallax Offset Mapping):
    float height = SAMPLE_TEXTURE2D(_HeightMap, sampler_HeightMap, input.uv).r; float2 parallaxOffset = viewDirTS.xy * (height * _ParallaxStrength) / viewDirTS.z; float2 parallaxUV = input.uv - parallaxOffset; // 注意是减号,因为高度凸起的地方应该看到更“后面”的纹理
  3. 使用偏移后的UV:用parallaxUV去采样法线贴图和颜色贴图。注意,对于颜色贴图,可能还需要进行多次步进采样(Relief Parallax Mapping)来获得更精确的结果,但性能开销也更大。

视差映射能显著提升地面、砖墙等具有明显深度变化的表面的质感,是法线贴图技术的一个强力补充。

5.4 常见问题排查速查表

问题现象可能原因排查步骤
模型表面无凹凸感,像纯色1. 法线贴图纹理导入类型不是“Normal map”。
2. Shader中法线贴图采样或属性连接错误。
3. 法线向量未正确连接到光照计算。
1. 检查纹理导入设置。
2. 在Shader Graph中预览Normal From Texture节点输出;在手写Shader中返回解码后的法线值可视化。
3. 确认世界空间法线已传递给光照函数(如InputData.normalWS)。
凹凸方向完全相反法线贴图的绿色(G)通道方向与Unity预期相反。在纹理导入设置中勾选“Flip Green Channel”,或在Shader中将采样后的法线Y分量乘以-1。
接缝处有黑色/白色条纹模型切线在UV接缝处不连续。1. 检查模型UV拆分,避免不必要的硬边。
2. 考虑使用对象空间法线贴图(但限制多)。
3. 使用平滑组或顶点法线调整工具重新计算模型的切线。
移动设备上效果模糊或闪烁1. 法线贴图压缩格式不合适(如用了ETC1)。
2. Mipmaps导致细节丢失。
3. 精度不足(在低端机上使用float导致性能下降)。
1. 为Android/iOS覆盖设置为ASTC格式。
2. 调整纹理的Max Size或Aniso Level。
3. 在片段着色器中尽量使用half精度。
特定角度下凹凸消失或变平法线转换矩阵(TBN)构建错误,导致在某些视角下法线Z分量接近0。检查TBN矩阵的构建和乘法顺序。确保在片段着色器中对normalWS,tangentWS,bitangentWS进行了归一化(normalize)。
性能开销过大1. 法线贴图分辨率过高。
2. 使用了复杂的视差或曲面细分。
3. Shader中存在大量每像素计算。
1. 根据模型在屏幕上的大小,合理降低法线贴图分辨率(如从2K降到1K)。
2. 仅在需要的高视觉优先级物体上使用高级技术。
3. 将部分计算移到顶点着色器(如TBN矩阵计算,但会损失精度)。

掌握这些排查思路,能让你在遇到法线贴图相关问题时快速定位。归根结底,理解数据流——从纹理导入、到采样解码、再到空间变换——是解决一切问题的钥匙。URP虽然要求我们多做一些显式的工作,但这也让我们对渲染管线的控制力上了一个台阶,为实现更复杂、更个性化的视觉效果打下了坚实的基础。