Ruxel — создание трассировщика лучей с помощью Rust Часть 1

Это сообщение — часть 1 из серии, в которой я рассказываю о своем пути в разработке Ruxel, простого трассировщика лучей и 3D-рендерера, написанного на языке Rust, с нуля.

Пожалуйста, ознакомьтесь с прелюдией серии для получения дополнительной информации о моих целях для v.0.1.0 этого проекта.

В идеале, к концу серии Ruxel сможет рендерить изображение, подобное тому, что в заголовке, или даже лучше…

Примечание: Глубокое объяснение математики 3D не является целью этих сообщений. Чтобы узнать больше о математике и теории, пожалуйста, ознакомьтесь с книгами, на которые я ссылаюсь в серии «Прелюдия».

Архитектура высокого уровня

Долгосрочная цель состоит в том, что Ruxel станет большим проектом, и, конечно, он пройдет через несколько фаз рефакторинга; однако, очень важно использовать систему модулей Rust с самого начала, чтобы сохранить хорошую организацию проекта и облегчить его сопровождение по мере его роста…

Следующая диаграмма представляет собой высокоуровневое представление архитектуры приложения, по крайней мере, для v.0.1.0:


Диаграмма архитектуры высокого уровня Ruxel — сделано с помощью https://mermaid.live.

В Части 1 и Части 2 этой серии мы сосредоточимся на создании следующего:

Часть 1:

  • Начальная структура проекта:
    • Cargo.toml
    • Дерево модулей
    • Тестирование модулей
  • Модуль геометрии:
    • Векторы
    • Структура
    • Трейты
    • Реализации
    • Точки
    • Структура
    • Черты
    • Реализации

Часть 2:

  • Модуль изображения:
    • Canvas
    • Структура
    • Черты
    • Реализации
    • Пиксель
    • Структура
    • Черты
    • Реализации
    • Цвета
    • Структура
    • Черты
    • Реализации
    • Изображение
    • Файл

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

Проектные леса

Для начала откройте терминал — в моем случае это комбо Alacritty + Tmux + Fish + Neovim — и начните новый проект Cargo, затем выполните несколько команд mkdir и touch, чтобы получить правильную структуру каталогов…

cargo new ruxel
mkdir src/geometry
mkdir src/picture

-- other commands
Войдите в полноэкранный режим Выход из полноэкранного режима

Если вы используете Neovim, вы можете выполнять команды оболочки в командной строке Neovim, добавляя ! перед командами оболочки, например: :!mkdir geometry или :!touch vector.rs.
{: .prompt-tip }

Начальная структура проекта выглядит следующим образом:

Rust/ruxel on  main [✘] > v0.0.0 | v1.63.0
 λ tree -L 4
.
├── Cargo.lock
├── Cargo.toml
├── images
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
└── src
    ├── geometry
    │   ├── vector
    │   │   └── tests.rs
    │   └── vector.rs
    ├── geometry.rs
    ├── main.rs
    ├── picture
    │   ├── canvas
    │   │   └── tests.rs
    │   ├── canvas.rs
    │   ├── colors
    │   │   └── tests.rs
    │   └── colors.rs
    └── picture.rs

8 directories, 16 files
Вход в полноэкранный режим Выход из полноэкранного режима

Эта начальная структура позволяет продолжать добавлять модули и соответствующие им модульные тесты структурированным образом.

Например, добавление модуля Matrix подразумевает создание нового каталога matrix, его исходного файла rust и соответствующих тестов:

  • /src/geometry/matrix/
  • /src/geometry/matrix.rs
  • /src/geometry/matrix/tests.rs

В результате получится следующее дерево:

Rust/ruxel on  main [✘] > v0.0.0 | v1.63.0
 λ tree -L 4
.
├── Cargo.lock
├── Cargo.toml
├── images
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
└── src
    ├── geometry
**  │   ├── matrix 
**  │   │   └── tests.rs
**  │   ├── matrix.rs
    │   ├── vector
    │   │   └── tests.rs
    │   └── vector.rs
    ├── geometry.rs
    ├── main.rs
    ├── picture
    │   ├── canvas
    │   │   └── tests.rs
    │   ├── canvas.rs
    │   ├── colors
    │   │   └── tests.rs
    │   └── colors.rs
    └── picture.rs

8 directories, 16 files
Вход в полноэкранный режим Выход из полноэкранного режима

