地形系统思路来自 ProjectS中的GPU Driven

前言

再来回顾下传统地表材质与我们定好的方案之间的对比:

  • 传统Weight Blend方案:通过纹理 + Mask图实现多纹理混合渲染
    • 每多一张纹理贴图,就要多一份遮罩数据。
    • 由于每点的mask总和为1,所以改变一层需要动到其他所有层的数据,耦合度太高,不方面大规模修改迭代。
    • 编辑器在处理edit layer时,由于需要全局的归一化操作,所以会让上层的layer表现非常奇怪。
    • 随着edit layer的增加,内存和操作延时也会是个问题。
    • 渲染时由于每个地块使用的weightmap各不相同,所以加大了合批处理的难度。
  • Material Id方案:确保每个点只有一种纹理贴图的情况下,通过一张Material Id图控制,最后通过双线性插值混合平滑,这里的材质不是指材质球,而是指一个数据结构,其中包含材质参数,贴图索引以及其他信息
    • 优点是这张MaterialId,它相当于一个间接的索引,每个值表示一张纹理图。8位的单通道materialID图就足以支持超过200种纹理图。它的大小可控,不会随着纹理数量的增加而增加。同时,由于不需要归一化操作,在支持多层edit layer方面也比较简单,保证上层layer覆盖下层即可。
      • 刷地形纹理的时候记录纹理值,例如0,1,2,3。。。然后从materialID图中得到具体的纹理
      • 所有纹理通过Unity的TextureArray进行存储
      • 通过双线性插值得到平滑效果
    • materialID的使用让纹理数量不成问题,但相较于weightmap算法有个明显的缺点,就是边缘过渡效果比较固定。为了解决这个问题,可以增加materialID的信息,做双层material混合。可以想象成为抹在蛋糕上的奶油(爆炒。
      • 8位Material Id A + 8位Material Id B + 混合权重 + 8位其他, B会覆盖在A上
    • 如果追求高品质的地表效果,可以在TextureArray加上法线,粗糙度,ao贴图等,通过自定义的Index规则进行索引

由于我们为俯视角,所以暂不考虑大面积过渡的情况,所以只需要一张Material Id即可,通过Houdini制作得到,具体步骤参考:ProjectS中的地形系统-Procedural Content Generation(PCG)

Material Id方案预研

为了验证效果和可行性,我们先临时写死所有纹理

image-20241002170033429

随后在Shader进行采样即可

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
float4 SampleTargetMaterial(int index, float2 uv)
{
if (index == 0)
{
return SAMPLE_TEXTURE2D(_GroundTex, sampler_GroundTex, uv);
}
if (index == 1)
{
return SAMPLE_TEXTURE2D(_GrassTex, sampler_GrassTex, uv);
}
if (index == 2)
{
return SAMPLE_TEXTURE2D(_RockTex, sampler_RockTex, uv);
}
return float4(1, 1, 1, 1);
}

float2 uv = input.texcoord;
float2 texSize;
_IndexTex.GetDimensions(texSize.x, texSize.y);

int index = floor(SAMPLE_TEXTURE2D(_IndexTex, sampler_IndexTex, uv).r * 256.0f);

// 手动做一次Tiling
float4 color = SampleTargetMaterial(index, uv * 300);

效果如下

直接采样结果

双线性插值

发现有一些问题

  1. 在不同Material交界处,有一些异常像素,具体原因和解决方案参考:解决地形渲染里不同材质之间的接缝问题
  2. 过渡过于僵硬,甚至是锯齿,因为我们是直接采样,没有任何插值的,所以需要双线性插值进行效果优化

先看锯齿,是因为我们index map是point采样点,即使2048m的地形给到4096分辨率的index map,也是无法避免的,因为原始贴图每个纹素值都是确定性的,没有过渡,可以在render doc看

一个锯齿的大小已经是一个纹素大小了,换言之,已经没法再小了

那么就只能通过双线性插值进行优化了

双线性插值示例,来自GAMES101

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float2 uv = input.texcoord;
// 四个角的uv,采样index
float2 uv00 = floor(uv * _IndexTexResolution) / _IndexTexResolution;
float2 uv11 = ceil(uv * _IndexTexResolution) / _IndexTexResolution;
float2 uv01 = float2(uv00.x, uv11.y);
float2 uv10 = float2(uv11.x, uv00.y);
// 四个角对应的index
int index00 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv00, 0).r * 256.0f);
int index11 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv11, 0).r * 256.0f);
int index01 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv01, 0).r * 256.0f);
int index10 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv10, 0).r * 256.0f);
// 由于已经对indexmap进行了采样,所以这里直接采样同一个uv处对应的不同材质即可
float4 color00 = SampleTargetMaterial(index00, uv * _MatTexTilling);
float4 color11 = SampleTargetMaterial(index11, uv * _MatTexTilling);
float4 color01 = SampleTargetMaterial(index01, uv * _MatTexTilling);
float4 color10 = SampleTargetMaterial(index10, uv * _MatTexTilling);
// 插值factor以indexmap为准
float2 f = frac(uv * _IndexTexResolution);
float4 color = lerp(lerp(color00, color10, f.x), lerp(color01, color11, f.x), f.y);

