11. 介电质
像水、玻璃和钻石这样的透明材料都是介电质。当一条光线打到它们时,光线会分成一条反射光线和一条折射(透射)光线。我们会通过在反射和折射之间随机选择来处理这件事,也就是每次相互作用只生成一条散射光线。
先快速复习一下术语:反射光线会打到表面,然后“弹开”到新的方向。
折射光线则会在从材料周围环境进入材料本身时发生弯折,例如进入玻璃或水中。这就是为什么铅笔部分插入水中时,看起来会像是弯掉了一样。
折射光线弯折的程度由材料的折射率决定。一般来说,折射率是一个单一数值,用来描述光从真空进入某种材料时会弯折多少。玻璃的折射率大约是 1.5 到 1.7,钻石大约是 2.4,而空气的折射率很小,约为 1.000293。
当一个透明材料被放在另一个不同的透明材料中时,可以用相对折射率来描述折射:也就是物体材料的折射率除以周围材料的折射率。例如,如果你想渲染水中的玻璃球,那么玻璃球的有效折射率会是 1.125。这是由玻璃的折射率 1.5 除以水的折射率 1.333 得到的。
你可以通过快速的网络搜索,找到大多数常见材料的折射率。
11.1 折射
最难调试的部分是折射光线。我通常会先让所有光线都发生折射,只要存在折射光线就这么做。对于这个项目,我试着在场景中放入两个玻璃球,然后得到了下面这样的结果(我还没告诉你这样做是对还是错,不过很快就会讲到!):
这样对吗?玻璃球在现实生活中看起来确实有点怪。但不,这并不对。整个世界应该上下颠倒,而且不应该有那些奇怪的黑色东西。我只是把穿过图像正中央的光线打印出来看了一下,结果明显是错的。这招通常就能抓出问题。
11.2 斯涅尔定律
折射可以用斯涅尔定律来描述:
其中, 和 是相对于法线的角度,而 和 读作 “eta” 和 “eta prime”,表示折射率。几何关系如下:
为了确定折射光线的方向,我们需要解出 :
在表面的折射侧,有一条折射光线 和一条法线 ,它们之间存在一个角度 。我们可以把 分解成垂直于 和平行于 的两个部分:
如果我们解出 和 ,就会得到:
如果你愿意,可以自己继续证明这个式子,但我们会先把它当成事实继续往下走。本书后面的内容不要求你理解这个证明。
我们知道右边每一项的值,除了 之外。众所周知,两个向量的点积可以用它们之间夹角的余弦来解释:
如果我们限制 和 都是单位向量:
现在我们可以用已知量重写 :
当我们把这些部分重新组合起来,就可以写一个函数来计算 :
...
inline vec3 reflect(const vec3& v, const vec3& n) {
return v - 2*dot(v,n)*n;
}
inline vec3 refract(const vec3& uv, const vec3& n, double etai_over_etat) {
auto cos_theta = std::fmin(dot(-uv, n), 1.0);
vec3 r_out_perp = etai_over_etat * (uv + cos_theta*n);
vec3 r_out_parallel = -std::sqrt(std::fabs(1.0 - r_out_perp.length_squared())) * n;
return r_out_perp + r_out_parallel;
}
而总是折射的介电材质如下:
...
class metal : public material {
...
};
class dielectric : public material {
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}
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 ri = rec.front_face ? (1.0/refraction_index) : refraction_index;
vec3 unit_direction = unit_vector(r_in.direction());
vec3 refracted = refract(unit_direction, rec.normal, ri);
scattered = ray(rec.p, refracted);
return true;
}
private:
// 真空或空气中的折射率,或者材料折射率与包围介质折射率的比值
double refraction_index;
};
现在,我们将更新场景来展示折射效果:把左边的球改成玻璃材质,玻璃的折射率大约是 1.5。
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.50);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 1.0);
其给出以下结果:
11.3 全内反射
折射中有一个比较麻烦的实际问题:对于某些光线角度,用斯涅尔定律是得不到解的。当一条光线以足够贴近表面的掠射角进入折射率较低的介质时,它可能会以大于 的角度发生折射。如果我们回到斯涅尔定律以及 的推导:
如果光线在玻璃内部,而外部是空气,也就是 且 :
的值不可能大于 1。所以,如果
方程两边的相等关系就被破坏了,也就不存在解。如果不存在解,玻璃就不能发生折射,因此必须反射这条光线:
if (ri * sin_theta > 1.0) {
// 必须折射
...
} else {
// 可以折射
...
}
这里所有光线都会被反射,而且因为在实际中这种情况通常发生在实体物体内部,所以它被称为全内反射 (total internal relfection)。这就是为什么有时候,当你潜在水下时,水和空气的交界面会像一面完美的镜子:如果你在水下向上看,可以看到水面上的东西;但当你靠近水面并侧向看时,水面看起来就像镜子一样。
我们可以用三角恒等式求出 sin_theta:
以及:
double cos_theta = std::fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);
if (ri * sin_theta > 1.0) {
// 必须折射
...
} else {
// 可以折射
...
}
且这个总是会发生折射的介电材质(在可能折射的情况下)为:
class dielectric : public material {
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}
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 ri = rec.front_face ? (1.0/refraction_index) : refraction_index;
vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);
bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;
if (cannot_refract)
direction = reflect(unit_direction, rec.normal);
else
direction = refract(unit_direction, rec.normal, ri);
scattered = ray(rec.p, direction);
return true;
}
private:
// 真空或空气中的折射率,或者材料折射率与包围介质折射率的比值
double refraction_index;
};
衰减始终为 1 —— 玻璃表面不会吸收任何东西。
如果我们用新的 dielectric::scatter() 函数渲染之前的场景,会发现……没有变化。嗯?
其实原因是:对于一个折射率大于空气的球体材料来说,不存在任何入射角会产生全内反射——无论是在光线进入球体的位置,还是在光线离开球体的位置都不会。这是由球体的几何形状导致的,因为一条掠射入射的光线总会先被弯折到一个更小的角度,然后在离开时又被弯回原本的角度。
那么我们要怎样展示全内反射呢?如果球体的折射率小于它所在介质的折射率,那么我们就可以用较浅的掠射角打到它,从而得到全外反射。这样应该足够观察到这个效果了。
我们会建模一个充满水的世界,水的折射率大约是 1.33,然后把球体材质改成空气,空气的折射率是 1.00 —— 也就是一个气泡!为此,需要把左边球体材质的折射率改成:
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.00 / 1.33);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 1.0);
这个改变给出以下渲染图:
在这里可以看到,或多或少接近直射的光线会折射,而擦着表面掠过的光线会反射。
11.4 Schlick 近似
现实中的玻璃具有随角度变化的反射率——以很陡的角度看窗户时,它就会变得像一面镜子。描述这个现象有一个很大、很丑的方程,但几乎所有人都会使用 Christophe Schlick 提出的一个开销较小且出奇准确的多项式近似。这样我们就得到了完整的玻璃材质:
class dielectric : public material {
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}
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 ri = rec.front_face ? (1.0/refraction_index) : refraction_index;
vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction, rec.normal), 1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);
bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;
if (cannot_refract || reflectance(cos_theta, ri) > random_double())
direction = reflect(unit_direction, rec.normal);
else
direction = refract(unit_direction, rec.normal, ri);
scattered = ray(rec.p, direction);
return true;
}
private:
// 真空或空气中的折射率,或者材料折射率与包围介质折射率的比值
double refraction_index;
static double reflectance(double cosine, double refraction_index) {
// 使用 Schlick 近似来计算折射率
auto r0 = (1 - refraction_index) / (1 + refraction_index);
r0 = r0*r0;
return r0 + (1-r0)*std::pow((1 - cosine),5);
}
};
11.5 建模空心玻璃球
我们来建模一个空心玻璃球。它是一个具有一定厚度的球体,内部还有另一个由空气组成的球。如果你思考一条光线穿过这种物体的路径,它会先打到外层球体,发生折射;然后打到内层球体(假设确实打到了),再次折射,并在内部空气中传播。接着它会继续前进,打到内层球体的内侧表面,折射回去,然后打到外层球体的内侧表面,最后再次折射,退出并回到场景的大气中。
外层球体可以直接用标准的玻璃球建模,折射率大约为 1.50,也就是模拟光线从外部空气进入玻璃。内层球体稍微有点不同,因为它的折射率应该相对于周围外层球体的材料来定义,也就是模拟从玻璃进入内部空气的过渡。
这其实很容易指定,因为介电材质中的 refraction_index 参数可以被解释为:物体材料的折射率除以包围介质的折射率。在这个例子中,内层球体的折射率就是空气的折射率(内层球体材料)除以玻璃的折射率(包围介质),也就是:
代码如下:
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.50);
auto material_bubble = make_shared<dielectric>(1.00 / 1.50);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 0.0);
world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.2), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.4, material_bubble));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));
...
以下是结果:
