[Research] 实时可交互沙土模拟方法 - 渲染部分

本篇续前文介绍一种移动端真实感沙土渲染方法

Real-time Interactive Sand Simulation in Mobile Platform


渲染部分/Shading Part

做完物理解算,还需要做沙子的渲染部分。这里就不得不提《风之旅人》,在过去十年发行的众多独立游戏中,《风之旅人》几乎在各个方面都是卓越的典范。《风之旅人》可以说是“用沙子建造的”,它的沙子不仅仅是一个吸引眼球的视觉效果,而同时是与游戏核心玩法和整体体验息息相关的。
没有沙子,就没有《风之旅人》。

《风之旅人》中的风沙渲染

解刨《风之旅人》中的风沙渲染

正因为有《风之旅人》这样的珠玉在前,所以实现我们自己的沙子shader之前,我们先来解刨一下《风之旅人》的沙子shader是怎么实现的。
thatgamecompany首席工程师John Edward曾经在GDC做过演讲:“Sand Rendering in journey”(传送门:https://www.youtube.com/watch?v=wt2yYnBRD3U&ab_channel=GDC),本文对《风之旅人》的沙子shader解读大部分整理自上述课程。
《风之旅人》中的沙子光照模型可以用下图概括:

《风之旅人》中的沙子渲染模型

由于沙子是由许多微小的晶粒组成的,因此不能简单地用光滑表面对其进行光照建模,在渲染过程中,我们需要考虑沙子本身的粒度。

1 - Diffuse Part

漫反射部分是整个shader的实现流程中最简单的部分,直接用lambert模型即可:

I=4(N[1.0,0.3,1.0]L)I = 4*(N \odot [1.0,0.3,1.0] \cdot L)

2 - RimLight Part

《风之旅人》中有着大片连绵的沙丘,为了让玩家能够看清楚每一个沙丘的边界,John Edward在shader中添加了Rim Light,这部分可以用Fresnel实现:

I=(1.0NV)powstrengthI = {(1.0 - N \cdot V)^{pow}} * strength

现在沙丘看上去像这样,整体还比较光滑:

3 - Normal Part

为了让沙丘看上去不那么光滑,我们可以使用Normal Map通过扰动沙丘表面的法线方向来近似展现出沙子的粒度,但《风之旅人》里并没有使用传统意义上的Normal Map,而是使用一张Random Noise Texture来模拟沙子的法线,就像下图这样:

每个像素的 R、G 和 B 分量存储沙子表面法向量的 X、Y 和 Z 分量。由于图片中存储的颜色分量范围为[0,1],因此使用时我们需要将各个分量的范围重映射到[-1,1]:

float3 SandNormal (float2 uv, float3 N) {
    float3 random = tex2D(_NoiseTex, uv).rgb;
    // Random direction: [0,1]->[-1,+1]
    float3 S = normalize(random * 2 - 1);
    return S;
}

现在它看起来像这样,左侧是没有法线模拟的沙子,右侧是有法线模拟的沙子:

4 - Specular Part

高光部分,《风之旅人》做得比较独特。John Edward表示,他们团队在开发《风之旅人》时,希望游戏中的沙子感觉更像是流体,而不是固体。为了强化这种感觉,他们使用一种称之为Ocean Reflection的镜面反射,灵感来源于日落时在海洋或湖泊上看到的反射:

I=(NH^)powstrength,H=V+LI = {(N \cdot \hat H)^{pow}} * strength,H = V + L

float3 OceanSpecular (float3 N, float3 L, float3 V) {
 	// Half direction
    float3 H = normalize(V + L);
    float NdotH = max(0, dot(N, H));
    float specular = pow(NdotH, _OceanSpecularPower) * _OceanSpecularStrength;
    return specular * _OceanSpecularColor;
}

加上Ocean Specular 之后,现在整个画面看起来是这种感觉:

5 - Glitter & SandRipple Part

最后两个部分是《风之旅人》中的沙子渲染最为独特的部分:模拟沙子的细碎闪光和沙子在风力作用下形成的波浪。
细碎的闪光是沙丘表面一个不容忽视的特征,在真实沙丘上我们之所以可以看到闪光,是因为一些沙粒随机地将光线反射回我们的眼睛。

但我们无法仅使用Specular来模拟闪光,因为几何体表面的法线变化通常太过连续,无法反射得到零碎且亮度大于1.0的光线。因此,《风之旅人》将闪光效果作为一个单独的着色阶段来实现,这部分借鉴了PBR模型中的微表面理论(Micro-facet Theory),它将沙丘表面视为由无数个微观镜子组成,每个镜子都有随机的方向。
Normal Part中我们已经生成了一张Random Noise Texture,因此在这里我们就可以直接采样噪声纹理,将随机方向与构成沙子表面的每一粒沙子的微表面相关联。我们将这个噪声方向称为闪光方向,由于闪光方向是完全随机的,因此反射方向也将是完全随机而不连续的。最后结合Bloom屏幕后处理,我们就能够实现沙子表面细碎的闪光:

float3 GlitterSpecular (float2 uv, float3 N, float3 L, float3 V) {
    float3 G = normalize(tex2D(_NoiseTex, uv).rgb * 2 - 1);
    // Light that reflects on the glitter and hits the eye
    float3 R = reflect(L, G);
    float RdotV = max(0, dot(R, V));
    if (RdotV > _GlitterThreshold) return 0;
    else return (1 - RdotV) * _GlitterColor;
}

最后是沙子表面波浪的部分,这是沙丘表面另一个不容忽视的特征。在风力作用下,沙粒之间由于摩擦力的作用而聚集在一起,形成波浪。这些波浪的变化不仅取决于沙丘的形状,还取决于风的方向和速度。大多数沙丘都仅在其一侧出现波纹:

我们可以使用法线贴图来解决这个问题,但每次对沙丘做一些细微修改或创建新的沙丘时,都必须重新绘制法线纹理,这会显著减慢资产的生产速度。因此《风之旅人》中采用了一种更高效的解决方案:程序化(Procedural)波浪纹理。
根据观察可以得出:波浪的形状会随着沙丘的倾斜度而变化,浅而平坦的沙丘波浪比较柔和;陡峭的沙丘则呈现出更深的波浪,因此我们可以先准备两张可平铺的法线贴图,一张用于较浅的沙丘波浪,一张则用于较深的沙丘波浪,然后根据每个沙丘的陡峭程度、风的方向来混合这两张贴图。
这里采用的计算方式是:取沙丘表面法线与世界方向Y轴计算点积,计算结果越接近1,则两个向量之间的夹角越小,因此越陡峭;波浪的方向则根据风的方向与沙丘表面法线的点积来决定,但混合比例与陡度相反,因为通常沙丘逆风的一侧才会生成波浪。
最后混合所有计算结果,沙丘看起来就是游戏中呈现的模样:


实现我们自己的沙子渲染

《风之旅人》是自由视角,而我们的沙子模拟是俯视角,因此在渲染流程上,我们可以将《风之旅人》的方法作为参考,但无法照搬,因为视角的不同会带来很多效果和实现方式上的差异。
由于是俯视角,摄影机和光源角度都不会变化,因此显而易见的是我们首先可以删掉Ocean Specular部分的高光分量;其次,由于我们只做一小堆沙子,不像《风之旅人》中大规模的沙丘,小堆沙子不受风力影响,不会产生波纹,因此Ripple部分也可以排除。最后我们需要增加两个《风之旅人》的沙子Shader中没有的部分:杂质分量和Alpha分量,并对闪光分量的实现做一些修改,体现我们自己沙子Shader的特点。
下面四张分别是我们的沙子shader中需要预先输入的漫反射纹理、杂质纹理、法线纹理以及Alpha纹理:

1 - Diffuse Part & Impurity Part

漫反射部分比较简单,用一张可平铺的沙子纹理做采样就可以了。
但如果仅用一张Color Texture,我们很快就会发现问题:《风之旅人》中的大片沙子都非常纯净,没有任何杂质,如果我们照搬到小沙堆的渲染上,就会发现一个严重的问题:沙子显得太纯太假了,不够写实,而且重复感严重。
因此我们可以再正片叠底一张Impurity Texture到颜色部分,增加沙子的质感。需要注意的是Impurity Texture的平铺方式需要和Color Texture不同,减少重复感。

2 - Normal Part

法线部分,同样是用一张可平铺的法线纹理,lambert模型做shading即可,丰富一下沙堆表面的明暗变化。这里一层法线就够了,不需要再混合做ripple了。

3 - Glitter Part

日常经验告诉我们,当扫除灰尘的时候,顺着扫除的方向会扬起烟尘。而沙子相比烟尘效果要更复杂一些,不仅有烟,还有细碎的闪光,这些闪光只在扬起烟雾的时候出现,不断闪烁,随后又消失,就像下图这样。

为了让渲染结果更逼真,这部分闪光颗粒的模拟是不可或缺的,那么这部分怎么实现呢?上文模拟部分我们留了一个伏笔:解算纹理中我们留了一个G通道作为闪光分量,这个分量就是用在这里模拟闪光的。
首先我们回顾一下《风之旅人》中沙子闪光的实现,《风之旅人》中使用一张RGB噪声纹理模拟随机的沙子表面法线变化,实现视角相关的闪光效果。但我们的摄影机是静止的,所以明显不能简单地把噪声作为法线方向。
这里我们同样生成一张procedural noise texture,但和《风之旅人》中的噪声纹理不同,它是一张单通道纹理,我们实际只取用其灰度值。这个灰度值不作为法线,而是直接叠加到沙子的diffuse部分生成高光,当然叠加之前需要先让其通过一个高光滤波器,只保留其中亮度值大于一定阈值的像素点(下图左侧为灰度噪声图,右侧为闪光点反相):

然后把这部分作为亮度信息相加到diffuse上去即可生成闪光。但此时我们会面临两个问题:
1、闪光点怎么闪烁?烟雾运动停止后闪光怎么熄灭?
2、闪光点怎么跟随擦除方向和烟雾运动?

首先闪烁效果的部分,我们可以在生成noise texture时用时间作为种子,这样noise texture就会随时间不断变化,且与摄影机视角无关,就能够实现闪烁。
运动和熄灭效果的实现就涉及到解算纹理的G通道:
如果我们直接把闪光分量叠加到Thickness Buffer,会造成Thickness Buffer在模拟中变得不精确,甚至带来数值耗散;而如果直接叠加到diffuse,又无法产生运动。因此最好的办法就是让闪光分量单独作为一个通道来模拟,这样每次迭代过程中,不仅Thickness Buffer和Velocity Buffer随着迭代改变,闪光分量也随着速度场的改变而改变,就得到了闪光随着烟雾运动的效果。

其次,我们可以让G通道的亮度随解算时间t衰减:

Lumi(G,t+1)=Lumi(G,t)(1/2)tLumi(G,t + 1) = Lumi(G,t) * {(1/2)^t}

这样就能够做到扬起烟雾一定时间后,闪光熄灭。

4 - Alpha Part

最后Alpha部分,这部分既描述物理模拟中的Thickness Buffer,也描述渲染过程中的沙堆Alpha。如上文所述,这部分分量可以由artist绘制得到,并在解算的过程中会不断发生改变,在渲染着色阶段,我们直接将其作为Alpha输出即可。
这里需要注意的是,实际上Alpha Texture是可以合并到Color Texture的Alpha通道的,但Color Texture的RGB三个通道必须要保持四方连续,因为物理解算过程中Alpha通道的可见范围会不断发生变化,原来周围沙堆不可见的透明区域可能会在模拟途中变得可见,所以颜色通道必须是可平铺的。

One Last Thing

至此所有的物理解算和渲染都已经完成了,但还有最后一步需要完成:如何判断沙子已经被玩家刷干净了?遍历每一个厚度场像素去计算剩余沙子的比例显然不合适,更何况Unity中RT的颜色值在CPU侧还无法直接读取。
因为沙子总余量的统计不需要特别精确,所以这里我们可以借助mipmap来实现对整个厚度场总厚度的估计,每次玩家交互后一旦厚度场发生变化,我们就重新生成厚度场的mipmap,然后把RT转换为Texture2D:

Texture2D ToTexture2D(RenderTexture rTex) {
RenderTexture.active = rTex;
    Texture2D tex = new Texture2D(rTex.width, rTex.height, TextureFormat, false);
    tex.ReadPixels(new Rect(0, 0, rTex.width, rTex.height), 0, 0); 
tex.Apply();
    return tex;
}

最后取最后一层或倒数第二层mipmap level的中心像素颜色c即可,我们可以设定一个判定阈值thresholdc < threshold就代表沙子已经刷干净了。

最终的模拟中我们共用到:
• 2张RenderTexture,大小512x512,格式R32G32B32A32_SFLOAT
• 1张RGBA纹理(RGB通道为沙子漫反射颜色,A通道为沙子的厚度场)
• 1张RGBA杂质纹理
• 1张法线纹理

2张RT用于物理解算,其他纹理用于渲染。在配置2060 GPU的PC上物理解算部分GPU耗时0.21ms,渲染部分GPU耗时0.18ms,总耗时0.39ms;安卓端实机能以平均41.2FPS的帧率运行(测试机型:华为荣耀P20 Pro),对比2D MPM的21FPS,效率高出一倍。

最后,把所有的拼图拼到一起,DRSS就大功告成啦!来看看最终的效果吧:

总结/Conclusion

本文简要介绍了一种实时移动端真实感沙土交互与渲染方法,能够模拟带厚度信息的俯视角干质沙土。实现DRSS的过程中最大的体会是:不存在现有解决方案的效果不一定就是无法实现的,根据实际需求尝试对做现有方法做一些优化改进,多走一步说不定问题就迎刃而解了。相比简单抽卡,以及《遇见逆水寒》中“擦玻璃”式的交互模式,沙土交互存在更多的不确定性,更能调动起玩家的兴趣,让玩家参与其中。

但DRSS目前也还存在几点不足,未来可以有如下改进方向:
1、颜色和法线自始至终都是静态的,在解算过程中不会随着玩家交互而改变。这两个部分可以像闪光分量一样跟随Velocity Buffer做解算,让从颜色纹理中采样得到的颜色值也随着速度场运动,同时根据Thickness Buffer实时重新计算沙堆的法线方向,得到更加合理的明暗效果;
2、沙土目前仅可以与玩家交互,后期还可以考虑加入与周围环境中物品的交互;
3、如果把沙土覆盖的物品轮廓也考虑到沙土的法线中,做一个若隐若现的感觉,或许效果会更好。