Pull to refresh

Пишем свой упрощенный OpenGL на Rust — часть 1 (рисуем линию)

Reading time 12 min
Views 39K
Продолжение:
Пишем свой упрощенный OpenGL на Rust — часть 2 (проволочный рендер).
Пишем свой упрощенный OpenGL на Rust — часть 3 (растеризатор)

Наверное, мало кто на хабре не в курсе, что такое Rust — новый язык программирования от Mozilla. Уже сейчас он привлекает много интереса, а недавно наконец вышла первая стабильная версия Rust 1.0, что знаменует собой стабилизацию языковых возможностей. Мне всегда импонировали системные ЯП, а уж идея системного языка, предлагающего безопасность превосходящую языки высокого уровня, заинтересовала еще больше. Захотелось новый язык попробовать в деле и, заодно, интересно провести время, программируя что-нибудь увлекательное. Пока думал, что бы такого на расте написать, вспомнился недавний цикл статей про компьютерную графику, который я лишь бегло просмотрел. А очень интересно было бы попробовать все-таки написать все эти красивости самостоятельно. Вот так и родилась идея этого хобби-проекта, а также данной статьи.

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

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


Here is the Rust, which i hope to get at the end. (игра слов, Rust по-английски «ржавчина»)

Подготовка


Описывать установку компилятора, я не буду, там все достаточно очевидно. Установочный пакет с официального сайта установился на моей Ubuntu 14.04 парой команд. Следует лишь отметить, что есть репозиторий для Ubuntu, используя который также теоретически можно установить Rust. Однако у меня с этим репозиторием не задалось. Почему-то Rust устанавливался, но без Cargo. Synaptic даже не показывал Cargo в списке доступных пакетов. Rust без Cargo — это довольно бессмысленная штуковина, так что указанный PPA я не использовал.

Итак, как и всегда, знакомство с новым языком мы начинаем с Hello World. Создадим файл main.rs:

Hello World
fn main() {
    println!("Hello World!");
}

Результат запуска этого в консоли очевиден:
user@user-All-Series:~$ rustc main.rs 
user@user-All-Series:~$ ./main 
Hello World!
user@user-All-Series:~$ 


Код можно писать с использованием Gedit. Расцветка синтаксиса Rust для этого редактора может быть найдена по следующей ссылке. Сразу она не заработала, но в трекере был багрепорт, который объяснял причину проблемы. Простенький pull request решил проблему, поэтому сейчас у вас должна расцветка заработать без лишних плясок с бубном.

Обычно новый проект создается с использованием команды cargo new rust_project --bin , но я тогда еще об этом не знал, поэтому создал всю структуру вручную, благо она не сложная:

Структура нового проекта
Cargo.toml
src/main.rs

Содержимое Cargo.toml:
[package]
name = "rust_project"
version = "0.0.1"
authors = [ "Cepreu <cepreu.mail@gmail.com>" ]

Этот проект запускается на исполнение командой cargo run .

Для вывода изображения я решил не переписывать TGAImage, который предоставляется автором оригинальной статьи. Мне захотелось выводить результат при помощи SDL. Фактически нам от этой библиотеки понадобится только создание окна и вывод точки с заданным цветом по заданным координатам. Все остальные графические примитивы мы рисуем самостоятельно, так что при желании бэкенд вывода изображения можно изменить очень просто, всего-лишь реализовав 2 функции: set(x, y, color); и new(xsize, ysize) . Почему именно SDL? В перспективе хотелось бы уметь изменять точку обзора с клавиатуры. Может быть даже игрушку простенькую написать… TGA такого сделать не позволит.

Первая же ссылка в Google привела меня на сайт проекта Rust-SDL2 — привязки к SDL2 для Rust. Чтобы задействовать эту библиотеку добавляем в конец cargo.toml следующее объявление зависимости:

[dependencies]
sdl2 = "0.5.0"

Это добавляет в проект т. н. контейнер (crate) sdl2. Для компиляции библиотека использует заголовочные файлы SDL, поэтому надо не забыть их установить. В Ubuntu 14.04 это делает команда:

sudo apt-get install libsdl2-dev

После этого команда cargo build успешно соберет нам проект со всеми необходимыми зависимостями.

