一、地形几何方案:Terrain 与 Mesh
1.1 目前手游主流地形几何方案分析
先不考虑 LOD 等优化手段,目前地形的几何方案选择有如下几种:
- 使用 Unity 自带的 Terrain
- 使用 Unity 自带的 Terrain,但是等美术资产完成后使用工具转为 Mesh
- 直接使用 Mesh,地形直接由美术通过等 DCC 工具或 UE 工具制作(例如 worldmachine)后导入到 Unity
- 自己实现 Terrain 或魔改 Unity Terrain 源码,走 Heightmap 那一套
如果只看实现原理,本质上就是①④(Heightmap)和②③(Mesh)两种方案,据目前对多款手游的截帧分析,绝大多数的手游都还是 Mesh
下面简要分析一下各方案
1.1.1 Unity 自带的 Terrain:Heightmap 方案

关于 Heightmap 实现地形的原理不做介绍,主要讲讲他的地形混合部分和整体的工具与框架使用:
Unity 地形混合的原理是每4张纹理一个 Pass,每个 Pass 里无脑采样所有贴图,这就意味着如果你想要支持至多8张地形纹理混合,Unity 就要画两次,每次混4张,先不说多 Pass 已经不太可以接受了,采样次数也会出奇的多,事实上对于单个像素而言,8张图都有贡献是件不可能也不科学的事情,一般而言 2-4 张混合顶天,采样8次必然有性能浪费的现象,这还是没有考虑法线的

其次对于 Unity 源生的这些功能,都是大一统的思路,也就是考虑到的东西不少,能提供高质量的美术资产最后效果也确实不错,但事实上很多时候你的游戏用不到这么多的功能或者特性(features),因此最重要的还是做减法,减法做的好意味着性能也更优秀,更何况 UnityTerrain 对于斜坡陡坡的处理还是有点糟糕,很多时候内置的 TerrainTool 也并不能刷出完美的效果
想去做这些客制化就要有源码,源码获取难度大的话这一块没法操作确实会比较难受,特别是很多时候性能都是能扣一是一点,如果优化不好的话再好的效果也白搭,当然最新版的 Terrain 性能提升了很多,再加上智能手机近两年的快速发展,当然未来有机会 使得 UnityTerrain 这一套成为移动平台的主流
如果有条件的话,当然可以自己实现 Terrain 或魔改 Unity Terrain 源码,走 Heightmap 那一套,但这个开发成本还是挺高的,要有 Unity 源码以及相关的技术人员,一般小公司或者中小型手游都不会去花钱花精力做这件事情
那么哪些手游会去直接使用源生的 Terrain 呢?
那就是部分小体量线性关卡手游或者部分 2.5D 游戏,因为哪怕它的性能不好,但是奈何你的场景里面东西少,可能除了一个很小块的地形就几乎只有零星的人物和 UI 了,那确实也没什么问题,毕竟这样制作成本其实反而是最低的,最多做个略微调整和 shader 部分的源码修改,如果还有那就是花了功夫的大型游戏了
1.1.2 Mesh 方案
不管是 Terrain 制作好转成 Mesh,还是美术 DCC 直接制作/二次加工导出 FBX,本质上最终进游戏的还是 Mesh,那就是不依赖 Heightmap 的,可以将地形当作场景中的特殊物体来处理
和一般真正的物体不同的是,地形需要以下的额外支持
- 地形纹理混合
- 特殊的 LOD 及性能优化手段
相比无脑使用 Terrain 的方案,使用 Mesh 比较麻烦的点就是地形纹理混合这一部分要单独实现,以及美术资源制作上可能要稍微复杂一些,因为 DCC 工具上制作最后和场景不契合还是要多次调整

