Unity地形渲染进阶:基于高度混合技术突破四层纹理限制
1. 项目概述当四层纹理不再够用在Unity地形系统的标准工作流里美术和程序们对“四层纹理”这个限制应该都不陌生。无论是内置的Terrain系统还是基于SplatMap的常见Shader方案默认的纹理混合层数往往就卡在四层。对于一个小场景草地、泥土、沙石、岩石四层或许勉强够用。但一旦项目规模扩大需求变得复杂——比如你需要在一片草地上混合出被踩踏的泥泞小径在岩石表面叠加青苔和积雪或者在沙漠中表现干燥与湿润沙地的渐变——四层纹理立刻就显得捉襟见肘。硬着头皮增加纹理层数性能开销会直线上升Draw Call和纹理采样压力陡增这显然不是个可持续的方案。这个项目要解决的正是这个困扰许多Unity开发者的经典难题。它的核心不是粗暴地增加纹理层数而是引入一个更聪明的思路高度混合。我们不再仅仅依赖一张控制权重的SplatMap来告诉Shader“这里用A纹理那里用B纹理”而是将地形的高度信息作为一个关键的混合因子。想象一下一座山山脚是草地随着海拔升高草地逐渐过渡到裸露的岩石再往上可能还有积雪。这种基于物理高度的自然过渡正是高度混合最擅长表现的场景。通过将高度数据编码到一张贴图比如一张高度图或者巧妙地利用现有贴图的某个通道中并在Shader中根据像素的世界坐标或UV坐标映射的高度值动态计算多个纹理的混合权重我们就能用极低的成本实现远超四层的、过渡平滑的复杂地形表现。这不仅仅是“多画几层”那么简单它是一种思维上的转变从离散的、分层的纹理绘制转向连续的、基于物理属性的材质融合。对于追求写实风格的大型开放世界、需要丰富细节的RPG地图或是任何受限于性能但又不愿在美术质量上妥协的项目掌握高度混合技巧无疑是地形渲染技术栈中一次关键的进阶。2. 核心原理高度图如何驱动纹理融合要理解高度混合首先要摆脱“一层纹理对应一个权重”的固有思维。在标准的多纹理混合中我们通常有一张RGBA贴图SplatMap它的四个通道R, G, B, A分别控制着四张不同纹理的显示权重。某个像素的最终颜色是这四张纹理颜色的加权和所有权重之和为1。这种方法简单直接但扩展性差。高度混合的核心在于引入一个连续的标量场——高度图来作为混合权重的计算基础。这个高度图可以是一张单独的灰度贴图其中白色代表高点黑色代表低点也可以是从现有资源中“借用”的例如地形网格的顶点颜色某个通道、一张法线贴图的某个通道甚至是另一张纹理的Alpha通道。关键在于它提供了一个在空间上连续变化的值。2.1 混合权重的动态计算假设我们有四张基础纹理Tex_Dirt泥土低处、Tex_Grass草地中低处、Tex_Rock岩石中高处、Tex_Snow积雪高处。我们有一张高度图HeightMap其采样值height范围在[0, 1]。最基础的混合逻辑是阈值过渡。我们可以设定几个高度阈值例如height 0.3: 纯泥土0.3 height 0.6: 泥土与草地混合0.6 height 0.8: 草地与岩石混合height 0.8: 岩石与积雪混合但硬切换的阈值会导致明显的接缝。因此我们需要在阈值之间引入平滑过渡区间。常用smoothstep函数来实现。// 假设阈值定义 float dirt_end 0.3; float grass_start 0.25; float grass_end 0.6; float rock_start 0.55; float rock_end 0.8; float snow_start 0.75; // 计算各纹理的权重 float weight_dirt 1.0 - smoothstep(grass_start, dirt_end, height); float weight_grass smoothstep(grass_start, dirt_end, height) - smoothstep(rock_start, grass_end, height); float weight_rock smoothstep(rock_start, grass_end, height) - smoothstep(snow_start, rock_end, height); float weight_snow smoothstep(snow_start, rock_end, height);通过这种方式weight_dirt、weight_grass、weight_rock、weight_snow这四个权重在任意高度height下其和都约为1由于浮点数精度可能为0.999...或1.000...并且它们之间的过渡是平滑的。Shader在采样四张基础纹理后进行加权混合就能得到随高度平滑变化的材质表现。注意这里虽然我们仍然只使用了四张纹理但混合的逻辑和表现力已经超越了简单的SplatMap。更重要的是这个框架是可扩展的。我们可以用同样的高度数据以不同的阈值和过渡区间去驱动另一组纹理比如潮湿泥土、干燥岩石、不同种类的草地从而实现视觉上的“多层”效果而Shader实际采样的纹理数量可能并没有增加。2.2 高度数据的来源与处理高度数据的质量直接决定了混合效果的自然程度。专用高度图最准确的方法。可以使用World Machine、Gaea等地形软件生成或者由美术在3D软件中绘制并烘焙。导入Unity后作为一张单独的贴图使用。优点是精度高、控制力强。顶点颜色或顶点数据对于程序化生成或简单的地形可以将高度信息预计算到网格的顶点颜色如R通道中。这种方法在Shader中读取效率极高v.color.r但精度受顶点密度限制适合风格化或中等精度需求。世界坐标Y值最简单粗暴的方法。在Shader中直接使用像素的世界空间坐标的Y分量经过一个缩放和偏移(worldPos.y - _TerrainMinHeight) / (_TerrainMaxHeight - _TerrainMinHeight)归一化到[0,1]范围。这种方法完全动态无需额外贴图但要求地形网格的世界坐标Y轴与高度方向严格对齐且无法表现局部的高度细节如一块石头上的苔藓。复用现有贴图通道一种高效的技巧。例如如果你的岩石纹理自带了一张法线贴图而法线贴图的Alpha通道可能未被使用或者是光滑度。你可以让美术在制作时将高度变化信息绘制到那个Alpha通道里。这样在采样岩石法线贴图的同时你也获得了该区域的局部高度信息可以用来驱动岩石表面青苔或风化的混合实现“纹理自包含”的细节增强。选择哪种来源取决于项目对精度、性能、工作流和艺术风格的要求。对于追求极致细节的项目专用高度图配合世界坐标是常用组合对于移动端或风格化项目顶点颜色或复用通道可能是更经济的选择。3. 实战构建一个支持高度混合的Unity地形Shader理论说得再多不如一行代码。让我们从零开始构建一个支持高度混合的Surface Shader。这里我们将采用“专用高度图 世界坐标”混合的方案以兼顾整体地形轮廓和局部细节。3.1 Shader属性与变量定义首先在Shader的Properties块中定义我们需要的所有资源。Properties { // 四张基础纹理及其法线 [Header(Base Textures)] _MainTex1 (Texture Dirt (RGB) AO (A), 2D) white {} _NormalMap1 (Normal Map Dirt, 2D) bump {} _MainTex2 (Texture Grass, 2D) white {} _NormalMap2 (Normal Map Grass, 2D) bump {} _MainTex3 (Texture Rock, 2D) white {} _NormalMap3 (Normal Map Rock, 2D) bump {} _MainTex4 (Texture Snow, 2D) white {} _NormalMap4 (Normal Map Snow, 2D) bump {} // 平铺系数控制每张纹理的UV缩放 _Tiling1 (Tiling Dirt, Float) 1.0 _Tiling2 (Tiling Grass, Float) 1.0 _Tiling3 (Tiling Rock, Float) 1.0 _Tiling4 (Tiling Snow, Float) 1.0 [Header(Height Blending)] _HeightMap (Height Map (Global), 2D) white {} _HeightMapTiling (Height Map Tiling, Float) 1.0 _HeightStrength (Height Influence, Range(0, 2)) 1.0 // 世界高度范围用于世界坐标Y值归一化 _WorldHeightMin (World Height Min, Float) 0 _WorldHeightMax (World Height Max, Float) 1 // 各材质的高度区间控制 _DirtMaxHeight (Dirt Max Height, Range(0,1)) 0.3 _GrassMinHeight (Grass Min Height, Range(0,1)) 0.25 _GrassMaxHeight (Grass Max Height, Range(0,1)) 0.6 _RockMinHeight (Rock Min Height, Range(0,1)) 0.55 _RockMaxHeight (Rock Max Height, Range(0,1)) 0.8 _SnowMinHeight (Snow Min Height, Range(0,1)) 0.75 [Header(Other Settings)] _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 }在CGPROGRAM中我们需要声明对应的变量。sampler2D _MainTex1, _MainTex2, _MainTex3, _MainTex4; sampler2D _NormalMap1, _NormalMap2, _NormalMap3, _NormalMap4; float _Tiling1, _Tiling2, _Tiling3, _Tiling4; sampler2D _HeightMap; float _HeightMapTiling, _HeightStrength; float _WorldHeightMin, _WorldHeightMax; float _DirtMaxHeight, _GrassMinHeight, _GrassMaxHeight, _RockMinHeight, _RockMaxHeight, _SnowMinHeight; float _Glossiness; float _Metallic;3.2 顶点与片段着色器中的高度计算在surf函数中我们需要计算当前像素的最终高度值它是全局高度图和局部世界坐标的混合。void surf (Input IN, inout SurfaceOutputStandard o) { // 计算世界坐标Y值归一化高度 float worldHeight (IN.worldPos.y - _WorldHeightMin) / (_WorldHeightMax - _WorldHeightMin); worldHeight saturate(worldHeight); // 限制在0-1 // 采样全局高度图使用独立UV平铺通常较小以表现宏观变化 float2 heightUV IN.uv_HeightMap * _HeightMapTiling; // 假设Input结构中有uv_HeightMap float textureHeight tex2D(_HeightMap, heightUV).r; // 混合两种高度源 float finalHeight lerp(worldHeight, textureHeight, _HeightStrength); // 可以再加一个power操作来调整高度分布的对比度 // finalHeight pow(finalHeight, _HeightContrast);这里有几个关键点IN.worldPos需要从顶点着色器传递。在Input结构中需声明float3 worldPos;并在顶点着色器中赋值。uv_HeightMap是高度图的UV。如果地形使用一套UV可以直接用IN.uv_MainTex第一张纹理的UV。为了更灵活建议在Input中单独声明。_HeightStrength参数非常有用。当它为0时完全使用世界坐标高度适合大规模、规则的地形当它为1时完全使用高度图适合表现复杂、手工雕琢的细节在0和1之间则是两者的混合可以平衡宏观轮廓与微观细节。3.3 基于最终高度的权重计算接下来使用前面介绍的smoothstep方法根据finalHeight计算四个纹理的混合权重。// 计算平滑过渡的权重 float weight1 1.0 - smoothstep(_GrassMinHeight, _DirtMaxHeight, finalHeight); // Dirt float weight2 smoothstep(_GrassMinHeight, _DirtMaxHeight, finalHeight) - smoothstep(_RockMinHeight, _GrassMaxHeight, finalHeight); // Grass float weight3 smoothstep(_RockMinHeight, _GrassMaxHeight, finalHeight) - smoothstep(_SnowMinHeight, _RockMaxHeight, finalHeight); // Rock float weight4 smoothstep(_SnowMinHeight, _RockMaxHeight, finalHeight); // Snow // 归一化确保权重和为1防止因smoothstep边界导致的微小误差 float totalWeight weight1 weight2 weight3 weight4; weight1 / totalWeight; weight2 / totalWeight; weight3 / totalWeight; weight4 / totalWeight;3.4 纹理采样与混合有了权重我们就可以采样纹理并进行混合了。注意每张纹理可能有不同的平铺比例_Tiling。// 为每张纹理计算UV float2 uv1 IN.uv_MainTex * _Tiling1; float2 uv2 IN.uv_MainTex * _Tiling2; float2 uv3 IN.uv_MainTex * _Tiling3; float2 uv4 IN.uv_MainTex * _Tiling4; // 采样漫反射颜色 fixed4 tex1 tex2D(_MainTex1, uv1); fixed4 tex2 tex2D(_MainTex2, uv2); fixed4 tex3 tex2D(_MainTex3, uv3); fixed4 tex4 tex2D(_MainTex4, uv4); // 加权混合颜色 fixed4 blendedColor tex1 * weight1 tex2 * weight2 tex3 * weight3 tex4 * weight4; o.Albedo blendedColor.rgb; // 采样和混合法线需要将法线从切线空间解包、混合、再打包 fixed3 normal1 UnpackNormal(tex2D(_NormalMap1, uv1)); fixed3 normal2 UnpackNormal(tex2D(_NormalMap2, uv2)); fixed3 normal3 UnpackNormal(tex2D(_NormalMap3, uv3)); fixed3 normal4 UnpackNormal(tex2D(_NormalMap4, uv4)); // 法线混合需要谨慎简单加权平均可能导致错误。这里使用线性混合对于高度混合这种权重变化平缓的场景通常可以接受。 fixed3 blendedNormal normal1 * weight1 normal2 * weight2 normal3 * weight3 normal4 * weight4; blendedNormal normalize(blendedNormal); o.Normal blendedNormal; // 其他属性如光滑度、金属度也可以根据权重混合或者使用某一张纹理的特定通道 // 例如假设岩石和雪更光滑 float blendedSmoothness _Glossiness; // 或从纹理Alpha通道采样混合 float blendedMetallic _Metallic; o.Smoothness blendedSmoothness; o.Metallic blendedMetallic; }至此一个基础的高度混合地形Shader就完成了。在Unity材质面板中你可以通过调整_DirtMaxHeight、_GrassMinHeight等参数实时在场景中看到不同材质带随着高度变化而平滑迁移的效果远比静态的四层SplatMap生动。4. 进阶技巧与性能优化基础实现跑通了但要在实际项目中落地尤其是面对移动平台或大型场景我们还需要考虑更多。4.1 纹理数组与虚拟纹理我们上面使用了8个纹理采样器4张Albedo4张Normal。这已经不少了。一个进阶的优化手段是使用纹理数组。你可以将所有的Albedo纹理打包进一个纹理数组所有的Normal纹理打包进另一个纹理数组。在Shader中只需要声明两个纹理数组采样器然后通过索引来采样。这不仅能减少采样器占用在某些平台是有限资源还能让Shader代码更简洁便于管理更多纹理。// 在Properties中可能无法直接定义数组通常通过脚本赋值 uniform sampler2DArray _AlbedoArray; uniform sampler2DArray _NormalArray; // 在surf函数中 fixed4 tex1 tex2DArray(_AlbedoArray, float3(uv1, _Index1)); ...更前沿的方案是虚拟纹理如Unity的Splatmap Virtual Texturing。它将所有地形纹理拼接到一张超级大图上运行时根据视角只加载需要的部分。这能极大地突破纹理内存和数量的限制是3A级开放世界的标配。但实现复杂需要引擎和管线支持。4.2 三平面映射解决陡坡拉伸标准UV映射在陡峭的斜坡尤其是垂直的岩壁上会导致纹理被严重拉伸。三平面映射是解决这个问题的经典技巧。它的原理是为每个像素同时计算其在世界空间XZ、XY、ZY三个平面上的投影UV然后根据该点法线方向N来混合三个投影的结果。// 三平面映射采样函数示例 float4 TriplanarSample(sampler2D tex, float3 worldPos, float3 normal, float tiling) { // 在三个轴向平面上采样 float4 xProj tex2D(tex, worldPos.zy * tiling); float4 yProj tex2D(tex, worldPos.xz * tiling); float4 zProj tex2D(tex, worldPos.xy * tiling); // 使用法线分量的绝对值作为混合权重 float3 weights pow(abs(normal), _TriplanarSharpness); // _TriplanarSharpness用于控制混合锐度 weights weights / (weights.x weights.y weights.z); // 归一化 // 混合三个投影的结果 return xProj * weights.x yProj * weights.y zProj * weights.z; }将我们之前tex2D采样的地方替换为TriplanarSample调用并传入IN.worldPos和o.Normal注意这里需要世界空间法线就能有效消除陡坡纹理拉伸。当然这会增加采样次数每个纹理变成3次需要权衡性能与质量。4.3 细节纹理与视差映射为了在近距离观察时也有丰富的细节我们可以在高度混合的基础上叠加细节纹理。细节纹理通常平铺频率很高比如_Tiling50只提供高频的噪点变化通过一个独立的、小范围的混合权重可以基于高度、斜率或一张遮罩图与基础纹理进行混合。另一种提升立体感的技术是视差映射或视差遮蔽映射。它通过高度图对UV进行偏移模拟简单的深度错觉。我们可以为每张基础纹理配备一张高度图或使用其Alpha通道在采样时根据视角和高度进行UV偏移。这能极大地增强岩石缝隙、砖墙等表面的体积感而且性能开销相对可控。// 简化的视差偏移函数 float2 ParallaxOffset(sampler2D heightTex, float2 uv, float3 viewDirTS, float strength) { float height tex2D(heightTex, uv).r; float2 offset (viewDirTS.xy / viewDirTS.z) * (height * strength); return uv - offset; // 返回偏移后的UV }在采样_MainTex1之前先用这个函数计算出偏移后的uv1再用它去采样颜色和法线就能得到具有视差效果的表面。4.4 性能考量与参数优化纹理压缩与格式确保所有地形纹理使用合适的压缩格式如ASTC、ETC2。法线贴图使用DXT5nm或BC5格式。高度图可以使用单通道的BC4/BC5格式或者干脆用低精度的R8格式。Shader变体与关键字如果你的Shader包含很多可选的特性如三平面映射、视差映射、细节纹理务必使用#pragma shader_feature或#pragma multi_compile来生成变体。避免在运行时使用if语句判断这会导致所有分支的代码都被编译并可能影响运行效率。LOD与远距离简化在远处可以简化Shader计算。例如在特定的LOD级别关闭法线贴图、视差映射甚至减少混合的纹理层数使用一个简化版本的高度混合或直接使用一张烘焙好的远景贴图。批处理与GPU Instancing确保地形材质启用了GPU Instancing。对于使用相同Shader和纹理集的不同地形块这能显著降低Draw Call。注意如果纹理是通过属性块MaterialPropertyBlock设置的可能会打断合批需要评估。5. 常见问题与调试技巧在实际应用高度混合Shader时你肯定会遇到各种问题。下面是一些常见坑点和解决思路。5.1 混合区域出现“闪烁”或“抖动”这是最常见的问题通常由两个原因导致精度问题在权重计算的边缘smoothstep函数可能因为浮点数精度产生微小的、帧间不一致的值导致权重分配不稳定。解决方法是在最后对权重进行强制的“钳位”归一化如我们之前做的/ totalWeight或者在smoothstep的过渡区间设置一个非常小的重叠避免绝对硬边界。Mipmap导致的细节丢失当相机拉远纹理使用低级别的Mipmap时高度图或颜色纹理的细节会模糊导致混合边界计算不准。解决方法是为高度图设置独立的Mipmap采样状态比如使用tex2Dlod并指定一个固定的LOD级别如0或者关闭高度图的Mipmap如果它是宏观控制图细节不重要。对于颜色纹理确保它们的Mipmap生成设置正确避免过度模糊。5.2 法线混合导致光照异常直接对切线空间法线向量进行线性加权混合在权重差异大的区域如从纯泥土突然过渡到纯草地可能会导致混合后的法线长度不为1或者方向出现意料之外的扭曲从而在光照下产生黑点或亮斑。初级解决混合后一定要normalize。中级解决使用更复杂的法线混合算法如Reoriented Normal Mapping或UDNUnreal Engine使用的延迟渲染法线混合。其核心思想是选择一个主法线如权重最大的那个然后将其他法线“旋转”到主法线的切空间上进行混合效果更稳定。妥协解决如果效果要求不是极高可以考虑只为权重最大的纹理采样法线忽略其他层的法线贡献。这虽然损失了细节但绝对稳定。5.3 高度图与地形实际高度对不齐表现为材质带漂浮在空中或沉入地下。问题出在世界坐标Y值的归一化计算上。检查_WorldHeightMin和_WorldHeightMax这两个值必须准确对应你地形网格在世界空间中的最小和最大Y值。最好写一个简单的编辑器脚本在材质赋值时自动从地形对象获取并设置这两个参数。检查高度图内容在Photoshop等软件中打开你的高度图确认白色值1对应的确实是你期望的最高点。有时烘焙流程可能导致数值范围被压缩或偏移。使用Debug模式在Shader中临时将finalHeight直接输出为颜色o.Albedo float3(finalHeight, finalHeight, finalHeight);在场景中观察高度信息的可视化结果看其是否与地形轮廓吻合。5.4 性能热点分析如果你在Profiler中发现地形渲染耗时异常高检查纹理采样次数使用RenderDoc或Frame Debugger抓取一帧查看实际Draw Call中这个Shader的指令数。每个tex2D都是一个采样指令。三平面映射会使采样数翻三倍务必谨慎使用。检查Shader变体数量在Editor Log中搜索你的Shader名看看编译了多少个变体。过多的变体会增加编译时间、构建体积和运行时内存。使用shader_feature精细控制功能开关。简化远处地形这是最有效的优化。为地形配置LODGroup为低LOD级别使用一个更简单的、只有2层高度混合甚至只有一张Diffuse贴图的Shader。5.5 与Unity地形系统的协作你可能会问用了这个自定义ShaderUnity Terrain组件自带的绘制工具是不是就没法用了确实标准的地形笔刷是直接绘制SplatMap的而我们的混合权重是实时计算的。要让它们协作有几种思路完全自定义放弃Unity地形绘制工具开发自己的基于高度图或顶点颜色的地形编辑工具。这给了你最大控制权但成本高。混合方案将Unity Terrain的SplatMap作为输入之一。在我们的权重计算中可以加入对SplatMap的采样。例如finalWeight_dirt calculatedHeightWeight_dirt * splatMap.r。这样美术可以用熟悉的笔刷在特定区域“绘制”出权重修饰覆盖或增强高度混合的结果实现更局部的艺术控制。烘焙导出在编辑阶段美术仍然使用Unity地形工具绘制。在构建或运行前通过脚本将Terrain Data中的SplatMap、高度图等信息烘焙成一套我们的Shader所需的贴图一张高度图其中编码了最终混合规则。这样运行时完全使用我们的高效Shader但编辑流程对美术友好。我个人在实际项目中更倾向于第三种“烘焙导出”方案。它平衡了编辑效率与运行性能。实现一个编辑器脚本遍历地形所有Splat Alpha图层和高度按照我们设定的高度区间规则预计算出一张最终的高度混合控制图。这个过程只需在资源导入或地形修改后执行一次对运行时零开销。美术同事无需改变他们深耕多年的Terrain笔刷使用习惯而程序则获得了性能更优、效果更可控的渲染方案。这个脚本的核心就是一个嵌套循环对每个像素计算其高度再根据高度和SplatMap权重决定最终输出值复杂度不高但收益显著。