Rust предлагает другие альтернативы для структурирования проектов, например, использование mod.rs; однако я нахожу вариант использования имени файла и каталога более понятным.

Важные диагностические атрибуты, которые нужно установить с самого начала:

/src/main.rs

#![warn(missing_docs, missing_debug_implementations)]
Вход в полноэкранный режим Выход из полноэкранного режима

Это обеспечит предупреждения линтера об отсутствии doc комментариев, используемых Rust для создания автоматической документации, и для обнаружения типов, не имеющих признака Debug, который обеспечивает удобные способы отображения значений типов во время разработки.

И, наконец, давайте убедимся, что все наши файлы являются частью дерева модулей Rust:

/src/main.r

/**
The geometry module implements the functionality for Points, Vectors, Matrices, and their transformations
*/
pub mod geometry;

/**
The picture module implements the functionality for Canvas and Colors in order to create an image file.
*/
pub mod picture;
Войти в полноэкранный режим Выход из полноэкранного режима

/src/geometry.rs

/// Provides data structures, methods and traits for Matrix4 computations.
pub mod matrix;
/// Data structures and methods for Vector3 and Point3 computations.
pub mod vector;
Войти в полноэкранный режим Выход из полноэкранного режима

/src/picture.rs

/// Provides the data structure and implementation of the Color type
pub mod colors;

/// Provides the data structure and implementation of the Canvas type
pub mod canvas;
Войти в полноэкранный режим Выход из полноэкранного режима

/src/geometry/vector.rs

// Unit tests for Vector3 and Point3
#[cfg(test)]
mod tests;
Войти в полноэкранный режим Выход из полноэкранного режима

/src/picture/canvas.rs

// Canvas Unit Tests
#[cfg(test)]
mod tests;
Войти в полноэкранный режим Выход из полноэкранного режима

/src/picture/colors.rs

// Colors Unit Tests
#[cfg(test)]
mod tests;
Войти в полноэкранный режим Выход из полноэкранного режима

Если все настроено правильно, rust-analyzer не должен выдавать предупреждение о том, что файл не является частью дерева модулей.

Теперь можно написать некоторые типы с их ассоциированными функциями и вынести их в область видимости main.rs с полной помощью rust-analyzer при завершении:

/src/main.rs

fn main() {
    let v = Vector3::one();
    let p = Point3::one();

    println!("Vector: {:?} n Point: {:?}", v, p);

    let c = ColorRgb::red();
    println!("Color: {}", c);
}
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь, когда начальные строительные леса готовы, давайте приступим к кодированию.

Векторы и точки

Модуль vector содержит типы Vector3 и Point3, которые математически определяются как:

Определения типов

Математическое определение может быть переведено в код Rust следующим образом:

/// Type representing a geometric 3D Vector with x, y, z components.
#[derive(Debug, Clone, Copy)]
pub struct Vector3<T> {
    /// Component on x axis
    pub x: T,
    /// Component on y axis
    pub y: T,
    /// Component on z axis
    pub z: T,
}

/// Type representing a geometric 3D Point with x, y, z components.  
#[derive(Debug, Clone, Copy)]
pub struct Point3<T> {
    /// Component on x axis
    pub x: T,
    /// Component on y axis
    pub y: T,
    /// Component on z axis
    pub z: T,
    /// Component representing the 'weight' 
    pub w: T,
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Оба типа:

  • Являются структурами named-field, чтобы иметь доступ к своим компонентам по имени (например: vector.x, point.z).
  • Иметь генеративные параметры типа в своих компонентах, чтобы иметь возможность реализовать их как f64 или i64, и т.д.
  • Реализовать общие черты Debug, Copy и Clone.
  • на данный момент являются публичными через pub.

Наиболее важным решением здесь является то, что оба типа реализуют общие черты Copy и Clone.

И главная причина в том, что все их компоненты, как ожидается, будут обычными числовыми типами, такими как i64, f64 и т.д., которые не владеют ресурсами heap.

Черты и реализация

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

