真实感图像渲染系列:小结

这是真实感图像渲染系列的第十篇文章。

这里写一写个人在编写过程中发现的一些坑,分享出来以免之后再踩吧。

  • CMake好啊,比Makefile好,可以很容易整合第三方库,但是……编译超级慢,别人的工程3s编译完成我的需要半分钟……
  • eps设多少确实是个坑,我设的是1e-7,但是得保证牛顿迭代的精度严格高于eps,否则焦点可能存在大于eps的误差,eps的设置就没有意义了
  • MAC的错误报告挺有用的 == 比如下面这个直接秒调

真实感图像渲染系列:相机(Camera)与景深效果(Field of Depth)

这是真实感图像渲染系列的第九篇文章。

普通相机模型

不同的相机模型中,有一个人眼位置和一个屏幕,对于屏幕中每一个像素,连接人眼和该像素位置得到一条射出光线。其2D示意图如下:

首先,一直屏幕像素坐标,以及屏幕本身在场景中的位置,我们可以计算出该像素的位置,如图中红点所述。接着,连接人眼和像素的光线就可以用于计算该光线的颜色。这一部分挺容易理解的。

带景深的相机模型

在普通相机模型的屏幕和人眼位置之外,带景深的相机模型额外存在一个焦平面。如下图所示

通过普通的相机模型,我们可以求得普通相机的光线的方向。

普通相机的光线一定会和焦平面在某个位置碰撞,为了达到景深效果,一方面,我们需要保证同一个像素对应的光线一定会击打到焦平面上的同一个点,另一方面,在焦平面之外的其他位置,同一个像素对应的光线应该击打到不同的点。

解决方案如下:我们求得在普通相机模型中,光线和焦平面的焦点,将该焦点景深相机模型的焦点。接下来,我们对光线的起始位置做一个微小扰动,并且修改光线方向使得光线恒定过焦平面上的点。这样,无论初始光线如何扰动,焦平面上的点都可以最清晰的表现出来。而不在焦平面上的点,会产生随机的模糊效果。

 

 

真实感图像渲染系列:贝塞尔曲线旋转体与直线求交

这是真实感图像渲染系列的第八篇文章。

概述

贝塞尔曲线是一个由多个控制点组合而成的曲线。在本文探讨的贝塞尔曲线,是由多条简单的4阶贝塞尔曲线拼接而成。绕z轴旋转贝塞尔曲线,我们可以得到一个旋转体曲面,该曲面就是待渲染的参数曲面。本文将探讨如何使用牛顿迭代法与直线求交。求交过程中

  1. 关于迭代初值的求法
  2. 迭代中的一些优化技巧
  3. 关于浮点数精度误差eps的求法。

贝塞尔曲线的定义

贝塞尔曲线是一个由多个控制点组合而成的曲线,n阶贝塞尔曲线定义为

(1)   \begin{equation*}  P(u) = \left[ \begin{matrix}P^{(x)}(u) \\ P^{(y)}(u) \end{matrix} \right] = \left[ \begin{matrix} \sum _{i=0}^n P_i^{(x)} C(n,i) (1-u)^{n-i}u^i \\ \sum _{i=0}^n P_i^{(y)}C(n,i) (1-u)^{n-i}u^i \end{matrix} \right] \end{equation*}

式(1)定义了该曲线P(u)的关于参数u \in [0,1]的参数方程,其中P_i为n个曲线的控制点。当我看到这个公式的时候,整个人都已经懵掉了。实际上理解起来并不复杂。

函数P(u)由参数u定义了一个二维向量,其中P^{(x)}P^{(y)}相互独立。只需要理解其中一个的产生式即可。考虑函数 f_i(u) =C(n,i) u^i (1-u)^{n-i-1},发现这f_0(u),f_1(i),\cdots,f_{n-1}(u)实际上是一个和为1的数列,数列在杨辉三角形中定义。换句话来说,f_i(u)实际上可以得到一个权值的分配,使用f_i(u)对于所有的控制点P_i加权平均,就是贝塞尔曲线尝试完成的事情。当f_0(u) = C(n,0) u^0 (1-u)^{n-i} = 1权值全部分配给了P_0,因此贝塞尔曲线的起点(u=0)和P_0重合。

贝塞尔曲线旋转体

