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不够那我就用三个方向。具体来说空间坐标选择我们不再使用模型的UV坐标(uv)而是使用其顶点在某个空间下的坐标。常用的是世界空间坐标(worldPos)或物体空间坐标(objectPos)。世界空间坐标的优点是不同物体可以无缝拼接比如地形的各个块物体空间坐标的优点是物体移动旋转时纹理会跟着“粘”在物体表面。本教程我们先从更通用的世界空间坐标开始。三向投影我们取这个空间坐标的(y, z)分量作为一套UV去采样X轴方向的投影纹理取(x, z)分量作为UV采样Y轴方向的投影纹理取(x, y)分量作为UV采样Z轴方向的投影纹理。你可以想象从正X、正Y、正Z三个方向分别把纹理像投影仪一样打到模型上。法线混合模型表面一个点的法线指示了这个点朝向哪个方向。如果法线主要朝向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 { RenderTypeOpaque } 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映射法线贴图要复杂一些因为从三个方向投影的法线贴图是在不同的切线空间下的我们需要将它们转换到统一的世界空间下再混合。步骤简述采样三向法线像采样颜色一样采样三个方向的法线贴图得到切线空间的法线向量需要从颜色值解码通常范围是[0,1]映射到[-1,1]。构建三个切线空间到世界空间的变换矩阵对于每个投影方向X, Y, Z我们需要知道其对应的切线Tangent和副切线Bitangent在世界空间中的方向。X投影切线方向为世界空间的Z轴副切线方向为世界空间的Y轴。Y投影切线方向为世界空间的X轴副切线方向为世界空间的Z轴。Z投影切线方向为世界空间的X轴副切线方向为世界空间的Y轴。法线方向就是投影轴的反方向因为UV平面是垂直于投影轴的。变换并混合将每个切线空间法线用对应的矩阵变换到世界空间然后使用相同的权重进行混合。归一化混合后的世界空间法线需要重新归一化然后用于光照计算。这是一个相对复杂的操作会显著增加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, uvZfloat2 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.01到0.1的范围开始尝试。_BlendSharpness地形通常需要比较平滑的过渡。设置在2.0到4.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采样一张噪声图得到每个片元的噪声值。这个噪声值可以作为颜色纹理的扰动DistortionUV偏移制造出扭曲的能量场表面。与时间变量结合驱动纹理UV的滚动模拟能量流动。因为TriPlanar基于世界坐标流动会自然地绕过物体的三维形状效果比简单的2D UV滚动要真实得多。作为溶解Dissolve效果的边缘阈值创造出从世界空间某个点开始逐步消散的效果。参数调优这类效果通常需要较高的_Tiling值来获得密集的噪声细节同时_BlendSharpness可以适当降低让噪声在物体表面过渡更柔和、更自然。6. 常见问题排查与调试技巧即使按照教程做了你可能还是会遇到一些奇怪的问题。这里记录了几个我踩过的坑和解决方法。6.1 问题纹理闪烁或出现“游泳”现象Texture Swimming现象当相机或物体移动时纹理看起来在表面上“滑动”或“游泳”而不是稳定地附着。原因这几乎总是因为使用了世界空间坐标且_Tiling值较大时由于世界坐标数值很大浮点数精度不足导致的。在片元着色器中i.worldPos的精度是有限的当它乘以一个较大的_Tiling值后微小的精度误差在采样时被放大导致纹理采样位置抖动。解决方案优先方案在顶点着色器中就对世界坐标进行缩放。将_Tiling乘法移到顶点着色器因为顶点着色器的计算频率远低于片元着色器且传递到片元着色器时经过插值能一定程度上平滑误差。// 顶点着色器中 o.worldPosForTex mul(unity_ObjectToWorld, v.vertex).xyz * _Tiling;使用相机相对坐标不直接使用绝对世界坐标而是使用相对于相机位置的坐标。这能保证在相机附近有较高的精度。float3 cameraRelativePos i.worldPos - _WorldSpaceCameraPos; float2 uvX cameraRelativePos.zy * _Tiling;切换到物体空间如果物体移动范围不大且不需要与其他物体纹理连续改用物体空间坐标是根除问题的方法。6.2 问题模型边缘或尖锐处出现颜色“溢出”现象在模型的边缘、棱角处本应是一种主导纹理却混合进了其他方向的纹理颜色使得边缘看起来模糊、脏污。原因_BlendSharpness值设置过低导致法线权重混合区域过宽。即使在法线几乎垂直于某个面的边缘其他方向的权重仍然有可观的值。解决方案逐步提高_BlendSharpness值直到边缘清晰。对于硬表面模型可以尝试6.0到8.0甚至更高。检查模型法线。有时低模在烘焙法线或导入时顶点法线信息不正确没有在硬边处断开即共享顶点的法线被平滑了这会导致在硬件层面法线就是平滑过渡的TriPlanar自然会混合。确保你的模型在需要硬边的地方正确地分割了顶点法线。6.3 问题性能开销比预期大很多现象使用了TriPlanar Shader后GPU帧时间明显上升。原因与排查采样次数首先确认你的Shader是否是基础版本3次采样。使用Unity的Frame Debugger或GPU Profiler工具查看Draw Call的片元着色器指令数。如果指令数异常高可能是没开启纹理压缩或者进行了复杂的逐像素计算。过度绘制TriPlanar常用于地形地形往往有较大的屏幕覆盖率和深度复杂度。确保使用了合理的LOD细节层次系统在远处使用简化版本的Shader甚至不用TriPlanar。分支判断如果你使用了基于权重的采样剔除优化在部分移动GPU上分支可能导致性能波动。可以尝试不用分支改用lerp和权重比较来模拟或者直接采样三次看哪种方式在目标平台上更快。纹理采样器确保你的纹理设置了正确的压缩格式如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进行切换。