地形系统挣扎录——从Blender到Unity

  欢迎参与讨论,转载请注明出处。

前言

  之前由前篇决定场景的制作模式为Tile流,这种面片堆砌的流派对于方方正正的场景(如室内)很有效。但是对于如山地草原一般的场景就很难受了:
0
  可能粗看下去感觉还行,实际上是不禁细看的,地形是在方方正正的基础上揉捏而成,可以在斜边处可以到明显的采样变形,且草地边缘的石边是手动暴力贴上去的,制作起来非常的耗时且嗨奴。经此一役后美术决定引入业界更通用的场景制作方案——Terrain流:
1
  如上图所示,所谓Terrain流就是非常常见的那种在场景编辑器对模型揉揉捏捏塑形,涂涂抹抹上色的制作流派。对于制作地形复杂、贴图混杂的场景可谓相当受用。那么按理来说直接使用现有的地形编辑工具不就好了?——若是那么简单便没有本篇喽。
  首先经过一番实验后发现,由于美术风格、建模习惯等因素,决定采用传统建模方式,而非这些场景编辑器惯用的揉捏平面生成高度图。如此一来这些场景编辑器便都Pass,将目光放到了传统建模软件·Blender上……于是便有了本文的副标题:从Blender到Unity。Unity版本为2019.4(URP),Blender版本为2.9。

牛刀小试

  由于这波算是造轮子了,没得现有的套件可蹭,所以还是先整点最基本的实现吧。在抛开建模那块,Terrain流的贴图着色说白了就是由1张索引贴图+若干张地形贴图组成,通过工具绘制索引贴图,最终根据索引采样对应的地形贴图,实现贴图混杂的显示效果:
2
3
  大致Shader实现如下:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D(_TerrainMap1, sampler_MaskMap, input.uv) * mask.r;
half4 color2 = SAMPLE_TEXTURE2D(_TerrainMap2, sampler_MaskMap, input.uv) * mask.g;
half4 color = color1 + color2;
return color;
}

  可见实现原理并不算复杂,将贴图的每个通道(RGBA)作为贴图的透明度值显示即可,但一切才刚刚开始……

图集 OR 纹理数组

  首先第一个问题便是贴图的管理方式:根据上文代码可以看出,目前的地形贴图是一张张独立的存在。那么就会变成有多少张地形纹理就要开多少个口了,不利于环保且哈批。业界相关流行的解决方法有图集(Atlas)纹理数组(TextureArray)两种。
  所谓图集便是将各种贴图整合进一张大图里,按偏移采样,是很常见的做法:
4
  相关的采样方法可以参考冯乐乐的地形纹理合并,大致如此:

1
2
3
4
5
6
7
8
9
10
11
12
half4 SampleIndex(int index, half2 pos) {
half2 uv = frac(pos) * 0.484375 + 0.0078125;
int lines = floor(index / 2);
half2 uv2 = half2(index - lines * 2, lines);
uv2.y = 1 - uv2.y;
uv += uv2 / 2.0;
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
return color;
}

  大致思路为根据要采样的贴图索引(index),结合世界坐标的小数点(pos),得到对应的uv坐标。其中这里的0.4843750.0078125为采样收缩的魔法数字(0.0078125=1/128,128位图集的宽高,0.484375=0.5-0.0078125),这么做的理由与瓦片地图一致:由于贴图的密集性,线性采样两者的贴图边缘会产生混色现象,注意红色框选的部分:
5
  为了解决这个问题,于是选择人工收缩了采样范围。但相对而言,在镜头放大后还是能看得出贴图之间并非是严丝合缝的,毕竟采样已经不连续了,算是一个不大不小的缺陷吧。
  而纹理数组则是个PS4时代以来的新玩意,在Unity官方文档中明确了支持的平台,可见一般手机设备对此不见得能够支持。但我们做的是PC端便无所谓了,用起来用起来!
  纹理数组的原理很简单:一次性把多张贴图打包成新的数据,与一般贴图用法无异,只是采样的API有所不同,需要指定index。这么一来可就我可就不困了呀,拿先前的Shader改造下对比:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, 0) * mask.r;
half4 color2 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, 1) * mask.g;
half4 color = color1 + color2;
return color;
}

  较之图集那花里胡哨的采样方式真是爽快多了,而且还没有合缝问题。美中不足在于Unity并没有提供直接的创建纹理数组的方法,需要自己撸一个,限于篇幅便不再列出,给个参考便可。

传统UV OR 世界坐标

  从图集的采样算法可以看出是不便使用模型本身的uv的,而是要用世界坐标作为驱动代替。但现在决定使用纹理数组,那么这便成了个问题。使用传统UV采样在多数情况下并没有什么问题,但是在这种情况下便暴露了:
6
  如图所示,山峰出现了很夸张的拉伸现象,这是UV划分精度不足导致的(整个山峰的面采样了一张贴图),对此可以通过划分UV解决,但规模一旦上去后,这会给美术带来不小的负担。那么来对比下世界坐标的情况:
7
  虽然在采样上有点小瑕疵,但的确是好多了。由于世界坐标是三维的,而纹理采样是二维的。若是只按某两个维度进行采样,在某些面必然会发生问题:
8
  于是我们可以根据法线判定面的朝向以决定使用世界坐标的哪两个轴,但在某些斜面下实际上需要用到两个平面维度的结果混合。于是干脆一步到底,根据法线三个轴的值决定三个平面维度(zy、xz、xy)的混合度,是为三向贴图(Tri-planar Mapping)