使用 TerrainTool 后再 TerrainToMesh 看上去可以白嫖 TerrainTool 面板,但是拿到 Mesh 后你还是要调整,除此之外你想要编辑器效果(此时是 Heightmap 实现)和最终效果(Mesh 实现)一致,也要花点时间
好处就是可扩展性好,整体操作也比较常规,性能上更好把控,本文要介绍的的也正是这个方案
1.2 TerrainToMesh 工具
Amazing Аssets: Terrain To Mesh
当然有现成的可以直接用,装配好 package 后只需要把其中的两个 dll 文件拿出来就 OK,注意它们的相对位置不能变,即 Editor.dll 要放在 Editor 文件夹中,并且两个 dll 目录深度应该一致
![]()
工具的使用手册可以直接参考下面这篇文档
当然你也可能需要对生成的 Mesh 进行微调,因为 Terrain 生成的 Mesh 顶点是无脑等距排列的,因此若要用 DCC 工具对 Mesh 进行二次加工,就需要生成可供 DCC 工具读取的 .obj 文件而非 Mesh

一般而言,对于比较平坦的部分、或者是水底的部分、不可到达的区域等等,都可以适当的删除部分顶点,不过在修改时要注意 uv 的值,如果改错的了的最终采样结果可能和在 TerrainTool 中不一样
如果你是直接在 DCC 工具中做的,这些操作就都不需要,因为直接就是 Mesh,导入 Unity 就好
二、地形纹理混合方案
2.1 常规地形混合方案
目前地形混合主要有两种思路,一种是直接按照权重图进行叠加混合:

这个思路非常简单,拿至多4层地形纹理举例,权重图(对于 UnityTerrain 是 alphaTexture)的4个通道分别对应着4张地形纹理的权重,在计算最终地形颜色时,每个地形纹理采样后乘上贡献相加作为最终颜色:当然你的地形纹理层数若多于4张,那么权重图四个通道就不够用,就需要不止一张权重图
mixedDiffuse = 0.0h;
mixedDiffuse += diffAlbedo[0] * half4(_DiffuseRemapScale0.rgb * splatControl.rrr, 1.0h);
mixedDiffuse += diffAlbedo[1] * half4(_DiffuseRemapScale1.rgb * splatControl.ggg, 1.0h);
mixedDiffuse += diffAlbedo[2] * half4(_DiffuseRemapScale2.rgb * splatControl.bbb, 1.0h);
mixedDiffuse += diffAlbedo[3] * half4(_DiffuseRemapScale3.rgb * splatControl.aaa, 1.0h);
这样做的好处就是:每张地形纹理和权重图的大小和精度不需要很高(一般256~512大小即可),通过这种方式铺满整个场景后最终细节效果也不会差,不然你只靠一张有限大小的纹理铺满整个场景几乎是不可能的事,除非采用类似于 GPU Gems2 Chapter2 中的大世界方案
2.1.1 基于高度的地形混合
基于高度的纹理混合 shader
这也是个经典算法,其实思路也很简单,就是每张地形纹理多一个 alpha 通道用于存储高度信息,最后在计算权重图贡献的时候,通过这个高度信息重算真实权重以达到一个非平滑过渡的效果:

