像Horizo​​n Zero Dawn一样实现物理上正确的体积云

以前,游戏中的云是使用普通的2D子图形绘制的,该子图形始终沿相机的方向旋转,但是近年来,新的视频卡模型使您可以绘制物理上正确的云,而不会造成明显的性能损失。 据认为,游戏中大量的云将工作室Guerrilla Games和游戏Horizo​​n Zero Dawn一起带来了。 当然,这种云以前就可以渲染,但是工作室为源资源和所使用的算法形成了类似于行业标准的东西,现在任何体积云的实现都以某种方式符合该标准。



渲染云的整个过程非常好地分为多个阶段,需要注意的是,即使对其中之一进行错误的实现也会导致这样的后果,即不清楚错误在哪里以及如何解决它,因此建议每次对结果进行控制结论。

色调映射,sRGB


在开始照明工作之前,重要的是要做两件事:

  1. 在屏幕上显示最终图像之前,请至少应用最简单的色调映射:

    tunedColor=color/(1+color) 

    这是必需的,因为计算出的颜色值将远大于1。
  2. 确保要绘制并显示在屏幕上的最终帧缓冲区为sRGB格式。 如果激活sRGB模式存在问题,则可以在着色器中手动完成转换:

     finalColor=pow(color, vec3(1.0/2.2)) 

    该公式适用于大多数情况,但并非100%(取决于监视器)。 务必始终在最后完成sRGB转换。

照明模式


考虑一个充满不同密度的部分透明物质的空间。 当光线穿过这种物质时,它会受到四种影响:吸收,散射,放大散射和自辐射。 后者发生在物质的化学过程中,在此不受影响。

假设有一束光线从A点穿过物质到达B点:


吸收性

穿过物质的光会被该物质吸收。 未吸收的光份额可以通过以下公式得出:


在哪里 -吸收后残留的光 -在AB段上相隔一段距离 来自A。

散布

一部分光在物质粒子的影响下改变其方向。 不变方向的光的比例可以通过以下公式找到:


在哪里 -在一点散射后没有改变方向的光的比例

吸收和分散必须结合起来:


功能介绍 称为衰减或消光。 功能 -传递函数。 它显示了从点A到点B剩余的光量。

至于 ,其中C是某个常数,对于RGB中的每个通道,其值可能不同, 是该点的介质密度

现在,让任务复杂化。 光线从点A移到点B,并在移动过程中熄灭。 在点X,一部分光沿不同方向散射,其中一个方向对应于观察者在点O。接下来,一部分散射光从点X移到点O,然后再次衰减。 我们感兴趣的AXO光之路。


从A到X的光损失我们知道: ,就像我们知道从X到O的光损失-这 。 但是,朝观察者方向散射的那部分光呢?

放大色散

如果在普通散射的情况下光强度降低,那么在放大散射的情况下,光强度由于在相邻区域中发生的光散射而增加。 可以通过以下公式找到来自邻近区域的总光量:


在哪里 表示将积分覆盖球体, -相位功能 -来自方向的光

从各个方向计算光是非常困难的,但是,我们知道光的原始部分是由我们的原始AB光束承载的。 该公式可以大大简化:


在哪里 -光束和观察者光束之间的角度(即AXO角), -光强度的初始值。 总结以上所有内容,我们得出以下公式:


在哪里 -入光 -到达观察者的光。

我们使任务复杂化了一点。 假设光是由定向光发出的,即 太阳:


一切都与前面的情况相同,但是多次。 来自点A1的光在点X1处朝向观察者O散射,来自点A2的光在点X2处朝向观察者O点散射,等等。 我们看到到达观察者的光等于总和:


或更精确的积分表达式:


请务必在此处了解 ,即 该段被分为无数个长度为零的段。

天空


稍微简化一下,穿过​​大气层的阳光只受到散射,即


甚至不是一种散射,而是两种:瑞利散射和Mi散射。 第一种是由空气分子引起的,第二种是由水的气溶胶引起的。

从点A到点B的光线穿过的空气(或气溶胶)的总密度:
在哪里 -缩放高度,h-当前高度。

一个简单的整体解决方案是:

其中dh是获取高度样本的步长。

现在看一下该图,并使用“照明模型”上一部分中得出的公式:


观察者从O到O'。 我们要收集到达点X1,X2,...,Xn的所有光,这些光在其中散射,然后到达观察者:


在哪里 太阳发出的光的强度, -点的高度 ; 在天空的情况下,常数C起作用 表示为

