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

Software render на Rust: кубические текстуры

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




Для начала, расскажу что вообще такое CubeMap-текстуры. Это набор из 6 текстур, на которых изображено то, что мы бы увидели, если бы в текущей сцене посмотрели вдоль полуосей: +x, -x, +y, -y, +z, -z. Если составить их вместе, вдоль соответствующих полуосей, мы получим куб, который полностью содержит текущую сцену вокруг нас, собственно их поэтому и называют CubeMap-текстурами. Может кому-то будет понятнее прочитать про них на Wikipedia. В развёртке они могут выглядеть вот так:

Для их честного создания, нужно поворачивать камеру вдоль каждой оси и условно "фотографировать" сцену в текстуру. Там конечно нужно соблюдать некоторые условия по позиционированию камеры и т.п. я вдаваться в подробности не буду, как это можно сделать можно посмотреть тут. У меня к сожалению окружающей сцены никакой нет, в кадре только одна модель, поэтому я использую уже готовые текстуры. Большой набор бесплатных лежит вот тут.

Такие текстуры используют в основном для того, что бы быстро рисовать относительно честные отражения на объектах. Для начала вспомним как вообще в реальном мире формируются отражения. Лучи от источника света попадают на какой-то предмет, "окрашиваются" в его цвет и отражаются от него и какая-то их часть попадает на зеркальную поверхность, отражаясь от которой в свою очередь попадают к нам на сетчатку. Это очень упрощенно разумеется, нужно ещё учитывать преломления, тени, поглощение света и т.п. Вот картинка для понимания:
Алгоритмы с таким подходом конечно делают, получая при этом изумительные изображения, но вот затраты просто невероятны, один кадр может рисоваться часы, а то и больше. Если кому интересно называется этот подход "прямая трассировка лучей", в интернете информации полно.

Можно попробовать обратный подход. При рисовании каждого пикселя зеркальной поверхности "пускать" луч от камеры к рисуемому пикселю, в точке пересечения луча с пикселем, отражать его и смотреть с чем пересечётся отражённый луч. И цвет точки пересечения отражённого луча принимать за цвет отражения в рисуемом пикселе зеркала. Этот подход называется "обратная трассировка лучей":
Это уже на порядки менее ресурсоёмкий алгоритм, поскольку обрабатывать приходится только лучи непосредственно попадающие в камеру. Модель конечно физически не точная и качество при этом страдает.

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

Насчёт производительности тут тоже есть нюансы. Если используется не готовая, как в моём случае кубическая текстура, то для её создания потребуется фактически 6 раз нарисовать сцену. Что может быть гораздо более тяжёлой операцией, чем найти несколько пересечений отражённого луча с объектами сцены. Обычно помогают разного рода оптимизации: рисовать эти сцены для кубических текстур с более низким разрешением, с упрощёнными шейдерами, не рисовать мелкие детали, обновлять их не каждый кадр и т.п. Если у вас зеркальный объект грубо говоря не просто куб, а сложная модель, то подобные "оптимизации" не сильно бросаются в глаза.

Теперь о реализации. При рисовании каждого пикселя нам понадобится иметь вектор отражения луча исходящего из камеры к данной точке. Отражение считается относительно нормали к рисуемой точке. В принципе, т.к. треугольники у нас плоские (нет карты нормалей), то вполне допустимо будет посчитать эти отражения только для каждой вершины треугольника и потом интерполировать. Кто не помнит формулу, на habrahabr есть неплохая статья про расчёт вектора отражения. Традиционно приведу мою реализацию вершинного шейдера я там считаю во "view space", т.к. так получается на 1 операцию меньше.

Небольшое уточнение по кубическим текстурам - обычно считается, что куб который они формируют, находится от камеры и каждой точки зеркального объекта на таком большом расстоянии, что можно считать что каждый пиксель находится в центре этого куба. Это несколько упрощает расчёты. Теперь с учётом этого, нужно уже в пиксельном шейдере находить с какой из граней кубической текстуры должен пересекается отражённый вектор. Очевидно (хотя я бы не взялся так сходу написать строгое доказательство), что необходимо найти максимальную из проекций вектора на каждую из шести полуосей (+x, -x, +y, -y, +z, -z) и вдоль той полуоси, где проекция была максимальной и располагается необходимая текстура. Для примера, у нас есть вектор vec(0.5, 0.1, -0.6) - максимальной будет проекция на полуось "-z", она будет равна 0.6, следовательно, нужно брать ту текстуру, которая находится вдоль "-z".