user@user-All-Series:~/temp/rust_project$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading num v0.1.25
 Downloading rustc-serialize v0.3.15
   Compiling sdl2-sys v0.5.0
   Compiling rustc-serialize v0.3.15
   Compiling libc v0.1.8
   Compiling bitflags v0.2.1
   Compiling rand v0.3.8
   Compiling num v0.1.25
   Compiling sdl2 v0.5.0
   Compiling rust_project v0.1.0 (file:///home/user/temp/rust_project)
user@user-All-Series:~/temp/rust_project$ 

Эксперименты с SDL


Всю функциональность, связанную со взаимодействием с SDL (или же другой графической библиотекой в перспективе), я решил вынести в отдельный класс — canvas.rs. Поначалу просто для проверки работы библиотеки я скопировал туда содержимое теста из репозитория Rust-SDL2, собираясь на основе его впоследствии написать готовый класс.

Тут у меня были первые грабли. Оказалось, что любое объявление extern crate package_name в библиотеке должно быть также продублировано в main.rs приложения. Потребовалось время, чтобы разобраться в этом, но после всех мучений, у меня наконец получился проект, который вы можете увидеть в снапшоте на github.

В итоге в файле canvas.rs был такой код:

// Copyright (C) Cepreu <cepreu.mail@gmail.com> under GPLv2 and higher
extern crate sdl2;

use sdl2::pixels::PixelFormatEnum;
use sdl2::rect::Rect;
use sdl2::keyboard::Keycode;

pub fn test() {
    let mut sdl_context = sdl2::init().video().unwrap();

    let window = sdl_context.window("rust-sdl2 demo: Video", 800, 600)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut renderer = window.renderer().build().unwrap();

    // FIXME: rework it
    let mut texture = renderer.create_texture_streaming(PixelFormatEnum::RGB24, (256, 256)).unwrap();
    // Create a red-green gradient
    texture.with_lock(None, |buffer: &mut [u8], pitch: usize| {
        for y in (0..256) {
            for x in (0..256) {
                let offset = y*pitch + x*3;
                buffer[offset + 0] = x as u8;
                buffer[offset + 1] = y as u8;
                buffer[offset + 2] = 0;
            }
        }
    }).unwrap();

    renderer.clear();
    renderer.copy(&texture, None, Some(Rect::new_unwrap(100, 100, 256, 256)));
    renderer.copy_ex(&texture, None, Some(Rect::new_unwrap(450, 100, 256, 256)), 30.0, None, (false, false));
    renderer.present();

    let mut running = true;

    while running {
        for event in sdl_context.event_pump().poll_iter() {
            use sdl2::event::Event;

            match event {
                Event::Quit {..} | Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
                    running = false
                },
                _ => {}
            }
        }
    }
}

Этот код выводит вот такое вот замечательное окошко:
Скриншот


Пишем код


Если взглянуть на сам код, здесь есть очевидные 3 части: инициализация, рисование, ожидание ввода. Следующим пунктом у нас переработка указанного кода с целью получения объекта Canvas, у которого есть:
  • конструктор, производящий необходимую инициализацию
  • функция вывода точки (вместо имеющегося сейчас вывода текстуры)
  • функция ожидания ввода Esc с клавиатуры

Знаю, ввод с клавиатуры не самая подходящая функция для класса Canvas, но проблема оказалась в том, что для ожидания ввода используется sdl_context, который бы после такого изменения стал бы полем Canvas. Что в любом случае приводит к тому, что или объект Keyboard будет зависеть от Canvas или же оба они должны будут зависеть от какого-то 3-го класса. Чтобы не усложнять все сверх меры, пока остановился на том, чтобы оставить все в одном классе. Если нагрузка на Canvas возрастет, тогда вынесу часть функций из него и таки проделаю вышеописанную работу.