1
2
3
4
5
6
7
8
9
10
11
12
half4 Frag(Varyings input) : SV_Target
{
half3 weight = pow(abs(input.normal), _BlendSharpness); // _BlendSharpness可增大混合效果
weight = weight / (weight.x + weight.y + weight.z); // 质量守恒
half4 color = 0;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.zy) * weight.x;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.xz) * weight.y;
color += SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.position.xy) * weight.z;
return color;
}

  但由上图也看得出来,在某些情况下它并非是完美的(或者没必要)。且性能消耗大,采样次数翻了三倍。由此可见两者皆有其使用场合,故决定通过分支开关控制两者的切换。当然最理想的情况自然是让美术好好分UV(

界限突破

  讨论了以上两个问题后,还有一个很明显的问题:若是使用RGBA四个通道代表四种地形贴图的透明度,那么首先可使用的地形贴图数量就太少了,并且需要同时对这四张贴图进行采样,若是加上三向贴图更是达到了恐怖的12次采样!这绝对是不可接受的,仔细想来,实际上多数情况只会有两种贴图混合,加上Demo的像素风格更是不会发生太多的混合现象。那么便可以改成同一像素最多采样两种地形贴图的方案了:

1
2
3
4
5
6
7
8
9
half4 Frag(Varyings input) : SV_Target
{
half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, input.uv);
half4 color1 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, mask.r);
half4 color2 = SAMPLE_TEXTURE2D_ARRAY(_BaseMap, sampler_BaseMap, input.uv, mask.g);
half4 color = lerp(color1, color2, mask.b);
return color;
}

  如上所示,新方案下RG通道代表地形贴图的索引值,B通道作为两者颜色的混合度(255则完全显示color1,反之亦然),如此便可只用到三个通道的前提下支持多种地形贴图,同时只采样2次,哪怕加上三向贴图也算在可以接受的程度了。

在Blender的战斗

  以上Shader看着简单,到了Blender这边的Shader开发那麻烦可大了。由于可视化的因素,我们需要用特定颜色代表对应的索引值,且要选择通道明确的颜色,也就是:黑(RGB)、红(R)、绿(G)、蓝(B)、黄(RG)、紫(RB)、青(GB)。鉴于黑色的特性,将之作为地形的默认底色。其余颜色根据深浅与黑色进行混合。其中的关键便是如何识别单/双通道颜色的有效性,对此我选择双通道颜色值必须相差小于10才有效,反之则是值大的通道有效
  这看着也不算很复杂,可恶的是Blender的新版渲染引擎Cycles并不支持代码形式的Shader(OGL),只支持连连看。而老板渲染引擎Eevee只支持在渲染模式下看到结果,这样便达不到实时修改预览的效果了。于是乎化身为毛线团战士:
9
10
11
  主要Blender内置的节点并不支持分支判断,于是只能老老实实把每种颜色的处理都弄上去加一块,使用比较节点取得0值来屏蔽不该显示的部分。除此之外便是贴图资源不能作为参数值存在,只能老老实实创建贴图节点在外头进行填写,可由于三向贴图的加入,每种贴图还得手动填三次,算上总和一共是21种了……
12
  不过所幸效果还是不错的,以后再有这样也许可以考虑看看源码了……
13

脚本转换

  在Blender绘制的贴图还无法直接用于游戏,毕竟游戏可顶不住这样的Shader写法。于是便需要一Python脚本将之转换为游戏Shader可直接食用的贴图。实现大致思路与Blender Shader无差,只是在CPU端便可暴力条件判断了,爽歪歪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def convert_index(color):
a = get_max_channel(color)
b = get_mid_channel(color)
if a == 0:
return INDEX_MAP['black'], 0, None, None # Black
if a - b < 10:
v = a + b
alpha = (a + b) // 2
if v == color[0] + color[1]:
return INDEX_MAP['yellow'], alpha, 0, 1 # Yellow
elif v == color[0] + color[2]:
return INDEX_MAP['purple'], alpha, 0, 2 # Purple
elif v == color[1] + color[2]:
return INDEX_MAP['azure'], alpha, 1, 2 # Azure
else:
if a == color[0]:
return INDEX_MAP['red'], a, 0, None # Red
elif a == color[1]:
return INDEX_MAP['green'], a, 1, None # Green
elif a == color[2]:
return INDEX_MAP['blue'], a, 2, None # Blue
return INDEX_MAP['black'], 0, None, None # Black

  当然为了图片的可视化,索引不会按照原值输出,而是return math.floor(index * 255 / 6),自然游戏Shader那边也要做相应解码处理。
  除此之外,由于索引贴图的设计因素无法达到传统素材做法的线性采样效果,只能如二图那般马赛克:
14
15
  为了减少这种马赛克的感觉,便使用脚本判断像素周边有多少不相同索引的像素,以此按比例减少透明度:
16
  另外诸如设置图片输出宽高,指定颜色对应的索引值等功能限于篇幅在此便不展开了。

后记

  本篇的内容有点超乎我想象,也到了收获的时候了,最后来看看成果吧:
17
  在本次地形系统的调研中断断续续挣扎了一个半月,可谓把各种坑都踩了一遍。属实离谱,当然也与最近项目较忙有关。目前看来效果与先前并无太大差距,主要在创作模式多了新的道路,相关美术效果仍会持续优化,期待由此开端最终会演进到怎样的程度呢?