积分的解可以如下:

该公式对于瑞利散射和米氏散射均有效。 结果,每个散射的光值简单地相加:


瑞利分散



(包含每个RGB通道的值)



结果:


弥散



(所有RGB通道的值都相同)



结果:


每段样本数 在细分市场上 您可以参加32岁或以上。 地球半径为6371000 m,大气为100000 m。

如何处理所有这些:

  1. 在屏幕的每个像素中,我们计算观察者V的方向
  2. 我们采用观察者O的位置等于{0,6371000,0}
  3. 我们发现 射线从点O开始与V的方向相交的结果,球体的中心在点{0,0,0},半径为6471000
  4. 线段 分成相等长度的32个部分
  5. 对于每个部分,我们计算瑞利散射和米氏散射,并添加所有内容。 而且,要计算 我们还需要分割细分 每种情况下32个相等的地块。 可以通过变量读取,该变量的值在循环的每个步骤中都会增加。

最终结果:


云模型


我们将需要3D噪声中的几种类型。 第一个是Perlin潜伏的分形布朗运动(fBm)噪声:

2D切片的结果:


第二个是Voronoi掩盖的fBm噪声。

2D切片的结果:


要获得Vorley的掩盖fBm噪声,您需要反转Voronoj的掩盖fBm噪声。 但是,我根据自己的判断略微更改了值的范围:

 float fbmTiledWorley3(...) { return clamp((1.0-fbmTiledVoronoi3(...))*1.5-0.25, 0.0, 1.0); } 

结果立即类似于云结构:


对于云,您需要获得两个特殊的纹理。 第一个大小为128x128x128,负责低频噪声,第二个大小为32x32x32,负责高频噪声。 每个纹理仅使用一个R8格式的通道。 在一些示例中,将R8G8B8A8的4个通道用于第一个纹理,将R8G8B8的三个通道用于第二个纹理,然后在着色器中混合这些通道。 我不明白这一点,因为可以提前进行混合,从而在缓存一致性方面获得更大的成功。

为了进行混合以及在某些地方,将使用remap()函数,该函数将值从一个范围缩放到另一个范围:

 float remap(float value, float minValue, float maxValue, float newMinValue, float newMaxValue) { return newMinValue+(value-minValue)/(maxValue-minValue)*(newMaxValue-newMinValue); } 

让我们开始准备低频噪声的纹理:
R通道-佩林的fBm噪声
G通道-平铺的fBm Vorley噪声
B通道-fBm Worley噪声较小,规模较小
A通道-规模更小的Varley可调节的fBm噪声


混合是通过以下方式完成的:

 finalValue=remap(noise.x, (noise.y * 0.625 + noise.z*0.25 + noise.w * 0.125)-1, 1, 0, 1) 

2D切片的结果:


现在准备带有高频噪声的纹理:
R通道-平铺的fBm Vorley噪声
G通道-较小比例的fBm Vorley噪声
B通道-规模更小的Varley taylivaya fBm噪声


 finalValue=noise.x * 0.625 + noise.y*0.25 + noise.z * 0.125; 

2D切片的结果:


我们还需要一个2D纹理-天气图,该图将根据空间坐标确定云的存在,密度和形状。 由艺术家绘画以微调云层。 在我借出的版本中,天气图颜色通道的解释可能有所不同,如下所示:


R通道-低空云层覆盖
G通道-高空云层
B通道-最大云层高度
A通道-云密度

现在我们准备创建一个函数,该函数将根据3D空间的坐标返回云的密度。

在入口处,坐标为km的空间点

 vec3 position 

立即将偏移量添加到风中

 position.xz+=vec2(0.2f)*ufmParams.time; 

获取天气图值

 vec4 weather=textureLod(ufmWeatherMap, position.xz/4096.0f, 0); 
我们得到高度的百分比(从0到1)

 float height=cloudGetHeight(position); 

在下面添加一小部分云:
 float SRb=clamp(remap(height, 0, 0.07, 0, 1), 0, 1); 
根据天气地图的B通道,我们将密度随着高度增加而线性减小到0:

 float SRt=clamp(remap(height, weather.b*0.2, weather.b, 1, 0), 0, 1); 
合并结果:

 float SA=SRb*SRt; 

再次在下面添加云的舍入:

 float DRb=height*clamp(remap(height, 0, 0.15, 0, 1), 0, 1); 