Стоит отметить, что до этого мой процесс разработки неплохо характеризовался словами «Stack Overflow Driven Development». Я просто дергал разные куски кода из разных мест и компоновал их вместе даже не зная синтаксиса языка. Это типичный для меня способ изучения нового языка или технологии — просто начать писать код. К различным ресурсам же я обращаюсь как к справочнику, чтобы понять, как сделать конкретный нужный мне кусочек. Нужен цикл, прочитать статью про циклы в языке. Нужно IoC, нагуглить хороший ответ на Stackoverflow. Ну вы поняли. Большинство языков и технологий довольно похожи, поэтому проблем при подобном подходе не возникает. Можно начать сразу программировать, даже еще толком не зная язык. С Rust такой номер не прошел. Язык довольно своеобразен и уже на попытке понять, как сделать класс или объект, произошел затык. В книге раста не было статей с названием Classes/Objects/Constructors и т. п. Стало понятно, что сначала придется немного подучить матчасть. Поэтому, чтобы составить общее впечатление о синтаксисе языка, были пройдены уроки Guessing Game и Dining Philosophers из официальной документации Rust'а. Она на английском, стоит отметить существование перевода на русский. Правда тогда я еще этого не знал, читал на языке оригинала.

В общем после этого, работа пошла. Хотя еще пришлось повоевать с системой владения и заимствования Rust. Официальная документация отмечает, что этот этап проходит каждый новичок, но ободряет нас тем, что по мере практики код можно будет писать легко и непринужденно, привыкнув к ограничениям, накладываемым компилятором. Во вступлении говорится, что сложность обучения — цена, которую приходится платить за безопасность без накладных расходов. Конкретно проблема была в том, что в методе new(), создающем структуру, поля вновь созданной структуры необходимо проинициализировать несколькими объектами из SDL, чтобы потом использовать их в методах структуры. Если я пытался проинициализировать поле ссылкой, компилятор ругался, что не может сделать изменяемое заимствование ( error: cannot borrow data mutably in a `&` reference ). Вот в таком коде:

pub struct Canvas<'_> {
    renderer: &'_ mut Renderer<'_>,
}
...
    pub fn new(x: u32, y: u32) -> Canvas<'_> {
        ...
        let mut renderer = window.renderer().build().unwrap();
        Canvas { renderer: &mut renderer }
    }

Кстати, на Хабре сломана подсветка синтаксиса Rust, из-за чего код выше не расцвечивается, хотя и должен. Проблема в этом кусочке: <'_> . Если его убрать, то все расцвечивается нормально. Но убирать его здесь как раз таки нельзя. Это обозначение времени жизни (lifetime) — вполне себе правильный синтаксис для Rust. Не знаю, куда это написать, поэтому написал здесь. Надеюсь, починят.
Просто name: mut type в структурах писать нельзя. Тогда я попробовал сделать переменную неизменяемой (immutable), но от этого сломался другой код:

pub struct Canvas<'_> {
    renderer: Renderer<'_>,
}
...
    pub fn new(x: u32, y: u32) -> Canvas<'_> {
        ...
        let renderer = window.renderer().build().unwrap();
        Canvas { renderer: renderer }
    }
    pub fn point(&self, x: u32, y: u32) {
        ...
        self.renderer.clear();
        ...
    }

Компилятор жаловался, что не может заимствовать неизменяемое поле renderer как изменяемое ( error: cannot borrow immutable field `self.renderer` as mutable ). Ларчик открывался просто. Оказывается в Rust все поля в структуре рассматриваются как изменяемые или неизменяемые в зависимости от того, была ли передана сама структура в метод как изменяемая или неизменяемая. Таким образом правильный код таков:

pub struct Canvas<'_> {
    renderer: Renderer<'_>,
}
...
    pub fn new(x: u32, y: u32) -> Canvas<'_> {
        ...
        let renderer = window.renderer().build().unwrap();
        Canvas { renderer: renderer }
    }
    pub fn point(&mut self, x: u32, y: u32) {
        ...
        self.renderer.clear();
        ...
    }

Как видите, здесь я изменил сигнатуру метода point.

Теперь еще немного о граблях:
Были большие сложности в работе с кодом, взаимодействующим с библиотекой Rust-SDL2. У нее есть документация, но там пока мало чего сказано, а существование некоторых методов вообще умалчивается. Например о методе sdl2::init() в документации ничего. Словно его и не существует. Система автоматического вывода типов Rust упрощает и ускоряет написание кода, но она же сыграла со мной и злую шутку, когда мне понадобилось понять, какой же тип возвращает вызов sdl2::init().video().unwrap(), потому-что этот результат надо было сохранить в поле в структуре, а там тип указывается явно всегда. Пришлось читать исходники библиотеки, хотя чуть позже я нашел и менее трудоемкий выход. Просто указываешь у поля любой произвольный тип, Rust при компиляции ругается на несоответствие типов, выводя в сообщении об ошибки тип, который должен быть. Вуаля!

