雪地脚印 & 体积云

雪地脚印 & 体积云

【博物纳新】专栏是UWA旨在为开发者推荐新颖、易用、有趣的开源项目,帮助大家在项目研发之余发现世界上的热门项目、前沿技术或者令人惊叹的视觉效果,并探索将其应用到自己项目的可行性。很多时候,我们并不知道自己想要什么,直到某一天我们遇到了它。

今天推荐的两个项目来自UWA开源库:雪地脚印和体积云。


雪地脚印

一、概览

https://lab.uwa4d.com/lab/5b66026ed7f10a201ff999c2

该项目实现的功能是在雪地上踩出脚印,只支持PC平台。

效果如下:

二、实现原理

1. 脚印踩在何处?
我们可以看到dynamic_texture这个父物体下有一个摄像机(暂时叫它副摄像机),并且在运行状态下,每一个实时踩出的脚印都一一对应着框中一个个实例化出的default_sprite预制体,如下图:


图1:dynamic_texture结构

在dynamic_texture附带的DistanceMapPainter中,最主要的工作就是把脚印的基本信息以颜色的形式附给预制体的颜色信息,如下图:


图2:颜色包含的信息(from DistanceMapPainter.cs)

那这些预制Sprite的意义是什么呢?不只是传递以颜色为表现形式的位置信息,也在通过a通道传递脚印的“寿命”信息。


图3:颜色的a通道(from sprite_fading.cs)

最重要的是,还会保存深度信息:


图4:Sprite中的深度信息(from DistanceMapPainter.shader)

2. 脚印信息如何传递?
副摄像机在这里会专门“监视”这些实时生成的预制体,然后把它们渲染在PainterRT上(如下图):


图5:副摄像机的渲染目标

那这张“PainterRT”记录着什么呢?直观上来讲,它记录着如图1中花花绿绿的方块图,上文也提到过了这些颜色包含的是各个脚印的位置等信息,但它们重叠怎么办呢?

请注意我们刚才提到Sprite预制体中的Shader会保存深度信息,然后看PainterRT的信息:


图6:PainterRT基本信息

它是打开深度缓存的,所以这张RT上保存的是通过了深度检测的点的颜色信息。至于哪些点能通过深度检测呢?按照图4对于深度的写法,那就是处于每个Sprite中心位置最“白”的地方,也就是最靠近摄像机的地方,也就是最后能通过深度检测的地方,那最后这张图的中心处肯定能渲染到RT上。


图7:把深度作为颜色输出之后的PainterRT

为了直观地看到深度图的效果,我们暂时把深度作为颜色输出(看完之后记得改回来哦~),可以看到单个的Sprite是一个白色的“山丘”,越靠近中心越白,而我们看到的这些白色其实已经是通过了深度测试的了,可见每个山丘的中心是一定能通过深度测试的,而其他边缘处,即使记载了脚印信息,但是脚印也很可能覆盖不到那儿(当然,这得看你想把脚印画多大了)。

那么会存在两张图完全重合而使得信息读取不到的情况吗?从上图可知只要不是两张图完完全全重合,就不会互相影响,如果真的是两个山丘完全重合,那么的确会丢失其中一个脚印。这就是为什么操控着角色在一个地方反复徘徊,总有几个脚印画不出来的缘故。

而这张RenderTexture将会作为地面Shader的输入,这样就把位置、角度等信息通过一张图传给了地面Shader。

3. 曲面细分着色器
我们现在来看最重要的地面Shader——SnowGroundShader。

打开并浏览这个Shader,可以看到这个Shader中使用了细分曲面。


图8:曲面细分着色器和几何着色器的标志

关于曲面细分的教程并不多,可以参考《Unity Shader:曲面细分着色器》

我们主要关注细分计算着色器,因为大部分重点在这一部分。


图9:细分计算着色器中获取脚印信息部分

其中_DistanceFiled就是上文中副摄像机渲染的图,centerPos和angle分别读取了其中保存的位置和角度信息。

至于地面的凹凸起伏,就是在顶点的纵向上加上高度,而高度就是从高度图读取的,如下图:


图10:细分计算着色器中顶点的起伏计算

顶点只要变换了,法线就得重构,这里切线空间的三个向量会根据高度图重新计算,存入o.tspace中:


图11:细分计算着色器中法线重构部分

4. 最后的效果
最后的效果在片元着色器中。

首先视觉效果上,上文的o.tspace中保存的因高度改变而重构的法线会与雪地和脚印的法线之和做一个点乘操作,如下图:


图12:片元着色器中法线合并部分

之后的光照,就是用了Unity封装的LightingStandard_Deferred(该项目用了延迟渲染),这里就不赘述了。

总之,整个流程就是确定位置和角度、根据高度图调整顶点高度、重构法线。对于曲面细分的部分,细分的越精细,效果就越精致。

