黑马 SLG 游戏《三国:谋定天下》怎么用 Unity 技术实现高效地形渲染?

三国时期以其风云变幻的政治环境和英雄辈出的时代背景,一直是游戏开发者与玩家创作的灵感源泉。SLG 游戏《三国:谋定天下》凭借多项对传统 SLG 手游的重大创新和改动,自 2024 年 6 月 13 日公测以来,迅速获得市场关注,iOS 手机端 6 月 16 日单日游戏收入高达 1,821,632 美元。

多职业的游戏设定不仅丰富了游戏的战略维度,也提升了玩家之间的互动性和合作性。更引人注目的是,为了给玩家带来更优的视觉与玩法体验,游戏采用了无极缩放技术,使得玩家可以流畅的从宏观战场切换到微观细节的观察视角。

在 Unite Shanghai 2024 游戏专场上,华娱网络 CTO 吴志强先生为大家分享了《三国:谋定天下》无极缩放和大世界地形渲染方案,讲解如何在移动平台实现更大观察视野、更流程丝滑的无极缩放与超多层地表材质混合等。

无极缩放功能

我是来自浙江华娱网络科技有限公司东风工作室的吴志强,我今天分享主题是《三国:谋定天下》无极缩放和地形渲染技术,我在《三国:谋定天下》主要负责引擎渲染、性能优化、基础设施的搭建。

《三国:谋定天下》是首款多职业战争策略手游,玩家可以选择天工、神行、青囊、司仓、镇军、奇佐六大职业,每个职业都能通过其特色的职业技能对沙盘局势造成影响,为了让玩家能够从战略视角清晰地查看沙盘上的局势,我们引入了无极缩放功能。

在《三国:谋定天下》里面一共有 13 个州,在这个视角下我们可以观察到 6 个州的局势。

随着相机高度的不断下降,可以看到局部地区更加详细的信息,比如说这张图我们能看到官道、同盟建筑、城池模型。

继续下降可以看到玩家建筑的图表、同盟标记的文本信息。

当相机下降到最低高度可以看到玩家建筑的模型、军队模型、行军线这些玩家更加关注的信息。

无极缩放对游戏体验的提升是巨大的。

它能让游戏目标更加清晰呈现在沙盘中,所有的同盟成员都能清晰知道需要攻打哪座城,哪里是战场的前线,哪里可能藏着老六。

基于上述原因我们在立项之初就决定要做好无极缩放,为了让无极缩放的需求得到满足,我们在《三国:谋定天下》的沙盘地形渲染中一共使用了7 级 LOD,其中 LOD0 的单个地块覆盖了3.2×3.2米的区域,约等于游戏中4×4个游戏格子。

LOD 级别每增加 1,其地块覆盖区域的边长会翻一倍,LOD6 的单个地块是 284.8 米,我们全地图的尺寸是1200×1200米,一共包含了225万个游戏格子。

这张图是 LOD0 的地块模型,它正好包含了游戏中的 16 个资源地。

第 6 级的地块会大很多,已经有小半个州的面积了,经过减面后其模型精度相比于 LOD0 的 Mesh 会低很多。

地形渲染技术方案

地块加载

为了方便后续讨论我们先引入两个名词,一个是Grid,整个大地图我们会把它均匀地划分成若干个 3.2×3.2 米的网格,每个小格子我们称为一个 Grid。第二个是LOD Chunk,也叫 LOD 地块,每个 LOD0 的 chunk 正好包含一个 Grid,每个 LOD1 的 chunk 包含了 2×2 个 Grid,以此类推。

每级 LOD 的 chunk 位置和数量是固定的,LOD 级别每增加一级,chunk 的数量会降低至四分之一,其面积也会相应地扩大四倍,这张图中有 64 个 Grid,64 个 LOD0 的 chunk,16 个 LOD1 的 chunk,4 个 LOD2 的 chunk。