  • Сосредоточиться на реализации Vector и Point для распространенных числовых типов f64 и для системы координат 3D. Так что никаких Vector2<f64> или Point3<i64>.
  • Используйте дженерики и трейты как можно больше, помня о будущей расширяемости при минимальном или полном рефакторинге. Например, включение Vector2 должно быть направлено на добавление функциональности без необходимости рефакторинга существующего кода.
  • Создайте публичный Enum с именем Axis, который определяет ось системы координат 2D, 3D или 4D.
  • Создайте публичный признак CoordInit, который определяет методы инициализации системы координат для любого типа, который его реализует:
    • Это обеспечивает расширяемость для других типов векторов и точек, таких как Vector2 или Point2, или в принципе для любого типа, который требует инициализации координат: вверх, вниз, назад, вперед и т.д.
  • Реализуйте функцию CoordInit для Vector3 и Point3.
  • Создайте публичный трейт VecOps, который определяет общие возможности исключительно для векторов, такие как magnitude, cross product, dot product и т.д.
  • Реализуйте ассоциированную функцию new(...) для каждого типа.
  • Реализовать возможности перегрузки операторов для удобного написания общих бинарных операций над Vector3 и Point3 (чтобы узнать больше об этой теме, вы можете ознакомиться с этой статьей Basic Operator Overloading with Traits):
    • Add, AddAssign
    • Sub, SubAssign
    • Mul и Div
    • Neg
  • Реализуйте следующие общие черты:

Перечислитель осей

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

/src/geometry/vector.rs

trait Init{
        fn new(...);
    }

impl Init for Vector2<T>{
        fn new(x: T, y: T) -> Self{...}
    }
impl Init for Vector3<T>{
        fn new(x: T, y: T, z: T) -> Self{...}
    }
impl Init for Point4<T>{
        fn new(x: T, y: T, z: T, w: T) -> Self{...}
    }

Войти в полноэкранный режим Выйти из полноэкранного режима

Существуют различные альтернативы, например, использование ассоциированных функций с синтаксисом turbofish, однако для данного проекта я предпочитаю использовать enumerator для инкапсуляции параметров метода:

/src/geometry/vector.rs

/**
Enumerator that encapsulates the different coordinate systems used to initialize a Vector or
Point
*/
#[derive(Debug)]
pub enum Axis<U> {
    /// Coordinate system with X and Y axis.
    XY(U, U),
    /// Coordinate system with X, Y and Z axis.
    XYZ(U, U, U),
    /// Coordinate system with X, Y, Z and W axis.
    XYZW(U, U, U, U),
}
Войти в полноэкранный режим Выход из полноэкранного режима

А затем привести его в область видимости с помощью упрощенного псевдонима:

/src/geometry/vector.rs

use geometry::vector::{
    Axis,
    Axis::{XY as xy, XYZ as xyz, XYZW as xyzw},
};

Ввести полноэкранный режим Выйти из полноэкранного режима

Конечный результат — возможность инициализировать новый тип, связанный с координатами, последовательным, ясным и сжатым образом, используя имя метода new(...):

/src/main.rs

    let vec3 = Vector3::new(xyz(1.0, 2.0, 3.0));
    let point3 = Point3::new(xyzw(1.0, 2.0, 3.0, 4.0));
Вход в полноэкранный режим Выход из полноэкранного режима

Это обеспечивает дополнительное преимущество расширяемости для новых типов, сохраняя при этом последовательный синтаксис инициализации.

Так, например, если в будущем для Vector2, Vector4, Point2 или любого другого связанного с координатами типа потребуется новый метод, мы будем использовать тот же синтаксис:

/src/main.rs

