Трассировка лучей на Rust. Часть 4. Сглаживание и материалы.

2020-01-04 • edited 2021-02-06

В этой части мы добавим сглаживание и несколько типов материалов.

Оглавление

Генерация случайных чисел

Реализуем простой генератор псевдослучайных чисел, известный как XORSHIFT . С его помощью мы сначала получим беззнаковые целые числа, которые потом преобразуем в вещественные из интервала $$[0, 1].$$

// /src/random/mod.rs

pub fn frand(seed: u32) -> impl Iterator<Item = f64> {
    let mut r = seed;

    std::iter::repeat_with(move || {
        r ^= r << 13;
        r ^= r >> 17;
        r ^= r << 5;
        r
    })
    .map(|value| (value as f64) / (std::u32::MAX as f64))
}

Функция frand возвращает бесконечный генератор псевдослучайных чисел, который мы применим при реализации сглаживания. Вместо самописного генератора можно было использовать готовый крейт с генераторами случайных чисел, например rand , но для текущей задачи можно обойтись без лишних зависимостей реализовав простой генератор своими руками.

Сглаживание

Сглаживать изображение будем по следующему принципу - вместо одного луча для каждого пикселя будем выпускать N лучей, а затем в качестве итогового цвета для него брать усреднённый по всем выпущенным лучам.

Сначала создадим два генератора псевдослучайных чисел

// /src/main.rs

mod random;

...

fn main() {

let mut g1 = random::frand(32); // mut так как итератор меняется с каждым вызовом next()
let mut g2 = random::frand(44); // mut так как итератор меняется с каждым вызовом next()

let ns = 100; // число испускаемых для каждого пикселя лучей
...

for j in (0..ny).rev() {
        for i in 0..nx {
            let r_rays = g1
                .by_ref()
                .take(ns)
                .zip(g2.by_ref().take(ns))
                .map(|(r1, r2)| {
                    (
                        ((i as f64) + r1) / (nx as f64),
                        ((j as f64) + r2) / (ny as f64),
                    )
                })
                .map(|(u, v)| camera.get_ray(u, v));

            let mut col = Vec3::default();
            for ray in r_rays {
                col += color(&ray, &world);
            }

            col /= ns as f64;

            let (ir, ig, ib) = col.irgb(255.99_f64);
            writeln!(&mut file, "{} {} {}", ir, ig, ib).expect("Unable to write to file");
        }
    }

}

Для подсчёта лучей мы воспользовались возможностями итераторов Rust. Сначала с их помощью были получены пары случайных чисел, которые позволили вычислить случайные смещения относительно позиции исследуемого пикселя (i,j). Далее при помощи функций map и camera.get_ray был получен требуемый итератор лучей. Отметим, что take(n) возвращает итератор из которого можно получить ровно n элементов после чего тот станет невалидным, но это поведение можно изменить, передав не сам итератор в take, а изменяемую ссылку на него при помощи by_ref().

Сравним изображения без сглаживания и с ним:

diff

Диффузное отражение

Теперь реализуем диффузное отражение. Объекты из диффузного материала, не являющиеся источником света, принимают цвет своего окружения. Смоделируем это испусканием случайного луча в направлении поверхности сферы единичного радиуса, которая касается нашего объекта в точке попадания исходного луча.

rays

Сначала напишем функцию создания случайного луча в направлении поверхности единичной сферы, расположенной в начале координат. Будем генерировать случайные числа, пока не получим случайный вектор, квадрат длины которого меньше 1.

// /src/main.rs

fn random_in_unit_sphere(rng: &mut impl Iterator<Item = f64>) -> Vec3 {
    let mut r_vec = Vec3::new(2_f64, 2_f64, 2_f64);

    while r_vec.length2() >= 1_f64 {
        r_vec = Vec3::new(
            2_f64 * rng.next().unwrap() - 1_f64,
            2_f64 * rng.next().unwrap() - 1_f64,
            2_f64 * rng.next().unwrap() - 1_f64,
        );
    }

    r_vec
}

Здесь мы воспользовались тем же приёмом, что и в прошлых частях: получили преобразование из $$[0,1]$$ в $$[-1,1].$$ Функция unwrap нужна, чтобы достать f64 из Option<f64>, возвращаемого методом next(). Далее модифицируем функцию color:

