Unity三平面映射(TriPlanar)技术详解:告别UV接缝,实现无缝纹理渲染
2026/7/23 10:46:39 网站建设 项目流程

1. 项目概述:为什么我们需要告别UV接缝?

如果你在Unity里做过地形、大世界或者任何需要无缝拼接纹理的模型,那你一定对“UV接缝”这个词深恶痛绝。想象一下,你精心雕刻了一个复杂的岩石模型,或者用程序生成了一片广袤的丘陵地带,当你把漂亮的岩石纹理贴上去时,总会在模型的边缘、接缝处看到一道丑陋的、颜色或纹路断裂的线。这就是UV接缝——由于模型UV坐标在接缝处不连续,导致纹理采样出现跳跃,破坏了视觉上的整体性。

传统的美术解决方案是花大量时间手动展UV,确保接缝藏在看不见的地方,或者使用高精度的纹理来减少接缝感。但对于程序化生成的内容、低多边形风格化模型,或者对性能有苛刻要求的移动端项目,这些方法要么不现实,要么成本太高。这时,TriPlanar Mapping(三平面映射)技术就成了我们的救星。它不依赖于模型自带的UV坐标,而是根据世界空间或物体空间的坐标,从三个轴向(X, Y, Z)分别投影纹理,然后根据表面法线进行混合,从而彻底消除接缝。这个技术在地形渲染、体素世界(如《我的世界》风格)、科幻场景中的能量墙等领域应用极广。

最近在和一些同行交流时,发现很多朋友虽然听说过TriPlanar,但要么被Shader代码吓退,要么实现的版本有性能或效果上的瑕疵。所以,我决定结合自己踩过的坑,写一份从原理到优化、从基础到进阶的“保姆级”实现教程。目标很简单:让你看完就能在自己的项目里用上稳定、高效且好看的三平面映射。

2. 核心原理拆解:三平面映射到底在做什么?

要理解TriPlanar,我们得先忘掉UV。UV的本质是一套2D坐标,告诉GPU在2D纹理的哪个位置取颜色。当模型表面复杂时,一套UV很难完美包裹整个3D表面而不产生拉伸或接缝。

TriPlanar的思路非常巧妙:既然一个方向(一套UV)不够,那我就用三个方向。具体来说:

  1. 空间坐标选择:我们不再使用模型的UV坐标(uv),而是使用其顶点在某个空间下的坐标。常用的是世界空间坐标(worldPos)或物体空间坐标(objectPos)。世界空间坐标的优点是不同物体可以无缝拼接(比如地形的各个块),物体空间坐标的优点是物体移动旋转时纹理会跟着“粘”在物体表面。本教程我们先从更通用的世界空间坐标开始。
  2. 三向投影:我们取这个空间坐标的(y, z)分量作为一套UV,去采样X轴方向的投影纹理;取(x, z)分量作为UV,采样Y轴方向的投影纹理;取(x, y)分量作为UV,采样Z轴方向的投影纹理。你可以想象从正X、正Y、正Z三个方向,分别把纹理像投影仪一样打到模型上。
  3. 法线混合:模型表面一个点的法线,指示了这个点朝向哪个方向。如果法线主要朝向X轴(比如(1, 0, 0)),那么这个点的颜色就应该几乎全部来自X轴的投影;如果法线朝向(0.7, 0.7, 0)这样一个斜方向,那么它的颜色就应该由X和Y两个方向的投影按权重混合。这个权重通常通过法线向量各分量的绝对值(abs(Normal))的幂次方来计算,以确保混合平滑且权重和为1。

这样,无论模型表面多复杂,在任意一点,我们都能从至少一个(通常是两个)投影方向获得良好的纹理采样,接缝问题自然就消失了。因为采样坐标是基于连续的空间位置计算的,而不是离散的UV。

注意:这里有一个关键选择,我们通常使用同一张纹理进行三个方向的投影,这样能保证风格统一。但高级用法中,你也可以为三个轴向使用不同的纹理,来实现更复杂的效果,比如侧面是苔藓、顶面是草地、底面是岩石。

3. 基础实现:手把手编写你的第一个TriPlanar Shader

理论说再多不如一行代码。我们直接在Unity中创建一个Unlit Shader文件,命名为TriPlanarBasic.shader,然后开始编写。

3.1 Shader属性与变量声明

首先,定义Shader的基本信息和可供材质球调节的属性。