    let vec2 = Vector2::new(xy(1.0, 2.0));
    let vec3 = Vector3::new(xyz(1.0, 2.0, 3.0));
    let vec4 = Vector4::new(xyzw(1.0, 2.0, 3.0, 4.0));
    let point2 = Point2::new(xy(1.0, 2.0));
    let point3 = Point3::new(xyz(1.0, 2.0, 3.0));
    let point4 = Point4::new(xyzw(1.0, 2.0, 3.0, 4.0));
Войти в полноэкранный режим Выйти из полноэкранного режима

В следующем разделе, где определен признак CoordInit, показано, как использовать перечислитель Axis в функции признака.

Тит CoordInit

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

В случае типов, связанных с координатами, наиболее распространенными инициализациями являются: вверх, вниз, влево, вправо, вперед, назад, единица и ноль.

Также может быть удобно иметь метод self.equal() для сравнения одного типа с другим.

В Rust этот признак можно определить следующим образом:

/src/geometry/vector.rs

/// Trait allows Types with coordinates (x, y, etc.) to be efficiently initialized with common shorthand.
pub trait CoordInit<T, U> {
    /// Return a type with shorthand, for example [0, 0, -1].
    fn back() -> T;
    /// Return a type with shorthand, for example  [0, -1, 0].
    fn down() -> T;
    /// Return true if a type is identical to another, else return false.
    fn equal(self, rhs: Self) -> bool;
    /// Return a type with shorthand, for example  [0, 0, 1].
    fn forward() -> T;
    /// Return a type with shorthand, for example  [-1, 0, 0].
    fn left() -> T;
    /// Return a type with user-defined Axis components.
    fn new(axis: Axis<U>) -> T;
    /// Return a type with shorthand, for example  [1, 1, 1].
    fn one() -> T;
    /// Return a type with shorthand [1, 0, 0].
    fn right() -> T;
    /// Return a type with shorthand [0, 1, 0].
    fn up() -> T;
    /// Return a type with shorthand [0, 0, 0].
    fn zero() -> T;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Признак определяется с помощью двух общих компонентов <T, U>.

T представляет типы, связанные с координатами (Vector, Points, и т.д.), которые будут инициализированы, и которые будут возвращены почти из всех функций.

U представляет общий тип (f64), который будет использовать Axis enum для инициализации типов, связанных с координатами.

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

Как определено выше, для v.0.1.0 основное внимание уделяется только поддержке типов в трех измерениях и с плавающей точкой (f64). Тем не менее, уже создана основа для расширения на другие измерения и примитивные типы данных.

Для получения полной информации о реализации вы можете посетить репозиторий Ruxel на GitHub.

Здесь я объясню лишь пару моментов в его реализации для Vector3<f64>:

  • fn new(axis: Axis<U>) -> T
  • fn zero()

Rust-код выглядит следующим образом:

/src/geometry/vector.rs

impl CoordInit<Vector3<f64>, f64> for Vector3<f64> {

    // other type-associated functions

    // new()
    fn new(axis: Axis<f64>) -> Vector3<f64> {
        match axis {
            Axis::XY(x, y) => Vector3 { x, y, z: 0.0 },
            Axis::XYZ(x, y, z) => Vector3 { x, y, z },
            Axis::XYZW(x, y, z, _w) => Vector3 { x, y, z },
        }
    }

    fn zero() -> Self {
        Vector3 {
            x: 0.0,
            y: 0.0,
            z: 0.0,
        }
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Первая строка определяет реализацию признака CoordInit для типа Vector3, использующего f64 в своих компонентах.

Функция fn new(...) получает перечислитель Axis<f64>, выполняет match по поддерживаемым системам координат (XYZ и т.д.) и возвращает Vector, инициализированный значениями, заданными пользователем или sane.

В данном случае нормальным значением является возврат Vector3, даже если пользователь вводит 2D систему координат.

Функция fn zero() возвращает Vector3 со всеми его компонентами со значением 0.0.

Перегрузка операторов

Для реализации возможностей перегрузки операторов необходимо ввести в область видимости модуль std::ops и реализовать нужные черты в каждом из типов.

/src/geometry/vector.rs

// Bring overflow operator's traits into scope
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}
Вход в полноэкранный режим Выход из полноэкранного режима

Бинарные операции между векторами и точками должны следовать некоторой математической логике, представленной в этой таблице:

Операция LHS RHS Результат
Добавить V P P
Добавить P V P
Добавить V V V
Добавить P P N/A
Sub V P N/A
Sub P V P
Sub V V V
Sub P P V
Мул P Скаляр N/A
Mul V Скаляр V
Mul V P N/A
Div P Скаляр N/A
Div V Скаляр V
Div V P Н/А
Отрицательный V N/A -V
Нег P N/A N/A

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

Например, для поддержки операции Add между Vector3 и Point3 необходимы три функции реализации:

impl Add<Point3<f64>> for Vector3<f64> {
    type Output = Point3<f64>;

    fn add(self, rhs: Point3<f64>) -> Point3<f64> {
        Point3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: rhs.w,
        }
    }
}

impl Add<Vector3<f64>> for Point3<f64> {
    type Output = Point3<f64>;

    fn add(self, rhs: Vector3<f64>) -> Point3<f64> {
        Point3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: self.w,
        }
    }
}

impl Add for Vector3<f64> {
    type Output = Vector3<f64>;

