понедельник, 24 августа 2015 г.

Software render на Rust: рисуем треугольник

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


Сразу обещанная ссылка на мою реализацию.

А теперь приступим: как установить rust и сделать минимальную IDE для него я уже писал раньше. Минимальный проект можно создать при помощи cargo:
cargo new rust-software-render --bin
Что бы как-то отправлять мои художества на отрисовку, понадобиться sdl2, для archlinux уставить можно вот так:
sudo pacman -S sdl2 sdl2_image
Для ubuntu вот так:
sudo add-apt-repository ppa:team-xbmc/ppa -y
sudo apt-get update -q
sudo apt-get install libsdl2-dev
На сайте можно поискать как установить для других OS, библиотека вполне кроссплатформенная. Обёртку для неё писать не пришлось, её уже сделали добрые люди. Что бы её подключить не пришлось ничего ни выкачивать, ни компилировать, ни как-то особо прописывать пути, достаточно в файле проекта указать какие библиотеки и каких версий нужны и пакетный менеджер всё грязную работу сделает за нас. Что порадовало - в репозитории выложено уже около 3 000 готовых библиотек.

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

Дальше нужно научиться рисовать одноцветный треугольник на плоскости. Воспользуемся самым простым алгоритмом на основе scanline, отмечу что существуют и другие алгоритмы (кому интересно может поискать в google "traversal rasterization algorithm"), но они сложнее для понимания и заточены под SIMD и многопоточную отрисовку. Главное не забыть, что треугольник может выходить за границу экрана и учесть вырожденные случаи - вроде треугольников у которых 2 вершины имеют одинаковые координаты. Вообще конечно эту часть лучше сделать никуда не подглядывая, т.к. там математика на уровне школы, достаточно знать уравнение прямой и подумать немного. Немного нетривиальным является момент с тем - рисовать или нет пиксель, если треугольник "залезает" на него лишь частично. Для решения этой проблемы есть так называемые rasterization rules, реализуются они если упрощенно - проверкой того, "вылезает" ли начальный и конечный пиксель каждой рисуемой линии за половину пикселя или нет. В конце концов должно получиться что-то похожее на на вот это. Ну и что бы было не совсем скучно будем рисовать сразу много треугольников со случайным цветом и координатами. Осталось собрать и запустить:
cargo run --release
В итоге получим 16 000 треугольников и всего 1 FPS:


Можно конечно попробовать это все ускорить, но функция отрисовки будет ещё сильно меняться, поэтому смысла нет.

Наверное не стоит даже упоминать, что первый правильный "на глаз" результат содержал кучу ошибок. Причём даже мощный статический анализ кода от компилятора rust их не обнаруживал, ибо они были в алгоритме. Однако в арсенале языка есть встроенный framework для unit-testing. Он довольно простой, но на удивление самодостаточный и неплохо описанный. В качестве тестовых данных я взял отличную картинку от Microsoft, которая показывает результаты растеризации для "особых" случаев:


И для каждого треугольника из рисунка я сделал отдельный тест и почти каждый выявлял одну, а то и парочку проблем в моей реализации. Тесты, кстати, запускаются вот так:
cargo test
Ну и напоследок первые впечатления о rust:
  • Писать методом copy-paste со stackoverflow почти невозможно, требуется более глубокое понимание концепций языка. Например python, я первые пол года использовал не заглядывая в книги, тут так не выйдет.
  • Компилятор очень дотошный, заставляет исправлять все, начиная от неявного приведения типов вплоть до того, что ругается на лишние скобки в выражении, неиспользуемые переменные, функции и т.п. Впрочем надеюсь это окупиться меньшим количеством ошибок в runtime.
  • Сообщения об ошибках компиляции радуют своей внятностью, особенно после C++.
  • Менеджер пакетов (cargo) после многих лет использования языков без оного - порадовал. Если создатели языка не озаботились подобным, то обязательно вокруг вырастает куча костылей разной степени убогости. И вообще управление проектом и тестами в rust радует простотой и удобством.
  • Сам язык создаёт впечатление этакого C++ спроектированного с нуля, без оглядки на совместимость. По крайней мере сейчас я не вижу в нем каких-то нелогичных моментов, любая строчка ясно говорит, что будет сделано. Н-р когда передаёшь переменную в функцию, то без заглядывания в определение функции понятно, как будет передана переменная и будет ли она модифицирована. 
Продолжение следует

