GAMES101大作业之SDF场景渲染

前言

由于在不久前终于完成了整个GAMES101课程的视频学习(当然除了大作业以外的作业也认真弄了),所以终于可以来挑战一下大作业了;虽然可以自由组织项目,但是作为菜鸟个人觉得课程给出的大作业选题就足够有意思和有挑战了(主要是想不出……),而且这里的选题符合课程内容方向,也比较容易去上手和扩展。

反正一眼扫过去,第一想要做的就是这个了:

img

因为这个一看就知道,只需要写片段着色器就好了,不需要操作其他的数据了,因而用webGL也正好;然后打开相关的资料一看,这作者太熟悉了,著名的iq大神(ShaderToy网站的创建者,且热衷于在其博客上分享各种图形学的干货内容,其制作的各种着色器demo或动画更是令人惊奇):

img

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了,可以说是十分丰富:

img

除了SDF,这篇文章甚至给出了常见的SDF图形之间的组合操作,如并集、交集,补集操作等,还有各种变形操作等等,十分有用的技巧:

img

没错,学会了上述技巧之后,场景和复杂物体的搭建无非就跟建模一样,各种组合和调参而已,接下来就是对场景物体的拆解了;

场景拆解

img

如上图所示,这里的场景大致可以分成三个部分:

  • 柱子
  • 地板
  • 类似于“章鱼”的东西

地板

地板就是大量的长方体组成的,然后不停地重复,所以比较简单,当然那种散乱的堆砌又是其他的细节了;

“章鱼”

而“章鱼”部分就是一个球体加上几个重复的扭曲状圆柱体组成,这一点PPT上有提及:

img

柱子

这个物体也是大量的重复,关于重复某个物体的技巧也可以看看上面提到的文章[1:1];但是单个的柱子还是比较复杂的,可以继续拆解:

img

呃,穹顶那个部分实在是不知道怎么构造,看起来不像是简单的半球形,所以暂时略过;上面的建模也并非完全按照一比一进行复原的,目的并非一比一复原上述场景,所以有个大概的样子就够了😂;把上述拆解的物体进行拼接即可得到类似的柱体:

img

场景渲染

在根据场景建模得到完整场景的SDF后,就可以开始相应的渲染了;按照一般流程,渲染所需要的信息大概有:

  • 顶点位置
  • 法线方向
  • 纹理坐标

由于这里并没有相关的纹理贴图,所以可以不用纹理坐标,因而需要根据SDF得到每个着色点对应的顶点位置和法线方向;

ray martching

由于SDF没法直接得到当前着色点对应的物体坐标点,因而常借助ray martching技术来获取当前着色点应该渲染哪个坐标点;原理实际上就是从着色点发射一束光线(射线),然后根据步长的SDF来计算这束光线是否碰到物体,碰到则渲染交点位置;实际上就是一般成像过程的逆过程,即以相机位置作为原点,以成像平面上的每个像素点为投射点,构建射线;

img

而投射的光线则可以构建出相应的射线方程:

r(t)=o+tdr(t) = \overrightarrow{o} + t * \overrightarrow{d}

o\overrightarrow{o}即投射的起点,d\overrightarrow{d}为射线方向(单位向量),tt就是步长了;

获取成像平面的位置

由于着色点和成像平面上的像素点实际上是一一对应的,因而可以根据相机参数得到成像平面上像素点的坐标,然后根据着色点进行映射即可得到当前着色点在相机空间坐标系中的坐标,最后进行坐标系变换即可得到对应的世界坐标系;

光线与物体求交

根据成像平面的像素点位置和相机位置即可得到当前发射的光线的方向,因而就可以得到对应的射线方程,接下来就是查找是否有物体和光线相交了;

当然,可以设置一个固定的步长,每次迭代都增加这个步长,然后根据SDF得到当前点的距离,判断是否与物体相交;不过这种方式可能会出现精度较低(步长较大时)或迭代次数大(步长较小)的问题;