还要在顶部添加云的舍入:

 float DRt=height*clamp(remap(height, 0.9, 1, 1, 0), 0, 1); 
我们将结果结合起来,在这里我们添加了气象图上的密度影响和通过gui设置的密度影响:

 float DA=DRb*DRt*weather.a*2*ufmProperties.density; 

结合来自纹理的低频和高频噪声:

 float SNsample=textureLod(ufmLowFreqNoiseTexture, position/48.0f, 0).x*0.85f+textureLod(ufmHighFreqNoiseTexture, position/4.8f, 0).x*0.15f; 

在我阅读的所有文档中,合并以不同的方式进行,但是我喜欢此选项。

我们确定覆盖范围(被云占据的天空的百分比),这是通过gui设置的,还使用了天气图的R通道和G通道:

 float WMc=max(weather.r, clamp(ufmProperties.coverage-0.5, 0, 1)*weather.g*2); 

计算最终密度:

 float d=clamp(remap(SNsample*SA, 1-ufmProperties.coverage*WMc, 1, 0, 1), 0, 1)*DA; 

整个功能:

 float cloudSampleDensity(vec3 position) { position.xz+=vec2(0.2f)*ufmParams.time; vec4 weather=textureLod(ufmWeatherMap, position.xz/4096.0f+vec2(0.2, 0.1), 0); float height=cloudGetHeight(position); float SRb=clamp(remap(height, 0, 0.07, 0, 1), 0, 1); float SRt=clamp(remap(height, weather.b*0.2, weather.b, 1, 0), 0, 1); float SA=SRb*SRt; float DRb=height*clamp(remap(height, 0, 0.15, 0, 1), 0, 1); float DRt=height*clamp(remap(height, 0.9, 1, 1, 0), 0, 1); float DA=DRb*DRt*weather.a*2*ufmProperties.density; float SNsample=textureLod(ufmLowFreqNoiseTexture, position/48.0f, 0).x*0.85f+textureLod(ufmHighFreqNoiseTexture, position/4.8f, 0).x*0.15f; float WMc=max(weather.r, clamp(ufmProperties.coverage-0.5, 0, 1)*weather.g*2); float d=clamp(remap(SNsample*SA, 1-ufmProperties.coverage*WMc, 1, 0, 1), 0, 1)*DA; return d; } 

该功能究竟应该是什么是一个悬而未决的问题,因为忽略设置参数时云遵循的定律,您将获得非常不寻常的美观结果。 这完全取决于应用程序。


整合性


地球大气分为内部和外部两层,可以在这两层之间放置云。 这些层可以用球体表示,也可以用平面表示。 我定居在领域。 对于第一层,我取球面半径为6415 km,对于第二层,我取球半径为6435 km。 地球半径四舍五入为6400公里。 一些参数将取决于大气“阴天”部分(20 km)的条件厚度。



与天空不同,云是不透明的,集成不仅需要获取颜色,还需要获取alpha通道的值。 首先,您需要一个函数,该函数返回云的总密度,来自太阳的光线将通过该密度。


没有人引起注意,但是实践表明,根本不需要考虑光束的整个路径,只需要最大的间隙即可。 我们假设截断段以上的云根本不存在。


此外,我们在不影响性能的情况下可以完成的密度样本数量非常有限。 游击队游戏做了6。此外,在其中一个演示中,开发人员说,它们将这些样本分散在圆锥体中,最后一个样本与其他样本的距离特别远,可以覆盖尽可能多的空间。 所产生的误差和噪声仍将在相邻样本的背景下被消除,相反,这将提高准确性。


最后,我确定了位于同一条线上的4个样本,但后者的步数增加了6倍。 步长为20 km * 0.01,即200 m。

