以上图中的柱子参考为1000m高,Ray Marching采样数为64,云分辨率为960x540,屏幕分辨率1920x1080,天空被覆盖较多半透明的云时耗时约1.5ms,云较少或者覆盖较多浓度较高的云时耗时约1ms。
本次实践主要参考的是SIGGRAPH 2015的The Real-time Volumetric Cloudscapes of Horizon - Zero Dawn以及Convincing Cloud Rendering。
虽然网上也有不少体积云的案例,不过我还是打算考验下自己,动手实践一次。其中大部分实现方式是参考PPT和论文的,这篇博客做一个简单解释。
形态/噪声生成
云的基本形态是在一个128x128x128的3D纹理采样得到的,具体是RGBA四个通道隔储存不同频率的Perlin Noise或者Worley Noise,并且要求tileable,否则生成的云会出现明显的缝隙。这里网上没有找到比较好的3D Tileable Perlin Noise和Worley Noise的代码,因此我自己写了一个可以生成这样纹理的脚本。脚本可以在我Git仓库中找到。
Perlin Noise的顶点随机向量用极坐标方法生成,要注意对生成的01半径取三次方根(参见上篇圆盘均匀采样)。为了让纹理能够无缝衔接,要让所有边界处的随机向量的值能够对应起来。Worley Noise更简单一点,采样点对附近晶格内部生成的随机点取最小距离即可,在边界处的采样点要找到对应的衔接处晶格取距离比较。
生成的纹理如图所示,图中为两个Cube拼接起来,可见纹理之间是无缝的。
除了基本形态,我们要在此基础上减去另一个细节纹理,细节纹理是32x32x32的3D无缝纹理,RGB通道分别为频率不同的Worley Noise。然后我们就能看见云边缘的类似烟雾的细节效果。
另外,我们还要使用一张Weather贴图,几个不同通道分别代表云在此地表向上处的浓度,高度,厚度等细节。这里我暂时只用到了一个浓度Mask,中心高度固定在大气层上下高度的平均值,厚度和浓度成正比。当然这张纹理也需要是无缝的。
光照/渲染
主要参考比尔朗伯定律和光在云中的各向异性散射,以及一种会在边缘处造成暗线的Powder Effect(具体参见论文)。因为云是不定形的,并且为了体现出云的体积感,因此我们不能用常规的光栅化方式去渲染云,而要使用Ray Marching这种步进的方法在云中采样叠加数据最后得到进入眼中的光线。
因此我们将云的渲染当做后处理看待。根据我之前写的使用深度图重构世界空间坐标博客中的方法,我们可以同样将相机采样的方向向量作为UV的方式传入GPU,这里不再赘述。
根据比尔朗伯定律,光在介质中走得越深其衰减越大并呈指数衰减,如图所示。
因为是指数衰减,我们可以计算好每一步前进积累的透明度衰减,并在每一步中累积相乘得到当前步的透明度,这构成了Ray Marching半透明材质的最基本的光照模型。
根据Henyey-Greenstein scattering function,光在云这样的介质中的散射并不是像大气散射一样趋于各向同性,而是在不同方向上有较大的比例差距。
图中其中占据最大比例的是0°附近的直射光。因此在面对太阳观察云的时候,我们可以清晰的看到云的“金边”。
我们平常直接能够观察到的云的色彩来自30°到90°左右的散射。阳光和天空光在云介质中传播,最终又有一部分从表面传出,进入我们的眼中,因此,我们需要给表层附近的云添加环境光和类似漫反射的阳光散射光。在Ray Marching每一步中,我们都对朝向太阳的方向再采样几次查询该店是否能被阳光照射到,以正确模拟云层表面的光照。
再之后就是Powder Effect,主要效果是给边缘处增加类似“暗边”的效果(非阳光直射入眼)。采用了这个效果之后云的表面更有对比度,更能体现云的体积感。
最后计算累加得到的光照并不是直接求出的适用于像素的颜色值,因此我们需要进行一次Tone Mapping。
float3 ToneMapping (float3 x)
{
const float A = 0.15;
const float B = 0.50;
const float C = 0.10;
const float D = 0.20;
const float E = 0.02;
const float F = 0.30;
return ((x*(A*x + C * B) + D * E) / (x*(A*x + B) + D * F)) - E / F;
}
Temporal Upsampling
如果在Ray Marching时采用固定步长,会出现如图所示的带状条纹。
对此我们需要在Ray Marching时采用随机变动步长(随机数来源于采样一张噪点图)消除这种效果。但是这样做会导致噪点的出现,文中解决噪点采用的方法是时间性增采样(Temporal Upsampling),即计算采样点在上一帧中的屏幕UV位置并使用此UV对上一帧采样,以达到升采样效果消除噪点。
首先我选取的采样点是当前视线穿越大气层过程的中点,然后乘以从脚本传来的上一帧的VP矩阵即可得到上一帧剪裁空间的坐标,稍加处理就能得到上一帧UV。
previousVP = cam.projectionMatrix * cam.worldToCameraMatrix;
cloudMaterial.SetMatrix("_LastFrameVPMatrix",previousVP);
float4 reprojectionPoint = float4((rayMarchingStart + rayMarchingEnd) / 2,1);
float4 lastFrameClipCoord = mul(_LastFrameVPMatrix, reprojectionPoint);
float2 lastFrameUV = float2(lastFrameClipCoord.x / lastFrameClipCoord.w, lastFrameClipCoord.y / lastFrameClipCoord.w)* 0.5 + 0.5;
得到上一帧结果后我们可以采取多种方式混合两帧,这里我直接采用了简单的类似SrcAlpha OneMinusSrcAlpha的方法混合。
return lerp(lastFrameCol, currentFrameCol, lerpFac);
这里Lerp越靠近上一帧,除噪效果越好,但是收敛越慢;越靠近当前帧噪点越多,但是收敛快;这里用户可以根据自己的需求调节。
显然这种方式对相对玩家移动较慢的物体非常有效,我使用这种方法之后确实能得到不错的效果。如果我们能采用其他方式混合,其实还可以再降低采样率,例如地平线零之曙光的体积云就是采用每次更新4x4像素块中的1/16的方式,极大地提高了渲染效率。
优化
显然我们在Ray Marching时并不是所有的光线都需要走完规定的采样步数,如果光线走到已经完全不透光时就不需再往下走。
同样,如果某条光线上完全没有云我们能直接看到天空,那么我们也可以降低采样数。我们需要得到上一帧对应位置的透光度,如果非常接近1的话那么在本帧我们将大幅提高每一步的步长。
如果采样过程中连续好几步都采样到较低密度的话,我们此时将采用更“便宜”的采样并增加一定的步长,直到下一次采样到超过我们设定的密度阈值再换回常规采样。这里的“便宜“具体指的是我们不会在此去计算那几个朝向太阳的采样。
另外,Temporal Upsampling也让我能够把记录Ray marching分辨率降至原先的一半(1/4像素)。
采用这几种方式之后,渲染效率大概提高到了原先没有任何优化的情况的20倍(1~1.5ms)左右。尽管图形质量有略微下降,但是至少我们能将体积云渲染放在一个实时的系统里。
待完成
这几个有空再做。
首先是对体积云形态的进一步调整,我们观察显示中云的照片是能够发现云层侧边和底部一般有较多细节,而顶部形状较为规律,因此我们用Detail Texture蚀刻基本形时要增加一个和采样点高度负相关的权重。另外,对于类似积雨云的之类的巨型云朵底部应该是较为平坦的,这个要再重新写一下决定形态的公式。
现在的Shader已经可以使用高度图/厚度图,之后待生成几张有趣的图实验一下。
尝试采用地平线零之曙光PPT中提到的每次更新4x4像素块中的一点的方法极大增高效率,这样我们就可以有更多资源去消耗在更精确的光照模型上。
目前的混合是只在深度为1的情况下才混合(OnRenderImage阶段),因此无法实现云中的朦胧物体的效果。目前构思是用深度图重构出屏幕的世界空间坐标后再对Ray Marching的起点和终点进行调整,最后直接Alpha混合在屏幕上。这样应该不仅能正确显示出云中物体的效果,还能在天空被物体遮挡的情况下直接跳过Ray Marching,提升一些性能。
日出/晚霞/夜晚效果
高层云/卷云等