前面提到 7 级 LOD,每一级 LOD 都会加载距离视野中间点最近的 5×5 个地块进内存,一共是 25×7,175 个地块。

这里有几个点是需要注意的,从上面的三张图可以清晰看出不同级别的 LOD 加载的地块区域会相互重叠的。另外相机大约需要移动一个 Grid 的距离才会触发 LOD0 的刷新,而 LOD2 的刷新大约需要移动 4 个 Grid 才会触发,也就是说LOD 的级别越大它的刷新频率越低。

我们会设定一个相机的基础高度 H0,当相机高度大于 H0 后会隐藏所有 LOD0 的地块,大于 2 倍 H0 会开始隐藏所有 LOD1 的地块,当相机高度达到 32 倍 H0 后,全场景只会显示 LOD6 的地块。这样的好处是当相机高度很高时,不用再刷新低级别的 LOD 地块,否则随着相机高度的增加,其移动速度也会成倍增长,从而导致每帧都需要刷新大量低级别的 LOD 地块。

这个算法是很简单的,有兴趣的同学可以看一下PPT。

通过上面的算法我们就能够知道每帧需要加载、卸载哪些 LOD chunk,而加载 LOD chunk 又需要做哪些事情呢?

第一步肯定是异步加载 LOD chunk 需要的资源,包括 Mesh 和地块相关的材质贴图。由于我们项目中使用了 RVT 来做地形渲染,所以在加载完这些地块资源之后,我们会在 GPU 中做地表的贴图混合,输出这个地块的 Albedo、Normal、Roughness 贴图,其中 Normal、roughness 我们选择存储在同一张贴图的 RG 通道和 B 通道。大致的渲染流程也比较简单,首先是修改相机矩阵和投影矩阵,保证相机的方向是自顶向下,并且使用正交投影,这样可以让当前烘焙的地块正好覆盖整个 RT。

然后是渲染地形,将地形的 Albedo 和 Normal 输出到 RT0 和 RT1 中,然后再渲染和当前地块相交的山体、贴花、道路、逻辑网格线这些地形上的静态元素,这里有一个好处是只需要通过 Alpha Blend 就能实现山体、贴花和地形做材质融合。

第三步是需要在 GPU 上做实时的 ASTC 贴图压缩,我们会将 RT0 和 RT1 都压缩成 ASTC6×6 的格式,有两个好处,一个是降低内存占用,一个是降低后续的 RVT 采样带宽,实时压缩 ASTC 这部分代码我在去年的时候已经在 github 开源了,有需要的同学可以直接在 github 上搜索 UnityAstcGpuEncoder 项目。

图上这一步是将地形渲染到两个 G-buffer 的 RT 中,由于这个地块没有包含山体,所以它的 Mesh 也很简单只有两个三角形,如果有山体的话 Mesh 会复杂很多。

将官道直接以Alpha Blend的形式叠加到 RT 上,贴花、山体这些都会以这样的形式叠加到地形上。

经过上面的步骤我们会得到当前地块两张 ASTC 格式的 G-buffer 贴图,为了后续支持场景建筑跟地表做材质融合,这两张贴图是不能直接使用的,而是需要将所有地块的 G-buffer 贴图都存储到两个全局的 texteure array 中,这样才能够实现在场景建筑的材质中可以采样任意位置的地表贴图,这也是 RVT 对资源占用最大的部分。两张 ASTC6×6 格式的贴图,尺寸是 720×720,会包含三级 Mipmap,包含 175 个 slice,这大约需要 100 兆的内存,低配我们可以通过限制贴图尺寸,将内存降低到 25 兆左右,效果勉强能接受。

