漫反射的通俗理解是,当一个光线打到某一个物体的某一个点上,这条光线一部分会被吸收,一部分会被随机的反射出去,而反射出去的光线,又可能会打到另一个物体面上的一个点,然后又会被吸收,以及随机的反射出去。现实中的光线可能会无限递归下去,但是我们在程序中实现,不可能无限递归,会设置一个反射次数,达到了那个次数,就停止。

漫反射的实现过程,首先,一条光线打到了物体表面上的一个 P 点,在 P 点法线的那一侧,做一个单位球,这个球与 P 点相切。法线的一侧,我们以球体为例,如果法线向外,那这个单位球就在球的外面,如果法线向内指向球心,那这个单位球就在球的内侧。然后,在单位球内,随机生成一个点 S,然后从 P 到 S 的方向,就是原光线的反射方向,实现上,我们只要从 P 向 S 点再发射一条光线即可。对于每一条光线,重复上面的过程。

还是在上一次的代码基础上修改,主要修改如下

Vec3 的实现中添加一个 random_in_unit_sphere 的函数,用于生成单位球内的一个随机点。

// src/vec.rs
pub fn random_in_unit_sphere() -> Vec3 {
    let mut rng = rand::thread_rng();

    let mut p: Vec3;
    loop {
        let x = rng.gen_range(-1.0..1.0);
        let y = rng.gen_range(-1.0..1.0);
        let z = rng.gen_range(-1.0..1.0);
        p = Vec3::new(x, y, z);
        if p.length_squared() >= 1.0 {
            continue;
        }
        break;
    }
    return p;
}

然后修改 main.rs 中的 ray_color 函数,添加光线的随机反射,depth 参数是我们设定的光线反射次数。

// 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.0, f64::INFINITY) {
        let target: Vec3 = hit_record.p + hit_record.normal + Vec3::random_in_unit_sphere();
        let ray = Ray::new(hit_record.p, target - hit_record.p);
        return 0.5 * ray_color(&ray, world, depth - 1);
    }

    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);
}

在 main 函数中添加 MAX_DEPTH 常量,以及修改对于 ray_color 函数的调用。MAX_DEPTH 的大小,对于计算的耗时影响很大,这里设置为了2次,原教程中设置为了50次,但是生成的结果视觉上差别不大,原理上是正确的。如果将 MAX_DEPTH 设置为 50,在我的机器上,2020 款 Mac mini M1,耗时大概接近 1 分钟。

// 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 = 2;

    // World
    let mut world = HittableList::new();
    world.add(Box::new(Sphere::new(Vec3::new(0.0, 0.0, -1.0), 0.5)));
    world.add(Box::new(Sphere::new(Vec3::new(0.0, -100.5, -1.0), 100.0)));

    // 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);
}

还修改了一个地方是,ray_color 函数中的 r: Ray 参数,修改为了引用的形式 r: &Ray

这时,运行将输出下面的结果,这个是未经过伽马矫正的图。

上面的图片看起来很暗,在现实中是不应该这样的,我们的球体只吸收了 50% 的光线,所以另外的 50% 被反射了。之所以看起来很黑,是因为在计算机中,都被假设图像是经过了 gamma 校正的,也就是在将图片的数据存入文件前,进行了一些转换。所以我们也只需要在写入文件前,对颜色进行一定的转换即可。我们可以使用 “gamma 2” (我也不知道这是什么),简单来说就是提升颜色值。计算方式如下

修改 color.rs 中的 write_color 函数,将原来的 r *= scale 变为开平方。

// src/color.rs
use crate::vec3::Vec3;
pub type Color = Vec3;

pub fn get_color_string(pixel_color: Color, samples_per_pixel: u64) -> String {
    let mut r = pixel_color.x;
    let mut g = pixel_color.y;
    let mut b = pixel_color.z;

    // Divided the color by the number of samples and gamma-correct for gamma = 2.0
    let scale = 1.0 / samples_per_pixel as f64;
    r = (scale * r).sqrt();
    g = (scale * g).sqrt();
    b = (scale * b).sqrt();
    // r *= scale;
    // g *= scale;
    // b *= scale;

    let r = (256.0 * r.clamp(0.0, 0.999)) as u64;
    let g = (256.0 * g.clamp(0.0, 0.999)) as u64;
    let b = (256.0 * b.clamp(0.0, 0.999)) as u64;

    format!("{} {} {}\n", r, g, b)
}

最终效果图如下

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