Пишем свой упрощенный OpenGL на Rust — часть 2 (проволочный рендер)
Итак, в продолжение предыдущей статьи пишу 2-ю часть, где мы попробуем добраться до того, чтобы написать проволочный рендер. Напоминаю, что цель этого цикла статей — написать сильно упрощенный аналог OpenGL на Rust. В качестве основы используется «Краткий курс компьютерной графики» от haqreu, в своих же статьях я сосредоточиваюсь больше не на графике как таковой, а на особенностях реализации при помощи Rust: возникающие проблемы и их решения, личные впечатления, полезные ресурсы для изучающих Rust. Сама получившаяся программа не имеет особенной ценности, польза от этого дела в изучении нового перспективного ЯП и основ трехмерной графики. Наконец, это занятие довольно таки увлекательно. :)
Напоминаю также, что поскольку я не являюсь профессионалом ни в 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 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 operator +(const Vec2 &V) const { return Vec2(u+V.u, v+V.v); }
inline Vec2 operator -(const Vec2 &V) const { return Vec2(u-V.u, v-V.v); }
inline Vec2 operator *(float f) const { return Vec2(u*f, v*f); }
template friend std::ostream& operator<<(std::ostream& s, Vec2& 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,
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,
}
}
}
Особых сложностей с ним не было. Просто чтение файла и обработка строк. Разве что только поиск информации, как сделать ту или иную штуку в расте осложняется тем, что язык быстро меняется. Подчас находишь какие-то ответы, пробуешь их, а они не работают, потому-что оказывается буквально несколько недель назад в 1.1 этот метод переименовали и т. п. Столкнулся с этим на примере метода from_str()
, который удалили из Rust 1.1.
Поначалу я допустил в этом коде ошибку, забыв написать строчку 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:~/Download/rust-study/project$ time ./rust_project
real 0m0.702s
user 0m0.596s
sys 0m0.104s
А вот цифры реализации на 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; infaces(); i++) {
std::vector 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 оказался быстрее — шок. Видимо компилятор уже далеко не такой сырой, как я думал.