half4 Blend(half4 high, half4 control, int4 index)
{half4 blend = half4(.0, .0, .0, .0);half4 weight = 1 - float4(_TerrainHeightWeight[index.r], _TerrainHeightWeight[index.g], _TerrainHeightWeight[index.b], _TerrainHeightWeight[index.a]);blend.r = high.r * control.r;blend.g = high.g * control.g;blend.b = high.b * control.b;blend.a = high.a * control.a;half ma = max(blend.r, max(blend.g, max(blend.b, blend.a)));blend = saturate(blend - ma + weight) * control;half blendTotal = blend.r + blend.g + blend.b + blend.a;return blendTotal == 0 ? half4(1.0, 0.0, 0.0, 0.0) : blend / blendTotal;
}
原文介绍的非常清楚所以这里也不再详细描述了
2.1.2 多层地形混合优化方案
这个前面也提到过,如果场景足够大,只给4层地形纹理估计是不够的,如果增加到8张纹理,那么就需要
- 8张地形纹理(废话)
- 2张权重纹理(RGBA,一般512)
- 采样 8+2 = 10 次,如果算上法线,则需要采样 8*2 + 2 = 18 次(单个 pixel)
- 如果是 UnityTerrain 这种做法,需要绘制两次
其实①②还好,因为图不算大,但是③采样那么多次是无法接受的,考虑到其实一个像素不可能出现这么多张纹理都有贡献的情形,可以先采样权重图,再写 if 判断权重是否为0,为0就不采样对应的地形纹理,这样确实没问题,但是这种写 if 的方法,事实上正是 if 的最坏情况,因为每个像素都可能会走向不同的分支,此时性能可能和暴力采样差不多
在此基础之上一个优化思路就是:可以预先计算每个 pixel 到底采样哪几张地形纹理,把它们的 index 存储到单独一张图上,然后采样的时候先点采样这张索引贴图,根据信息采样指定的 n 张地形贴图即可,一般 n = 2~4 完全足够

