Triplanar三平面映射:破解程序化地形UV拉伸的Shader方案
1. 三平面映射到底在解决什么痛点1.1 一个让人头疼的真实场景前阵子我在做某游戏项目的地形系统需要给一大片悬崖峭壁贴材质。普通模型上美术会预先拆好UV把一张岩石贴图映射到网格表面上。但悬崖这种地形网格往往是用程序化算法生成的顶点分布不规则坑坑洼洼的凹面和凸面交错在一起UV拆出来的效果惨不忍睹——纹理拉伸、接缝明显、重复感强正面看着还行侧面就直接糊成了马赛克。当时团队里有人提议把UV修一修但程序化生成的地形每次跑出来网格都不一样手动修UV根本不现实。后来我去翻了翻一些大厂的技术分享发现很多开放世界游戏的地形和岩石渲染都在用一种叫Triplanar Mapping三平面映射的方案。查了一圈资料又在自己的项目里实测了几个版本彻底把这块吃透了。这篇文章把我的完整思路、踩过的坑以及可以“抄作业”的代码都整理出来希望能帮到正在被UV问题折磨的读者。1.2 普通UV映射为什么会失效在讲三平面映射之前先理清普通UV映射的原理。通常美术在建模软件里为网格生成一套二维坐标叫UV坐标代表网格表面上的每个点在纹理贴图上的对应位置。渲染时GPU从每个像素的UV坐标去纹理图里取颜色再把颜色贴到三角形上。问题在于UV坐标是一张“平面展开图”而三维物体表面是凹凸不平、朝向各异的。平面展开图描述不了复杂曲面上发生的形变。想象把一张世界地图直接贴在乒乓球上表面褶皱的地方地图必然变形。我们项目里那些悬崖顶部区域和垂直面之间的过渡带形变最严重纹理被拉成了细长条看起来就像老电影里转场的镜头扭曲效果。更麻烦的是接缝。地形网格在程序化生成的时候是由大量Patch拼接而成的相邻Patch之间的拓扑和顶点分布可能完全不同。如果每个Patch各自展开UV接缝处就很难做到纹理连续。哪怕美术再细心地手动调整几百上千个Patch挨个调工期也耗不起。1.3 三平面映射的思路放弃UV三平面映射的思路非常直接不要UV了直接用世界坐标作为采样依据。具体来说它把三维空间中的一个点分别向三个坐标轴方向做投影得到三套二维坐标分别去采样纹理再根据表面法线的朝向把三次采样结果按权重混合起来。举个例子一个石壁上的点世界坐标是(x, y, z)。我们从X轴方向看把它投影到YZ平面上得到一个坐标(y, z)用这个坐标去采样岩石纹理从Y轴方向看投影到XZ平面得到(x, z)再采样一次从Z轴方向看投影到XY平面得到(x, y)再采样一次。三个采样结果都拿到后看这个点的法线朝哪个方向——朝上多一点那就多取Y方向投影的结果朝前往右多一些就多取X或Z方向的结果。最终混合在一起得到一个连续、没有明显拉伸的纹理效果。这个思路之所以能解决悬崖那种复杂网格的UV问题是因为它完全不依赖网格自身的拓扑和UV展开。只要世界坐标是连续的三个方向的投影就是连续的采样结果自然连续。不管网格多碎、多扭曲只要顶点在空间中的位置没变采样的效果就不会乱。对于程序化生成的地形、洞穴、桥梁等场景这个特性简直是福音。2. 从零搭起一个可用的Triplanar Shader2.1 Shader整体结构与属性声明理论说清楚了接下来直接进入实战。我先写一个最基础的Triplanar Shader保证在Unity里能跑、效果直观可见后续再在这个基础上加高级功能。Shader用的是Unity的ShaderLab语法渲染管线以内置管线Built-in Render Pipeline为例对URP的适配我后面会单独说。Shader Custom/TriplanarBasic { Properties { _MainTex (Albedo (RGB), 2D) white {} _Tiling (Tiling, Float) 1.0 _Sharpness (Sharpness, Range(1, 16)) 4.0 _Color (Color Tint, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf Lambert vertex:vert #pragma target 3.0 sampler2D _MainTex; float _Tiling; float _Sharpness; fixed4 _Color; struct Input { float3 worldPos; float3 worldNormal; }; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input,o); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); } void surf (Input IN, inout SurfaceOutput o) { // 计算三个方向投影的UV坐标 float2 uvX IN.worldPos.zy * _Tiling; // X轴方向投影取ZY平面 float2 uvY IN.worldPos.xz * _Tiling; // Y轴方向投影取XZ平面 float2 uvZ IN.worldPos.xy * _Tiling; // Z轴方向投影取XY平面 // 采样三次 fixed3 colX tex2D(_MainTex, uvX).rgb; fixed3 colY tex2D(_MainTex, uvY).rgb; fixed3 colZ tex2D(_MainTex, uvZ).rgb; // 根据世界法线计算混合权重 float3 weights pow(abs(IN.worldNormal), _Sharpness); weights / (weights.x weights.y weights.z); // 最终颜色 三次采样结果按权重加权混合 fixed3 col colX * weights.x colY * weights.y colZ * weights.z; col * _Color.rgb; o.Albedo col; o.Alpha 1.0; } ENDCG } FallBack Diffuse }这段代码是整篇文章的地基我逐个部分拆开讲。属性栏里四个参数_MainTex基础纹理贴墙、贴地用的主纹理_Tiling纹理在世界空间里的重复尺寸。数值越大单位面积内的纹理重复次数越多相当于“纹理缩放”_Sharpness混合权重锐度控制三个方向过渡带的宽窄_Color整体颜色Tint用来调节明暗Input结构体里声明了worldPos和worldNormal。我在顶点着色器vert里手动计算了它们——unity_ObjectToWorld把模型局部顶点坐标转到世界坐标UnityObjectToWorldNormal把模型法线转到世界空间。这两步很关键稍不注意法线方向算错了混合权重就会完全错乱。2.2 投影坐标的计算逻辑片元着色器里最核心的就是三个投影UV的计算X轴方向投影取世界坐标的zy分量Y轴方向投影取世界坐标的xz分量Z轴方向投影取世界坐标的xy分量为什么X方向投影取的是zy而不是yz从X轴正方向看过去能看到的平面是由Y和Z轴张成的所以X方向投影对应的坐标是( y, z )。这个顺序我一开始也搞反过当时出来的纹理方向全错了后来对着世界方向反复确认才调过来。_Tiling直接影响纹理密度。比如_Tiling设为1世界空间中每1个单位长度对应纹理的一个重复周期设为4每0.25个单位长度就重复一次。这个参数在实际项目里要按纹理内容微调比如岩石纹理细节多可以设大一点大面积的草地设小一号显得比较自然。2.3 权重混合公式的推导与验证混合权重这步是整段着色器的灵魂。先取世界法线的绝对值——注意是绝对值因为法线朝向负方向时它仍然指向那个投影平面我们关注的是角度差而不是方向。然后做幂运算float3 weights pow(abs(IN.worldNormal), _Sharpness); weights / (weights.x weights.y weights.z);为什么要做幂运算直接用法线分量做权重行不行比如法线是(0.7, 0.7, 0.0)直接归一化后权重各占大约一半在45度斜面上X和Y两个方向的采样结果各自贡献一半混合区会显得很宽纹理边缘发虚。用幂次把权重“压”向主方向法线分量大的方向权重被放大分量小的方向被压得更小。_Sharpness越大过渡区越窄三个方向的边界越锐利_Sharpness越小过渡越柔和。这一步还需要归一化保证三个分量加起来等于1混合结果不会整体偏亮或偏暗。验证一下一个完全朝上的平面世界法线是(0, 1, 0)abs后是(0, 1, 0)pow之后还是(0, 1, 0)归一化后权重是(0, 1, 0)——完全使用Y方向投影的结果即从顶上看下来的纹理。这符合直觉因为朝上的平面就应该用从顶向下的投影。朝左的平面法线(1, 0, 0)权重就是(1, 0, 0)完全用X方向投影。只有在斜面上才会有多个方向的投影混合。注意这里的worldNormal必须归一化否则abs和pow做完以后权重归一化之前的结果会包含长度信息最终颜色可能偏暗偏亮。我用UnityObjectToWorldNormal已经把法线归一化了一次但有些第三方代码里法线可能没有归一化出问题先检查这一行。3. 混合细节与参数调优的实战经验3.1 Sharpness到底调到多少合适我在项目里反复调过_Sharpness总结出来的经验是常规地形和岩石4到8之间效果最好。小于2的时候过渡带太宽峡谷、悬崖边缘会有明显的“雾蒙蒙”感纹理像蒙了一层半透明的纱细节全丢。大于16以后过渡带窄到几乎只有几个像素效果反而像直接把表面切成了三个硬边界斜坡拐弯处会出现比较生硬的色彩断层。但这也不是绝对值要看你的网格尺度和纹理内容。我在测试场景里放了一个半径5米的圆球和一个100米高的悬崖做对比球体表面曲率变化快_Sharpness调8到10才舒服悬崖大平面过渡缓和4就够。实操建议是从4开始渲染出来看地面转45度角的地方有没有明显糊边再小幅增减。3.2 Tiling的坑纹理密度变化引发的问题_Tiling越大纹理越小越密远处看会有一种类似摩尔纹的闪烁感尤其在相机移动的时候非常明显。这在高清纹理上尤其严重因为纹理本身细节多频率高投影到远处像素的时候采样率不够就会产生混叠。项目里遇到过这样的场景某段悬崖的陡坡从上往下看纹理很密侧面看又感觉变稀疏了。原因是陡坡在世界空间里按单位长度重复纹理但当坡面在屏幕上投影面积变小的时候视觉上的纹理密度没变可实际像素覆盖的纹理跨度在变大观感就失衡了。常用的缓解方案有几种。一种是在顶点着色器里根据片元距相机的距离动态调整_Tiling——但片元着色器已经拿到世界坐标了在surf函数里做也行只是要额外传一个相机参数。另一种更干脆给纹理加一层很细微的噪点混合打断摩尔纹的规律性但要小心别把整体亮度带偏。我后来更推荐用高质量的纹理过滤方式配合生成mipmap能解决大部分闪烁问题。3.3 用三张纹理混合还是用一张纹理的多个通道基础的Triplanar Shader只用了一张纹理。实际项目里岩石和地面往往需要更丰富的混合效果比如岩石上覆盖一层薄薄的苔藓或者泥土和沙地过渡。很多项目会准备多张纹理分别作为基底、细节层、湿滑层然后通过世界高度、坡度、噪声图来实时控制混合比例。我做过一个版本用三张纹理分别放在RGB通道里R通道存苔藓G通道存岩石肌理B通道存泥土。然后在片元着色器里对每张纹理做Triplanar采样再根据高度和法线方向混合。效果比单纹理直接丰富很多但代价是三倍多的采样次数性能开销直接上去。移动端跑这个Shader帧率明显下滑后来我改用一张纹理集Texture Array把三张纹理打包采样次数没变但通过动态索引减少了带宽压力。3.4 法线贴图遇上Triplanar一个容易翻车的组合基础Albedo的Triplanar很好写一旦要加法线贴图坑就来了。法线贴图的方向是相对于局部坐标系的Triplanar采样时纹理方向被扭曲了如果还按原始的法线贴图方式直接作用物体表面光照会很怪异——明明是凹陷的地方凸起来凸起的地方反而凹陷。我踩过这个坑之后查了业界通用的处理方式对三方向采样分别构建对应的切线空间变换矩阵把每个方向采样的法线变换回世界空间再合并。这是一个比较大的话题简化的做法是分别在X、Y、Z方向采样法线贴图得到三个切线空间的法线值用对应的投影轴方向构建一个3x3旋转矩阵把切线空间的法线转换到世界空间三个世界空间法线按Albedo的同一套权重混合代码量大约会增加一倍但效果非常明显岩石表面的立体感完全不一样了。这个版本我放在后面“进阶玩法”小节里详细展开。4. 进阶玩法从地表到洞穴的完整方案4.1 支持法线贴图的完整Triplanar Shader上文提到法线贴图需要单独处理这里贴出完整版本。这个版本我实测在Unity 2021和2022的内置管线下都能正常渲染关键点在注释里标注清楚。Shader Custom/TriplanarNormal { Properties { _MainTex (Albedo (RGB), 2D) white {} _BumpMap (Normal Map, 2D) bump {} _Tiling (Tiling, Float) 1.0 _Sharpness (Sharpness, Range(1, 16)) 8.0 _Color (Color Tint, Color) (1, 1, 1, 1) _NormalStrength (Normal Strength, Range(0, 2)) 1.0 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf Lambert vertex:vert #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; float _Tiling; float _Sharpness; fixed4 _Color; float _NormalStrength; struct Input { float3 worldPos; float3 worldNormal; INTERNAL_DATA }; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input,o); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); } // 将切线空间法线通过指定的投影轴变换到世界空间 float3 WorldNormalFromTangent (float3 tangentNormal, float3 axis) { // 构建正交化基向量 float3 T normalize(cross(axis, float3(0, 1, 0))); // 如果axis接近Y轴cross会有问题需要换一个参考向量 if (abs(axis.y) 0.999) T normalize(cross(axis, float3(1, 0, 0))); float3 B cross(axis, T); // tangentNormal是xy分量在切线空间里的法线 float3 worldNormal tangentNormal.x * T tangentNormal.y * B tangentNormal.z * axis; return normalize(worldNormal); } void surf (Input IN, inout SurfaceOutput o) { float2 uvX IN.worldPos.zy * _Tiling; float2 uvY IN.worldPos.xz * _Tiling; float2 uvZ IN.worldPos.xy * _Tiling; // Albedo三次采样 fixed3 colX tex2D(_MainTex, uvX).rgb; fixed3 colY tex2D(_MainTex, uvY).rgb; fixed3 colZ tex2D(_MainTex, uvZ).rgb; // 法线贴图三次采样 fixed3 nX UnpackNormal(tex2D(_BumpMap, uvX)); fixed3 nY UnpackNormal(tex2D(_BumpMap, uvY)); fixed3 nZ UnpackNormal(tex2D(_BumpMap, uvZ)); // 权重计算 float3 weights pow(abs(IN.worldNormal), _Sharpness); weights / (weights.x weights.y weights.z); // 权重平滑可选避免某些角度导致个别权重为0 float3 blend smoothstep(0.2, 0.8, weights); // 混合Albedo fixed3 albedo colX * blend.x colY * blend.y colZ * blend.z; albedo * _Color.rgb; // 混合法线 float3 nrmWorld float3(0,0,0); nrmWorld WorldNormalFromTangent(nX, float3(1, 0, 0)) * blend.x; nrmWorld WorldNormalFromTangent(nY, float3(0, 1, 0)) * blend.y; nrmWorld WorldNormalFromTangent(nZ, float3(0, 0, 1)) * blend.z; nrmWorld normalize(nrmWorld); nrmWorld lerp(IN.worldNormal, nrmWorld, _NormalStrength); o.Albedo albedo; o.Normal nrmWorld; o.Alpha 1.0; } ENDCG } FallBack Diffuse }这段代码里最需要注意的是WorldNormalFromTangent函数输入的是从法线贴图采样得到的切线空间法线以及当前投影轴方向输出的是世界空间法线。它的原理是切线空间的Z轴就是表面法线方向在Triplanar场景下我们可以近似地把投影轴方向当作表面法线方向来构建基向量。另外如果法线本身是从X方向投影采样的基向量的构建就要以X轴为“表面法线”来推导否则方向会错。《渲染中的数学》里有完整推导这里不展开但记住一点三个投影轴分别对应世界空间的X、Y、Z两个切向量T和B必须与轴正交。4.2 高度混合让地形过渡更自然只有法线贴图还不够实际场景里石壁和地面交界处往往有泥土、落叶覆盖单纯用权重混合边界会很干净反而显得“假”。这时可以在Triplanar基础上加入高度混合。做法是在片元着色器里多采样一张高度图或者用噪声生成一个高度值然后根据当前点的世界Y坐标和噪声值决定Albedo和法线的混合权重。我记得当时为了模拟山脚碎石和山上雪线的自然过渡生成了一张大范围的噪声图映射到世界XZ平面作为高度控制在高度超过阈值的地方把岩石纹理调亮变白低于阈值的地方混入泥土纹理过渡区域用smoothstep做平滑。配合Triplanar的权重混合效果非常自然完全看不到纹理接缝。高度混合还有另一个妙用解决简单的Triplanar在垂直大平面上的“重复感”。当Tiling很大时一整面墙全是同一个纹理的重复排列肉眼很容易看出规律。加了一层随空间变化的高度噪声后纹理的明暗在小范围内起伏视觉上破坏了重复规律。4.3 程序化生成场景时的封装思路程序化生成地形或洞穴时我们通常希望材质参数能在运行时动态控制而不是每个Patch都新建一个Material实例。做法是提前创建少数的几个Material实例然后公有属性让C#脚本统一设置。比如把_Tiling和_Sharpness暴露成地形系统配置文件里的字段生成地形Patch时直接给材质赋值大幅减少运行时材质数量和Draw Call。另一个经验是Triplanar通常配合**Texture Array纹理数组**使用。程序化生成的地表往往包含多种材质比如岩石、泥土、草地、苔藓。每创建一个材质都做Triplanar会成倍增加采样开销。用Texture Array把同一组纹理的多个变体打包在Shader里根据顶点颜色或高度信息动态选择纹理索引一个Pass就能完成多种材质的混合性价比很高。5. 移动端和URP环境下的适配与优化5.1 采样次数膨胀核心性能瓶颈Triplanar最直接的性能代价是纹理采样次数成倍增加。普通Lambert只需要采一次AlbedoTriplanar至少三次加上法线贴图就直接六次。移动端GPU的采样带宽有限六次采样对中低端机压力很大容易造成帧率骤降和发热。我的优化思路首先是控制分辨率。移动端上把法线贴图分辨率做成Albedo的一半或者用ETC2/ASTC压缩格式都能显著降低带宽占用。其次是提前在C#端检查相机距离远距离的Patch只开启Albedo的Triplanar近处才开启法线贴图用LOD思路分层渲染。实测在骁龙中端芯片上近景6次采样、远景3次采样整体帧率比全程6次采样高15%左右效果肉眼几乎没差。5.2 URP下的迁移要点如果项目用的是URPUniversal Render Pipeline上面基于Surface Shader的写法不能直接用需要改成HLSL Shader并把光照函数换成URP的Standard PBR函数。核心Triplanar算法部分不变差异主要在结构和语义。URP的Shader Pass要自己声明TagsLightMode UniversalForward顶点着色器的输出结构需要包含positionCS和worldPos、worldNormal。片元着色器里用UniversalFragmentPBR函数来计算光照和颜色。混合权重和采样的逻辑完全照搬CG版的代码模块改成HLSL语法即可。实践上更快的迁移方式是用Shader Graph在Shader Graph里添加Custom Function节点把Triplanar的采样和权重计算封装成HLSL文件用SubGraph的方式接入。这样既能保留可视化调参的便利又能复用现成的代码逻辑。我后来把项目里所有地形材质都改成了Shader Graph Custom Function的方案维护起来比纯手写HLSL省心很多。5.3 移动端的特殊注意事项移动端的GPU架构跟桌面端差异大有几个细节值得单独说。一个是半精度浮点的问题世界坐标的数值范围可能很大移动端如果用half精度存储worldPos距离原点太远时精度不足会导致纹理抖动。我在Shader里给worldPos开了float精度其他计算保持half平衡了精度和性能。还有一点是关于纹理过滤的。Triplanar的投影采样在斜面上会跨越多个纹理像素如果纹理mipmap层级变化太突兀远处和近处的纹理清晰度跳变会很明显。我开了anisotropic filtering配合trilinear filtering并且设置_MainTex的MipMap Bias为-0.5从实际效果看洞穴项目的远景稳定性和近景锐度都有改善。这部分经验可能跟具体的GPU驱动有关但方向是通用的。6. 常见问题速查与独家避坑经验6.1 纹理方向错乱这是最容易遇到、也最好排查的问题纹理上的条纹方向跟模型方向对不上比如横纹变成了竖纹。原因基本只有一个——投影平面取错了。我在前面强调过X轴投影要取世界坐标的zy因为从X轴方向看过去可见平面的两个基向量就是Z和Y。如果你写反了比如取成yz或者用yz乘了不同的系数方向就会错乱。排查时先在片元着色器里把三个采样结果分别用纯色标记输出看一眼哪块区域颜色不对就知道哪个投影轴取反了。6.2 斜面上出现明显的纹理拉伸有时候权重计算没问题但斜面的纹理还是拉伸得厉害。这通常发生在_Sharpness设置偏小的时候斜面上X和Y方向的采样结果各占一半两个方向投影的纹理拉伸方向不一样混在一起的视觉效果就是“糊”、锐度低。把_Sharpness调大或者改成用世界法线和三个轴的夹角做反向权重角度越接近90度权重越小能明显缓解。另一种情况是世界法线在Vertex Shader阶段没有被正确归一化导致pow的结果被缩放这时要检查UnityObjectToWorldNormal的输出并在片元里手动normalize一下。6.3 阴影接收和光照异常Surface Shader模式下如果法线混合出了错表现往往不是纹理糊而是光照怪亮的亮、暗的暗跟周围地形不协调。最常见的原因是我在WorldNormalFromTangent里构建基向量时的那个分支判断当投影轴接近Y轴时如果还用float3(0,1,0)做cross结果会是零向量。我写了一个if (abs(axis.y) 0.999)的分支换用float3(1,0,0)来避免这种情况。这个细节容易忽略但一旦触发就是整块区域法线全错。建议在实验阶段把这个分支保留着不要随便删。6.4 移动端纹理闪烁上一篇已经提了一部分这里补充一个排查思路如果闪烁只在相机运动时出现大概率是mipmap选择问题如果静态时也闪就要检查是不是世界坐标精度不够。我用float精度后静态闪烁基本消失相机运动时的闪烁靠调MipMap Bias和开启各向异性过滤解决。另外Tiling如果大于64高频纹理的闪烁会非常明显这时候有必要考虑在片元着色器里做一层简单的frac降频采样或者用细节纹理叠加放大。6.5 接缝处上下两半颜色不一致在程序化生成的洞穴里顶部和底部分别用不同材质甚至不同Shader渲染如果边界正好落在Triplanar的过渡带上会因为相邻Patch的_Sharpness不同导致颜色跳变。我的处理方式是把_Sharpness、_Tiling这类参数写入Patch的统一配置确保相邻Patch用同一组参数从根源上避免不一致。如果没法统一可以在边缘做一层基于世界坐标的渐变混合用smoothstep把两种材质在交界处融合掉。7. 与具体项目结合的个人体会做完这个地形系统的Triplanar改造之后我对它的理解从“一个纹理采样技巧”变成了“一种渲染架构思路”。它最大的价值不是修UV而是解耦了纹理映射与网格拓扑。只要物体表面在世界空间里有定义就能得到可预测的纹理效果。这一点对程序化生成、作品展示类的Demo、以及美术资产复用场景价值极大。但也不是说Triplanar是银弹。平面感很强的网格比如室内地板、桌面用普通UV映射更高效只有表面朝向复杂、UV展开成本高的地方才值得上Triplanar。判断标准很朴素你给这张纹理的UV调试花的时间如果超过了写Triplanar Shader的时间那就果断换方案。最后分享一个我在项目里用的小技巧Triplanar的投影方向不一定要锁死在世界空间。在某些固定在世界坐标里的场景比如洞穴、城池世界空间是对的但如果是飘浮平台、可以旋转的空中岛屿改用物体空间或自定义锚点空间会更好。使用物体空间时Shader里把unity_ObjectToWorld换成unity_ObjectToWorld逆矩阵的处理方向即可代码改动不大但适配场景能扩大很多。根据我自己的经验提前想清楚投影空间坐标系能省掉不少返工时间。