偶然在知乎上看到图形学大佬的几篇关于二维渲染绘制的文章,感觉非常有趣。与普通的光线求交不同,这里的二维渲染采用了光线步进法和符号距离场。下图就是渲染出来的效果,二维虽然少了一维,但渲染开销依旧大,渲染下面的这张图开启了多线程也花费了1个半小时多的时间。
- 二维渲染
- SDF构造实体几何
- 比尔-郎伯定律
- 实现效果
- 参考资料
1 | unsigned char * Renderer::render() |
1 | float Renderer::sample(float x, float y) |
1 | float Renderer::circleSDF(float x, float y, float cx, float cy, float radius) |
1 | float Renderer::trace(float ox, float oy, float dx, float dy) |
渲染的结果有很多明显的噪声点,这是因为我们的蒙特卡洛数值积分方法做了一个比较少数量的随机采样,导致积分估值的误差比较。增大采样数量能够使得结果更精确,噪声点减少,下图2显示了不同采样次数下的渲染结果,可以看到随着采样次数的增大,渲染出现的噪声点越来越说。但是随着采样次数的采样,算法耗费的时间也呈线性增长。为了能够在低采样数时依然获取较好的结果,我们从改进采样方法入手。
一种采样方法就是分层采样(Stratified sampling),这种采样方法不再随机,而是将采样的范围做一个等分,划分成$N$个区间,在每个区间采样一个确定而非随机的值。这种方法实际上就是非随机的均匀采样。这里我们将$[0,2\pi]$划分成$N$个区间,每个区间获取一个采样方向。在sample函数里修改一行代码即可:
1 | float Renderer::sample(float x, float y) |
采样了分层采样获取的结果如下图3所示,看起来还不错,渲染的结果不再有噪声点,此时的采样数量仅为$64$。但是分层采样渲染出来的效果太过规律了,很明显的几何分割痕迹,因此为了更好地获取渲染效果,我们将采用另外一种采样方法——抖动采样(Jittered Sampling)。
抖动采样方法就是将均匀随机采样和分层采样这两种方法结合在一起,首先将采样范围分成$N$个区间,在每个区间做均匀的随机采样,这就是抖动采样方法的思想,非常简单。同样我们只需在sample函数修改一行即可:
1 | // jittered sampling. |
上图4就是抖动采样获取的效果,采样数量同样为$64$个。可以看到,对比随机采样和分层采样,抖动采样获取的效果要好上不少。这里还有一点就是我们采样的随机数都是系统生成的伪随机数,相对而言均匀性较差,因此可以考虑前面一篇提到的低差异性随机数,占个坑,以后来填。
二、SDF构造实体几何
SDF是一个非常有用的工具,它通过一个函数来显式地表达几何形状。满足SDF函数$\Phi(x)\leq 0$的$x$点集,构成了该SDF所表达的几何体的封闭空间。我们还可以通过不同SDF函数之间进行集合运算构建出各种各样不同形状的几何体,这个就是SDF的强大之处。SDF的三个基本运算为并集、交集和相对补集,分别如下所示:
直接看上面的数学公式可能没有那么直观,我们可以通过围绕SDF构成了封闭空间为中心展开。
两个SDF函数的求并集运算就是将两个几何体构成的封闭空间(或者封闭空间中的点的集合)取并集,此时在几何体内的点$x$应满足$\Phi_A(x)\leq 0$或者$\Phi_B(x)\leq 0$,SDF值取最小的那个,这是因为两个SDF取并集时它们的几何体构成了一个整体,我们应该取到这个整体的最近表面的距离长度。
1 | Result SDF::unionOperation(Result a, Result b) |
两个SDF函数的求交集运算就是将两个几何体构成的封闭空间取交集,此时在几何体内的点$x$应满足$\Phi_A(x)\leq 0$且$\Phi_B(x)\leq 0$,SDF值取最大的那个,这是因为在几何体内的点其SDF函数值为负数,负数越大则其绝对值表示的距离越小。
1 | Result SDF::intersectOperation(Result a, Result b) |
两个SDF函数的相对补集就将第一个几何体构成的封闭空间减去第一个几何体与第二个几何体相交区域,此时在几何体内的点应满足$\Phi_A(x)\leq0$且$\Phi_B(x)\geq 0$,这个就相当于取$B$的补集,然后取$A$与$B$的补集之间的交集。$B$的补集就是取其SDF函数的相反数。
1 | Result SDF::subtractOperation(Result a, Result b) |
通过以上的三个SDF基本运算以及圆盘的SDF函数,我们就可以组合出一些比较新奇的几何体了。下面是两个不同场景的构建代码。第一个场景有三个发光圆盘,利用并集运算把它们全部囊括到场景中。第二个场景是两个有重合区域的发光圆盘使用不同的SDF运算。
1 | Result Scene::threeEmissiveSphereScene(float x, float y) |
上图5展示了几个组合不同SDF运算构建的几何实体,可以看到通过相对补集运算,我们可以构建一个月牙形状的几何体。此外还需要提一点的是,上图已经出现了阴影效果,这是因为我们在光线步进时触碰到几何体时不在往前步进,因此导致了几何遮蔽,几何体后方接收的光照强度相对较弱,从而产生阴影。
除了圆盘的SDF,接下来我们构建其他一些常见的几何体。首先来看下圆盘的SDF,即公式$(3)$,可以看到圆盘的SDF就是坐标$x$与圆心$c$的欧式距离再减去$r$,这个减去$r$非常关键,通过减去$r$我们把圆心点$c$扩展成半径为$r$的圆盘。实际上,对于任意形状几何体的SDF,减去一个常数$r$,都相当于把该几何体的封闭空间向外扩展$r$大小的范围。下面我们将使用这个特性。
首先是平面的SDF,但是在二维空间中平面退化成了直线。在二维空间中,直线将二维空间划分成了两个无穷大的两个平面。直线可用直线上的一点$p$和及其法线向量$n$来定义,对于直线上的任意一点$x$:
位于直线上方的点$(x-p)\cdot n>0$,下方的点$(x-p)\cdot n<0$,故直线的符号距离场函数为:
1 | float SDF::planeSDF(float x, float y, float px, float py, float nx, float ny) |
然后是胶囊条状的几何体。如下图所示,胶囊条用点$a$和$b$以及半径$r$来表示。胶囊几何体的SDF就等价于线段$ab$的SDF再减去$r$(即扩大$r$半径)。
线段的SDF就是计算一点$x$到该线段的距离,我们通过把$x$点投影到线段的延长线上,然后再将投影点$x’$限制在线段的范围内,因为有可能投影点在线段之外。如下图7所示。
然后距离场就为$||x-x’||$:
1 | float SDF::segmentSDF(float x, float y, float ax, float ay, float bx, float by) |
胶囊几何体在线段的基础上,在向外扩展半径$r$大小的范围:
1 | float SDF::capsuleSDF(float x, float y, float ax, float ay, float bx, float by, float radius) |
紧接着我们来实现矩形的SDF,我们把矩形当作一个定向的包围盒,由中心点$c$、旋转角$\theta$和半长$s$表示。
基本思路就是将SDF函数的输入$x$变换到OBB的坐标系。我们知道从OBB坐标系的$x’$转换到世界空间的$x$,其变换矩阵如下所示,需要注意我们的y轴正向是朝下的。
那么反过来从$x$到$x’$就是上面的逆过程,旋转矩阵为正交矩阵,其逆矩阵就是转置矩阵。
同时由于矩形具有对称性,我们仅考虑第一象限。
1 | float SDF::boxSDF(float x, float y, float cx, float cy, float theta, float sx, float sy) |
最后就是三角形的SDF,三角形可以用三个线段来表示,我们先计算点到三个线段的最小距离,然后判断点是在三角形里面还是外面来判断距离的符号。我们采用环绕顺序来判断内外情况。
1 | float SDF::triangleSDF(float x, float y, float ax, float ay, float bx, float by, float cx, float cy) |
下图9就是我们实现的几个SDF几何体。
三、比尔-郎伯定律
本文实现的本质上就是一个二维的光线追踪器,关于反射、折射以及菲涅尔定律前面都已经提及过,这里不再赘述。采用SDF进行光线步进来追踪光线有个问题,就是如何获取几何点上的法线向量。我们的几何体都是采用一个函数来表示,法线的获取的肯定与这个函数密切相关。在SDF构建的几何体边界,距离场变化最大的方向就是法线方向,距离长变化最大的方向就是SDF函数的梯度:
这里我们采用中心差分法求梯度的数值近似,中心差分法在前面流体模拟相关文章已提及多次:
中心差分法对于锯齿函数等这类不连续的函数存在着严重的零域问题,但是我们的SDF通常是一个连续的函数,因此不用担心中心差分法在此处出现严重错误。
1 | void Renderer::gradient(float x, float y, float & nx, float &ny) |
然后这里要提一下的就是透射问题,这个透射在之前的三维光线追踪渲染器中没有涉及到。透射在生活中很常见,一个例子就是大气层的透射导致天空呈现蓝色。这里要介绍关于透射的一个定律——比尔郎伯定律(Beer-Lambert law),它描述了电磁波(例如可见光)通过物体时,物体吸收部分电磁波,吸收的程度与物体的厚度、物质的消光系数和浓度密切相关,透射率计算公式如下:
$T\in[0,1]$即为透射率,$\alpha’$为物体的消光系数,通常与波长有关,因而是一个RGB向量。而$c\in [0,\infty)$是浓度,$d\in[0,\infty)$是光程距离。我们实现的物质是均匀的,因而$\alpha’$和$c$是一个常量,我们把这两个合并为一个,在光线步进的结果中新增一条消光率absorption。
1 | struct Result |
在计算透射率时就输入消光率absorption和光程距离,返回透射率。
1 | glm::vec3 Renderer::beerLambert(glm::vec3 a, float d) |
在追踪的过程中,将最终的结果再乘上这个透射率,这样就能实现透明的有色水晶。透明水晶呈现不同的颜色是因为光透光水晶时,一些波长的光被吸收,剩下的光波形成颜色,从而呈现水晶有颜色一样。下图10展示了实现的几个水晶宝石,非常漂亮。
1 | glm::vec3 Renderer::trace(float ox, float oy, float dx, float dy, int depth) |
四、实现效果
参考资料:
$[1]$ 用 C 语言画光(一):基础
$[2]$ 用 C 语言画光(二):构造实体几何
$[3]$ 用 C 语言画光(三):形状
$[4]$ 用 C 语言画光(七):比尔-朗伯定律