写在开头:
虽然作者说一个周末就能实现一个基于近似的、球形物体的ray tracer,虽然说不难,我最终还是花了4天的时间来做这个,不过第一次手动实现了一个渲染器,自我感觉还是不错的🤣。主要是了解的内容如下:
- 了解了ppm的图片格式
- 学习了RGB相关内容
- 复习了c++中的const函数定义、虚函数与纯虚函数、运算符重载
- 复习了光线的定义
- 理解了viewport与真实图片的对应关系
- 理解了光线的发出过程(ray是怎么打入场景中去的)
- 学会了球形相关的内容(球的基本定义,如何相交,怎么在场景中显示球)
- 学习了hittable物体的一系列属性(法线,内部外部,内涵碰撞检测)
- 抗锯齿(主要是SSAA与MSAA的复习)
- 材质的定义(lambertian(完美的漫反射),metal,dielectrics)
- 光线的反射、折射(并不是物理意义的,大多都是近似的)
- 漫反射中采样问题(反射到发出点了)
- metal物体反射时的fuzzy程度,以及reflect的函数
- 折射的snell定则,以及snell定则无法解决的全反射(Total Internal Reflection)问题
- 菲涅尔项,以及为了简便而采取的schilick近似
- 视口变化相关的复习
- 散焦模糊(深景)的初步学习
- 以及很多很多的英语单词
下面就再说一说这些都是啥玩意吧!!
大概长如下形式
P3
3 2
255
# The part above is the header
# "P3" means this is a RGB color image in ASCII
# "3 2" is the width and height of the image in pixels
# "255" is the maximum value for each color
# The part below is image data: RGB triplets
255 0 0 # red
0 255 0 # green
0 0 255 # blue
255 255 0 # yellow
255 255 255 # white
0 0 0 # black
rgb通道在0-255是由于我们一般使用的通道是8位单通道,即 $$ 2^8=256 $$ 0-255正好256中颜色
映射到0-1是因为节省存储的空间,之后存储的时候在转化成舍弃精度的值即可
ir = static_cast<int>(255.999 * r);
在使用8位单通道的RGB时,在做颜色的映射时,要注意保存的值不能大于255,否则会出现过多的噪点,而且结果将不正确,这种情况一般使用一个clamp限制
inline double clamp(double x, double min, double max)
{
if (x < min)
return min;
if (x > max)
return max;
return x;
}
对于一个const的对象,调用它的函数或它使用的函数需要被定义为const(具体我也还没翻书啊,初步实践是这样)
虚函数与纯虚函数:相当于java里面的抽象和接口吧,定义的方法是纯虚函数后面加一个=0
运算符重载:在类内部重载时,会自动传入一个this指针,代表当前元素。
友元函数:如果外部函数需要使用内部private的属性,则可以定义成友元
类成员函数定义:
如果在类的外部定义内部声明的函数,一定要加class::funcName
定义static函数,仅需要在声明的时候声明static即可
随机数的定义:
定义一个连续均匀分布类模板uniform_real_distribution
or定义一个离散均匀分布类模板uniform_int_distribution
定义一个伪随机数生成器mt19937
ray的定义: $$ \vec{O}+t\vec{d} $$
光线打出时,会计算当前的点,以及出射位置,分为两种情况:
-
就是在观测点打出
光线打出时,用视口上的一个点减去观测点坐标,得到一个方向向量即可
-
反射或折射打出
光线打出时要注意光线的能量变化
关于光线反射的次数:
- 可以自己定义最大的反射次数,衰减率就是当前物体的albedo
- 通过俄罗斯轮盘赌,衰减率是albedo/p(正好期望等于albedo)
viewport本质上是不存在的,可以被看做是一个虚拟的(真实)世界,存在的意义,在我看来有以下几点:
- 用于光线的发射
- 计算着色时需要
- 所有物体基本单位与视口保持一致,相当于一个世界的基准
viewport与最终的图像关系:
- 图片的像素与视口无关,只与图片本身的定义有关
- 图片的像素多少意味着将会把视口分为多少份(分的份数越多当然就越细腻啦)
- 视口的大小可以说就是视角的大小(视口长宽固定时,提高距离就是变相把视口缩小),和focal-length、fov有关(当然focal-length只是相对的有关,即如果视口大小不变,而focal-length变大,视野自然就小了),将会影响最终图片的视野
- 视口的宽高比要与图像的宽高比一致(这是当然的罗,不然怎么对应划分)
对viewport的一些理解:
- 视口的位置与观测点有关
- 视口的高一般设为2个单位长度*fov角度的tan值,fov小观测空间自然就小
- focal-length一般都会设为1个单位
- 视口的位置基本上可以代表从哪个方位渲染画面
- 观测点,一定对应视口的中心
关于相交:
光线与球的相交比较简明,所以大家都喜欢用球来做光线追踪的物体
由于计算需要,我们将球的公式转化为以向量为term的formulas $$ (\vec{P}-\vec{C})·(\vec{P}-\vec{C})=(x-C_x)^2+(y-C_y)^2+(z-C_z)^2=>\(\vec{P}-\vec{C})·(\vec{P}-\vec{C})=r^2=>\((\vec{O}+t\vec{d})-\vec{C})·((\vec{O}+t\vec{d})-\vec{C}) = r^2=>\t^2\vec{d}·\vec{d}+2t\vec{d}·(\vec{O}-\vec{C})+(\vec{O}-\vec{C})·(\vec{O}-\vec{C})-r^2=0 $$ 根据上述公式最后接出来t即可
判断有无hit直接使用判别式判定就行了
在场景中显示球:
需要与所有的球都求交,找出最近的hit点,读出该点的材质,并继续做光线追踪,最终求出所有交点的加权平均即可
如果只是需要显示单独的一个球,使用最近的hit点给该像素shade即可
hittable当然就是可以与光发生一定作用的意思罗
当然,就此原因,我们就会把碰撞的一系列函数定义在这种物体的类里面(当然如果是一个hittable_list需要的碰撞函数,就是找到最近的那个点了)
既然是光的碰撞,那自然避不开法线,所以需要计算出来法线嘞
在这里,需要判断法线的方向,如果在内部仍然使用向外的法线自然是错的
与物体相交时,需要注意一个自相交问题,如果t_min选择为0.0,计算得出的t虽然也是0,但是实际在计算机中可能是0.00000001,那光线就会在原地打转了
即需要处理阴影失真的问题
导致这个问题的原因有两种:
- 精度不够,导致自相交等问题
- 在shadowmap里面,深度map的走样问题(深度的采样频率不够)
解决方案:
精度问题:忽略掉t处于0附近的值
深度map走样问题:一般加入一个bias即可
aliasing走样
相当于以一张更大的分辨率来渲染场景,提高采样率,需要采样所有采样点的颜色(这个导致需要先把每个片段的颜色先shader才能算出来,计算量巨大),最后平均得到像素的颜色
也是相当于在一张更大的分辨率来渲染场景,提高了采样率,不过只需要覆盖测试以及深度测试,及当前像素点的颜色,以及提高分辨率后的覆盖率即可计算出当前像素的颜色
lambertian(完美的漫反射):图不好放
从hit点延申到法线单位圆,使用lambertian反射方法,取一个长度为单位长度的向量打到圆上,从而确定最终的出射方向,这种方法的分布为cos(φ)
注意:
取得的单位向量可能会打回到原来的点,所以需要对这种情况进行处理
bool vec3::near_zero() const { const auto s = 1e-8; return (fabs(e[0]) < s) && (fabs(e[1]) < s) && (fabs(e[2]) < s); }
metal材质(反射):
关于光线的反射
vec3 reflect(const vec3 &v, const vec3 &n) { return v - 2 * dot(v, n) * n; }再给金属球做反射时,为了让球看起来并不是完美的光滑,需要让他fuzzy一些
scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere());
dielectrics材质(折射):以glass为代表
对于一次光线的交会,只进行折射(refraction)或只进行反射(reflection)
典型的折射率:air:1.0 glass:1.3-1.7 diamond:2.4
可以将折射的光线看成平行于n'的向量于垂直于n'向量的和(下列公式都是向量,我懒得打了) $$ R'=R'{\bot} + R'{\parallel}\ because:\sin{θ'}=\frac{\eta}{\eta'}\sin{θ}\ so:R'{\bot}=\frac{\eta}{\eta'}(R+\cos{\theta}\vec{n})=>R'{\bot}=\frac{\eta}{\eta'}(R+(-\vec{R}·\vec{n})\vec{n})\ R'{\parallel}=-\sqrt{1-R'^2{\bot}}·\vec{n} $$
vec3 refract(const vec3 &uv, const vec3 &n, double etai_over_etat) { auto cos_theta = fmin(dot(uv, n), 1.0); // i guess this is because dot result maybe over 1.0, such as 1.0000001 vec3 r_out_perp = etai_over_etat * (uv + cos_theta * n); vec3 r_out_parallel = -sqrt(fabs(1 - r_out_perp.length_squared())) * n; return r_out_perp + r_out_parallel; }snell解决不了全内反射(total internal reflection)
由于sinθ要小于1,所以snell low只能解决一部分折射,当从高折射率截至转入低折射率介质时,就完蛋了
即当右边大于1时,需要控制进行反射 $$ \sin{θ'}=\frac{\eta}{\eta'}\sin{θ} $$
菲涅耳方程(Fresnel equation)描述了光线经过两个介质的界面时,反射和透射的光强比重。
分别对应入射光的 s 偏振(senkrecht polarized)和 p 偏振(parallel polarized)所造成的反射比。图形学中通常考虑光是无偏振的(unpolarized),也就是两种偏振是等量的,所以可以取其平均值: $$ R_s=(\frac{\eta_1\cos{\theta_i}-\eta_2\cos{\theta_t}}{\eta_1\cos{\theta_i}+\eta_2\cos{\theta_t}})^2\R_p=(\frac{\eta_1\cos{\theta_t}-\eta_2\cos{\theta_i}}{\eta_1\cos{\theta_t}+\eta_2\cos{\theta_i}})^2\R=\frac{R_s+R_p}{2} $$ 但是,这种方法太复杂了,所以这里使用的是另一种近似方法
Schilick近似
计算出的结果为反射比,即有多少光线是被反射的占比,剩下T=1-R $$ R(\theta_i) ≈ R(0) + (1 - R(0))(1-\cos{\theta_i})^5 $$
散焦模糊,用另一个摄影的术语来说就是——深景
仔细体会看东西的时候,只有我们关注的点会很清晰,而我们不关注的点会变得模糊
这其实和人眼构造有关系——人眼相当于一个棱镜
距离注视的平面越远,光圈的大小越大,自然越模糊
所以在渲染的时候我们也应该注意这种模糊,即加入了一个focus-distance(成像距离,注意与focal-length区分)与apertrue(光圈大小),这时,视口又有了一个新的含义,就是聚焦的位置平面,离这个平面越远当然这个点就越模糊了.
注意,为了保证视口的相对大小不变,需要做如下变化
horizontal = focus_dist * viewport_width * u;
vertical = focus_dist * viewport_height * v;
这里暂时还只是了解,并没有进行深入学习
就写到这吧,happy-end,结果图如下: