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

一、地形几何方案: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_ONLYhalf4 newColor = color;FinalColor(newColor, i);color = lerp(color, newColor, blendTotal);
#elseFinalColor(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里的效果? - 知乎

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C语言前瞻

文章目录 C语言基础简介编译方式分布编译示例流程一步编译 代码运行运行结果展示实际代码 C语言基础简介 关于C语言的书籍&#xff0c;文章有很多。C的历史我不赘述&#xff0c;只讲C语言的基础语法和使用&#xff0c;帮助大家入门&#xff0c;同时也是自己学习过程的一个回顾。…

HandBrake :MacOS专业视频转码工具

handbrake 俗称大菠萝&#xff0c;是一款免费开源的视频转换、压缩软件&#xff0c;它几乎支持目前市面上所能见到的所有视频格式&#xff0c;并且支持电脑硬件压缩&#xff0c;是一款不可多得的优秀软件 优点 ∙Windows, Linux, Mac 三平台支持 ∙开源、免费、无广告 ∙支…

Redis-高性能原理剖析

redis安装 下载地址&#xff1a;http://redis.io/download 安装步骤&#xff1a; # 安装gcc yum install gcc# 把下载好的redis-5.0.3.tar.gz放在/usr/local文件夹下&#xff0c;并解压 wget http://download.redis.io/releases/redis-5.0.3.tar.gz tar -zxvf redis-5.0.3.tar…

.NET 8 Video教程介绍(开篇)

教程简介 本文将简单描述视频网站教程&#xff0c;视频网站是一个类似于腾讯视频一样的网站&#xff0c;视频资源用户自己上传&#xff0c;然后提供友好的界面查看视频和搜索视频&#xff0c;并且提供管理页面对于视频进行管理&#xff0c;我们将使用Blazor作为前端&#xff0…

【Spring】SpringBoot的扩展点之ApplicationContextInitializer

简介 其实spring启动步骤中最早可以进行扩展的是实现ApplicationContextInitializer接口。来看看这个接口的注释。 package org.springframework.context;/*** Callback interface for initializing a Spring {link ConfigurableApplicationContext}* prior to being {linkpl…

【图像分类】【深度学习】【轻量级网络】【Pytorch版本】MobileNets_V2模型算法详解

【图像分类】【深度学习】【轻量级网络】【Pytorch版本】MobileNets_V2模型算法详解 文章目录 【图像分类】【深度学习】【轻量级网络】【Pytorch版本】MobileNets_V2模型算法详解前言MobleNet_V2讲解反向残差结构(Inverted Residuals)兴趣流形(Manifold of interest)线性瓶颈层…

智能驾驶汽车虚拟仿真视频数据理解(一)

赛题官网 datawhale 赛题介绍 跑通demo paddle 跑通demo torch 提交的障碍物取最主要的那个&#xff1f;不考虑多物体提交。障碍物&#xff0c;尽可能选择状态发生变化的物体。如果没有明显变化的&#xff0c;则考虑周边的物体。车的状态最后趋于减速、停止&#xff0c;时序…

Ubuntu18.04运行gazebo的launch文件[model-4] process has died报错

启动gazebo仿真环境报错[model-4] process has died [model-4] process has died [pid 2059, exit code 1, cmd /opt/ros/melodic/lib/gazebo_ros/spawn_model -urdf -model mycar -param robot_description __name:model __log:/root/.ros/log/8842dc14-877c-11ee-a9d9-0242a…

ts学习04-Es5中的类和静态方法 继承

最简单的类 function Person() {this.name "张三";this.age 20; } var p new Person(); console.log(p.name);//张三构造函数和原型链里面增加方法 function Person(){this.name张三; /*属性*/this.age20;this.runfunction(){console.log(this.name在运动);} }…

redis-持久化

目录 一、RDB RDB触发保存的两种方式 优劣势总结 二、AOF AOF持久化流程&#xff1a; 1、开启AOP 2、异常恢复 3、AOF的同步频率设置 4、ReWrite压缩 5、优劣势总结 Redis 4.0 混合持久化 redis是内存数据库&#xff0c;所有的数据都会默认存在内存中&#xff0c;如…

时间序列预测实战(十七)PyTorch实现LSTM-GRU模型长期预测并可视化结果(附代码+数据集+详细讲解)

