среда, 9 сентября 2015 г.

Software render на Rust: интерполяция атрибутов и освещение

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


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

Тут есть одна тонкость, не все грани одинаково полезны, разные грани очевидно должны вносить разный вклад в результат. Способов реализовать такое можно придумать массу - учитывать площадь или размеры сторон треугольников, анализировать их форму и т.п. Можно ещё считать угол между нормалями и когда он больше 90 градусов - разбивать вершину на две, что бы допустим модель куба не сглаживалась до низкополигональной сферы. Впрочем последнее спорно, некоторые справедливо считают, что подобным должен заниматься экспортёр файлов, а не импортёр. Самым дешёвым способом будет учёт площади грани, он даже дешевле, чем просто считать среднее от всех нормалей граней. Напомню, что когда мы считали нормаль для грани, то использовали функцию cross, которая возвращала нормаль по длине равную удвоенной площади треугольника, т.е. если складывать не нормализованные нормали граней, то в результате как раз и получим что хотели. Вот как это можно сделать.

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

Двигаемся дальше. Нормали мало рассчитать в каждой вершине, их ещё нужно уметь интерполировать. Если обобщить задачу, то для каждого числа возвращаемого вершинным шейдером, нужно уметь получать его значение в каждом пикселе внутри треугольника. Алгоритмов довольно много, попробуем выбрать самый подходящий:
  1. Можно линейно интерполировать нужный параметр в пространстве экрана, как это делалось в прошлой статье со значением 1/z. Но т.к. преобразование координат 3d треугольника в экранные координаты происходило не линейно, то подобная интерполяция даст весьма неточные результаты. Однако, этот способ вполне имеет право на жизнь для отрисовки маленьких треугольников, когда неточность будет незаметна.
  2. "Точная интерполяция" - подробное описание и вывод формул можно посмотреть тут. Краткий вывод: для каждого интерполируемого значения u можно получить формулу зависящую от экранных координат (sx, sy) и коэффициентов (C1-С6) зависящих от параметров грани, вот такого вида: u = (C1*sx+C2*sy+C3) / (C4*sx+C5*sy+C6). Тут мы получим честные значения.
  3. "Барицентрические координаты" - можно почитать вот тут, по моему вполне правильное объяснение: Они тоже дают правильный результат.
  4. "Перспективно-корректная интерполяция" - как выводятся формулы написано тут, в разделе "Интерполяция атрибутов". Вывод очень простой, если просто значение u интерполировать нельзя, то u/z уже можно. Т.е. пусть для каждой вершины треугольника задано некоторое значение u: Au, Bu, Cu, а так же известны координаты z: Az, Bz, Cz. Тогда нужно посчитать Au/Az, Bu/Bz, Cu/Cz, линейно их интерполировать и в каждом пикселе получившееся значение умножить на z. Значение z (ну если точнее то 1/z) мы все равно считаем для буфера глубины, т.е. оно нам достаётся "бесплатно", остаётся сделать линейную интерполяцию для каждого значения, что весьма не сложно.
Это все известные мне базовые алгоритмы, которые в той или иной модификации используется для интерполяции значений. Первый по очевидным причинам в чистом виде использовать не рекомендуется. Второй и третий обычно используются для семейства "traversal алгоритмов" растеризации, т.к. довольно хорошо ложатся на sse, а так же за счёт того, что позволяют разбивать треугольник на независимые части, то ещё и хорошо параллелятся. В rust к сожалению сейчас в стабильной ветке поддержки использования sse нет, а в нестабильной оно имеет весьма неполную поддержку - только базовые функции, а переходить на ассемблерные вставки не хочется. Остаётся 4-й алгоритм, который неплохо "ложиться" на scanline растеризацию и является для неё, самым быстрым точным алгоритмом интерполяции (ИМХО конечно, поскольку очень глубоко я в это не вникал).

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

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


Дальше обещанное освещение, самое простое по Ламберту, оно не имеет specular составляющей (вершинный и пиксельный шейдера):

Усложняем, освещение по модели Фонга-Блина, тут уже есть блики (вершинный и пиксельный шейдера):

