Задумывались ли вы как на вашем плоском экране формируется трёхмерное изображение? Почему отрисовка кадров в играх выполняется быстро, а в вашем 3D редакторе это занимает часы, даже если вы купили себе новомодную видеокарту. Давайте разбираться.
Главными двигателями прогресса графических технологий являются игры и кинематограф, но начнем мы с игр. Когда делать трёхмерные игры очень хотелось, а технологии не позволяли, люди придумывали способы имитации 3D. Первые попытки визуализировать трёхмерное пространство можно увидеть в играх subLOGIC Flight Simulator 1 (1979) и Battlezone (1980)
subLOGIC Flight Simulator 1, 1979
Battlezone, 1980
Эти игры использовали каркасный рендеринг (wireframe rendering). Объекты в пространстве представляются в виде точек. Некоторые пары точек соединены линиями. Для отображения такой сцены нужно спроецировать точки, которые находятся в зоне видимости, на экран, а после просто дорисовать линии между ними. О том как именно происходит проекция на экран мы поговорим немного позже.
Это все не выглядит особо впечатляюще, поэтому идём дальше. Первая приличная имитация 3D пространства была реализована в игре Wolfenstein 3D от idSoftware в 1992 году. Они впервые реализовали в игре метод рендеринга, который называется ray casting.
Wolfenstein 3D
Так как этот метод очень старый, то русскоязычное название вы нигде не встретите. Поэтому в статье будет использоваться англоязычный вариант. В методе ray casting сцена представляется в виде изображения на котором размечены стены. В этой сцене определяется положение камеры и её угол обзора, а после из камеры пускаются лучи и для каждого из них ищется точка столкновения. Количество лучей равно количеству пикселей у монитора по ширине. Дальше для каждого луча мы рисуем вертикальный сегмент стены, размер которого зависит от того насколько далеко от камеры находится точка столкновения. Отрисовав все вертикальные сегменты, мы получаем изображение стен с имитацией линейной перспективы.
Дополнительно можно окрасить стены в определенный цвет или же наложить на них текстуру. Для этого на нашей карте стен мы помечаем стены определенными цветами и связываем каждый цвет с определенной текстурой. Методика отрисовки при этом не меняется. Просто при рисовании вертикальных сегментов стен будет браться соответствующий сегмент из текстуры.
Разумеется этот метод довольно ограниченный. Трёхмерными являются только стены, а все остальные объекты реализуются в виде спрайтов, которые все время направлены к камере.
Wolfenstein 3D была не первой игрой которая использовала ray casting. Например, годом ранее idSoftware выпустила игру Catacomb 3-D, которая использовала тот же метод рендеринга. Но так исторически сложилось что игра Wolfenstein 3D запомнилась больше. Ну и конечно позже вышел всем известный Doom, в котором геометрия уровней была намного сложнее чем в предыдущих играх. Для этого использовались изображения, которые хранили дополнительную информацию о стенах, например, высоту.
Catacomb 3-D, Doom
Теперь мы можем перейти к настоящей 3D графике. Начиная с 1994 года стали появляться игры которые использовали метод растеризации. Этот способ рендеринга используется в трёхмерных играх и по сей день. А первой игрой которая полноценно внедрила растеризацию в свой движок можно считать Descent, 1994 года. Стоит сказать, что никаких ускорителей 3D-графики на тот момент еще не существовало. Так как не было общепринятого метода рендеринга 3D графики, то и укорять было нечего. И именно растеризация способствовала появлению видеокарт с 3D ускорителями.
Descent, 1994
Принцип растеризации довольно простой. Все объекты состоят из треугольников, они же полигоны. Почему треугольники? Они состоят из минимального количества вершин, которыми можно описать плоскость. Соответственно они не могут быть выпуклыми или вогнутыми и поэтому являются минимальной структурной единицей.
Чтобы нарисовать объект, нам нужно нарисовать каждый полигон из которого он состоит. Для этого все вершины проецируются на плоскость экрана. Затем для каждого полигона закрашиваются пиксели между его вершинами. Весь процесс изображен на картинках ниже.
Для оптимизации, полигоны которые перекрываются другими полигонами не участвуют в отрисовке. Это легко определить, такие полигоны направлены в противоположную сторону от камеры. Поэтому когда вы в игре проваливаетесь внутрь объекта, то можете видеть сквозь него. Так как изнутри видны обратные стороны полигонов, движок пропускает их отрисовку. Но все же это правило не всегда применимо. Иногда нам нужно чтобы полигоны были видны с обеих сторон. Например, листва на деревьях, которая часто создается из пересекающихся плоскостей.
Порой, при рисовании полигона его место уже может занимать другой полигон. В этом случае нам нужно понять, стоит ли рисовать новые пиксели поверх старых или же пропустить их. Для этого используется z-буфер. Всё что он делает — это хранит глубину каждого пикселя на экране. При отрисовке, глубина пикселей сравнивается между собой и приоритет отдается тем пикселям что ближе к экрану.
Проекция точек на экран из примера выше называется ортогональной проекцией. И она не совсем подходит для рендеринга 3D графики, так как в ней отсутствует перспектива. Перспектива — это когда дальние объекты кажутся меньше, а параллельные линии сходятся в одной точке. И чтобы достичь такого эффекта нам нужно использовать перспективную проекцию.
Слева — ортогональная проекция, справа — перспективная
Думаю из изображения выше суть понятна. В перспективной проекции с каждой вершины проводятся линии к точке за экраном, назовём ее точкой обзора. Места, где эти линии пересекают экран и являются проецированными точками. От положения точки обзора напрямую зависит область видимости камеры. Технически такая проекция реализуется очень несложно, так как это простая геометрия.
В компьютерной графике все геометрические трансформации и проекции выполняются не с помощью формул, а путем умножения вершин на матрицы. Числа в матрице определяют какое-то преобразование: перемещение, поворот и т. д. Можно перемножить между собой 3 матрицы и тогда результирующая матрица будет содержать в себе сразу все 3 преобразования. Таким образом можно умножить каждую вершину на одну матрицу, а не на три, что сильно экономит вычислительные ресурсы. Собственно этим и занимаются видеокарты — умножением матриц.
Первая видеокарта с ускорителями 3D графики была выпущена в 1996 году компанией 3dfx Interactive, а называлась она Voodoo Graphics. И в ней не было ускорителя для двумерной графики, а значит приходилось ставить две видеокарты: для р̶а̶б̶о̶т̶ы̶ ̶и̶ ̶и̶г̶р̶ 2D и 3D графики.
Кстати, как обычно это и происходит, игры ничего нового не изобрели, а лишь заставили это работать в реальном времени. Растеризация использовалась и раньше, в исследованиях и кинематографе. Например, фильм «Трон» 1982 года, тоже использовал растеризацию, но тогда им понадобился суперкомпьютер.
Перейдем к недостаткам метода растеризации. Один из них — это сложность создания реалистичного освещения. Для затенения объектов для каждого полигона вычисляется угол под которым на него подают солнечные лучи, то есть угол между нормалью полигона и солнечным лучом. Он определяет освещенность полигона и соответственно цвет пикселей. Но при таком подходе можно видеть четкие границы полигонов. Чтобы от них избавится, проводится интерполяция углов между нормалями. В зависимости от свойств материала можно реализовать блики, изменение оттенка и т. д. Большинство игр сегодня используют PBR (physically based rendering) — физически корректный рендеринг. Который описывает, как должны материалы реагировать на свет.
На последней картинке показывается как разные материалы реагируют на свет, согласно PBR.
Но вся физическая корректность рушится, когда дело доходит до теней. Единственное что мы можем делать в растеризации — это имитировать тени. Самый банальный способ отображения теней заключается в рендеринге сцены с позиции источника света. После чего мы можем получить данные о том какие части сцены находятся в тени и понизить освещенность соответствующих пикселей.
Но у этого способа не достаточная точность и иногда тени немного сдвинуты относительно объектов. Этот классический недостаток получил забавное название «Питер Пеннинг» (Peter Panning). В добавок ко всему тень совершенно не реалистичная. В жизни края тени размываются по мере удаления от объектов, а здесь тень получается абсолютно жёсткой. Её можно разве что размыть по всему периметру.
На самом деле мягкие тени разработчики уже научились делать. Но алгоритмы рендеринга таких теней довольно сложные и требовательные к ресурсам. Программисты которые придумали эти методы рендеринга теней наверняка имеют огромные бороды, поэтому разбирать данные методы мы не будем. Интересно что проблема настолько серьезная, что некоторые видеокарты реализуют аппаратное ускорение для определенных методов рендеринга теней.
Так же освещение в растеризации страдает из-за невозможности реализовать глобальное освещение (global illumination). Дело в том что лучи света постоянно отражаются и освещают места куда свет не попадает на прямую. Поэтому тени никогда не бывают абсолютно черными. На изображении ниже можно увидеть насколько огромной может быть разница в освещении, если не учитывать глобальное освещение. Опять же, есть только способы имитации этого эффекта такие как SSGI, VXGI и прочие непонятные аббревиатуры.
Невозможно создать и достоверные отражения. Есть один хак который помогает частично решить эту проблему — это создание кубических карт. Кубическая карта (cube map) — это изображение которое хранит в себе пространство вокруг объекта. С их помощью можно просчитать, что именно должно отражаться в определенной точке. Но они не подходят для динамических сцен, где объекты или источники света могут двигаться, потому что объекты будут отражать только то, что сохранено в кубической карте.
Классическая проблема при использовании кубических карт — персонаж не отображается на стеклянной двери.
Последнее на что стоит обратить в методе растеризации — это проблема сортировки. Возникает она при рендеринге полупрозрачных объектов. Посмотрите на ниже приведенное изображение. В сцене находится две полупрозрачные панели. При этом зелёный куб за первой панелью не отображается. Чтобы понять почему это произошло вспомним z-буфер. Если на месте отрисовки полигона уже присутствуют пиксели другого полигона, который ближе к камере, то отрисовка новых пикселей пропускается. То есть, на сцене ниже, сначала отрисовалась полупрозрачная панель, а потом куб. Так как панель ближе к камере, при отрисовке куба пиксели которые находятся за панелью были пропущены.
Чтобы избежать данной проблемы, объекты рисуются в порядке от дальнего к ближнему. Таким образом полупрозрачные пиксели будут налаживаться на предыдущие. Но это тоже не всегда работает. Могут быть сложные объекты в которых полупрозрачные полигоны могут пересекаться с непрозрачными и где сортировкой объектов не обойтись. Чаще всего таких ситуаций художники пытаются избегать. Но в крайнем случае реализуют метод порядко-независимой прозрачности (order-independent transparency). Суть метода лежит в сохранении каждого пикселя в точке, не зависимо от того какую глубину он имеет. Дальше эти пиксели сортируются между собой по глубине и рисуются в нужном порядке. То есть мы сортируем не объекты, а пиксели. Такой метод требует больше памяти, менее производителен и более сложен в реализации, поэтому его сложно считать панацеей.
Что ж, растеризация имеет множество ограничений, тем не менее до сих пор используется как основной метод рендеринга в играх. Все благодаря тому, что этот метод достаточно производителен, чтобы работать в реальном времени. Теперь давайте посмотрим, как можно получить реалистичное изображение, если не ограничивать себя всеми требованиями к производительности которые необходимы играм.
Хотя NVIDIA в рамках своей маркетинговой компании для линейки RTX и пыталась провести либез по трассировке лучей, кажется все еще никто толком не понимает, как она работает. С одной стороны, суть метода рендеринга довольно проста, а с другой, существуют разные способы его реализации. Давайте разбираться по порядку.
Принцип трассировки лучей лежит в имитации солнечных лучей. Как упоминалось ранее, когда луч света попадает на поверхность, он может отразиться в любом направлении. Это потому что даже гладкие объекты имеют микроскопические неровности. Если же объект действительно гладкий, то мы получаем зеркальный материал. В этом случае все лучи отражаются примерно в одном направлении.
Но нас интересуют только лучи которые в итоге попали в камеру. Поэтому для рендеринга мы стоим лучи в обратном порядке (для каждого пикселя): от камеры, к источнику света. В первую очередь ищется точка столкновения с объектом, затем стоится луч к источнику света. Как и в растеризации, угол между нормалью полигона и падающим светом определяет освещенность. Если луч к источнику света пересекает какой либо объект значит точка находится в тени и освещенность нулевая.
Но пока что, такой подход ничем не лучше растеризации. И даже хуже, ведь нам теперь нужно искать столкновения с объектами, а это намного ресурсозатратней чем просто проецировать полигоны на экран. Поэтому обычно используется более сложная версия трассировки путей, которая называется path tracing, или же трассировка путей. Она позволяет симулировать многократное отражение солнечных лучей и тем самым реализовать эффект глобального освещения.
В этой вариации алгоритма из точки столкновения, помимо луча к источнику света, бросается еще несколько дополнительных лучей в случайном направлении. Для каждого такого луча так же определяется столкновение и освещенность в этой точке. После чего мы можем определить влияние каждого такого луча на освещенность нашей изначальной точки (пикселя). Чтобы результат был более реалистичным мы можем рекурсивно повторять процесс бросания дополнительных лучей для каждого дополнительного луча. Сложно? Смотрим картинку и видео ниже.
Обратите внимание как окружающие объекты могут влиять на освещенность. Сфера справа кажется слишком светлой, но именно такой она и должна быть, её освещает плоскость под ней.
В добавок могут быть разные типы лучей для разных материалов. Например, преломляющие лучи для стекла или отражающие лучи для зеркальных материалов. Всё это зависит от реализации алгоритма. Разный движки рендера реализуют трассировку лучей по разному, но принцип один, мы бросаем лучи в разных направления чтобы оценить влияние окружающих объектов на освещенность в точке.
Настройки количества разных лучей в Блендере
Так как количество дополнительных лучей ограничено, то оценка влияния окружающих объектов на изначальную точку не особо точная. Из-за этого в соседних точках (пикселях) освещенность может быть разной даже если она должна быть одинаковой. Из-за этого изображения с использованием трассировки лучей всегда имеют шум. Чтобы от него избавится нужно увеличить кол-во лучей, но это в свою очередь значительно увеличит время рендеринга. Более простой вариант — это использовать фильтры шумоподавления или денойзеры (denoisers). Самые лучшей денойзеры работают на основе искусственного интеллекта. Например, Intel Open Image Denoise или NVIDIA OptiX. Они способны убрать шум за несколько миллисекунд.
Слева — оригинальное изображение, справа — с применением NVIDIA OptiX
Даже с использованием шумоподавления рендеринг одного изображения может занимать минуты, а то и часы. И тут настало время поговорить о видеокартах NVIDIA с технологией RTX, которые реализуют аппаратное ускорение трассировки лучей. С их помощью можно использовать трассировку лучей даже в играх, в реальном времени! Ведь так? Ну… эээ… может быть.
Обладатели данных видеокарт могли заметить, что хотя Киберпанк 2077 действительно выглядит круто, рендеринг 3D изображений в любом редакторе по прежнему занимает бесконечное количество времени. Революции не произошло. Почему же так? На самом деле в играх трассировка лучей используется очень ограниченно, она лишь дополняет растеризацию. Например, на ее основе можно реализовать глобальное освещение или отражения, то есть прикрыть слабые места растеризации. А рендерить всю сложную сцену трассировкой в реальном времени все еще не возможно. Но это и не нужно, гораздо лучше комбинировать разные методы рендеринга для баланса производительности и реалистичности картинки.
Казалось бы тут можно закончить. У нас есть два метода рендеринга, которые идеально перекрывают слабые стороны друг друга. Но есть еще один метод который нельзя пропустить.
Так как русское название термина не прижилось, то дальше я буду использовать английский вариант. Ray marching используется для определённого класса задач и на нем сложно реализовать реалистичную графику. Это очень простой метод рендеринга, но объяснить его принцип на словах не просто. Поэтому предлагаю сначала посмотреть видео.
И так, разберем по шагам:
Для пикселя на экране определяется расстояние до ближайшего объекта.
Это расстояние определяет радиус на который мы можем пустить наш луч. Пускаем луч.
Для конечной точки луча пункты 1 и 2 повторяются до тех пор пока следующий радиус не станет достаточно маленьким, что будет означать столкновение с объектом. Или же радиус может начать стабильно увеличиваться, если луч прошел мимо всех объектов.
Выполнив этот процесс для всех пикселей мы можем получить изображение сцены. Для пикселей которые отображают столкновение с объектом можно даже реализовать простейшее затенение опираясь на угол под которым падает свет. Но зачем вообще нужен этот метод, когда есть другие? Как можно заметить основное действие которое в нем происходит — это поиск расстояния до объекта. Обычно чтобы найти минимальное расстояние, нам нужно найти расстояние до каждого полигона из которого состоит объект и выбрать меньшее. Но это требует много ресурсов, поэтому ray marching используется для рендеринга объектов для которых существует функция расстояния (signed distance function).
Функция расстояния позволяет нам вычислить расстояние до объекта используя информацию о его положении в пространстве. И нам не нужно считать расстояние до каждого полигона. На самом деле полигоны в ray marching даже не используются. Если нам нужно отобразить сферу, то мы просто указываем ее положение и радиус, и этого будет достаточно чтобы ее отрисовать. Функция расстояния существует для большинства простых фигур, но также для сложных фрактальных структур.
Фрактал под названием «Оболочка Мандельброта». Легко рендерится с помощью ray marching
Ещё функции расстояния можно модифицировать, чтобы выполнять булевы операции над объектами или плавно сливать объекты в один. Это все делается без каких либо дополнительных вычислений.
Чаще всего этот метод используется для рендеринга воксельной геометрии. Так как воксели — это кубы, а расстояние до куба рассчитать очень просто, то можно отображать сцены которые состоят из миллионов вокселей. Сразу приходит на ум игра Teardown. Да, она полностью рендерится с использованием метода ray marching. Кстати у неё неплохая графика, а я сказал что реалистичной графики с этим методом не сделать. Обманул? На самом деле когда вся сцена состоит из кубов, то реализовать хорошую графику не сложно. Но если бы в игре были другие фигуры, то это бы все усложнило.
Ещё эта игра использует трассировку лучей для некоторых эффектов, то есть движок рендеринга там на самом деле гибридный. Ray marching часто используют в связке с другими методами рендеринга, в том числе с растеризацией.
Так же этот метод подходит для рендеринга объёмных эффектов (volumetric effects) таких как дым, туман, огонь или сумеречные лучи (god rays). Такие эффекты как правило состоят из огромного количества кубов, а значит с помощью ray marching мы можем рендерить эти эффекты в реальном времени, но только если не просчитывать физику. Правда так как дым полупрозрачный, то нам нужно находить не только первое столкновение с кубом, но и с несколькими последующими чтобы оценить густоту дыма. Но все равно это возможно делать в реальном времени благодаря производительности алгоритма.
Эти эффекты очень важны для достижения реалистичной картинки. Вся наша планета заполнена воздухом, а он не совсем прозрачный и влияет на освещение. Именно благодаря объемным эффектам графика в Red Dead Redemption 2 выглядит круто даже без трассировки лучей. На эту тему есть прекрасное видео от Digital Foundry в котором показывается значимость волюметрических эффектов.
Компания Atomontage разрабатывает движок который способен отображать сцены что состоят из микровокселей. Это воксели которые настолько маленькие что их даже трудно заметить. В компании уверенны что за воксельной геометрией будущее и она сможет заменить полигональную геометрию. В любом случае производительность их движка впечатляет.
На этом все. Эта статья основана на моём видео «How does 3D graphics actually work». Если вы прочитали статью до конца, то ничего нового вы в нем не узнаете. Но там есть много визуализаций которые помогут укрепить понимание методов рендеринга.