一、本文介绍 本文给大家带来的实战内容是利用PyTorch实现LSTM-GRU模型&#xff0c;LSTM和GRU都分别是RNN中最常用Cell之一&#xff0c;也都是时间序列预测中最常见的结构单元之一&#xff0c;本文的内容将会从实战的角度带你分析LSTM和GRU的机制和效果&#xff0c;同时如果你…

论文导读 | 大语言模型与知识图谱复杂逻辑推理

前 言 大语言模型&#xff0c;尤其是基于思维链提示词&#xff08;Chain-of Thought Prompting&#xff09;[1]的方法&#xff0c;在多种自然语言推理任务上取得了出色的表现&#xff0c;但不擅长解决比示例问题更难的推理问题上。本文首先介绍复杂推理的两个分解提示词方法&a…

【数据结构】C语言实现带头双向循环链表万字详解(附完整运行代码)

&#x1f984;个人主页:修修修也 &#x1f38f;所属专栏:数据结构 ⚙️操作环境:Visual Studio 2022 一.了解项目功能 在本次项目中我们的目标是实现一个带头双向循环链表: 该带头双向循环链表使用动态内存分配空间,可以用来存储任意数量的同类型数据. 带头双向循环链表结点(No…

Windows 安装 Docker Compose

目录 前言什么是 Docker Compose &#xff1f;安装 Docker Compose配置环境变量结语开源项目 前言 在当今软件开发和部署领域&#xff0c;容器化技术的应用已成为提高效率和系统可移植性的关键手段。Docker&#xff0c;作为领先的容器化平台&#xff0c;为开发人员提供了轻松构…

矩阵的QR分解

矩阵的QR分解 GramSchmidt 设存在 B { x 1 , x 2 , … , x n } \mathcal{B}\left\{\mathbf{x}_{1},\mathbf{x}_{2},\ldots,\mathbf{x}_{n}\right\} B{x1​,x2​,…,xn​}在施密特正交化过程中 q 1 x 1 ∣ ∣ x 1 ∣ ∣ q_1\frac{x_1}{||x_1||} q1​∣∣x1​∣∣x1​​ q k …

Axure RP Pro 8 mac/win中文版:打造无限可能的原型设计工具

在如今的数字化时代&#xff0c;原型设计工具越来越受到设计师和产品经理们的重视。而Axure RP Pro8作为一款强大的原型设计工具&#xff0c;成为了众多专业人士的首选。 首先&#xff0c;Axure RP Pro8具备丰富的功能。它提供了多种交互元素和动画效果&#xff0c;使得用户可…

SR-LIO--手写紧耦合IESKF

1.ESKF初始化 void eskfEstimator::tryInit(const std::vector<std::pair<double, std::pair<Eigen::Vector3d, Eigen::Vector3d>>> &imu_meas) { //通过imu测量值初始化均值&#xff0c;协方差&#xff1b;(均值用于初始化零偏&#xff0c;协方差用于…

鸿蒙应用开发初尝试《创建项目》,之前那篇hello world作废

经过几年的迅速发展&#xff0c;鸿蒙抛弃了JAVA写应用的方式&#xff0c;几年前了解的鸿蒙显然就gg了。 这几年鸿蒙发布了方舟&#xff08;ArkUI Arkts&#xff09;&#xff0c;将TypeScript作为了推荐开发语言&#xff0c;你依然可以用FAJS,但华为推荐用StageArkTs!!!那么你还…

Java架构师软件架构设计导论

目录 1 软件架构设计导论2 HR角度看架构师3 软件架构设计概述4 顶级大师眼中的架构5 建筑中的架构师6 软件架构的发展阶段7 软件架构的意义8 架构是项目干系人进行交流的手段9 架构有助于循序渐进的原型设计10 架构是设计决策的体现11 架构明确系统设计约束条件12 架构与组织结…

二阶低通滤波器(二阶巴特沃斯滤波器)

连续传递函数G(s) 离散传递函数G(z) 差分方程形式 二阶巴特沃斯滤波器参数设计 设计采样频率100Hz&#xff0c;截止频率33Hz。 注意&#xff1a;设计参数使用在离散系统中&#xff01; 同理&#xff0c;其他不同阶数不同类型的滤波器设计&#xff0c;如二阶高通滤波器、二阶…