    fn add(self, rhs: Self) -> Vector3<f64> {
        Vector3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Проверьте репозиторий GitHub для полной реализации всех операторов.

Когда все бинарные операторы перегружены, можно «выстраивать цепочки» операций Vector и Point, как с любым обычным примитивом, что чрезвычайно полезно и удобно:

src/main.rs

    let v1 = Vector3::new(xyz(2.0, 3.5, 4.0));
    let v2 = Vector3::new(xyz(3.0, 7.5, 8.0));
    let v3 = Vector3::new(xyz(2.55555, 7.88888, 9.34343));
    let v4 = Vector3::new(xyz(2.55553, 7.88887, 9.34342));
    let p1 = Point3::new(xyz(2.5, 3.5, 4.5));
    let p2 = Point3::new(xyz(3.0, 7.0, 8.0));
    let p3 = Point3::new(xyz(2.55555, 7.88888, 9.34343));
    let p4 = Point3::new(xyz(2.55553, 7.88887, 9.34342));


    println!("{:?}", v1 + v4 - v1 - v3 + (v2 - v4) / 1.522445523);
    println!("{:?}", v3 + p4 + v1);
    println!("{:?}", p1 - p2 / 3.7626374);
    println!("{:?}", p2 - v1);
    println!("{:?}", v2 + v1);
Вход в полноэкранный режим Выход из полноэкранного режима

Общие черты

Руководство по API Rust предлагает обширное объяснение рекомендаций по разработке API для языка с целью создания идиоматического кода.

Одна из ключевых задач этого проекта — максимально придерживаться этих стандартов.

По мере развития проекта, более общие черты, вероятно, будут реализованы по мере необходимости, и только если это имеет смысл.
{: .prompt-info }

Для начала, это общие черты, которые реализованы на данном этапе для типов Vector3 и Point3:

Тип Общий признак
Вектор3 Eq, PartialEq, Display, Debug, Clone, Copy, Default
Точка3 Eq, PartialEq, Display, Debug, Clone, Copy, Default

Реализации по умолчанию могут быть произведены путем украшения определений структур атрибутами #[derive(Debug, Copy,...)].

Однако только в отдельных случаях Display и Default будет написана ручная реализация для изменения поведения default:

Реализация по умолчанию через derive:

src/geometry/vector.rs

#[derive(Debug)]
pub enum Axis<U>

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct Vector3<T> 

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct Point3<T> 
Войти в полноэкранный режим Выход из полноэкранного режима

А теперь реализация manual:

src/geometry/vector.rs

impl Display for Vector3<f64> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = format!("v: [{:^5.2},{:^5.2},{:^5.2}]", self.x, self.y, self.z);
        f.write_str(&s)
    }
}

impl Display for Point3<f64> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = format!(
            "p: [{:^5.2},{:^5.2},{:^5.2},{:^5.2}]",
            self.x, self.y, self.z, self.w
        );
        f.write_str(&s)
    }
}

impl Default for Point3<f64> {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
        }
    }
}

impl Default for Vector3<f64> {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            z: 0.0,
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Даже когда Default для Vector3 реализует умолчания Rust о заполнении значения 0.0 для f64, удобно иметь под рукой ручную реализацию для целей тестирования.

Общие векторные операции

Последние важные реализации, которые необходимы для типа Vector, касаются их общих математических операций:

  • Вычислить величину
  • Нормализовать вектор
  • Вычислить точечное произведение
  • Вычислить кросс-продукт
  • Получить минимальный компонент в векторе
  • Получить максимальную компоненту вектора
  • Возвращать компоненты вектора по имени и индексу.

Эти возможности могут быть определены с помощью публичного трейта VecOps<T>, с общим параметром, чтобы расширить его реализацию для типов, отличных от Vector3<f64>:

src/geometry/vector.rs

/// A trait that encapsulates common Vector Operations.
pub trait VecOps<T> {
    /// Computes the magnitude of a Vector.
    fn magnitude(&self) -> f64;
    /// Returns the vector normalized (with magnitude of 1.0)
    fn normalized(&mut self) -> Self;
    /// Returns the Dot Product of two Vectors.
    fn dot(lhs: T, rhs: T) -> f64;
    /// Returns the Cross Product of two Vectors.
    fn cross(lhs: T, rhs: T) -> T;
    /// Returns the Smallest component in the Vector.
    fn min_component(&self) -> (i8, char, f64);
    /// Returns the Largest component in the Vector.
    fn max_component(&self) -> (i8, char, f64);
    /// Returns the component of the Vector by index. this(1)
    fn this(&self, index: i8) -> Option<(i8, char, f64)>;
    /// Returns the component of the Vector by name. this_n('x')
    fn this_name(&self, index: char) -> Option<(i8, char, f64)>;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как и в случае с трейтом CoordInit, реализация должна быть определена для каждой функции внутри блока impl для Vector3<f64>:

impl VecOps<Vector3<f64>> for Vector3<f64> {
    fn magnitude(&self) -> f64 {
        (self.x.powf(2.0) + self.y.powf(2.0) + self.z.powf(2.0)).sqrt()
    }