Shader "Custom/TriPlanarBasic" { Properties { _MainTex ("Texture", 2D) = "white" {} // 用于投影的主纹理 _Tiling ("Tiling", Float) = 1.0 // 纹理平铺系数,控制纹理密度 _BlendSharpness ("Blend Sharpness", Range(0.5, 8)) = 4 // 混合锐度,控制过渡区域大小 } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 我们需要法线信息来进行混合 }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; // 将世界坐标传递给片元着色器 float3 worldNormal : TEXCOORD1; // 将世界法线传递给片元着色器 }; sampler2D _MainTex; float4 _MainTex_ST; // 虽然我们用不上_ST的偏移,但Unity会默认传递,声明以避免警告 float _Tiling; float _BlendSharpness;

这里有几个关键点:

  • _Tiling:因为我们是基于世界坐标采样,坐标值可能很大,直接采样会导致纹理被极度拉伸。通过_Tiling系数,我们可以缩放采样坐标,让纹理重复出现,形成平铺效果。
  • _BlendSharpness:这个参数控制法线权重计算的“锐度”。值越大,混合区域越窄,不同投影之间的界限越清晰;值越小(接近0.5),混合越平滑柔和。通常设置在3-5之间效果比较自然。
  • 我们通过v2f结构体将计算所需的世界坐标和世界法线从顶点着色器传递到片元着色器。

3.2 顶点着色器:准备数据

顶点着色器的任务很简单:转换顶点位置,并计算世界空间下的位置和法线。

v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); // 标准的位置变换 o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 计算世界坐标 o.worldNormal = UnityObjectToWorldNormal(v.normal); // 将法线从物体空间变换到世界空间,并归一化 return o; }

UnityObjectToWorldNormal函数内部已经处理了法线的归一化和非统一缩放的校正,比我们自己用模型矩阵的逆转置矩阵来乘法线更可靠,这是Unity提供的一个非常实用的内置函数。

3.3 片元着色器:核心的三平面采样与混合

这里是所有魔法发生的地方。我们一步步来拆解。

fixed4 frag (v2f i) : SV_Target { // 1. 对世界法线进行归一化,确保计算准确 float3 normal = normalize(i.worldNormal); // 2. 计算三个轴向的权重 // 取法线各分量的绝对值,并施加一个幂运算来控制混合锐度 float3 weights = pow(abs(normal), _BlendSharpness); // 确保权重归一化(总和为1),这是混合正确的关键 weights = weights / (weights.x + weights.y + weights.z); // 3. 准备采样坐标(基于世界坐标) // 使用_Tiling缩放世界坐标,实现纹理平铺 float2 uvX = i.worldPos.zy * _Tiling; // X轴投影,使用YZ平面 float2 uvY = i.worldPos.xz * _Tiling; // Y轴投影,使用XZ平面 float2 uvZ = i.worldPos.xy * _Tiling; // Z轴投影,使用XY平面 // 4. 三向采样 fixed4 colX = tex2D(_MainTex, uvX); fixed4 colY = tex2D(_MainTex, uvY); fixed4 colZ = tex2D(_MainTex, uvZ); // 5. 根据权重混合三个颜色 fixed4 finalColor = colX * weights.x + colY * weights.y + colZ * weights.z; return finalColor; } ENDCG } } FallBack "Diffuse" }

现在,将这个Shader赋给一个材质球,并拖到一个复杂的模型(比如一个默认的Sphere或自己导入的高模)上。旋转模型,你会看到纹理完美地包裹着模型,没有任何接缝!调整_Tiling可以控制纹理大小,调整_BlendSharpness可以观察混合区域的变化。

实操心得:在计算权重时,使用pow(abs(normal), _BlendSharpness)是一种常见且有效的方法。但这里有个细节,当_BlendSharpness为偶数时,pow函数对负数的处理可能会有平台差异。因为我们先取了绝对值abs(normal),所以避开了这个问题。更严谨的写法是pow(max(abs(normal), 0.0001), _BlendSharpness),加一个极小值防止出现0的幂次运算错误,不过在这个场景下,abs(normal)的分量很少恰好为0,所以问题不大。

4. 进阶优化与功能扩展

基础版本虽然能用,但在实际项目中往往不够。我们需要考虑性能、效果和灵活性。下面介绍几个关键的进阶点。

4.1 性能优化:减少纹理采样次数

上面的基础版本每个片元进行了3次tex2D采样。在移动端或复杂场景中,这会带来不小的开销。一个重要的优化是使用纹理图集纹理数组,但更直接的方法是利用导数指令进行自适应。

不过,对于TriPlanar,一个更简单且效果显著的优化是:基于权重提前剔除不必要的采样。如果某个方向的权重非常小(比如小于一个阈值),那么我们完全可以跳过对这个方向的采样,直接用0代替。这需要分支判断,在有些GPU架构上可能不划算,但对于权重差异很大的情况(比如一个近乎平面的表面),收益明显。

我们可以这样修改采样和混合部分:

// 设置一个权重阈值,例如0.1 #define WEIGHT_THRESHOLD 0.1 fixed4 finalColor = fixed4(0,0,0,0); float weightSum = 0; if (weights.x > WEIGHT_THRESHOLD) { fixed4 colX = tex2D(_MainTex, uvX); finalColor += colX * weights.x; weightSum += weights.x; } if (weights.y > WEIGHT_THRESHOLD) { fixed4 colY = tex2D(_MainTex, uvY); finalColor += colY * weights.y; weightSum += weights.y; } if (weights.z > WEIGHT_THRESHOLD) { fixed4 colZ = tex2D(_MainTex, uvZ); finalColor += colZ * weights.z; weightSum += weights.z; } // 重新归一化,因为可能剔除了部分权重 if (weightSum > 0) { finalColor /= weightSum; }

4.2 效果增强:添加法线贴图支持

只有颜色纹理看起来会很“平”。为了增加表面细节,我们必须支持法线贴图。TriPlanar映射法线贴图要复杂一些,因为从三个方向投影的法线贴图是在不同的切线空间下的,我们需要将它们转换到统一的世界空间下再混合。

步骤简述:

  1. 采样三向法线:像采样颜色一样,采样三个方向的法线贴图,得到切线空间的法线向量(需要从颜色值解码,通常范围是[0,1]映射到[-1,1])。
  2. 构建三个切线空间到世界空间的变换矩阵:对于每个投影方向(X, Y, Z),我们需要知道其对应的切线(Tangent)和副切线(Bitangent)在世界空间中的方向。
    • X投影:切线方向为世界空间的+Z轴,副切线方向为世界空间的+Y轴。
    • Y投影:切线方向为世界空间的+X轴,副切线方向为世界空间的+Z轴。
    • Z投影:切线方向为世界空间的+X轴,副切线方向为世界空间的+Y轴。
    • 法线方向就是投影轴的反方向(因为UV平面是垂直于投影轴的)。
  3. 变换并混合:将每个切线空间法线用对应的矩阵变换到世界空间,然后使用相同的权重进行混合。
  4. 归一化:混合后的世界空间法线需要重新归一化,然后用于光照计算。

这是一个相对复杂的操作,会显著增加Shader的指令数。在性能允许的情况下,它能带来质的提升。如果性能紧张,可以考虑只对颜色进行TriPlanar映射,而使用一套传统的、基于模型UV的细节法线贴图(Detail Normal Map)作为叠加,这是一种折中方案。

4.3 灵活控制:分通道独立平铺与偏移

有时我们希望不同轴向的纹理平铺密度不一样。比如在地形中,我们可能希望侧面的岩石纹理比顶面的草地纹理更密集。我们可以为每个轴向暴露独立的_Tiling_Offset参数。

在Properties中添加:

_TilingX ("Tiling X", Float) = 1.0 _TilingY ("Tiling Y", Float) = 1.0 _TilingZ ("Tiling Z", Float) = 1.0 // 同理可以添加OffsetX, OffsetY, OffsetZ

然后在片元着色器中,分别用不同的平铺系数计算uvX, uvY, uvZ:

float2 uvX = i.worldPos.zy * _TilingX; float2 uvY = i.worldPos.xz * _TilingY; float2 uvZ = i.worldPos.xy * _TilingZ;

这提供了极大的艺术控制自由度。

4.4 物体空间 vs 世界空间

我们一直用的是世界空间坐标。它的好处是全局一致,多个物体拼在一起时纹理是连续的,非常适合地形系统。但缺点也很明显:物体移动或旋转时,纹理会像“投影幻灯片”一样滑过物体表面,这对于一个需要随身携带纹理的物体(比如一个道具、一个角色)来说是不真实的。

物体空间坐标解决了这个问题。只需将顶点着色器中计算的o.worldPos替换为物体空间坐标即可:

o.objectPos = v.vertex.xyz; // 或者 mul(unity_WorldToObject, float4(i.worldPos, 1.0)).xyz 在片元着色器里算

使用物体空间坐标后,纹理会牢牢“粘”在物体上,随物体一起移动旋转。但代价是,两个相邻的物体无法在接缝处完美融合纹理。你需要根据项目需求做出选择。一个常见的混合方案是:对静态环境(地形、建筑)使用世界空间TriPlanar,对动态物体使用物体空间TriPlanar或传统UV映射。

5. 实战应用场景与参数调优指南

理解了原理和实现,我们来看看在具体项目中怎么用,以及如何调出最佳效果。

5.1 场景一:程序化生成地形

这是TriPlanar最经典的应用。你有一个由高度图或噪声函数生成的地形网格。

  • Shader选择:务必使用世界空间版本。确保整个地形的不同区块(Chunk)使用相同的_Tiling和纹理,这样区块边界就不会有接缝。
  • 纹理选择:使用一张高质量的、可无缝平铺(Tileable)的纹理。为了增加丰富度,可以采用纹理混合技术:实现两到三层的TriPlanar映射,用高度、坡度或噪声图作为遮罩来混合它们。例如,低处用泥土,高处用岩石,中间用草地。
  • 参数调优
    • _Tiling:根据地形规模和纹理分辨率调整。值太小纹理会模糊,太大则细节过多显得假。可以先从0.010.1的范围开始尝试。
    • _BlendSharpness:地形通常需要比较平滑的过渡。设置在2.04.0之间。值太低(如1.0)会导致各个方向的纹理过度混合,失去投影特征;值太高则会在坡度变化处产生生硬的环形纹路。
  • 性能考量:地形通常面数多,覆盖屏幕范围大。务必使用上面提到的权重阈值优化来减少不必要的采样。如果使用多层混合,每一层都是3次采样,开销会成倍增长,需要仔细权衡层数和效果。

5.2 场景二:体素化风格(Voxel Style)世界

像《我的世界》这样的游戏,每个方块都是一个独立的模型,用传统UV映射每个面非常麻烦。TriPlanar是绝配。

  • Shader选择:使用物体空间版本。因为每个方块是独立的实体,我们不关心方块之间的纹理连续性(实际上,方块间的接缝正是这种风格的视觉特征之一)。物体空间能保证纹理稳定地附着在方块上。
  • 特殊处理:体素世界的表面法线通常是严格对齐轴向的(如(1,0,0),(0,1,0))。这意味着权重向量中通常只有一个分量是1,其他是0。在这种情况下,混合计算几乎不消耗性能,因为只会采样一个方向。你可以将_BlendSharpness设得非常高(比如8),让混合区域极窄,模拟出硬边的方块感。
  • 扩展:可以为顶面、侧面、底面使用不同的纹理(_MainTexTop,_MainTexSide,_MainTexBottom),在采样时根据权重最大的方向选择对应的纹理,而不是混合同一张纹理。这能实现更丰富的视觉表现,比如草方块顶面是草地,侧面是泥土。

5.3 场景三:科幻能量场或全息效果

TriPlanar不仅可以映射颜色纹理,还可以映射噪声纹理(Noise)或流动图(Flow Map)来驱动一些特效。

  • 实现思路:使用世界空间TriPlanar采样一张噪声图,得到每个片元的噪声值。这个噪声值可以:
    1. 作为颜色纹理的扰动(Distortion)UV偏移,制造出扭曲的能量场表面。
    2. 与时间变量结合,驱动纹理UV的滚动,模拟能量流动。因为TriPlanar基于世界坐标,流动会自然地绕过物体的三维形状,效果比简单的2D UV滚动要真实得多。
    3. 作为溶解(Dissolve)效果的边缘阈值,创造出从世界空间某个点开始逐步消散的效果。
  • 参数调优:这类效果通常需要较高的_Tiling值来获得密集的噪声细节,同时_BlendSharpness可以适当降低,让噪声在物体表面过渡更柔和、更自然。

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

即使按照教程做了,你可能还是会遇到一些奇怪的问题。这里记录了几个我踩过的坑和解决方法。

6.1 问题:纹理闪烁或出现“游泳”现象(Texture Swimming)

现象:当相机或物体移动时,纹理看起来在表面上“滑动”或“游泳”,而不是稳定地附着。原因:这几乎总是因为使用了世界空间坐标,且_Tiling值较大时,由于世界坐标数值很大,浮点数精度不足导致的。在片元着色器中,i.worldPos的精度是有限的,当它乘以一个较大的_Tiling值后,微小的精度误差在采样时被放大,导致纹理采样位置抖动。解决方案

  1. 优先方案:在顶点着色器中就对世界坐标进行缩放。将_Tiling乘法移到顶点着色器,因为顶点着色器的计算频率远低于片元着色器,且传递到片元着色器时经过插值,能一定程度上平滑误差。
    // 顶点着色器中 o.worldPosForTex = mul(unity_ObjectToWorld, v.vertex).xyz * _Tiling;
  2. 使用相机相对坐标:不直接使用绝对世界坐标,而是使用相对于相机位置的坐标。这能保证在相机附近有较高的精度。
    float3 cameraRelativePos = i.worldPos - _WorldSpaceCameraPos; float2 uvX = cameraRelativePos.zy * _Tiling;
  3. 切换到物体空间:如果物体移动范围不大,且不需要与其他物体纹理连续,改用物体空间坐标是根除问题的方法。

6.2 问题:模型边缘或尖锐处出现颜色“溢出”

现象:在模型的边缘、棱角处,本应是一种主导纹理,却混合进了其他方向的纹理颜色,使得边缘看起来模糊、脏污。原因_BlendSharpness值设置过低,导致法线权重混合区域过宽。即使在法线几乎垂直于某个面的边缘,其他方向的权重仍然有可观的值。解决方案

  1. 逐步提高_BlendSharpness值,直到边缘清晰。对于硬表面模型,可以尝试6.08.0甚至更高。
  2. 检查模型法线。有时低模在烘焙法线或导入时,顶点法线信息不正确,没有在硬边处断开(即共享顶点的法线被平滑了),这会导致在硬件层面法线就是平滑过渡的,TriPlanar自然会混合。确保你的模型在需要硬边的地方正确地分割了顶点法线。

6.3 问题:性能开销比预期大很多

现象:使用了TriPlanar Shader后,GPU帧时间明显上升。原因与排查

  1. 采样次数:首先确认你的Shader是否是基础版本(3次采样)。使用Unity的Frame Debugger或GPU Profiler工具,查看Draw Call的片元着色器指令数。如果指令数异常高,可能是没开启纹理压缩,或者进行了复杂的逐像素计算。
  2. 过度绘制:TriPlanar常用于地形,地形往往有较大的屏幕覆盖率和深度复杂度。确保使用了合理的LOD(细节层次)系统,在远处使用简化版本的Shader甚至不用TriPlanar。
  3. 分支判断:如果你使用了基于权重的采样剔除优化,在部分移动GPU上,分支可能导致性能波动。可以尝试不用分支,改用lerp和权重比较来模拟,或者直接采样三次,看哪种方式在目标平台上更快。
  4. 纹理采样器:确保你的纹理设置了正确的压缩格式(如ASTC、ETC2)和Mipmaps。不合理的纹理设置会导致采样效率低下。

6.4 调试技巧:可视化权重

当混合效果不理想时,最直接的调试方法是把权重可视化出来。在片元着色器最后,你可以直接返回权重向量作为颜色:

// 调试:可视化X(红)、Y(绿)、Z(蓝)权重 // return fixed4(weights.x, weights.y, weights.z, 1);

这样你就能在场景中直观地看到每个轴向的投影在模型表面的影响区域。红色区域代表X投影主导,绿色代表Y投影,蓝色代表Z投影。这能帮你快速判断_BlendSharpness参数是否合适,以及模型法线是否正确。

最后,关于TriPlanar Shader的选用,我的个人体会是,它不是一个“银弹”,而是一个强大的工具。在静态环境、程序化内容、风格化渲染中,它能极大提升美术效果和开发效率。但在动态物体、需要极致性能的移动端,或者已经有精心绘制UV的高模上,传统UV映射可能仍是更合适的选择。理解其原理和代价,在合适的场景运用它,才是关键。在实际项目中,我通常会准备两个版本的Shader,一个完整功能版用于PC和主机,一个简化优化版(如只做颜色映射、固定混合锐度)用于移动端,通过Quality Settings进行切换。

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

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

立即咨询