Ещё усложняем: упрощённый Cook-Torrance. Cчитается, что если предыдущая модель освещения больше подходит для пластика, то Cook-Torrance - для металлов, но я если честно особой разницы не вижу (а вот проседание fps существенное) (вершинный и пиксельный шейдера):


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

И традиционная рубрика впечатлений о Rust (сегодня почему-то получился в основном негатив):
  • ООП фактически на уровне старого доброго C - структура и набор методов для работы с ней, с небольшим синтаксическим сахаром в виде явно задаваемого self.
  • Переопределения методов - нет, возможно это антипаттерн, поскольку делает код более запутанным, но я к ним настолько привык, что без них очень тяжело.
  • Возможности задавать умолчательные значения для аргументов функций - тоже нет. В совокупности с предыдущим пунктом, раздражает особенно сильно. Приходится писать кучу функций для всех комбинаций аргументов. Есть альтернатива в виде Option, но работать с ними не очень удобно, хотя и более наглядно.
В целом всё чаще возникает мысль, что во многих местах упрощают жизнь компилятору путём урезания возможностей для программиста. Но с другой стороны это упрощение играет на руку тем, кто код читает, становится гораздо легче понять что происходит за счёт того, что в коде очень мало неоднозначностей. Вспомните любимые многими вопросы на собеседовании по плюсам: вот у вас офигенно сложная структура наследования, куча dynamic_cast-ов и подобной лабуды, а теперь угадайте какой метод вызывается вот тут и как организовать таблицу виртуальных функций, что бы это все заработало? Тут такого не бывает, если вызывается функция, то найти какая именно, как правило не вызывает сложностей.

Продолжение

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

  1. >> ООП фактически на уровне старого доброго C - структура и набор методов для работы с ней, с небольшим синтаксическим сахаром в виде явно задаваемого self.

    Это спорный момент) Все-таки трейты дают полноценный полиморфизм.

    Я вот все жду, чем кончатся срачи про добавление в ржавчину наследования или альтернативы ему. Надеюсь, что остановятся на анонимных полях, как в Go. Тогда с ООП проблем бы быть не должно, наоборот понятнее все станет из-за явного разделения полиморфизма и наследования реализации.

    ОтветитьУдалить
    Ответы
    1. Может до меня просто медленно доходит, но только сейчас начинает постепенно вырисовываться их концепция ООП: и зачем нужны их трейты, и куда пристраивать типажи. Ощущение от всего этого - взяли старые знакомые концепции ООП, вывернули их наизнанку, назвали по другому и реализовали в языке.

      Удалить
    2. >> зачем нужны их трейты, и куда пристраивать типажи

      Это ж одно и то же)

      >> Ощущение от всего этого - взяли старые знакомые концепции ООП, вывернули их наизнанку, назвали по другому и реализовали в языке.

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

      Ржавчина ранних версий - 0.2-0.3 очень сильно отличается от современной, туда интересно бывает заглянуть и попытаться проследить мысль авторов)

      Удалить
    3. Эх тут бы с текущей версией разобраться. На то что бы заниматься археологией совсем времени не остается

      Удалить
    4. Если есть какие затыки по части языка - обращайся, буду рад помочь если смогу) Серия статей у тебя интересная, не хотелось бы что бы ты ее забросил.

      Удалить
    5. Спасибо. Там вроде нет ничего принципиально непонятного, нужно просто читать, заставлять кого-то перечитывать мануалы и делать выжимку - не удобно как-то )

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

      Удалить
    6. Хм.. Скелетную анимацию добавить, например? )

      Удалить
  2. От желания перегружать функции отвык относительно быстро.

    А вот про нехватку опциональных/умолчальных аргументов соглашусь. Года полтора активно ковыряю язык, а так и не привык к этому до конца. Можно, конечно, использовать шаблон Строитель, но это часто ощущается "стрельбой из пушки для воробьям".

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

    >> Переопределения методов - нет

    Ну не то что бы совсем нет - http://is.gd/2STKxf

    ОтветитьУдалить
    Ответы
    1. Я может неудачно выразился, но мне хочется 2 рядом стоящих метода с одинаковым названием, но с разным количеством аргументов или с разными типами. А компилятор пусть сам выбирает какой должен быть вызван в том или ином случае.

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

      Удалить