最后是将当前地块的 VT copy 到 textere array 后还需要处理 Mipmap,但是我们并不能直接通过 GenerateMipmap 这个接口自动生成 Mipmap,这是因为同一个区域在不同 LOD 的表现可能差异很大,这个差异主要有两个因素造成,其一是我们的贴花它只会在 LOD 小于等于 3 的地块上显示。其二是为了降低相机拉高后的贴图重复感,我们会在渲染 LOD 大于等于 2 的地块 VT 时使用更大的 tiling 值去采样另外一组没有明显特征的低精度地表贴图,使用无明显特征的贴图是为了避免贴图中的一个小坑它突然变成一个很大的坑这种问题,所以说除了最后一级 LOD 外,其他的 LOD Mipmap 都是从相邻的 LOD 中复制过来的。

比如说这里加载到下图中 LOD1 的红色地块的时候,这个红色地块的第一级 Mipmap,它是直接从 LOD2 的绿色地块的 VT 的左下角区域复制出来的,同样如果 LOD0 四个地块已经加载完成了,也需要将 LOD1 中的红色地块对应区域分别复制到这四个 VT 的第一 Mipmap 中。这个可以看出 LOD chunk 它们之间是存在一定的层级关系的,我们将 LOD1 中的红色地块称为绿色地块的 child chunk,同时它也是 LOD0 中四个地块的 parent chunk,而 LOD0 中的四个 chunk 它们是互为 sibling chunk,完成了上述过程后,我们就可以为每一个地块创建其对应的 game object,然后通过加载出来的 Mesh 和烘焙后的 VT 进行最终的地块渲染。

但是并不是所有的创建出来都会直接显示,它是否需要显示是根据这里列出的 4 条显示规则决定的。

首先从我们之前提到的 Mipmap 更新算法可以看出,如果一个 chunk 它的 Parent chunk 没有加载完成的话,这个 chunk 是不能显示的,因为它的 Mip1 还没有更新。

第二如果一个 chunk 它的四个 Child chunk,没有全部加载完成,那么当前已经完成加载的 Child chunk 也不能显示的,不然就会出现 Parent chunk 的 Mesh 和 Child chunk 的 Mesh 会发生重叠,换句话说除了最后一级 LOD 外,其他的 LOD chunk 只有当自己的 sibling 全部加载完成自己才能显示。

第三点是 Child chunk 全部加载完成之后,这个 Parent chunk 就不需要显示了,这也是避免发生重叠。

最后再加上之前说过的相机高度增加后需要隐藏低级别 LOD 这一点,一共组成了四条显示规则。

有了这些规则我们就可以实现一个递归的算法来决定哪些加载完成的地块它应该要显示,这里首先是遍历所有第 6 级 LOD 的chunk,调用 UpdateChunkVisibility 这个函数,如果这个 chunk 它的 LOD 是大于 min visible LOD,并且这个 chunk 的所有 children 都是加载完成的状态,就递归遍历它的 child chunks,不然这个地块它就可以显示了。

地表材质融合

再回到之前提到过的地表融合问题上,前面已经说过了为了让融合材质能够采样任意位置的地表材质信息,我们会将所有地块的 G-buffer 贴图放进两张全局的 texture array 中,这为在 PS 中采样任意位置的贴图提供了可能性,但是想要知道某一个世界坐标位置它对应了 texture array 中哪一层贴图中哪一个像素仍然是很麻烦的事情。

因为我们并不能知道某一个世界坐标位置它当前使用的是哪一级 LOD,由于 LOD chunk 它的最小单位是一个 Grid,所以最简单的做法是创建一张lookup 贴图,这个贴图的每个像素对应沙盘中每一个 Grid,像素中这个 Grid 应该采样 texture 的哪一层贴图,以及这一层贴图的 LOD 层级。但是这个 lookup 贴图会比较大,在游戏中大概需要 375×375 这个分辨率的尺寸,这样在相机大范围移动的时候可能需要单帧更新上万个像素的值,这个开销也是不小的,同时一旦地图的面积扩大,开销会成倍增长。

另一种做法是将当前显示的地块信息通过 cbuffer 传递进 shader 中,然后按 LOD 级别从高到低的顺序依次判断应该使用哪一级 LOD,我们直接看下面的例子。