④就不用说了,完全没有必要,因此在这种优化之下,8张纹理的混合成本就为
- 8张地形纹理(没得优化,只能压缩)
- 2张权重纹理 + 1张索引纹理(索引纹理可以减通道,但是权重不太好减!后面会给出原因)
- 采样 n+3 or n+2 次,n 为一个像素最多混合的纹理个数,一般为3足够
这也是手游地形混合的主流思路,以多一张索引贴图(indexTexture)为代价,减少大量无意义的采样,也完全无需多次绘制
2.2 UnityTerrain 纹理资源导出
下面开始正题,就是思路有了怎么做的问题
考虑最复杂的情况:美术使用 TerrainTool 刷地形后导出 Mesh,然后微调后运用到游戏,这里面会多两个要处理的事情:
- 确保编辑器下(Terrain)和游戏运行时(Mesh)表现一致
- Mesh 导出可以交给工具,但是纹理导出要自己写
2.2.1 使用 TextureArray 存储地形纹理
好了一样前面①先不管,先解决②
网络上很多都是拼接的做法,就是将 8-16 张地形纹理拼成一张大图:
这样做的唯一好处就是避免使用 TextureArray,可能是当时大家都担心 TextureArray 在手机上的兼容性不好,所以都不采取,但事实上现在绝大多数手机都支持 openGL3.0+,也就支持 TextureArray,其实没太大问题的
可其坏处很多,又要处理接缝问题,又要处理不同子图之间的 Tiling 问题等等,这些用 TextureArray 都不需要考虑,且若有多个场景,它们某些地形纹理是共用的话,还会出现包体空间浪费的情况。网络上很多文章介绍这个思路,基本上都在解决这些问题,而且很多解决的都不太好,所以直接 PASS
其实使用 TextureArray 也没多麻烦只是要注意两点
一是导出的所有纹理格式大小必须一致,不一致的话可以写编辑器给美术资产处理一下:
Texture2D RefreshSplatTextureMode(Texture2D tex, int newSize = 256)
{RenderTexture renderTex = RenderTexture.GetTemporary(newSize, newSize, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);Graphics.Blit(tex, renderTex);Texture2D resizedTexture = new Texture2D(newSize, newSize, TextureFormat.ARGB32, false);RTToTex(renderTex, ref resizedTexture);if (!Directory.Exists(TerrainTextureFolder + "ExportTerrain/")){Directory.CreateDirectory(TerrainTextureFolder + "ExportTerrain/");}var path = TerrainTextureFolder + "ExportTerrain/" + tex.name + "_" + newSize.ToString() + "x" + newSize.ToString() + ".png";var data = resizedTexture.EncodeToPNG();File.WriteAllBytes(path, data);AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);var textureIm = AssetImporter.GetAtPath(path) as TextureImporter;textureIm.isReadable = true;textureIm.anisoLevel = tex.anisoLevel;textureIm.mipmapEnabled = false;//textureIm.streamingMipmaps = tex.streamingMipmaps;//textureIm.streamingMipmapsPriority = tex.streamingMipmapsPriority;textureIm.wrapMode = tex.wrapMode;textureIm.filterMode = tex.filterMode;var apf = textureIm.GetPlatformTextureSettings("Android");var ipf = textureIm.GetPlatformTextureSettings("iPhone");var wpf = textureIm.GetPlatformTextureSettings("Standalone");apf.overridden = true;ipf.overridden = true;wpf.overridden = true;apf.format = TextureImporterFormat.ASTC_8x8;ipf.format = TextureImporterFormat.ASTC_8x8;wpf.format = TextureImporterFormat.DXT5;textureIm.SetPlatformTextureSettings(apf);textureIm.SetPlatformTextureSettings(ipf);textureIm.SetPlatformTextureSettings(wpf);textureIm.SaveAndReimport();AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);resizedTexture = (Texture2D)AssetDatabase.LoadAssetAtPath(path, typeof(Texture2D));return resizedTexture;
}
代码看上去很长但是所有细节都考虑到了,包括但不限于:①不同平台压缩格式设置,手机压缩为 ASTC8x8,PC 为 DXT5;②锁定格式为256,可以降采样解决;③考虑到基于高度的混合方式,所有纹理统一加 alpha 通道
二就是 TextureArray 的组装
很可惜,Material 并不支持序列化数组信息,包括 TextureArray,因此这个需要实时组装:这个操作只需要做一次,所以没有常驻性能损耗
public void SetArray2D()
{if (sourceTextures.Length == 0 || sourceTextures[0] == null){return;}Texture2DArray texture2DArray = new Texture2DArray(sourceTextures[0].width,sourceTextures[0].height, sourceTextures.Length, sourceTextures[0].format,sourceTextures[0].mipmapCount, false);for (int i = 0; i < sourceTextures.Length; i++){Graphics.CopyTexture(sourceTextures[i], 0, texture2DArray, i);//texture2DArray.SetPixels(sourceTextures[i].GetPixels(), i, 0);}texture2DArray.filterMode = FilterMode.Bilinear;texture2DArray.wrapMode = TextureWrapMode.Repeat;material.SetTexture("_SplatArr", texture2DArray);
}
可以给美术写个编辑器界面查看这些导出的地形纹理信息,并支持一些额外设置:

2.2.2 权重图导出与索引计算
然后就是导出权重图,这里网上代码还是很多的,可以不做什么特别的操作直接导出:
void ExportAlphaTexture(int textureLength, out string[] textureDataLocal, out string indexTextureDataLocal)
{Texture2D[] alphaTextures = terrainData.alphamapTextures;int alphaWidth = alphaTextures[0].width;int alphaHeight = alphaTextures[0].height;int aimSize = alphaWidth / (int)tar.downSampling;Texture2D[] blendTex = new Texture2D[alphaTextures.Length];for (int i = 0; i < blendTex.Length; i++){blendTex[i] = new Texture2D(alphaWidth, alphaHeight, TextureFormat.RGBA32, false, true);blendTex[i].filterMode = FilterMode.Bilinear;}Texture2D indexTex = new Texture2D(aimSize, aimSize, TextureFormat.RG16, false, true);indexTex.filterMode = FilterMode.Point;for (int j = 0; j < alphaWidth; j++){for (int k = 0; k < alphaHeight; k++){for (int i = 0; i < alphaTextures.Length; i++){blendTex[i].SetPixel(j, k, alphaTextures[i].GetPixel(j, k));}}}Material getIndexmat = (Material)AssetDatabase.LoadAssetAtPath(T4MEditorFolder + "TerrainIndexTexBakeMat.mat", typeof(Material));textureDataLocal = new string[blendTex.Length];for (int i = 0; i < blendTex.Length; i++){EditorUtility.DisplayProgressBar("地形生成中", String.Format("导出第 {0} 张权重纹理", i + 1), (i + 1.0f) / (textureLength + 4));//这里就是导出并保存资源,上面代码也有所以就省略吧,不然太长了}
}
权重图纹理的大小设置如下:

也可以导出的时候降采样,例如这里设置 2048x2048 也没问题,导出的时候降采样两次到 512 即可,降采样的部分可以写 shader 来实现,直接采样邻近4像素做平均:

//DownSample
RenderTexture toRT = null;
Texture2D temp = null;
for (int i = 0; i < additionalDownSampleTimes; i++)
{toRT = RenderTexture.GetTemporary(blendTexture.width / 2, blendTexture.height / 2, 0, RenderTextureFormat.ARGB32);mat.SetTexture("_Control1", blendTexture);Graphics.Blit(blendTexture, toRT, mat, 1);temp = new Texture2D(blendTexture.width / 2, blendTexture.height / 2, TextureFormat.RGBA32, false, true);temp.filterMode = FilterMode.Bilinear;RTToTex(toRT, ref temp);RenderTexture.ReleaseTemporary(toRT);
}//Shader:这里只贴核心代码
float4 Tap4Down(float2 uv, float4 d)
{d *= _Control1_TexelSize.xyxy * float4(-1.0, -1.0, 1.0, 1.0);float4 color = SAMPLE_TEXTURE2D(_Control1, sampler_Control1, uv + d.xy);color += SAMPLE_TEXTURE2D(_Control1, sampler_Control1, uv);color += SAMPLE_TEXTURE2D(_Control1, sampler_Control1, uv + d.zy);color += SAMPLE_TEXTURE2D(_Control1, sampler_Control1, uv + d.xw);color += SAMPLE_TEXTURE2D(_Control1, sampler_Control1, uv + d.zw);color *= (1.0 / 5.0);return color;
}float4 frag(v2f i) : SV_Target
{float4 color = Tap4Down(i.uv.xy, 1);return color;
}
当然还没有结束,你可能在网上看过这样的思路:既然我一个 pixel 至多混 2~3 张地形纹理,那我权重图也只存 2~3 个通道不就好了,反正有索引可以知道你当前 pixel 需要采样哪三张,那我按照索引解码或者索引大小的顺序,把这三张地形纹理的权重依次存储到 RGB 三个通道中就好,这样就可以省掉权重图中大部分为值为0的部分
理论可行,但是会带来一个非常严重且不好解决的问题:那就是线性采样差错
举一个例子:默认的 Texture 采样都是双线性插值,这种插值的前提是本身它的意义是连续的,但是按照上述思路导出的权重图并没有满足这个条件,例如相邻的两个像素 A 和 B,A 融合了 ID=1 权重 90% ID=7 权重为 10%,B 融合了 ID=1 权重 70% ID=6 权重为 30%,它们第一个通道的融合是没问题的,但是第二个通道它们对应的地形纹理压根不是同一张(一张 ID=6,一张 ID=7)此时线性插值得到的结果会将两个像素的值进行(一张 10%,一张 30%)混合,得到的结果根本没有意义,并且会得到错误的表现:

想要解决这个问题还是比较困难,不采用线性采样的方式而采用点采样是不可能的,这样得到的结果就是马赛克,如果强行对齐 ID,也总会遇到对不齐的,并且扩像素的话还是会浪费通道(注意无论你怎么对齐,也不能根治这个问题,只能改善,特别是混合 3 张以上贴图的情况)
当然还有一个思路就是遇到边缘(也就是相邻像素索引不同的情况)手动进行插值,不再硬件 Bilinear,尽管这样会带来额外的消耗,但这应该是最靠谱的方案
也可以跟美术规定,强行指定一张打底的图作为权重 R 通道,G 通道存储图集中 2-4 区间的图,B 通道存储图集中 5-8 区间的图,然后在笔刷涂抹的时候记住 2-4 之间的图不要重合,5-8 之间的图不要重合这样,输出贴图的时候也是按照这种方式去输出,但是这样极大的限制了美术的发挥,落实起来也比较麻烦
然后就是索引图的计算和生成:
逻辑很简单,很容易想到暴力权重图的每一个像素,找到权重最大的 n 个通道,然后记录这 n 个索引存起来存入索引图中,但是考虑到权重图采样是 Bilinear,因此单看权重图像素值为0是不对的,因为实际采样结果可能不为0,所以真正的处理方式是在编辑器下模拟采样,然后根据采样结果来判断要不要写入索引:这个和降采样的处理方式一致:
EditorUtility.DisplayProgressBar("地形生成中", "导出索引纹理", 3.0f / (textureLength + 4));
for (int i = 0; i < textureDataLocal.Length; i++)
{Texture blendTexture = (Texture)AssetDatabase.LoadAssetAtPath(textureDataLocal[i], typeof(Texture));getIndexmat.SetTexture("_Control" + (i + 1).ToString(), blendTexture);
}
RenderTexture rt2 = RenderTexture.GetTemporary(aimSize, aimSize, 0, RenderTextureFormat.RG16);
Graphics.Blit(blendTex[0], rt2, getIndexmat, 0);
RTToTex(rt2, ref indexTex);//Shader:这里只贴核心代码
float4 ExportIndex(float2 uv)
{float4 ctr = Tap4Down(_Control1, uv, 1);float4 ctr2 = Tap4Down(_Control2, uv, 1);bool sum[8] = {ctr.r > 0 ? true : false, ctr.g > 0 ? true : false, ctr.b > 0 ? true : false, ctr.a > 0 ? true : false,ctr2.r > 0 ? true : false, ctr2.g > 0 ? true : false, ctr2.b > 0 ? true : false, ctr2.a > 0? true : false};int index = 0;int indexArray[4] = {0, 0, 0, 0};for (int i = 0; i < 8; i++){if (sum[i]){indexArray[index] = i;index = index + 1;}}return float4((indexArray[0]) / 16.0 + (indexArray[1]) / 256.0, (indexArray[2]) / 16.0 + (indexArray[3]) / 256.0, 0, 0);
}v2f vert(appdata v)
{v2f o;o.vertex = TransformObjectToHClip(v.vertex.xyz);o.uv = v.uv;return o;
}float4 frag(v2f i) : SV_Target
{float4 color = ExportIndex(i.uv.xy);return color;
}
这里处理不对也会出现马赛克或者锯齿,需要非常注意,举一个例子:索引值为0意味着采样第1张纹理,但是索引图的默认值也为0,所以要小心不要出现歧义,否则采样的时候权重会算错
最后就是索引图数据存储的问题,例如要确保同一个像素最多只混4张地形纹理(4张已经非常多了,绝大多数都是2-3张),那么就需要存储4个索引值(int 值,范围 0~7,或者 0~15,取决于你总共有多少张纹理)
- 最无脑的就是直接4个通道,每个通道存个 int
- 但是很容易想到2个通道的存储方案,既然你的总纹理张数不会超过 8or16,那么就可以按照下面方式存储:
即一个通道存储两个索引值(x, y),由于范围是 0~15,一个索引只占 4bit,而一个通道 8bit 刚好
解码也很简单:
int4 GetIndexArray(float2 val)
{int x = floor(val.x * 16);int y = val.x * 256 - x * 16;int z = floor(val.y * 16);int w = val.y * 256 - z * 16;return int4(x, y, z, w);
}
不过2个通道真的就是极限了嘛?必然不是!如果你的纹理总数只有8张,其实一个通道就够了
你可能会问,就算纹理总数只有8张,那么一个索引也会占 3bit,一个通道 8bit 必然不够,但事实上并没有说一定要存索引值,可以把位当索引,结果存 bool 值,即取或不取
举个例子:如果你的采样结果为 164/256,164 对应的二进制数为 10100100,翻译过来就是取第 1, 3, 6 这三张纹理,搞定,只需要一个位运算即可,代码略
当然如果你支持至多16张纹理的话,一个通道就不够了
这两种存储方式要根据实际情况来选,例如你一个像素至多只混两层,那么就要采用前面的方案,因为它无论如何只需要一个通道,如果你至多只支持8张纹理,就可以采取方案②以极限压缩数据
2.3 纹理采样与细节处理
准备好这些信息之后,工作就完成90%了,采样的 shader 写起来并没有难度,根据高度采样的思路代码其实就是一样的,唯一的变化就是多了一个采样索引的步骤,以及多了个 TextureArray 的定义:
float4 ctr1 = SAMPLE_TEXTURE2D(_Control, sampler_Control, i.uv).rgba;
float4 ctr2 = SAMPLE_TEXTURE2D(_Control2, sampler_Control, i.uv).rgba;
float ctrArray[8] = {ctr1.rgba, ctr2.rgba};
float2 indexTex = SAMPLE_TEXTURE2D(_Index, sampler_Index, i.uv).rgba;
int4 index = GetIndexArray(indexTex);
float4 ctr = {ctrArray[index.x], ctrArray[index.y], ctrArray[index.z], ctrArray[index.w]};
half4 lay1 = SAMPLE_TEXTURE2D_ARRAY(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.r], _tilingY[index.r]), index.r).rgba;
half4 lay2 = SAMPLE_TEXTURE2D_ARRAY(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.g], _tilingY[index.g]), index.g).rgba;
half4 lay3 = SAMPLE_TEXTURE2D_ARRAY(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.b], _tilingY[index.b]), index.b).rgba;
half4 lay4 = SAMPLE_TEXTURE2D_ARRAY(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.a], _tilingY[index.a]), index.a).rgba;
但是整体要注意的细节和坑还是挺多的,这里还是列一下吧:
0. 关于 TextureArray 和 TextureAtlas 方案的选择:这里选择的是 TextureArray,问题天然比前者少,但是要注意格式的一致、TextureArray 的设置,以及移动平台的支持
public static bool useTexArray
{get{switch (SystemInfo.graphicsDeviceType){case GraphicsDeviceType.Direct3D11:case GraphicsDeviceType.Direct3D12:case GraphicsDeviceType.PlayStation4:case GraphicsDeviceType.Vulkan:case GraphicsDeviceType.OpenGLES3:return true;default:return false;}}
}
- 索引纹理需要点采样(sampler_PointClamp),其它都需要双线性采样(sampler_LinearRepeat),如果你的权重图是只保留有效权重的方式,就需要在过渡边界手动插值
- 索引图的计算不能单纯暴力权重图,需要模拟采样结果,否则一定会出现马赛克问题
- 适当的降采样是一个不错的选择,低分辨率也能得到一个相对较好的结果,离线做法无需关心性能,如果前面4点包括后面的 mipmap 都处理好的了话,是不可能出现接缝、马赛克(锯齿)等问题的,此和最终贴图分辨率无关
- 既然使用 TextureArray,像一些高度混合上限、MSE 这种额外的纹理参数,也需要用数组保存,一样不可以序列化,Tiling 同理
- 为了方便美术制作及导出资源,尽量将这些功能集成,包括前面的 TerrainToMesh:

