github: https://github.com/yangrc1234/VolumeCloud
更新的内容在底部
最近在知乎上看到一篇文章讲云层的渲染(https://zhuanlan.zhihu.com/p/34836881?utm_medium=social&utm_source=qq)
原文简单的讲了噪声生成云体的办法,以及一个光照模型。
看了之后很感兴趣,加上本科毕设做的就是体渲染,于是打算在unity里山寨一个出来。
原原文(知乎上的文章引用的文章)是2015年地平线黎明时分制作团队的一个talk(http://advances.realtimerendering.com/s2015/The Real-time Volumetric Cloudscapes of Horizon - Zero Dawn - ARTR.pdf),讲的更清楚一些。
其中印象最深的是一段视频(https://www.youtube.com/watch?v=FhMni-atg6M)。
这里要说明一下的是,地平线团队制作的这套云层渲染,是服务于游戏中的天气系统的。
游戏中的天气系统会模拟云层的状况(包括分布、密度等);然后根据这个信息去渲染云层。
上面的视频中演示的是,天气系统模拟的一个下雨的场景(视频左下角有天气系统的输出,可以看到中间的一片雨云),可以看到雨云不断接近到视点,然后开始下雨,临场感非常强。
这篇文章会主要讲讲实现过程中的问题,特别是一些细节,毕竟其他的东西上面都有了,就不再重复了。
先看看实现的效果:
三张图片用了不同的Height Signal和Coverage Texture;其中第三张图是想山寨一下视频里的效果(emmmm感觉不太成功)
原文中分成了四个部分进行说明,包括建模、光照、渲染和优化,这篇文章也会按照这个顺序去说一下各种奇怪的问题。
建模
18/4/17更新 建模可以有很多不同的实现,毕竟你有2张3D贴图,7个通道(乐意的话用8个),而唯一需要的值就是一个密度值而已
在15年talk中没有具体提及,但是17年的talk中有一份code,但是着手实现之后会发现这个code和slider里的展示的噪声贴图并不太配合。(slider里的贴图是R通道perlin-worley,GBA三个通道是不同频率的反Worley噪声的fBm,然而slider里的code使用的Worley噪声应该是未反的,并且octave为1,否则计算结果显得十分non sense),后来发现slider里的代码是作者本人(Andrew S.)的GPU Pro 7上发布的,可能跟实际使用的时候有所出入
我在github中的版本修改为了在GBA通道存储原始的Worley噪声,然后在shader里叠加成fBm,这样也是出于精度考虑。0-255的范围听起来很大,但是在raymarch的时候,路径上小的值有可能会被累加起来,就会变得非常明显
下文中的一些说法还是最早的时候的理解,最终实现以github为准
建模指的是通过噪声去生成云层的3D texture。
原文中有2张3D Texture,一是Worley-Perlin噪声加上三张不同频率的Worley噪声,最后云层建模通过raymarch这张贴图获取一个点上的密度值。
这里就是第一个奇怪的点,为什么会有4张噪声贴图,毕竟raymarch的时候最终只取一个浮点数啊。
想来想去,似乎唯一的可能性就是,4张贴图对应了不同的云的种类。结合下文可以看到,这套系统是可以指定某个位置的渲染的云的种类的。
(最后我只用了一个Worley-Perlin去渲染)
同样,第二张3D Texture中,是3张不同频率的Worley噪声,我也只能理解为是对应不同的云的种类了。
这里要注意的是,生成的噪声必须是tilable的,不然天上的云都是一块一块儿的了。
关于如何生成tilable的Perlin噪声跟Worley噪声,刚好有一篇文章讲了这个:https://lightbulbbox.wordpress.com/2015/11/11/clouds-by-perlin-and-worley/
文章中还配有图片,还是挺好懂的。
到这里,我们的的云的基本形状就完成了。实际去sample的话,会是相当严实的一整块东西(如果worley噪声没有调整过的话),因为在贴图中,大部分地方都是非0的(特别是叠加起来的噪声)。我们可以直接调整采样结果,将低于某个值的采样归0,用一下的式子:
tempResult = saturate((tempResult - _Cutoff) / (1-_Cutoff));
低于_CutOff的值会被裁掉,大于_CutOff的会被重新映射到(0,1]上。
在ppt中,提到了天气系统会提供一张2D的云层覆盖图;可以直接乘上这张图的采样结果,就可以自定义云层的分布了。(嗯,比如在贴图里写一句话,就可以让云显示出这句话了。感觉很low)
到这一步已经有云的样子了,然后是限制云层高度,用一张贴图,叫Height Signal,用采样的高度去采样这种贴图即可。
(这里还需要定义一下云层的起始位置跟上限高度,根据画面需求随意发挥吧)
在用第一张3D Texture取样完毕后,会用第二张3D Texture作为Detaill Texture,去减去一开始的采样。其中特意提到了,只在云层的边缘做这个操作。
我的实现方法是计算一个“边缘值”,表示该点有多接近云边缘,通过以下的式子
float edge = saturate(_DetailMask - lowresSample / _CloudDentisy); //_CloudDentisy是整体的云层密度,lowresSample之前采样时已经乘上了这个值,现在还原回来再计算;
其中_DetailMask表示的是,低于_DetailMask的采样被认作是边缘。
然后在减去时将edge值乘上Detail Texture的采样即可。
return saturate(lowresSample - edge * _Detail * sampleResult * _CloudDentisy);
上面这句返回了对lowresSample进行减去操作之后的密度值。
ppt中说用了三张2d的Curl Noise去distort这个detail texture来表现出流动的效果,不是很明白是怎么操作的,等我搞懂了curl noise再来更新。
接下去的部分就是配合天气系统的演示了,这里当然不可能山寨一个天气系统出来,就跳过了。
关于显示不同种类的云,ppt中只有几句话带过了,
个人推测是将不同云的HeightSignal录入,然后根据天气系统的输入,按照某种规则去选择不同的HeightSignal去采样。
天气系统的输入包括(ppt中提到的)云层覆盖率、降水率和云的种类,对应一张2D贴图的rgb通道。
ppt中提到的HeightSignal有三种,分别是Stratus、Cumulus和Cumulonimbus
(要说明的是,这里最主要的渲染对象是低层的云,即Stratus跟Cumulus(还有一个两者的结合体stratocumulus,不用管),和Cumulonimbus)
其中Cumulonimbus只会在大暴雨的情况下出现(即上文视频中的那一大片云)。
结合ppt中提到的,当降雨率大于70%时,不管什么云都会变成Cumulonimbus,这一事实。
可以认为,天气系统输入的云的种类,会控制HeightSignal的采样在Stratus跟Cumulus之间blend。
当降雨率大于70%时,则去采样Cumulonimbus的HeightSignal(当然会跟普通的采样稍微有点blend,不然太怪了)。
这样就实现了不同种类的云的渲染。
光照
光照部分的核心是一个公式
该公式描述了云里面的一个点的能量接收情况(乘上HG之后则是该点的能量传递到视点)(我随便说的,我也不知道.jpg)
d表示的是深度,来自于Beer\'s Law,该公式描述了深度和能量传递的关系;
r是他们自己观察得到的一个效果,名为Powder,指的是从光源方向观察这类物体时,会出现边缘变暗的情况。(但是我复现不出来,最终实现的时候去掉了,emmm)
HG是Henyey–Greenstein公式,描述能量在各向异性物质中传播的规律。有它我们可以表现出,朝着太阳看云时,云的边缘处的发光。具体公式如下(复制自http://www.oceanopticsbook.info/view/scattering/the_henyeygreenstein_phase_function):
cosΨ是传播角度的cos值,在这里应该是视线跟日光方向的夹角(应该是吧,嗯)
其中g值控制各向异性的规律。当g在(0,1)时,光会倾向向前传播,(-1,0)时则会向后,0则是各向同性。
设置成0.2左右就有比较明显的,边缘发光的效果了。
P是雨云的能量吸收比例。
这里面唯一神秘的量就是d深度了,这是需要我们自己算的,但是没有什么直观的办法。
具体的实现则在下一部分渲染中提到。
渲染
ppt中没有提到渲染实现的细节。我这里简单提一下体渲染在Unity中的实现办法。
首先是模型。不管渲染什么东西,都必须先有个模型(Proxy mesh)。
有两种办法,第一个是用一个模型去罩住你想要渲染的体。这个说法有点奇怪,因为直觉的讲是根据模型的位置去渲染体;但是就比如我们的情况下,云的位置是固定的,我们不希望变动模型的位置、缩放之后云也跟着变了。
这个方法的最大问题是,进入模型内部后,模型被裁切,啥都没了,可以通过开启背面渲染解决,但是总会有负担。
第二种办法是在摄像机前渲染一个矩形片面覆盖住视野。如果对深度不在意的话,应该可以写成后处理效果去实现(后处理效果其实就是渲染个四边形)。
为了方便起见我目前是用的一个巨大的盒子飘在天上emmmm。(所以上面的演示截图3中,远处的云看不到,其实是模型超出frustrum了)
渲染部分提到的第一点是raymarch加速。简单来说就是根据低级采样(第一个贴图采样的结果)的结果去决定步长。
当某次采样采样到的结果等于0时,这一块儿就是没有云的,我们可以保持一个比较大的步长去raymarch。
如果采样到不为0时,我们则切换到一个较小的步长去采样(记得在这之前先倒退一步)。
如果在精细采样期间,连续几次采样到0,则再切换会较大的步长去采样。(还有这种操作.jpg)
第二点是上一部分提到的深度计算。
用了一个看起来乱来,但是效果很好的办法,对着太阳的方向的一个锥形进行6次采样。最后的结果作为深度值。
同时,当一个点的alpha值超过0.3之后,还会切换到一个更加cheap的采样方法作为优化。
后面提了一下中层云的渲染,其实就是普通的贴图;
上面这些做完了之后,ppt中提到,最终的渲染需要20ms才能完成一帧。
我做完之后,实际更惨,需要50ms。
但是稍微修改了一下参数,竟然可以降低到5ms左右。
其中最主要的是改成只用3D贴图的一个通道,帧数立刻暴增。可以推测带宽是最主要的瓶颈。
另外还有一个云的大小的值,用于将贴图分辨率跟实际的云的大小对应。当该值较低时,帧数也会变低。推测是云足够大时,采样的位置在贴图里足够接近,并行化程度较高(纯粹猜的,以前看到过类似的案例)。
优化
优化部分讲的很简单,主要是每次渲染只渲染一个quarter buffer,然后更新最终图像中1/16个像素;上一帧没有的信息直接用低分辨率的顶上。(我目前还没实现,感觉是个技术活)
过段时间再提升一下完成度,我会把整个工程放出来作为参考emmmm。
18/4/4更新
2017年地平线制作组又关于这个云渲染系统发了一个talk(http://www.advances.realtimerendering.com/s2017/index.html),是2015年talk的升级版,讲了相当多细节,而且有很多实现代码(有一些地方明显不对,估计只是按着意思再写的一份,但是依然很有用)。
回到这里,整个project最关键的部分应该是通过quarter resolution渲染加速的这一点了。毕竟不用这个的话,渲染一帧起码5ms往上,除非是做个看云模拟器,不然是不可能实际使用的。
这一过程类似于temporal reprojection,不同的是在这里我们在合成final image时的采样对象是单个像素点,而不是sub-pixel。
整个实现参考了playdead的temporal reprojection代码(有很多方便的代码直接拿来用了);但是我们这里不需要考虑渲染velocity buffer,因此reprojection的过程有点出入,原本是对velocity buffer采样获取上一帧中的对应位置,我们这里则是用矩阵投影回上一帧(其实就是渲染velocity buffer的时候做的事)。
要渲染low-resolution buffer的话,我们就不能用一个mesh套上材质的方法来渲染了。我选择作为后处理特效加进去。
写shader的时候有个比较麻烦的问题,后处理特效中,原来的一些矩阵没有填充,比如UNITY_MATRIX_VP这些,要获取worldPos有点麻烦。playdead的代码中给出了一些方便的方法,可以在后处理特效中计算视线等。
2017年slider中比较重要的更新是光照模型,之前的模型提到了Beer\'s law(加入"Powder"效果的修改版)和HG公式,2017年的版本修改了一些细节,
- Beer\'s law有两个不同数值的计算,最后2个结果取个max;最后效果是深处的云会被更多的照亮。
- HG公式也类似的变成两次计算,来扩大"silver line"效果的面积、亮度
- 加入了一个In-Scatter效果取代之前的Powder效果,In-Scatter意思是一个点收到的其他点散射传来的能量。 个人感觉效果比起之前的好不少。
三个效果分别的截图如下,感受一哈
Beer\'s Law:
HG:
InScatter:
三个效果合体:
暂时就这些吧。