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

一、地形几何方案: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 的笔刷想想就痛苦

其它参考:

  • 地表纹理混合优化 - 知乎
  • [Unity Shader] 地形纹理合并 - 知乎
  • unity32层大地形采样性能优化(1) - 知乎
  • 怎么看待Unity 2021.2里最新的terrain地形工具在HDRP和URP里的效果? - 知乎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/169909.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Day36力扣打卡

打卡记录 T 秒后青蛙的位置&#xff08;DFS&#xff09; 链接 class Solution:def frogPosition(self, n: int, edges: List[List[int]], t: int, target: int) -> float:g [[] for _ in range(n 1)]for x, y in edges:g[x].append(y)g[y].append(x)g[1].append(0)ans …

Uniapp矩阵评分组件

uniapp矩阵评分组件支持自定义图标、选择颜色、评分等级。 <template><view style"width: 100%;overflow: hidden;"><view class"flex-sub flex-table flex flex-direction-column":style"{ --table-border-color: tableBorderColor…

Swifit学习第一天

学到了什么&#xff1a; 布局&#xff1a;ZStack 、HStack、VStack、Image 其它&#xff1a;点击事件、属性包装器ClampedValue、三目运算 1、图片缩放 2、属性包装器

Arcgis 日常天坑问题2——三维场景不能不能加载kml图层,着手解决这个问题

arcgis js api官网介绍kml图层的地址&#xff1a; https://developers.arcgis.com/javascript/latest/api-reference/esri-layers-KMLLayer.html从文档里看到kml图层有诸多限制&#xff0c;比较重要的两点是&#xff1a; 1、不能在三维场景&#xff08;SceneView&#xff09…

【Rust 日报】2023-11-19 solars:可视化太阳系

eyre 0.6.9发布 Eyre是一个可定制的应用程序错误报告库&#xff0c;通过诸如tracing等集成&#xff0c;允许进行可配置的格式化和上下文聚合。本次更新如下。 组织一个由共同决策驱动的异步维护团队。添加一个贡献指南。修复在丢弃已抹除的错误报告时发生的堆叠借用违规。修复由…

(动手学习深度学习)第13章 实战kaggle竞赛:CIFAR-10