    fn normalized(&mut self) -> Self {
        let magnitude = self.magnitude();
        Self {
            x: self.x / magnitude,
            y: self.y / magnitude,
            z: self.z / magnitude,
        }
    }

    // other functions in the VecOps<T> trait
Вход в полноэкранный режим Выход из полноэкранного режима

Модульные тесты

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

Как было описано в начале, тесты для каждого из модулей реализованы в test.rs в каталоге модуля.

В случае Vector3 и Point3, файл находится в src/geometry/vector/tests.rs.

Есть 5 тестов, которые будут выполнены для проверки наших типов:

  1. целостность построения вектора и точки
  2. целостность перегрузки операторов вектора и точки
  3. целостность общих операций вектора
  4. Симулятор запуска ракеты, основанный на книге The Ray Tracer Challenge1

Первый шаг — ввести в область видимости модуль vector и использовать alias для перечислителя Axis:

src/geometry/vector/tests.rs

/// Unit testing for Vector3 and Point3 types
use super::*;

use super::Axis::XYZ as xyz;

Войти в полноэкранный режим Выход из полноэкранного режима

Затем мы определим функции тестов:


#[test]
// This test validates the construction of the Vector3 and Point3 types
fn vector_and_point_construction_integrity() {}

#[test]
// This test validates the operation overloading Add, Sub, Div, Equality, Mul, Neg, AddAssign, SubAssign  for the Vector3 and Point3
fn vector_and_point_operator_overloading_integrity() {}

#[test]
// This test validates the implementation of the fuctions in the VecOps trait
fn vector_common_operations_integrity() {} 

#[test]
// This test validates integrity by simulating a rocket launch
fn simulate_rocket_lauch() {}
Вход в полноэкранный режим Выход из полноэкранного режима

Каждый тест представляет собой набор макросов assert!() и assert_eq!(), а также утверждений println!().

Файл тестирования довольно обширный, поэтому в этом посте я покажу только код для тестов vector_common_operations_integrity() и ‘simulate_rocket_lauch()’:

src/geometry/vector/tests.rs

#[test]
// This test validates the implementation of the fuctions in the VecOps trait
fn vector_common_operations_integrity() {
    // Magnitude
    let v1 = Vector3::new(xyz(1.0, 2.0, 3.0));
    assert_eq!(v1.magnitude(), 14f64.sqrt());
    // Normalization
    let mut v2 = v1;
    assert_eq!(v2.normalized().magnitude(), 1f64);
    // Dot product
    let a = Vector3::new(xyz(1.0, 2.0, 3.0));
    let b = Vector3::new(xyz(2.0, 3.0, 4.0));
    assert_eq!(Vector3::dot(a, b), 20f64);
    // Cross product
    assert_eq!(Vector3::cross(a, b), Vector3::new(xyz(-1.0, 2.0, -1.0)));
    assert_eq!(Vector3::cross(b, a), Vector3::new(xyz(1.0, -2.0, 1.0)));
    // Min, Max and Get Components
    assert_eq!(a.min_component(), (0, 'x', 1.0));
    assert_eq!(a.max_component(), (2, 'z', 3.0));
    assert_eq!(a.this(0).unwrap(), (0, 'x', 1.0));
    assert_eq!(a.this(9), None);
    assert_eq!(b.this(b.min_component().0).unwrap(), (0, 'x', 2.0));
    assert_eq!(a.this_name('z').unwrap(), (2, 'z', 3.0));
}
Войти в полноэкранный режим Выход из полноэкранного режима

Запуск теста с помощью cargo test vector_common_operations_integrity дает положительный результат:

