Контекст рендеринга что это
Как работает тайловый растеризатор
Если вы следили за моей серией постов «Растеризация за одни выходные», но не компилировали и не запускали демо, то для вас станет большим сюрпризом, если я скажу, насколько медленными они оказались. В конце серии постов я упомянул существующие техники, позволяющие ускорить мучительно тормозной растеризатор. Теперь настало время двигаться дальше и посмотреть, как они применяются на практике.
Как часть этого проекта я реализовал Tyler — тайловый растеризатор, который мы проанализируем в данной статье. Моей целью при разработке этого проекта были масштабируемость. настраиваемость и понятность растеризатора для людей, которые хотят немного больше понять в этой теме и поэкспериментировать с ней. Эта статья достаточно сильно связана с тем, что объяснено в серии «Растеризация за одни выходные», поэтому лучше будет прочитать и её. Я не буду предполагать, что вы её изучили, но в статье будет больше высокоуровневых объяснений — я не хочу повторять уже сказанное и то, что можно найти в других источниках.
Краткий обзор
Тайловый рендеринг (tile-based rendering или tiled rendering) — это улучшенный по сравнению с традиционным immediate-mode-рендерингом; в нём render target (RT) разделяется на тайлы (т.е. субрегионы кадрового буфера), каждый из которых содержит примитивы, которые можно рендерить в тайлы по отдельности.
Обратите внимание на выражение «по отдельности», потому что оно подчёркивает одно из самых больших преимуществ этой техники по сравнению с immediate-mode: ограничение всех операций доступа внутри тайла буферами цветов/глубин, которые остаются в «медленной» DRAM благодаря использованию отдельного тайлового кэша на чипе. Разумеется, эта экономящая пропускную способность технология в основном используется на мобильных маломощных GPU. Посмотрите на рисунок ниже: в верхней части вершины обрабатываются, растеризируются и затеняются сразу же, а в нижней части операции отложены.
На высоком уровне для работы тайлового рендеринга в дополнение к обычной механике растеризатора достаточно построить для каждого тайла список примитивов, которые накладываются на данный тайл, а затем затенить все примитивы во всех тайлах по одному или, что даже лучше, параллельно. Разумеется, в таком описании теряются детали, поэтому ниже мы подробно рассмотрим реализацию Tyler.
Этот растеризатор реализует только небольшое подмножество современного конвейера 3D-рендеринга и состоит из следующих этапов, которые проходят треугольники, от вершинных буферов до пикселей на экране:
Подготовка контекста растеризатора
Основной цикл рендеринга
На стороне приложения не происходит никакой магии: после подготовки контекста рендеринга стандартной конфигурацией, обычной привязки буфера кадров, буферов вершин/индексов, шейдеров и всего остального объекты рендерятся под одному мешу за раз. При этом свою задачу выполняет тайловый растеризатор; мы рассмотрим строку 18, в которой срабатывает вызов отрисовки. Обратите внимание на комментарий о том, что растеризатор блокирует поток вызвавшей функции, пока не будет обработан и завершён текущий вызов отрисовки. Очевидно, это сильно отличается от того, как работают реальные API и аппаратные растеризаторы: они никогда не обрабатывают только один вызов отрисовки за раз и не выполняют блокировку до его завершения; это было бы ужасно неэффективно. Вместо этого они обрабатывают «пакеты» (batches) команд, которые приложения записывают в буфер команд и при помощи графических драйверов передают оборудованию. Основные причины этого: 1) минимизация избыточного планирования работ CPU GPU и 2) увеличение использования ресурсов GPU.
Теперь, когда мы примерно поняли, как сэмпл выполняет вызов отрисовки, можно переходить к тому, что происходит в растеризаторе. Если вы взглянете на каталог исходников проекта, то на этот раз увидите намного больше файлов. Однако самое важное обычно находится в Pipeline Thread и в Render Engine, а всё остальное обеспечивает вспомогательную функциональность. Render Engine обрабатывает подготовку вызовов отрисовки и различных ресурсов, необходимых для тайлового растеризатора, например групп для каждого тайла и буферов масок покрытия, а также обеспечивает синхронизацию между Pipeline Threads, каждый из которых порождает собственный поток для параллельного выполнения вышеупомянутых этапов конвейера. Как же Render Engine подготавливает вызов отрисовки для выполнения потоками конвейера?
Здесь мы равномерно разделяем входящие примитивы по потокам конвейера, чтобы масштабировать растеризатор при увеличении доступных потоков. Возможно, вы спросите, а что насчёт итераций? Они возникают потому, что мы никак не можем заранее узнать, будет ли следующий вызов отрисовки содержать 6 треугольников или 5 миллионов треугольников, и мы совершенно не хотим динамически выделять внутренние буферы или изменять их размер; так растеризатор перестал бы работать в реальном времени! Вместо этого мы задаём верхнюю границу (она может быть произвольно большой, мы подумаем, каким должен быть подходящий размер итераций) для наших внутренних буферов, выделим их один раз, а во время отрисовки будем просто выполнять итерации по наборам примитивов, разделённых на итерации, что логически аналогично выполнению этого всего за одну итерацию. Это похоже на то, как с этой неизвестностью справляются аппаратные растеризаторы.
Пример подготовки вызова отрисовки: 3 потока, параллельно обрабатывающих по 6 примитивов на итерацию
Могут возникать различные граничные случаи, например, когда количество примитивов не кратно количеству потоков или даже меньше количества потоков, поэтому мы здесь мы обрабатываем и их. Наличие верхней границы для внутренних буферов оправдывает себя, однако требует от нас дополнительного контроля, например, нужно делать недействительными устаревшие данные во внутренних структурах данных и кэшах перед каждой итерацией. Напоследок стоит заметить, что нужно, чтобы все потоки входили в соответствующие конвейеры не раньше того, пока есть хотя бы один поток, параметры вызовов отрисовки которого пока не назначены, чтобы это не вызывало проблем синхронизации; поэтому мы используем атомарный флаг m_DrawcallSetupComplete для приостановки потоков до завершения подготовки вызовов отрисовки. После этого потокам даётся зелёный свет и мы ждём, пока все итерации текущего вызова отрисовки обрабатываются потоками, чтобы вернуть исполнение в поток вызвавшей функции.
Теперь, когда мы увидели, как потоки включаются для обработки вызова отрисовки, можно внимательнее посмотреть на сам конвейер.
Обработка геометрии
Обработкой геометрии называется обработка вершин, получаемых из пользовательских буферов. Она состоит из этапов затенения вершин, усечения, подготовки треугольников и создания групп. Помните, что к этому моменту все потоки будут выполняться параллельно!
Обработка геометрии. при которой каждый поток итеративно обходит примитивы в заданном ему интервале
Конвейер, как и раньше, начинает с вершинного шейдера (VS), задача которого заключается в получении вершин/индексов из назначенных буферов вершин/индексов и в вызове заданной пользователем процедуры VS, возвращающей позиции в пространстве усечения, а также передающей атрибуты затенённым вершинам. Кроме того, он использует небольшой кэш вершинного шейдера, индивидуальный для каждого потока. Если вершина индексированного меша будет присутствовать в VS$, то вместо повторного вызова VS, кэшированная позиция пространства усечения и атрибуты вершин копируются непосредственно из кэша, что может сэкономить значительный объём вычислений при большом количестве вершин. В противном случае мы вызываем VS обычным образом и помещаем копию возвращённых данных вершин в VS$. После получения позиции в пространстве усечения и атрибутов мы также вызываем CalculateInterpolationCoefficients(), который вычисляет и сохраняет данные интерполяции, которые нам понадобятся для интерполяции атрибутов вершин перед их передачей в процедуру FS. Заметьте, что мы сохраняем в цикле копию преобразованных вершин, для эффективности критически важно, чтобы они хранились в L1/L2/L3 (перечисление от наилучшего до наихудшего случая), пока поток обрабатывает этапы геометрии.
После завершения VS и получения атрибутов вершин мы продолжаем усечением треугольником по пирамиде видимости и вычислением ограничивающих параллелограммов примитивов, т.е. ограничивающей площади экранного пространства. Как видно из названия функции, мы усекаем примитивы по дробности полных треугольников. Замечательно, но что это означает?
На показанном выше рисунке плоскости X-W (взятого из статьи Блинна Calculating Screen Coverage) тёмно-серая область в верхней части обозначает точки перед глазом, то есть полностью видимые, а нижняя область представляет точки за глазом. В функции усечения мы проверяем, находятся ли все три вершины треугольника полностью внутри видимой области, или в области Trivial-Accept (TA) или же полностью снаружи, или в области Trivial-Reject (TR). Если в TA, то мы можем безопасно вычислить ограничивающий параллелограмм примитива, применив однородное деление (например, деление на W), а если в TR, то мы можем без проблем отбросить весь примитив. В противном случае примитив должен иметь вершины в разных частях пирамиды видимости, то есть его нужно усекать плоскостями и отрисовывать частично. Однако особенностью алгоритма однородной растеризации является то, что его метод преобразования сканов может рендерить даже частично видимые примитивы, что подробнее рассматривалось в предыдущих постах и оригинальной статье. Следовательно, если возникает состояние «необходимо усечение» (т.е. треугольник не в TR и не в TA), то мы зададим ограничивающий параллелограмм (чрезвычайно консервативно!) как границы всего экрана.
После завершения усечения по полным треугольникам мы продолжаем этапом подготовки отсечения треугольников (Triangle Setup and Culling), на котором вычисляются знакомые коэффициенты уравнений рёбер. В отличие от реализации «Растеризации за одну неделю», на этот раз я реализовал оптимизации исходной статьи, которые позволяют умным образом избавиться от необходимости инвертирования матрицы вершин:
Ещё одно преимущество этой техники заключается в том, что она позволяет нам использовать одинаковые коэффициенты уравнений рёбер и для преобразования сканов, и для интерполяции атрибутов. В прошлый раз мы вычисляли вектор интерполяции параметров для каждого отдельного параметра, что может быстро стать узким местом, если у нас будет больше атрибутов, а не просто несколько скалярных значений, например, нормалей + координат текстур. После завершения подготовки треугольников (Triangle Setup) мы перешли к очень важной части тайлового растеризатора, а именно к группированию (Binning) (многие понятия которого превосходно были объяснены не кем иным, как самим Майклом Абрашем). Роль Binning-а в конвейере огромна: он должен находить, какие тайлы покрывают какие примитивы, или, иными словами, какие примитивы накладываются на какие тайлы. Стоит учесть, что тайл — это подобласть render target, по умолчанию состоящая из 8×8 блоков.
Для этого мы просто смотрим на отдельный треугольник и на то, как он будет обрабатываться, но помните, что всё это выполняется параллельно несколькими потоками. И реализовать это легко: для каждого тайла мы выделяем массив пересекающихся примитивов на каждый поток. То есть поместив примитивы группы каждого потока в собственную группу каждого потока, мы можем заставить их работать одновременно и без необходимости синхронизации. Кроме того, таким образом мы сохраним порядок рендеринга примитивов, позже пройдя по сгруппированным примитивам в порядке потоков.
Цветными стрелками обозначены нормали рёбер, а прямоугольником — ограничивающий параллелограмм треугольника.
Исходя из примитива, его ограничивающего параллелограмма и коэффициентов уравнений рёбер, группа предоставляет нам следующую информацию:
Процесс группирования (binning) мы начинаем с того, что сначала находим минимальный и максимальный индексы тайлов, покрываемых ограничивающим параллелограммом треугольника:
После получения коэффициентов уравнений рёбер мы задаём углы TR и TA каждого ребра:
Отмечен угол TR для каждого тайла ребра 2 (ось X направлена вправо, ось Y — вниз!)
Угол TR тайла — это угол, находящийся наиболее глубоко в ребре, а угол TA — наиболее снаружи. Углы TR и TA нужны нам потому, что если мы придём к выводу, что угол TR любого ребра находится за пределами ребра, то весь тайл должен быть за пределами треугольника, а значит, его можно тривиально отбросить. Аналогично, если углы TA всех рёбер находятся внутри соответствующих рёбер, то весь тайл должен находиться внутри треугольника (или наоборот), а следовательно, его можно тривиально принять.
Способ поиска этих углов в приведённом выше фрагменте кода — одна из самых важных частей тайловго растеризатора: мы выбираем углы TR/TA на основании наклона нормали ребра =(a, b) (a, b — это компоненты x и y нормали). На показанном выше рисунке мы можем видеть, что для ребра 2 (т.е. для синей стрелки) угол TR для всех тайлов помечен как правый нижний. Почему? Для ребра 2 справедливо (a > 0), то есть ребро должно удлиняться влево, то есть один из правых углов (нижний или верхний) должен быть ближе к ребру, чем левый. Аналогично, (b > 0), то есть угол TR должен быть правым нижним углом. У нас есть два варианта нахождения угла TA: или применить ту же логику, или положиться на тот факт, что самый дальний угол от угла TR будет находиться наиболее снаружи, что находится на диагонали от угла TR, что более оптимально.
Найдя углы TR и TA всех трёх рёбер, мы переходим в цикл, где итеративно обходим тайлы в интервале [minTile
Очередь растеризатора — это простой FIFO индексов тайлов, ожидающих растеризации (в случае, если тайл пересекается с примитивом) или затеняемых пофрагментно (тайлы, одобренные по TA) на следующих этапах. На этом можно завершить обработку геометрии и перейти к растеризации. Для сравнения вот пример Hello, Triangle! по умолчанию и тривиально принимаемые тайлы:
Растеризация
Прежде чем любой из потоков сможет перейти к этапу растеризации, они простаивают, пока все потоки не достигнут этапа постгруппировки (post-binning), который необходим для определения того, что все примитивы были сгруппированы в тайлы перед началом работы растеризатора. Как только все потоки завершат все этапы обработки геометрии, они начинаю обрабатывать тайлы из очереди растеризатора.
Если вы поняли идею группирования, то суть растеризатора очень проста: мы применяем тот же набор проверок TR/TA, на этот раз на уровне блоков, спускаясь с уровня тайлов:
Потоки получают следующий доступный индекс тайла из очереди растеризатора и обрабатывают этот тайл, снова параллельно применяя вышеописанные проверки ко всем примитивам, сгруппированным и привязанным к этому тайлу.
Как и на этапе группировки, при помощи ограничивающего параллелограмма мы находим максимальный и минимальный индексы блоков, которые пересекаются с примитивов, определяем углы TR/TA блоков для всех рёбер и обходим в цикле весь интервал, проверяя, можно ли подвергнуть блоки TR или TA, или полностью проигнорировать. Если блок снова отбрасывается TR, мы продолжаем двигаться дальше. Если он удовлетворяет TA, то мы создаём маску покрытия для всего блока и продолжаем. В противном случае нам нужно опуститься ещё ниже, на уровень пикселей, чтобы выполнять проверку рёбер и создавать маски покрытия для пикселей. Удобно то, что благодаря SIMD мы можем растеризировать несколько пикселей параллельно:
Хитрость здесь заключается в том, что лишние затраты на растеризацию оправдывают себя только на очень мелком уровне, а большинство сгруппированных треугольников тайла больше, чем один блок, потому что в противном случае нам придётся отбросить все вычисления, сделанные на более высоких уровнях; именно поэтому в аппаратных реализациях обычно существуют различные оптимизации для мелких треугольников.
Затенение фрагментов
Достигнув этапа пострастеризации, мы сталкиваемся с ещё одной точкой синхронизации:
Сделав так, чтобы все потоки достигли этапа затенения фрагментов (FS) одновременно после простоя до завершения растеризации, мы переходим к последнему этапу конвейера. Как и этап растеризации, этап FS параллельно работает с тайлами, полученными из очереди растеризатора, используя маски покрытия, созданные нами сначала на этапе группирования (Binning), а затем на этапе растеризации.
Прежде чем мы вызовем пользовательскую процедуру FS, нам сначала нужно вычислить то, что называется базисными функциями параметров, которые являются непосредственной реализацией метода, описанного в статье, ссылку на которую я давал выше. Суть в том, что вместо вычисления вектора интерполяции для каждого параметра, мы находим базисные функции, которыми можно интерполировать (корректно с точки зрения перспективы) любой параметр:
Реализация для разных масок покрытия (тайлов/блоков/четырёхугольников) почти одинакова, поэтому для полноты обзора я использовал маски четырёхугольников. Заметьте, что это самый сжатый путь выполнения кода, поэтому мы стремимся как можно активнее использовать SIMD.
После вычисления базисных функций мы сначала интерполируем значения по Z и выполняем тест глубин. Запомнив результат теста глубин, мы интерполируем все остальные пользовательские атрибуты, заданные после VS, передаём их в процедуру FS, которую вызываем следующей, и получаем выходные данные цвета для фрагмента из 4 сэмплов. Для тайлов/блоков у нас есть одна маска записи, которая является результатом теста глубин, потому что мы точно знаем, что все одобренные TA тайлы/блоки видимы. Для четырёхугольнка фрагментов мы также используем маску покрытия для сохранения по маске в буфер кадров значений глубины и цвета. И на этом мы завершаем наше исследование простого тайлового растеризатора.
В заключение
Теперь, когда мы закончили изучение внутренней работы тайлового растеризатора, в голову приходят идеи для экспериментов и его улучшения:
Обязательная сцена Sponza из примерно 275k треугольников, отрендеренная на моём ноутбуке с Intel i7 6700-HQ примерно за 60 мс
Справочные материалы
В основном я вдохновлялся/заимствовал/копировал следующие статьи и исследования, поэтому если вы хотите знать больше, то изучите их:
Learn OpenGL. Урок 5.9 — Отложенный рендеринг
В предыдущих статьях мы использовали прямое освещение (forward rendering или forward shading). Это простой подход, при котором мы рисуем объект с учётом всех источников света, потом рисуем следующий объект вместе с всем освещением на нём, и так для каждого объекта. Это достаточно просто понять и реализовать, но вместе с тем получается довольно медленно с точки зрения производительности: для каждого объекта придётся перебрать все источники света. Кроме того, прямое освещение работает неэффективно на сценах с большим количество перекрывающих друг друга объектов, так как большая часть вычислений пиксельного шейдера не пригодится и будет перезаписана значениями для более близких объектов.
Отложенное освещение или отложенный рендеринг (deferred shading или deferred rendering) обходит эту проблему и кардинально меняет то, как мы рисуем объекты. Это даёт новые возможности значительно оптимизировать сцены с большим количеством источников света, позволяя рисовать сотни и даже тысячи источников света с приемлемой скоростью. Ниже изображена сцена с 1847 точечными источниками света, нарисовання с помощью отложенного освещения (изображение предоставил Hannes Nevalainen). Что-то подобное было бы невозможно при прямом расчёте освещения:
Часть 2. Базовое освещение
Часть 3. Загрузка 3D-моделей
Часть 4. Продвинутые возможности OpenGL
Часть 5. Продвинутое освещение
Идея отложенного освещения состоит в том, что мы откладываем самые вычислетельно сложные части (типа освещения) на потом. Отложенное освещение состоит из двух проходов: в первом проходе, геометрическом (geometry pass), рисуется вся сцена и различная информация сохраняется в набор текстур, называемых G-буффером. Например: позиции, цвета, нормали и/или зеркальность поверхности для каждого пикселя. Сохранённая в G-буфере графическая информация позже используется для расчёта освещения. Ниже приведено содержания G-буфера для одного кадра:
Во втором проходе, называемом проходом освещения (lighting pass), мы используем текстуры из G-буффера, когда рисуем полноэкранный прямоугольник. Вместо использования вершинного и фрагементного шейдеров отдельно для кадого объекта, мы пиксель за пикселем рисуем сразу всю сцену. Расчёт освещения остаётся точно таким же, как и при прямом проходе, но мы берём необходимые данные только из G-буфера и переменных шейдера (uniforms), а не из вершинного шейдера.
Изображение ниже хорошо показывает общий процесс рисования.
Главным преимуществом является то, что сохранённая в G-буфере информация принадлежит самым близким фрагментам, которые ничем не заслонены: тест глубины оставляет только их. Благодаря этому мы расчитываем освещение для каждого пикселя только по одному разу, не совершая лишенй работы. Более того, отложенное освещение даёт нам возможности для дальнейших оптимизаций, позволяющих использовать намного больше источников освещения, чем при прямом освещении.
Впрочем, есть и пара недостатков: G-буфер хранит большое количество информации о сцене. Вдобавок, данные типа позиции требуется хранить с высокой точностью, в итоге G-буфер занимает довольно много места в памяти. Ещё одним недостатком является то, что мы не сможем использовать полупрозрачные объекты (так как в буфере хранится информация только для самой близкой поверхности) и сглаживание типа MSAA тоже не будет работать. Существуют несколько обходных путей для решения этих проблем, они рассмотрены в конце статьи.
(Прим. пер. — G-буффер занимает реально много места в памяти. Например, для экрана 1920*1080 и использовании 128 бит на пиксель буфер займёт 33мб. Вырастают требования к пропускной способности памяти — данных пишется и читается значительно больше)
G-буфер
G-буфером называют текстуры, используемые для сохранения связанной с освещением информации, используемой в последнем проходе рендеринга. Давайте посмотрим, какая информация нам нужна для расчёта освещения при прямом рендеринге:
С помощью этих переменных мы можем посчитать освещение по уже знакомой нам модели Блинна-Фонга. Цвет и положение источника света, а так же позиция камеры могут быть общими переменными, но остальные значения будут своими для каждого фрагмента изображения. Если мы передадим ровно же данные в финальный проход отложенного освещения, что мы бы использовалили при прямом проходе, мы получим тот же самый результат, не смотря на то, что мы будет рисовать фрагменты на обычном 2д прямоугольнике.
В OpenGL нет ограничений на то, что мы можем хранить в текстуре, так что имеет смысл хранить всю информацию в одной или нескольких текстурах размером с экран (называемых G-буфером) и использовать их все в проходе освещения. Так как размер текстур и экрана совпадает, мы получим те же самые входные данные, что и при прямом освещении.
В псевдокоде общая картина выглядит примерно так:
Информация, которая необходима для каждого пикселя: вектор позиции, вектор нормали, вектор цвета и значение для зеркальной составляющей. В геометрическом проходе мы нарисуем все объекты сцены и сохраним все эти данные в G-буфер. Мы можем использовать множественные цели рендерига (multiple render targets), чтобы заполнить все буферы за один проход рисования, такой подход обсуждался в предыдущей статье про реализацию свечения: Bloom, перевод на хабре.
Для геометрического прохода создадим фреймбуфер с очевидными именем gBuffer, к которому присоединим несколько цветовых буферов и один буфер глубины. Для хранения позиций и нормали предпочтительно использовать текстуру с высокой точностью (16 или 32-битные float значения для каждой компоненты), диффузный цвет и значения зеркального отражения мы будем хранить в текстуре по-умолчанию (точность 8 бит на компоненту).
В дальнейшем мы должны отрендерить данные в G-буфер. Если каждый объект имееет цвет, нормаль и коэффициент зеркального отражения, мы можем написать что-то вроде следующего шейдера:
Так как мы используем несколько целей рендеринга, при помощи layout указываем, что и в какой буфер текущего фреймбуфера мы рендерим. Обратите внимание, что мы не сохраняем коэффициент зеркального отражения в отдельный буфер, так как мы можем хранить float значение в альфа-канале одного из буферов.
Имейте ввиду, что при расчётах освещения крайне важно хранить все переменные в одном и том же координатном пространстве, в данном случае мы храним (и производим вычисления) в пространстве мира.
Если мы сейчас отрендерим несколько нанокостюмов в G-буфер и нарисуем его содержимое с помощью проецирования каждого буфера на четверть экрана, мы увидим что-то типа такого:
Попробуйте визуализировать вектора позиций и нормалей и убедитесь, что они верны. Например, вектора нормалей, указывающих вправо, будут красным. Аналогично с объектами, расположенными правее центра сцены. После того, как Вы будете удовлетворены содержимым G-буфера, перейдём к следующей части: проходу освещения.
Проход освещения
Теперь, когда у нас есть большок количество информации в G-буфере, мы имеем возможность полностью вычислить освещение и финальные цвета для каждого пикселя G-буфера, используя его содержание в качестве входных данных для алгоритмов расчёта освещения. Так как значения G-буфера представляют только видимые фрагменты, мы выполним сложные рассчёты освещения ровно по одному разу для каждого пикселя. Благодаря этому отложенное освещение довольно эффективно, особенно в сложных сценах, в которых при прямом рендеринге для каждого пикселя довольно часто приходится производить вычисление освещения по нескольку раз.
Для прохода освещения мы собираемся рендерить полноэкранный прямоугльник (немного похоже на эффект пост-обработки) и произвести медленное вычисление освещения для каждого пикселя.
Мы присоединяем (bind) все необходимые текстуры G-буфера перед рендерингом и вдобавок устанавливаем относящиеся к освещению значения переменных в шейдере.
Фрагментный шейдер прохода освещения сильно похож на тот, что мы использовали в уроках совещения. Принципиально новым является способ, которым мы получаем входные данные для освещение прямо из G-буфера.
Так как для каждого фрагмента есть значения (а так же uniform переменные шейдера), необходимые для рассчёта освещеняи по модели Блинна-Фонга, нам нет необходимости изменять код расчёта освещения. Единственное, что было изменено — способ получения входных значений.
Запуск простой демонстрации с 32 маленькими источникам света выглядит примерно так:
Одним из недостатков отложенного освещения являетя невозможность смешивания, так как все g-буфера для каждого пикселя содержат информацию только об одной поверхности, в то время как смешивание использует комбинации нескольких фрагментов. (Blending), перевод. Ещё одним недостатком отложенного освещения является то, что оно вынуждает вас использовать один общий для всех объектов способ расчёта освещения; хотя это ограничение можно как-нибудь обойти с помощью добавления информации о материале в g-буфер.
Чтобы справиться с этими недостатками (особенно с отсутствием смешивания), часто разделяют рендеринг на две части: рендеринг с отложенным освещением, и вторая часть с прямым рендерингом предназначина для наложения чего-то на сцену или использования шейдеров, не сочетающихся с отложенным освещением. (Прим пер. Из примеров: добавление полупрозрачных дыма, огня, стёкол) Для иллюстрации работы мы нарисуем источники света как маленькие кубики с помощью прямого рендеринга, так как кубики освещения требуют специальный шейдер (равномерно светятся одним цветом).
Комбинируем отложенный рендериг с прямым.
Предположим, что мы хотим нарисовать каждый источник света в виде 3д кубика с центром, совпадающим с позицией источника света и излучающим свет с цветом источника. Первой идеей, которая приходит в голову, является прямой рендеринг кубиков для каждого источника света поверх результатов отложенного рендеринга. Т.е,, мы рисуем кубики как обычно, но только после отложенного рендеринга. Код будет выглядить примерно так:
Эти отрендеренные кубы не учитывают значения глубины из отложенного рендеринга и в результате рисуются всегда поверх уже отрендеренных объектов: это не то, чего мы добиваемся.
Сначала нам нужно скопировать информацию о глубине из геометрического прохода в буфер глубины, и только после этого нарисовать светящиеся кубики. Таким образом, фрагменты светящихся кубиков будут нарисованы только в том случае, если они находятся ближе, чем уже нарисованные объекты.
Для объектов, нарисованных в проходе отложенного освещения, мы сохранили глубину в g-буфере объекта фреймбуфера. Если мы просто скопируем содержимое буфера глубины g-буфера в буфер глубины по-умолчанию, светящиеся кубики будут нарисованы так, как будто вся геометрия сцены была нарисована с помощью прямого прохода рендеринга. Как было кратко объяснено в примере со сглаживанием, мы должны установить фреймбуферы для чтения и записи:
Здесь мы копируем целиком содержимое буфера глубины фреймбуфера в буфер глубины по-умолчанию (При необходимости можно аналогично скопировать буферы цвета или stensil буфер). Если мы теперь отрендерим светящиеся кубики, они нарисуются так, как будто геометрия сцены реальна (хотя она рисуется как простой).
Исходный код демо можно найти здесь.
С таким подходом мы можем легко комбинировать отложенный рендеринг с прямым. Это превосходно, так как мы сможем применять смешивание и рисовать объекты, которы требуют специальных шейдеров, не применимых при отложенном рендеренге.
Больше источников света
Отложенное освещение часто хвалят за возможность рисовать огромное количество источников света без сильного снижения производительности. Отложенное освещение само по себе не позволяет рисовать очень большого количества источников света, так как мы всё ещё должны для каждого пикселя посчитать вклад всех источников света. Для рисования огромного количества источников света используется очень красивая оптимизация, применимая к отложенному рендерингу — области действия источников света. (light volumes)
Обычно, когда мы рисуем фрагменты в сильно освещённой сцене, мы учитываем вклад каждого источника света на сцене независимо от его расстояния до фрагмента. Если большая часть источников света никогда не повлияют на фрагмент, зачем мы тратим время на вычисления для них?
Идея области действия источника света состоит в том, чтобы найти радиус (или объём) источника света — т.е., область, в которой свет способен достигнуть поверхности. Так как большинство источников света используют какое-нибудь затухание, мы можем найти максимальное расстояние (радиус), которое свет может достигнуть. После этого мы выполняем сложные рассчёты освещения только для тех источников света, которые влияют на данный фрагмент. Это спасает нас от огромного количесва вычислений, так как мы вычисляем освещение только там, где это необходимо.
При таком подходе основной хитростью является определение размера области действия источнка света.
Вычисление области действия источника света (радиуса)
Для получения радиуса источника света мы должны решить уравнение затухания для яркости, которую мы посчитаем тёмной — это может быть 0.0 или что-то чуть более освещённое, но всё ещё тёмное: например, 0.03. Для демонстрации, как можно посчитать радиус, мы будем использовать одну из сложных, наиболее общих функций затухания из примера с light caster
Мы хотим решить это уравнение для случая, когда , т.е., когда источник света будет полностью тёмным. Впрочем, данное уравнение никогда не достигнет точного значения 0.0, так что решения не существует. Однако мы вместо этого можем решить уравнение для яркости для значения, близкого к 0.0, которое можно считать практически тёмным. В этом примере мы считаем приемлемым значение яркости в
— делёное на 256, так как 8-битный фреймбуфер может содержать 256 различных значений яркости.
Выбранная функция затухания становится практически тёмной на расстоянии радиуса действия, если мы ограничим её на меньшей яркости чем 5/256, то область действия источника света станет слишком большой — это не так эффективно. В идеале человек не должен видеть внезапной резкой границы света от источника света. Конечно, это зависит от типа сцены, большее значение минимальной яркости даёт меньшие области действия источников света и повышает эффективность рассчётов, но может приводить к заметным артефакты на изображении: освещение будет резко обрываться на границах области действия источника света.
Уравнение затухания, которое мы должны решить, становится таким:
Здесь — наиболее яркая составляющая света (из r, g, b каналов). Мы спользуем самую яркую компоненту, так как остальные компоненты дудут более слабое ограничение на область действия источника света.
Продолжим решать уравнение:
Последнее уравнение является квадратным уравнением в форме со следующим решением:
Мы получили общее уравнение, которое позволяет подставить параметры (коэффициенты константного затухания, линейного и квадратичного), чтобы найти x — радиус области действия источника света.
Формула возвращает радиус примерно между 1.0 и 5.0 в зависимости от максимальной яркости источника света.
Мы находим этот радиус для каждого источника света на сцене и используем его для того, чтобы для каждого фрагмента учитывать только те источники света, внутри областей действия которых он находится. Ниже приведён переделанный проход освещения, который учитывает области дейстия источников света. Обратите внимание, что этот подход реализован только в целях обучения и плохо подходит для практического применения (скоро обсудим, почему).
Результат точно такой же, как и раньше, но сейчас для каждого источника света учитывается его влияние только внутри области его действия.
Реальное применение области действия источника света.
Фрагментный шейдер, показанный выше, не будет работать на практике и служит только для иллюстрации, как мы можем избавиться от ненужных вычислений освещения. В реальности видеокарта и язык шейдеров GLSL очень плохо оптимизируют циклы и ветвления. Причной этого является то, что выполенние шейдера на видеокарте производится параллельно для различных пикселей, и многие архитектуры накладывают ограничение, что при паралельном выполнении различные потоки должны вычислять один и тот же шейдер. Часто это приводит к тому, что запущенный шейдер всегда вычисляет все ветвления, чтобы все шейдеры работали одинаковое время. (Прим пер. Это не влияет на результат вычислений, но может снижать производительность шейдера.) Из-за этого может получиться, что наша проверка на радиус бесполезна: мы всё ещё будем вычислять освещение для всех источников!
Подходящим подходом для использования области действия света будет рендеринг сфер с радиусом как у источника света. Центр сферы совпадает с позицией источника света, так что сфера содержит внутри себя область действия источника света. Здесь есть небольшая хитрость — мы используем в основном такой же отложенный фрагментный шейдер для рисования сферы. При рисовании сферы фрагментный шейдер вызывается именно для тех пикселей, на которые влияет источник света, мы рендерим только нужные пиксели и пропускаем все остальные. Иллюстрация на картинке ниже:
Мы сделаем так для каждого источника света, результаты вычислений будут сложены все вместе. Результат будет именно такой же, как и раньше, но на этот раз мы рендерим только необходимые пиксели для каждого источника света. Это значительно снижаем сложность вычислений с количество_объектов*количество_источников_света до
количество_объектов + количество_источников_света, что делает отложенный рендеринг неимоверно эффективным в сценах с большим количеством источников света.
При этом подходе всё ещё есть проблема: отсечение обратных граней должно быть включено (чтобы не рассчитывать освещение дважды) и, когда оно включено, пользователь может оказаться внутри области источника света, из-за чего она не будет рисоваться (по причине отсечения обратных граней). Это может быть решено с помощью хитрого использования stenсil буфера.
Рендеринг областей действия источников света приводит к большим потерям производительности, и хотя это значительно быстрее, чем обычное отложенное освещение, это не является лучшим решением. Существуют ещё два популярных (и более эффективных) способа рассчёта освещения при отложенном рендеринге: отложенное оcвещение (deferred lighting) и потайловое отложенное затенение (tile-based deferred shading). Эти способы невероятно эффективны при рендеринге большого количества источников света и так же позволяют относительно эффективно использовать сглаживание MSAA. Ради размера этой статьи мы оставим эти оптимизации для рассмотрения в последующих статьях.
Отложенный рендеринг vs прямой
Отложенный рендеринг (без оптимизаций освещения) сам по-себе уже является хорошей оптимизацией, так как каждый пиксель только один раз требует вычисления фрагментного шейдера, в то время как при прямом рендеринге мы часто вычисляем освещения по нескольку раз для пикселя. Вместе с тем, отложенное освещение имеет недостатки — большое использование памяти, отсутствие сглаживания MSAA, смешивание можно использовать только при прямом рендеринге.
Для простой сцены с небольшим количеством источников света отложенный рендеринг не обязательно будет быстрее (иногда даже медленнее), так как дополнительные расходы (запись в g-буфер и т.п.) могут перевесить преимущества от меньшего количества рассчётов освещения. В более сложных сценах отложенный рендеринг становится значительной оптимизацией, особенно при использовании более продвинутых способов рассчёта освещения.
В заключение я хочу отметить: изначально все эффекты, которые могут быть получены прямым рендерингом, так же могут быть реализованны в отложенном рендеринге, зачастую это требует лишь небольших изменений. Например, если мы хотим использовать карты нормалей при отложенном рендеринге, мы можем изменить геометрическй проход так, чтобы шейдер возвращал нормаль на основе значений из карты нормалей вместо нормали геометрической поверхности. Проход освещения вообще не потребует изменений. Если вы хотите добавить parallax mapping, вы сначала немного измените текстурные координаты в геометрическом шейдере перед чтением из текстур значений цветов, отражающей способности и нормалей. Как только вы поймёте идею отложенного рендеринга, внесение изменений в него будет довольно простым.