源教程链接Peter Shirley - Ray Tracing in One Weekend。
由于不能一天之内一鼓作气全部啃下来,思维断点衔接后总需要温习一遍,于是边coding边写这份笔记。这也导致笔记里都是零碎的东西,与原作者的思路完全没法比,主要给自己复习用。如果里面的一两句话正好点开了读者的某个问题,那自然是再好不过了。
第一节 输出图像
主要介绍ppm这种图像格式,直观易理解。
文件头记录数据格式(比如P3对应ASCII)、图像宽度和高度(即列数和行数,注意列数在前)、颜色最大值255。然后每三个数为一组(对应RGB),从左到右,从上到下,依次填充每个像素点。
Note:
- ppm格式mac系统可以直接预览,不需要ToyViewer;
- 输出的时候将
std::cout
换成指定文件。
#include <fstream>
...
int main(int argc, const char * argv[])
{
std::ofstream file;
file.open("hello.ppm");
...
file.close();
return 0;
}
第二节 Vec3类
由于色彩、位置和方向等的数据都可以用一个三维向量来表示,于是有必要定义一个最基础的类vec3
,用来表达相对底层的数据和运算。
这里涉及到操作符重载和内联等C++底层概念。从代码来看比较直观,主要是将一些基本的运算在向量语境下重新定义一遍,让后面的章节在实现具体算法时更加注重算法本身,而非基础的运算规则。
第三节 光线、相机和背景
将光线定义为:
\[p(t)=A+t*B\]其中$p$为光线上任意一点,$A$为光线起始点,$B$为方向,$t$为参数,随着$t$的变化形成整条光线。当$t$的符号确定后,模型变为一条射线。
光线追踪的核心在于:
send rays through pixels and compute what color is seen in the direction of those rays.
直观一点可以这样理解:
相机本身是一个光线接收器,但我们可以求解这个逆过程。把相机位置视为光源,向环境中各个方向发射无数条光线,计算这些光线与环境中物体的交点,并计算对应的色彩信息。固定一个视角范围后,得到对应光线追踪图像。
遵循右手系,作者定义右侧为x轴正方向,上侧为y轴正方向,垂直于屏幕向内为z轴负方向。
把相机放在坐标系原点,从相机向图像坐标范围发射若干光线来遍历像素点,根据每条光线y方向的分量变化来改变像素点颜色,从而构造一个颜色从$(0.5,0.7,1.0)$(天蓝色)渐变至$(1.0,1.0,1.0)$(白色)的背景。
(注意这里的白色并不严格,通过仔细观察$y$值的变化应该能够发现。)
第四节 第一个球
使用球体作为场景中物体的主要原因是:球面方程较为简单,因此容易计算一条光线与球面是否相交。
同上一节构造背景的思路类似,我们仍然从相机发出若干条光线,这些光线如果与球体表面相交,则将对应像素点渲染为红色,代表球体所占的空间,;若未相交,则构成背景色。
第五节 表面法线和多个物体
上一节只计算了光线是否与相交,一旦相交则用同样的颜色表达,这一点显然和真实模型有差别。因此引入表面法线的计算,即:不仅计算光线是否与球体相交,还需计算这个点的具体坐标值(减去圆心坐标即为法线方向)。由法线方向的x,y,z分量,映射到色彩空间R,G,B分量上,体现出球体表面的不同点之间的差异。
当场景中存在多个物体(球)时,光线先与哪一个相交,即将像素点填充为该点的颜色。为了让程序逻辑更清晰,构造hitable类以及派生自hitable类的sphere类和hitablelist类。目前各个类的层级关系如下:
注意作者实现的代码约去了2和4,比如$\Delta$判别式写作b*b-a*c
,此处变量的定义发生了改变,但计算结果仍然一致。
渲染结果中绿色平面其实是一个大球的顶部。
第六节 消除锯齿
对每个像素点,同时计算多条光线的颜色信息,取平均值作为渲染结果。
同时这一节补充了camera类,自定义方法get_ray()
的作用是根据u,v
值获取对应光线。
第七节 漫反射材质
漫反射材质的物体自身不发光,通过接收环境光并与自身颜色混合,呈现出最终颜色。光线射向其表面时,以任意角度进行反射,同时以一定比例吸收(深色物体吸收的比例较高)。呈现效果类似磨砂质感。
设一条光线射向一个球体$H$表面,得到hitpoint $P$。为了体现「任意角度反射」这一特性,需要由点$P$生成一条新的光线。设新光线为$PS$,其中$S$为单位球体表面任意一点,该单位球体与球体$H$相切于$P$点,球心为$C$,由向量加法:
\[PS=PC+CS\]这样就不难理解代码实现了。
Note:仔细理解vec3 color(const ray& r, hitable *world)
函数的递归调用。实现过程完美诠释了呈现颜色=环境光+自身颜色。这里环境光就是我们设定的背景渐变;物体自身颜色虽然没有显式给出,但指明反射光线强度的这行语句return 0.5*color(ray(rec.p, target-rec.p), world);
就是物体的颜色(物体的颜色取决于它对光的反射)。尝试改变0.5这个数值,可以看到不同灰度的颜色效果,而下一节通过指定各个反射光线的分量则能更好地说明这一点。
第八节 金属材质
本节我们介绍的第二种材质:金属。
当场景中存在多种材质时,有必要对材质单独用一个类进行描述。一个材质的核心特性包括:
- 产生散射光线的方式;scattered
- (如果发生散射)散射的光线与原始光线相比有多大程度减弱。attenuated
更确切地说,第一项特性(scattered)能区分不同种类材质之间的差异,比如表面光滑还是粗糙;而第二项特性(attenuated)体现同一种材质的不同形式,比如颜色。
对于金属材质,核心部分是反射光线的计算,其他部分与漫反射类似。
第九节 透明材质
自然界中的透明材质包括水、玻璃和钻石等。光线射向这类材质表面时,不仅会发生反射,同时会产生折射。本节的关键就在于处理折射光线。
折射光线的计算遵从折射定律:$nsin(\theta) = n’sin(\theta’)$ ,其中$\theta$和$\theta’$分别是入射角和折射角,$n$是介质对应的折射系数(比如空气为1,玻璃为1.3-1.7,钻石为2.4)。
实际实现中,需要特别注意的一个点是光线从物体中射向空气时发生的全反射问题。实现细节中,先将光线矢量归一化,根据点积计算出光线与法线的夹角$cos$值,进而导出$sin$值。
然后两个小point:
-
反射率及其模拟
一条光线射向透明材质的物体时,其部分能量射入物体内部,部分能量被反射。而反射的这个「部分」即为反射率。
我们没有对光线加入「能量」的概念。取而代之,将射向同一个像素点的不同光线按照一定概率分别进行反射或折射(回顾「消除锯齿」部分的特性)。
「光线射向像素点」这个说法在这里有些不那么严谨,但前面已经解释过光线追踪是个“逆过程”,此处就先这么表达了。
- 翻转法线
将球体半径设为负数值,就形成了翻转法线后的球体。借助这个特性,我们可以构造出带有中空效果的气泡。
第十节和第十一节讲述相机相关概念。由于相机模型之前已在其他教程中有更详细的讨论,此处不作特别记录。(其实是因为这部分代码还没完全挖透。。可以回顾【Perception-1】图像构成的几何原理)
最后放上整个教程的实验结果:
后记
一些题外话和小心得。
首先感谢Danielhu同学推荐的CG教程,并再次感谢教程作者Peter Shirley。
刚开学上CG课的时候就想填这个坑,硬是拖了3个月,到期末做完图形学课程final才想起来。正好上前几天天气不好,干脆试着填了。从周六上午开始,到周二晚上结束,除去这两天的上课时间+周末活动时间+笔记整理时间,大概确实可以用一个完整周末理清这份教程中的所有细节。当然教程作者说“if you take longer, don’t worry about it.” 但愿目前这个速度还算凑合。
不过这个过程并不算特别顺利。
一开始被C++的底层问题困扰了一阵子,比如运算符重载、继承和派生、inline
关键字的用法等等,险些剑走偏锋去补C++。好在111qqz同学及时点拨一番。于是重心又返回到这份教程本身,目前只能先做到理解了核心代码和算法,纯手写项目还差的很远。以后如果真的要上手C++大项目,底层还是不能太半吊子。
以及,现在越来越觉得,必要情况下需要有“不求甚解”的精神,这在读paper时也很重要。能把每个细节全都挖清楚当然很好,但更多时候总会碰上一些知识盲区。先记下来这块知识大概属于什么范围,如果欠缺会对理解主干内容有多大影响。影响不大的情况下可以先搁置,如果多次遇到再进行填坑。
最后,记住这些一切只不过是个开始。