// /src/main.rs

fn color(ray: &Ray, world: &HittableList, rng: &mut impl Iterator<Item = f64>) -> Vec3 {
    match world.hit(ray, 0_f64, std::f64::MAX) {
        Some(hit) => {
            let target = hit.p + hit.n + random_in_unit_sphere(rng);
            0.5_f64 * color(&Ray::new(hit.p, target - hit.p), world, rng)
        }
        None => {
            let unit_direction = ray.direction.unit_vector();

            let t = 0.5_f64 * (unit_direction.y() + 1_f64);

            (1_f64 - t) * Vec3::new(1_f64, 1_f64, 1_f64) + t * Vec3::new(0.5_f64, 0.7_f64, 1.0_f64)
        }
    }
}

У функции color появился новый аргумент rng - изменяемая ссылка на итератор случайных чисел, кроме того она теперь рекурсивна, так как в случае попадания луча в объект на сцене нам нужно выпустить новый луч и для него вновь нужно знать цвет. Мы предполагаем, что отражённый луч обладает в 2 раза меньшей энергией, поэтому компоненты цвета для него делятся пополам.

Соответственно нам понадобится новый генератор случайных чисел:


fn main() {
    let mut g1 = random::frand(32);
    let mut g2 = random::frand(44);
    let mut g = random::frand(53);

    ...

    for j in (0..ny).rev() {
        for i in 0..nx {
            let r_rays = g1
                .by_ref()
                .take(ns)
                .zip(g2.by_ref().take(ns))
                .map(|(r1, r2)| {
                    (
                        ((i as f64) + r1) / (nx as f64),
                        ((j as f64) + r2) / (ny as f64),
                    )
                })
                .map(|(u, v)| camera.get_ray(u, v));

            let mut col = Vec3::default();
            for ray in r_rays {
                col += color(&ray, &world, &mut g);
            }

            col /= ns as f64;

            let (ir, ig, ib) = col.irgb(255.99_f64);
            writeln!(&mut file, "{} {} {}", ir, ig, ib).expect("Unable to write to file");
        }
    }
}

Мы не можем использовать итераторы g1, g2 внутри цикла по лучам, так как на этот момент ссылки на них всё ещё заняты итератором r_rays, поэтому нам потребовался ещё один генератор g.

Перегенерировав изображение, получим:

dark

Сферы на этом изображении выглядят тёмными, причиной этому является тот факт, что программы для просмотра изображений предполагают, что изображение прошло предварительную гамма-коррекцию . Примером такой коррекции является gamma 2, которая означает, что для каждого цвета мы предварительно возьмём квадратный корень его компонент:

// /src/main.rs

col /= ns as f64;

col = Vec3::new(col.r().sqrt(), col.g().sqrt(), col.b().sqrt());

Также несколько откорректируем функцию color, чтобы обработать ситуацию, когда отражённый луч попадает в объект в момент времени близкий к начальному.

//src/main.rs