Особого упоминания заслуживает такая штука, как время жизни (lifetime) в Rust. С ней я долго боролся. Вообще говоря в Rust у каждой переменной и ссылки есть свой lifetime. Просто он выводится компилятором автоматически на основе определенных правил. Однако иногда его требуется указывать явно. Чтение статьи про время жизни из книги раста ничего для меня не прояснило. (хотя я ее 3 раза перечитал) Я так и не понял, почему же в моем случае Rust попросил указать lifetime. По сути я просто добавил эти странные <'_> везде, где компилятор указывал на ошибку с неуказанным lifetime, так и не разобравшись, зачем же ему это от меня было надо. Если есть знающие люди, буду рад, если просветите в комментариях. Почему именно подчерк, а не какой-то другой знак после апострофа? Просто в сообщении об ошибке про несоответствие типов было sdl2::render::Renderer<'_> . Поначалу я пытался обозначать поле как просто renderer: Renderer , но компилятор меня ругал: error: wrong number of lifetime parameters: expected 1, found 0 . UPD: Пользователь Googolplex в своем комментарии разъяснил этот момент. Вызов window.renderer().build().unwrap() возвращает sdl2::video::Renderer<'static> . Внимание на lifetime-параметр — он равен 'static. Это специальный lifetime, обозначающий нечто, что может жить до конца всей программы. Таким образом правильное объявление структуры выглядит так:
pub struct Canvas {
    renderer: Renderer<'static>,
    ...
}

В других участках кода все упоминания lifetime можно убрать.



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

Пишем линию


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


Дальше пошло проще, потому-что серьезного взаимодействия с библиотекой Rust-SDL2 уже не требовалось. Ее неочевидный и недокументированный, местами даже недоделанный API был постоянным источником трудностей. Всякая арифметика и управляющие конструкции в Rust не слишком отличаются от других ЯП. Поэтому линия была написана всего за несколько часов (в отличии от нескольких дней на предыдущих этапах). И то большая часть работы была из-за того, что я решил не реализовывать готовый алгоритм, а написать свой, используя уравнение прямой y=kx+b, как основу, а все остальные формулы выведя самостоятельно. Вот такая функция у меня получилась в итоге:

    pub fn line(&mut self, x1: i32, y1: i32, x2: i32, y2: i32, color: u32) {
        if (x1 == x2) && (y1 == y2) {
            self.set(x1, y1, color);
        }
        if (x1-x2).abs() > (y1-y2).abs() {
            if x1 < x2 {
                let mut yi = y1;
                for xi in x1..x2+1 {
                    let p = (y2-y1)*xi + x2*y1 - x1*y2;
                    if yi*(x2-x1) < p + (y2-y1)/2 {
                        yi = yi+1;
                    }
                    self.set(xi, yi, color);
                }
            } else {
                let mut yi = y2;
                for xi in x2..x1+1 {
                    let p = (y1-y2)*xi + x1*y2 - x2*y1;
                    if yi*(x1-x2) < p + (y1-y2)/2 {
                        yi = yi+1;
                    }
                    self.set(xi, yi, color);
                }
            }
        } else {
            if y1 < y2 {
                let mut xi = x1;
                for yi in y1..y2+1 {
                    let p = yi*(x2-x1) - x2*y1 + x1*y2;
                    if xi*(y2-y1) < p + (y2-y1)/2 {
                        xi = xi+1;
                    }
                    self.set(xi, yi, color);
                }
            } else {
                let mut xi = x2;
                for yi in y2..y1+1 {
                    let p = yi*(x1-x2) - x1*y2 + x2*y1;
                    if xi*(y1-y2) < p + (y1-y2)/2 {
                        xi = xi+1;
                    }
                    self.set(xi, yi, color);
                }
            }
        }
    }