这个例子中绿色的地块表示正在显示的,红色表示隐藏的地块,白色是未加载的。

左边是 LOD3 右边是 LOD2,黑色的点表示的是查询点,黑色的大方框是当前 LOD 的加载区域,在这个例子中我们首先进行 LOD3 的查询,可以计算出查询点所处的 LOD3 chunk,在整个加载列表中的 index 是 4,这个 chunk 的状态是隐藏中,因此我们继续向下查询 LOD2,查询点所处的 LOD2 chunk 在 LOD2 的加载列表中 index 是 3,经过查询发现这个 chunk 的状态是显示中可以直接采样,这样只需要将查询点的坐标转化为 chunk 的贴图 UV 坐标可以采样得到查询点的地表信息。这个算法虽然很直观但是性能比较差,因为大部分视野内能看到的建筑都位于 LOD0 的地表上,因此可以反转一下遍历的顺序,改为从 LOD0 开始遍历,如果查询点的 LOD chunk 不是显示中的状态,再查询更高级别的 LOD,除此之外我们可以把当前可见最小的 LOD 级别传到 shader 里面去,使得可以快速跳过完全不可见的 LOD,这个在相机拉高之后会很实用。

这是最终的伪代码,我们首先从 min visible LOD 一直到最高级 LOD,然后计算查询点在当前 LOD 当中的 chunk index,如果 chunk index 小于 0 就表示这个查询点并不在当前的 LOD 的加载区域内,就直接去查询下一级 LOD,如果在加载区域内,我们通过 chunk 去拿到当前这个 chunk 它在 VT 中是属于哪一层 slice,如果 slice 也是大于等于 0,就表示这个 chunk 处于显示中的状态,不然还是只能继续查询下一级 LOD。

地表精度优化

上面的渲染方式实现后我们偶尔会在屏幕边缘出现这个图里面的明显分割线,这里应该也能看清,在分割线的一侧是比较清晰的地表,另外一侧是比较模糊的地表,这个很明显在分割线那里发生了 LOD 的切换,在很长一段时间内我们都认为这是一个比较合理的情况,一直没有处理。

但是这种情况出现很频繁,对画面的影响已经到了不能忽视的程度,经过一番排查后分割线下方是 LOD0 的地块,但是上面那一部分不是 LOD1,是 LOD2 的地块,这就相当不合理了。

经过排查我们发现 LOD0 到 LOD2 的显示区域如下面这三张图所示,从左到右依次是 LOD0、LOD1、LOD2,我们能够看到 LOD0 中显示的地块数量是 16 个,我们加载 25 个,为什么少了 9 个是因为显示规则中的第二条导致的,只有当自己的 sibling chunk 全部加载完毕才能显示,所以这里少了 9 个。LOD1 同样是因为这个规则而导致有 9 个 chunk 没有显示,直接导致了我们 LOD0 和 LOD2 地块是直接相接的。

所以虽然最终表现上觉得不太合常理,但是经过分析后我们发现它是正常的现象,图中的模糊区域我们实际已经加载了精度更高的地块,但是因为显示规则导致无法使用高精度的地块。第二条显示规则本质是因为 parent chunk 和 child chunk 的 Mesh 会相互重叠,所以只能要么隐藏所有的 child chunk 只显示 parent,要么隐藏 parent 然后显示全部的 child chunk。

但是我们现在碰到的问题是地表的贴图精度不够,并不是 Mesh 精度不够,所以可能只使用 parent chunk 的 Mesh,但是贴图使用 child chunk 吗?这是完全可以的,而且我们只需要做非常小的改动。在地表材质融合的时候我们已经实现了传入世界坐标位置就可以采样出任意位置 G-buffer 的功能,但是这个只能采样出已经显示的地块,不过我们可以直接忽略当前地块是否显示,只要地块完成了加载,我们就将地块信息写入刚刚的 ChunkInfo 数组中,这样的话就能够实现任意位置 G-buffer 的功能。

