Напоминаю также, что поскольку я не являюсь профессионалом ни в Rust ни в 3D-графике, а изучаю эти вещи прямо по ходу написания статьи, то в ней могут быть грубые ошибки и упущения, которые я, впрочем, рад исправить, если мне на них укажут в комментариях.
Машинка, которую мы получим в конце статьи
Приводим линию в порядок
Что ж, давайте начнем с того, чтобы переписать нашу кошмарную handmade-функцию line на нормальную реализацию алгоритма Брезенхэма из статьи haqreu. Во-первых она более быстрая, во-вторых более каноничная, в-третьих мы сможем сравнить код на Rust с кодом на C++.
pub fn line(&mut self, mut x0: i32, mut y0: i32, mut x1: i32, mut y1: i32, color: u32) {
let mut steep = false;
if (x0-x1).abs() < (y0-y1).abs() {
mem::swap(&mut x0, &mut y0);
mem::swap(&mut x1, &mut y1);
steep = true;
}
if x0>x1 {
mem::swap(&mut x0, &mut x1);
mem::swap(&mut y0, &mut y1);
}
let dx = x1-x0;
let dy = y1-y0;
let derror2 = dy.abs()*2;
let mut error2 = 0;
let mut y = y0;
for x in x0..x1+1 {
if steep {
self.set(y, x, color);
} else {
self.set(x, y, color);
}
error2 += derror2;
if error2 > dx {
y += if y1>y0 { 1 } else { -1 };
error2 -= dx*2;
}
}
}
Как видите отличия минимальны, а количество строк относительно оригинала осталось без изменений. Никаких особых затруднений на этом этапе не возникло.
Делаем тест
После того, как с реализацией линии было покончено, я решил не удалять сослуживший мне столь хорошую службу в деле тестирования код, который рисовал 3 наших тестовых линии:
let mut canvas = canvas::Canvas::new(100, 100);
canvas.line(13, 20, 80, 40, WHITE);
canvas.line(20, 13, 40, 80, RED);
canvas.line(80, 40, 13, 20, BLUE);
Уж не знаю, какой опыт у автора оригинальной статьи, но оказывается как раз эти 3 вызова неплохо прокрывают почти весь спектр ошибок, которые можно допустить при реализации линии. И которые я, конечно же, допускал.
Вынос кода в неиспользуемую функцию заставит Rust выдавать warning при каждой компиляции (компилятор ругается на каждую неиспользуемую функцию, или переменную). Конечно, warning можно и подавить, дав функции имя, начинающееся с нижнего прочерка
_test_line()
, но это как-то плохо пахнет. А хранить потенциально полезный но сейчас ненужный код в комментариях вообще, на мой взгляд, дурной тон программирования. Гораздо более разумное решение — создать тест! Так что, за информацией обращаемся к соответствующей статье про функциональность тестирования в Rust, чтобы сделать свой первый тест на этом языке.Это делается элементарно. Достаточно написать
#[test]
строчкой выше сигнатуры функции. Это превращает ее в тест. На такие функции Rust не выводит warning'ов как на неиспользуемые, а запуск cargo test
приводит к тому, что Cargo выводит нам статистику по прогону всех таких функций в проекте: Running target/debug/rust_project-2d87cd565073580b
running 1 test
test test_line ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
Что интересно он также выводит warning'и по всем неиспользуемым функциям и переменным исходя из того, что входная точка проекта — функции, помеченные как тест. В перспективе это помогает определить покрытие тестами функций проекта. Понятное дело, что пока наш тест толком ничего не тестирует, потому-что окошко с результатами рисования просто появляется и сразу исчезает. По-хорошему должен быть mock-объект, заменяющий наш Canvas, который позволяет проверить последовательность вызовов функции
set(x, y, color);
на соответствие заданному. Тогда это будет автоматический юнит-тест. Пока же мы просто поигрались с соответствующей функциональностью компилятора. Вот снимок репозитория после этих изменений.Векторы и чтение файлов
Что ж, самое время приступить к реализации проволочного рендера. Первое препятствие на этом пути — нам понадобится читать файл модели (который хранится в формате «Wavefront .obj file»). haqreu в своей статье дает готовый парсер для своих студентов, который при работе использует классы 2-хмерного и 3-хмерного векторов, также представленные haqreu. Поскольку его реализация на C++, нам все это надо будет переписать на Rust. Начнем, естественно с векторов. Вот отрывок кода оригинального вектора (двухмерный вариант):
template <class t> struct Vec2 {
union {
struct {t u, v;};
struct {t x, y;};
t raw[2];
};
Vec2() : u(0), v(0) {}
Vec2(t _u, t _v) : u(_u),v(_v) {}
inline Vec2<t> operator +(const Vec2<t> &V) const { return Vec2<t>(u+V.u, v+V.v); }
inline Vec2<t> operator -(const Vec2<t> &V) const { return Vec2<t>(u-V.u, v-V.v); }
inline Vec2<t> operator *(float f) const { return Vec2<t>(u*f, v*f); }
template <class > friend std::ostream& operator<<(std::ostream& s, Vec2<t>& v);
};
В реализации векторов на C++ используются шаблоны. В Rust их аналогом выступают обобщенные типы (Generics), про которое можно почитать соответствующую статью, а также посмотреть примеры их использования на сайте rustbyexample.com. Вообще этот сайт является очень полезным ресурсом при изучении Rust. На каждую возможность языка там есть пример использования с подробными комментариями и возможностью редактировать и запускать примеры прямо в окне браузера (код исполняется на удаленном сервере).
Когда я попытался сделать конструктор, не принимающий аргументов, а создающий нулевой вектор (0, 0), я столкнулся с еще одной проблемой. Насколько я понял систему типов раста, такой создать нельзя, потому-что мы не сможем инициализировать структуру значениями по умолчанию из-за отсутствия неявного приведения типов. Подобную функциональность можно реализовать через типажи (Traits), но для этого придется писать немало кода или использовать стандартный типаж
std::num::Zero
, который является unstable. Оба варианта мне не понравились, поэтому я решил, что проще писать new(0, 0)
в коде.На разборки с обобщенными типами, типажами и перегрузкой операторов ушло несколько часов. Когда я понял, что для реализации аналога оригинальных классов векторов мне понадобится еще вникать, как делать перегрузку операторов (которая сама устроена при помощи типажей) для обобщенного типа, я решил зайти с другого бока. Похоже то, что в C++ делается несколькими строчками кода и, в Rust порой реализуется в разы более сложным и длинным кодом. Возможно это из-за того, что я пытаюсь дословно перевести C++-код на Rust, вместо того, чтобы осмыслить алгоритм и написать его аналог на языке с существенно другой идеологией. В общем я остановился на том, чтобы сделать свой вектор с только теми возможностями, которые, насколько я могу судить, точно мне понадобятся для хранения информации из файла модели согласно моим собственным суждениям об этом. Получился вот такой вот нехитрый класс, которого вполне достаточно на текущем этапе задачи:
pub struct Vector3D {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vector3D {
pub fn new(x: f32, y: f32, z: f32) -> Vector3D {
Vector3D {
x: x,
y: y,
z: z,
}
}
}
impl fmt::Display for Vector3D {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({},{},{})", self.x, self.y, self.z)
}
}
Теперь можно взяться за парсер, но работу с файлами в Rust мы еще не изучали. Тут на выручку пришел StackOverflow, где был ответ с простым для понимания примером кода. На основе него был получен следующий код:
pub struct Model {
pub vertices: Vec<Vector3D>,
pub faces : Vec<[i32; 3]>,
}
impl Model {
pub fn new(file_path: &str) -> Model {
let path = Path::new(file_path);
let file = BufReader::new(File::open(&path).unwrap());
let mut vertices = Vec::new();
let mut faces = Vec::new();
for line in file.lines() {
let line = line.unwrap();
if line.starts_with("v ") {
let words: Vec<&str> = line.split_whitespace().collect();
vertices.push(Vector3D::new(words[1].parse().unwrap(),
words[2].parse().unwrap(),
words[3].parse().unwrap()));
debug!("readed vertex: {}", vertices.last().unwrap());
} else if line.starts_with("f ") {
let mut face: [i32; 3] = [-1, -1, -1];
let words: Vec<&str> = line.split_whitespace().collect();
for i in 0..3 {
face[i] = words[i+1].split("/").next().unwrap().parse().unwrap();
face[i] -= 1;
debug!("face[{}] = {}", i, face[i]);
}
faces.push(face);
}
}
Model {
vertices: vertices,
faces: faces,
}
}
}
Особых сложностей с ним не было. Просто чтение файла и обработка строк. Разве что только поиск информации, как сделать ту или иную штуку в расте осложняется тем, что
from_str()
.Поначалу я допустил в этом коде ошибку, забыв написать строчку
faces.push(face);
и долго не мог понять, почему мой рендер даже и не входит в цикл, пробегающий по всем faces. Только после того, как я методом тыка выяснил, в чем проблема, я обнаружил интересную строчку в выводе компилятора warning: variable does not need to be mutable, #[warn(unused_mut)] on by default
относительно строчки объявления переменной face. А не заметил я этого warning'а потому, что у меня была еще пачка предупреждений относительно неиспользуемых переменных, так что я забил просматривать их. После этого я закомментировал все неиспользуемые переменные, так что теперь любой warning бросится в глаза. В Rust предупреждения компилятора весьма полезны в поиске ошибок и не стоит ими пренебрегать.Стоит также отметить, что код выглядит достаточно простым и понятным в отличии от оригинала на C++. Примерно также он мог бы быть написан на каком-нибудь Python или Java. Интересно еще, насколько он производителен по сравнению с оригинальным. Планирую сделать замеры производительности, когда весь рендер от начала до конца будет готов.
Проволочный рендер
Наконец, вот он проволочный рендер. Большая часть работы была сделана на предыдущих этапах, так что код простейший:
fn main() {
env_logger::init().unwrap();
info!("starting up");
let model = Model::new("african_head.obj");
let mut canvas = canvas::Canvas::new(WIDTH, HEIGHT);
debug!("drawing wireframe");
for face in model.faces {
debug!("processing face:");
debug!("({}, {}, {})", face[0], face[1], face[2]);
for j in 0..3 {
let v0 = &model.vertices[face[j] as usize];
let v1 = &model.vertices[face[(j+1)%3] as usize];
let x0 = ((v0.x+1.)*WIDTH as f32/2.) as i32;
let y0 = ((v0.y+1.)*HEIGHT as f32/2.) as i32;
let x1 = ((v1.x+1.)*WIDTH as f32/2.) as i32;
let y1 = ((v1.y+1.)*HEIGHT as f32/2.) as i32;
debug!("drawing line ({}, {}) - ({}, {})", x0, y0, x1, y1);
canvas.line(x0, y0, x1, y1, WHITE);
}
}
info!("waiting for ESC");
canvas.wait_for_esc();
}
Если не считать мелких отличий в синтаксисе, то от C++ он отличается главным образом большим количеством преобразований типов. Ну и логгированием, которое я везде понатыкал, когда искал ошибки. Вот, какую картинку мы получаем в итоге (снапшот кода в репозитории):
Это уже довольно неплохо, но во-первых если скормить моей программе в ее текущем виде модель машинки, которую я планирую нарисовать, она ее просто не покажет. Во-вторых рисуются все эти красоты жутко долго (запустил программу и можно идти пить кофе). Первая проблема из-за того, что в модели машинки вершины записаны совсем в других масштабах. Код выше подогнан под масштабы модели головы. Чтобы он стал универсальным с ним еще надо поработать. Вторая проблема пока не знаю из-за чего, но если подумать, то варианта всего 2: или используется неэффективный алгоритм, или написана на данном конкретном стеке технологий неэффективная реализация этого алгоритма. В любом случае возникнет еще вопрос, какой конкретно кусок алгоритма (реализации) неэффективен.
В общем, как вы уже поняли, я решил начать с вопроса скорости.
Меряемся производительностью
Поскольку у меня все равно в планах было сравнение производительности оригинального проекта и моей реализации на Rust, я решил просто сделать это пораньше. Однако принцип работы оригинала и моей реализации существенно отличаются. Оригинал рисует во временном буфере и только под конец записывает TGA-файл, в то время как мое приложение выполняет команды отрисовки SDL прямо по ходу обработки треугольников.
Решение простое — переделать наш Canvas, чтобы метод рисования точки
set(x, y, color)
только лишь сохранял данные во внутренний массив, а непосредственно рисование средствами SDL уже выполнялось в конце работы программы, после отработки всех вычислений. Этим мы убиваем 3-х зайцев: - Получаем возможность сравнить скорость реализаций до отрисовки/сохранения в файл, т. е. там где они по сути еще делают идентичные вещи.
- Получаем заготовки на будущее для двойной буферизации.
- Отделяем свои вычисления от рисования, что позволяет нам оценить оверхэд, накладываемый вызовами SDL.
По-быстрому переписав Canvas, я увидел, что сам расчет линий происходил очень быстро. А вот отрисовка при помощи SDL выполнялась с черепашьей скоростью. Тут есть простор для оптимизации. Оказалось, что функция рисования точки в Rust-SDL2 отнюдь не была такой быстрой, как я ожидал. Проблему удалось решить при помощи сохранения всего изображения в текстуру и последующего вывода этой текстуры вот таким вот кодом:
pub fn show(&mut self) {
let mut texture = self.renderer.create_texture_streaming(PixelFormatEnum::RGB24,
(self.xsize as u32, self.ysize as u32)).unwrap();
texture.with_lock(None, |buffer: &mut [u8], pitch: usize| {
for y in (0..self.ysize) {
for x in (0..self.xsize) {
let offset = y*pitch + x*3;
let color = self.canvas[x][self.ysize - y - 1];
buffer[offset + 0] = (color >> (8*2)) as u8;
buffer[offset + 1] = (color >> (8*1)) as u8;
buffer[offset + 2] = color as u8;
}
}
}).unwrap();
self.renderer.clear();
self.renderer.copy(&texture, None, Some(Rect::new_unwrap(0, 0,
self.xsize as u32, self.ysize as u32)));
self.renderer.present();
}
Вообще в переписывании Canvas не возникло ничего нового с точки зрения программирования на Rust, так что рассказывать особо не о чем. Код на этом этапе в соответствующем снимке репозитория. После этих изменений программа стала летать. Прорисовка занимала доли секунды. Тут уже интерес к тому, чтобы померяться производительностью исчез. Поскольку выполнение программы занимало очень мало времени, простая погрешность измерений из-за случайных процессов в ОС могла увеличить это время в 2 раза или же наоборот уменьшить его. Чтобы как-то с этим побороться заключил основное тело программы (чтение .obj-файла и вычисление двумерной проекции) в цикл, который выполнялся 100 раз. Теперь можно было что-то мерить. То же самое сделал и с C++ реализацией от haqreu.
Собственно вот цифры Rust-реализации:
cepreu@cepreu-P5K:~/Загрузки/rust-3d-renderer-70de52d8e8c82854c460a41d1b8d8decb0c2e5c1$ time ./rust_project
real 0m0.769s
user 0m0.630s
sys 0m0.137s
А вот цифры реализации на C++:
cepreu@cepreu-P5K:~/Загрузки/tinyrenderer-f6fecb7ad493264ecd15e230411bfb1cca539a12$ time ./a.out
real 0m1.492s
user 0m1.483s
sys 0m0.008s
Каждую из программ я запускал 10 раз, а потом выбирал лучшее время (real). Его я вам и привел. В свою реализацию я внес модификации, чтобы выпилить все упоминания SDL, чтобы внешние обращения не влияли на результирующее время. Собственно можете увидеть в снимке репозитория.
Вот модификации, которые я внес в C++-реализацию:
int main(int argc, char** argv) {
for (int cycle=0; cycle<100; cycle++){
if (2==argc) {
model = new Model(argv[1]);
} else {
model = new Model("obj/african_head.obj");
}
TGAImage image(width, height, TGAImage::RGB);
for (int i=0; i<model->nfaces(); i++) {
std::vector<int> face = model->face(i);
for (int j=0; j<3; j++) {
Vec3f v0 = model->vert(face[j]);
Vec3f v1 = model->vert(face[(j+1)%3]);
int x0 = (v0.x+1.)*width/2.;
int y0 = (v0.y+1.)*height/2.;
int x1 = (v1.x+1.)*width/2.;
int y1 = (v1.y+1.)*height/2.;
line(x0, y0, x1, y1, image, white);
}
}
delete model;
}
//image.flip_vertically(); // i want to have the origin at the left bottom corner of the image
//image.write_tga_file("output.tga");
return 0;
}
Ну и еще удалил отладочную печать в model.cpp. Вообще, конечно, результат меня удивил. Мне казалось, что компилятор Rust еще не должен быть так же хорошо оптимизирован как gcc, а я по незнанию наверняка нагородил неоптимального кода… Я как-то даже и не понимаю толком, почему это мой код оказался быстрее. Или это Rust такой супербыстрый. Или в C++-реализации что-то неоптимально. В общем желающие это обсудить — добро пожаловать в комментарии.
Итоги
Наконец путем нехитрой подгонки коэффициентов (смотрите снимок репозитория) я получил картинку с машиной, оптимально занимающую пространство окна. Ее вы и наблюдали в начале статьи.
Немного впечатлений:
- Писать на Rust становится все проще. Первые дни были непрестанной борьбой с компилятором. Сейчас же я просто сажусь и пишу код, время от времени подсматривая в интернете, как сделать ту или иную штуку. Вобщем по большей части язык уже воспринимается знакомым. Как видите, это не заняло много времени.
- По-прежнему радуют warning'и раста. То, что в других языках подсказывет только очень продвинутая IDE (типа IntelliJ IDEA в Java), в Rust говорит сам компилятор. Помогает поддерживать хороший стиль, бережет от ошибок.
- То, что Rust оказался быстрее — шок. Видимо компилятор уже далеко не такой сырой, как я думал.
Заключительная — 3-я часть цикла: Пишем свой упрощенный OpenGL на Rust — часть 3 (растеризатор)
Комментарии (19)
Xlab
09.07.2015 18:59-1Разве что только поиск информации, как сделать ту или иную штуку в расте осложняется тем, что язык быстро меняется. Подчас находишь какие-то ответы, пробуешь их, а они не работают, потому-что оказывается буквально несколько недель назад в 1.1 этот метод переименовали и т. п. Столкнулся с этим на примере метода from_str(), который удалили из Rust 1.1.
Очень мило.stepik777
09.07.2015 19:34+1Как написал автор, в статье могут быть грубые ошибки и упущения.
Если сидеть на стабильном канале, то вам ничего не уберут и не переименуют в следующих версиях. Здесь, видимо, автор нашёл примеры, написанные на Rust версии < 1.0, то есть ещё до стабильного релиза. Действительно сейчас на Stack Overflow и других сайтах достаточно много примеров, написанных на устаревших версиях языка.
Googolplex
09.07.2015 19:51from_str() удалили не из Rust 1.1, его нет уже давным-давно. Стандартным способом сконвертировать строку во что-то как минимум полгода является метод parse().
Semmaz
09.07.2015 20:19Удалили только отдельную функцию из Trait std::str::FromStr, еще до того как появился стабильный Rust (коммит).
Метод from_str() для разных типов вполне существует и работает.
yatagarasu
09.07.2015 20:15std::vector<int> face = model->face(i);
Копирование массива вершин треугольника на каждой итерации. Вот откуда тормоза в С++ версии наверное.stepik777
09.07.2015 20:43Если это C++11, то тут вроде не должно быть копирования, а только перемещение.
А вообще пытаться угадать, где программа проводит больше всего времени, весьма сомнительное занятие. Нужно использовать профайлер или дебаггер.
Unrul
09.07.2015 20:54Да, таки копируется. По уму, нужно что-то вроде
std::vector<int> const& Model::face(int idx) const { return faces_[idx]; } ... auto const& face = model->face(i);
cepreu4habr Автор
10.07.2015 00:11Внес исправления в C++ согласно вашим предложениям. Небольшое ускорение есть, но существенного влияния это не оказывает:
cepreu@cepreu-P5K:~/Загрузки/tinyrenderer-f6fecb7ad493264ecd15e230411bfb1cca539a12$ time ./a.out real 0m1.463s user 0m1.453s sys 0m0.008s
yatagarasu
10.07.2015 17:01Да, ещё было бы интересно бенчмаркать отдельно загрузку модели и рендеринг модели.
withkittens
1. Я надеюсь, для бенчмарков проект компилируется с оптимизациями:
Плохой, плохой код!cargo build --release
?Ещё возможно вам будет интересно: у Rust есть встроенный движок бенчмарков: doc.rust-lang.org/book/benchmark-tests.html
2.
Во-первых, вместо
&str
правильнее передаватьAsRef<Path>
: см. std::fs::File::open. Некоторые пути непредставимы в валидном utf-8, коими являются все строки в Rust.Во-вторых, не прививайте себе привычку везде тыкать
.unwrap()
. Почитайте прекрасную статью об обработке ошибок в Rust: blog.burntsushi.net/rust-error-handling. Да, большая, но она раскладывает всё по полочкам.3.
Ещё есть стабильный std::default::Default. Но
Zero
, конечно, по смыслу подходит больше.Googolplex
Я думаю, что для кода приложения, а не библиотеки, использовать &str для имени файла — вполне нормально. Если понадобится что-то другое, всегда можно будет исправить; а использование AsRef никак не поможет, если опять же в самом начале передавать строку.
То же самое, в общем-то, справедливо и для обработки ошибок, с учётом «игрушечности» рендерера и целей, с которыми он делается.
withkittens
На самом деле, жаль, что Rust так легко позволяет обойти свою модель обработки ошибок. Это ведь даже не unsafe код.
Googolplex
Ну на счёт «приучаться правильно» я с вами, в принципе, согласен, но не согласен с тем, что это «обход» модели ошибок. Паники — такая же часть модели ошибок, как и Result.
cepreu4habr Автор
Да. Для бенчмарков компилировалось release в Rust и -O3 в gcc.
stack_trace
А можно подробнее об этом? Ни разу не слышал о не текстовых путях. Какая-то специфическая область?
withkittens
Например, вот про Linux: github.com/rust-lang/rust/issues/7225
А вот про Windows: github.com/rust-lang/rust/issues/12056
stack_trace
Спасибо