[OpenGL] 体积云实现探索
发布日期:2021-06-29 07:15:32 浏览次数:4 分类:技术文章

本文共 5083 字,大约阅读时间需要 16 分钟。

 

动图

          首先,从云的形状开始。由于云的形状是不规则的,在图形学中,为了模拟这种不规则,我们通常使用噪声来实现。

          我们使用两种不同的噪声来模拟云的形状——worley噪声和perlin噪声,图中只使用了两张纹理,变化量是两张纹理混合的比例。

         根据地平线的体积云论文的描述,实际上他们是使用一张perlin-worly噪声来模拟云块状的基本形状(左一),不同频率的worley噪声模拟(右三)模拟体积云蓬松的细节,不同的频率对应着不同云的细节。

        

Worley噪声 

        我们可以把云的形状看作是"一朵朵"的,为了表现这种块状、多孔的效果,我们可以考虑使用worley噪声来表现。关于worley噪声的具体描述,可以参阅这一论文 。

        在讨论worley噪声本身之前,我们有必要了解一下Voronoi图,它是像下面的这样一张图(摘自维基百科):

Voronoi-C.png
Voronoi

        可以看到,空间被划分为多个块状,而每个块状有一个中心点,worley噪声的论文中将其称为特征点。这个图的数学性质是:空间中的任一点,到其所在区域的特征点的距离(相比起到其它区域特征点的距离)最短。

        那么,如果我们想要生成一张Voronoi图的话,我们只需要考虑特征点的位置就可以了。因为在确定了特征点的位置后,区域会被自然地确定。同时,论文中还定义了第二最短距离、第三最短距离……以及第n最短距离,利用这些性质,我们还能得到很多其它具有独特外观的图案。

       一种可以生成Voronois图的算法描述如下(3D):

 

       (1)将空间划分为立方体网格,通过floor操作得到每个点所落在的网格点位置。

        空间中的每个小立方体可能包含0个,一个或多个特征点。如果每立方体平均密度为,则单位立方体中出现点的概率为。实践中使用m = 4,并限制每个单元中点处在[1,9]之内。

 

          (2)  确定每个小立方体所包含的特征点数目。

        特定立方体中特征点数量的随机数字需要是确定的,可以将立方体的三个整数坐标作为随机种子,一个简单的三元数(i,j,k)的hash函数像,但更好的方法是使用交换数组。

        我们使用种子随机数生成器中的第一个值作为特征点预计算概率列表的索引,以找到立方体中的点数。

 

        (3) 计算m个特征点的位置。

        同样的,它们虽然是随机的,但对于每个立方体而言是固定的,因此我们使用已经初始化好的随机数生成器来计算每个特征点的x,y,z位置,范围为[0,1]。

 

        (4) 找到距离最近的点,在当前立方体和邻域进行查找。

       在生成这些点后,由于加入了新的点,我们需要更新某一点到所有特征点的距离的函数的排序,保证这一序列的顺序性。这样,我们就得到了当前空间的立方体内,点的最近特征点和一系列Fn的值。但是,相邻立方体中可能存在更近的点,因此我们需要在边界立方体也做一次查找。

        根据以上描述,可以实现一个CPU版本的worley 3D噪声生成。其中给出了一个参考可用的hash函数,此外就是注意随机数的生成方式,考虑模拟C库中rand()的线性同余方法,给定一个特定的初始随机种子,每次求解新的随机数时,需要以上一个随机数为输入。如果输入种子是一定的,那么就会得到一个伪随机的固定序列。

        通过CPU的方式生成后需要将结果烘焙为3D纹理,然后在计算体积云的时候直接从3D纹理中采样,避免了每帧重复计算。这个过程很(wo)麻(bu)烦(hui), 于是我又参考了这一GPU的实现,实际上这是一个利用GPU生成2D Voronoi图的shader,它的实现相比而言更加简单,通过一个vec3到vec3的hash映射直接得到了每个特征点的随机偏移,然后通过多个循环在邻域立方体里查找离当前点的最近距离,再将每个点到其最近特征点的距离作为灰度图的颜色输入就可以得到我们需要的所谓的worley噪声。

        计算得到worley 3D噪声之后,根据世界坐标作为采样点绘制到了一个立方体上,并且做了反色运算(也就是x = 1 - x)。

       

 