Знаю, она у меня страшненькая, длинная, неэффективная. Но зато своя — родная. :) Я почти сразу пришел к тому, чтобы писать целочисленную версию, не используя вещественную арифметику. В Rust нет неявных преобразований типов, а явные получаются очень многословными. Поэтому от идеи написать сначала вещественную версию я отказался. Много кода, а ее потом все равно в итоге переписывать, чтобы она не тормозила.

А вот фотка моих расчетов на бумажке (для истории)

Знатокам математики просьба не пинать. Все делалось just for fun.

В ходе написания этой портянки мне понадобилось логгирование, поскольку проверенной временем IDE для Rust пока нет. Есть несколько свежих, но я не хотел залипать на их бета(альфа?)-тесте. Если кто-то пользовался и увидел, что оно стабильно и хорошо работает, отпишитесь, пожалуйста, в комментариях. Пользоваться отладчиком из консоли мне не в радость. Да и вообще логгирование штука полезная. Стараюсь приучать себя использовать логи вместо отладчика, потому-что это помогает писать логи, по которым потом реально разобраться, в чем возникла проблема у конечного пользователя (по крайней мере, если он сумеет запустить в режиме логгирования debug).

Итак, логгирование. Для логгирования в поставке Rust есть библиотека log. Но это просто абстрактное API, а конкретную его реализацию надо выбрать самому. Я использовал env_logger, который предлагается в документации к log. Пишем следующее в cargo.toml:

[dependencies]
sdl2 = "0.5.0"
log = "0.3"
env_logger = "0.3"

Пользоваться этим набором очень просто:

#[macro_use]
extern crate log;
extern crate env_logger;

fn main() {
    env_logger::init().unwrap();
    info!("starting up");

Надо только не забыть, что контейнер (crate) env_logger настраивается посредством переменных окружения. Я использую следующую команду перед запуском проекта:

set RUST_LOG=rust_project=debug

Это задает модулю rust_project уровень логгирования debug. Все остальные модули остаются с уровнем по умолчанию, поэтому лог при запуске программы не засоряется всяким отладочным мусором от cargo или sdl. Итог работы на этом этапе можно увидеть в срезе репозитория. При запуске наша программа выводит вот такую вот красоту:



Как раз то, что нужно было в соответствии с оригинальной статьей. Только цвет второй линии я изменил на синий. Статья уже получилась довольно длинная, поэтому на сегодня я заканчиваю. Дальнейшие планы:
  • переписать свою страшную функцию line() на ту, которая предлагается в исходной статье как конечный результат
  • написать проволочный рендер
  • далее продвигаться по урокам Краткого курса компьютерной графики

Напоследок маленькая порция личных впечатлений от языка.

Впечатления


Все это мои субъективные плюсы и минусы. Я не пишу того, о чем прочитал где-то. А лишь то с чем лично сам столкнулся в ходе разработки. Например, я знаю, что Rust безопасен, но пока меня эта его фишка от выстрела в ногу еще нигде не спасла (насколько я могу судить), так что пока не могу этого оценить.
Минусы программирования на Rust:
  • Тяжел в изучении.
  • Пока еще мало легкой для понимания документации, сторонние библиотеки вообще документированы абы как.

Плюсы:
  • Компилятор проверят стилистические характеристики кода и выводит warning'и, если стиль отличается от предлагаемого разработчиками. Мне, как любителю поддержания одного стиля в проекте, это очень импонирует.
  • Многие вещи также легко пишутся, как и в высокоуровневых языках, но при этом язык компилируемый и системный.
  • Любовь к хорошим практикам в стандартной библиотеке. Например, env_logger конфигурируется из переменных окружения, что соответствует одному из 12-ти факторов от Heroku.
  • Cargo. Тут тебе и управление жизненным циклом проекта и разрешение зависимостей и тестирование. Все это относительно новые, но уже общепризнанные полезности. И это в стандартной поставке языка. Неплохо соответствует определению «Системный язык 21-го века».


Напоследок


Если я не ослабею, не сорвусь, не озверею, то буду писать продолжение. :) Ну и, конечно, если меня за эту статью в Read-only не отправят.
Tags:
Hubs:
+44
Comments 16
Comments Comments 16

Articles