我们现在已经拥有两种材质——磨砂和金属,分别代表着两种光线反射方式——漫反射和镜面反射。但别忘了还有一种材质,它对待光线的方式远比前两者要复杂,这就是以玻璃为代表的透明材质。
玻璃材质会镜面反射一部分光线,但是也有一部分光线会射入表面,产生折射。我们需要通过再反射和折射之间随机选择来处理这个问题,每一次采样都会随机选择折射和反射的其中一种。
斯涅尔定律
我们先来搞定折射,折射光线会遵循以下的法则进行折射,这个定律叫折射定律,或者是斯涅尔定律(Snell’s law):
公式中,θ 和 θ′ 是入射光线和折射光线和法线的夹角,η 和 η′ 则是折射率,它表示入射光线所在的介质和折射光线所在的介质的一种性质,是光在真空中的传播速度与光在该介质中的传播速度之比(空气的折射率为1.00029,近似为1,玻璃是1.3-1.7,钻石则是2.4),如下图所示:
折射方向向量
记入射向量为,折射后的向量为
,假设它们都是单位向量。
我们现在的目标就是通过已知的入射向量和法线向量求出这个。
直接去求出这个向量是非常困难的,我们对其做一个分解,把目标向量分解到法线方向和与法线垂直方向这两个互相垂直的方向上,有
先求,它是
中与法线方向垂直的分量。我们清楚的知道它的模是sinθ′ ,但我们得找到一个和法线方向垂直的向量
(上图中指向正右方)作为它的向量表示,即最终的结果就是sinθ′ * unitvector(
)。
我们可以从入射角那里找到一个这样的向量 ,它是一个和同向,并且模为sinθ的向量,我们要的向量的模是sinθ′ ,不过没关系,再利用斯涅尔定律转换一下就可以了,解析式如下:
到这一步还不够,我们已知的东西是入射向量和法线向量,θ不应该出现在最终的解析式里。因为和法线向量
都是单位向量,我们可以用一个点乘去消灭这个碍事的cosθ,即:
知道之后,
可以通过一个简单的公式推算得到。首先,
是和
同向的,它的长度是cosθ′ ,通过三角函数中最基础的公式:
我们就可以得到如下最终解析式:
一切都准备妥当了,我们在vec3.h中创建一个类外函数,来描述折射这一过程:
//折射函数,喂我吃单位化的入射和法线向量以及两种介质折射率的比值,吐给你折射方向向量。
vec3 refract(const vec3& uv/*入射向量*/, const vec3& n/*法线*/, double etai_over_etat/*η和η′的比值*/) {
auto cos_theta = dot(-uv, n);
//垂直于法线的分量
vec3 r_out_perp = etai_over_etat * (uv + cos_theta*n);
//平行于法线的分量
vec3 r_out_parallel = -sqrt(fabs(1.0 - r_out_perp.length_squared())) * n;
return r_out_perp + r_out_parallel;
}
总是折射的玻璃
我们有了折射函数,但是,想一步到位写出一个能反射和折射的材质依旧非常的困难,我们还有一些知识点没有介绍。我们一步一步来,先写一个总是折射的透明材质,这对我们来说没什么难度,看代码:
class dielectric : public material {
public:
dielectric(double index_of_refraction) : ir(index_of_refraction) {}
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
//理论上的透明玻璃不会引起能量的损耗。
attenuation = color(1.0, 1.0, 1.0);
//记得我们曾经记录过的看碰撞面是否是物体外面的bool变量么。
//我们使用这个变量来控制物体射入和射出此材质时候η和η′分子分母调换。
double refraction_ratio = rec.front_face ? (1.0/ir) : ir;
//单位化入射方向。
vec3 unit_direction = unit_vector(r_in.direction());
//调用折射函数。
vec3 refracted = refract(unit_direction, rec.normal, refraction_ratio);
//制造光线。
scattered = ray(rec.p, refracted);
return true;
}
public:
double ir; // 这种透明材质的折射率。
};
在main中使用这种透明材质:
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_left = make_shared <metal> (color(0.8, 0.8, 0.8),0.3);
//玻璃的折射率在1.3-1.7之间。
auto material_right = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(point3(0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3(-0.5, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), 0.5, material_right));
全内反射
上面的结果是不正确的,如果我们认真观察斯涅尔定律你会发现,这个公式是有失效的时候的。
光线从较高折射率的介质进入到较低折射率的介质时,如果入射角大于某一临界角θc(光线远离法线)时,折射光线将会消失,所有的入射光线将被反射而不进入低折射率的介质,这种现象叫做全内反射,或称全反射。
就拿玻璃举例子,对于光线从玻璃中射入空气的情况,带入,有:
对于如上的公式,当sinθ大于某特定角度时,sinθ′会大于1,但是sin函数是永远不会大于1的,也就是说,对于这样的角度来说,上述公式会失效。
也就是说,我们的代码得甄别这样的情况,在这种情况下,我们得让光线反射而不是折射。也就是说会有如下的结构:
double cos_theta = dot(-unit_direction, rec.normal);
double sin_theta = sqrt(1.0 - cos_theta*cos_theta);
if (refraction_ratio * sin_theta > 1.0) {
// 折射
...
} else {
// 反射
...
}
所以,“只要能折射我就算拼了老命也要折射绝对不反射反射就是懦夫”的透明材质如下:
class dielectric : public material {
public:
dielectric(double index_of_refraction) : ir(index_of_refraction) {}
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
attenuation = color(1.0, 1.0, 1.0);
double refraction_ratio = rec.front_face ? (1.0/ir) : ir;
vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = sqrt(1.0 - cos_theta*cos_theta);
//是否达到全反射的临界值。
bool cannot_refract = refraction_ratio * sin_theta > 1.0;
vec3 direction;
if (cannot_refract)
//全反射
direction = reflect(unit_direction, rec.normal);
else
//折射
direction = refract(unit_direction, rec.normal, refraction_ratio);
scattered = ray(rec.p, direction);
return true;
}
public:
double ir;
};
施利克近似
铺垫结束,我们现在可以来挑战一下正确的透明材质了!
一个正确的透明材质会反射部分光线的,入射光线和面的夹角越大,它就越倾向于反射光线。这也就是为什么我们越从掠射角去观察窗户,我们越难看清窗外的景色,而越容易从其上看到自己的脸的原因。
入射光线和法线的夹角和反射率之间的关系是有一个巨大且丑陋的等式所决定的,几乎所有的人都会使用克里斯托弗·施利克(Christophe Schlick)的简单且令人惊讶的精确多项式去近似这个等式。
class dielectric : public material {
public:
dielectric(double index_of_refraction) : ir(index_of_refraction) {}
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
attenuation = color(1.0, 1.0, 1.0);
double refraction_ratio = rec.front_face ? (1.0/ir) : ir;
vec3 unit_direction = unit_vector(r_in.direction());
//为了防止因为失误传入了非单位向量导致cosθ大于1,进而导致下面根号内有负值使程序崩溃,我们加一层保险。
double cos_theta = fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = sqrt(1.0 - cos_theta*cos_theta);
bool cannot_refract = refraction_ratio * sin_theta > 1.0;
vec3 direction;
//如果全反射了,或者反射概率通过了随机数测试。
if (cannot_refract || reflectance(cos_theta, refraction_ratio) > random_double())
//反射
direction = reflect(unit_direction, rec.normal);
else
//折射
direction = refract(unit_direction, rec.normal, refraction_ratio);
scattered = ray(rec.p, direction);
return true;
}
public:
double ir;
private:
static double reflectance(double cosine, double ref_idx) {
// 使用施利克近似来计算反射概率。
auto r0 = (1-ref_idx) / (1+ref_idx);
r0 = r0*r0;
return r0 + (1-r0)*pow((1 - cosine),5);
}
};
我们来看看施利克近似到底为何物,它到底是如何影响光线选择反射或折射的,先写下它的具体公式:
最终概率落于0到1之间。
公式中只有两个变量:1.两种物质的折射率之比,即 2.夹角的余弦值cosθ。
它们是怎么影响结果的呢?首先,显而易见的是,θ越小,cosθ越大,反射的概率就越小。也就是说,反射率随着入射光线和法线的夹角增大而增大,这是符合规律的。
其次, 一个容易发现的规律——我们用去代替公式中的
,最终结果不会有变化,也就是说,对于特定的两种介质,无论哪个是入射光线所在的介质,反射率都遵循一样的规律。
还有一个规律,那就是如果越靠近1,最终解析式中的第二项就越大。即,对于折射率越接近的两种介质,反射概率受θ的影响就越剧烈,反之,θ对于最终反射概率的影响就越小。
最后一个规律,对于折射率差距巨大的两种介质,接近1,第二项接近0,光线极度倾向于反射,罕有折射。
在我们的代码中,reflectance(cos_theta, refraction_ratio) 越大,则它越容易大于random_double(),即越容易反射,这是一个经典的利用已知概率和随机数配合进行随机采样的例子。你会得到:
你得到上面的图之后,你可能会说:“这是什么丑东西,这是玻璃吗?”
我知道这确实不太像,这是有多种原因决定的:
1.咱们的场景过于简单,在现实世界中,你不可能处于这样的场景中,也就不存在对于这种环境下的玻璃材质的视觉直觉。
2.广角相机(嘿!我们埋这个伏笔好久了!)把远离视角中心的一切物体都拉变形了,这非常令人讨厌,我们的眼睛可不是这样!
这两个问题我们都会解决,我们会在之后重塑相机,再给出一个更复杂的场景来结束我们的阶段性学习——没错,我们的光追器的第一阶段即将告一段落啦!
空心玻璃
我们来实现一个空心的玻璃,别害怕,我们不需要新的材质,空心玻璃的实现远比你想象的要简单。我们只需在玻璃球的同样位置放一个稍微小一点的,半径为负值的球即可。半径为负值代表着球的外表面的法线指向球心。通过我们已经实现的功能,耍一个简单的小聪明就实现了一个玻璃球内“空气泡泡”。
world.add(make_shared<sphere>(point3(0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3(-0.5, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), 0.5, material_right));
// 负半径的玻璃球,作为空心球的内胆。
world.add(make_shared<sphere>(point3(0.5, 0.0, -1.0), -0.4, material_right));
你会得到:<br /><br />很漂亮不是么。但是它看起来似乎是厚薄不均的样子,我依然把这个问题甩锅给我们的相机!它太垃圾了!<br />我们已经有三种材质了,是时候暂时结束对材质类的折腾了!下一章开始我们终于要开始摆弄我们的相机。 <br />自由视角相机是任何一个渲染器必备的东西,它珊珊来迟,不过我相信它不会让你失望。相机也是这个教学性质的系列文章的阶段性boss,尽情期待。
课后实践
1.咱们的空心玻璃球得到的太过简单梦幻,请手动追踪代码,仔细思考负半径玻璃球是如何运作的。
2.你应该注意到空心玻璃球左侧有一个小黑点,这是为什么呢?
参考文献
https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第10节。
https://en.wikipedia.org/wiki/Refraction
维基百科中对于折射相关的光学知识。