最后我们需要将地表 shader 修改一下,改成和融合材质一样,直接调用刚才实现的SampleTerrainAtPosition这个函数,去获取地表的信息,而不是说直接采样某一层贴图,这是一个动态的采样。

优化后同样视角下已经完成看不到分割线了,之前采样 LOD 贴图的区域已经转为采样 LOD0 的贴图了。

多层地表的材质混合

最后一个话题是多层地表的材质混合,2022-2024 年将近两年的时间我们只支持四层地表材质贴图的混合,而且由于一开始没有专门的美术同学复杂场景地形的绘制,当时完全是靠在 Photoshop 中盲画地形的分布图,随着一次次的画质迭代,在 2024 年 1 月下旬我们决定至少要支持16层地表贴图的混合,这其实在当时是非常大胆的决定,因为当时离公测封包只有不到 4 个月的时间了,程序实现完机制还需要美术熟悉工具,去绘制、去调优,所以我们要在 4 个月的时间把之前两年没有解决的问题解决。

从 1 月 20 日开始动工,大概耗费 1 个月的时间实现渲染方案和工具,我们为了敏捷开发直接在工具上使用了 Unity 的terrain tool package来支持地形的笔刷功能,所有的笔刷操作都是在 Unity 上进行的,编辑完成之后再导出自定义的资源格式。

这个图是当隐藏 Unity Terrain 切换成游戏内的 LOD Terrain 的效果图,我们对 URP 自带的 TerrainLit 材质做了大量的修改,来保证 Unity 的地形效果和游戏内 LOD 地形效果是几乎完全匹配的。

我们会将整个场景拆分成 36 个分组,每个分组下面包含了 16 个 Unity 节点,每个节点它覆盖了 51.2 米的范围,所有这些节点都位于单独的编辑场景中,运行时不会加载这个场景的。每个 Unity 节点都有一个对应的 terrain data 资源文件存储美术绘制的地形数据,这样可以支持美术协作绘制同一个场景的不同区域,在导出运行时需要的地形数据时,也会根据 terrain data 的 hash 判断是否需要导出,通常导出时间只需要几秒钟,全部导出不到 2 分钟的时间,能够加快美术的迭代效率。

我们目前支持 16 层地表贴图,技术上已经实现了最多 256 层,只是美术暂时用不上就没有开放,16 层虽然不多但是运行时不能直接在 PS 里直接采样,所以传统的 SplatMap 方案是不可行的,为了解决这个主要的方法是使用IDMap,每个控制点它存储的不再是 16 层贴图的混合强度,而是材质 ID,然后通过双线性插值做不同 ID 贴图的混合,这 16 层贴图会被存储到多个 texture array 中,一般会有 Normal texture array 和 Albedo texture array,贴图的 ID 对应其在 texture array 中的索引,但是 IDMap 的效果并不够好,主要还是因为双线性插值的结果不太可控,很多区域只能使用单层材质,对于确实需要多层混合效果的区域是不能支持的。

我们最终使用了一个 hybrid 的方法,在每个 3.2×3.2 米 block 内,我们可以单独指定使用 4 层地表贴图,这个 block 有 32×32 个控制点,每个控制点可以单独控制这 4 层地表贴图的混合强度,也就是说我们会有一张低精度的 IDMap,IDMap 中的每个像素记录的是一个 Block 内的 4 层地表贴图 ID,以及一张记录了控制点混合强度的全精度的 splat map,splat map 的分辨率大约是 IDMap 分辨率的 32×32 倍。

这个做法听起来很简单,但是有一个非常麻烦的问题需要解决,那就是Grid 之间它的 splat map 插值问题,我们直接看这个例子。