该功能非常简单:

 float cloudSampleDirectDensity(vec3 position, vec3 sunDir) { //   float avrStep=(6435.0-6415.0)*0.01; float sumDensity=0.0; for(int i=0;i<4;i++) { float step=avrStep; //      6 if(i==3) step=step*6.0; //  position+=sunDir*step; //  ,  ,   //  float density=cloudSampleDensity(position)*step; sumDensity+=density; } return sumDensity; } 

现在,您可以继续进行更困难的部分。 我们确定地球表面的观察者在点{0,6400,0}处,并找到观察光束与半径为6415 km且中心为{0,0,0}的球面的交点-我们获得了起点S。


下面是该函数的基本版本:

 vec4 mainMarching(vec3 viewDir, vec3 sunDir) { vec3 position; crossRaySphereOutFar(vec3(0.0, 6400.0, 0.0), viewDir, vec3(0.0), 6415.0, position); float avrStep=(6435.0-6415.0)/64.0; for(int i=0;i<128;i++) { position+=viewDir*step; if(length(position)>6435.0) break; } return vec4(0.0); } 

步长定义为20 km / 64。 在观察者光束严格垂直的情况下,我们将制作64个样本。 但是,当此方向更水平时,样本将稍大,因此循环中没有64个步长,但有128个有余量。

首先,我们假设最终的颜色是黑色,透明度是统一的。 在每一步中,我们将增加颜色值并降低透明度值。 如果透明度接近0,则可以预先退出循环:

 vec3 color=vec3(0.0); float transmittance=1.0; … //    //      float density=cloudSampleDensity(position)*avrStep; //   ,   //   float sunDensity=cloudSampleDirectDensity(position, sunDir); //      float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*m2*m3; //       color+=sunColor*light*transmittance; transmittance*=exp(-ufmProperties.attenuation*density); … return vec4(color, 1.0-transmittance); 

ufmProperties。衰减-除了C外,什么也没有 和ufmProperties。衰减2是C在 。 ufmProperties.sunIntensity-太阳的辐射强度。 sunColor-太阳的颜色。

结果:


立即发现缺陷-严重的阴影。 但是现在我们将纠正太阳附近缺少放大照明的问题。 发生这种情况是因为我们没有添加相位函数。 为了计算穿过云层的光的散射,我们使用了Hengy-Greenstein函数的相位,该函数在1941年打开,用于空间气体簇中的类似计算:


这里应该做题题。 根据规范照明模型,相位函数应为1。 但是,实际上,获得的结果并不适合任何人,每个人都使用两个相位函数,甚至以特殊方式组合其值。 我还专注于两个阶段的函数,但我只是将它们的值相加。 第一阶段功能的g接近1,可让您在太阳附近进行明亮的照明。 第二阶段函数的g接近0.5,可让您逐渐降低整个天球的照明度。

更新的代码:

 // cos(theta) float mu=max(0, dot(viewDir, sunDir)); float m11=ufmProperties.phaseInfluence*cloudPhaseFunction(mu, ufmProperties.eccentrisy); float m12=ufmProperties.phaseInfluence2*cloudPhaseFunction(mu, ufmProperties.eccentrisy2); float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*(m11+m12)*m2*m3; 

ufmProperties.eccentrisy,ufmProperties.eccentrisy2是g值

结果:


现在,您可以使用太多阴影开始战斗。 之所以存在,是因为我们没有考虑现实生活中来自周围云层和天空的光线。

我这样解决了这个问题:

 return vec4(color+ambientColor*ufmProperties.ambient, 1.0-transmittance); 

其中environmentColor是沿观察光束方向的天空颜色,ufmProperties.ambient是调整参数。

结果:


它仍然是解决最后一个问题。 在现实生活中,视角越水平,我们看到的雾或霾就越多,使我们无法看到非常遥远的物体。 这也需要反映在代码中。 我采用了通常的凝视角和指数函数的余弦值。 基于此,计算出一定的混合系数,该系数允许在所得颜色和背景颜色之间进行线性插值。

 float blending=1.0-exp(-max(0.0, dot(viewDir, vec3(0.0,1.0,0.0)))*ufmProperties.fog); blending=blending*blending*blending; return vec4(mix(ambientColor, color+ambientColor*ufmProperties.ambient, blending), 1.0-transmittance); 

ufmProperties.fog-用于手动配置。


摘要功能:

 vec4 mainMarching(vec3 viewDir, vec3 sunDir, vec3 sunColor, vec3 ambientColor) { vec3 position; crossRaySphereOutFar(vec3(0.0, 6400.0, 0.0), viewDir, vec3(0.0), 6415.0, position); float avrStep=(6435.0-6415.0)/64.0; vec3 color=vec3(0.0); float transmittance=1.0; for(int i=0;i<128;i++) { float density=cloudSampleDensity(position)*avrStep; if(density>0.0) { float sunDensity=cloudSampleDirectDensity(position, sunDir); float mu=max(0.0, dot(viewDir, sunDir)); float m11=ufmProperties.phaseInfluence*cloudPhaseFunction(mu, ufmProperties.eccentrisy); float m12=ufmProperties.phaseInfluence2*cloudPhaseFunction(mu, ufmProperties.eccentrisy2); float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*(m11+m12)*m2*m3; color+=sunColor*light*transmittance; transmittance*=exp(-ufmProperties.attenuation*density); } position+=viewDir*avrStep; if(transmittance<0.05 || length(position)>6435.0) break; } float blending=1.0-exp(-max(0.0, dot(viewDir, vec3(0.0,1.0,0.0)))*ufmProperties.fog); blending=blending*blending*blending; return vec4(mix(ambientColor, color+ambientColor*ufmProperties.ambient, blending), 1.0-transmittance); } 

演示视频:


优化和可能的改进


在实现基本的渲染算法之后,下一个问题是它的工作速度太慢。 我的版本在radeon rx 480上产生了25 fps的全高清图像。游击游戏自己提出了以下两种解决问题的方法。

我们画出真正可见的东西

屏幕分为大小为16x16像素的图块。 首先,绘制通常的3D环境。 事实证明,大部分天空都被群山或大物体覆盖。 因此,您仅需要在云没有被任何东西阻挡的那些图块中执行计算。

重投影

事实证明,当相机静止不动时,通常无法更新云。 但是,如果摄像机已经移动,这并不意味着我们需要更新整个屏幕。 一切都已绘制完毕,您只需要根据新坐标重建图像即可。 通过投影和查看当前帧和先前帧的矩阵在新坐标上找到旧坐标称为投影。 因此,在摄像机移动的情况下,我们只需根据新坐标传递颜色即可。 如果这些坐标表示屏幕外,则必须如实地重绘云。

部分更新

我不喜欢重新投影的想法,因为在相机急转弯时,可能会发现必须在屏幕的三分之一处渲染云,这可能会导致延迟。 我不知道游击队游戏是如何处理的,但是至少在“地平线零黎明”中,当控制操纵杆时,摄像机可以平稳移动,并且急剧跳动不会有问题。 因此,作为实验,我想出了自己的方法。 云是在三次面的5个面上绘制的,因为 底部对我们不感兴趣。三次方图的侧面的降低的分辨率等于屏幕高度的1/3。立方贴图的每个面都分为8x8的图块。每个面上的每一帧仅使用每个图块中的64个像素之一进行更新。这会在突然变化时产生明显的伪像,但是因为云是很静态的,那么这种技巧是看不见的。结果,radeon RX 480的火山全高清输出为500 fps,opengl为330 fps。 Radeon HD 5700系列在opengl下可产生全高清的109 fps(vulkan不支持)。

使用Mip级别

当访问带有噪点的纹理时,只能在最初的样本中从零Mip级别获取数据,然后我们制作的样本越远,Mip级别就可以越大。

高云

为了模拟整合期间游击游戏中卷云高度和卷积云的存在,最新的样本不是由我所谈到的3D纹理生成的,而是由特殊的2D纹理生成的。


卷曲噪声卷曲噪声中的其他

几个纹理用于​​创建吹云的效果。需要这些纹理来移动原始坐标。


神光


这种在戏剧中捕捉的光线是在后处理中实现的。首先,在太阳周围绘制明亮的照明,在该照明下不会被云遮挡。然后,该背光必须径向偏离太阳。


现在,您需要应用径向平滑。


实际上,还有更多的改进和细微之处,但是我没有全部检查,因此我无法自信地说出来。但是,您可以自己熟悉它们。我认为最强的是Frostbite引擎的云文档。

有用的链接


Guerrilla Games
d1z4o56rleaq4j.cloudfront.net/downloads/assets/Nubis-Authoring-Realtime-Volumetric-Cloudscapes-with-the-Decima-Engine-Final.pdf?mtime=20170807141817
killzone.dl.playstation.net/killzone/horizonzerodawn/presentations/Siggraph15_Schneider_Real-Time_Volumetric_Cloudscapes_of_Horizon_Zero_Dawn.pdf
www.youtube.com/watch?v=-d8qT5-1LOI

GPU Pro 7
vk.com/doc179245989_437393482?hash=a9af5f665eda4edf58&dl=806d4dbdac0f7a761c


www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/simulating-sky/simulating-colors-of-the-sky

Frostbite
media.contentapi.ea.com/content/dam/eacom/frostbite/files/s2016-pbs-frostbite-sky-clouds-new.pdf
www.shadertoy.com/view/XlBSRz

Source: https://habr.com/ru/post/zh-CN459948/


All Articles