本篇续前文介绍一种移动端真实感沙土渲染方法
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模型即可:
2 - RimLight Part
《风之旅人》中有着大片连绵的沙丘,为了让玩家能够看清楚每一个沙丘的边界,John Edward在shader中添加了Rim Light,这部分可以用Fresnel实现:
现在沙丘看上去像这样,整体还比较光滑:
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的镜面反射,灵感来源于日落时在海洋或湖泊上看到的反射:
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衰减:
这样就能够做到扬起烟雾一定时间后,闪光熄灭。
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即可,我们可以设定一个判定阈值threshold,c < 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、如果把沙土覆盖的物品轮廓也考虑到沙土的法线中,做一个若隐若现的感觉,或许效果会更好。