Осталось только найти точку пересечение вектора с гранью куба. Сразу извинюсь - нарисовать это у меня не получилось, поэтому предлагаю развивать воображение. Исходные данные: у нас есть точка, к которой приложен вектор отражения vec(x, y, z) она, как я уже рассказывал, находится условно в центре куба, и для упрощения расчётов будем считать, что ещё в центре координат. Размер стороны куба выберем равными единице, из тех соображений, что текстурные координаты находятся в диапазоне от 0 до 1. И рассматривать будем частный случай, когда нам нужно найти пересечение вектора с гранью куба расположенной вдоль полуоси "+z". Исходя из размеров куба, его грань пересекает эту ось (+z) на высоте 0.5, поэтому и наш вектор отражения будет пересекать эту грань на той же высоте, поэтому промасштабируем вектор на значение 0.5/z, получим: vec(0.5*x/z, 0.5*y/z, 0.5), тем самым мы получим эту точку пересечения. Теперь сделаем параллельный перенос всей нашей системы на вектор vec(0.5, 0.5, -0.5), что бы грань куба находящаяся вдоль полуоси +z "начиналась" в начале координат и имела размеры вдоль осей x и y равными единице. Точка пересечения теперь vec(0.5*x/z, 0.5*y/z, 0.5) + vec(0.5, 0.5, -0.5) = vec( (x/z-1)/2, (y/z-1)/2, 0.0 ). Первые две координаты (x/z-1)/2, (y/z-1)/2 за счёт единичного размера куба фактически являются искомыми текстурными координатами, по которыми мы будем выбирать цвет отражения.

Надеюсь понятно объяснил, в любом случае рекомендую самим порисовать на бумаге и рассчитать точку пересечения. Для остальных граней формула будет рассчитываться аналогично, будут лишь меняться знаки и оси координат. Nvidia была так добра, что даже составила таблицу (см. раздел "Mapping Texture Coordinates to Cube Map Faces") для каждого случая. Единственное различие в формулах - у меня вторая текстурная координата (t в формулах по ссылке) имеет обратное направление, поэтому я везде в столбце "tc" изменил знак на противоположный. Ну и ещё пару слов о реализации: я разнёс основной пиксельный шейдер рассчитывающий освещение и шейдер рассчитывающий отражение в 2 разных, что бы не писать все возможные сочетания этих шейдеров. И сначала вызываю основной шейдер и потом, если установлена cubemap-текстура, его значение передаю в шейдер для расчёта 3d отражения, где рассчитываю по формулам выше отражение и смешиваю его с основным цветом. Мою реализацию попиксельного шейдера для отражений, вы найдёте вот тут.

Для начала проверим работу на самом простом случае - отражающая плоскость:
Теперь проверим для шара:
Дальше я даже не поленился записать видео для монстра:

Потом для кольца:

И ещё добавил новую модель черепа:

На этом я пожалуй закончу этот цикл статей, за столько месяцев я от этой темы устал. Возможно позже я вернусь к ней, но пока хватит.

В планах остались:

  • Оптимизация и многопоточная реализация, всё вместе может дать по моим прикидкам примерно 10-и кратное ускорение на сложных сценах.
  • Отсечение треугольников по zNear плоскости, которую я оставил на потом в самом начале и так и не добрался.
  • Управление камерой (не возможно без предыдущего пункта) и вообще нужно добавить хоть какой-то GUI.
  • Assimp для импорта моделей произвольного формата.
  • Возможно в комментариях предложат ещё интересные идеи.
Так же я готов принимать pull requests с исправлением ошибок и новой функциональностью, если у кого-то вдруг возникнет такая идея.

4 комментария:

  1. Крутой пост!

    С ржавчиной тоже завязываешь, наигрался?

    Как в итоге, после н-месячного знакомства, мнение о языке? Отъест себе место под солнцем? )

    ОтветитьУдалить
    Ответы
    1. В целом Rust понравился, но ИМХО сложно или даже скажем так - много не привычных концепций. А следовательно сложно будет взять разработчика на другом языке и быстро переучить на Rust. Но если наберется критическая масса людей, которые его знают, что бы не было проблемы найти на рынке таковых - то должен взлететь. В любом случае они показали очень интересный путь развития ЯП, если не Rust, то потом появится другой язык со схожей концепцией, который взлетит.

      На Rust я обязательно еще попробую пописать, в целом для "домашних" проектов он удобнее тех же плюсов.

      Удалить
  2. Упомянули в http://this-week-in-rust.org/blog/2015/11/23/this-week-in-rust-106 :)

    ОтветитьУдалить
    Ответы
    1. угу, я уже заметил нашествие google translate в статистике блога )

      Удалить