5. Tips
下图是Tessellation hull constant shader,其功能是用来输出细分因子(Tessellation factor)。细分因子用于在Tessellation阶段告诉硬件如何对Patch进行细分。

我们可以看到这里的factor不是写死的,旨在让需要细分的地方细分,也就是只细分脚印周围的地方,不失为一个优化性能的好办法。


图13:细分因子的实时计算部分


图14:细分因子的实时计算效果

体积云

一、概览

https://lab.uwa4d.com/lab/5bc54ec004617c5805d4e8d0

体渲染是从三维标量数据生成图像的渲染技术。通过对自然现象的测量或数值仿真,体渲染可以绘制出逼真的效果。在视觉艺术和计算机游戏领域中,体渲染经常用于模拟云、雾和火等自然现象。

该篇的CloudSkybox项目是Unity默认程序天空盒着色器的扩展,它就是使用了体渲染技术绘制云。

二、原理概述

该项目主要由两部分组成:基于大气散射的天空和基于光线步进的体积云。

在描绘天空时,通过简略地模拟大气散射过程的算法,我们可以渲染出一个带有瑞利和米氏散射现象的天空;而在描绘云时,则采用了噪声采样和光线步进结合的传统方法,来渲染出云层的体积感。

三、具体实现

1. 大气散射
该项目的主要逻辑都在CloudSkybox.shader和ProceduralSky.cginc中,先看一下顶点着色器:


图1:顶点着色器

顶点着色器重点在vert_sky函数,该函数的实现在ProceduralSky.cginc中。它的作用在描绘地面、天空和太阳的颜色,用的方法来自于Nvidia上一篇很经典的文章:
https://developer.nvidia.com/gpugems/gpugems2/part-ii-shading-lighting-and-shadows/chapter-16-accurate-atmospheric-scattering

对于大气散射原理的讲解有很多,这里不再细说,只讲一些标志性的代码段:


图2:scale函数

该函数近似于查找表,计算出来的是某个方向的射线上光强的衰减。该方法虽然简化了计算,但前提条件是要求平均大气密度为2.5%。这是一种比较简化的方法,现在大都用预计算和查找表的方法替代该方法。


图3:衰减公式实现

这里对应的公式主要是衰减和光学密度公式:

其中scale查表查到的是以某个角度发射的射线路径上的光学密度,而(kInvWavelength * kKr4PI + kKm4PI)对应的是上式的散射系数β,所以119行的attenuate就是衰减函数的实现。

最终要满足的是上述方程,而121行的depth和scaledLength分别对应上式的密度函数ρR,M(h)和微元ds。至于相位函数F(θ)和散射系数β是在之后的输出时分别乘上的:



图4:散射公式的完善

这里的很多变量是声明在开头处的一些分母的计算,旨在减少除法的运算次数。

上述公式图来源于以下知乎文章:

《[Rendering] 基于物理的大气渲染》

《【译】【大气散射】[Elek09] 实时渲染参数化的、有多次散射的行星大气》

2. 体积云
我们把摄像机想象成一个射线簇,每条射线都从相机坐标出发,对应着穿过近裁剪平面上每个像素,而云层的描绘就是由于这每条射线穿过的云层的密度不同造成的。一般上体积云的渲染,都会采用光线步进法(Ray marching)来进行云层密度的累加计算,这也是核心部分。

不过在该项目中,光线步进法出现在了两个地方。
第一处是单独封装的MarchLight方法:


图5:光线步进方法

该方法描述了从每个pos出发,沿着光源方向步进到穹顶的射线上,累加的云层密度对光所造成的衰减(通过BeerPowder,也就是比尔定律计算)总共有多少,为片元中计算散射做准备。

第二处在片元着色器中,如下图:


图6:片元着色器中的光线步进

在该循环中,acc累加了每个像素发出的射线上,每个步进点处的云层密度和光源在该点处发生的单次散射的乘积(193行),而在循环结束后,又融合了天空被云层衰减之后的颜色(199行)。

值得注意的是在算散射时,用到了Henyey-Greenstein相位函数,该函数可用于计算向内散射,以在面对太阳的云具有高亮效果。


图7:相位函数

至于云层密度的采样,是用了tex3Dlod采样事先计算好的基于Perlin和Worley噪声的Texture3D,并且通过_Time.x来控制采样点的偏移,实现了云层的移动。


图8:噪声采样函数

3. 性能分析
通过UWA的GOT OnLine工具在高端机型OPPO K9(8G RAM)上对该工程进行的检测来看,数据并不乐观,该机型上的性能简报如下图:

帧率尚不足8帧/秒。下图是CPU耗时情况:

从图中可以看到几乎都是Gfx.WaitForPresentOnGfxThread在耗时,该函数表明GPU的运行压力较大,我们来看GPU的耗时:

GPU的耗时均值为124.31ms/帧,这个耗时是非常高的,可想而知在中低端机上该耗时将会更加严重。综上所述,该插件并不适合应用在手机项目中,不过可以用来学习借鉴。