上周我手里有个项目,把一张8km x 8km的程序化地形切成了64个chunk,本来想着拆完块之后视锥剔除、LOD、流式加载都方便,结果换上一套之前跑得好好的地形shader,画面直接崩了:草地纹理每块密度不一样,相邻地块边缘像是被刀切过又硬拼起来,甚至有些地块的边缘直接翘起半米高。折腾了两天,最后问题全出在“分割为mesh之后的地形shader编写”思路上。
这个系列文章就是想把我这段路重新走一遍。第一篇先把最核心的基础讲清楚:分割成mesh之后,地形shader的顶点阶段、片元阶段到底该怎么写,为什么不能沿用整块地形的老思路。适合正在做程序化地形、场景分块加载、或者自己手写LOD地形渲染的朋友参考,内容会用Unity ShaderLab/CG做示例,但思路移植到别的引擎也一样成立。
1. 为什么要拆:地形块不是“网格串”,是shader要换思路的起点
1.1 拆mesh的两个核心驱动
把一张大地图拆成几十上百个地形块,通常是两个原因。
第一个原因是LOD。一整块地形如果想做远近分级细节,只能靠细分网格,改一个顶点影响全局,控制起来非常笨重。拆成块之后,每个块可以独立决定自身的网格密度:离相机近的块细分高一些,远处用低模,甚至直接消失。这种做法在四叉树地形、CDLOD里都是基础能力。
第二个原因是剔除和流式加载。一整块地形的包围盒巨大,相机在某个角落时,GPU仍然要遍历全部三角形。拆块后每个块有自己的包围盒,视锥剔除精确得多。高度图、控制贴图、纹理资产也能按块加载,内存压力小不少。另外如果以后要做物理同步、动态破坏、挖洞填坑这类功能,分块之后只需要重建受影响的局部网格,不用动整张地图。
所以“分割为mesh”不是单纯把顶点拆开,而是把一张大图变成了一个带空间关系的块集合。这种变化直接影响了shader怎么写,因为你的输入从“世界坐标即模型坐标”变成了“局部坐标加块偏移”。
1.2 切割之后,着色输入发生了什么变化
拿到切块工具导出的mesh之后,你先别急着上材质,打开顶点数据看一眼,通常会看到三个变化。
第一个变化是坐标原点。整块地形时期,模型空间坐标往往从世界原点开始延续,顶点坐标动辄几千上万。切块之后每个小块都从自己的(0,0,0)开始,一个1000米见方的chunk,顶点坐标范围基本就在0到1000之间。
第二个变化是UV。很多切割工具为了方便美术处理,会把每个块的UV重新归一化到0到1区间。这在建模软件里看着挺整洁,但对shader是致命的:原本靠UV平铺纹理的逻辑全部失效,每个块都像独立的一张“贴图纸”,块之间的纹理密度、方向全对不上。
第三个变化是法线。如果切割后重新计算硬边,法线会变成垂直于面的方向;如果保留切割前的法线,那顶点位移之后法线也是错的。在shader里我们通常还要基于高度图重新抬顶点,此时法线必须和最终位移后的几何保持一致。
到这里应该能得出一个结论:分块之后,凡是需要采样整张地图数据(高度图、控制贴图)的地方,都不能信局部UV,必须回到世界坐标重新建立映射。这是整个系列的核心思路。
2. 拆完之后最容易看到的三种翻车现场
理论说再多不如直接看现象。我调试那两天,遇到的翻车问题基本可以归成三类,你如果正在搞地形分割,大概率也会撞见。
2.1 翻车一:贴图被块边界“切断”
这是最直观的翻车。把地形切成64个chunk之后给每个块套上原来的地形材质,画面里草地、岩石的纹理密度在块与块之间完全不一样。有的块里小草纹理被放大到像床单,有的块里纹理缩成一团看不清。翻车原因就是切割工具重写了UV,把原本连续的平铺UV变成每块独立的0到1范围。纹理尺寸与mesh尺寸的对应关系被强行打断。
解决思路一句话:片元阶段不用mesh的局部UV,改为用世界坐标算一个平铺UV,即uv = worldPos.xz * _TexScale。这样不管地形被切成多少块,同一个世界位置采样到的纹理永远是同一处。
2.2 翻车二:相邻地块边缘像台阶
纹理乱了还能忍,边缘高度对不上才是真正的灾难。我项目里的现象是两个相邻chunk的共享边,一块的边缘顶点被抬高到40米,另一块对应顶点只抬到38.2米,中间直接出现一道纵向断面。角色走过去感觉像在爬坎。
原因也很典型:两个块在顶点着色器里各自用局部UV去采样那张全图高度图。问题来了,块A的局部UV范围是0到1,块B也是0到1,但它们的原点在世界空间差了1000米。如果shader里直接用tex2Dlod(_HeightMap, v.uv)采样,两个块采样的高度图区域其实是同一块,那当然不可能得到连续高度。
正确做法是把高度图的UV建立成“全图坐标”,让世界空间同一个坐标永远换算到同一个高度图UV。公式长这样:
float2 heightUV = (worldPos.xz - _HeightUVOffset.xy) * _HeightUVScale;其中_HeightUVOffset是高度图覆盖区域在世界空间的原点,_HeightUVScale是高度图分辨率与覆盖面积的换算比例。只要相邻块都用同一套参数,同一个世界位置采样到的高度必然一致,台阶问题在根源上被消除。
2.3 翻车三:光照阴影在块边界撕裂
高度对了,纹理齐了,但到了傍晚太阳角度低的时候,块边界上会出现一道非常扎眼的亮线或暗线。原因大多出在法线上。切块之后顶点法线处理不当,相邻两块在共享边两侧的法线方向突然变化,光照计算就会把这种不连续暴露出来,尤其在阴影交界处最明显。
后来我统一了法线重建逻辑,并且保证采样邻域时使用一致的“高度图texel步长”,而不是顶点坐标间距,块与块之间照度才恢复正常。细节在第4章详细展开。
3. 顶点阶段实操:用全局高度UV把顶点抬起来
3.1 需要传入shader的几个外部参数
在写顶点着色器之前,先把外部参数定好。我所用的T1版本shader需要这几个关键uniform:
| 参数 | 类型 | 作用 |
|---|---|---|
_HeightMap | 2D | 全图高度图,R通道存高度 |
_HeightUVOffset | Vector | 高度图覆盖区域对应的世界坐标原点 |
_HeightUVScale | Float | 世界坐标到高度图UV的换算比例 |
_HeightScale | Float | 高度图中0到1数值映射到真实米数的倍率 |
_HeightOffset | Float | 高度整体偏移,用于对齐原点高度 |
_TexScale | Float | 片元阶段纹理世界平铺比例 |
_SplatMap | 2D | 地表控制贴图,RGBA对应四层材质权重 |
_HeightUVScale的计算很直白:如果高度图是一张整图,覆盖世界空间边长MapSize米,那_HeightUVScale = 1.0 / MapSize。比如8km地图,scale就是1.0 / 8000.0。
3.2 顶点阶段为什么必须用worldPos算UV
顶点着色器里,拿到的v.vertex是该块模型空间坐标。比如某块位于世界坐标(2000, 0, 3000)处,它的局部顶点可能是(0, 0, 0)到(1000, 0, 1000)之间的数值。直接用局部坐标去采样高度图是不行的,因为两个位于不同世界位置的块可能在局部坐标上完全相同。
所以第一步先把顶点从模型空间变换到世界空间:
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;然后用世界坐标减去高度图原点,乘以_HeightUVScale,得到全局高度图UV:
float2 heightUV = (worldPos.xz - _HeightUVOffset.xy) * _HeightUVScale;这里有个容易被忽略的细节:heightUV的x对应世界X,y对应世界Z,不是传统贴图的u/v方向。因为地形是平铺在XZ平面上的,这个顺序不要写反,否则高度图采样会旋转90度。
在顶点着色器里采样高度图,要用tex2Dlod而不是tex2D。顶点阶段没有自动mip和插值,tex2D在部分平台会编译报错或结果异常。tex2Dlod的第四个分量是mip层级,这里固定为0,表示强制采样最高精度:
float h = tex2Dlod(_HeightMap, float4(heightUV, 0, 0)).r;3.3 把高度加到顶点上的顺序
得到采样高度后,下一步是把它作用到顶点Y轴上。我见过一些写法直接在裁剪空间里改坐标,这是错的,因为后续的normal、fog、shadow等计算都需要正确的世界坐标和模型坐标。正确顺序是:
float h = (tex2Dlod(_HeightMap, float4(heightUV, 0, 0)).r - 0.5) * _HeightScale + _HeightOffset; v.vertex.y += h; o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.pos = UnityObjectToClipPos(v.vertex);注意我在采样结果里减了0.5。这是为了让高度图中间灰值对应零高度,方便美术用一张灰度图控制地形高低起伏。如果你的工具导出的高度图已经做过零点对齐,这个步骤可以省略。
关于旋转问题:这套代码默认地形块是Y轴向上的直立块,没有旋转。如果地形块在场景里有旋转,直接在局部空间加Y位移就不对了,需要先在世界空间算出目标高度,再逆变换回模型空间。大多数地形系统不会旋转chunk,所以这个简化是安全的。
4. 顶点阶段续:分割后法线为什么等于零,以及梯度重建
4.1 为什么必须重建法线
如果你只是在顶点着色器里抬高了顶点,却没有重新计算法线,渲染结果会非常诡异。原因很好理解:原mesh的顶点法线是基于切割前的几何算出来的,你把顶点Y坐标整个改了,几何表面朝向全变了,旧法线自然不再垂直于新表面。
即使你重新切割mesh并让建模软件重算法线,这个法线也只对应“未位移”的平面状态,顶点位移发生在GPU的shader阶段,软件根本不知道。所以顶点阶段必须做一次法线重建。
4.2 由高度场梯度重建法线
这里用到的原理不复杂。把高度看成二维函数H(x, z),表面法线近似为:
normal = normalize(float3(-dH/dx, 1, -dH/dz));dH/dx和dH/dz可以通过高度图邻域采样做中心差分得到。我在顶点里分别采样左、右、下、上四个邻居,然后计算梯度。完整代码:
float2 texelSize = _HeightMap_TexelSize.xy; float hL = tex2Dlod(_HeightMap, float4(heightUV + float2(-texelSize.x, 0), 0, 0)).r; float hR = tex2Dlod(_HeightMap, float4(heightUV + float2( texelSize.x, 0), 0, 0)).r; float hD = tex2Dlod(_HeightMap, float4(heightUV + float2(0, -texelSize.y), 0, 0)).r; float hU = tex2Dlod(_HeightMap, float4(heightUV + float2(0, texelSize.y), 0, 0)).r; float gradU = (hR - hL) * 0.5 / texelSize.x; float gradV = (hU - hD) * 0.5 / texelSize.y; float3 n = float3(-gradU * _HeightScale * _HeightUVScale, _NormalStrength, -gradV * _HeightScale * _HeightUVScale); n = normalize(mul((float3x3)unity_ObjectToWorld, n));这段代码里最容易搞错的是两个scale怎么乘。gradU计算的是高度值在UV空间的变化率,而我们要的是世界空间的变化率,所以要先除以高度图texel得到“每UV单位的变化量”,再乘_HeightUVScale换算到“每米的变化量”,最后乘_HeightScale把高度值从0到1映射到真实米数。这三步换算缺一步,法线方向都会偏。
4.3 邻居采样必须基于高度图texel,而不是顶点间距
这是我在项目里踩过最深的一个坑。最开始我图省事,用顶点世界坐标的间距当作差分步长,比如float step = 1.0米。结果在精细LOD的块上,法线锯齿明显;在粗LOD的块上,法线又平滑过头。两边交界处光照不连续。
原因是法线代表的是高度场在该点的“局部趋势”,趋势的衡量步长应当由高度图分辨率决定,而不是由mesh顶点密度决定。同一个高度场,无论你用哪种密度的mesh去渲染,法线都应该一致。只有统一用_HeightMap_TexelSize做差分步长,才能保证相邻不同LOD的块在边界处法线连续。
边界采样越界的问题我也遇到了。当一个chunk的顶点位于高度图边缘时,左右邻居会采样到高度图外面。解决办法是把高度图的WrapMode设为Clamp,让越界采样自动取边缘值。如果你有更高要求,可以在运行时为每个chunk生成一圈border数据,或者专门烘焙一张带法线WorldNormal贴图,用同样的全局UV在片元阶段采样,逻辑会更稳。
5. 片元阶段实操:世界坐标平铺与splat混合
5.1 splat控制贴图的采样
顶点抬起来之后,片元阶段的工作就是给地表上材质。最经典的做法是使用一张splat map控制四层地表材质的混合权重:R通道对应草地,G通道对应岩石,B通道对应泥地,A通道对应雪地。采样splat map时,同样要使用和高度图一致的全局UV:
float4 ctrl = tex2D(_SplatMap, i.heightUV);这里有个很关键的工程问题:splat map如果也做成全图一张,内存可能吃不消,尤其在地图越来越大的时候。更好的做法是跟着chunk走,每个块只加载自己那部分的splat区域。但采样UV依然要用全局算出的heightUV,因为块的局部UV范围相同,但对应全世界区域不同。如果你直接对splat map也用局部UV,那每个块采样的都是splat图的左上角,权重信息基本废了。
有些方案会把splat权重直接烘焙进顶点色。这个在分割之后也成立,但有一个风险:如果后续要做LOD跨块过渡,顶点色需要跟随网格重新插值,处理不好会产生边缘权重突变。用贴图控制权重会更灵活。
5.2 纹理世界平铺,绕开块坐标干扰
四层材质贴图本身用哪个UV采样?答案是世界坐标平铺。代码一行:
float2 worldUV = i.worldPos.xz * _TexScale;_TexScale表示每米对应多少个纹理UV单位。比如我想让一张草地贴图在10米范围内重复一次,_TexScale = 0.1。
世界平铺的好处是显而易见的:相邻chunk共享同一个世界坐标系,纹理完全连续,不再受切割工具重写UV的影响。而且在不同LOD块之间切换时,如果没有法线贴图,纹理细节看起来依然是连续的。
坏处也要说清楚:世界坐标数值很大的时候,worldUV会变得非常大,采样远处纹理时浮点精度不足会产生抖动。这个问题的缓解手段有几个:一是让chunk本地坐标尽量保持小数值,在片元阶段用worldPos - chunkOrigin再乘scale;二是给worldUV做一次frac和噪声扰动,但这样会破坏跨块连续性,需要后续处理。T1版本先用最简单的方式,后续优化再展开。
5.3 四层混合的具体公式
有了splat权重和四层纹理采样,混合就是加权求和:
fixed4 col = tex2D(_Tex1, worldUV) * ctrl.r; col += tex2D(_Tex2, worldUV) * ctrl.g; col += tex2D(_Tex3, worldUV) * ctrl.b; col += tex2D(_Tex4, worldUV) * ctrl.a;如果只有三层,就把alpha通道权重并到第一层上去,否则混合结果会有整体变暗的坑。比如三层的话:
ctrl.r += ctrl.a;不要让材质“消失”一部分亮度。
光照方面,T1版本没有复杂PBR,只用一个简单漫反射:
float ndotl = saturate(dot(normalize(i.normal), normalize(_WorldSpaceLightPos0.xyz))); fixed3 lighting = _LightColor0.rgb * ndotl + UNITY_LIGHTMODEL_AMBIENT.rgb; return fixed4(col.rgb * lighting, 1);实际项目里如果需要法线贴图、AO、粗糙度之类,往这个框架上加就好。
6. 分块边界的接缝,其实就是“信息一致性”问题
6.1 高度接缝:同一个世界位置必须查同一张表
接缝问题的本质,可以归结为“同一个世界位置,在不同块里采样得到的信息是否一致”。高度最大的接缝隐患来自采样映射不统一。
T1版本解决得比较彻底:所有块的shader统一用(worldPos.xz - _HeightUVOffset) * _HeightUVScale采样高度图。那么相邻两个块在共享边上,只要顶点世界坐标是连续过渡的,采样到的高度就是同一个值。注意这里的“连续过渡”也隐含了一个mesh层面的要求:相邻块共享边上的顶点位置必须一致,最好做到焊接。
如果两个块在共享边上一块的顶点密度是每1米一个,另一块是每4米一个,高度采样即使一致,最终网格形状也可能有轻微偏差,这是因为线性插值LOD不同。真正解决LOD缝合需要在CPU侧做边匹配,这是系列后续的主题。
6.2 UV接缝:让纹理贴图不受分块影响
UV接缝在“世界平铺+全图高度UV”这套方案里基本不存在了。但还有一个容易踩的坑:贴图的WrapMode。如果把草地贴图的WrapMode设置为Repeat,在世界平铺UV跨越0、1整数边界时可能出现一条细线或明显的重复跳变。这是因为相邻像素正好跨在纹理图集的接缝上。
解决技巧:在地形渲染里,把主要地表贴图的WrapMode改成Clamp,然后配合世界平铺采样,可以避免绝大多数边界跳变。如果担心远处过滤出现色边,可以给纹理设置sRGB和合适的aniso等级。
6.3 法线接缝:用同一套差分步长
法线接缝的根本原因是邻域采样不一致。有的块在边界处采样时越界取了wrap值,有的块做了一次clamp,两者的梯度就完全不同,法线自然断裂。
T1版本的统一做法是:
- 所有块的差分步长使用
_HeightMap_TexelSize; - 高度图WrapMode统一Clamp;
- 同一世界位置的左右、上下邻居采样结果一致;这样计算出的法线也一致。
如果你改用法线纹理方案,那么法线纹理的UV也必须是同一个全局高度UV,而不是块的局部UV。这一点不管换什么方案都绕不开。
6.4 数值精度:大世界坐标下的隐藏杀手
最后提一个容易被忽略的边界问题:数值精度。当地图中心离世界原点超过几公里,worldPos.xz数值达到几千甚至上万。float的精度在整数部分超过8192之后,小数精度开始明显下降,顶点会抖动,法线会波动。
分块方案本身就有助于缓解这个问题:块内部的局部坐标范围小,精度高。T1版本里,顶点阶段先用局部坐标计算,再转世界坐标采样高度,片元阶段做世界平铺时也可以减去块原点再缩放。如果项目采用双精度坐标系统,需要把块的Double精度Offset以额外参数传入shader,避免在GPU里直接做double计算。
7. T1版基础地形shader完整实现与参数说明
7.1 一段可以跑起来的完整代码
下面这段是T1版本的地形shader完整实现,按我前面讲的所有逻辑整理而成。它没有做复杂的光照、没有做LOD跨块过渡,不含雾效,也没有顶点烘焙AO,但足够支撑一个分块地形的基础渲染。
Shader "Custom/TerrainChunkT1" { Properties { _HeightMap ("Height Map (R)", 2D) = "gray" {} _SplatMap ("Splat Map (RGBA)", 2D) = "black" {} _Tex1 ("Layer 1 Albedo", 2D) = "white" {} _Tex2 ("Layer 2 Albedo", 2D) = "white" {} _Tex3 ("Layer 3 Albedo", 2D) = "white" {} _Tex4 ("Layer 4 Albedo", 2D) = "white" {} _HeightScale ("Height Scale", Float) = 80 _HeightOffset ("Height Offset", Float) = 0 _HeightUVScale ("Height UV Scale", Float) = 0.000125 _HeightUVOffset("Height UV Offset", Vector) = (0,0,0,0) _TexScale ("Texture World UV Scale", Float) = 0.1 _NormalStrength("Normal Strength", Range(0, 2)) = 1.0 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _HeightMap; float4 _HeightMap_TexelSize; sampler2D _SplatMap; sampler2D _Tex1; sampler2D _Tex2; sampler2D _Tex3; sampler2D _Tex4; float _HeightScale; float _HeightOffset; float _HeightUVScale; float4 _HeightUVOffset; float _TexScale; float _NormalStrength; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; float2 heightUV : TEXCOORD1; float3 normal : TEXCOORD2; }; float SampleHeight(float2 uv) { return tex2Dlod(_HeightMap, float4(uv, 0, 0)).r; } v2f vert(appdata v) { v2f o; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float2 heightUV = (worldPos.xz - _HeightUVOffset.xy) * _HeightUVScale; o.heightUV = heightUV; float h = (SampleHeight(heightUV) - 0.5) * _HeightScale + _HeightOffset; v.vertex.y += h; o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.pos = UnityObjectToClipPos(v.vertex); // 法线重建:heightmap梯度 float2 texelSize = _HeightMap_TexelSize.xy; float hL = SampleHeight(heightUV + float2(-texelSize.x, 0)); float hR = SampleHeight(heightUV + float2( texelSize.x, 0)); float hD = SampleHeight(heightUV + float2(0, -texelSize.y)); float hU = SampleHeight(heightUV + float2(0, texelSize.y)); float gradU = (hR - hL) * 0.5 / texelSize.x; float gradV = (hU - hD) * 0.5 / texelSize.y; float3 n = float3(-gradU * _HeightScale * _HeightUVScale, _NormalStrength, -gradV * _HeightScale * _HeightUVScale); o.normal = normalize(mul((float3x3)unity_ObjectToWorld, n)); return o; } fixed4 frag(v2f i) : SV_Target { float4 ctrl = tex2D(_SplatMap, i.heightUV); float2 worldUV = i.worldPos.xz * _TexScale; fixed4 col = tex2D(_Tex1, worldUV) * ctrl.r; col += tex2D(_Tex2, worldUV) * ctrl.g; col += tex2D(_Tex3, worldUV) * ctrl.b; col += tex2D(_Tex4, worldUV) * ctrl.a; // 柔和的环境光 + 主光源方向光 float3 L = normalize(_WorldSpaceLightPos0.xyz); float3 N = normalize(i.normal); float ndotl = saturate(dot(N, L)); fixed3 lighting = _LightColor0.rgb * ndotl + UNITY_LIGHTMODEL_AMBIENT.rgb; return fixed4(col.rgb * lighting, 1.0); } ENDCG } } }7.2 参数设置的参考值
这段shader换成自己的项目时,几个参数的初始值可以参考下面这张表:
| 参数 | 推荐设置 |
|---|---|
_HeightUVScale | 1.0 / 地图世界边长(米) |
_HeightUVOffset | 高度图左下角对应的世界坐标 |
_HeightScale | 整张高度图从最低到最高的实际米数,例如80表示最高差80米 |
_TexScale | 单片草地纹理希望重复的世界距离的倒数,例如0.1表示10米重复一次 |
_NormalStrength | 先设1.0,如果地形过于陡峭可降到0.6左右 |
_HeightMap_TexelSize在CG里是Unity自动提供的变量,不需要手动声明。如果你在别的引擎里没有这个变量,直接通过贴图尺寸算:float2 texelSize = 1.0 / _HeightMapSize;。
高度图颜色值建议保持线性空间。如果贴图设置了sRGB采样,高度值会被Gamma矫正过一次,数值范围依然在0到1之间,但非线性分布会让梯度计算出现偏差。实际项目里可以把高度图关掉sRGB采样。
7.3 T1版本现在能做什么,暂时还缺什么
这套T1 shader能解决分块地形的基础显示:高度位移正确、法线重建基本一致、纹理跨块连续、splat权重混合正常。它非常适合作为你后续地形系统的起点。
当前版本还没处理的点,我列在这里,也作为系列后续内容的预告:
- LOD跨块边界的顶点缝合,低模块和高模块在共享边上的高度咬合;
- 三平面映射,陡峭悬崖处的纹理拉伸问题;
- 视距内的纹理Mip选择和远处细节抖动;
- 更完善的PBR光照、阴影接收、法线贴图叠加;
- tiledDetail抖动、程序化细节纹理的随机感。
按照我调试这套系统的经验,建议你先不要直接塞一堆美术素材。第一遍用一个最简单的条纹测试贴图,比如横向拉一条黑白格,把每个块都刷上同一套平铺参数,肉眼确认块边界处的连续性,再换真正的地表材质。我踩过的坑基本都是因为跳过测试贴图直接看材质效果,结果把纹理资产的问题和在object中的shader逻辑问题混在一起,白白浪费了一个通宵。