关于折射的实现,可以使用斯涅尔定律。当光波从一种介质传播到另一种介质时,如果两种介质拥有不同的折射率,那么光线就会发生折射现象。例如光线从空气中进入水中,或者从空气中进入玻璃中。

下面涉及到的公式,也可以不用理解推导过程,只要拿来用就行。

斯涅尔定律

斯涅尔定律表明,当光波从介质1传播到介质2时,假若两种介质的折射率不同,则会发生折射现象,其入射光和折射光都处于同一平面,称为“入射平面”,并且与界面法线的夹角满足如下关系:

$n_{1}\sin\theta_{1} = n_{2}\sin\theta_{2}$

其中,$n_{1}$、$n_{3}$ 分别是两种介质的折射率,$\theta_{1}$、$\theta_{2}$ 分别是入射光线、折射光线与界面法线的夹角,分别叫做入射角折射角

要求折射光线的方向,就需要解出 $\sin\theta_{2}$ 来。根据上面的公式可知

$\sin\theta_{2} = \frac{n_{1}}{n_{2}} \cdot \sin\theta_{1}$

我们可以将折射光线的向量,分解为一个垂直向量和一个平行向量。计算出这两个向量,然后相加,即可得到最终的折射向量。

$ 垂直向量 = \frac{n_{1}}{n_{2}}(入射向量 + \cos\theta_{1}*法向量)$

$ 平行向量 = - \sqrt{1 - |垂直向量|^2 * 法向量}$

由于,两个向量的点乘与其夹角的 cos 值有关,也就是 $ a \cdot b = |a||b|\cos\theta $ ,如果 a 和 b 都是单位向量,那么可得到 $a \cdot b = \cos\theta$

根据上面的规则,可以使用新的方式来表示垂直向量

$ 垂直向量 = \frac{n_{1}}{n_{2}}(入射向量 + (-入射向量 \cdot 法向量) * 法向量) $

综上,可以使用代码来实现折射逻辑,在 vec3.rs 中添加一个新的函数 refract

// src/vec3.rs
pub fn refract(unit_in_v: Vec3, n: Vec3, etai_over_etat: f64) -> Vec3 {
    let cos_theta = Vec3::dot(-unit_in_v, n).min(1.0);
    let r_out_perp = etai_over_etat * (unit_in_v + cos_theta * n);
    let r_out_parallel = -((1.0 - r_out_perp.length_squared()).abs().sqrt()) * n;
    return r_out_perp + r_out_parallel;
}

然后添加 Dielectric 材质

// src/material.rs
pub struct Dielectric {
    pub ir: f64,  // 不同介质的折射率
}

impl Dielectric {
    pub fn new(ir: f64) -> Self {
        Self { ir }
    }
}

impl Material for Dielectric {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let attenuation = Color::one();
        let refraction_ratio = if hit_record.front_face {
            1.0 / self.ir
        } else {
            self.ir
        };

        let unit_in_direction = Vec3::unit_vector(r_in.direction);
        let refracted = Vec3::refract(unit_in_direction, hit_record.normal, refraction_ratio);
        let scattered = Ray::new(hit_record.p, refracted);
        return Some((scattered, attenuation));
    }
}

然后修改 main 函数中的 material_centermaterial_left 材质

// src/main.rs
let material_center = Dielectric::new(1.5);
let material_left = Dielectric::new(1.5);

使用 cargo run --release > refracts.ppm 可以得到下面的图

全内反射与临界角

假设光线从折射率较大的价质传播进入折射率较小的价质,则入射角越大,光线的折射角也越大,直至当入射角大于临界角时,由于折射角不能大于90度,这时会出现全内反射。

由于 $ \sin\theta_{2} = \frac{n_{1}}{n_{2}} \cdot \sin\theta_{1} $,假设 $n_{1} = 1.5,n_{2} = 1.0 $ 也就是 $\sin\theta_{2} = \frac{1.5}{1.0} \cdot \sin\theta_{1}$。由于 $\sin\theta_{2}$ 的值不能大于 1.0,所以如果 $\frac{1.5}{1.0} \cdot \sin\theta_{1} > 1.0$ 则会发生全内反射。