那么有没有办法得到一个合适的步长?答案是有的,因为根据SDF的特性可知,SDF得到的是当前点到物体的距离,因此如果这个SDF包含场景下所有的物体时,得到的自然是当前点到场景下所有物体表面的最近距离,所以下一次迭代就可以用这个距离增加步长,就会大大地减少迭代次数和精度丢失的问题;

img

根据上图就可以看得很明显,这种以当前SDF值作为下一次步长的增量的方式,只会导致两种结果:

  • 刚好到达物体的表面
  • 还没到达物体的表面

所以不必担心下次迭代就穿透物体,导致精度很差;一般步骤可以描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vec4 rayMartch () {
vec3 ro = eyePos; // 光线原点
vec3 pos = getWorldPos(); // 当前着色点对应的坐标
vec3 rd = normalize(pos - ro); // 光线方向
vec4 color = vec4(vec3(0.0), 1.0);
float t = 0.0; // 当前步长
for (int i = 0; i < maxStep; i++) {
vec3 curPos = ro + rd * t;
float next = sdf(curPos); // 每次都获取最近距离作为步长
if (next < 0.001) { // 这里必须使用大于0的一个微小值来作为阈值判断
color.xyz = render(curPos); // 相交时进行渲染
break;
}
if (t > far) { // 光线超出最大距离不再计算
break;
}
t += next;
}
return color;
}

渲染

当投射的光线与物体相交时就可以对相交点进行渲染了,即把相交点的渲染当做着色点最终呈现的物体颜色(自然地处理了Z-buffer的关系);到了这一步是已经知道了物体的位置信息,这里以blinn-phong着色模型为例,还需要知道当前点对应的法向量

获取法向量

实际上,法向量就是物体位置的一阶导数,因而可以通过求梯度来近似求导:

n=normalize(SDF(x+Δx,y,z)SDF(xΔx,y,z),SDF(x,y+Δy,z)SDF(x,yΔy,z),SDF(x,y,z+Δz)SDF(x,y,zΔz))\overrightarrow{n} = normalize(\\ \begin{aligned} SDF(x + \Delta x, y, z) - SDF(x - \Delta x, y, z), \\ SDF(x, y + \Delta y, z) - SDF(x, y - \Delta y, z), \\ SDF(x, y, z + \Delta z) - SDF(x, y, z - \Delta z) \end{aligned}\\ )

转化为代码就是这样:

1
2
3
4
5
6
7
8
vec3 getNormal (vec3 p) {
vec2 diff = vec2(0.001, 0.0); // 设置一个较小的变化值
return normalize(vec3(
scene(p + diff.xyy) - scene(p - diff.xyy),
scene(p + diff.yxy) - scene(p - diff.yxy),
scene(p + diff.yyx) - scene(p - diff.yyx)
));
}

细节

虽然通过上述一系列计算已经可以得到一个SDF场景的渲染结果了,但是观察iq的作品不难发现里面还加了“亿点点”细节,用于营造更真实的环境;

凹凸映射

凹凸映射,简言之就是通过对物体的法向量进行一定的扰动使得物体表面“看起来”凹凸不平,但是实际上位置并没有改变;虽然可以通过纹理贴图来实现,不过也能通过类似于生成噪声的方法来实时生成凹凸映射的值。

PPT中有提及到使用fbmFractional Brownian Motion分形布朗运动)来生成bumping,而fbm的基本算法[2]如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float fbm( in vecN x, in float H )
{
float G = exp2(-H);
float f = 1.0;
float a = 1.0;
float t = 0.0;
for( int i=0; i<numOctaves; i++ )
{
t += a*noise(f*x);
f *= 2.0;
a *= G;
}
return t;
}