它的一个 block 中是 4×4 个控制点,左边的 block 是使用了 ID1、2、3、4 的地表贴图,右边的 block 使用了 ID1、2、4、5 的贴图,我们可以看到控制点 A 记录的混合权重是 0.3、0、0、0.7,它表示的是 30% 的 1 号贴图和 70% 的 4 号贴图做混合,控制点 B 中记录了权重是 0.6、0.2、0.2、0,它表示的是 60% 的 1 号贴图,20% 的 2 号贴图和 20% 的 4 号贴图。

如果直接使用双线性插值的话,在 AB 的中间插值权重是 0.45、0.1、0.1、0.35,但是中点左右两侧的像素会对这个权重值有不一样的解释,比如说 B 通道的值是 0.1,会被左侧的像素理解成是 10% 的 3 号贴图,右侧的像素会把它理解成是 10% 的 4 号贴图,这个是错误的,会在最终的图像上产生一条很明显的接缝。

为了解决这个问题我们需要把边界上的顶点复制一份,使其同时存在于左边的 block 和右边的 block 中,修改之前一个 block 包含了 4×4 个控制点,影响世界坐标系下 0.4×0.4 米的区域(假定这个小格子是 0.1 米),修改之后一个 block 中仍然包含 4×4 个控制点,但是它只影响0.3×0.3米的区域。图中左边的 0.3×0.3 米的区域包含 4×4 个控制点,每个控制点记录的混合强度、含义都是一样的,也就是它 4 个通道都依次表示 ID1、2、3、4 的贴图混合强度,也就不会出现插值问题,右边的 0.3×0.3 米的区域同样也包含了 4×4 个控制点,并且也包含了 A 点。但是这个 block 中它存储的控制点 A 的值和左边 block 中 A 的值是不一样的左边是 0.3、0、0.7,右边 0.3、0.7、0,因为左边区域第四个 ID 是 4,右边区域的4号贴图是第三个 ID。

在《三国:谋定天下》我们提到一共会创建576个 unity terrain 节点,每个都会导出一张 Splat 贴图,每一个 Splat 贴图包含了 17×17 个 block,每个 block 包含 32×32 个控制点,并且覆盖 3.1×3.1 米的区域。可以算出来我们每张 Splat 的贴图尺寸是 544×544,这里有一个注意点,由于跨 block 的 Splat 像素没有办法插值的,不能让它们同时出现在 ASTC 中的 block 中,我们的 block 现在是 32×32 的,所以它存储在 ASTC8×8 中是不会有插值问题的,但是如果 block 是 36×36,那就只能是使用 ASTC6×6 或者 4×4 这种格式了,要让它整除 36。

然后 IDMap 只有一张全局的,贴图宽度是 24×17 408 个像素,每个 IDMap 的像素需要两个字节存储 4 个 ID。

由于每张 Splat 贴图它覆盖的区域是 51.2×51.2,正好等于一个 LOD4chunk 的面积,所以在渲染 LOD0 到 LOD4 chunk 时,只需要采样一张 Splat 贴图就可以完成渲染,但是 LOD5 和 LOD6 chunk 需要更多张 Splat 贴图,因此我们将 4 张 544×544 的 Splat 贴图合并成一张新的 544×544 尺寸的 Splat1,这个 Splat1 的贴图是用于 LOD5 的 chunk 渲染。同样我们会将 4 张 Splat1 的贴图合并一张同尺寸的 Splat2 的贴图用于 LOD6 的 chunk 渲染,这个跟Mipmap是很像的,也是一种 LOD。

最终所有的 Splat 贴图加起来是 53.3 兆,由于每级 LOD 同时加载进内存的地块数量是 25 个,所以大约需要加载 75 张 splatmap,也就是 5.3 兆左右的贴图完成单个视角的渲染,在生成的第二级 Splat 贴图中,每个 block 包含的控制点的数量也将从 32×32 降低至 8×8 个,它更好可以放进 ASTC8×8 的 block 中,这是为什么我们要选 32×32 作为 block。

Unity 官方微信

第一时间了解Unity引擎动向,学习进阶开发技能

每一个“在看”,都是我们前进的动力