在前面的文章中,我们渲染出来的图片使用的是漫反射的材质。不同的材质,可以简单理解为对于光线的影响不同,这里的影响包括如何吸收,如何散射等。在这一节,我们将加入另一种材质,金属材质。

最终的渲染图如下

首先要做的是将材质(Material)相关的代码抽象出来。新建一个 material.rs 的文件,代码如下。

代码大概的含义是将 Material 作为一个 Trait,里面有一个 scatter 方法,这个方法决定了光线如何散射,对于不同的材质,实现自己不同的散射方式。

Lambertian 是漫反射材质的逻辑,里面的变量 scatter_direction 就是我们之前的逻辑中的光线散射方向,在这里还加入了另一个东西,就是判断这个方向的值是否接近于0,如果是,那么直接使用光线打到的点的法线作为方向。有了方向,有了原光线打到的点,就可以产生一条新的从打到的点,到新方向的光线,也就是 scattered 变量。attenuation 是原光线打到的点的颜色,对于漫反射来说,也就是当前材质的颜色,这个值也会返回。

Metal 是金属材质的实现,对于金属材质来说,会比漫反射散射更多的光线,所以看起来会闪亮亮的。散射的原理如下

其中, V 是入射光线,N 是标准化的法线,而红色的向量,就是反射光线的方向,它 = v + 2b。这里的 V 不一定是标准化的。b 的长度是 V 点乘 N,因为 V 是向内的,所以这里需要一个负号,将它进行反向,最后的公式就是

$反射光线 = V - 2 * (V \cdot N) * N$

// src/material.rs
use crate::color::Color;
use crate::hittable::HitRecord;
use crate::ray::Ray;
use crate::vec3::Vec3;

pub trait Material {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)>;
}

pub struct Lambertian {
    pub albedo: Color,
}

impl Lambertian {
    pub fn new(color: Color) -> Self {
        Self { albedo: color }
    }
}

impl Material for Lambertian {
    fn scatter(&self, _: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let mut scatter_direction = hit_record.normal + Vec3::random_unit_vector();
        if scatter_direction.near_zero() {
            scatter_direction = hit_record.normal;
        }

        let scattered: Ray = Ray::new(hit_record.p, scatter_direction);
        let attenuation = self.albedo;
        Some((scattered, attenuation))
    }
}

pub struct Metal {
    pub albedo: Color,
}

impl Metal {
    pub fn new(color: Color) -> Self {
        Self {
            albedo: color,
        }
    }
}

impl Material for Metal {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let unit_ray_direction = Vec3::unit_vector(r_in.direction);
        let reflected = Vec3::reflect(unit_ray_direction, hit_record.normal);
        let scattered = Ray::new(
            hit_record.p,
            reflected,
        );
        let attenuation = self.albedo;
        if Vec3::dot(scattered.direction, hit_record.normal) > 0.0 {
            Some((scattered, attenuation))
        } else {
            None
        }
    }
}

上面代码中用到的两个东西是在 vec3.rs 中,一个是 Lambertian 实现中的 near_zero() 函数。一个是 Metal 实现中的 reflect 函数。代码加到 vec3.rs 中。

// src/vec3.rs
pub fn near_zero(&self) -> bool {
    let border: f64 = 1e-8;
    self.x.abs() < border && self.y.abs() < border && self.z.abs() < border
}

pub fn reflect(v: Vec3, n: Vec3) -> Vec3 {
    v - 2.0 * Vec3::dot(v, n) * n
}

材质抽象完,还需要修改光线打到的点的数据,之前只记录了打到哪个位置,这个位置的法线方向是什么等,现在还需要将打到的点的材质,也加进去,修改 hittable.rs,在 HitRecord 中加入材质相关的记录

在这里,使用了引用的形式来记录打到时的点的材质信息,所上诉人需要生命周期相关的东西。