即不停地提高频率且同时减小振幅,然后累加噪声值,即可得到相应噪声的fbm;由于这里是三维物体,所以随便挑选一个三维的噪声即可,这里我使用了三维的柏林噪声

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 2维柏林噪声(xz平面)
float perlinNoise2d (vec3 pos) {
vec3 ipos = floor(pos);
vec3 fpos = fract(pos);
float a = dot(random3d(ipos), fpos);
float b = dot(random3d(ipos + topRight), fpos - topRight);
float c = dot(random3d(ipos + bottomLeft), fpos - bottomLeft);
float d = dot(random3d(ipos + bottomRight), fpos - bottomRight);
vec2 f = smoothstep(0.0, 1.0, fpos.xz);
float y1 = mix(a, b, f.x);
float y2 = mix(c, d, f.x);
return mix(y1, y2, f.y);
}

// 3维柏林噪声,立方体插值,先得到上下两个xz平面的插值,最后对y方向进行插值
float perlinNoise3d (vec3 p) {
vec3 bottom = vec3(p.x, floor(p.y), p.z);
vec3 top = bottom + vec3(0.0, 1.0, 0.0);
float y1 = perlinNoise2d(bottom);
float y2 = perlinNoise2d(top);

return mix(y1, y2, fract(p.y));
}

柏林噪声思路虽然简单,但是计算量还是偏多,其实也可以使用改进版的simplex噪声;不过我在iq的博客[3]上发现一个有意思的思路,就是用特定的SDF来替代噪声,让SDFfbm进行结合,看起来效果也不错,计算量也少了很多;

img

img

软阴影

SDF来生成硬阴影实在是过于便利,因为只需要像ray matching那样从当前点发出一束光线到光源位置即可,若在碰到光源之前就碰到了其他物体就是位于阴影之中;

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 硬阴影
float hardShadow (vec3 ro, vec3 rd) {
float res = 1.0;
float t = 0.05; // t的起步值要大于0,否则会出现全暗或密纹
for (int i = 0; i < maxStep; i++) {
vec3 curPos = ro + rd * t;
float d = distance(curPos, light);
float next = min(d, scene(curPos)); // 每次都获取最近距离作为步长(要考虑点光源的位置)
if (d == next) { // 直接碰到光源
res = 1.0;
break;
}
if (next < 0.001) { // 这里必须使用大于0的一个微小值来作为阈值判断
res = 0.0; // 光线被遮挡
break;
}
t += next;
}
return res;
}

不过,硬阴影看起来效果有点突兀,所以相应的就有了软阴影;软阴影实际上就是在原本的阴影区边缘部分会产生一层过渡,导致阴影看起来不是那么锐利,也更接近现实效果;实际上,在上述方法的基础上只需要额外计算一个值[4]就能获得不错的软阴影效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
float softshadow( in vec3 ro, in vec3 rd, float mint, float maxt, float k )
{
float res = 1.0;
for( float t=mint; t<maxt; )
{
float h = map(ro + rd*t);
if( h<0.001 )
return 0.0;
res = min( res, k*h/t ); // 这里多了一个当前步长与当前最近距离的比值
t += h;
}
return res;
}

img

至于为何这个比值的计算就能得到不错的软阴影效果,是因为这个比值实际上就是近似地得到了当前光线与途径物体表面之间的距离关系;顺着这个思路实际上也可以只比较每次的步长用于得到一个最小的步长来近似光线到途径物体表面的最小距离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 用光线到物体表面的最近距离来插值软阴影程度
float softShadow (vec3 ro, vec3 rd, float k) {
float res = 1.0;
float t = 0.1; // t的起步值要大于0,否则会出现全暗或密纹
float minD = 1000.0; // 最小步长,用于近似获取光线到物体的最近距离
for (int i = 0; i < maxStep; i++) {
vec3 curPos = ro + rd * t;
float d = distance(curPos, light);
float next = min(scene(curPos), d); // 每次都获取最近距离作为步长(要考虑点光源的位置)
if (d == next) { // 直接碰到光源
res = smoothstep(0.0, 0.5, minD);
break;
}
if (next < 0.001) { // 这里必须使用大于0的一个微小值来作为阈值判断
res = 0.0; // 光线被遮挡
break;
}
if (t > 1.0) { // 保证光线先离开渲染表面一段距离后才计算最近距离,避免光线刚开始得到的距离特别小但并不是有效的
minD = min(minD, next * k);
}
t += next;
}
return res;
}