2.3.1 Mipmap 与 VirtualTexture
最后就是不得不提的 mipmap,理论上无论是地形纹理还是权重理论都是需要开启 mipmap 的,但是如果无脑开启 mipmap,在跨纹理采样的时候 uv 会突变,此时在突变处就会出现奇怪的缝隙:

这个是不可以接受的,因此要不直接关闭 mipmap,要不就在采样的时候手动指定 mipmap 层级以避免缝隙出现,这要根据摄像机距离或者相邻世界坐标差来判断具体采样的 LOD 等级
对于 URP,可以直接计算 ddx ddy,再通过 SAMPLE_TEXTURE2D_ARRAY_GRAD 进行采样:
float4 ddxddy = _MipmapCtrl * float4(ddx(i.worldPos.xz), ddy(i.worldPos.xz)); \
half4 lay1 = SAMPLE_TEXTURE2D_ARRAY_GRAD(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.r], _tilingY[index.r]), index.r, ddxddy.xy, ddxddy.zw).rgba;
half4 lay2 = SAMPLE_TEXTURE2D_ARRAY_GRAD(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.g], _tilingY[index.g]), index.g, ddxddy.xy, ddxddy.zw).rgba;
half4 lay3 = SAMPLE_TEXTURE2D_ARRAY_GRAD(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.b], _tilingY[index.b]), index.b, ddxddy.xy, ddxddy.zw).rgba;
half4 lay4 = SAMPLE_TEXTURE2D_ARRAY_GRAD(_SplatArr, sampler_LinearRepeat, i.uvSplat * float2(_tilingX[index.a], _tilingY[index.a]), index.a, ddxddy.xy, ddxddy.zw).rgba;
对于 VirtualTexture,由于它不止可用于地形,所以后面有机会做了的话再单独开一篇文章介绍
2.3.2 Unity TerrainTool 编辑器表现与游戏表现一致问题
前面提到过:由于 UnityTerrain 使用的方案和常规 Mesh 不同,因此要准备两个材质,一个给编辑器用,一个给实际效果用,编辑器那种 AddPass 的思路无需采样索引图,直接按照4张图混合的方式写就 OK,这点是最大的不同,但是还是有不少地方要注意(按照重要度排序)
0. 由于采取的是 Addtive 的颜色叠加方式,因此像所有的环境贡献(shader 里直接做叠加的那种)类似于雾效只需要在 BasePass 里面做一次,AddPass 里面不计算,除此之外所有 Lerp(color) 的计算,都需要再 lerp 一下当前 Pass 权重图的总贡献(blendTotal),这个很好理解,其实本质就是乘法分配律
#ifdef SC_EDITOR_ONLYhalf4 newColor = color;FinalColor(newColor, i);color = lerp(color, newColor, blendTotal);
#elseFinalColor(color, i);
#endif
- Tiling 的计算有所不同,差一个 TerrainTextureLength 的倍数
- 注意 Gamma 和 Linear 的配置,如果你是 Gamma 的设置自己写的软线性,可能会出现下图混合区间发白的现象:这种需要自己在计算权重时做一下 Gamma 矫正

- 最后就是高度混合,如果你的高度混合是参考的这篇文章,那么估计不好在 TerrainTool 下直接实现这个效果了,因为它有一步计算要拿到当前所有纹理的高度最值,可是 UnityTerrain 这种 AddPass 的方式,当你在第二个 Pass 中计算第 4~8 张纹理颜色贡献的时候,第 1~4 张的贡献已经算完了,也就是说你已经拿不到前4张的权重和高度信息,这种情况下只改 shader 估计不行,要改源码,所以在 TerrainTool 刷的时候,只能先不考虑高度混合,或者把有高度信息的放在一个组里
之所以做这个本质上还是想白嫖 Unity 的工具,毕竟自己再写一个 Mesh 的笔刷想想就痛苦
其它参考:
- 地表纹理混合优化 - 知乎
- [Unity Shader] 地形纹理合并 - 知乎
- unity32层大地形采样性能优化(1) - 知乎
- 怎么看待Unity 2021.2里最新的terrain地形工具在HDRP和URP里的效果? - 知乎