GAMES101大作业之SDF场景渲染
前言
由于在不久前终于完成了整个GAMES101
课程的视频学习(当然除了大作业以外的作业也认真弄了),所以终于可以来挑战一下大作业了;虽然可以自由组织项目,但是作为菜鸟个人觉得课程给出的大作业选题就足够有意思和有挑战了(主要是想不出……),而且这里的选题符合课程内容方向,也比较容易去上手和扩展。
反正一眼扫过去,第一想要做的就是这个了:
因为这个一看就知道,只需要写片段着色器就好了,不需要操作其他的数据了,因而用webGL
也正好;然后打开相关的资料一看,这作者太熟悉了,著名的iq
大神(ShaderToy
网站的创建者,且热衷于在其博客上分享各种图形学的干货内容,其制作的各种着色器demo
或动画更是令人惊奇):
iq
在上述场景渲染的演讲PPT
中虽然没有给出完整的着色器代码,但是关于渲染的各种重要部分都给出了伪代码和原理讲解,所以推荐仔细阅读这个PPT
;
不过,尽管这种渲染方式无需准备复杂场景的顶点数据和繁琐的矩阵变换等等,但绝不代表着就很容易实现。
实现效果
https://xiexuefeng.cc/app/we/demo/games101-sdf/
场景搭建
SDF
SDF
,即signed distance function/field
(有向距离函数/场);原理很简单,给定某个点的坐标,距离函数可以得到该点在场景中距离最近的点的距离,如果距离为正则表明该点在物体外面,为负则在物体内部;更多的原理讲解可以参考这篇文章——Signed Distance Field(基础篇) - 知乎,就不再赘述。
总之就是给定了一个物体甚至是一个场景的SDF
后,就能根据SDF
渲染出对应的物体或场景;所以如何才能得到一个物体的SDF
呢?尤其是那种看起来很复杂的,一眼压根想不出来对应的SDF
。
其实就跟建模类似,再复杂的物体都可以通过一些基本图形的组合操作来组成;这里可以推荐iq
总结[1]的常见的3D
基本图形的SDF
了,可以说是十分丰富:
除了SDF
,这篇文章甚至给出了常见的SDF
图形之间的组合操作,如并集、交集,补集操作等,还有各种变形操作等等,十分有用的技巧:
没错,学会了上述技巧之后,场景和复杂物体的搭建无非就跟建模一样,各种组合和调参而已,接下来就是对场景物体的拆解了;
场景拆解
如上图所示,这里的场景大致可以分成三个部分:
- 柱子
- 地板
- 类似于“章鱼”的东西
地板
地板就是大量的长方体组成的,然后不停地重复,所以比较简单,当然那种散乱的堆砌又是其他的细节了;
“章鱼”
而“章鱼”部分就是一个球体加上几个重复的扭曲状圆柱体组成,这一点PPT
上有提及:
柱子
这个物体也是大量的重复,关于重复某个物体的技巧也可以看看上面提到的文章[1:1];但是单个的柱子还是比较复杂的,可以继续拆解:
呃,穹顶那个部分实在是不知道怎么构造,看起来不像是简单的半球形,所以暂时略过;上面的建模也并非完全按照一比一进行复原的,目的并非一比一复原上述场景,所以有个大概的样子就够了😂;把上述拆解的物体进行拼接即可得到类似的柱体:
场景渲染
在根据场景建模得到完整场景的SDF
后,就可以开始相应的渲染了;按照一般流程,渲染所需要的信息大概有:
- 顶点位置
- 法线方向
- 纹理坐标
由于这里并没有相关的纹理贴图,所以可以不用纹理坐标,因而需要根据SDF
得到每个着色点对应的顶点位置和法线方向;
ray martching
由于SDF
没法直接得到当前着色点对应的物体坐标点,因而常借助ray martching
技术来获取当前着色点应该渲染哪个坐标点;原理实际上就是从着色点发射一束光线(射线),然后根据步长的SDF
来计算这束光线是否碰到物体,碰到则渲染交点位置;实际上就是一般成像过程的逆过程,即以相机位置作为原点,以成像平面上的每个像素点为投射点,构建射线;
而投射的光线则可以构建出相应的射线方程:
即投射的起点,为射线方向(单位向量),就是步长了;
获取成像平面的位置
由于着色点和成像平面上的像素点实际上是一一对应的,因而可以根据相机参数得到成像平面上像素点的坐标,然后根据着色点进行映射即可得到当前着色点在相机空间坐标系中的坐标,最后进行坐标系变换即可得到对应的世界坐标系;
光线与物体求交
根据成像平面的像素点位置和相机位置即可得到当前发射的光线的方向,因而就可以得到对应的射线方程,接下来就是查找是否有物体和光线相交了;
当然,可以设置一个固定的步长,每次迭代都增加这个步长,然后根据SDF
得到当前点的距离,判断是否与物体相交;不过这种方式可能会出现精度较低(步长较大时)或迭代次数大(步长较小)的问题;
那么有没有办法得到一个合适的步长?答案是有的,因为根据SDF
的特性可知,SDF
得到的是当前点到物体的距离,因此如果这个SDF
包含场景下所有的物体时,得到的自然是当前点到场景下所有物体表面的最近距离,所以下一次迭代就可以用这个距离增加步长,就会大大地减少迭代次数和精度丢失的问题;
根据上图就可以看得很明显,这种以当前SDF
值作为下一次步长的增量的方式,只会导致两种结果:
- 刚好到达物体的表面
- 还没到达物体的表面
所以不必担心下次迭代就穿透物体,导致精度很差;一般步骤可以描述如下:
1 | vec4 rayMartch () { |
渲染
当投射的光线与物体相交时就可以对相交点进行渲染了,即把相交点的渲染当做着色点最终呈现的物体颜色(自然地处理了Z-buffer
的关系);到了这一步是已经知道了物体的位置信息,这里以blinn-phong
着色模型为例,还需要知道当前点对应的法向量;
获取法向量
实际上,法向量就是物体位置的一阶导数,因而可以通过求梯度来近似求导:
转化为代码就是这样:
1 | vec3 getNormal (vec3 p) { |
细节
虽然通过上述一系列计算已经可以得到一个SDF
场景的渲染结果了,但是观察iq
的作品不难发现里面还加了“亿点点”细节,用于营造更真实的环境;
凹凸映射
凹凸映射,简言之就是通过对物体的法向量进行一定的扰动使得物体表面“看起来”凹凸不平,但是实际上位置并没有改变;虽然可以通过纹理贴图来实现,不过也能通过类似于生成噪声的方法来实时生成凹凸映射的值。
PPT
中有提及到使用fbm
(Fractional Brownian Motion
,分形布朗运动)来生成bumping
,而fbm
的基本算法[2]如下:
1 | float fbm( in vecN x, in float H ) |
即不停地提高频率且同时减小振幅,然后累加噪声值,即可得到相应噪声的fbm
;由于这里是三维物体,所以随便挑选一个三维的噪声即可,这里我使用了三维的柏林噪声:
1 | // 2维柏林噪声(xz平面) |
柏林噪声思路虽然简单,但是计算量还是偏多,其实也可以使用改进版的simplex
噪声;不过我在iq
的博客[3]上发现一个有意思的思路,就是用特定的SDF
来替代噪声,让SDF
与fbm
进行结合,看起来效果也不错,计算量也少了很多;
软阴影
用SDF
来生成硬阴影实在是过于便利,因为只需要像ray matching
那样从当前点发出一束光线到光源位置即可,若在碰到光源之前就碰到了其他物体就是位于阴影之中;
1 | // 硬阴影 |
不过,硬阴影看起来效果有点突兀,所以相应的就有了软阴影;软阴影实际上就是在原本的阴影区边缘部分会产生一层过渡,导致阴影看起来不是那么锐利,也更接近现实效果;实际上,在上述方法的基础上只需要额外计算一个值[4]就能获得不错的软阴影效果:
1 | float softshadow( in vec3 ro, in vec3 rd, float mint, float maxt, float k ) |
至于为何这个比值的计算就能得到不错的软阴影效果,是因为这个比值实际上就是近似地得到了当前光线与途径物体表面之间的距离关系;顺着这个思路实际上也可以只比较每次的步长用于得到一个最小的步长来近似光线到途径物体表面的最小距离:
1 | // 用光线到物体表面的最近距离来插值软阴影程度 |
从上图可以看到,利用这种思路也能得到软阴影效果;不过上述这两种软阴影的计算都不算太准确,所以iq
的文章[4:1]中提到了一个比较巧妙的改进方法:利用ray matching
中相邻两次的步长,然后形成两个球面,最后可以得到两球面的相交圆,然后计算光线到该圆的距离,这个距离可以更加精确地得到该光线到物体表面的最近距离!
好巧不巧,不久前正好在一本书中看到关于卫星导航定位的科普,里面就提到了一种定位思路,大致上是相似的:
AO(环境光遮蔽)
AO
的思路实际上就是根据物体被周围物体遮挡程度来给定不同环境光,而非一个恒定的环境光值,因此更加接近真实效果;所以该如何计算物体被遮挡的程度?PPT
中提到了一个极其巧妙的思路:从当前点沿着法线方向前进,每隔一段步长比较此时的SDF
值和累进步长之间的关系;
用公式可以表示为如下:
其中:为每步前进的步长,为物体位置,为物体法向量,为调整遮挡程度的系数;对应的glsl
实现如下:
1 | // 获取当前点的环境光遮蔽(AO)系数近似值,size(步长)越大AO越小 |
从上图可以看到,开启AO
之后,一些边角的地方变得更暗了;
体积光/散射光
在现实中,光线除了直接照射到物体上被感知到,还有一种沿着空气传播被人看到的即视感,类似于“丁达尔效应”,这种感知是字面上的“光线”;这种现象就是光被空气中的胶体/微粒所散射而造成的,CG
中称之为体积光;
知道了体积光产生的原因,可能会立马想到直接通过在场景中添加一定数量的微粒物体不就好了;但是,很明显这种方式会导致计算量飙涨;而常用的一个计算体积光方法就是利用ray martching
技术从相机位置沿着屏幕平面一点的方向进行均匀步长的采样计算:
关于这种技术的细节和实现推荐阅读这篇文章:在 Unity 中实现体积光渲染 - 知乎;加上体积光之后,场景的光线确实看起来更加有“仙气”了,尤其是光线碰到遮挡物后:
虽然加上体积光会让场景看起来更真实,但是在这种SDF
场景的渲染中,每次着色器渲染都会调用SDF
上万次,这种计算量实在是过大,经测试开启体积光和不开启体积光渲染时间大致为10:1
;
后话
虽然这个课题看似很简单,但是实现过程中才发现原来还隐藏了那么多的渲染细节,真的是一边实现一边找资料对比和优化,学到了很多;
本来是想把这个SDF
场景做成一个实时的动画,加上一种类似于烟雾缭绕的效果,但是加上体积光的渲染是真的无法达到实时效果,哪怕是不加上体积光也只能做到10FPS
,远达不到最低的24FPS
,所以还得看看有没有更高效的基于SDF
的体积光实现方法了;
相关文档
- shader 中,fwidth 或者说 ddx/ddy 到底是什么意思? - 知乎
- 入门Distance Field Soft Shadows - 知乎
- Signed Distance Field - 知乎:常见二维图形的
SDF
描述