17 комментариев:

  1. Добавь лычку о покрытии тестами с coveralls.io, раз уже обмазал все тестами :) .

    https://github.com/tomaka/glium - тут вот пример подключения.

    ОтветитьУдалить
    Ответы
    1. Стыдно, это были похоже первые и последние тесты в проекте, то что дальше как-то плохо поддается тестированию.

      Удалить
    2. Да, графика вообще почти всегда слишком тяжело тестируется, что бы с этим заморачиваться.

      Удалить
  2. >> Компилятор очень дотошный, заставляет исправлять все

    если предупреждения о неиспользуемом том-то и том-то мешаю во время разработки/отладки,то удобно бывает временно добавить "#![allow(unused)]" в начало файла. Ну или не "unused", а что-то другое из вывода "rustc -W help".

    ОтветитьУдалить
    Ответы
    1. Да я знаю. Но как ни странно мне подобное скорее даже нравиться, перфекционизм и все такое...

      Удалить
  3. Насколько знаю, лучше в Cargo.toml не прописывать вручную конкретные версии зависимостей без необходимости - будет тебе cargo собирать по пять версий каждой библиотеки, когда зависимостей станет чуть побольше. Есть же автогенерируемый Cargo.lock.

    ОтветитьУдалить
    Ответы
    1. Я сначала тоже не прописывал. Потом оказалось, что те библиотеки, что я использую часто меняют интерфейс, происходят конфликты с зависимостями и т.п. Получалось, что бранч на который я ссылаюсь в посте может самопроизвольно ломаться примерно раз в месяц, поэтому я плюнул и завязался на конкретные стабильные версии.

      Удалить
    2. Так это, закомить Cargo.lock лучше - http://doc.crates.io/guide.html#cargo.toml-vs-cargo.lock

      Удалить
    3. Да, похоже имеет смысл, дома добавлю

      Удалить
  4. Еще я бы заменил все .unwrap()`ы хотя бы на .expect()`ы с понятными сообщениями.

    ОтветитьУдалить
    Ответы
    1. А вот это интересно.

      unwrap - напишет мне исходную ошибку, которую мне кинул допустим sdl, часто с понятным описанием того что случилось

      expect - содержит только мой текст, я даже не нашел способа прокинуть туда изначальное сообщение, а я в своем коде чаще всего понятия не имею почему могла не создаться к примеру текстура

      анализировать результат в явном виде через match - как-то слишком многословно получается

      В итоге - не получается "нащупать" правильный способ обработки ошибок.

      Удалить
    2. Есть такое неудобство. Скоро должны бы стабилизировать нормальный expect() для Result, пока для него нужна ночная сборка и #![feature(result_expect)].

      https://doc.rust-lang.org/std/result/enum.Result.html#method.expect

      Удалить
    3. Да, еще в сторону некритичного, но полезного: по крайней мере в texture.rs у тебя информация о путях из функции в функцию передается как &str или &PathBuf. &str лучше как можно раньше преобразовать к Path, что бы не было "строковой типизации") А просто так передавать &PathBuf вместо &Path странно.

      Удалить
    4. >>...А просто так передавать &PathBuf вместо &Path странно.
      Это ты уже в мастер полез, в статья про первый бранч, но с путями согласен - не разобрался. Меня смущает наличие 2 классов с разным набором возможностей, причем почти все функции типа открытия файла или fs::metadata - они вообще работают со строкой, а не с &PathBuf или &Path. И в итоге я использовал тот тип в аргументах функции, который позволял делать меньше конвертаций перед использованием.

      Удалить
    5. Ой, да, я мастер читал, виноват.

      Два класса - это ты про &Path/PathBuf? Это аналог &str/String или &[]/Vec - один просто ссылается на данные, а другой ими владеет.

      А те функции работют не со строками, а со "AsRef<Path>". Т.е. можно передать и &str, и &String, и &Path, и
      для чего оно еще там реализовано. А в доках строковые литералы используются только для краткости примеров.

      Удалить
    6. >> Единственное чем стоит дополнить документацию это упоминанием функции debug_assert, которая позволяет выводить расширенные сообщения об ошибках.

      Вроде, оно от обычного макроса assert! отличается только тем, что будет выпилено из релизной сборки, а "хвост" с форматной строкой и аргументами и там, и там работает.

      Удалить
    7. Про debug_assert - точно, перечитал документацию, так и есть, в первый раз как-то невнимательно читал. Убрал из поста строчку.
      Про Path/PathBuf - теперь вроде понятнее стало.
      Спасибо за ревью, язык для меня новый, не простой, делаю кучу косяков.

      Удалить