fn color(ray: &Ray, world: &HittableList, rng: &mut impl Iterator<Item = f64>) -> Vec3 {
    match world.hit(ray, 0.001_f64, std::f64::MAX) {
    ...
}

После данных модификаций мы получим более яркое изображение:

light

Материалы

Абстрактный материал

Ранее мы реализовали диффузное отражение, но далеко не все объекты отражают свет подобным образом. Для поддержки различных типов материалов нам нужна новая абстракция Material.

// /src/materials/mod.rs

use crate::obj::HitRecord;
use crate::structs::ray::Ray;
use crate::structs::vec3::Vec3;

pub mod lambertian;
pub mod metal;

pub trait Scatterable {
    fn scatter(
        &self,
        ray: &Ray,
        hit: &HitRecord,
        rng: &mut impl Iterator<Item = f64>,
    ) -> Option<(Ray, Vec3)>;
}

#[derive(Debug, Clone, Copy)]
pub enum Material {
    Metal(metal::Metal),
    Lambertian(lambertian::Lambertian),
}

impl Scatterable for Material {
    fn scatter(
        &self,
        ray: &Ray,
        hit: &HitRecord,
        rng: &mut impl Iterator<Item = f64>,
    ) -> Option<(Ray, Vec3)> {
        match *self {
            Material::Lambertian(ref mat) => mat.scatter(ray, hit, rng),
            Material::Metal(ref mat) => mat.scatter(ray, hit, rng),
        }
    }
}

impl Default for Material {
    fn default() -> Self {
        Material::Lambertian(lambertian::Lambertian {
            albedo: Vec3::new(0.9_f64, 0.5_f64, 0.5_f64),
        })
    }
}

Добавим поддержку двух разновидностей материалов: с диффузным отражением - Lambertian и зеркальным отражением - Metal. Чтобы не создавать лишних структур воспользуемся тем, что в перечислениях (enum) можно указывать связанные с ними объекты. Последнее позволило нам легко реализовать трейт Scatterable для абстрактного материала через паттерн матчинг: компилятор сам выберет нужную реализацию. Также мы явно указали, что нам достаточно только ссылки на материал (ref mat).

Так как материалы теперь отвечают за создание отражённых лучей, то функцию получения случайного луча перенесём из main.rs в /src/materials/mod.rs.

// /src/materials/mod.rs

fn random_in_unit_sphere(rng: &mut impl Iterator<Item = f64>) -> Vec3 {
    let mut r_vec = Vec3::new(2_f64, 2_f64, 2_f64);

    while r_vec.length2() >= 1_f64 {
        r_vec = Vec3::new(
            2_f64 * rng.next().unwrap() - 1_f64,
            2_f64 * rng.next().unwrap() - 1_f64,
            2_f64 * rng.next().unwrap() - 1_f64,
        );
    }

    r_vec
}

Добавим в запись о событии попадания луча в объект и сферу информацию о материале:

// /src/obj/mod.rs


/// Record of hit event between hittable object and a ray
#[derive(Debug, Clone, Copy)]
pub struct HitRecord<'a> {
    /// Time when the hit had happened
    pub t: f64,
    /// Point where the ray had hit the object
    pub p: Vec3,
    /// Normal to the object hit with ray in the point of the hit
    pub n: Vec3,
    pub material: &'a Material,
}

Так как мы не хотим каждый раз при создании HitRecord копировать материал, то будем хранить ссылку на него, однако для этого также требуется явно связать время жизни поля material с оригинальным объектом (HitRecord<'a> ... pub material: & 'a Material).

// /src/obj/sphere

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


impl Hittable for Sphere {
    fn hit(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        ...
        
            if t_fi > t_min && t_fi < t_max {
                let p_fi = ray.point_at(t_fi);
                return Some(HitRecord {
                    t: t_fi,
                    p: p_fi,
                    n: (p_fi - self.center) / self.radius,
                    material: &self.material, // передаём ссылку на материал
                });
        ...

            if t_si > t_min && t_si < t_max {
                let p_si = ray.point_at(t_si);
                return Some(HitRecord {
                    t: t_si,
                    p: p_si,
                    n: (p_si - self.center) / self.radius,
                    material: &self.material, // передаём ссылку на материал
                });
            }
        }
        None
    }
}

Диффузные поверхности

Переносим код из main.rs в /src/materials/lambertian.rs

// /src/materials/lambertian.rs

use crate::structs::ray::Ray;
use crate::structs::vec3::Vec3;

use super::Scatterable;
use crate::obj::HitRecord;

#[derive(Debug, Clone, Copy)]
pub struct Lambertian {
    pub albedo: Vec3,
}

impl Scatterable for Lambertian {
    fn scatter(
        &self,
        ray: &Ray,
        hit: &HitRecord,
        rng: &mut impl Iterator<Item = f64>,
    ) -> Option<(Ray, Vec3)> {
        let target = hit.p + hit.n + super::random_in_unit_sphere(rng);
        Some((Ray::new(hit.p, target - hit.p), self.albedo))
    }
}

Здесь мы также добавили возможность указывать альбедо материала, который вызывающий код сможет использовать при расчёте финального цвета.

Металлы

Для металлических поверхностей нам нужен не просто случайный луч из точки касания, а отраженный, вычисляемый по формуле $$\overline{v}+2*\overline{B}$$, где $$\overline{B} = -(\overline{v}\cdot \overline{n}) * \overline{n}.$$

mirvec

// /src/materials/mod.rs

fn reflect(v: &Vec3, n: &Vec3) -> Vec3 {
    *v - 2_f64 * v.dot(n) * *n
}

