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

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

В этой части мы создадим базовую структуру сферы и отобразим её в ppm формате.

Оглавление

Проверка на пересечение между лучом и сферой

Для начала добавим обработку первого объекта в наш трассировщик лучей. Наиболее простым объектом для трассировки является сфера, определяемая уравнением:

$$ x^2+y^2+z^2 = R^2. $$

В случае, если центр сферы расположен не в начале координат, а в точке $$C=(x_0, y_0, z_0)$$, то последнее уравнение примет вид

$$ (x-x_0)^2+(y-y_0)^2+(z-z_0)^2 = R^2, $$

в векторной форме:

$$ (\overline{P}-\overline{C}) \cdot (\overline{P}-\overline{C}) = R^2, $$

где через $$\cdot$$ обозначено скалярное произведение разности векторов $$\overline{P} = (x,y,z)$$ и $$\overline{C} = (x_0, y_0, z_0).$$

Подставив в эту формулу вместо $$\overline{P}$$ определяющий вектор луча $$\overline{P(t)} = \overline{A} + t * \overline{B}$$ в момент времени $$t$$, получим

$$ (\overline{A} + t * \overline{B} - \overline{C}) \cdot (\overline{A} + t * \overline{B} - \overline{C}) = R^2, $$

что, в свою очередь, можно переписать в виде следующего квадратного уравнения:

$$ t^2 * \overline{B} \cdot \overline{B} + 2 t * \overline{B} \cdot (\overline{A} - \overline{C}) + (\overline{A} - \overline{C}) \cdot (\overline{A} - \overline{C}) -R^2 = 0. $$

Последнее уравнение, в зависимости от значения его дискриминанта ($$D$$), может иметь одно, два или ни одного решения, что позволит нам определить в какой момент времени луч пересекает ($$D>0$$), касается сферы ($$D=0$$), либо убедиться, что луч не пересекает сферу ($$D < 0$$).

Реализуем эту проверку в коде

// src/main.rs

fn hit_sphere(center: Vec3, radius: f64, ray: &Ray) -> bool {
    let oc = ray.origin - center; // A - C
    let a = ray.direction.dot(&ray.direction); // dot(B, B)
    let b = 2_f64 * oc.dot(&ray.direction); // 2 * dot(A-C, B)
    let c = oc.dot(&oc) - radius * radius; // dot(A-C, A-C) - R * R
    (b * b - 4_f64 * a * c) > 0_f64
}

и отметим красным те точки, в которых луч пересекает сферу

// src/main.rs