效果如下

双线性插值优化效果

但是对比线性插值前后的效果,发现有个问题:有些像素带往里过渡,有些像素带往外过渡,如图

插值前后效果

这显然会反过来增强锯齿感,看起来像是采样index map的时候有些信息丢失了,就像是被降采样了一样

检查后发现手动传递的_IndexTexResolution给的值是1024,而图为2048分辨率的,修正后就正常了

噪声过渡优化

发现过渡效果还是有些过于固定了,并且由于我们的indexmap分辨率和地形分辨率一致,即1texel/1m,所以一定会有这些难以避免的锯齿感

那么如何优化呢,简单的思路就是再单独存储每个材质的权重信息,在边界处混合时采样权重做透明度混合,这种方式优点就是可以做大面积的地表过渡效果,且效果十分可控,缺点就是实现起来较为麻烦,尤其是非常规的(常规指Unity那种每个material权重都单独存一个通道的做法)做法,比如文章开头提及的双层materialId+权重混合,或者 块存储的32层地形混合方案

作为俯视角游戏,不需要大面积过渡效果,那么有没有更简单的方案?

我想到了在交界处采样噪声图做扰动,从而获得更加随机的效果,用来掩盖锯齿感,示意如下

对于P点,在我们双线性插值的处理下,会是A0->B3的线性过渡,而要进行噪声扰动的话,其实就是让P在双线性插值的时候,上下左右4个纹素目标不再是B0,B1,A0,B3,而是一个经过噪声扰动的值,比如A1,B0,B4,B1,从而达到一种侵蚀的效果,削弱锯齿感

那么噪声哪里来呢?那当然从我们老朋友Houdini里随便连一个啦

还就那个一键生成

噪声图的生成只有一个注意点,那就是需要保证上下左右平铺的时候不能有bound,否则地形渲染也会出现,就像这样

错误示例

噪声混合代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float4 noise = SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, uv * _NoiseTilling, 0);

// 四个角的uv,采样index
float2 uv00 = floor(uv * _IndexTexResolution + noise * _NoiseFac) / _IndexTexResolution;
float2 uv11 = ceil(uv * _IndexTexResolution + noise * _NoiseFac) / _IndexTexResolution;
float2 uv01 = float2(uv00.x, uv11.y);
float2 uv10 = float2(uv11.x, uv00.y);
// 四个角对应的index
int index00 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv00, 0).r * 256.0f);
int index11 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv11, 0).r * 256.0f);
int index01 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv01, 0).r * 256.0f);
int index10 = floor(SAMPLE_TEXTURE2D_LOD(_IndexTex, sampler_IndexTex, uv10, 0).r * 256.0f);
// 由于已经对indexmap进行了采样,所以这里直接采样同一个uv处对应的不同材质即可
float4 color00 = SampleTargetMaterial(index00, uv * _MatTexTilling);
float4 color11 = SampleTargetMaterial(index11, uv * _MatTexTilling);
float4 color01 = SampleTargetMaterial(index01, uv * _MatTexTilling);
float4 color10 = SampleTargetMaterial(index10, uv * _MatTexTilling);
// 插值factor以indexmap为准
float2 f = frac(uv * _IndexTexResolution + noise * _NoiseFac);
float4 color = lerp(lerp(color00, color10, f.x), lerp(color01, color11, f.x), f.y);