Применим разработанную функцию reflect для металлов.

// /src/materials/metal.rs

use crate::structs::ray::Ray;
use crate::structs::vec3::Vec3;

use super::Scatterable;
use crate::obj::HitRecord;

#[derive(Debug, Clone, Copy)]
pub struct Metal {
    pub albedo: Vec3,
    pub fuzz: f64,
}

impl Scatterable for Metal {
    fn scatter(
        &self,
        ray: &Ray,
        hit: &HitRecord,
        rng: &mut impl Iterator<Item = f64>,
    ) -> Option<(Ray, Vec3)> {
        let reflected = super::reflect(
            &(ray.direction.unit_vector() + self.fuzz * super::random_in_unit_sphere(rng)),
            &hit.n,
        );

        Some((Ray::new(hit.p, reflected), self.albedo))
    }
}

Не все материалы являются идеальными зеркалами, чтобы смоделировать это мы добавили свойство fuzz в структуру металла: после расчёта отражённого луча просто сместим его в случайном направлении на величину равную fuzz.

Отображение объектов из различных материалов

Скорректируем main.rs с учётом нового функционала:

// /src/main.rs

fn color(ray: &Ray, world: &HittableList, rng: &mut impl Iterator<Item = f64>, depth: i32) -> Vec3 {
    match world.hit(ray, 0.001_f64, std::f64::MAX) {
        Some(hit) => match hit.material.scatter(ray, &hit, rng) {
            Some((scattered_ray, attenuation)) => {
                if depth > 50 {
                    return Vec3::new(0_f64, 0_f64, 0_f64);
                }

                attenuation * color(&scattered_ray, world, rng, depth + 1)
            }
            None => Vec3::new(0_f64, 0_f64, 0_f64),
        },
        None => {
            let unit_direction = ray.direction.unit_vector();

            let t = 0.5_f64 * (unit_direction.y() + 1_f64);

            (1_f64 - t) * Vec3::new(1_f64, 1_f64, 1_f64) + t * Vec3::new(0.5_f64, 0.7_f64, 1.0_f64)
        }
    }
}

Функция color получила ограничение на глубину рекурсии (50) и научилась учитывать материал при подсчёте результирующего цвета.

// /src/main.rs

let world = HittableList {
        objects: vec![
            Box::new(Sphere::new(
                Vec3::new(0_f64, 0_f64, -1_f64),
                0.5,
                Material::Lambertian(Lambertian {
                    albedo: Vec3::new(0.8_f64, 0.3_f64, 0.3_f64),
                }),
            )),
            Box::new(Sphere::new(
                Vec3::new(0_f64, -100.5_f64, -1_f64),
                100_f64,
                Material::Lambertian(Lambertian {
                    albedo: Vec3::new(0.8_f64, 0.8_f64, 0_f64),
                }),
            )),
            Box::new(Sphere::new(
                Vec3::new(1_f64, 0_f64, -1_f64),
                0.5_f64,
                Material::Metal(Metal {
                    albedo: Vec3::new(0.8_f64, 0.6_f64, 0.2_f64),
                    fuzz: 0.3_f64,
                }),
            )),
            Box::new(Sphere::new(
                Vec3::new(-1_f64, 0_f64, -1_f64),
                0.5_f64,
                Material::Metal(Metal {
                    albedo: Vec3::new(0.8_f64, 0.8_f64, 0.8_f64),
                    fuzz: 0.8_f64,
                }),
            )),
        ],
    };
    

На сцене стало больше объектов, отобразив которые получим следующее изображение:

fuzzy

А вот результат если сделать поверхности металлических сфера зеркальными:

clear

Приложения

Примечание

Данная заметка написана в рамках реализации трассировки лучей на Rust. Остальные статьи из этой серии можно найти по следующему тегу или в первой публикации из цикла .

Исходный код проекта доступен на github.

Полезные материалы

  1. Лекции по программированию на Rust от Computer Science Center.
  2. Programming Rust: Fast, Safe Systems Development.
  3. Серия книг про трассировку лучей.
developmentrustdevelopmentstudyraytracerrustraytracer
License: MIT

Трассировка лучей на Rust. Часть 5. Диэлектрики.

Трассировка лучей на Rust. Часть 3. Сфера.

comments powered by Disqus