fn color(ray: &Ray) -> Vec3 {
    if (hit_sphere(Vec3::new(0_f64, 0_f64, -1_f64), 0.5_f64, ray)) {
        return Vec3::new(1_f64, 0_f64, 0_f64);
    }

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

После запуска программы получим следующий результат:

redsphere

Мы успешно отобразили точки пересечения сферы с центром в точке $$(0,0,-1)$$ с нашим виртуальным лучом. Однако если мы попробуем запустить ту же программу для сферы с центром в $$(0,0,1),$$ то мы получим то же изображение, так как пока не учитывается условие $$0 \leqslant t \leqslant 1, $$ что позволяет “видеть” объекты, находящиеся у нас за “спиной”. Это не является желаемым поведение и будет исправлено далее.

Вычисление нормали к поверхности сферы

Далее изменим функцию hit_sphere так, чтобы она возвращала момент времени первого пересечения луча со сферой:

// scr/main.rs

fn hit_sphere(center: Vec3, radius: f64, ray: &Ray) -> Option<f64> {
    let oc = ray.origin - center;
    let a = ray.direction.dot(&ray.direction);
    let b = 2_f64 * oc.dot(&ray.direction);
    let c = oc.dot(&oc) - radius * radius;
    let discriminant = (b * b - 4_f64 * a * c);

    if (discriminant < 0_f64) {
        None
    } else {
        Some((-b - discriminant.sqrt()) / (2_f64 * a)) // - так как мы хотим получить момент первого пересечения
    }
}

Теперь вместо bool, мы возвращаем Option<f64>, которое равно None в случае, если нет пересечения со сферой и Some в противном случае. Some содержит в себе момент первого пересечения луча со сферой.

Далее раскрасим сферу согласно направлению нормалей к её поверхности. Вычислить нормаль сферы достаточно просто, так как она вычисляется просто как нормализованный вектор $$\overline{P} - overline{C}.$$

// scr/main.rs

fn color(ray: &Ray) -> Vec3 {
    match hit_sphere(Vec3::new(0_f64, 0_f64, -1_f64), 0.5_f64, ray) {
        Some(t) => {
            let n = (ray.point_at(t) - Vec3::new(0_f64, 0_f64, -1_f64)).unit_vector();
            return 0.5_f64 * Vec3::new(n.x() + 1_f64, n.y() + 1_f64, n.z() + 1_f64);
        }
        None => (),
    }
    
    ...
}

В функции color мы применили тот же приём, что и при вычислении значения t для закраски фона - так как нормаль является вектором единичной длины со значениями компонент из отрезка $$[-1,1],$$ то добавив к каждой её компоненте $$1$$ и разделив пополам мы приведём из к значениям из интервала $$[0,1].$$ Последнее позволяет нам сопоставить каждой точке на поверхности сферы цвет, отвечающий направлению нормали к сфере в этой точке.

normalsphere

Отображение нескольких объектов

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

// src/obj/mod.rs

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

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

/// Record of hit event between hittable object and a ray
#[derive(Debug, Clone, Copy)]
pub struct HitRecord {
    /// 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,
}

Функция hit возвращает возможное пересечение луча с объектом, если пересечения нет, то она вернёт None. Структура HitRecord состоит из момента времени пересечения луча с объектом t, координат пересечения луча с объектом p и нормали к объекту в этой точке n.

Далее реализуем структуру сферы и трейт Hittable для неё.

// src/obj/sphere.rs

use super::HitRecord;
use super::Hittable;
use crate::structs::ray::Ray;
use crate::structs::vec3::Vec3;

mod tests {
    use super::*;

    #[test]
    fn hit_test() {
        let s = Sphere::new(Vec3::new(0_f64, 0_f64, -1_f64), 0.5);
        let r = Ray::new(Vec3::default(), s.center);
        let m_r = Ray::new(Vec3::default(), -1_f64 * s.center);

        let hit = s.hit(&r, 0_f64, std::f64::MAX);
        let miss = s.hit(&m_r, 0_f64, std::f64::MAX);

        assert_eq!(hit.is_some(), true);
        assert_eq!(miss.is_none(), true);
    }
}


#[derive(Debug, Clone, Copy)]
pub struct Sphere {
    pub center: Vec3,
    pub radius: f64,
}

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

impl Hittable for Sphere {
    fn hit(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let oc = ray.origin - self.center;
        let a = ray.direction.dot(&ray.direction);
        let b = oc.dot(&ray.direction);
        let c = oc.dot(&oc) - self.radius * self.radius;
        let discriminant = b * b - a * c;

        if discriminant > 0_f64 {
            let t_fi = (-b - discriminant.sqrt()) / a;

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

            let t_si = (-b + discriminant.sqrt()) / a;

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

Обратите внимание на use блоки в начале файла, до структур мы добираемся через спецификатор crate, а до HitRecord, Hittable через super. Метод hit по сути повторяет функционал метода hit_sphere с тем отличием, что в зависимости от рассматриваемого промежутка времени мы получим либо первое пересечение со сферой, либо второе (ранее мы всегда брали первое).

Также реализуем структуру для представления нашей сцены - набора объектов с которыми мы ищем пересечение:

// src/obj/hittablelist.rs

use super::HitRecord;
use super::Hittable;
use crate::structs::ray::Ray;

pub struct HittableList {
    pub objects: Vec<Box<dyn Hittable>>,
}

impl Hittable for HittableList {
    fn hit(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let mut current_hit: Option<HitRecord> = None;
        let mut best_hit_time = t_max;
        for object in &self.objects {
            let hit_candidate = object.hit(ray, t_min, best_hit_time);

            match hit_candidate {
                Some(hit) => {
                    best_hit_time = hit.t;
                    current_hit = Some(hit);
                }
                None => (),
            }
        }

        current_hit
    }
}

Обратите внимание, что поле objects имеет тип Vec<Box<dyn Hittable>>, а не Vec<dyn Hittable>, причиной этого является то, что Vec хранит объекты одинакового размера, а объекты, для которых реализована характеристика Hittable могут иметь произвольный размер. Однако ничего нам не мешает иметь вектор указателей на области в куче, где лежат нашит объекты, ведь указатели будут иметь одинаковый размер. Отметим также, что здесь мы применили паттерн матчинг, чтобы обработать случаи, когда было найдено пересечение с одним из объектов в коллекции.

Далее добавим в src/obj/mod.rs экспорт только что реализованных типов:

// src/obj/mod.rs

...

pub mod hittablelist;
pub mod sphere;

...

Теперь перепишем функцию color использовав реализованный функционал:

// src/obj/main.rs

fn color(ray: &Ray, world: &HittableList) -> Vec3 {
    match world.hit(ray, 0_f64, std::f64::MAX) {
        Some(hit) => 0.5_f64 * Vec3::new(hit.n.x() + 1_f64, hit.n.y() + 1_f64, hit.n.z() + 1_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)
        }
    }
}

Если обнаружено пересечение, то закрашиваем в соответствии с направлением нормали в точки пересечения, иначе закрашиваем традиционным бело-голубым градиентом.

// src/obj/main.rs

fn main {

...

            let world = HittableList {
                objects: vec![
                    Box::new(Sphere::new(Vec3::new(0_f64, 0_f64, -1_f64), 0.5)),
                    Box::new(Sphere::new(Vec3::new(0_f64, -100.5_f64, -1_f64), 100_f64)),
                ],
            };

            let col = color(&r, &world);
 
...

}

В функции main мы добавили на сцену две сферы, одну из прошлых примеров и другую заметно большую, которая будет выступать в роли “земли”.

twospheres

Приложения

Примечание

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

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

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

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

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

Трассировка лучей на Rust. Часть 2. Луч.

comments powered by Disqus