在三维空间中,我们通过旋转贝塞尔曲线得到一个曲面。曲面强制对z轴旋转对称。(注:在代码中是对x轴旋转对称,不过z轴对称相对好理解一些)。

(2)   \begin{equation*}  S(u,\theta) = \left[ \begin{matrix} Q_x + sin\theta P^{(x)}(u) \\ Q_y + cos\theta P^{(x)}(u) \\ Q_z + P^{(y)}(u) \end{matrix} \right] \end{equation*}

一个贝塞尔曲线旋转体要能够成功渲染,其核心就是需要支持与光线的求交。光线我们使用(rayO,rayD)表示,rayO表示光线起点,rayD表示光线方向,是一个单位向量。则光线上一点可表示为C(t),满足:

(3)   \begin{equation*}  C(t) = rayO + t \times rayD \end{equation*}

倘若直线C(t)S(u,\theta)相交,则有

(4)   \begin{equation*}  F(t,u,\theta) = C(t) - S(u,\theta) = 0 \end{equation*}

其中,F(t,u,\theta)t,u,\theta的函数,要求t>00\leq u \leq 1,原问题可转化为求函数零点问题。函数零点问题使用牛顿迭代解决。

(5)   \begin{equation*}  x_{i+1} = x_i - [F'(x_i)]^{-1} \cdot F(x_i) \end{equation*}

其中F'(x_i)为函数F(x_i)的Jacobian矩阵,定义为:

(6)   \begin{equation*}  \frac{\partial F}{\partial t} = \left[ \begin{matrix} \frac{\partial F_1}{\partial t} & \frac{\partial F_1}{\partial u} & \frac{\partial F_1}{\partial \theta} \\ \frac{\partial F_2}{\partial t} & \frac{\partial F_2}{\partial u} & \frac{\partial F_2}{\partial \theta} \\ \frac{\partial F_3}{\partial t} & \frac{\partial F_3}{\partial u} & \frac{\partial F_3}{\partial \theta} \\ \end{matrix} \right] \end{equation*}

要试图计算出式(\ref(eq:jacobian)),我们需要分别求出F(t,u,\theta)对于t,u,\theta偏导数的每一个分量的值(共九个)。结果为:

(7)   \begin{equation*}  \frac{\partial F}{\partial args} = \left[ \begin{matrix} rayD_x & -sin\theta \frac{dP_x}{du} & -cos\theta P_x(u)\\ rayD_y & -cos\theta \frac{dP_x}{du} & +sin\theta P_x(u)\\ rayD_z & -\frac{dP_y}{du} & 0\\ \end{matrix} \right] \end{equation*}

其中\frac{dP}{du}是对于式子(1)按照最原始的求导法则求导的结果。

贝塞尔曲面物体设计

如图

共6条4阶贝塞尔曲线,每一条由绿蓝红绿四个控制点定义。

注意事项

  1. 牛顿迭代需要保证答案中u \in [0,1],而牛顿迭代本身无法附加条件,因此需要在答案的邻域中查找合适的u,t,\theta,我用到的方法是,对于给定曲线,生成一个圆柱体包含框,随机该圆柱体里面高度h的一个圆形面片,将光线与该面片的交点的u,t,\theta作为迭代的初值。随机h约30次即可得解。
  2. 由于牛顿迭代不太精确,可能是的结果产生微小偏移,这种偏移会对判断点在平面哪一侧产生影响,我们可以通过调整eps的取值,不同部分赋予不同的eps,使得这些误差不至于相互影响。
  3. 贝塞尔曲线求交本身费时间,但是判断是否一定没有交点却相对简单。可以求出贝塞尔曲线旋转体的“圆柱体包围盒”,(更精确的话,可以是空心圆柱体包围盒)。然后判断直线是否与包围盒有交点。判断直线与圆柱交点P = rayO + k \times rayD的位置,可以先讲所有东西映射到z=0屏幕,这是变成2维直线和圆求交,算出k,回带到三维情况验证。
  4. 实际上,像本文采用的多条低阶贝塞尔曲线求交并不明智,倘若将本文那个碗的6条贝塞尔曲线改为1条简单一些的曲线,然后做一个玻璃杯什么的,渲染效率将提高6倍!

 

 

真实感图像渲染系列:使用OpenMP并行优化渲染

这是真实感图像渲染系列的第七篇文章。

如果将渲染修改为并行,那么渲染效率将提升约8倍(我的电脑是八核)。非常非常有诱惑力。而且基于OpenMP框架的并行化甚至还非常简单!

平台编译

使用OpenMP最麻烦的地方是框架安装问题,倘若本机的g++版本支持 -fopenmp ,那么恭喜你,你已经完成了90%的工作了。倘若本机g++不支持该编译选项,那么需要在网上搜索对应的安装方法。安装方法不同系统都有较大的差别,故不在这里细讲。

OpenMP程序并行化指令都是用 #pragma 实现的,也就是说倘若编译器不支持,也不会产生什么问题。

OpenMP简单使用

这是一个最简单的for语句,对应于PPM算法中的光子映射。

该for语句满足如下条件:

  • 循环起始位置和终结位置确定
  • 不同的循环没有依赖性,

满足这样条件的for语句,理论上,将循环不同的i并行处理是没有问题的。OpenMP使用了最简单的预编译指令帮助我们实现for语句的并行,修改为

OpenMP临界区设置

#pragma omp critical 编译器将自动将 #pragma 指令后面紧接着的语句块进行并行。

一般来说,并行语句块内使用的变量,都是读写局部变量以及读全局变量,这时,不会存在什么问题,但是总有特殊情况,我们多个并行的循环需要修改同一个全局变量。这是可能导致错误的。如PPM算法中,计算“视点”,需要将结果保存在全局数组 view_pts 里面。这时,多个线程并行修改会导致一些问题(诸如SegmentationFault)。这时,使用 #pragma omp critical 可以强制后面紧接着的那个语句块通过“加锁”保证同时只有一个线程在运行该代码块。

 

真实感图像渲染系列:渲染可视化

这是真实感图像渲染系列的第六篇文章。

动机

一幅图片渲染时间少则一分钟,多则半天,等到渲染完成再看效果未免也太佛系了。况且诸如PPM算法亮度调节什么的一般在前两轮的时候,就已经大概知道了,并不需要渲染完。因此我们希望写一个实时显示渲染效果的类。

实现

显示部分在技术上没有任何难度,就是OpenCV中的imshow而已,如果必要,每次imshow之前,使用imwrite备份图片到文件。

我们需要一边显示一遍渲染,这个就需要多线程了。由于opencv的限制,imshow和waitKey只能在主线程调用,因此我们把图像渲染放在子线程里面去。这里线程的创建我使用了pthreads库。

具体细节就看代码啦。init函数传入一个Color类数组的指针,而PaintBoard能够实时监测该指针指向内容的变化,并且显示出来。(需要用户在窗口按回车键刷新)

 

 

真实感图像渲染系列:渐进式光子映射算法(Progressive Photon Mapping)

这是真实感图像渲染系列的第五篇文章。提前声明,这篇文章讲述的算法之和PPM在思想上相似,细节上由于笔者也没有仔细研究论文,所以可能会有比较大的出入。

算法概述

光线追踪算法 中,我们提到了光线追踪实际上无法成功捕获到一部分光路。随之而来的是透明物体下方出现错误的阴影。

渐进式光子映射算法(Progressive Photon Mapping),尝试分别从摄像机和光源两边一起投放射线。两边的射线在某漫反射面上相聚。RayTracing和PPM的关系挺像深度优先搜索和双向深度优先搜索的关系。

摄像机出发有若干视线,这些视线落在漫反射面上形成视点。由于视线本身存在归属的像素,所以每一个视点也存在归属像素。假设该视点位置得到了光强,那么该光强将累加至对应像素。

与此同时,光源处随机发出若干光子,光子经过折射,反射,以及随机漫反射后,也会在若干位置碰撞漫反射界面。我们希望光子会对碰撞位置周围半径R的所有视点有一定的光强贡献。称之为“送光”。

从摄像机出发视点的计算,和RayTracing视线的计算相似,唯一的区别是RayTracing在视线碰撞漫反射面后,通过采样光源得到此处的光强,而PPM在得到的所有视点后,使用一颗KD树进行储存便于后续的“送光”。

从光源处发的光子,其起始位置、方向皆随机选取。与视线相比,唯一的不同是光线并不会终止于一个漫反射面,在漫反射面上,光子仍然会随机选取方向反射,并且,在对于该漫反射面碰撞点周围R距离内的所有视点,都进行“送光”。

对于整体稳定的考虑

PPM算法中,光子是随机发射的,由于光线追踪本身速度并不快,所以有限时间内,实际可以发射光子数是有限的。另一方面,如果我们场景是一个“点光源”和一个“地平面”,很容易发现,点光源随机发射的可以达到地面可见区域的光子更少。我们现在需要考虑的,就是如何合理利用这些足够少的光子,使得渲染出来的图片尽量均匀。

下图是R比较小导致送光不均匀的例子,观察地面上“斑驳”的光影:

光子本身需要给半径R周围的所有视点送光,一方面,提高R有助于使得送光范围更大,少数离散的光子也能渲染出均匀的图片。另一方面,将送光光强定义为半径R从中心到两边线性递减的函数,可以消除边界处光强骤减的问题。

对于局部高亮的考虑

过大的R导致局部细节无法突出,下图是R较大的情形,可以发现橙色透明玻璃球的聚焦并不算成功。

因此最优的方法应该是将渲染分成若干不同的round,R随着渲染的进行而逐渐变小。这样,前期图像趋向于亮度均匀,后期,细节被强调出来。

对于整体光强的考虑

每个光子究竟需要携带多少光强这是一个非常难以衡量的问题。由之前的讨论,我们意识到了光子的发射需要区分round,不同round对应的R不同,换句话来说,前面的round和后面的round同等重要。但是,倘若前面round和后面round光子拥有相同的光强,这会下面的问题:

由于像素本身颜色是对应所有视点接收到的光强总和,在RayTracing算法中,我们限制光子的“折射”“反射”皆存在系数,最终的光强一定是小于等于(1,1,1)的。在PhotonMapping由于是随机化算法,我们是无法保证这一点的,沿用RayTracing的光强算法,我们得到的像素光强将是一个无限接近于(0,0,0)的值,这并不是我们想要的。同时,如果将所有像素光强乘以一个值,也会导致问题,比如一块正常的蓝色(0.1,0.1,1),如果不幸被乘以了系数10,最终得到的是(1,1,10) = (1,1,1),蓝色就变成白色了。这个问题可能导致下图所示的结果,不是我们想要的。

解决思路很巧妙,核心是调整每一轮的光强,我是用的方式是将每一轮光强设置为调和级数。即最终图片光强为1 \times round_1 + \frac{1}{2} \times round_2 + \frac{1}{3} \times round_3 + \dots + \frac{1}{n} \times round_n. 实际上这是一个巧妙地想法,因为后期的round期望对整体亮度不产生改变,而对局部亮度改变,恰好调和级数之和不收敛,但是单纯取出奇数或偶数项的话,他是收敛的假装他就突然收敛了。意味着倘若一个位置在之后的每一次都有很多光子碰撞,则该位置倾向于变量,而倘若之后光子碰撞频率较低的话,该位置亮度倾向于收敛。

实际上,究竟多少光强是一件很灵活的事,例如我发现高亮部分不明显,那么我可以定义每一轮初始光子光强随轮数增强,增强的系数等同于半径衰减系数等等。即R' = R \times \alphaE' = E\times \frac{1}{\alpha}.

KD树的建立

我工程中使用的KD树是基于维度轮换分割的,写起来非常方便。这里单独讲一讲KD树编写中的一些技巧:

  • KD树建树期间,按照指定维度分割出较小的一半和较大的一半。使用std::sort函数排序固然可以,但是存在更加高效的算法:std::nth_elements(begin,begin+k,end)可以提取序列第k大,将小于第k大数的放在前面,其余放在后面。将k设为n/2,则KD树的分割部分直接通过调用nth_elements完成。
  • KD树通过对子树维护点集包围盒,实现快速判断一个询问点是否在包围盒中。实际上,很多人即使得到了包围盒以及一个点的坐标,仍然无法得到一个高效的算法判断点距离包围盒的距离。这里介绍一个比较快捷的方式:
    • 令包围盒用maxX,minX,maxY,minY,maxZ,minZ定义,询问点(X,Y,Z)
    • dx = max(0,|X-maxX|,|X-minX|),dy = max(0,|Y-maxY|,|Y-minY|),dz = max(0,|Z-maxZ|,|Z-minZ|)
    • 答案dist满足:dist = \sqrt{dx^2+dy^2+dz^2}

渲染效果图

源代码

progressive_photon_mapping.h

progressive_photon_mapping.cpp

 

真实感图像渲染系列:光线追踪算法(Ray Tracing)及基于Hash的抗锯齿方法

这是真实感图像渲染系列的第四篇文章。

算法概述

光线追踪算法可以算是图像渲染中的Hello World了,不过这个Hello World可没有那么好写。通过前两篇文章,我们已经知道了渲染算法包含的核心要素,如颜色,物体等表示。

算法本身网上已经有很多详细的教程,多说无益,本质上RayTracing就是最直接的通过视线反推光路,并忽视一切不容易追踪的光路的算法。

光线追踪本身可以处理的光路需要满足两个限制:

  1. 只经过了一次漫反射面
  2. 漫反射面是作为摄像机到光源光路中最后一个界面存在

我们可以通过一个玻璃球在地上是否会聚焦光线形成光斑来判断一个算法是不是普通的RayTracing。这里,漫反射面(地面)并没有作为光路的最后一个面(中间还插入了玻璃球的投射面)。光线追踪算法会在玻璃球下产生一个黑色的阴影。

算法步骤

下面,我将以最简练的方式概述RayTracing的算法。

对于屏幕上的每一个像素,我们可以通过摄像机(camera)找到一条视线光路,RayTracing算法目的就是得到这条光路的颜色。当然,倘若光路直接射到了光源内部(即与光源产生碰撞),那么颜色就是光源颜色。否则,倘若光路射到了无穷远处,那么我们将提前设置好的背景光颜色作为实现颜色。

多数情况,视线既不会与光源碰撞,也不会到达无穷远处。视线沿着这个光路行进,在物体表面可能由于“折射”,“反射”等而分叉,我们递归得处理折射,反射后视线的颜色。这里,递归总深度,光线强度皆可作为停止递归的信号。

最终,部分视线将到达一个漫反射平面,这时,光线亮度设置为能够直接照亮该位置的光源亮度。对于复杂光源,诸如面光源,我们需要在光源处随机取点,计算出光源中多少光可以不受遮挡到达反射面。

基于hash的边缘超采样

通过基本的光线追踪算法,我们可以得到如下的图片

可以发现,球体的边沿存在较严重的锯齿效应。通过基于hash的边缘超采样后,效果如下

锯齿处的颜色变味了两边颜色的混合,从而在肉眼看来消除了锯齿。

在RayTracing中,实现的函数调用可以看做是一棵树结构,每一个节点都会碰撞一个物体。在每一次碰撞后,我们可以维护光路hash值hash' = hash \times 19 + object_{hash}。通过如下方法迭代出来的hash值,只要中途光路的折射顺序有任何细微差别,都会导致hash值的变化。我们对于摄像机每一个像素的hash值进行比较,倘若一个像素四周hash值都和他相同,则其不在边缘上。否则,我们对该像素(x,y)进行超采样。其颜色为(x,y),(x+1/3,y+1/3),(x-1/3,y+1/3),…,(x-1/3,y-1/3)这九个像素位置对应颜色的平均值。

在实际运行中,超采样的运行时间相对会比较长。毕竟稍微复杂一点的场景,会有很多边缘点,而每个边缘点的重采样时间是原来的9倍。

源代码

ray_tracing.h

ray_tracing.cpp

 

真实感图像渲染系列:使用json初始化场景

这是真实感图像渲染系列的第三篇文章。

场景初始化的必要性

不同于普通的编程作业,图像渲染的场景结构非常复杂,比如同样是物体,一个球体需要读入中心坐标以及半径,而平面则只需要一个定位坐标以及法线方向就能够确定。如何初始化一个场景,为每一个参数赋予正确的初始值是非常重要的。

在参考过往代码发现场景的初始化无非两种方式:一种是将场景作为一个类写在源代码中;另一种是将场景写在文本文件中,对于所有需要初始化的类写一个接口来读取文件。在我的代码中,使用了第二种初始化方式。就编译效率上来说,第二种方式可以防止每次修改模型都重新编译。这在后期可以优化出很多时间。

场景初始化方式

一方面,我使用json格式储存场景,json格式的读取在C++中确实不算简单,需要安装jsoncpp库。jsoncpp库提供了一个数据类型 Json::Value ,这个数据类型支持整数下表访问(用于访问列表),以及字符串下表访问(用于访问字典),以及各种转换函数如 val.asDouble() 等。最终场景的json文件附在本文末尾。

jsoncpp的简单使用可以参考 通过jsoncpp库的CharReaderBuilder解析字符串

我使用伪·visitor设计模式初始化场景,对于每一个需要初始化的类申明一个成员函数 void accept(const Json::Value& val); 接受一个 Json::Value 用于初始化自身。

例如,对于vector来说,accept函数如下。

而一个包含vector的sphere类,可以拥有如下accept函数

可见,至少在安装了库之后,json的读取是足够简单的。

为场景添加功能

场景本身会被后续不同的渲染算法所使用,为了增加代码复用性,我们可以把渲染算法公共的一些函数放在场景中,例如求一个光线最近碰撞到的物体的函数 findCollidedObject 等。最终场景类如下

后续的光线追踪等代码,都会直接继承scene来使用scene的这些接口。

源代码

场景json文件

 

真实感图像渲染系列:颜色,向量与物体

这是真实感图像渲染系列的第二篇文章。

颜色

颜色,向量与物体是各种渲染算法的基础。

颜色定义为三个取值在0-1之间的浮点数,分别表示RGB分量。由于分量的值恰好为0-1之间的浮点数,颜色不仅可以理解为每种颜色的强度,还可以理解为每种颜色光线的穿透率。换句话来说,这样定义的颜色同时支持乘法和加法。

特别的,对于透明物体的折射,光线穿过物体,存在一定的吸收,该吸收率可以看做是距离的指数函数。即Color_{absorb} = e^{-dist \times Material_{absorb}},如在红光吸收率0.1的物体中穿过2单位距离,则射出的红光应该为原来射入红光强度的e^{-2*0.1}.

向量

向量的定义,也是三个0-1之间的浮点数,表示xyz坐标分量。向量同时具有表征方向和表征位置的功能,坐标本身在某种意义上也是坐标原点O加上某个表示方向的向量。

一个光线,我们就可以用两个向量rayOrayD分别表示他的起始位置以及方向,rayD是一个单位向量,某点P在光线上当且仅当P = rayO + k*rayD,且k>=0

 

物体

由于C++的多态机制,物体本身是一个抽象类,只需要支持:

  • 判断和某个光线的交点
  • 判断某个位置对应的贴图颜色
  • 提取出诸如漫反射率等表面材质的性质
  • 询问物体hash值(用于抗锯齿等)

碰撞

碰撞是渲染里面非常核心也很复杂的一个模块,我们使用一个专门的类记录碰撞的信息,实际上,对于碰撞,可以通过碰撞位置C,碰撞法线方向N和如何光线方向I完全表述。有了这三个量,我们可以简单的计算出这个碰撞的反射、折射方向(其中折射方向需要物体折射率)。这里讲一个小的注意点,就是反射,折射光线的起始点不应该都设置为C,反射需要保证光线的起始位置和入社光线位置同侧,而折射恰恰相反,由于精度误差,可能C实际在物体表面的某一侧而我们用C作为新光线的起点是不妥的。解决方案很简单,对于反射,我们使用C_{surface} = C+N*eps,折射则使用C_{backface}=C-N*eps作为光线的其实位置。

 

 

真实感图像渲染系列:工程概述

这是真实感图像渲染系列的第一篇文章。

这是图形学课程的一次大作业,基础要求是实现光线追踪算法(Ray Tracing),包括反射,折射以及漫反射,以及参数曲面求交(Bazier Curves),在此基础上,我增加了渐进式光子映射算法(Progressive Photon Mapping)以及景深效果(Depth of Focus). 不过由于本人毫无美术细胞,所以场景设计一团糟,只是粗略体现出了每一个算法的现象而已。结果如图

包含两个含贴图的球体,一个看起来像水果盘的贝塞尔曲线,一个透视的玻璃球,以及一个距离摄像头太近的,对焦失败的球。

工程源代码可以在 https://github.com/mhy12345/RayTracing-ProgressivePhotonMaping 找到。

其中,该工程依赖了glog(实际上master版本不依赖,但是需要他进行一些必要的调试信息输出),eigen(用于矩阵运算),opencv(用户实时显示渲染结果以及bmp格式保存)以及jsoncpp(用于解析json文件)。后面三个库都需要提前安装在电脑中。同时,工程需要你的编译器支持 -fopenmp 选项,例如,在我的笔记本上,编译器 clang-omp 支持该选项,编译命令即为

根目录下的 ./serial 和 ./main 分别是渲染程序的串行版本和并行版本。