perlin噪声

        为了表现云的蓬松和层次,我们使用perlin噪声来定义细节。

        perlin噪声是一个梯度噪声,相关的论文为《An Image Synthesizer》。

         我们目前已经知道worley噪声是通过晶格+随机偏移得到特征点位置生成的,最后我们根据输入点到其最近的特征点的距离得到该位置的噪声颜色。perlin噪声也是基于晶格生成的,不同的是它在每个晶格点存储的是一个随机梯度,我们根据输入点与其附近所有晶格点梯度的点乘加权和作为输出的噪声颜色。在此处就不展开详细说明了。

        此处我试着自己做了一个GPU版本的perlin噪声,效果如下:

        

       这种方法存在两个问题,一个是速度无法满足实时性要求(很卡,即使做了部分预处理),另一个是它和地平线发布的体积云论文中的perlin噪声有一些肉眼可见的差异(下图最左):

        

       论文中使用的实际上是perlin噪声的分形版本,也就是将不同尺度的perlin噪声叠加在一起得到的。

       为了达到实时性要求,我同样在shadertoy网站了找了一个比较近似的value噪声,并手动做了分形处理。

云的区域

       首先,我们需要定义天空中哪些地方有云,哪些地方无云。这可以由一张2D的噪声贴图来定义,我们可以利用已有的两个噪声来得到这张贴图,用世界坐标x,z来采样。

       

Ray-Maching积累浓度

       我们认为0是无云区,大于0为有云区。但实际上噪声中为0的区域是很少的,为了得到比较连续的无云区域,我们可以设定一个阈值,低于该阈值的都映射到0,而高于该阈值的,都重新映射到[0,1]区间,用代码描述则为:

sampled_density = clamp((sampled_density - thickness) / (1 - thickness),0,1) ;

       之后,我们通过Ray-Matching + 体渲染的方式,可以得到这么一个基本形状(右:siggraph):

        

        此处我直接把这个形状渲染在了一个立方体上, 并把起始采样高度设置了一个定值。为了得到图中的效果,实际上我是在Ray-Matching的时候,如果第一步采样就采样到了有云区,则直接返回了一个比较暗的颜色,然后退出循环,作为云的底部颜色。

云的形状

        确定了有云和无云区域后,我们再利用一张3D的Worley-Perlin噪声去构造云的形状。

        具体的做法是,在Ray-Marching的每一步,我们用当前云的世界坐标去采样3D Worley-Perlin 纹理,将得到的值与当前区域云的密度相乘,得到最终的结果。

        同样地,我们依然先把这个云绘制在一个立方体上看一下效果(右:siggraph):

        

        之后,我们给这个云添加一点细节,我们使用不同频率的worley噪声得到一些翻滚的细节,类似于花椰菜的外观(右:siggraph):

         

​float GetCloudDensity(vec3 pos){    float thick = GetCloudThickness(pos);    float base_density = GetCloudBaseShape(pos);    float detail_density = GetCloudDetailShape(pos);    return thick * base_density * detail_density;}

