понедельник, 19 октября 2015 г.

Software render на Rust: добавляем текстуры

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


Рассматривать будем только 2D текстуры, хотя они могут быть и 1D и 3D, можно придумать применение и для 4D. Очевидно, для адресации пикселей в текстуре, потребуются координаты, обычно: u и v. Далее, имея текстурные координаты, их можно легко привязать к каждой из вершин модели:

Причём для любой текстуры, независимо от размера обе координаты лежат в диапазоне от 0 до 1. Это не значит, что не может быть например значения 1.5, такая координата будет означать, что мы "положили рядом" две текстуры и указываем точно на середину второй.

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

Проведём мысленный эксперимент: допустим мы рисуем треугольник, который на экране занимает всего 1 пиксель и наложим на него текстуру в 1000 на 1000 пикселей. Если действовать описанным выше способом, то при выборке из текстуры получим какой-то один, "случайный" пиксель, который никак изначальную текстуру не характеризует. Правильным способом было бы уменьшить исходное изображение до размера 1x1 пиксель, допустим усреднив все цвета текстуры и наложить на наш треугольник получившиеся изображение. Теперь выбранный пиксель из текстуры будет хотя бы отражать в какой-то мере свойства всей текстуры.

Если обобщить, то в идеале, для получения хорошей картинки нужно изменять размер текстуры таким образом что бы количество пикселей, которое занимает треугольник на экране было равно количеству пикселей в текстуре. Ну и соответственно, если на текстурирование треугольника уходит не вся, а допустим половина текстуры, то кол-во пикселей в половине текстуры должно быть равно кол-ву пикселей в треугольнике. Понятно, что заготовить все возможные размеры текстуры не возможно, поэтому обычно рассчитывают так называемые mip-уровни изображения, т.е. последовательно уменьшают текстуру с коэффициентом 2, до размеров 1x1. Если допустим исходный размеры 8x8 пикселей, то нужно сгенерировать ещё 3 уровня: 4x4, 2x2, 1x1. А дальше при рисовании треугольника (или даже для каждого пикселя) выбирать соответствующий уровень и рисовать с ним.

Сгенерировать уменьшенное изображение не так просто как может показаться на первый взгляд. Только самых распространённых алгоритмов можно найти около десятка. Кому интересно, можете поискать по ключевым словам: "downscaling image algorithm". Я их не буду описывать, т.к. полноценное описание и сравнение плюсов и минусов - пожалуй потянет на небольшую книгу. Упомяну пожалуй только, что чаще всего идеальным называют алгоритм основанный на функции sinc, хотя регулярно встречается и критика этого заявления. Так же отмечу, что неплохо было бы учитывать гамма-коррекцию при масштабировании, вот тут подробно, с примерами, описано почему. Удручает то, что на текущий момент - идеального алгоритма не существует. Одни теряют существенные детали, другие выдают мыльную картинку, третьи идеально масштабируют одни типы изображений, но сильно портят другие. 

Лично я, ещё до поиска решения проблемы в google написал вот такой алгоритм, который в итоге и оставил. По сути там берётся область текстуры 3x3 и цвета усредняются с коэффициентами, потом следующая область и т.д. Коэффициенты подобраны так, что в итоге каждый пиксель исходного изображения вносит одинаковый вклад в уменьшенную текстуру. Гамма-коррекцию я убрал, она на моих простеньких примерах не влияла на результат (хотя художники наверное не согласятся), а вот скорость загрузки просаживала сильно. Получилось вроде неплохо и довольно быстро.

Допустим уровни (LOD-ы) для текстуры мы сгенерировали, теперь нужно подумать, как для конкретного треугольника или пикселя выбрать нужный LOD текстуры. Алгоритмов опять куча, хотя найти вменяемые статьи с полным описанием не удалось, но раскопать получилось следующее. Во-первых, они делятся на 2 основных типа:
  • выбор LOD для каждого полигона, так получается быстро, но менее качественно.
  • выбор LOD для каждого пикселя, этот алгоритм наоборот более затратный, но картинка получается лучше.
Для расчёта per polygon - можно найти площадь текущего полигона в экранных координатах, фактически узнать сколько пикселей на экране он покрывает. Потом найти площадь полигона в текстурных координатах нулевого LOD, тем самым узнав сколько пикселей из текстуры (текселей) прийдётся на треугольник. Разделив их друг на друга - узнаем сколько текселей приходится на один пиксель. Что бы было понятнее давайте на примере квадрата, который занимает на экране 10x10 пикселей, его площадь будет 100 пикселей. Мы целиком накладываем на него текстуру размером 20x20 пикселей, получается площадь квадрата в текселях будет равно 400. Итого на каждый пиксель приходится 4 текселя. На практике мы будем иметь дело конечно не с квадратами, а с треугольниками, там площадь легко посчитать через векторное произведение. При соотношении 1:1 очевидно нужно выбирать 0-й LOD. А вот при 4:1 нужно брать уже 1-й, т.к. он в 2 раза меньше по ширине и по высоте, т.е. содержит в 4 раза меньше текселей, чем нулевой. При 16:1 берём 2-й LOD и т.д. В нашем примере соотношение 1:4 поэтому выбираем 1-й LOD, у него будет разрешение текстуры 10x10 пикселей - это идеально подойдёт для нашего квадрата. Промежуточные отношения, н-р 1:2 округляют к ближайшему меньшему или большему LOD, в зависимости от того, что хочется получить. Вот моя реализация выбора LOD.