// src/hittable.rs
use crate::material::Material;
use crate::ray::Ray;
use crate::vec3::Vec3;

#[derive(Copy, Clone)]
pub struct HitRecord<'a> {
    pub p: Vec3,
    pub normal: Vec3,
    pub material: &'a dyn Material,
    pub t: f64,
    pub front_face: bool,
}

impl<'a> HitRecord<'a> {
    pub fn new(p: Vec3, t: f64, outward_normal: Vec3, r: Ray, material: &'a dyn Material) -> Self {
        let front_face = Vec3::dot(r.direction, outward_normal) < 0.0;
        let normal = if front_face {
            outward_normal
        } else {
            -outward_normal
        };

        Self {
            p,
            normal,
            t,
            front_face,
            material: material,
        }
    }
}

pub trait Hittable {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord>;
}

接下来一步就是将材质加入到具体的物体中,也就是我们之前的球体 Sphere。一个物体可以使用不同的材质,所以这里使用了泛型的方式来实现。代码与之前没有什么变化,只是加入了材质,并且在被 hit 时,将材质传给 HitRecord。

// src/sphere.rs
use crate::hittable::{HitRecord, Hittable};
use crate::material::Material;
use crate::ray::Ray;
use crate::vec3::Vec3;

pub struct Sphere<M: Material> {
    pub center: Vec3,
    pub radius: f64,
    pub material: M,
}

impl<M: Material> Sphere<M> {
    pub fn new(cen: Vec3, r: f64, material: M) -> Self {
        Self {
            center: cen,
            radius: r,
            material,
        }
    }
}

impl<M: Material> Hittable for Sphere<M> {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let oc = r.origin - self.center;
        let a = r.direction.length_squared();
        let half_b = Vec3::dot(oc, r.direction);
        let c = oc.length_squared() - self.radius * self.radius;
        let result = half_b * half_b - a * c;
        if result < 0.0 {
            return None;
        }

        let sqrtd = result.sqrt();

        // 找到光线打到球面最近的点,有可能光线会穿透球体,与两个点相交,
        // 但是远的点会被近的面遮住,我们看不到,所以这里使用近的点就可以了
        let mut root = (-half_b - sqrtd) / a;
        if root < t_min || t_max < root {
            root = (-half_b + sqrtd) / a;
            if root < t_min || t_max < root {
                return None;
            }
        }

        let t = root;
        let p = r.at(t);
        let outward_normal = (p - self.center) / self.radius;

        let hit_rec = HitRecord::new(p, t, outward_normal, *r, &self.material);
        return Some(hit_rec);
    }
}

最后是修改 main.rs。首先修改 ray_color 函数,将之前里面的漫反射代码去掉,换成调用材质的 scatter 函数来获取打到点的颜色和散射出的新光线方向。代码还是没有什么变化,只是将调用方式换了。

// src/main.rs
fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::zero();
    }

    if let Some(hit_record) = world.hit(r, 0.001, f64::INFINITY) {
        if let Some((scattered, attenuation)) = hit_record.material.scatter(&r, &hit_record) {
            return attenuation * ray_color(&scattered, world, depth - 1);
        } else {
            return Color::zero();
        }
    }

    let unit_direction = Vec3::unit_vector(r.direction);
    let t = 0.5 * (unit_direction.y + 1.0);
    return (1.0 - t) * Color::one() + t * Color::new(0.5, 0.7, 1.0);
}

接着,我们需要创建不同的材质,然后再多创建两个球到场景中,将它们使用不同的材质来显示。