云与天空盒

       和体积雾之类的体素渲染物体不太一样,天空和体积云固定在场景中,不会随着镜头推进而近大远小地改变形态,而是像远景一样不会移动,但能旋转镜头观察。为了达到这一效果,我们采用和天空盒类似的方式来处理体积云的位置(如果此处有不清楚的地方,可以参考

        在天空盒绘制时候,我们的基本思路是,使用一个单位立方体绘制,深度测试改为GL_LEQUAL。在顶点着色器中做坐标变换时,仅考虑相机矩阵的旋转,而不考虑移动,这样可以保证天空盒位置不随相机变化而变化,并把立方体的世界坐标传给片元着色器。在片元着色器采样时,将立方体世界坐标归一化(实际上就落在了一个单位球上),用它来采样CubeMap。

        那么我们就可以把云彩绘制在天空盒的位置,将立方体的世界坐标映射到一个圆弧或平面上:

      

 

        此时,我们有云和无云的地方过渡比较突兀,云直接在分界线被截断了。为了缓解这一现象,我们需要根据离眼睛距离做一个线性插值,让密度缓慢减为0。

云的高度分布

        在刚才讨论云的天空盒分布时,提到了可以让云在边界做一个线性淡出的效果。实际上,如果我们把这一思路扩展一下,可以很好地定义云在不同高度的分布,比如我们还可以设定高于某一高度后,云也会线性淡出;不同类型的云分布在不同的高度,也就是对特定高度的密度做一个重映射。

      《Horizon: Zero Dawn》中给出了一个密度-高度函数的贴图,分别代表了层云(左),积云(中)和积雨云(右)。

       

云的光照

        我们的云目前看起来只有单薄的白色,缺乏立体感。我们试着加一点光照来提升真实感。由于使用了体渲染,云的光照需要在Ray-Marching的每一步都计算,并将结果累积起来。

       beer定律:光的衰减

        在现实生活中,云的底部会有一点偏暗,光通过物体时,光线会不断衰减,整个过程是一个指数衰减。对于每一步计算得到的光照,都有必要乘以这一衰减值,传入的参数是Ray Marching开始到目前累积的浓度。其中,p值可以视为积雨量(吸收水平),值越大,云整体就越暗。

float beers = exp(-p * density);

        

        糖堆效应:光的内散射

         在云的凸起折痕处较多的地方会更亮,我们用糖堆效应来描述这一现象,它本身是一个概率意义上的函数,如下图蓝色线条所示,同样的,我们也在每一步光照计算中都乘以这一系数。这一系数可以得到云缝隙偏亮的效果。

float powder = (1.0f - exp(-density*2);

        

        Henyey-Greenstein相位函数

        我们使用hg相位函数来模拟光在云中靠近云时,云边发出闪耀的银光的效果,它描述了光的前向散射。该公式与云的浓度无关,而是取决于当前采样位置与光源(一般是太阳)的距离,最终得到的效果为越靠近光源越明亮。

        

        g越大,中心越亮,向外衰减也越快。 

vec3 lightDir = pos - lightPos;vec3 viewDir = pos - cameraPos;float cos_angle = dot(normalize(lightDir), normalize(viewDir));float inG = 0.2;float hg = ((1.0 - inG * inG) / pow((1.0 + inG * inG - 2.0 * inG * cos_angle), 3.0/2.0)) / 4.0 * 3.1415;

        阴影计算

        云的底部会有偏暗的效果,为了得到这种光在云朵上的明暗效果,我们进行一些简单的阴影计算,或者我们把其称为遮蔽会更加准确。此处可以做一些比较“粗暴"的模拟,我们设定一个比较高的高度(高于云层),离这一高度越近,云就越亮。具体的计算是给定一个颜色,除以归一化后的距离,得到当前高度云的基本颜色。

        之后,我们对云的颜色做衰减处理,以当前位置为基础向上做Ray-Marching,得到累积后的浓度,然后使用beer定律得到衰减系数。

        处理    

        以上所有系数在每一步中相乘得到当前步采样颜色,并将所有步得到的颜色累加起来。实际上由于每步的衰减,我们最终得到的颜色已经比较趋向于归一化的结果了,但是我们还是有必要类似于HDR一样,做最终的颜色做一些映射,让其回归到一个比较合理的颜色区间。

        

加入体积光

        这个我只是稍微思考了一下大致思路,还没有动手实现。此处想要模拟的是阳光穿过云层丝丝缕缕的效果。思路是利用生成云的区域的噪声,利用透视投影的shadowMap,判断当前位置是否处在云的缝隙处(有阳光穿过)。

        此处可以省略生成shadowMap的过程,因为云的位置是不变的,我们直接从噪声图采样即可,但是需要反解出从上往下透视观察时,正好让整个噪声覆盖视野范围的透视投影矩阵,将当前位置利用这一矩阵做空间转换后再进行对比。

后记 

       这个云差不多折腾了两周,有幸存了上周做的第一版效果,虽然有那么一点云的感觉,但是完全没有体积云的意思?

        

       之后我又推倒重做了一次,最后做出来依然没有论文里香香软软蓬蓬松松的效果,但好歹有了那么一点体积的感觉。就这么作为最终交付结果吧(

转载地址:https://blog.csdn.net/ZJU_fish1996/article/details/89430718 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:[OpenGL] Bloom自发光效果
下一篇:[图形学] 实时体积云(Horizon: Zero Dawn)

发表评论

最新留言

不错!
[***.144.177.141]2024年04月06日 02时51分04秒