导入相关库 import collections import math import os import shutil import pandas as pd import torch import torchvision from torch import nn from d2l import torch as d2l下载数据集 d2l.DATA_HUB[cifar10_tiny] (d2l.DATA_URL kaggle_cifar10_tiny.zip,2068874e4…

解决证书加密问题:OpenSSL与urllib3的兼容性与优化

在使用客户端证书进行加密通信时&#xff0c;用户可能会遇到一些问题。特别是当客户端证书被加密并需要密码保护时&#xff0c;OpenSSL会要求用户输入密码。这对于包含多个调用的大型会话来说并不方便&#xff0c;因为密码无法在连接的多个调用之间进行缓存和重复使用。用户希望…

【mediasoup】TransportCongestionControlClient 1: 代码走读

TransportCongestionControlClient 基于m77版本的libwebrtc ,但是TransportCongestionControlClient 并不是libwebrt中的,是mediasoup自己封装实现:TransportCongestionControlClient 用于发送端D:\XTRANS\soup\mediasoup-sfu-cpp\worker\src\RTC\TransportCongestionContro…

HarmonyOS开发(四):UIAbility组件

1、UIAbility概述 UIAbility 一种包含用户界面的应用组件用于与用户进行交互系统调度的单元为应用提供窗口在其中绘制界同 注&#xff1a;每一个UIAbility实例&#xff0c;都对应一个最近任务列表中的任务。 一个应用可以有一个UIAbility也可以有多个UIAbility。 如一般的…

BLIP-2:冻结现有视觉模型和大语言模型的预训练模型

Li J, Li D, Savarese S, et al. Blip-2: Bootstrapping language-image pre-training with frozen image encoders and large language models[J]. arXiv preprint arXiv:2301.12597, 2023. BLIP-2&#xff0c;是 BLIP 系列的第二篇&#xff0c;同样出自 Salesforce 公司&…

力扣贪心——跳跃游戏I和II

1 跳跃游戏 利用边界进行判断&#xff0c;核心就是判定边界&#xff0c;边界内所有步数一定是最小的&#xff0c;然后在这个边界里找能到达的最远地方。 1.1 跳跃游戏I class Solution {public boolean canJump(int[] nums) {int len nums.length;int maxDistance 0;int te…

C/C++多级指针与多维数组

使用指针访问数组 指针类型的加减运算可以使指针内保存的首地址移动。 指针类型加n后。首地址向后移动 n * 步长 字节。 指针类型减n后。首地址向前移动 n * 步长 字节。 步长为指针所指向的类型所占空间大小。 例如&#xff1a; int *p (int *)100;p 1&#xff0c;结果为首…

[机缘参悟-119] :反者道之动与阴阳太极

目录 一、阴阳对立、二元对立的规律 1.1 二元对立 1.2 矛盾的对立与统一 二、阴阳互转、阴阳变化、变化无常 》无序变化和有序趋势的规律 三、阴阳合一、佛魔一体、善恶同源 四、看到积极的一面 五、反者道之动 5.1 概述 5.2 "否极泰来" 5.3 “乐极生悲”…

科大讯飞 vue.js 语音听写流式实现 全网首发

组件下载 还是最近的需求&#xff0c;页面表单输入元素过多&#xff0c;需要实现语音识别来由用户通过朗读的方式向表单中填写数据&#xff0c;尽量快的、高效的完成表单数据采集及输入。 国内科大讯飞在语音识别方面的建树还是有目共睹&#xff0c;于是还是选择了科大讯飞的平…

让别人访问电脑本地

查看本地IP地址&#xff1a; 使用ipconfig&#xff08;Windows&#xff09;或ifconfig&#xff08;Linux/macOS&#xff09;命令来查看你的计算机本地网络的IP地址。确保*****是你的本地IP地址。 防火墙设置&#xff1a; 确保你的防火墙允许从外部访问*****。你可能需要在防火…

leetcode:504. 七进制数

一、题目&#xff1a; 链接&#xff1a; 504. 七进制数 - 力扣&#xff08;LeetCode&#xff09; 函数原型&#xff1a; char* convertToBase7(int num) 二、思路 本题要将十进制数转换为二进制数&#xff0c;只要将十进制num数模7再除7&#xff0c;直到num等于0 每次将模7的结…

React整理总结(五、Redux)

1.Redux核心概念 纯函数 确定的输入&#xff0c;一定会产生确定的输出&#xff1b;函数在执行过程中&#xff0c;不能产生副作用 store 存储数据 action 更改数据 reducer 连接store和action的纯函数 将传入的state和action结合&#xff0c;生成一个新的state dispatc…

【算法】二分查找-20231121

这里写目录标题 一、344. 反转字符串二、392. 判断子序列三、581. 最短无序连续子数组四、680. 验证回文串 II 一、344. 反转字符串 提示 简单 865 相关企业 编写一个函数&#xff0c;其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。 不要给另外的数组…

数据结构--串的基本概念

目录 串的基本概念 串的定义 串与线性表对比 ​串的基本操作​ 串的比较 字符集编码 乱码问题​编辑 总结 ​串的存储结构 ​串的顺序存储​编辑 串的链式存储 串的基本操作 1、求字串 2、比较 3、定位操作 总结 串的基本概念 串的定义 串与线性表对比 串的…

飞翔的小鸟

运行游戏如下&#xff1a; 碰到柱子就结束游戏 App GameApp类 package App;import main.GameFrame;public class GameApp {public static void main(String[] args) {//游戏的入口new GameFrame();} } main Barrier 类 package main;import util.Constant; import util.Ga…