img

从上图可以看到,利用这种思路也能得到软阴影效果;不过上述这两种软阴影的计算都不算太准确,所以iq的文章[4:1]中提到了一个比较巧妙的改进方法:利用ray matching中相邻两次的步长,然后形成两个球面,最后可以得到两球面的相交圆,然后计算光线到该圆的距离,这个距离可以更加精确地得到该光线到物体表面的最近距离!

img

好巧不巧,不久前正好在一本书中看到关于卫星导航定位的科普,里面就提到了一种定位思路,大致上是相似的:

img

AO(环境光遮蔽)

AO的思路实际上就是根据物体被周围物体遮挡程度来给定不同环境光,而非一个恒定的环境光值,因此更加接近真实效果;所以该如何计算物体被遮挡的程度?PPT中提到了一个极其巧妙的思路:从当前点沿着法线方向前进,每隔一段步长比较此时的SDF值和累进步长之间的关系;

img

用公式可以表示为如下:

ao=1.0ki=1512i(iΔSDF(p+niΔ))ao = 1.0 - k * \sum_{i = 1}^{5}\frac{1}{2^i}(i * \Delta - SDF(p + n * i * \Delta))

其中:Δ\Delta为每步前进的步长,pp为物体位置,nn为物体法向量,kk调整遮挡程度的系数;对应的glsl实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取当前点的环境光遮蔽(AO)系数近似值,size(步长)越大AO越小
// 算法参照PPT
float getAO (vec3 p, vec3 n, float size, float k) {
float hidden = 0.0; // 累计遮挡程度
float basis = 0.5; // 权重系数

for (int i = 1; i < 6; i++) {
float delta = float(i) * size;
hidden += basis * (delta - sdf(p + n * delta));
basis *= 0.5;
}

return 1.0 - k * hidden;
}

img

从上图可以看到,开启AO之后,一些边角的地方变得更暗了;

体积光/散射光

在现实中,光线除了直接照射到物体上被感知到,还有一种沿着空气传播被人看到的即视感,类似于“丁达尔效应”,这种感知是字面上的“光线”;这种现象就是光被空气中的胶体/微粒所散射而造成的,CG中称之为体积光

知道了体积光产生的原因,可能会立马想到直接通过在场景中添加一定数量的微粒物体不就好了;但是,很明显这种方式会导致计算量飙涨;而常用的一个计算体积光方法就是利用ray martching技术从相机位置沿着屏幕平面一点的方向进行均匀步长的采样计算:

img

关于这种技术的细节和实现推荐阅读这篇文章:在 Unity 中实现体积光渲染 - 知乎;加上体积光之后,场景的光线确实看起来更加有“仙气”了,尤其是光线碰到遮挡物后:

img

虽然加上体积光会让场景看起来更真实,但是在这种SDF场景的渲染中,每次着色器渲染都会调用SDF上万次,这种计算量实在是过大,经测试开启体积光和不开启体积光渲染时间大致为10:1

后话

虽然这个课题看似很简单,但是实现过程中才发现原来还隐藏了那么多的渲染细节,真的是一边实现一边找资料对比和优化,学到了很多;

本来是想把这个SDF场景做成一个实时的动画,加上一种类似于烟雾缭绕的效果,但是加上体积光的渲染是真的无法达到实时效果,哪怕是不加上体积光也只能做到10FPS,远达不到最低的24FPS,所以还得看看有没有更高效的基于SDF的体积光实现方法了;

相关文档


  1. Inigo Quilez :: fractals, computer graphics, mathematics, shaders, demoscene and more ↩︎ ↩︎

  2. fBM - 2019 ↩︎

  3. FBM detail in SDFs - 2019 ↩︎

  4. soft shadows in raymarched SDFs - 2010 ↩︎ ↩︎