Bump Noise Cloud – 3D噪点+GPU instancing制作基于模型的体积云

Last modified date

用3D噪点贴图,配合GPU instancing将模型沿着法线挤压渲染多层,制作类似《sky光遇》里的的体积云。

最近上线的《sky光遇》里的体积云做的很赞,忍不住想尝试做做看。试了很多不同的做法,一开始想到的就是比较流行的Ray marching。但是光线步进一般的做法是代码算出几何体形状和坐标,然后依赖3D噪点图的随机形状,不能自由的用模型捏云的造型。所以转向用一个比较“土”的方式来做。

最终效果:

H5 Demo:

体积云的基础形体

首先要做一张3D噪点图。在github上找到一个别人做的Unity的3D噪点生成器。(连接:https://github.com/mtwoodard/TextureGenerator)基于compute shader生成Perlin Noise,Curl Noise和Cellular Noise,也可以自己扩展其他类型的噪点。我目前只用了Perlin,为了节省性能,生成一个64x64x64尺寸的3D Perlin Noise。

然后再Unity里创建模型和相应材质,看看3D贴图采样的效果。给UV一个滚动偏移,要不然看不出3D采样的效果。

Unity的3D采样代码:

half3 flowUV = i.uv.xyz / _NoiseScale + _Time.x * _Speed * half3(1,1,1);
half noise = tex3D(_3DNoise, flowUV).x;

然后我需要把这个模型绘制n次,每次把vertex沿着法线方向向外挤压一点距离,并且用噪点图的值做alpha clip,让越靠外的图裁剪的越多,这样层次越多,就越能画出云层高低起伏的效果了。具体做法是用Unity的Graphics.DrawMeshInstanced() 方法把这个mesh绘制n次,并且用MaterialPropertyBlock把每层绘制的偏移量和AlphaClip值传给Shader。

注意alpha clip的值如果是线性的话,云层端点会变尖,不够圆滑,所以要做一次幂运算,让clip值越靠外越小。

这样就能获得一个体积感很强的体积云了。

把3D噪点的采样缩放控制也加进去。

绘制层数越多就越像体积云,但绘制层数少的时候会穿帮,会看到明显的一层一层的边界。为了节省渲染性能,最好是绘制20层以内,所以这里要再利用噪点值做一次Alpha Dither半透明抖动。

为了性能考虑,这里就不再多采一次噪点图了,直接用坐标做个简陋的噪点来做半透明抖动:

half dither = frac((sin(i.worldPos.x + i.worldPos.y) * 99 + 11) * 99);

还有个很重要的就是控制一下远近距离的3D噪点采样衰减系数,让远处的UV采样不要太小,否则远处会显得很tiling。这个系数还可以控制每层绘制的偏移量,让离距离越近的偏移越少,越远越大,这样远处也能有比较好的云朵凹凸,近处则减弱分层的感觉。总之这些都是经验参数,靠自己感觉来调。

如图是绘制了16层的效果。可以看到模型边缘变成了抖动的噪点,基本看不到明显的层切面了,云层远近的效果都还可以。

如果觉得云层的凹凸滚动幅度还不够大的话,可以再加上模型顶点偏移。不过我觉得加不加无所谓。

云层光影

云层基本上只受主光源(太阳光)的影响,用NdotL作为主光源系数。做一次幂运算让diffuse的感觉更平滑一点,而且要加上每一层绘制的偏移量,让光影也反应出云朵凹凸的感觉。

half NdotL = max(0, dot(i.worldNormal, lightDir));
half smoothNdotL = saturate(pow(NdotL, 2 - clipRate));

云层的次表面散射也是很重要的,特别是背光的时候,要营造出云层的通透感。之后同样是用幂运算做平滑,并且加上clipRate做凹凸效果。

half3 backLitDir = i.worldNormal * _BackSssStrength +  lightDir;
half backSSS = saturate(dot(viewDir, -backLitDir));
backSSS = saturate(pow(backSSS, 2 + clipRate * 2) * 1.5);

然后云还有一个很独特的特性,就是视角方向的光源也很强,这样容易营造出云的体块感。所以要加上NdotV的系数。

half NdotV = max(0, dot(i.worldNormal, viewDir));
half smoothNdotV = saturate(pow(NdotV, 2 - clipRate));

让云接受shadow,并且加入距离剔除的alpha渐变。

fixed shadow = saturate(lerp(SHADOW_ATTENUATION(i), 1.0, (distance(i.worldPos.xyz,  _WorldSpaceCameraPos.xyz) - 100) * 0.1));

最后靠反复尝试的经验把上述系数混合成云的光影系数:

half finalLit = saturate(smoothNdotV * 0.5 + shadow * saturate(smoothNdotL +  backSSS) * (1 - NdotV * 0.5));

最后把颜色加上。颜色可以直接设置Color,也可以利用环境光的参数。

与场景对象穿插混合

云层作为半透明对象,放在场景之后渲染。然后把场景的深度与云的深度做比较,做出云层与物体边缘的alpha半透效果。我之前把云层渲染了十几层,如果每层都采样深度的话太耗性能。所以我用Camera.SetTargetBuffers()的方式把云的color buffer和depth buffer读取出来,用Image Effect的做法把场景和云层混合到一起。这样只采样一次颜色和深度。

其实云层的深度还可以再压缩压缩,用EncodeFloatRG()的方式把深度压缩在color buffer里,然后B通道用来放光影系数(finalLit),云的颜色放到Image Effect里去混合。但这个做法在安卓手机上表现会很差,因为安卓强制colorbuffer是ARGB32的,每个通道只有8位,云层深度不够用,会有很明显的一层一层,远处的云的深度值太希都不够跟场景做ZTest了。

最后

经发布到手机上测试,性能开销还是挺大的,只有当前旗舰机才能跑的顺畅,毕竟体积云目前不管什么做法都没有特别省性能的。这个做法的体积云我暂时就做到这一步了,以后如果想到其他优化方式再更新。

如果去掉深度混合的话,性能会提高不少,感觉深度采样和计算占了2/3的性能。所以如果不需要云靠的很近的话,可以去掉深度的计算, 缺点就是没有物体交界半透明混合的效果, 我个人感觉在手机上看效果也不差。这样目前的中端手机可以稳定跑30帧以上。

像光遇那样的云层基本都是静态的,所以可以把光影直接都烘焙在lighting map上,不用做动态光影。角色飞到云里的效果可以通过一个只写深度不输出color的trailer renderer来做。

不过我觉得光遇里的云应该还是用类似ray march的做法,用camera的矩阵方向向前一层层绘制云层的,以后有时间再研究研究看。

Share

This site is protected by wp-copyrightpro.com