最终效果如下:

对比

渲染效果提升

我们先来看看给地形加上最简单的Blinn-Phong光照

Blinn-Phong

。。。我的建议是不如不加,太平了,原因是我们地形所有法线都是一个朝向,没有任何细节

地形法线

先看看切线空间和法线的定义

一个坐标空间就是一个坐标系,只要有三个正交坐标轴xyz作为基轴就可以定义坐标空间中任意一点

切线空间是位于三角形表面之上的空间,切线空间中的xyz轴分别是t轴(切线方向)、b轴(副切线方向)和n轴。n轴代表的是该点的法线方向

我们知道,可以将物体表面凹凸对光照的影响存储到一张切线空间的法线纹理上,用于在低面数上获得较好的光照表现,即使是一个只有4个顶点的quad:

Quad + 法线

但是需要注意的是,所谓法线纹理只是主动把原本由硬件插值的像素法线变成从纹理采样的,这就意味着必须要保证法线纹理的分辨率“足够高”,多高算高?至少要能4x补全空出来的法线信息,例如一个128x128,64m的grid,法线纹理不能低于128/64 * 4 x 64 = 512,才能获得比较好的效果

然后视角来到我们地形这边,由于我们最小粒度的mesh是一个16x16的grid,uv方向和世界空间xz一致,即t(切线),b(副切线)全是0,所以法线信息全是(0,0,1)即全指向世界空间y方向,导致整个地形看上去缺少细节

首先明确一点,GPU Driven的地形一般不具备自己的法线信息,那么法线来源一般有两种

  1. 手动计算每个变换后的顶点法线,这往往需要借助Compute Shader来获取周围顶点信息,但因为我们分辨率是0.5m一个顶点,远处LOD的mesh分辨率更低,得到的法线信息非常低频,效果不好
  2. 导出地形高度图时顺带导出一份法线纹理,分辨率要比高度图高得多,至少为4倍,才能获得比较好的表现

但是我们仅仅导出高度图文件已经非常多了(单一个2048m的地块,就需要1365个高度图),如果要在Houdini导出高精度的法线纹理,渲染导出耗时都会增加,有没有比较讨巧一点的方案?

另辟蹊径,材质法线

常规的物体使用法线纹理计算光照时都是在原mesh法线基础上加上法线纹理的扰动,那么我们能不能就把地形看成一个2x2的grid,全部使用地表材质的法线纹理?

答案是,可以,但也不可以

可以是因为但看地形渲染效果是真可以

效果对比

不可以的原因有以下几点:

  • 地形法线和地标材质强制绑定了,相当于自身的法线信息还是缺失的,那么在需要单独用到法线的地方就必须额外渲染一次地形(例如SSAO),而且是高消耗的渲染,不能像正常物体那样一个极简的depth normal pass搞定
  • 说到底地形自身的凹凸法线是错误的,那么在一些起伏比较剧烈的地形处,光照计算就会出现问题,比如一处地形凸起原本应当有高光的,但由于法线方向与之不对应,导致最终效果不一定有高光

不过ProjectS本身是俯视角游戏,且大部分地形是偏平坦的,这就会让高光大部分由材质法线来计算也是没问题的

最终对比效果如下

最终对比

地表纹理Tiling重复性改善

TODO

参考

解决地形渲染里不同材质之间的接缝问题

块存储的32层地形混合方案