根据三角函数可以求出 $\sin\theta_{1}$ 的值。$\sin\theta_{1} = \sqrt{1 - \cos^2\theta_{1}} $。

而 $\cos\theta_{1} = 入射向量 \cdot 法向量$

修改 Dielectric 材质的代码,将全内反射的逻辑添加进去。

// src/material.rs
impl Material for Dielectric {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let attenuation = Color::one();
        let refraction_ratio = if hit_record.front_face {
            1.0 / self.ir
        } else {
            self.ir
        };
        let unit_in_direction = Vec3::unit_vector(r_in.direction);

        let cos_theta = Vec3::dot(-unit_in_direction, hit_record.normal).min(1.0);
        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
        let cannot_refract = refraction_ratio * sin_theta > 1.0;
        let direction = if cannot_refract {
            Vec3::reflect(unit_in_direction, hit_record.normal)
        } else {
            Vec3::refract(unit_in_direction, hit_record.normal, refraction_ratio)
        };

        let scattered = Ray::new(hit_record.p, direction);
        return Some((scattered, attenuation));
    }
}

修改 main 函数中的材质代码,将四个材质的代码修改如下

// src/main.rs
let material_ground = Lambertian::new(Color::new(0.8, 0.8, 0.0));
let material_center = Lambertian::new(Color::new(0.1, 0.2, 0.5));
let material_left = Dielectric::new(1.5);
let material_right = Metal::new(Color::new(0.8, 0.6, 0.2), 0.0);

使用 cargo run --release > dielectric_and_shinny_sphere.ppm 可以得到下面的图

Schlick 近似

现实世界中,不同的角度,会导致不同的反射率,以陡峭的角度看玻璃,它会产生镜子的特性。对于这一点的实现比较复杂,但是大家都会使用一种简单并且能达到近似效果的方法,也就是 Schlick 近似,具体的原理,请查看 Wiki,我们直接修改代码,添加逻辑。

在 Dielectric 的实现中,添加一个函数 reflectance

// src/material.rs
impl Dielectric {
    pub fn new(ir: f64) -> Self {
        Self { ir }
    }

    pub fn reflectance(cosine: f64, ref_idx: f64) -> f64 {
        let mut r0 = (1.0 - ref_idx) / (1.0 + ref_idx);
        r0 = r0 * r0;
        return r0 + (1.0 - r0) * (1.0 - cosine).powf(5.0);
    }
}

然后修改 Dielectric 的 Material Trait 的实现

// src/material.rs
impl Material for Dielectric {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let attenuation = Color::one();
        let refraction_ratio = if hit_record.front_face {
            1.0 / self.ir
        } else {
            self.ir
        };
        let unit_in_direction = Vec3::unit_vector(r_in.direction);

        let cos_theta = Vec3::dot(-unit_in_direction, hit_record.normal).min(1.0);
        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
        let cannot_refract = refraction_ratio * sin_theta > 1.0;
        let mut rng = rand::thread_rng();
        let random_double = rng.gen();
        let direction = if cannot_refract
            || Dielectric::reflectance(cos_theta, refraction_ratio) > random_double
        {
            Vec3::reflect(unit_in_direction, hit_record.normal)
        } else {
            Vec3::refract(unit_in_direction, hit_record.normal, refraction_ratio)
        };

        let scattered = Ray::new(hit_record.p, direction);
        return Some((scattered, attenuation));
    }
}

通过上面的修改,我们可以实现一个空心玻璃球效果,修改 main 函数,添加一个新的材质和一个新的球体。这个球体的半径为负的,而对于它的几何形状没有影响,但是这将使它的法线向内,最终形成一个空心玻璃球效果。

// main.rs
let material_left2 = Dielectric::new(1.5);

world.add(Box::new(Sphere::new(
    Vec3::new(-1.0, 0.0, -1.0),
    -0.4,
    material_left2,
)));

使用 cargo run --release > hollow_glass_sphere.ppm 可以得到下面的图

完整代码: https://github.com/moeif/rtiow-rs/tree/11