一个完备的手游地形实现方案

一、地形几何方案:Terrain 与 Mesh

1.1 目前手游主流地形几何方案分析

先不考虑 LOD 等优化手段,目前地形的几何方案选择有如下几种:

  1. 使用 Unity 自带的 Terrain
  2. 使用 Unity 自带的 Terrain,但是等美术资产完成后使用工具转为 Mesh
  3. 直接使用 Mesh,地形直接由美术通过等 DCC 工具或 UE 工具制作(例如 worldmachine)后导入到 Unity
  4. 自己实现 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张纹理,那么就需要

  1. 8张地形纹理(废话)
  2. 2张权重纹理(RGBA,一般512)
  3. 采样 8+2 = 10 次,如果算上法线,则需要采样 8*2 + 2 = 18 次(单个 pixel)
  4. 如果是 UnityTerrain 这种做法,需要绘制两次

其实①②还好,因为图不算大,但是③采样那么多次是无法接受的,考虑到其实一个像素不可能出现这么多张纹理都有贡献的情形,可以先采样权重图,再写 if 判断权重是否为0,为0就不采样对应的地形纹理,这样确实没问题,但是这种写 if 的方法,事实上正是 if 的最坏情况,因为每个像素都可能会走向不同的分支,此时性能可能和暴力采样差不多

在此基础之上一个优化思路就是:可以预先计算每个 pixel 到底采样哪几张地形纹理,把它们的 index 存储到单独一张图上,然后采样的时候先点采样这张索引贴图,根据信息采样指定的 n 张地形贴图即可,一般 n = 2~4 完全足够

④就不用说了,完全没有必要,因此在这种优化之下,8张纹理的混合成本就为

  1. 8张地形纹理(没得优化,只能压缩)
  2. 2张权重纹理 + 1张索引纹理(索引纹理可以减通道,但是权重不太好减!后面会给出原因)
  3. 采样 n+3 or n+2 次,n 为一个像素最多混合的纹理个数,一般为3足够

这也是手游地形混合的主流思路,以多一张索引贴图(indexTexture)为代价,减少大量无意义的采样,也完全无需多次绘制

2.2 UnityTerrain 纹理资源导出

下面开始正题,就是思路有了怎么做的问题

考虑最复杂的情况:美术使用 TerrainTool 刷地形后导出 Mesh,然后微调后运用到游戏,这里面会多两个要处理的事情:

  1. 确保编辑器下(Terrain)和游戏运行时(Mesh)表现一致
  2. 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,那么就可以按照下面方式存储:

f=\frac{x}{16}+\frac{y}{256}

即一个通道存储两个索引值(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;
        }
    }
}
  1. 索引纹理需要点采样(sampler_PointClamp),其它都需要双线性采样(sampler_LinearRepeat),如果你的权重图是只保留有效权重的方式,就需要在过渡边界手动插值
  2. 索引图的计算不能单纯暴力权重图,需要模拟采样结果,否则一定会出现马赛克问题
  3. 适当的降采样是一个不错的选择,低分辨率也能得到一个相对较好的结果,离线做法无需关心性能,如果前面4点包括后面的 mipmap 都处理好的了话,是不可能出现接缝、马赛克(锯齿)等问题的,此和最终贴图分辨率无关
  4. 既然使用 TextureArray,像一些高度混合上限、MSE 这种额外的纹理参数,也需要用数组保存,一样不可以序列化,Tiling 同理
  5. 为了方便美术制作及导出资源,尽量将这些功能集成,包括前面的 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_ONLY
    half4 newColor = color;
    FinalColor(newColor, i);
    color = lerp(color, newColor, blendTotal);
#else
    FinalColor(color, i);
#endif
  1. Tiling 的计算有所不同,差一个 TerrainTextureLength 的倍数
  2. 注意 Gamma 和 Linear 的配置,如果你是 Gamma 的设置自己写的软线性,可能会出现下图混合区间发白的现象:这种需要自己在计算权重时做一下 Gamma 矫正
  3. 最后就是高度混合,如果你的高度混合是参考的这篇文章,那么估计不好在 TerrainTool 下直接实现这个效果了,因为它有一步计算要拿到当前所有纹理的高度最值,可是 UnityTerrain 这种 AddPass 的方式,当你在第二个 Pass 中计算第 4~8 张纹理颜色贡献的时候,第 1~4 张的贡献已经算完了,也就是说你已经拿不到前4张的权重和高度信息,这种情况下只改 shader 估计不行,要改源码,所以在 TerrainTool 刷的时候,只能先不考虑高度混合,或者把有高度信息的放在一个组里

之所以做这个本质上还是想白嫖 Unity 的工具,毕竟自己再写一个 Mesh 的笔刷想想就痛苦

其它参考:

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值