Такая методика расчёта считается нехорошей из-за заметных артефактов. Это и резкое изменение разрешения текстуры на треугольнике при приближении\удалении от него и то, что рядом стоящие треугольники могут иметь разный LOD, как на скриншоте:


Тут очень заметно, как пол человечка слева внизу имеет разрешение хуже, чем остальные в его ряду.

Для расчёта per pixel основной смысл алгоритма не меняется, только расчёт ведётся на уровне каждого пикселя. Площадь пикселя принимается за 1. Для нахождения площади в текселях нужно знать на сколько изменятся текстурные координаты в пикселе (y,x+1) назовём это значение (dudx, dvdx), так же изменение в пикселе (y+1,x) - получим некое (dudy, dvdy). Ну и дальше очевидно площадь будет равна 0.5 * cross((dudx, dvdx), (dudy, dvdy)). Находим отношение площадей и выбираем LOD, как в предыдущем случае. В моей версии растеризатора это сделать проблемно, там значения (dudx, dvdx) есть в явном виде, а вот как посчитать дешёво (dudy, dvdy) - в голову не приходит. В любом случае этот способ просадит производительность если не на порядок, то близко к тому, а качества на высокополигональных моделях сильно не прибавится, поэтому я даже не стал его реализовывать.

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

Как пример других методик расчёта, можно посмотреть тут пункт "Выбор LOD на основе краевого сжатия (Edge compression-based LOD selection)". Там правда, что бы до конца разобраться нужно лезть в оригинальные статьи Криса Хекера, мне было лень.

Отлично, почти все сложности позади, остался небольшой, но важный штрих. Вот добрались мы до отрисовки пикселя, так или иначе получили текстурные координаты, посчитали LOD и тут перед нами встаёт новая задача. Текстурные координаты обычно получаются дробными, а текстура - дискретной. Если просто взять и округлить координаты до ближайшего целого, то текстура конечно наложиться, но пиксели будут сильно видны - получится некрасиво. Тут на помощь приходит билинейная фильтрация текстур, FPS в очередной раз сильно просядет, но результат того стоит. Ещё лучше результат будет при использовании трилинейной фильтрации. При некоторых расположениях треугольника оба алгоритма дают плохой результат и тут на помощь может прийти анизотропная фильтрация. Практически применимой для real-time расчётов на CPU остаётся пожалуй лишь билинейная, которую я и реализовал. Остальные на мой взгляд не так критично улучшают качество, что бы платить за них таким количеством производительности.

Разницу между отсутствием и билинейной фильтрацией, можно оценить вот на этих скриншотах:

Ну и напоследок отмечу, что я все же отказался от 3ds формата моделей, поскольку десятки малодокументированных типов чанков меня окончательно доконали. В качестве альтернативы был выбран obj и уже готовая библиотека для его загрузки. Остаётся лишь конвертировать в мой формат моделей и можно пользоваться. Из минусов пока заметил лишь очень большой объем файлов. Но зато как приятно когда можно в любом текстовом редакторе поменять что-то в модели!

Несколько скриншотов текстурированных моделей:


И традиционная небольшая рубрика впечатлений о Rust:

  • Приятно удивила его кроссплатформенность, я недавно писал как пользоваться appveyor для сборки проекта под windows на примере этого приложения и мне для компиляции под другую ОС не пришлось делать ровным счётом ничего. Всё завелось с первого раза.
  • Ещё недавно "распробовал" типажи в Rust. Это же бесподобно, когда я могу настолько явно указать какой интерфейс должен предоставлять шаблонный тип. Это одна из тех возможностей, которую я очень жду в C++, но боюсь не дождусь. 
Продолжение

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

  1. Приятная статья. Так-то я не часто задумываюсь, что там opengl делает с текстурками.

    Как, определился с темами статей или это последняя в цикле?

    У тебя в коде для векторной математики используются обычные методы типа add_v, но в cgmath есть перегруженные операторы почти для всего. Это принципиальный момент? Вроде ты этот момент не комментировал)

    >> Из минусов пока заметил лишь очень большой объем файлов.

    https://crates.io/crates/zip ?

    ОтветитьУдалить
    Ответы
    1. >> Как, определился с темами статей или это последняя в цикле?

      Не знаю точно, нужно подумать, скорее всего еще одна будет

      >> У тебя в коде для векторной математики используются обычные методы типа add_v, но в cgmath есть перегруженные операторы почти для всего. Это принципиальный момент? Вроде ты этот момент не комментировал)

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

      Сейчас даже кажется, что так лучше, явно видно что происходит, легче найти реализацию.

      >> https://crates.io/crates/zip

      Да я видел, спасибо. Это скорее для релизной версии игры, когда модели больше не меняются. А я их часто вручную правлю, делать это в архиве не удобно, в любом случае на моих размерах моделей не стоит заморачиваться

      Удалить
  2. Готовая сборка почему-то...
    http://i.imgur.com/6Sl8x9b.jpg

    ОтветитьУдалить
    Ответы
    1. Может добавить сразу?)

      Удалить
    2. Это x86 версия наверное? Я похоже что-то с системой сборки накосячил похоже, попробую на выходных разобраться что не так. А пока возьмите это dll с https://github.com/ReanGD/rust-software-render/releases/tag/v02-x86-win в той версии она еще есть.

      Удалить
    3. Пересобрал x86 билды для 3-й и 4-й версии, включил туда эту dll

      Удалить