// src/main.rs
fn main() {
    // Image config
    const ASPECT_RATIO: f64 = 16.0 / 9.0;
    const IMAGE_WIDTH: u64 = 600;
    const IMAGE_HEIGHT: u64 = ((IMAGE_WIDTH as f64) / ASPECT_RATIO) as u64;
    const SAMPLES_PER_PIXEL: u64 = 100;
    const MAX_DEPTH: i32 = 50;

    let mut world = HittableList::new();
    
    // 创建不同的材质
    let material_ground = Lambertian::new(Color::new(0.8, 0.8, 0.0));
    let material_center = Lambertian::new(Color::new(0.7, 0.3, 0.3));
    let material_left = Metal::new(Color::new(0.8, 0.8, 0.8));
    let material_right = Metal::new(Color::new(0.8, 0.6, 0.2));

    // World
    world.add(Box::new(Sphere::new(
        Vec3::new(0.0, -100.5, -1.0),
        100.0,
        material_ground,
    )));
    world.add(Box::new(Sphere::new(
        Vec3::new(0.0, 0.0, -1.0),
        0.5,
        material_center,
    )));

    world.add(Box::new(Sphere::new(
        Vec3::new(-1.0, 0.0, -1.0),
        0.5,
        material_left,
    )));

    world.add(Box::new(Sphere::new(
        Vec3::new(1.0, 0.0, -1.0),
        0.5,
        material_right,
    )));

    // Camera config
    let cam = Camera::new();

    // Render
    let mut rng = rand::thread_rng();
    let mut image_file_string = String::new();
    image_file_string.push_str(&format!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT));
    for j in (0..=IMAGE_HEIGHT - 1).rev() {
        for i in 0..IMAGE_WIDTH {
            let mut pixel_color = Color::zero();
            for _ in 0..SAMPLES_PER_PIXEL {
                let u_rand: f64 = rng.gen();
                let v_rand: f64 = rng.gen();
                let u = (i as f64 + u_rand) / (IMAGE_WIDTH - 1) as f64;
                let v = (j as f64 + v_rand) / (IMAGE_HEIGHT - 1) as f64;
                let r = cam.get_ray(u, v);
                pixel_color += ray_color(&r, &world, MAX_DEPTH);
            }

            image_file_string.push_str(&format!(
                "{}",
                color::get_color_string(pixel_color, SAMPLES_PER_PIXEL)
            ));
        }
    }

    println!("{}", image_file_string);
}

然后 cargo run --release > shiny_metal.ppm,将产生下面的图

与文章刚开始的图不太一样,这是因为,现在渲染出来的是闪亮的金属材质,我们可以加入另一个参数,控制金属的模糊感,使它不那么亮。它的原理就是在原来反射的点,进行一个随机。

修改 Metal 的实现,加入一个参数 fuzz,来控制模糊的程度,最大值为 1.0。下面代码中,在计算 scattered 的时候,将 fuzz 加入到了运算逻辑中。

// src/material.rs
pub struct Metal {
    pub albedo: Color,
    pub fuzz: f64,
}

impl Metal {
    pub fn new(color: Color, fuzz: f64) -> Self {
        Self {
            albedo: color,
            fuzz: if fuzz < 1.0 { fuzz } else { 1.0 },
        }
    }
}

impl Material for Metal {
    fn scatter(&self, r_in: &Ray, hit_record: &HitRecord) -> Option<(Ray, Color)> {
        let unit_ray_direction = Vec3::unit_vector(r_in.direction);
        let reflected = Vec3::reflect(unit_ray_direction, hit_record.normal);
        let scattered = Ray::new(
            hit_record.p,
            reflected + self.fuzz * Vec3::random_in_unit_sphere(),
        );
        let attenuation = self.albedo;
        if Vec3::dot(scattered.direction, hit_record.normal) > 0.0 {
            Some((scattered, attenuation))
        } else {
            None
        }
    }
}

然后在 main 函数中创建 Metal 材质时,传入模糊参数值。

// src/main.rs
 let material_left = Metal::new(Color::new(0.8, 0.8, 0.8), 0.3);
let material_right = Metal::new(Color::new(0.8, 0.6, 0.2), 1.0);

现在使用 cargo run --release > fuzzed_metal.ppm 生成的图,就是文章开头的图了。

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