 λ cargo test vector_common_operations_integrity
running 1 test
test geometry::vector::tests::vector_common_operations_integrity ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s
Вход в полноэкранный режим Выход из полноэкранного режима

А теперь симуляция запуска ракеты, которая объединяет все в одном тесте. Для проверки тест выведет координаты ракеты на основе начальных условий запуска и окружающей среды (гравитация и ветер).

Ожидается, что мы увидим данные параболического запуска:

#[test]
// This test validates integrity by simulating a rocket launch
fn simulate_rocket_lauch() {
    #[derive(Debug)]
    struct Projectile {
        position: Point3<f64>,
        velocity: Vector3<f64>,
    }

    struct Environment {
        gravity: Vector3<f64>,
        wind: Vector3<f64>,
    }

    let mut proj = Projectile {
        position: Point3::up(),
        velocity: Vector3::new(xyz(1.0, 1.0, 0.0)).normalized(),
    };

    let env = Environment {
        gravity: Vector3::down() / 10f64,
        wind: Vector3::left() / 100f64,
    };

    fn tick<'a, 'b>(env: &'a Environment, proj: &'b mut Projectile) -> &'b mut Projectile {
        proj.position = proj.position + proj.velocity;
        proj.velocity = proj.velocity + env.gravity + env.wind;
        proj
    }

    println!(
        "Launch position: - x: {:^5.2}, y: {:^5.2}, z: {:^5.2}",
        proj.position.x, proj.position.y, proj.position.z
    );
    while proj.position.y > 0.0 {
        tick(&env, &mut proj);
        if proj.position.y <= 0.0 {
            break;
        }
        println!(
            "Projectile position - x: {:^5.2}, y: {:^5.2}, z: {:^5.2}",
            proj.position.x, proj.position.y, proj.position.z
        );
    }
    println!("========================== End");
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Для просмотра результатов println!() необходимо запустить cargo test с аргументом -- --nocapture:

Rust/ruxel on  main [!] > v0.0.0 | v1.63.0
 λ cargo test simulate_rocket_lauch  -- --nocapture

     Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/main.rs (target/debug/deps/ruxel-6b30efdff903fb79)

running 1 test
Launch position: - x: 0.00 , y: 1.00 , z: 0.00
Projectile position - x: 0.71 , y: 1.71 , z: 0.00
Projectile position - x: 1.40 , y: 2.31 , z: 0.00
Projectile position - x: 2.09 , y: 2.82 , z: 0.00
Projectile position - x: 2.77 , y: 3.23 , z: 0.00
Projectile position - x: 3.44 , y: 3.54 , z: 0.00
Projectile position - x: 4.09 , y: 3.74 , z: 0.00
Projectile position - x: 4.74 , y: 3.85 , z: 0.00
Projectile position - x: 5.38 , y: 3.86 , z: 0.00
Projectile position - x: 6.00 , y: 3.76 , z: 0.00
Projectile position - x: 6.62 , y: 3.57 , z: 0.00
Projectile position - x: 7.23 , y: 3.28 , z: 0.00
Projectile position - x: 7.83 , y: 2.89 , z: 0.00
Projectile position - x: 8.41 , y: 2.39 , z: 0.00
Projectile position - x: 8.99 , y: 1.80 , z: 0.00
Projectile position - x: 9.56 , y: 1.11 , z: 0.00
Projectile position - x: 10.11, y: 0.31 , z: 0.00
========================== End
test geometry::vector::tests::simulate_rocket_lauch ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s
Вход в полноэкранный режим Выйти из полноэкранного режима

Как и ожидалось, тест прошел. Это означает, что наши реализации Vector3 и Point3 были успешными.

На этом мы завершаем первую часть этой серии!

Следующие шаги

Во второй части мы сосредоточимся на:

  • Созданию типов Color, Pixel и Canvas.
  • Определение и реализация их трейтов.
  • Написание и выполнение их блочных тестов.
  • И, наконец, создание первого изображения.

Этот пост был первоначально опубликован на:
https://rsdlt.github.io/posts/ruxel-part-1-rust-ray-tracer-renderer-3d-development/


Ссылки, упоминания и отказ от ответственности:

Фотография заголовка Rohit Choudhari на Unsplash

Книги:


  1. Бак, Джамис. (2019). The ray tracer challenge: Руководство по созданию вашего первого 3D-рендерера на основе тестов. The Pragmatic Programmers. 

Оцените статью
devanswers.ru
Добавить комментарий