Первая часть перевода находится здесь. В этой части мы поговорим об эффекте резкости, средней яркости, фазах Луны и атмосферных явлениях во время дождя.
В этой части мы подробнее рассмотрим ещё один эффект постобработки из The Witcher 3 — Sharpen.
Sharpening делает изображение на выходе немного чётче. Этот эффект известен нам по Photoshop и другим графическим редакторам.
В The Witcher 3 у sharpening есть две опции: low и high. О разнице между ними я расскажу ниже, а пока давайте взглянем на скриншоты:
![image](https://3.bp.blogspot.com/-48w3LzzZxck/W-nij_Zw4-I/AAAAAAAABFo/PT4Ag91YHGcAO2yJkzsMNPxdxLw0zhJNACLcBGAs/s1600/05_sharpen_low_before.png)
Опция «Low» — до
![image](https://1.bp.blogspot.com/-OHL-u69mwLY/W-nikHvPR9I/AAAAAAAABFk/t4lEbIpGSyYMCtKj9o4hRuoqi5L853rnQCLcBGAs/s1600/06_sharpen_low_after.png)
Опция «Low» — после
![](https://2.bp.blogspot.com/-0DszcHBoZbk/W-ng2wgnZHI/AAAAAAAABFU/US-j1-7eSzMEpV2BBfO2rvUqzvPssjcewCLcBGAs/s1600/03_sharpen_before.png)
Опция «High» — до
![](https://1.bp.blogspot.com/-f3gSJndHP-I/W-ng25Qvd5I/AAAAAAAABFQ/Is5RC6q7fZE8K_5P8Kd2TXTkDbP7ICUvQCLcBGAs/s1600/04_sharpen_after.png)
Опция «High» — после
Если вы хотите взглянуть на более подробные (интерактивные) сравнения, то посмотрите раздел в руководстве о производительности The Witcher 3 компании Nvidia. Как видите, эффект особенно заметен на траве и листве.
В этой части поста мы изучим кадр из самого начала игры: я выбрал его намеренно, потому что здесь мы видим рельеф (длинная дистанция отрисовки) и купол неба.
![](https://2.bp.blogspot.com/-K6ItWKMli8M/W-rtZjh6DNI/AAAAAAAABF4/Rf13MziMhUYYtJrKZ4aWzdsU5rvDRbdWACLcBGAs/s1600/07_final_kaermorhen_high.png)
С точки зрения входных данных для sharpening требуется цветовой буфер t0 (LDR после тональной коррекции и lens flares) и буфер глубин t1.
Давайте изучим ассемблерный код пиксельного шейдера:
50 строк ассемблерного кода выглядят как вполне посильная задача. Давайте приступим к её решению.
Первый этап заключается в загрузке (Load) буфера глубин (строка 1). Стоит заметить, что в «Ведьмаке 3» используется перевёрнутая глубина (1.0 — близко, 0.0 — далеко). Как вы можете знать, аппаратная глубина привязывается нелинейным образом (подробности см. в этой статье).
Строки 3-6 выполняют очень интересный способ привязки этой аппаратной глубины [1.0 — 0.0] к значениям [близко-далеко] (мы задаём их на этапе MatrixPerspectiveFov). Рассмотрим значения из буфера констант:
![](https://1.bp.blogspot.com/-3-Z0e-69I1k/W-rwM8rhxOI/AAAAAAAABGE/Ave1LqXNL3Y7aWn6NExbOocMw99-zBGwACLcBGAs/s1600/08_params_cb12.PNG)
Имея для «близко» значение 0.2, а для «далеко» значение 5000, мы можем вычислить значения cb12_v21.xy следующим образом:
Этот фрагмент кода довольно часто встречается в шейдерах TW3, поэтому я считаю, что это просто функция.
После получения «глубины пирамиды видимости» строка 7 использует масштаб/искажение для создания коэффициента интерполяции (здесь мы используем saturate, чтобы ограничить значения интервалом [0-1]).
![](https://2.bp.blogspot.com/-y_MUjpUHZA4/W-rzD9HHOgI/AAAAAAAABGQ/doOpYj_aFnUAX5mA_5MEogMVK2SMZ1J-ACLcBGAs/s1600/09_params_cb3.PNG)
cb3_v1.xy и cb3_v2.xy — это, яркость эффекта sharpening на ближних и дальних расстояниях. Давайте назовём их «sharpenNear» и «sharpenFar». И это единственное отличие опций «Low» и «High» данного эффекта в The Witcher 3.
Теперь настало время использовать полученный коэффициент. Строки 8-9 просто выполняют
![](https://2.bp.blogspot.com/-wlLIyueDFR4/W-rzvFODmRI/AAAAAAAABGc/yGjaPROAcEEErg5X9nxmH0RZUFga5fg0ACLcBGAs/s1600/02_mask_noSkyboxFilter.png)
Возможно, это едва заметно, но здесь мы интерполировали на основании расстояния яркость sharpen рядом с игроком (2.177151) и яркость эффекта очень далеко (1.91303). После этого вычисления мы прибавляем к яркости 1.0 (строка 10). Зачем это нужно? Предположим, что показанная выше операция lerp дала нам 0.0. После прибавления 1.0 мы, естественно, получим 1.0, и это значение, которое не повлияет на пиксель при выполнении sharpening. Подробнее об этом рассказано ниже.
Во время добавления резкости мы не хотим влиять на небо. Этого можно достичь, добавив простую условную проверку:
В The Witcher 3 значение глубины пикселей неба равно 1.0, поэтому мы используем его, чтобы получить своего рода «двоичный фильтр» (интересный факт: в данном случае step сработает неправильно).
Теперь мы можем умножить интерполированную яркость на «фильтр неба»:
![](https://3.bp.blogspot.com/-B5KPuxdGrKw/W-rzvEsdOVI/AAAAAAAABGg/Q2eaBeEikNc3jIi5jA4PZ_Ha1_vV1JE0gCEwYBhgL/s1600/01_mask_skybox.png)
Это умножение выполняется в строке 13.
Пример кода шейдера:
В SV_Position есть аспект, который будет здесь важен: смещение в половину пикселя. Оказывается, что этот пиксель в верхнем левом углу (0, 0) имеет координаты не не (0, 0) с точки зрения of SV_Position.xy, а (0.5, 0.5). Ничего себе!
Здесь мы хотим взять сэмпл в центре пикселя, поэтому посмотрим на строки 14-16. Можно записать их на HLSL:
А позже мы сэмплируем входную текстуру цвета из texcoords «uvCenter». Не волнуйтесь, результат сэмплирования будет тем же, что и при «обычном» способе (SV_Position.xy / ViewportSize.xy).
Решение о том, нужно ли применять sharpen, зависит от fSharpenAmount.
Настало время взглянуть на сами внутренности алгоритма.
По сути, он выполняет следующие действия:
— сэмплирует четыре раза входную текстуру цвета по углам пикселя,
— складывает сэмплы и вычисляет среднее значение,
— вычисляет разность между «center» и «cornerAverage»,
— находит максимальный абсолютный компонент разности,
— корректирует макс. абс. компонент, используя значения scale+bias,
— определяет величину эффекта, используя макс. абс. компонент,
— вычисляет значение яркости (luma) для «centerColor» и «averageColor»,
— делит colorCenter на его luma,
— вычисляет новое, интерполированное значение luma на основе величины эффекта,
— умножает colorCenter на новое значение luma.
Довольно много работы, и мне было сложно в этом разобраться, потому что я никогда не экспериментировал с фильтрами резкости.
Давайте начнём с паттерна сэмплирования. Как можно увидеть в ассемблерном коде, выполняется четыре считывания текстуры.
Лучше всего будет показать это на примере изображения пикселя (уровень мастерства художника — эксперт):
![](https://2.bp.blogspot.com/-QPzS6rmZwy4/W-sVeKn-Q3I/AAAAAAAABG4/oY7LZM7W1m0Or-dO6T9VwyLrlorVeY0SQCLcBGAs/s1600/10_sampling_pattern.png)
Все считывания в шейдере используют билинейное сэмплирование (D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT).
Смещение от центра до каждого из углов равен (±0.5, ±0.5), в зависимости от угла.
Видите, как это можно реализовать на HLSL? Давайте посмотрим:
Итак, теперь все четыре сэмпла суммированы в переменной «colorCorners». Давайте выполним следующие шаги:
Распознавание краёв выполняется вычислением макс. абс. компонента разности. Умный ход! Посмотрите его визуализацию:
![](https://4.bp.blogspot.com/-pwcUDABwk2c/W-zv1ldr2VI/AAAAAAAABHE/zjSO2IcXcDQCICtyHyexasv4PMLdWKO3ACLcBGAs/s1600/11_maxAbsComponent.jpg)
Визуализация максимального абсолютного компонента разности.
Отлично. Готовый HLSL-шейдер выложен здесь. Простите за довольно плохое форматирование. Можете воспользоваться моей программой HLSLexplorer и поэкспериментировать с кодом.
Могу с радостью сказать, что представленный выше код создаёт тот же ассемблерный код, что и в игре!
Подведём итог: шейдер резкости «Ведьмака 3» очень хорошо написан (заметьте. что fPixelSharpenAmount больше 1.0! это интересно...). Кроме того, основной способ изменения яркости эффекта — это яркость ближних/дальних объектов. В этой игре они не являются константами; я собрал несколько примеров значений:
Скеллиге:
Каэр Морхен:
Операцию вычисления средней яркости текущего кадра можно найти практически в любой современной видеоигре. Это значение часто позже используется для эффекта адаптации глаза и тональной коррекции (см. в предыдущей части поста). В простых решениях используется вычисление яркости для, допустим текстуры размером 5122, затем вычисление её mip-уровней и применение последнего. Обычно это срабатывает, но сильно ограничивает возможности. В более сложных решениях используются вычислительные шейдеры, выполняющие, например параллельную редукцию.
Давайте узнаем, как эту задачу решила в «Ведьмаке 3» команда CD Projekt Red. В предыдущей части я уже исследовал тональную коррекцию и адаптацию глаза, поэтому единственным оставшимся куском головоломки осталась средняя яркость.
Начнём с того, что вычисление средней яркости The Witcher 3 состоит из двух проходов. Для понятности я решил разбить их на отдельные части, и сначала мы рассмотрим первый проход — «распределение яркости» (вычисление гистограммы яркости).
Эти два прохода довольно просто найти в любом анализаторе кадров. Они являются идущими по порядку вызовами Dispatch прямо перед выполнением адаптации глаза:
![](https://1.bp.blogspot.com/-RXNWIKIxzI4/XBIfOXacITI/AAAAAAAABHo/1UqSRJ2WfeosMQoO52xi7yzcNKifd8vDACLcBGAs/s1600/01.PNG)
Давайте рассмотрим входные данные для этого прохода. Ему необходимы две текстуры:
1) HDR-буфер цветов, масштаб которого снижен до 1/4 x 1/4 (например, с 1920x1080 до 480x270),
2) Полноэкранный буфер глубин
![](https://4.bp.blogspot.com/-2-gLL5phczo/XBIgradglsI/AAAAAAAABH4/R8ifVyzMZFoGZvhYe0SIMUeAjb5y0a89ACLcBGAs/s1600/02_input_1.png)
HDR-буфер цветов с разрешением 1/4 x 1/4. Заметьте хитрый трюк — этот буфер является частью большего буфера. Многократное использование буферов — это хорошая практика.
![](https://2.bp.blogspot.com/-fWHqIUjR-sg/XBIgrRk67HI/AAAAAAAABH0/mRc33ErTB7UslMiOgrU6FGE0gCo0GKObgCLcBGAs/s1600/02_input_2.png)
Полноэкранный буфер глубин
Зачем уменьшать масштаб буфера цветов? Думаю, всё дело в производительности.
Что касается выходных данных этого прохода, то ими является структурированный буфер. 256 элемента по 4 байта каждый.
Здесь у шейдеров нет отладочной информации, поэтому предположим, что это просто буфер беззнаковых значений int.
Важно: первый этап вычисления средней яркости вызывает ClearUnorderedAccessViewUint для обнуления всех элементов структурированного буфера.
Давайте изучим ассемблерный код вычислительного шейдера (это первый вычислительный шейдер за весь наш анализ!)
И буфер констант:
![](https://3.bp.blogspot.com/-XbNC_wM2rXE/XBIs9w786iI/AAAAAAAABIU/1fjMODCCO9Q7ICwFUrlJvjm4O8_rfB0wACLcBGAs/s1600/04_cbuffer_params.PNG)
Мы уже знаем, что первыми входными данными является HDR-буфер цветов. При FullHD его разрешение равно 480x270. Посмотрим на вызов Dispatch.
Dispatch(270, 1, 1) — это означает, что мы запускаем 270 групп потоков. Проще говоря, мы запускаем по одной группе потоков на каждую строку буфера цветов.
![](https://3.bp.blogspot.com/-J0S7i3DPUeY/XBIk28NmvZI/AAAAAAAABII/HnvYgTiI-yUbYYj2BKAbSCqtx1qtDuuSgCLcBGAs/s1600/03_threadgroups.png)
Каждая группа потоков выполняет одну строку HDR-буфера цветов
Теперь, когда у нас есть этот контекст, давайте попробуем выяснить, что же делает шейдер.
Каждая группа потоков имеет 64 потоков в направлении X (dcl_thread_group 64, 1, 1), а также общую память, 256 элементов по 4 байта в каждом (dcl_tgsm_structured g0, 4, 256).
Заметьте, что в шейдере мы используем SV_GroupThreadID (vThreadIDInGroup.x) [0-63] и SV_GroupID (vThreadGroupID.x) [0-269].
1) Мы начинаем с того, что присваиваем всем элементам общей памяти нулевые значения. Так как в общей памяти содержится 256 элемента и 64 потока на группу, это удобно можно сделать с помощью простого цикла:
2) После этого мы устанавливаем барьер с помощью GroupMemoryBarrierWithGroupSync (sync_g_t). Мы делаем это, чтобы гарантировать обнуление всеми потоками всех элементов в общей памяти групп перед переходом к следующему этапу.
3) Теперь мы выполняем цикл, который можно приблизительно записать так:
Это простой цикл for с инкрементом на 64 (вы уже поняли, почему?).
Следующий этап — вычисление позиции загружаемого пикселя.
Давайте подумаем об этом.
Для координаты Y мы можем использовать SV_GroupID.x, потому что мы запустили 270 групп потоков.
Для координаты X мы… можем воспользоваться преимуществом текущего потока группы! Давайте попробуем это сделать.
Так как в каждой группе по 64 потока, такое решение обойдёт все пиксели.
Рассмотрим группу потоков (0, 0, 0).
— Поток (0, 0, 0) обработает пиксели (0, 0), (64, 0), (128, 0), (192, 0), (256, 0), (320, 0), (384, 0), (448, 0).
— Поток (1, 0, 0) обработает пиксели (1, 0), (65, 0), (129, 0), (193, 0), (257, 0), (321, 0), (385, 0), (449, 0)…
— Поток (63, 0, 0) обработает пиксели (63, 0), (127, 0), (191, 0), (255, 0), (319, 0), (383, 0), (447, 0)
Таким образом, будут обработаны все пиксели.
Также нам нужно гарантировать, что мы не загрузим пиксели из-за пределов буфера цветов:
Видите? Всё довольно просто!
Также я вычислил яркость (строка 21 ассемблерного кода).
Отлично, мы уже вычислили яркость из цветного пикселя. Следующий шаг — загрузка (не сэмплирование!) соответствующего значения глубины.
Но здесь у нас есть проблема, потому что мы подключили буфер глубин полного разрешения. Что с этим делать?
Это на удивление просто — достаточно умножить colorPos на какую-нибудь константу (cb0_v2.z). Мы уменьшили масштаб HDR-буфера цветов в четыре раза. поэтому значением будет 4!
Пока всё здорово! Но… мы дошли до строк 24-25…
Так. Сначала у нас есть сравнение равенства с плавающей запятой, его результат записывается в r2.x, а сразу после этого идёт… что? Побитовое И?? Серьёзно? Для значения с плавающей запятой? Какого чёрта???
Проблема 'eq+and'
Позвольте просто сказать, что это для меня была самая сложная часть шейдера. Я даже пробовал странные комбинации asint/asfloat…
А если использовать немного другой подход? Давайте просто выполним в HLSL обычное сравнение float-float.
А вот как выглядит вывод в ассемблерном коде:
Интересно, правда? Не ожидал увидеть здесь «and».
0x3f800000 — это просто 1.0f… Логично, потому что мы получаем в случае успеха сравнения 1.0 и 0.0 в противном случае.
А что если мы «заменим» 1.0 каким-то другим значением? Например так:
Получим такой результат:
Ха! Сработало. Это просто магия компилятора HLSL. Примечание: если заменить чем-то другим 0.0, то получится просто movc.
Вернёмся к вычислительному шейдеру. Следующим шагом будет проверка равенства глубины значению cb0_v2.w. Оно всегда равно 0.0 — проще говоря, мы проверяем, находится ли пиксель на дальней плоскости (в небе). Если да, то мы присваиваем этому коэффициенту какое-то значение, приблизительно 0.5 (я проверял на нескольких кадрах).
Такой вычисленный коэффициент используется для интерполяции между яркостью цвета и яркостью «неба» (значением cb0_v2.x, которое часто примерно равно 0.0). Предполагаю, что это нужно для управления важностью неба в вычислении средней яркости. Обычно важность уменьшается. Очень умная идея.
Так как у нас есть lumaOk, следующим этапом будет вычисление его натурального логарифма для создания хорошего распределения. Но постойте, допустим, lumaOk равно 0.0. Мы знаем, что значение log(0) является неопределённым, поэтому прибавляем 1.0, потому что log(1) = 0.0.
После этого мы масштабируем вычисленный логарифм на 128, чтобы распределить его по 256 ячейкам. Очень умно!
И именно отсюда берётся это значение 88.722839. Это
Это просто способ, которым HLSL вычисляет логарифмы.
В ассемблерном коде HLSL есть только одна функция, вычисляющая логарифмы: log, и она имеет основание 2.
Наконец мы вычисляем из логарифмически распределённой яркости индекс ячейки и прибавляем 1 к соответствующей ячейке в общей памяти.
Следующим шагом снова будет установка барьера, чтобы гарантировать, что были обработаны все пиксели в строке.
А последним шагом будет прибавление значений из общей памяти в структурированный буфер. Это делается тем же самым образом, через простой цикл:
После того, как все 64 потока в группе потоков заполнят общие данные, каждый поток добавляет 4 значения в буфер вывода.
Рассмотрим буфер вывода. Давайте подумаем об этом. Сумма всех значений в буфере равна общему количеству пикселей! (при 480x270 = 129 600). То есть мы знаем, сколько пикселей имеют конкретное значение яркости.
Если вы слабо разбираетесь в вычислительных шейдерах (как я), то поначалу это может быть непонятно, поэтому прочитайте пост ещё несколько раз, возьмите бумагу и карандаш, и попробуйте разобраться в концепциях, на которых построена эта техника.
Вот и всё! Именно так «Ведьмак 3» вычисляет гистограмму яркости. Лично я при написании этой части многому научился. Поздравляю ребят из CD Projekt Red с отличной работой!
Если вы интересуетесь полным HLSL-шейдером, то он выложен здесь. Я всегда стремлюсь получить как можно более близкий к игровому ассемблерный код и совершенно счастлив, что мне это снова удалось!
Это вторая часть анализа вычислений средней яркости в «The Witcher 3: Wild Hunt».
Прежде чем мы вступим в бой с ещё одним вычислительным шейдером, давайте вкратце повторим, что произошло в прошлой части: мы работали с HDR-буфером цветов с уменьшенным до 1/4x1/4 масштабом. После первого прохода мы получили гистограмму яркости (структурированный буфер 256 беззнаковых целочисленных значений). Мы вычислили логарифм для яркости каждого пикселя, распределили его по 256 ячейкам и увеличили соответствующее значение структурированного буфера по 1 на пиксель. Благодаря этому общая сумма всех значений в этих 256 ячейках равна количеству пикселей.
![](https://1.bp.blogspot.com/-VEZhGnUad6M/XBTXlGw0mQI/AAAAAAAABIg/EV0BwwMD8hA2-GnXMurSp_4Ij_dioq9EACLcBGAs/s1600/01_data.PNG)
Пример вывода первого прохода. Здесь 256 элементов.
Например, наш полноэкранный буфер имеет размер 1920x1080. После уменьшения масштаба первый проход использовал буфер 480x270. Сумма всех 256 значений в буфере будет равна 480 * 270 = 129 600.
После этого краткого вступления мы готовы перейти к следующему этапу: к вычислениям.
На этот раз используется только одна группа потоков ( Dispatch(1, 1, 1) ).
Давайте посмотрим на ассемблерный код вычислительного шейдера:
Здесь есть один буфер констант:
![](https://1.bp.blogspot.com/-QtKFy2N0N0g/XBTcWVycKaI/AAAAAAAABIs/B7LtdnnicCsF6gsf7eQ3VxEmScRCyt_oACLcBGAs/s1600/02_cbuffer.PNG)
Вкратце взглянем на ассемблерный код: прикреплено два UAV (u0: входной буфер из первой части и u1: выходная текстура формата 1x1 R32_FLOAT). Также мы видим, что есть 64 потока на группу и 256 элементов 4-байтной общей групповой памяти.
Начнём с заполнения общей памяти данными из входного буфера. У нас 64 потока, поэтому делать придётся почти то же самое, что и раньше.
Чтобы быть абсолютно уверенными, что для дальнейшей обработки загружены все данные, после этого мы ставим барьер.
Все вычисления выполняются только в одном потоке, все другие используются просто для загрузки значений из буфера в общую память.
«Вычисляющий» поток имеет индекс 0. Почему? Теоретически, мы можем использовать любой поток из интервала [0-63], но благодаря сравнению с 0 мы можем избежать дополнительного сравнения integer-integer (инструкции ieq).
Алгоритм основан на указании интервала пикселей, которые будут учитываться в операции.
В строке 11 мы умножаем width*height, получая общее количество пикселей и умножаем их на два числа из интервала [0.0f-1.0f], обозначающие начало и конец интервала. Дальше используются ограничения, гарантирующие, что
Как видите, ниже есть два цикла. Проблема с ними (или с их ассемблерным кодом) в том, что в концах циклов есть странные условные переходы. Мне было очень сложно воссоздать их. Также взгляните на строку 21. Почему там "-1"? Я объясню это чуть ниже.
Задача первого цикла — отбросить pixelsToConsiderStart и дать нам индекс ячейки буфера, в которой присутствует пиксель pixelsToConsiderStart +1 (а также количество всех пикселей в предыдущих ячейках).
Допустим, что pixelsToConsiderStart примерно равна 30000, а в буфере 37000 пикселей в ячейке «ноль» (такое случается в игре ночью). Поэтому мы хотим начать анализ яркости примерно с пикселя 30001, который присутствует в ячейке «ноль». В данном случае мы сразу же выходим из цикла, получив начальный индекс '0' и ноль отброшенных пикселей.
Посмотрите на код HLSL:
Загадочное число "-1" из строки 21 ассемблерного кода связано с булевым условием выполнения цикла (я обнаружил это почти случайно).
Получив количество пикселей из ячеек lumaValue и само lumaValue, мы можем переходить ко второму циклу.
Задача второго цикла — вычисление влияния пикселей и средней яркости.
Мы начинаем с lumaValue, вычисленного в первом цикле.
На этом этапе мы получили закодированное в интервале [0.0f-255.f] значение яркости.
Процесс декодирования довольно прост — нужно обратить вычисления этапа кодирования.
Краткий повтор процесса кодирования:
Чтобы декодировать яркость, мы обращаем процесс кодирования, например вот так:
Затем мы вычисляем распределение, умножая количество пикселей с заданной яркостью на декодированную яркость, и суммируя их, пока не дойдём до обработки pixelsToConsiderEndпикселей.
После этого мы делим общее влияние на число проанализированных пикселей.
Вот оставшаяся часть цикла (и шейдера):
Полный шейдер выложен здесь. Он полностью совместим с моей программой HLSLexplorer, без которой бы я не смог эффективно воссоздать вычисление средней яркости в «Ведьмаке 3» (да и все другие эффекты тоже!).
В заключение несколько мыслей. С точки зрения вычисления средней яркости этот шейдер было сложно воссоздать. Основные причины:
1) Странные «отложенные» проверки выполнения цикла, на это потребовалось гораздо больше времени, чем я предполагал ранее.
2) Проблемы с отладкой этого вычислительного шейдера в RenderDoc (v. 1.2).
Операции «ld_structured_indexable» поддерживаются не полностью, хотя результат считывания из индекса 0 даёт верное значение, все остальные возвращают нули, из-за чего циклы продолжаются бесконечно.
Хоть мне и не удалось добиться того же ассемблерного кода, что и в оригинале (ниже см. скриншот с различиями), с помощью RenderDoc я смог выполнить инъекцию этого шейдера в конвейер — и результаты оказались такими же!
![](https://3.bp.blogspot.com/-pif8jmrVsSA/XBT9oXVpn9I/AAAAAAAABI4/JiJDjrku5_0I7l87l1B7BW4INM6wmj8bwCLcBGAs/s1600/03_diff.PNG)
Результат битвы. Слева — мой шейдер, справа — оригинальный ассемблерный код.
В восьмой части статьи я исследую шейдер Луны из «Ведьмака 3» (а конкретнее — из расширения «Кровь и вино»).
Луна — важный элемент ночного неба, и её может быть достаточно сложно сделать правдоподобной, но для меня прогулки по ночам в TW3 стали настоящим удовольствием.
Только посмотрите на эту сцену!
![](https://2.bp.blogspot.com/-xrtK-9YELrA/XB4aaoJCgMI/AAAAAAAABJI/8QyXxXLUfjAJ8C56LyedMPXx_Cu0BQsaQCLcBGAs/s1600/01_scene_moon.jpg)
Прежде чем мы возьмёмся за пиксельный шейдер, скажу несколько слов о нюансах рендеринга. С геометрической точки зрения, Луна — это просто сфера (см. ниже), у которой есть координаты текстур, векторы нормалей и касательных. Вершинный шейдер вычисляет позицию в мировом пространстве, а также нормализованные векторы нормалей, касательных и касательных к двум точкам (с помощью векторного произведения), умноженные на матрицу мира.
Чтобы гарантировать, что Луна полностью лежит на удалённой плоскости, полям MinDepth и MaxDepth структуры D3D11_VIEWPORT присвоены значения 0.0 (тот же трюк, который использовался для купола неба). Луна рендерится сразу после неба.
![](https://1.bp.blogspot.com/-RuJNX9SFLrE/XB4cFkPwLzI/AAAAAAAABJU/mTkd6HDfyq0n6BmtdpFoIoFblDHoY8dSgCLcBGAs/s1600/02_geo.JPG)
Сфера, используемая для отрисовки Луны
Ну всё, думаю, можно приступать. Давайте взглянем на пиксельный шейдер:
Главная причина того, что я выбрал шейдер из «Крови и вина», проста — он короче.
Сначала мы вычисляем смещение для сэмплирования текстуры.
cb0[0].w используется как смещение по оси X. С помощью этого простого трюка мы можем симулировать вращение Луны вокруг своей оси.
![](https://3.bp.blogspot.com/-txpan7ictEU/XB4ja9wBvhI/AAAAAAAABJ0/-chx90jNKKYReiyG-0ZitU28F6Lx-CaOwCLcBGAs/s1600/05_cbuffer.JPG)
Примеры значений из буфера констант
В качестве входных данных прикреплена одна текстура (1024x512). В RGB-каналах закодирована карта нормалей, а в альфа-канале — цвет поверхности Луны. Умно!
![](https://2.bp.blogspot.com/-h_YMSQY4Jh0/XB4e1MiGGJI/AAAAAAAABJk/SMdJTU-TYRs7BsKXvkSNhnErei5jL44UACEwYBhgL/s1600/03_moon_a.jpg)
Альфа-канал текстуры — это цвет поверхности Луны.
![](https://2.bp.blogspot.com/-5bpexPlhw_w/XB4e0DLG4VI/AAAAAAAABJg/-MckQpWG3Hw2ds8NlmUoX63HhM0WV4B2wCEwYBhgL/s1600/04_moon_normal.jpg)
RGB-каналы текстуры — это карта нормалей.
Получив правильные координаты текстуры, мы сэмплируем RGBA-каналы. Нам необходимо распаковать карту нормалей и выполнить гамма-коррекцию цвета поверхности. На текущий момент HLSL-шейдер можно записать вот так, например:
Следующим шагом будет выполнение привязки нормалей, но только в компонентах XY. (В The Witcher 3 ось Z направлена вверх, а весь Z-канал текстуры равен 1.0). Мы можем сделать это следующим образом:
Теперь настало время моей любимой части этого шейдера. Снова взгляните на строки 15-16:
Что это за загадочное 0.033864? Поначалу кажется, что в нём нет никакого смысла, но если вычислить обратное ему значение, то получим примерно 29.53, что равно длительности синодического месяца в сутках! Вот, что я называю внимательностью к деталям!
Мы можем достоверно предположить, что cb0[0].y — это количество дней, прошедших за время геймплея. Здесь используется дополнительное отклонение, применяемое в качестве смещения по оси X текстуры.
Получив этот коэффициент, мы умножаем его на 2*Pi.
Затем с помощью sincos мы вычисляем другой 2d-вектор.
Вычислением скалярного произведения между вектором нормали и «лунным» вектором симулируется одна фаза луны.
Посмотрите на скриншоты с разными фазами Луны:
![](https://2.bp.blogspot.com/-IqcBeU8AUCg/XB4wneQCrRI/AAAAAAAABKE/TcWDz8bvz0YmiYIu5lXuNwyRjy-FiWHCQCLcBGAs/s1600/05_phase_2.png)
![](https://1.bp.blogspot.com/-Ky6NJ3CDRuU/XB4wm3Od4II/AAAAAAAABKA/w-5YSIYO_Bg9OtGyaofRmE4fcvU7CcFlQCLcBGAs/s1600/05_phase_1.png)
Последний этап — выполнение серии операций умножения для вычисления окончательного цвета.
Возможно, вы не понимаете, почему этот шейдер передаёт на выход значение альфы 0.0. Это потому, что Луна рендерится со включенным смешением:
![](https://2.bp.blogspot.com/-bIRi8lex15M/XB4zn55npAI/AAAAAAAABKg/4xn9SA1KqdUz_gIO_veS8hOy6AUMALEuwCLcBGAs/s1600/07_blending.JPG)
Такой подход позволяет получать цвет фона (неба), если этот шейдер возвращает чёрный цвет.
Если вам интересен полный шейдер, то можно взять его здесь. У него большие буферы констант и он уже должен быть готов к инъекции в RenderDoc вместо оригинального шейдера (просто переименуйте «MoonPS» в «EditedShaderPS»).
И последнее: я хотел поделиться с вами результатами:
Слева — мой шейдер, справа — оригинальный шейдер из игры.
Разница минимальна и не влияет на результаты.
![](https://3.bp.blogspot.com/-NUe8A26fabs/XB4xwFSkIsI/AAAAAAAABKU/MVdju04nd_oBayAH5wrdXhxESU_EJ4BKgCLcBGAs/s1600/06_comparison.JPG)
Как видите, этот шейдер воссоздать было довольно просто.
В этой части я раскрою некоторые подробности геометрического буфера (gbuffer) в The Witcher 3.
Будем считать, что вы знаете основы отложенного затенения (deferred shading).
Краткое повторение: идея откладывания в том, чтобы не вычислять всё готовое освещение и затенение сразу, а разделить вычисления на два этапа.
В первом (проходе геометрии) мы заполняем GBuffer данными о поверхности (позиция, нормали, specular color и т.д...), а во втором (проходе освещения) комбинируем всё и вычисляем освещение.
Отложенное затенение — это очень популярный подход, потому что оно позволяет вычислять за один полноэкранный проход такими техниками, как тайловое отложенное затенение, что сильно повышает производительность.
Если говорить просто, то GBuffer — это набор текстур со свойствами геометрии. Очень важно создать для него правильную структуру. В качестве примера из реальной жизни можно изучить технологии рендеринга Crysis 3.
После этого краткого введения давайте рассмотрим пример кадра из «Ведьмака 3: Кровь и вино»:
![](https://4.bp.blogspot.com/-vS0gb-xaLqs/XCZoLKFaBEI/AAAAAAAABLE/u89LZshDiVwWMKdJAqekSnKh3IGaHPYRACEwYBhgL/s1600/01_frame.jpg)
Одна из множества гостиниц в Туссенте
Основной GBuffer состоит из трёх полноэкранных render target в формате DXGI_FORMAT_R8G8B8A8_UNORM и буфера глубин + стенсила в формате DXGI_FORMAT_D24_UNORM_S8_UINT.
Вот их скриншоты:
![](https://3.bp.blogspot.com/-bMBPh5xiA14/XCZoSEM5MRI/AAAAAAAABLo/5AmcPysmSz8ID-nRgL0PXVYno58rPD2PQCEwYBhgL/s1600/02_gbuffer_albedo.jpg)
Render Target 0 — RGB-каналы, цвет поверхности
![](https://2.bp.blogspot.com/-u5LvobI921U/XCZqb1puz2I/AAAAAAAABLw/lu_Y0RI8ZTgba3rrLZbP-36UgNn5oykAwCEwYBhgL/s1600/02_gbuffer_albedo_a.jpg)
Render Target 0 — альфа-канал. Честно говоря, понятия не имею, что это за информация.
![](https://1.bp.blogspot.com/-shMFlxbH3dY/XCZoQ_RwWKI/AAAAAAAABLk/SFAzEil6Nm46AwTIOwS-Q3ElJLyPyumfwCEwYBhgL/s1600/03_gbuffer_normals.jpg)
Render Target 1 — RGB-каналы. Здесь записаны векторы нормалей в интервале [0-1].
![](https://2.bp.blogspot.com/-YX_kZvEgqUM/XCZqef2xJvI/AAAAAAAABL8/uw0W5cZTS8s5OVx1ZkC_BVbJN7oEEepEwCEwYBhgL/s1600/03_gbuffer_normals_a.jpg)
Render Target 1 — альфа-канал. Похоже на отражающую способность!
![](https://4.bp.blogspot.com/-i41R7ks1EBI/XCZoONtdFPI/AAAAAAAABLg/VmGKUyO8C4s60YP0-fgY4VP2akaMY2ODgCEwYBhgL/s1600/04_gbuffer_spec.jpg)
Render Target 2 — RGB-каналы. Похоже на specular color!
В этой сцене альфа-канал чёрный (но позже он используется).
![](https://2.bp.blogspot.com/-XRAEHtxpI94/XCZoQkCuaRI/AAAAAAAABLg/X1HrJIFdxYEpvKiEgt7ptOKNaLePhArmgCEwYBhgL/s1600/05_gbuffer_depth.jpg)
Буфер глубин. Заметьте, что здесь используется перевёрнутая глубина.
![](https://4.bp.blogspot.com/-MT7JStvC2Es/XCbInTDMi8I/AAAAAAAABOY/U8lwTaNwr7YTITzZRNZqEf_tYAdDNNelQCEwYBhgL/s1600/06_gbuffer_stencil.jpg)
Стенсил-буфер, используемый для пометки определённого типа пикселей (например кожи, растительности и т.д.)
Это не весь GBuffer. Проход освещения также использует зонды освещения и другие буферы, но их в этой статье я рассматривать не буду.
Прежде чем приступать к «основной» части поста, приведу общие наблюдения:
1) Единственный очищаемый буфер — это буфер глубин/стенсила.
Если проанализировать упомянутые выше текстуры в хорошем анализаторе кадров, то вы будете немного удивлены, потому что для них не используется вызов «Clear», за исключением Depth/Stencil.
То есть в реальности RenderTarget1 выглядит так (заметьте «размытые» пиксели на дальней плоскости):
![](https://2.bp.blogspot.com/-u3e_o5dAIlw/XCZuv_XBblI/AAAAAAAABME/-XhXs-pAGSMZU-BrpKTYLsIQumgIKJ54QCLcBGAs/s1600/07_gbuffer_real.jpg)
Это простая и умная оптимизация.
Важный урок: на вызовы ClearRenderTargetView нужно тратить ресурсы, поэтому используйте их только при необходимости.
2) Перевёрнутая глубина — это круто
Во многих статьях уже писали о точности буфера глубин с плавающей запятой. В Witcher 3 используется reversed-z. Это естественный выбор для такой игры с открытым миром и дальними дистанциями отрисовки.
Переключиться на DirectX будет несложно:
a) Очищаем буфер глубин записью «0», а не «1».
В традиционном подходе для очистки буфера глубин использовалось дальнее значение «1». После переворота глубины новым «дальним» значением стал 0, поэтому нужно всё поменять.
b) Поменять местами значения ближней и дальней границ при вычислении матрицы проецирования
c) Изменить проверку глубины с «меньше» на «больше»
Для OpenGL нужно проделать чуть больше работы (см. упомянутые выше статьи), но она того стоит.
3) Не храним позицию в мире
Да, всё так просто. В проходе освещения позицию в мире воссоздаём из глубины.
В этой части я хотел показать именно пиксельный шейдер, поставляющий в GBuffer данные поверхностей.
Итак, теперь мы уже знаем, как хранить цвет, нормали и specular.
Разумеется, всё не так просто, как вы могли подумать.
Проблема пиксельного шейдера в том, что он имеет множество вариантов. Они отличаются по количеству передаваемых им текстур и количеству параметров, используемых из буфера констант (вероятно, из буфера констант, описывающего материал).
Для анализа я решил использовать эту красивую бочку:
![](https://2.bp.blogspot.com/-kYcvNog7q2w/XCZ051LGr5I/AAAAAAAABMQ/z9lEImMhsjg9o_I2ipUVFv7yKvq0nFxVwCEwYBhgL/s1600/08_a_hero_barrel.JPG)
Наша героическая бочка!
Пожалуйста, поприветствуйте текстуры:
![](https://1.bp.blogspot.com/-l5XIEinnR5U/XCZ06AB9uGI/AAAAAAAABMc/JDro_Lm4ZY822_T64ZrZFUAwNasWa4AvwCEwYBhgL/s1600/08_barrel_inputs.JPG)
Итак, у нас есть albedo, карта нормалей и specular color. Довольно стандартный случай.
Прежде чем начнём, несколько слов о входных данных геометрии:
Геометрия передаётся с позицией, texcoords, буферами нормалей и касательных.
Вершинный шейдер выводит как минимум texcoords, нормализованные векторы касательных/нормалей/касательных к двум точкам, ранее умноженные на матрицу мира. Для более сложных материалов (например, с двумя диффузными картами или двумя картами нормалей) вершинный шейдер может выводить другие данные, но я хотел показать здесь простой пример.
Пиксельный шейдер в ассемблерном коде:
Шейдер состоит из нескольких этапов. Я опишу каждую основную часть этого шейдера по отдельности.
Но сначала как обычно — скриншот со значениями из буфера констант:
![](https://2.bp.blogspot.com/-bFaZnqsK21Q/XCZ6vAHH79I/AAAAAAAABMk/MEM2umwwge8aZbUOid_gQtLgFc2viRYJwCLcBGAs/s1600/09_cbuffer_mat.JPG)
Начнём мы со сложных вещей. Это не просто«OutputColor.rgb = Texture.Sample(uv).rgb»
После сэмплирования RGB текстуры цвета (строка 1), следующие 14 строки — это то, что я называю «буфером снижения насыщенности». Давайте я покажу код на HLSL:
Для большинства объектов этот код не делает ничего, кроме возврата исходного цвета из текстуры. Это достигается соответствующими значениями «material cbuffer». cb4_v1.x имеет значение 1.0, что возвращает в маске, равной 0.0, и выдаёт входной цвет из инструкции lerp.
Однако существуют некоторые исключения. Наибольшее найденное мной значение desaturationFactor равно 4.0 (оно никогда не бывает меньше 1.0), а desaturatedColor зависит от материала. Оно может быть чем-то вроде (0.2, 0.3, 0.4); здесь нет строгих правил. Разумеется, я не удержался от реализации этого в своём собственном DX11-фреймворке, и вот результаты, где все значения desaturatedColor равны float3( 0.25, 0.3, 0.45 )
![](https://4.bp.blogspot.com/-Gl590y6OyFc/XCasdWZbNRI/AAAAAAAABMw/8YwkPPu9sww_hMhDEHbwnabI6NlwZr8rQCLcBGAs/s1600/Screenshot_2018-12-28_23-45-48.jpg)
desaturationFactor = 1.0 (не оказывает никакого эффекта)
![](https://1.bp.blogspot.com/-c9ke0mLH2Ac/XCasdtAiUPI/AAAAAAAABM0/zKF64ZWoTMMGGaxSKCn32IxHukuyDCo1ACLcBGAs/s1600/Screenshot_2018-12-28_23-45-56.jpg)
desaturationFactor = 2.0
![](https://2.bp.blogspot.com/-x7p6M_z7XZ4/XCasdvYQPnI/AAAAAAAABM4/Ixiz21kPMdg8MZkE5r7x32HofqRYfRQtQCLcBGAs/s1600/Screenshot_2018-12-28_23-46-03.jpg)
desaturationFactor = 3.0
![](https://4.bp.blogspot.com/-dxDz6mmdmaU/XCasfK2B7qI/AAAAAAAABM8/ALZqY2blbQMOIQ0s6eTD2Q6sZ6vE8Ze2gCLcBGAs/s1600/Screenshot_2018-12-28_23-46-09.jpg)
desaturationFactor = 4.0
Я уверен, что это просто применение параметров материалов, но выполняемое не конце части с albedo.
Строки 15-20 добавляют финальные штрихи:
v0.z — это выходные данные из вершинного шейдера, и они равны нулю. Не забывайте об этом, потому что v0.z позже ещё будет использовано пару раз.
Похоже, что это какой-то коэффициент, и весь код похож на небольшое затемнение albedo, но так как v0.z равно 0, цвет остаётся неизменным. HLSL:
Что касается RT0.a, то, как мы видим, оно берётся из буфера констант материала, но так как у шейдера нет отладочной информации, сложно сказать, что это такое. Возможно просвечиваемость?
Мы закончили с первым render target!
Начнём с распаковки карты нормалей, а затем как обычно выполним привязку нормалей:
Пока ничего удивительного.
Посмотрите на строки 28-33:
Мы можем приблизительно написать их следующим образом:
Не уверен, правильно ли так писать. Если вы знаете, что это за математическая операция, то дайте мне знать.
Мы видим, что пиксельный шейдер использует SV_IsFrontFace.
Что это такое? На помощью приходит документация (я хотел написать «msdn», но...):
Я хотел проверить это самостоятельно. И в самом деле, эффект заметен только в каркасном (wireframe) режиме. Полагаю, этот фрагмент кода нужен для правильного вычисления нормалей (а значит и освещения) в режиме wireframe.
Вот сравнение: оба цвета каркаса готовой сцены при этом включенном/отключенном трюке, а также текстура нормалей gbuffer [0-1] при включенном/отключенном трюке:
![](https://1.bp.blogspot.com/-VUJokj6z34I/XCay9L14LjI/AAAAAAAABNU/njBnumAcAn4COL-S61HS8jRsNR39ZLswQCEwYBhgL/s1600/isfront_final_off.jpg)
Цвет сцены без трюка
![](https://1.bp.blogspot.com/-m-RlggZe4Q8/XCay-YpS0XI/AAAAAAAABNY/_K7M3D0k8y8n0DXqo4Fd9M5qxwSl13SpQCEwYBhgL/s1600/isfront_final_on.jpg)
Цвет сцены с трюком
![](https://4.bp.blogspot.com/-rcUxw16iTwM/XCazA3d733I/AAAAAAAABNo/gdgEcyKgdTYelKosDtsasV_UMHbzPxp_QCEwYBhgL/s1600/isfront_normals_off.png)
Нормали [0-1] без трюка
![](https://1.bp.blogspot.com/-gP0_7zgbrB0/XCazCx9OiwI/AAAAAAAABNs/2z8swLG9biIWfNWqYoHf6Of6AykfurbMQCEwYBhgL/s1600/isfront_normals_on.png)
Нормали [0-1] с трюком
Вы заметили, что каждый render target в GBuffer имеет формат R8G8B8A8_UNORM? Это означает, что на один компонент приходится 256 возможных значений. Достаточно ли этого для хранения нормалей?
Хранение в Gbuffer высококачественных нормалей с достаточным количеством байтов — известная проблема, но, к счастью, существует множество разных материалов по которым можно поучиться.
Возможно, некоторые из вас уже знают, какая техника здесь используется. Нужно сказать, что в целом проходе геометрии есть одна дополнительная текстура, прикреплённая к слоту 13...:
![](https://3.bp.blogspot.com/-5olczP0vhhI/XCa2wseLGgI/AAAAAAAABN0/qAsy2Urc1SQTIh6ZTTmGY_cJ9YdyFS_pACLcBGAs/s1600/bfn.jpg)
Ха! В The Witcher 3 используется техника под названием "Best Fit Normals". Здесь я не буду объяснять её подробно (посмотрите презентацию). Она была изобретена примерно в 2009-2010 году компанией Crytek, и поскольку CryEngine имеет открытые исходники, BFN тоже open source.
BFN придаёт текстуре нормалей «зернистый» вид.
После масштабирования нормалей с помощью BFN мы перекодируем их из интервала [-1;1] в [0, 1].
Начнём со строки 34, и сэмплируем текстуру specular:
Как видите, здесь есть знакомый нам по Albedo фильтр «затемнения»:
Вычисляем компонент с макс. значением, а затем вычисляем «затемнённый» цвет и интерполируем его с исходным specular color, взяв параметр из вершинного шейдера… который равен 0, поэтому на выходе мы получаем цвет из текстуры.
HLSL:
Я понятия не имею, подходит ли это название для этого параметра, потому что не знаю, как он влияет на проход освещения. Дело в том, что альфа-канал входной карты нормалей содержит дополнительные данные:
![](https://1.bp.blogspot.com/-DwHBqe5kMjA/XCa6g_QIXtI/AAAAAAAABOA/voqX8kUcAXEWLZNwnN_Uw4ESREQ4TUmrACLcBGAs/s1600/reflectivity.jpg)
Альфа-канал текстуры «карты нормалей».
Ассемблерный код:
Поздоровайтесь с нашим старым другом — v0.z! Его смысл схож с albedo и с specular:
Отлично! Это конец анализа первого варианта пиксельного шейдера.
Вот сравнение моего шейдера (слева) с исходным (справа):
![](https://1.bp.blogspot.com/-aBwBUoPtIYY/XCa9pRsygcI/AAAAAAAABOM/GH329af5jfUlnZKbxtLo387Df48iBskEwCLcBGAs/s1600/result_variant_01.JPG)
Эти различия не влияют на вычисления, поэтому моя работа здесь закончена.
Я решил показать ещё один вариант, теперь только с картами albedo и нормалей, без текстуры specular. Ассемблерный код чуть длиннее:
Разница между этим и предыдущим вариантами в следующем:
a) строки 1, 19: параметр интерполяции v0.z умножается на cb4[0].x из буфера констант, но это произведение используется только для интерполяции albedo в строке 19. Для других выходных данных используется «обычное» значение v0.z.
b) строки 54-55: o2.w теперь задаётся при условии, что (cb4[7].x > 0.0 )
Мы уже узнаём этот паттерн «какое-то сравнение — И» из вычисления гистограммы яркости. Его можно записать так:
c) строки 34-42: полностью другое вычисление specular.
Здесь нет текстуры specular. Давайте посмотрим отвечающий за эту часть ассемблерный код:
Заметьте, что здесь мы использовали (1 — отражаемая способность). К счастью, в HLSL это написать довольно просто:
Добавлю, что в этом вариенте буфер констант с данными материалов чуть больше. Здесь эти дополнительные значения используются для эмуляции specular color.
Остальная часть шейдера такая же, как в предыдущем варианте.
72 строк ассемблерного кода — это слишком много для отображения в WinMerge, поэтому поверьте мне на слово: у меня код получился почти таким же, как в оригинале. Или вы можете скачать мой HLSLexplorer и убедиться в этом самостоятельно!
… и если вы дочитали досюда, то, возможно, хотите ещё чуть больше углубиться.
То, что кажется простым в реальной жизни чаще всего таким не является, и передача данных в gbuffer The Witcher 3 не стала исключением. Я показал вам только простейшие варианты пиксельных шейдеров, отвечающих за неё, а также привёл общие наблюдения, которые относятся к отложенному затенению в целом.
Для самых терпеливых два варианта пиксельных шейдеров есть в pastebin:
Вариант 1 — с текстурой specular
Вариант 2 — без текстуры specular
В этой части мы рассмотрим замечательный атмосферный эффект, который мне очень нравится — далёкие занавесы дождя/освещения рядом с горизонтом. В игре их проще всего встретить на островах Скеллиге.
Лично мне очень нравится это атмосферное явление и было любопытно, как программисты графики CD Projekt Red его реализовали. Давайте разберёмся!
Вот два скриншота до и после применения занавесов дождя:
![](https://1.bp.blogspot.com/-GF2a8ieU86Y/XER-7XWBYdI/AAAAAAAABPg/7k1FQXMz4jQlsjSOyaR3aiWAZtXMYRxrwCLcBGAs/s1600/01.png)
До занавесов дождя
![](https://1.bp.blogspot.com/-LnC-matBdO8/XER-7ieelzI/AAAAAAAABPk/axKuLyy8SGQxRTszod-7Tm7Megc5RX5EwCLcBGAs/s1600/02.png)
После занавесов дождя
Сначала мы остановимся на геометрии. Идея заключается в использовании небольшого цилиндра:
![](https://1.bp.blogspot.com/-tppUXqwKNSg/XESCzAuYE1I/AAAAAAAABP0/EJLXguKd7dw0VLZfQE6NLq_4iN7_P3SbgCEwYBhgL/s1600/03_cyllinder_local.JPG)
Цилиндр в локальном пространстве
С точки зрения его позиции в локальном пространстве он достаточно мал — его позиция находится в интервале (0.0 — 1.0).
Входная схема для этого вызова отрисовки выглядит так…
![](https://4.bp.blogspot.com/-hHBEi8ffcZo/XESCzV6md_I/AAAAAAAABQA/o0QII9uI1m0BSo5-y3FlBekg3NM9OqFPQCEwYBhgL/s1600/04_input_layout.JPG)
Для нас здесь важно следующее: Texcoords и Instance_Transform.
Texcoords обёрнуты довольно просто: U верхнего и нижнего основания находятся в интервале [0.02777 — 1.02734]. V на нижнем основании равно 1.0, а на верхнем — 0.0. Как видите, можно довольно просто создать этот меш даже процедурно.
Получив этот небольшой цилиндр в локальном пространстве, мы умножаем его на матрицу мира, предоставляемую для каждого экземпляра элемента ввода INSTANCE_TRANSFORM. Давайте проверим значения этой матрицы:
![](https://4.bp.blogspot.com/-qJJFzLwOk4A/XESE5G54FpI/AAAAAAAABQQ/1DIXNk0y5OA5MPIAd0bnUvXof2-geSOVwCLcBGAs/s1600/05_xform_0.JPG)
![](https://2.bp.blogspot.com/-nXNJKMT-abE/XESE47jgoEI/AAAAAAAABQM/GBqxP-MMxHMPKJWtYxaYtsdmabSQWDNEQCLcBGAs/s1600/05_xform_1.JPG)
![](https://2.bp.blogspot.com/-uiaVHBOKU7Q/XESE4_Ewa0I/AAAAAAAABQI/pz2a4zsiBH4vCi1qQyglpa6YTz8cAc10wCLcBGAs/s1600/05_xform_2.jpg)
Выглядит довольно пугающе, правда? Но не волнуйтесь, мы разберём эту матрицу и посмотрим, что она скрывает!
Результаты очень интересны:
Важно знать позицию камеры в этом конкретном кадре: (-116.5338, 234.8695, 2.09)
Как видите, мы отмасштабировали цилиндр, чтобы сделать его довольно большим в мировом пространстве (в TW3 ось Z направлена вверх), перенесли его относительно позиции камеры, и повернули.
Вот как выглядит цилиндр после преобразования вершинным шейдером:
![](https://3.bp.blogspot.com/-ot0x-QCWwKY/XESI4dl92tI/AAAAAAAABQk/8JMLAWsCx40veOrapXOAFwj-Oq_VR032wCEwYBhgL/s1600/06_cylinder_xformed.JPG)
Цилиндр после преобразования вершинным шейдером. Посмотрите, как он расположен относительно пирамиды видимости.
Входная геометрия и вершинный шейдер строго зависимы друг от друга.
Давайте внимательнее посмотрим на ассемблерный код вершинного шейдера:
Наряду с простой передачей Texcoords (строка 0) и Instance_LOD_Params (строка 8), для вывода нужны ещё два элемента: SV_Position (это очевидно) и Height (компонент .z) позиции в мире.
Помните, что локальное пространство находится в интервале [0-1]? Так вот, прямо перед применением матрицы мира вершинный шейдер использует масштаб и отклонение для изменения локальной позиции. Умный ход!
В данном случае scale = float3(4, 4, 2), а bias = float3(-2, -2, -1).<
Паттерн, который заметен между строками 9 и 28 — это умножение двух row-major-матриц.
Давайте просто посмотрим на готовый вершинный шейдер на HLSL:
Сравнение моего шейдера (слева) и оригинального (справа):
![](https://2.bp.blogspot.com/-D_fgUmLY6Ww/XEShA78FI-I/AAAAAAAABQw/zuUS3zLAwNoBy3Y8y6Pv1QfbY5bakg1WwCLcBGAs/s1600/07_vs_comparison.JPG)
Различия не влияют на вычисления. Я выполнил инъекцию моего шейдера в кадр и всё по-прежнему было в порядке!
Наконец-то! Для начала я покажу вам входные данные:
Здесь используются две текстуры: текстура шума и буфер глубин:
![](https://3.bp.blogspot.com/-euhiOTyI6pk/XESoSRMRs6I/AAAAAAAABRA/JggBsebbVhwonTVms7GzniSFpv5h-MSVACLcBGAs/s1600/08_noise.JPG)
![](https://1.bp.blogspot.com/-_NwpkaHh88c/XESoSeVVvKI/AAAAAAAABQ8/WkHY6-PieHUWCtVeimeJvpWgY4FVk4zAQCLcBGAs/s1600/09_depth.png)
Значения из буферов констант:
![](https://3.bp.blogspot.com/-nit-_CTc4Aw/XESpICsn46I/AAAAAAAABRY/IxeBuGWIyfgkVNx04rcOGU5Vm0C6zjyiwCLcBGAs/s1600/10_cbuffer_4.JPG)
![](https://4.bp.blogspot.com/-eGtDSqJ7iJM/XESpHI38UnI/AAAAAAAABRQ/uL_ADuJlG0MAa5YrThHVfUD-vUf2Tw5TQCLcBGAs/s1600/10_cbuffer_0.JPG)
![](https://4.bp.blogspot.com/-Z6OoJlnBPMI/XESpHOfSdMI/AAAAAAAABRM/vlfW9aVXmUANQxQCPr-zc4oOTx-NibF1QCLcBGAs/s1600/10_cbuffer_2.JPG)
![](https://3.bp.blogspot.com/-rXnAcqcuOso/XESpHjtm4gI/AAAAAAAABRU/f_BtCNV-nEM0KD8UowAFMGOcTTsg--hGQCLcBGAs/s1600/10_cbuffer_12.JPG)
И ассемблерный код пиксельного шейдера:
Ого! Довольно большой объём, но на самом деле всё не так плохо.
Что же здесь происходит? Сначала мы вычисляем анимированные UV, воспользовавшись прошедшим временем из cbuffer (cb0[0].x) и масштабом/смещениями. Эти texcoords используются для сэмплирования из текстуры шума (строка 2).
Получив значение шума из текстуры, мы выполняем интерполяцию между значениями min/max (обычно 0 и 1).
Затем мы выполняем умножения, например на координату текстуры V (помните, что координата V идёт от 1 до 0?) — строка 5.
Таким образом мы вычислили «маску яркости» — она выглядит вот так:
![](https://2.bp.blogspot.com/--0daWUw7zs0/XESteHj16-I/AAAAAAAABRs/wb_BQdYE70wjXLly2uhZUwLKgbgTwKvEgCLcBGAs/s1600/11_noise_mask.png)
Заметьте, что далёкие объекты (маяк, горы...) пропали. Это произошло, потому что цилиндр проходит тест глубины — цилиндр не находится на дальней плоскости и отрисовывается поверх этих объектов:
![](https://2.bp.blogspot.com/-hAzVllLXW_M/XESvIhSV89I/AAAAAAAABSA/oIZFzNii_Vc6HwdOp2ljlriKenMeHlRegCLcBGAs/s1600/14_depth.png)
Тест глубины
Мы хотим имитировать то, что занавес дождя находится дальше (но необязательно на дальней плоскости). Чтобы сделать это, мы вычисляем ещё одну маску, «маску далёких объектов».
Вычисляется она по следующей формуле:
(0.001 берётся из буфера), что даёт нам нужную маску:
![](https://4.bp.blogspot.com/-rITAMcdy_9A/XEStd-Ho6eI/AAAAAAAABR4/Kc8BLkocib0pF4erwtCbfUKvLpBjJTpggCEwYBhgL/s1600/12_far_objects_mask.png)
(В части про эффект Sharpen я уже поверхностно объяснял, как из буфера глубин извлекается глубина пирамиды видимости.)
Лично мне кажется, что этот эффект можно было реализовать менее затратно, без вычисления высоты в мировом пространстве, умножением глубины пирамиды видимости на меньшее число, например 0.0004.
При перемножении обеих масок получается окончательная:
![](https://2.bp.blogspot.com/-3d5_Wnt0RaA/XESteljSzaI/AAAAAAAABR4/kB8au7lCHmgsDSOrphCvfBHGQF4qn5fggCEwYBhgL/s1600/13_combined_mask.png)
Получив эту окончательную маску (строка 16), мы выполняем ещё одну интерполяцию, поторая почти ничего не делает (по крайней мере, в протестированном случае), а затем умножаем окончательную маску на цвет занавесов (строка 19), выполняем гамма-коррекцию (строки 20-22) и окончательные умножения (23-24).
В конце мы возвращаем цвет с нулевым альфа-значением. Так делается, потому что на этом проходе включено смешивание:
Если вы не совсем хорошо понимаете, как работает смешивание, то вот краткое объяснение:
SourceColor — это выходные RGB-данные из пиксельного шейдера, а DestColor — это текущий RGB-цвет пикселя в render target. Так как SourceAlpha всегда равна 0.0, вышеупомянутое уравнение упрощается до:
Проще говоря, здесь мы выполняем аддитивное смешивание. Если пиксельный шейдер возвращает (0, 0, 0), то цвет останется таким же.
Вот готовый код на HLSL — думаю, что после объяснения понять его будет намного проще:
С радостью могу сказать, что мой пиксельный шейдер создаёт тот же ассемблерный код, что и в оригинале.
Надеюсь, статья вам понравилась. Спасибо за прочтение!
Часть 6. Sharpen
В этой части мы подробнее рассмотрим ещё один эффект постобработки из The Witcher 3 — Sharpen.
Sharpening делает изображение на выходе немного чётче. Этот эффект известен нам по Photoshop и другим графическим редакторам.
В The Witcher 3 у sharpening есть две опции: low и high. О разнице между ними я расскажу ниже, а пока давайте взглянем на скриншоты:
![image](https://3.bp.blogspot.com/-48w3LzzZxck/W-nij_Zw4-I/AAAAAAAABFo/PT4Ag91YHGcAO2yJkzsMNPxdxLw0zhJNACLcBGAs/s1600/05_sharpen_low_before.png)
Опция «Low» — до
![image](https://1.bp.blogspot.com/-OHL-u69mwLY/W-nikHvPR9I/AAAAAAAABFk/t4lEbIpGSyYMCtKj9o4hRuoqi5L853rnQCLcBGAs/s1600/06_sharpen_low_after.png)
Опция «Low» — после
![](https://2.bp.blogspot.com/-0DszcHBoZbk/W-ng2wgnZHI/AAAAAAAABFU/US-j1-7eSzMEpV2BBfO2rvUqzvPssjcewCLcBGAs/s1600/03_sharpen_before.png)
Опция «High» — до
![](https://1.bp.blogspot.com/-f3gSJndHP-I/W-ng25Qvd5I/AAAAAAAABFQ/Is5RC6q7fZE8K_5P8Kd2TXTkDbP7ICUvQCLcBGAs/s1600/04_sharpen_after.png)
Опция «High» — после
Если вы хотите взглянуть на более подробные (интерактивные) сравнения, то посмотрите раздел в руководстве о производительности The Witcher 3 компании Nvidia. Как видите, эффект особенно заметен на траве и листве.
В этой части поста мы изучим кадр из самого начала игры: я выбрал его намеренно, потому что здесь мы видим рельеф (длинная дистанция отрисовки) и купол неба.
![](https://2.bp.blogspot.com/-K6ItWKMli8M/W-rtZjh6DNI/AAAAAAAABF4/Rf13MziMhUYYtJrKZ4aWzdsU5rvDRbdWACLcBGAs/s1600/07_final_kaermorhen_high.png)
С точки зрения входных данных для sharpening требуется цветовой буфер t0 (LDR после тональной коррекции и lens flares) и буфер глубин t1.
Давайте изучим ассемблерный код пиксельного шейдера:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb3[3], immediateIndexed
dcl_constantbuffer cb12[23], immediateIndexed
dcl_sampler s0, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_input_ps_siv v0.xy, position
dcl_output o0.xyzw
dcl_temps 7
0: ftoi r0.xy, v0.xyxx
1: mov r0.zw, l(0, 0, 0, 0)
2: ld_indexable(texture2d)(float,float,float,float) r0.x, r0.xyzw, t1.xyzw
3: mad r0.x, r0.x, cb12[22].x, cb12[22].y
4: mad r0.y, r0.x, cb12[21].x, cb12[21].y
5: max r0.y, r0.y, l(0.000100)
6: div r0.y, l(1.000000, 1.000000, 1.000000, 1.000000), r0.y
7: mad_sat r0.y, r0.y, cb3[1].z, cb3[1].w
8: add r0.z, -cb3[1].x, cb3[1].y
9: mad r0.y, r0.y, r0.z, cb3[1].x
10: add r0.y, r0.y, l(1.000000)
11: ge r0.x, r0.x, l(1.000000)
12: movc r0.x, r0.x, l(0), l(1.000000)
13: mul r0.z, r0.x, r0.y
14: round_z r1.xy, v0.xyxx
15: add r1.xy, r1.xyxx, l(0.500000, 0.500000, 0.000000, 0.000000)
16: div r1.xy, r1.xyxx, cb3[0].zwzz
17: sample_l(texture2d)(float,float,float,float) r2.xyz, r1.xyxx, t0.xyzw, s0, l(0)
18: lt r0.z, l(0), r0.z
19: if_nz r0.z
20: div r3.xy, l(0.500000, 0.500000, 0.000000, 0.000000), cb3[0].zwzz
21: add r0.zw, r1.xxxy, -r3.xxxy
22: sample_l(texture2d)(float,float,float,float) r4.xyz, r0.zwzz, t0.xyzw, s0, l(0)
23: mov r3.zw, -r3.xxxy
24: add r5.xyzw, r1.xyxy, r3.zyxw
25: sample_l(texture2d)(float,float,float,float) r6.xyz, r5.xyxx, t0.xyzw, s0, l(0)
26: add r4.xyz, r4.xyzx, r6.xyzx
27: sample_l(texture2d)(float,float,float,float) r5.xyz, r5.zwzz, t0.xyzw, s0, l(0)
28: add r4.xyz, r4.xyzx, r5.xyzx
29: add r0.zw, r1.xxxy, r3.xxxy
30: sample_l(texture2d)(float,float,float,float) r1.xyz, r0.zwzz, t0.xyzw, s0, l(0)
31: add r1.xyz, r1.xyzx, r4.xyzx
32: mul r3.xyz, r1.xyzx, l(0.250000, 0.250000, 0.250000, 0.000000)
33: mad r1.xyz, -r1.xyzx, l(0.250000, 0.250000, 0.250000, 0.000000), r2.xyzx
34: max r0.z, abs(r1.z), abs(r1.y)
35: max r0.z, r0.z, abs(r1.x)
36: mad_sat r0.z, r0.z, cb3[2].x, cb3[2].y
37: mad r0.x, r0.y, r0.x, l(-1.000000)
38: mad r0.x, r0.z, r0.x, l(1.000000)
39: dp3 r0.y, l(0.212600, 0.715200, 0.072200, 0.000000), r2.xyzx
40: dp3 r0.z, l(0.212600, 0.715200, 0.072200, 0.000000), r3.xyzx
41: max r0.w, r0.y, l(0.000100)
42: div r1.xyz, r2.xyzx, r0.wwww
43: add r0.y, -r0.z, r0.y
44: mad r0.x, r0.x, r0.y, r0.z
45: max r0.x, r0.x, l(0)
46: mul r2.xyz, r0.xxxx, r1.xyzx
47: endif
48: mov o0.xyz, r2.xyzx
49: mov o0.w, l(1.000000)
50: ret
50 строк ассемблерного кода выглядят как вполне посильная задача. Давайте приступим к её решению.
Генерация величины Sharpen
Первый этап заключается в загрузке (Load) буфера глубин (строка 1). Стоит заметить, что в «Ведьмаке 3» используется перевёрнутая глубина (1.0 — близко, 0.0 — далеко). Как вы можете знать, аппаратная глубина привязывается нелинейным образом (подробности см. в этой статье).
Строки 3-6 выполняют очень интересный способ привязки этой аппаратной глубины [1.0 — 0.0] к значениям [близко-далеко] (мы задаём их на этапе MatrixPerspectiveFov). Рассмотрим значения из буфера констант:
Имея для «близко» значение 0.2, а для «далеко» значение 5000, мы можем вычислить значения cb12_v21.xy следующим образом:
cb12_v21.y = 1.0 / near
cb12_v21.x = - (1.0 / near) + (1.0 / near) * (near / far)
Этот фрагмент кода довольно часто встречается в шейдерах TW3, поэтому я считаю, что это просто функция.
После получения «глубины пирамиды видимости» строка 7 использует масштаб/искажение для создания коэффициента интерполяции (здесь мы используем saturate, чтобы ограничить значения интервалом [0-1]).
cb3_v1.xy и cb3_v2.xy — это, яркость эффекта sharpening на ближних и дальних расстояниях. Давайте назовём их «sharpenNear» и «sharpenFar». И это единственное отличие опций «Low» и «High» данного эффекта в The Witcher 3.
Теперь настало время использовать полученный коэффициент. Строки 8-9 просто выполняют
lerp(sharpenNear, sharpenFar, interpolationCoeff)
. Для чего это нужно? Благодаря этому мы получаем разную яркость рядом с Геральтом и вдали от него. Посмотрите:![](https://2.bp.blogspot.com/-wlLIyueDFR4/W-rzvFODmRI/AAAAAAAABGc/yGjaPROAcEEErg5X9nxmH0RZUFga5fg0ACLcBGAs/s1600/02_mask_noSkyboxFilter.png)
Возможно, это едва заметно, но здесь мы интерполировали на основании расстояния яркость sharpen рядом с игроком (2.177151) и яркость эффекта очень далеко (1.91303). После этого вычисления мы прибавляем к яркости 1.0 (строка 10). Зачем это нужно? Предположим, что показанная выше операция lerp дала нам 0.0. После прибавления 1.0 мы, естественно, получим 1.0, и это значение, которое не повлияет на пиксель при выполнении sharpening. Подробнее об этом рассказано ниже.
Во время добавления резкости мы не хотим влиять на небо. Этого можно достичь, добавив простую условную проверку:
// Не выполнять sharpen для неба
float fSkyboxTest = (fDepth >= 1.0) ? 0 : 1;
В The Witcher 3 значение глубины пикселей неба равно 1.0, поэтому мы используем его, чтобы получить своего рода «двоичный фильтр» (интересный факт: в данном случае step сработает неправильно).
Теперь мы можем умножить интерполированную яркость на «фильтр неба»:
![](https://3.bp.blogspot.com/-B5KPuxdGrKw/W-rzvEsdOVI/AAAAAAAABGg/Q2eaBeEikNc3jIi5jA4PZ_Ha1_vV1JE0gCEwYBhgL/s1600/01_mask_skybox.png)
Это умножение выполняется в строке 13.
Пример кода шейдера:
// Вычисление финального значения sharpen
float fSharpenAmount = fSharpenIntensity * fSkyboxTest;
Центр сэмплирования пикселя
В SV_Position есть аспект, который будет здесь важен: смещение в половину пикселя. Оказывается, что этот пиксель в верхнем левом углу (0, 0) имеет координаты не не (0, 0) с точки зрения of SV_Position.xy, а (0.5, 0.5). Ничего себе!
Здесь мы хотим взять сэмпл в центре пикселя, поэтому посмотрим на строки 14-16. Можно записать их на HLSL:
// Сэмплируем центр пикселя.
// Избавляемся от "половинопиксельного" смещения в SV_Position.xy.
float2 uvCenter = trunc( Input.Position.xy );
// Прибавляем половину пикселя, чтобы мы сэмплировали именно центр пикселя
uvCenter += float2(0.5, 0.5);
uvCenter /= g_Viewport.xy
А позже мы сэмплируем входную текстуру цвета из texcoords «uvCenter». Не волнуйтесь, результат сэмплирования будет тем же, что и при «обычном» способе (SV_Position.xy / ViewportSize.xy).
To sharpen or not to sharpen
Решение о том, нужно ли применять sharpen, зависит от fSharpenAmount.
// Получаем значение текущего пикселя
float3 colorCenter = TexColorBuffer.SampleLevel( samplerLinearClamp, uvCenter, 0 ).rgb;
// Финальный результат
float3 finalColor = colorCenter;
if ( fSharpenAmount > 0 )
{
// здесь выполняем sharpening...
}
return float4( finalColor, 1 );
Sharpen
Настало время взглянуть на сами внутренности алгоритма.
По сути, он выполняет следующие действия:
— сэмплирует четыре раза входную текстуру цвета по углам пикселя,
— складывает сэмплы и вычисляет среднее значение,
— вычисляет разность между «center» и «cornerAverage»,
— находит максимальный абсолютный компонент разности,
— корректирует макс. абс. компонент, используя значения scale+bias,
— определяет величину эффекта, используя макс. абс. компонент,
— вычисляет значение яркости (luma) для «centerColor» и «averageColor»,
— делит colorCenter на его luma,
— вычисляет новое, интерполированное значение luma на основе величины эффекта,
— умножает colorCenter на новое значение luma.
Довольно много работы, и мне было сложно в этом разобраться, потому что я никогда не экспериментировал с фильтрами резкости.
Давайте начнём с паттерна сэмплирования. Как можно увидеть в ассемблерном коде, выполняется четыре считывания текстуры.
Лучше всего будет показать это на примере изображения пикселя (уровень мастерства художника — эксперт):
![](https://2.bp.blogspot.com/-QPzS6rmZwy4/W-sVeKn-Q3I/AAAAAAAABG4/oY7LZM7W1m0Or-dO6T9VwyLrlorVeY0SQCLcBGAs/s1600/10_sampling_pattern.png)
Все считывания в шейдере используют билинейное сэмплирование (D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT).
Смещение от центра до каждого из углов равен (±0.5, ±0.5), в зависимости от угла.
Видите, как это можно реализовать на HLSL? Давайте посмотрим:
float2 uvCorner;
float2 uvOffset = float2( 0.5, 0.5 ) / g_Viewport.xy; // remember about division!
float3 colorCorners = 0;
// Верхний левый угол
// -0,5, -0.5
uvCorner = uvCenter - uvOffset;
colorCorners += TexColorBuffer.SampleLevel( samplerLinearClamp, uvCorner, 0 ).rgb;
// Верхний правый угол
// +0.5, -0.5
uvCorner = uvCenter + float2(uvOffset.x, -uvOffset.y);
colorCorners += TexColorBuffer.SampleLevel( samplerLinearClamp, uvCorner, 0 ).rgb;
// Нижний левый угол
// -0.5, +0.5
uvCorner = uvCenter + float2(-uvOffset.x, uvOffset.y);
colorCorners += TexColorBuffer.SampleLevel( samplerLinearClamp, uvCorner, 0 ).rgb;
// Нижний правый угол
// +0.5, +0.5
uvCorner = uvCenter + uvOffset;
colorCorners += TexColorBuffer.SampleLevel( samplerLinearClamp, uvCorner, 0 ).rgb;
Итак, теперь все четыре сэмпла суммированы в переменной «colorCorners». Давайте выполним следующие шаги:
// Вычисляем среднее четырёх углов
float3 averageColorCorners = colorCorners / 4.0;
// Вычисляем разность цветов
float3 diffColor = colorCenter - averageColorCorners;
// Находим макс. абс. RGB-компонент разности
float fDiffColorMaxComponent = max( abs(diffColor.x), max( abs(diffColor.y), abs(diffColor.z) ) );
// Корректируем этот коэффициент
float fDiffColorMaxComponentScaled = saturate( fDiffColorMaxComponent * sharpenLumScale + sharpenLumBias );
// Вычисляем необходимую величину резкости пикселя.
// Заметьте здесь "1.0" - именно поэтому мы прибавили в fSharpenIntensity значение 1.0.
float fPixelSharpenAmount = lerp(1.0, fSharpenAmount, fDiffColorMaxComponentScaled);
// Вычисляем яркость "центра" пикселя и яркость среднего значения.
float lumaCenter = dot( LUMINANCE_RGB, finalColor );
float lumaCornersAverage = dot( LUMINANCE_RGB, averageColorCorners );
// делим "centerColor" на его яркость
float3 fColorBalanced = colorCenter / max( lumaCenter, 1e-4 );
// Вычисляем новую яркость
float fPixelLuminance = lerp(lumaCornersAverage, lumaCenter, fPixelSharpenAmount);
// Вычисляем цвет на выходе
finalColor = fColorBalanced * max(fPixelLuminance, 0.0);
}
return float4(finalColor, 1.0);
Распознавание краёв выполняется вычислением макс. абс. компонента разности. Умный ход! Посмотрите его визуализацию:
![](https://4.bp.blogspot.com/-pwcUDABwk2c/W-zv1ldr2VI/AAAAAAAABHE/zjSO2IcXcDQCICtyHyexasv4PMLdWKO3ACLcBGAs/s1600/11_maxAbsComponent.jpg)
Визуализация максимального абсолютного компонента разности.
Отлично. Готовый HLSL-шейдер выложен здесь. Простите за довольно плохое форматирование. Можете воспользоваться моей программой HLSLexplorer и поэкспериментировать с кодом.
Могу с радостью сказать, что представленный выше код создаёт тот же ассемблерный код, что и в игре!
Подведём итог: шейдер резкости «Ведьмака 3» очень хорошо написан (заметьте. что fPixelSharpenAmount больше 1.0! это интересно...). Кроме того, основной способ изменения яркости эффекта — это яркость ближних/дальних объектов. В этой игре они не являются константами; я собрал несколько примеров значений:
Скеллиге:
sharpenNear | sharpenFar | sharpenDistanceScale | sharpenDistanceBias | sharpenLumScale | sharpenLumBias | |
---|---|---|---|---|---|---|
low | ||||||
high | 2.0 | 1.8 | 0.025 |
-0.25 |
-13.33333 |
1.33333 |
Каэр Морхен:
sharpenNear |
sharpenFar |
sharpenDistanceScale |
sharpenDistanceBias |
sharpenLumScale |
sharpenLumBias |
|
---|---|---|---|---|---|---|
low |
0.57751 |
0.31303 |
0.06665 |
-0.33256 |
-1.0 |
2.0 |
high |
2.17751 |
1.91303 |
0.06665 |
-0.33256 |
-1.0 |
2.0 |
Часть 7. Средняя яркость
Операцию вычисления средней яркости текущего кадра можно найти практически в любой современной видеоигре. Это значение часто позже используется для эффекта адаптации глаза и тональной коррекции (см. в предыдущей части поста). В простых решениях используется вычисление яркости для, допустим текстуры размером 5122, затем вычисление её mip-уровней и применение последнего. Обычно это срабатывает, но сильно ограничивает возможности. В более сложных решениях используются вычислительные шейдеры, выполняющие, например параллельную редукцию.
Давайте узнаем, как эту задачу решила в «Ведьмаке 3» команда CD Projekt Red. В предыдущей части я уже исследовал тональную коррекцию и адаптацию глаза, поэтому единственным оставшимся куском головоломки осталась средняя яркость.
Начнём с того, что вычисление средней яркости The Witcher 3 состоит из двух проходов. Для понятности я решил разбить их на отдельные части, и сначала мы рассмотрим первый проход — «распределение яркости» (вычисление гистограммы яркости).
Распределение яркости
Эти два прохода довольно просто найти в любом анализаторе кадров. Они являются идущими по порядку вызовами Dispatch прямо перед выполнением адаптации глаза:
Давайте рассмотрим входные данные для этого прохода. Ему необходимы две текстуры:
1) HDR-буфер цветов, масштаб которого снижен до 1/4 x 1/4 (например, с 1920x1080 до 480x270),
2) Полноэкранный буфер глубин
![](https://4.bp.blogspot.com/-2-gLL5phczo/XBIgradglsI/AAAAAAAABH4/R8ifVyzMZFoGZvhYe0SIMUeAjb5y0a89ACLcBGAs/s1600/02_input_1.png)
HDR-буфер цветов с разрешением 1/4 x 1/4. Заметьте хитрый трюк — этот буфер является частью большего буфера. Многократное использование буферов — это хорошая практика.
![](https://2.bp.blogspot.com/-fWHqIUjR-sg/XBIgrRk67HI/AAAAAAAABH0/mRc33ErTB7UslMiOgrU6FGE0gCo0GKObgCLcBGAs/s1600/02_input_2.png)
Полноэкранный буфер глубин
Зачем уменьшать масштаб буфера цветов? Думаю, всё дело в производительности.
Что касается выходных данных этого прохода, то ими является структурированный буфер. 256 элемента по 4 байта каждый.
Здесь у шейдеров нет отладочной информации, поэтому предположим, что это просто буфер беззнаковых значений int.
Важно: первый этап вычисления средней яркости вызывает ClearUnorderedAccessViewUint для обнуления всех элементов структурированного буфера.
Давайте изучим ассемблерный код вычислительного шейдера (это первый вычислительный шейдер за весь наш анализ!)
cs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[3], immediateIndexed
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_uav_structured u0, 4
dcl_input vThreadGroupID.x
dcl_input vThreadIDInGroup.x
dcl_temps 6
dcl_tgsm_structured g0, 4, 256
dcl_thread_group 64, 1, 1
0: store_structured g0.x, vThreadIDInGroup.x, l(0), l(0)
1: iadd r0.xyz, vThreadIDInGroup.xxxx, l(64, 128, 192, 0)
2: store_structured g0.x, r0.x, l(0), l(0)
3: store_structured g0.x, r0.y, l(0), l(0)
4: store_structured g0.x, r0.z, l(0), l(0)
5: sync_g_t
6: ftoi r1.x, cb0[2].z
7: mov r2.y, vThreadGroupID.x
8: mov r2.zw, l(0, 0, 0, 0)
9: mov r3.zw, l(0, 0, 0, 0)
10: mov r4.yw, l(0, 0, 0, 0)
11: mov r1.y, l(0)
12: loop
13: utof r1.z, r1.y
14: ge r1.z, r1.z, cb0[0].x
15: breakc_nz r1.z
16: iadd r2.x, r1.y, vThreadIDInGroup.x
17: utof r1.z, r2.x
18: lt r1.z, r1.z, cb0[0].x
19: if_nz r1.z
20: ld_indexable(texture2d)(float,float,float,float) r5.xyz, r2.xyzw, t0.xyzw
21: dp3 r1.z, r5.xyzx, l(0.212600, 0.715200, 0.072200, 0.000000)
22: imul null, r3.xy, r1.xxxx, r2.xyxx
23: ld_indexable(texture2d)(float,float,float,float) r1.w, r3.xyzw, t1.yzwx
24: eq r1.w, r1.w, cb0[2].w
25: and r1.w, r1.w, cb0[2].y
26: add r2.x, -r1.z, cb0[2].x
27: mad r1.z, r1.w, r2.x, r1.z
28: add r1.z, r1.z, l(1.000000)
29: log r1.z, r1.z
30: mul r1.z, r1.z, l(88.722839)
31: ftou r1.z, r1.z
32: umin r4.x, r1.z, l(255)
33: atomic_iadd g0, r4.xyxx, l(1)
34: endif
35: iadd r1.y, r1.y, l(64)
36: endloop
37: sync_g_t
38: ld_structured r1.x, vThreadIDInGroup.x, l(0), g0.xxxx
39: mov r4.z, vThreadIDInGroup.x
40: atomic_iadd u0, r4.zwzz, r1.x
41: ld_structured r1.x, r0.x, l(0), g0.xxxx
42: mov r0.w, l(0)
43: atomic_iadd u0, r0.xwxx, r1.x
44: ld_structured r0.x, r0.y, l(0), g0.xxxx
45: atomic_iadd u0, r0.ywyy, r0.x
46: ld_structured r0.x, r0.z, l(0), g0.xxxx
47: atomic_iadd u0, r0.zwzz, r0.x
48: ret
И буфер констант:
Мы уже знаем, что первыми входными данными является HDR-буфер цветов. При FullHD его разрешение равно 480x270. Посмотрим на вызов Dispatch.
Dispatch(270, 1, 1) — это означает, что мы запускаем 270 групп потоков. Проще говоря, мы запускаем по одной группе потоков на каждую строку буфера цветов.
![](https://3.bp.blogspot.com/-J0S7i3DPUeY/XBIk28NmvZI/AAAAAAAABII/HnvYgTiI-yUbYYj2BKAbSCqtx1qtDuuSgCLcBGAs/s1600/03_threadgroups.png)
Каждая группа потоков выполняет одну строку HDR-буфера цветов
Теперь, когда у нас есть этот контекст, давайте попробуем выяснить, что же делает шейдер.
Каждая группа потоков имеет 64 потоков в направлении X (dcl_thread_group 64, 1, 1), а также общую память, 256 элементов по 4 байта в каждом (dcl_tgsm_structured g0, 4, 256).
Заметьте, что в шейдере мы используем SV_GroupThreadID (vThreadIDInGroup.x) [0-63] и SV_GroupID (vThreadGroupID.x) [0-269].
1) Мы начинаем с того, что присваиваем всем элементам общей памяти нулевые значения. Так как в общей памяти содержится 256 элемента и 64 потока на группу, это удобно можно сделать с помощью простого цикла:
// Первый шаг - присвоение всем общим данным нулевых значений.
// Так как в каждой группе потоков есть 64 потока, каждый из них может с помощью простого смещения обнулить 4 элемента.
[unroll] for (uint idx=0; idx < 4; idx++)
{
const uint offset = threadID + idx*64;
shared_data[ offset ] = 0;
}
2) После этого мы устанавливаем барьер с помощью GroupMemoryBarrierWithGroupSync (sync_g_t). Мы делаем это, чтобы гарантировать обнуление всеми потоками всех элементов в общей памяти групп перед переходом к следующему этапу.
3) Теперь мы выполняем цикл, который можно приблизительно записать так:
// cb0_v0.x - это ширина буфера цветов уменьшенного масштаба. Для 1920x1080 она равна 1920/4 = 480;
float ViewportSizeX = cb0_v0.x;
[loop] for ( uint PositionX = 0; PositionX < ViewportSizeX; PositionX += 64 )
{
...
Это простой цикл for с инкрементом на 64 (вы уже поняли, почему?).
Следующий этап — вычисление позиции загружаемого пикселя.
Давайте подумаем об этом.
Для координаты Y мы можем использовать SV_GroupID.x, потому что мы запустили 270 групп потоков.
Для координаты X мы… можем воспользоваться преимуществом текущего потока группы! Давайте попробуем это сделать.
Так как в каждой группе по 64 потока, такое решение обойдёт все пиксели.
Рассмотрим группу потоков (0, 0, 0).
— Поток (0, 0, 0) обработает пиксели (0, 0), (64, 0), (128, 0), (192, 0), (256, 0), (320, 0), (384, 0), (448, 0).
— Поток (1, 0, 0) обработает пиксели (1, 0), (65, 0), (129, 0), (193, 0), (257, 0), (321, 0), (385, 0), (449, 0)…
— Поток (63, 0, 0) обработает пиксели (63, 0), (127, 0), (191, 0), (255, 0), (319, 0), (383, 0), (447, 0)
Таким образом, будут обработаны все пиксели.
Также нам нужно гарантировать, что мы не загрузим пиксели из-за пределов буфера цветов:
// Мы попиксельно перемещаемся вдоль оси X. Значение Y равно GroupID.
uint CurrentPixelPositionX = PositionX + threadID;
uint CurrentPixelPositionY = groupID;
if ( CurrentPixelPositionX < ViewportSizeX )
{
// HDR-буфер цветов.
// Вычисляем позицию HDR-буфера цветов в экранном пространстве, загружаем его и вычисляем яркость.
uint2 colorPos = uint2(CurrentPixelPositionX, CurrentPixelPositionY);
float3 color = texture0.Load( int3(colorPos, 0) ).rgb;
float luma = dot(color, LUMA_RGB);
Видите? Всё довольно просто!
Также я вычислил яркость (строка 21 ассемблерного кода).
Отлично, мы уже вычислили яркость из цветного пикселя. Следующий шаг — загрузка (не сэмплирование!) соответствующего значения глубины.
Но здесь у нас есть проблема, потому что мы подключили буфер глубин полного разрешения. Что с этим делать?
Это на удивление просто — достаточно умножить colorPos на какую-нибудь константу (cb0_v2.z). Мы уменьшили масштаб HDR-буфера цветов в четыре раза. поэтому значением будет 4!
const int iDepthTextureScale = (int) cb0_v2.z;
uint2 depthPos = iDepthTextureScale * colorPos;
float depth = texture1.Load( int3(depthPos, 0) ).x;
Пока всё здорово! Но… мы дошли до строк 24-25…
24: eq r2.x, r2.x, cb0[2].w
25: and r2.x, r2.x, cb0[2].y
Так. Сначала у нас есть сравнение равенства с плавающей запятой, его результат записывается в r2.x, а сразу после этого идёт… что? Побитовое И?? Серьёзно? Для значения с плавающей запятой? Какого чёрта???
Проблема 'eq+and'
Позвольте просто сказать, что это для меня была самая сложная часть шейдера. Я даже пробовал странные комбинации asint/asfloat…
А если использовать немного другой подход? Давайте просто выполним в HLSL обычное сравнение float-float.
float DummyPS() : SV_Target0
{
float test = (cb0_v0.x == cb0_v0.y);
return test;
}
А вот как выглядит вывод в ассемблерном коде:
0: eq r0.x, cb0[0].y, cb0[0].x
1: and o0.x, r0.x, l(0x3f800000)
2: ret
Интересно, правда? Не ожидал увидеть здесь «and».
0x3f800000 — это просто 1.0f… Логично, потому что мы получаем в случае успеха сравнения 1.0 и 0.0 в противном случае.
А что если мы «заменим» 1.0 каким-то другим значением? Например так:
float DummyPS() : SV_Target0
{
float test = (cb0_v0.x == cb0_v0.y) ? cb0_v0.z : 0.0;
return test;
}
Получим такой результат:
0: eq r0.x, cb0[0].y, cb0[0].x
1: and o0.x, r0.x, cb0[0].z
2: ret
Ха! Сработало. Это просто магия компилятора HLSL. Примечание: если заменить чем-то другим 0.0, то получится просто movc.
Вернёмся к вычислительному шейдеру. Следующим шагом будет проверка равенства глубины значению cb0_v2.w. Оно всегда равно 0.0 — проще говоря, мы проверяем, находится ли пиксель на дальней плоскости (в небе). Если да, то мы присваиваем этому коэффициенту какое-то значение, приблизительно 0.5 (я проверял на нескольких кадрах).
Такой вычисленный коэффициент используется для интерполяции между яркостью цвета и яркостью «неба» (значением cb0_v2.x, которое часто примерно равно 0.0). Предполагаю, что это нужно для управления важностью неба в вычислении средней яркости. Обычно важность уменьшается. Очень умная идея.
// Проверяем, лежит ли пиксель на дальней плоскости (в небе). Если да, то мы можем указать, как он будет
// смешиваться с нашими значениями.
float value = (depth == cb0_v2.w) ? cb0_v2.y : 0.0;
// Если 'value' равно 0.0, то эта lerp просто даёт нам 'luma'. Однако если 'value' отличается
// (часто около 0.50), то вычисленное luma имеет гораздо меньший вес. (cb0_v2.x обычно близко к 0.0).
float lumaOk = lerp( luma, cb0_v2.x, value );
Так как у нас есть lumaOk, следующим этапом будет вычисление его натурального логарифма для создания хорошего распределения. Но постойте, допустим, lumaOk равно 0.0. Мы знаем, что значение log(0) является неопределённым, поэтому прибавляем 1.0, потому что log(1) = 0.0.
После этого мы масштабируем вычисленный логарифм на 128, чтобы распределить его по 256 ячейкам. Очень умно!
И именно отсюда берётся это значение 88.722839. Это
128 * натуральный логарифм (2)
.Это просто способ, которым HLSL вычисляет логарифмы.
В ассемблерном коде HLSL есть только одна функция, вычисляющая логарифмы: log, и она имеет основание 2.
// Предположим, что lumaOk равно 0.0.
// log(0) имеет значение undefined
// log(1) = 0.
// вычисляем натуральный логарифм яркости
lumaOk = log(lumaOk + 1.0);
// Масштабируем логарифм яркости на 128
lumaOk *= 128;
Наконец мы вычисляем из логарифмически распределённой яркости индекс ячейки и прибавляем 1 к соответствующей ячейке в общей памяти.
// Вычисляем правильный индекс. Значение имеет формат Uint, поэтому в массиве 256 элементов,
// нужно убедиться, что мы не вышли за границы.
uint uLuma = (uint) lumaOk;
uLuma = min(uLuma, 255);
// Прибавляем 1 к соответствующему значению яркости.
InterlockedAdd( shared_data[uLuma], 1 );
Следующим шагом снова будет установка барьера, чтобы гарантировать, что были обработаны все пиксели в строке.
А последним шагом будет прибавление значений из общей памяти в структурированный буфер. Это делается тем же самым образом, через простой цикл:
// Ждём, пока обработаются все пиксели в строке
GroupMemoryBarrierWithGroupSync();
// Прибавление вычисленных значений в структурированный буфер.
[unroll] for (uint idx = 0; idx < 4; idx++)
{
const uint offset = threadID + idx*64;
uint data = shared_data[offset];
InterlockedAdd( g_buffer[offset], data );
}
После того, как все 64 потока в группе потоков заполнят общие данные, каждый поток добавляет 4 значения в буфер вывода.
Рассмотрим буфер вывода. Давайте подумаем об этом. Сумма всех значений в буфере равна общему количеству пикселей! (при 480x270 = 129 600). То есть мы знаем, сколько пикселей имеют конкретное значение яркости.
Если вы слабо разбираетесь в вычислительных шейдерах (как я), то поначалу это может быть непонятно, поэтому прочитайте пост ещё несколько раз, возьмите бумагу и карандаш, и попробуйте разобраться в концепциях, на которых построена эта техника.
Вот и всё! Именно так «Ведьмак 3» вычисляет гистограмму яркости. Лично я при написании этой части многому научился. Поздравляю ребят из CD Projekt Red с отличной работой!
Если вы интересуетесь полным HLSL-шейдером, то он выложен здесь. Я всегда стремлюсь получить как можно более близкий к игровому ассемблерный код и совершенно счастлив, что мне это снова удалось!
Вычисление средней яркости
Это вторая часть анализа вычислений средней яркости в «The Witcher 3: Wild Hunt».
Прежде чем мы вступим в бой с ещё одним вычислительным шейдером, давайте вкратце повторим, что произошло в прошлой части: мы работали с HDR-буфером цветов с уменьшенным до 1/4x1/4 масштабом. После первого прохода мы получили гистограмму яркости (структурированный буфер 256 беззнаковых целочисленных значений). Мы вычислили логарифм для яркости каждого пикселя, распределили его по 256 ячейкам и увеличили соответствующее значение структурированного буфера по 1 на пиксель. Благодаря этому общая сумма всех значений в этих 256 ячейках равна количеству пикселей.
Пример вывода первого прохода. Здесь 256 элементов.
Например, наш полноэкранный буфер имеет размер 1920x1080. После уменьшения масштаба первый проход использовал буфер 480x270. Сумма всех 256 значений в буфере будет равна 480 * 270 = 129 600.
После этого краткого вступления мы готовы перейти к следующему этапу: к вычислениям.
На этот раз используется только одна группа потоков ( Dispatch(1, 1, 1) ).
Давайте посмотрим на ассемблерный код вычислительного шейдера:
cs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[1], immediateIndexed
dcl_uav_structured u0, 4
dcl_uav_typed_texture2d (float,float,float,float) u1
dcl_input vThreadIDInGroup.x
dcl_temps 4
dcl_tgsm_structured g0, 4, 256
dcl_thread_group 64, 1, 1
0: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, vThreadIDInGroup.x, l(0), u0.xxxx
1: store_structured g0.x, vThreadIDInGroup.x, l(0), r0.x
2: iadd r0.xyz, vThreadIDInGroup.xxxx, l(64, 128, 192, 0)
3: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.w, r0.x, l(0), u0.xxxx
4: store_structured g0.x, r0.x, l(0), r0.w
5: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, r0.y, l(0), u0.xxxx
6: store_structured g0.x, r0.y, l(0), r0.x
7: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, r0.z, l(0), u0.xxxx
8: store_structured g0.x, r0.z, l(0), r0.x
9: sync_g_t
10: if_z vThreadIDInGroup.x
11: mul r0.x, cb0[0].y, cb0[0].x
12: ftou r0.x, r0.x
13: utof r0.y, r0.x
14: mul r0.yz, r0.yyyy, cb0[0].zzwz
15: ftoi r0.yz, r0.yyzy
16: iadd r0.x, r0.x, l(-1)
17: imax r0.y, r0.y, l(0)
18: imin r0.y, r0.x, r0.y
19: imax r0.z, r0.y, r0.z
20: imin r0.x, r0.x, r0.z
21: mov r1.z, l(-1)
22: mov r2.xyz, l(0, 0, 0, 0)
23: loop
24: breakc_nz r2.x
25: ld_structured r0.z, r2.z, l(0), g0.xxxx
26: iadd r3.x, r0.z, r2.y
27: ilt r0.z, r0.y, r3.x
28: iadd r3.y, r2.z, l(1)
29: mov r1.xy, r2.yzyy
30: mov r3.z, r2.x
31: movc r2.xyz, r0.zzzz, r1.zxyz, r3.zxyz
32: endloop
33: mov r0.w, l(-1)
34: mov r1.yz, r2.yyzy
35: mov r1.xw, l(0, 0, 0, 0)
36: loop
37: breakc_nz r1.x
38: ld_structured r2.x, r1.z, l(0), g0.xxxx
39: iadd r1.y, r1.y, r2.x
40: utof r2.x, r2.x
41: utof r2.w, r1.z
42: add r2.w, r2.w, l(0.500000)
43: mul r2.w, r2.w, l(0.011271)
44: exp r2.w, r2.w
45: add r2.w, r2.w, l(-1.000000)
46: mad r3.z, r2.x, r2.w, r1.w
47: ilt r2.x, r0.x, r1.y
48: iadd r2.w, -r2.y, r1.y
49: itof r2.w, r2.w
50: div r0.z, r3.z, r2.w
51: iadd r3.y, r1.z, l(1)
52: mov r0.y, r1.z
53: mov r3.w, r1.x
54: movc r1.xzw, r2.xxxx, r0.wwyz, r3.wwyz
55: endloop
56: store_uav_typed u1.xyzw, l(0, 0, 0, 0), r1.wwww
57: endif
58: ret
Здесь есть один буфер констант:
Вкратце взглянем на ассемблерный код: прикреплено два UAV (u0: входной буфер из первой части и u1: выходная текстура формата 1x1 R32_FLOAT). Также мы видим, что есть 64 потока на группу и 256 элементов 4-байтной общей групповой памяти.
Начнём с заполнения общей памяти данными из входного буфера. У нас 64 потока, поэтому делать придётся почти то же самое, что и раньше.
Чтобы быть абсолютно уверенными, что для дальнейшей обработки загружены все данные, после этого мы ставим барьер.
// Первый этап - заполнение всех общих данных данными из предыдущего этапа.
// Так как в каждой группе потоков по 64 потока, каждый может заполнить 4 элемента в одном потоке
// с помощью простого смещения.
[unroll] for (uint idx=0; idx < 4; idx++)
{
const uint offset = threadID + idx*64;
shared_data[ offset ] = g_buffer[offset];
}
// Здесь мы устанавливаем барьер, то есть блокируем выполнение всех потоков группы, пока не будет завершён
// весь общий доступ групп и все потоки в группе не достигнут этого вызова.
GroupMemoryBarrierWithGroupSync();
Все вычисления выполняются только в одном потоке, все другие используются просто для загрузки значений из буфера в общую память.
«Вычисляющий» поток имеет индекс 0. Почему? Теоретически, мы можем использовать любой поток из интервала [0-63], но благодаря сравнению с 0 мы можем избежать дополнительного сравнения integer-integer (инструкции ieq).
Алгоритм основан на указании интервала пикселей, которые будут учитываться в операции.
В строке 11 мы умножаем width*height, получая общее количество пикселей и умножаем их на два числа из интервала [0.0f-1.0f], обозначающие начало и конец интервала. Дальше используются ограничения, гарантирующие, что
0 <= Start <= End <= totalPixels - 1
: // Выполняем вычисления только для потока с индексом 0.
[branch] if (threadID == 0)
{
// Общее количество пикселей в буфере с уменьшенным масштабом
uint totalPixels = cb0_v0.x * cb0_v0.y;
// Интервал пикселей (или, если конкретнее, интервал яркости на экране),
// который мы хотим задействовать в вычислении средней яркости.
int pixelsToConsiderStart = totalPixels * cb0_v0.z;
int pixelsToConsiderEnd = totalPixels * cb0_v0.w;
int pixelsMinusOne = totalPixels - 1;
pixelsToConsiderStart = clamp( pixelsToConsiderStart, 0, pixelsMinusOne );
pixelsToConsiderEnd = clamp( pixelsToConsiderEnd, pixelsToConsiderStart, pixelsMinusOne );
Как видите, ниже есть два цикла. Проблема с ними (или с их ассемблерным кодом) в том, что в концах циклов есть странные условные переходы. Мне было очень сложно воссоздать их. Также взгляните на строку 21. Почему там "-1"? Я объясню это чуть ниже.
Задача первого цикла — отбросить pixelsToConsiderStart и дать нам индекс ячейки буфера, в которой присутствует пиксель pixelsToConsiderStart +1 (а также количество всех пикселей в предыдущих ячейках).
Допустим, что pixelsToConsiderStart примерно равна 30000, а в буфере 37000 пикселей в ячейке «ноль» (такое случается в игре ночью). Поэтому мы хотим начать анализ яркости примерно с пикселя 30001, который присутствует в ячейке «ноль». В данном случае мы сразу же выходим из цикла, получив начальный индекс '0' и ноль отброшенных пикселей.
Посмотрите на код HLSL:
// Количество уже обработанных пикселей
int numProcessedPixels = 0;
// Ячейка яркости [0-255]
int lumaValue = 0;
// Надо ли продолжать выполнение цикла
bool bExitLoop = false;
// Задача первого цикла - отбросить "pixelsToConsiderStart" пикселей.
// Мы сохраняем количество отброшенных пикселей из предыдущих ячеек и lumaValue, чтобы использовать их в следующем цикле.
[loop]
while (!bExitLoop)
{
// Получаем количество пикселей с заданным значением яркости.
uint numPixels = shared_data[lumaValue];
// Проверяем, сколько пикселей должно быть с lumaValue
int tempSum = numProcessedPixels + numPixels;
// Если больше, чем pixelsToConsiderStart, то выходим из цикла.
// Следовательно, мы начнём вычисление яркости из lumaValue.
// Проще говоря, pixelsToConsiderStart - это количество "затемнённых" пикселей, которые нужно отбросить, прежде чем начинать вычисления.
[flatten]
if (tempSum > pixelsToConsiderStart)
{
bExitLoop = true;
}
else
{
numProcessedPixels = tempSum;
lumaValue++;
}
}
Загадочное число "-1" из строки 21 ассемблерного кода связано с булевым условием выполнения цикла (я обнаружил это почти случайно).
Получив количество пикселей из ячеек lumaValue и само lumaValue, мы можем переходить ко второму циклу.
Задача второго цикла — вычисление влияния пикселей и средней яркости.
Мы начинаем с lumaValue, вычисленного в первом цикле.
float finalAvgLuminance = 0.0f;
// Количество отброшенных в первом цикле пикселей
uint numProcessedPixelStart = numProcessedPixels;
// Задача этого цикла - вычисление влияния пикселей и средней яркости.
// Мы начинаем с точки, вычисленной в предыдущем цикле, сохраняя количество отброшенных пикселей и начальную позицию lumaValue.
// Декодируем значение яркости из интервала [0-255], умножаем его на количество пикселей, имеющих это значение яркости, и суммируем их, пока не дойдём
// до обработки пикселей pixelsToConsiderEnd.
// После этого мы делим общее влияние на количество проанализированных пикселей.
bExitLoop = false;
[loop]
while (!bExitLoop)
{
// Получаем количество пикселей с заданным значением яркости.
uint numPixels = shared_data[lumaValue];
// Прибавляем ко всем обработанным пикселям
numProcessedPixels += numPixels;
// Текущее обрабатываемое значение яркости, распределённое в интервале [0-255] (uint)
uint encodedLumaUint = lumaValue;
// Количество пикселей с текущим обрабатываемым значением яркости
float numberOfPixelsWithCurrentLuma = numPixels;
// Текущее обрабатываемое значение яркости, закодированное в интервале [0-255] (float)
float encodedLumaFloat = encodedLumaUint;
На этом этапе мы получили закодированное в интервале [0.0f-255.f] значение яркости.
Процесс декодирования довольно прост — нужно обратить вычисления этапа кодирования.
Краткий повтор процесса кодирования:
float luma = dot( hdrPixelColor, float3(0.2126, 0.7152, 0.0722) );
...
float outLuma;
// так как log(0) равен undef, а log(1) = 0
outLuma = luma + 1.0;
// распределяем логарифмически
outLuma = log( outLuma );
// масштабируем на 128, что означает log(1) * 128 = 0, log(2,71828) * 128 = 128, log(7,38905) * 128 = 256
outLuma = outLuma * 128
// преобразуем в uint
uint outLumaUint = min( (uint) outLuma, 255);
Чтобы декодировать яркость, мы обращаем процесс кодирования, например вот так:
// начинаем с прибавления 0.5f (мы не хотим, чтобы получился нулевой результат)
float fDecodedLuma = encodedLumaFloat + 0.5;
// и декоридуем яркость:
// Делим на 128
fDecodedLuma /= 128.0;
// exp(x), что отменяет log(x)
fDecodedLuma = exp(fDecodedLuma);
// Вычитаем 1.0
fDecodedLuma -= 1.0;
Затем мы вычисляем распределение, умножая количество пикселей с заданной яркостью на декодированную яркость, и суммируя их, пока не дойдём до обработки pixelsToConsiderEndпикселей.
После этого мы делим общее влияние на число проанализированных пикселей.
Вот оставшаяся часть цикла (и шейдера):
// Вычисляем влияние этой яркости
float fCurrentLumaContribution = numberOfPixelsWithCurrentLuma * fDecodedLuma;
// (Временное) влияние от всех предыдущих проходов и текущего.
float tempTotalContribution = fCurrentLumaContribution + finalAvgLuminance;
[flatten]
if (numProcessedPixels > pixelsToConsiderEnd )
{
// чтобы выйти из цикла
bExitLoop = true;
// Мы уже обработали все нужные пиксели, поэтому выполняем здесь окончательное деление.
// Количество всех обработанных пикселей для выбранного пользователем начала
int diff = numProcessedPixels - numProcessedPixelStart;
// Вычисляем окончательную среднюю яркость
finalAvgLuminance = tempTotalContribution / float(diff);
}
else
{
// Передаём текущее влияние дальше и увеличиваем lumaValue
finalAvgLuminance = tempTotalContribution;
lumaValue++;
}
}
// Сохраняем среднюю яркость
g_avgLuminance[uint2(0,0)] = finalAvgLuminance;
Полный шейдер выложен здесь. Он полностью совместим с моей программой HLSLexplorer, без которой бы я не смог эффективно воссоздать вычисление средней яркости в «Ведьмаке 3» (да и все другие эффекты тоже!).
В заключение несколько мыслей. С точки зрения вычисления средней яркости этот шейдер было сложно воссоздать. Основные причины:
1) Странные «отложенные» проверки выполнения цикла, на это потребовалось гораздо больше времени, чем я предполагал ранее.
2) Проблемы с отладкой этого вычислительного шейдера в RenderDoc (v. 1.2).
Операции «ld_structured_indexable» поддерживаются не полностью, хотя результат считывания из индекса 0 даёт верное значение, все остальные возвращают нули, из-за чего циклы продолжаются бесконечно.
Хоть мне и не удалось добиться того же ассемблерного кода, что и в оригинале (ниже см. скриншот с различиями), с помощью RenderDoc я смог выполнить инъекцию этого шейдера в конвейер — и результаты оказались такими же!
Результат битвы. Слева — мой шейдер, справа — оригинальный ассемблерный код.
Часть 8. Луна и её фазы
В восьмой части статьи я исследую шейдер Луны из «Ведьмака 3» (а конкретнее — из расширения «Кровь и вино»).
Луна — важный элемент ночного неба, и её может быть достаточно сложно сделать правдоподобной, но для меня прогулки по ночам в TW3 стали настоящим удовольствием.
Только посмотрите на эту сцену!
![](https://2.bp.blogspot.com/-xrtK-9YELrA/XB4aaoJCgMI/AAAAAAAABJI/8QyXxXLUfjAJ8C56LyedMPXx_Cu0BQsaQCLcBGAs/s1600/01_scene_moon.jpg)
Прежде чем мы возьмёмся за пиксельный шейдер, скажу несколько слов о нюансах рендеринга. С геометрической точки зрения, Луна — это просто сфера (см. ниже), у которой есть координаты текстур, векторы нормалей и касательных. Вершинный шейдер вычисляет позицию в мировом пространстве, а также нормализованные векторы нормалей, касательных и касательных к двум точкам (с помощью векторного произведения), умноженные на матрицу мира.
Чтобы гарантировать, что Луна полностью лежит на удалённой плоскости, полям MinDepth и MaxDepth структуры D3D11_VIEWPORT присвоены значения 0.0 (тот же трюк, который использовался для купола неба). Луна рендерится сразу после неба.
Сфера, используемая для отрисовки Луны
Ну всё, думаю, можно приступать. Давайте взглянем на пиксельный шейдер:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[1], immediateIndexed
dcl_constantbuffer cb2[3], immediateIndexed
dcl_constantbuffer cb12[267], immediateIndexed
dcl_sampler s0, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_input_ps linear v1.w
dcl_input_ps linear v2.xyzw
dcl_input_ps linear v3.xy
dcl_input_ps linear v4.xy
dcl_output o0.xyzw
dcl_temps 3
0: mov r0.x, -cb0[0].w
1: mov r0.y, l(0)
2: add r0.xy, r0.xyxx, v2.xyxx
3: sample_indexable(texture2d)(float,float,float,float) r0.xyzw, r0.xyxx, t0.xyzw, s0
4: add r0.xyz, r0.xyzx, l(-0.500000, -0.500000, -0.500000, 0.000000)
5: log r0.w, r0.w
6: mul r0.w, r0.w, l(2.200000)
7: exp r0.w, r0.w
8: add r0.xyz, r0.xyzx, r0.xyzx
9: dp3 r1.x, r0.xyzx, r0.xyzx
10: rsq r1.x, r1.x
11: mul r0.xyz, r0.xyzx, r1.xxxx
12: mul r1.xy, r0.yyyy, v3.xyxx
13: mad r0.xy, v4.xyxx, r0.xxxx, r1.xyxx
14: mad r0.xy, v2.zwzz, r0.zzzz, r0.xyxx
15: mad r0.z, cb0[0].y, l(0.033864), cb0[0].w
16: mul r0.z, r0.z, l(6.283185)
17: sincos r1.x, r2.x, r0.z
18: mov r2.y, r1.x
19: dp2_sat r0.x, r0.xyxx, r2.xyxx
20: mul r0.xyz, r0.xxxx, cb12[266].xyzx
21: mul r0.xyz, r0.xyzx, r0.wwww
22: mul r0.xyz, r0.xyzx, cb2[2].xyzx
23: add_sat r0.w, -v1.w, l(1.000000)
24: mul r0.w, r0.w, cb2[2].w
25: mul o0.xyz, r0.wwww, r0.xyzx
26: mov o0.w, l(0)
27: ret
Главная причина того, что я выбрал шейдер из «Крови и вина», проста — он короче.
Сначала мы вычисляем смещение для сэмплирования текстуры.
cb0[0].w используется как смещение по оси X. С помощью этого простого трюка мы можем симулировать вращение Луны вокруг своей оси.
Примеры значений из буфера констант
В качестве входных данных прикреплена одна текстура (1024x512). В RGB-каналах закодирована карта нормалей, а в альфа-канале — цвет поверхности Луны. Умно!
![](https://2.bp.blogspot.com/-h_YMSQY4Jh0/XB4e1MiGGJI/AAAAAAAABJk/SMdJTU-TYRs7BsKXvkSNhnErei5jL44UACEwYBhgL/s1600/03_moon_a.jpg)
Альфа-канал текстуры — это цвет поверхности Луны.
![](https://2.bp.blogspot.com/-5bpexPlhw_w/XB4e0DLG4VI/AAAAAAAABJg/-MckQpWG3Hw2ds8NlmUoX63HhM0WV4B2wCEwYBhgL/s1600/04_moon_normal.jpg)
RGB-каналы текстуры — это карта нормалей.
Получив правильные координаты текстуры, мы сэмплируем RGBA-каналы. Нам необходимо распаковать карту нормалей и выполнить гамма-коррекцию цвета поверхности. На текущий момент HLSL-шейдер можно записать вот так, например:
float4 MoonPS(in InputStruct IN) : SV_Target0
{
// Смещения Texcoords
float2 uvOffsets = float2(-cb0_v0.w, 0.0);
// Готовые texcoords
float2 uv = IN.param2.xy + uvOffsets;
// Сэмплирование текстуры
float4 sampledTexture = texture0.Sample( sampler0, uv);
// Цвет поверхности Луны - выполняем гамма-коррекцию
float moonColorTex = pow(sampledTexture.a, 2.2 );
// Распаковываем нормали из интервала [0,1] в интервал [-1,1].
// Примечание: sampledTexture.xyz * 2.0 - 1.0 работает аналогичным образом
float3 sampledNormal = normalize((sampledTexture.xyz - 0.5) * 2);
Следующим шагом будет выполнение привязки нормалей, но только в компонентах XY. (В The Witcher 3 ось Z направлена вверх, а весь Z-канал текстуры равен 1.0). Мы можем сделать это следующим образом:
// Векторы касательного пространства
float3 Tangent = IN.param4.xyz;
float3 Normal = float3(IN.param2.zw, IN.param3.w);
float3 Bitangent = IN.param3.xyz;
// Матрица TBN
float3x3 TBN = float3x3(Tangent, Bitangent, Normal);
// Вычисление вектора нормали XY
// Ужимаем матрицу TBN во float3x2: 3 строки, 2 столбца
float2 vNormal = mul(sampledNormal, (float3x2)TBN).xy;
Теперь настало время моей любимой части этого шейдера. Снова взгляните на строки 15-16:
15: mad r0.z, cb0[0].y, l(0.033864), cb0[0].w
16: mul r0.z, r0.z, l(6.283185)
Что это за загадочное 0.033864? Поначалу кажется, что в нём нет никакого смысла, но если вычислить обратное ему значение, то получим примерно 29.53, что равно длительности синодического месяца в сутках! Вот, что я называю внимательностью к деталям!
Мы можем достоверно предположить, что cb0[0].y — это количество дней, прошедших за время геймплея. Здесь используется дополнительное отклонение, применяемое в качестве смещения по оси X текстуры.
Получив этот коэффициент, мы умножаем его на 2*Pi.
Затем с помощью sincos мы вычисляем другой 2d-вектор.
Вычислением скалярного произведения между вектором нормали и «лунным» вектором симулируется одна фаза луны.
// Лунная фаза.
// Мы вычисляем days/29.53 + bias.
float phase = cb0_v0.y * (1.0 / SYNODIC_MONTH_LENGTH) + cb0_v0.w;
// Умножаем на 2*PI. Таким образом, 29.53 будет равно полному периоду
// для функций sin/cos.
phase *= TWOPI;
// Вычисляем синус и косинус лунной фазы.
float outSin = 0.0;
float outCos = 0.0;
sincos(phase, outSin, outCos);
// Вычисляем лунную фазу
float lunarPhase = saturate( dot(vNormal, float2(outCos, outSin)) );
Посмотрите на скриншоты с разными фазами Луны:
![](https://2.bp.blogspot.com/-IqcBeU8AUCg/XB4wneQCrRI/AAAAAAAABKE/TcWDz8bvz0YmiYIu5lXuNwyRjy-FiWHCQCLcBGAs/s1600/05_phase_2.png)
![](https://1.bp.blogspot.com/-Ky6NJ3CDRuU/XB4wm3Od4II/AAAAAAAABKA/w-5YSIYO_Bg9OtGyaofRmE4fcvU7CcFlQCLcBGAs/s1600/05_phase_1.png)
Последний этап — выполнение серии операций умножения для вычисления окончательного цвета.
// Выполняем серию операций умножения для вычисления окончательного цвета.
// cb12_v266.xyz используется, чтобы усилить свечение и цвет Луны.
// например (1.54, 2.82, 4.13)
float3 moonSurfaceGlowColor = cb12_v266.xyz;
float3 moonColor = lunarPhase * moonSurfaceGlowColor;
moonColor = moonColorTex * moonColor;
// cb_v2.xyz - это, вероятно, фильтр, например (1.0, 1.0, 1.0)
moonColor *= cb2_v2.xyz;
// Не совсем понимаю, что делает этот фрагмент, возможно. это какое-то значение непрозрачности горизонта.
// Как бы то ни было, он имеет не такое большое влияние на окончательный цвет,
// как параметры выше.
float paramHorizon = saturate(1.0 - IN.param1.w);
paramHorizon *= cb2_v2.w;
moonColor *= paramHorizon;
// Выводим окончательный цвет с нулевым значением альфы
return float4(moonColor, 0.0);
Возможно, вы не понимаете, почему этот шейдер передаёт на выход значение альфы 0.0. Это потому, что Луна рендерится со включенным смешением:
Такой подход позволяет получать цвет фона (неба), если этот шейдер возвращает чёрный цвет.
Если вам интересен полный шейдер, то можно взять его здесь. У него большие буферы констант и он уже должен быть готов к инъекции в RenderDoc вместо оригинального шейдера (просто переименуйте «MoonPS» в «EditedShaderPS»).
И последнее: я хотел поделиться с вами результатами:
Слева — мой шейдер, справа — оригинальный шейдер из игры.
Разница минимальна и не влияет на результаты.
Как видите, этот шейдер воссоздать было довольно просто.
Часть 9. G-буфер
В этой части я раскрою некоторые подробности геометрического буфера (gbuffer) в The Witcher 3.
Будем считать, что вы знаете основы отложенного затенения (deferred shading).
Краткое повторение: идея откладывания в том, чтобы не вычислять всё готовое освещение и затенение сразу, а разделить вычисления на два этапа.
В первом (проходе геометрии) мы заполняем GBuffer данными о поверхности (позиция, нормали, specular color и т.д...), а во втором (проходе освещения) комбинируем всё и вычисляем освещение.
Отложенное затенение — это очень популярный подход, потому что оно позволяет вычислять за один полноэкранный проход такими техниками, как тайловое отложенное затенение, что сильно повышает производительность.
Если говорить просто, то GBuffer — это набор текстур со свойствами геометрии. Очень важно создать для него правильную структуру. В качестве примера из реальной жизни можно изучить технологии рендеринга Crysis 3.
После этого краткого введения давайте рассмотрим пример кадра из «Ведьмака 3: Кровь и вино»:
![](https://4.bp.blogspot.com/-vS0gb-xaLqs/XCZoLKFaBEI/AAAAAAAABLE/u89LZshDiVwWMKdJAqekSnKh3IGaHPYRACEwYBhgL/s1600/01_frame.jpg)
Одна из множества гостиниц в Туссенте
Основной GBuffer состоит из трёх полноэкранных render target в формате DXGI_FORMAT_R8G8B8A8_UNORM и буфера глубин + стенсила в формате DXGI_FORMAT_D24_UNORM_S8_UINT.
Вот их скриншоты:
![](https://3.bp.blogspot.com/-bMBPh5xiA14/XCZoSEM5MRI/AAAAAAAABLo/5AmcPysmSz8ID-nRgL0PXVYno58rPD2PQCEwYBhgL/s1600/02_gbuffer_albedo.jpg)
Render Target 0 — RGB-каналы, цвет поверхности
![](https://2.bp.blogspot.com/-u5LvobI921U/XCZqb1puz2I/AAAAAAAABLw/lu_Y0RI8ZTgba3rrLZbP-36UgNn5oykAwCEwYBhgL/s1600/02_gbuffer_albedo_a.jpg)
Render Target 0 — альфа-канал. Честно говоря, понятия не имею, что это за информация.
![](https://1.bp.blogspot.com/-shMFlxbH3dY/XCZoQ_RwWKI/AAAAAAAABLk/SFAzEil6Nm46AwTIOwS-Q3ElJLyPyumfwCEwYBhgL/s1600/03_gbuffer_normals.jpg)
Render Target 1 — RGB-каналы. Здесь записаны векторы нормалей в интервале [0-1].
![](https://2.bp.blogspot.com/-YX_kZvEgqUM/XCZqef2xJvI/AAAAAAAABL8/uw0W5cZTS8s5OVx1ZkC_BVbJN7oEEepEwCEwYBhgL/s1600/03_gbuffer_normals_a.jpg)
Render Target 1 — альфа-канал. Похоже на отражающую способность!
![](https://4.bp.blogspot.com/-i41R7ks1EBI/XCZoONtdFPI/AAAAAAAABLg/VmGKUyO8C4s60YP0-fgY4VP2akaMY2ODgCEwYBhgL/s1600/04_gbuffer_spec.jpg)
Render Target 2 — RGB-каналы. Похоже на specular color!
В этой сцене альфа-канал чёрный (но позже он используется).
![](https://2.bp.blogspot.com/-XRAEHtxpI94/XCZoQkCuaRI/AAAAAAAABLg/X1HrJIFdxYEpvKiEgt7ptOKNaLePhArmgCEwYBhgL/s1600/05_gbuffer_depth.jpg)
Буфер глубин. Заметьте, что здесь используется перевёрнутая глубина.
![](https://4.bp.blogspot.com/-MT7JStvC2Es/XCbInTDMi8I/AAAAAAAABOY/U8lwTaNwr7YTITzZRNZqEf_tYAdDNNelQCEwYBhgL/s1600/06_gbuffer_stencil.jpg)
Стенсил-буфер, используемый для пометки определённого типа пикселей (например кожи, растительности и т.д.)
Это не весь GBuffer. Проход освещения также использует зонды освещения и другие буферы, но их в этой статье я рассматривать не буду.
Прежде чем приступать к «основной» части поста, приведу общие наблюдения:
Общие наблюдения
1) Единственный очищаемый буфер — это буфер глубин/стенсила.
Если проанализировать упомянутые выше текстуры в хорошем анализаторе кадров, то вы будете немного удивлены, потому что для них не используется вызов «Clear», за исключением Depth/Stencil.
То есть в реальности RenderTarget1 выглядит так (заметьте «размытые» пиксели на дальней плоскости):
![](https://2.bp.blogspot.com/-u3e_o5dAIlw/XCZuv_XBblI/AAAAAAAABME/-XhXs-pAGSMZU-BrpKTYLsIQumgIKJ54QCLcBGAs/s1600/07_gbuffer_real.jpg)
Это простая и умная оптимизация.
Важный урок: на вызовы ClearRenderTargetView нужно тратить ресурсы, поэтому используйте их только при необходимости.
2) Перевёрнутая глубина — это круто
Во многих статьях уже писали о точности буфера глубин с плавающей запятой. В Witcher 3 используется reversed-z. Это естественный выбор для такой игры с открытым миром и дальними дистанциями отрисовки.
Переключиться на DirectX будет несложно:
a) Очищаем буфер глубин записью «0», а не «1».
В традиционном подходе для очистки буфера глубин использовалось дальнее значение «1». После переворота глубины новым «дальним» значением стал 0, поэтому нужно всё поменять.
b) Поменять местами значения ближней и дальней границ при вычислении матрицы проецирования
c) Изменить проверку глубины с «меньше» на «больше»
Для OpenGL нужно проделать чуть больше работы (см. упомянутые выше статьи), но она того стоит.
3) Не храним позицию в мире
Да, всё так просто. В проходе освещения позицию в мире воссоздаём из глубины.
Пиксельный шейдер
В этой части я хотел показать именно пиксельный шейдер, поставляющий в GBuffer данные поверхностей.
Итак, теперь мы уже знаем, как хранить цвет, нормали и specular.
Разумеется, всё не так просто, как вы могли подумать.
Проблема пиксельного шейдера в том, что он имеет множество вариантов. Они отличаются по количеству передаваемых им текстур и количеству параметров, используемых из буфера констант (вероятно, из буфера констант, описывающего материал).
Для анализа я решил использовать эту красивую бочку:
Наша героическая бочка!
Пожалуйста, поприветствуйте текстуры:
Итак, у нас есть albedo, карта нормалей и specular color. Довольно стандартный случай.
Прежде чем начнём, несколько слов о входных данных геометрии:
Геометрия передаётся с позицией, texcoords, буферами нормалей и касательных.
Вершинный шейдер выводит как минимум texcoords, нормализованные векторы касательных/нормалей/касательных к двум точкам, ранее умноженные на матрицу мира. Для более сложных материалов (например, с двумя диффузными картами или двумя картами нормалей) вершинный шейдер может выводить другие данные, но я хотел показать здесь простой пример.
Пиксельный шейдер в ассемблерном коде:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb4[3], immediateIndexed
dcl_sampler s0, mode_default
dcl_sampler s13, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_resource_texture2d (float,float,float,float) t2
dcl_resource_texture2d (float,float,float,float) t13
dcl_input_ps linear v0.zw
dcl_input_ps linear v1.xyzw
dcl_input_ps linear v2.xyz
dcl_input_ps linear v3.xyz
dcl_input_ps_sgv v4.x, isfrontface
dcl_output o0.xyzw
dcl_output o1.xyzw
dcl_output o2.xyzw
dcl_temps 3
0: sample_indexable(texture2d)(float,float,float,float) r0.xyzw, v1.xyxx, t1.xyzw, s0
1: sample_indexable(texture2d)(float,float,float,float) r1.xyz, v1.xyxx, t0.xyzw, s0
2: add r1.w, r1.y, r1.x
3: add r1.w, r1.z, r1.w
4: mul r2.x, r1.w, l(0.333300)
5: add r2.y, l(-1.000000), cb4[1].x
6: mul r2.y, r2.y, l(0.500000)
7: mov_sat r2.z, r2.y
8: mad r1.w, r1.w, l(-0.666600), l(1.000000)
9: mad r1.w, r2.z, r1.w, r2.x
10: mul r2.xzw, r1.xxyz, cb4[0].xxyz
11: mul_sat r2.xzw, r2.xxzw, l(1.500000, 0.000000, 1.500000, 1.500000)
12: mul_sat r1.w, abs(r2.y), r1.w
13: add r2.xyz, -r1.xyzx, r2.xzwx
14: mad r1.xyz, r1.wwww, r2.xyzx, r1.xyzx
15: max r1.w, r1.z, r1.y
16: max r1.w, r1.w, r1.x
17: lt r1.w, l(0.220000), r1.w
18: movc r1.w, r1.w, l(-0.300000), l(-0.150000)
19: mad r1.w, v0.z, r1.w, l(1.000000)
20: mul o0.xyz, r1.wwww, r1.xyzx
21: add r0.xyz, r0.xyzx, l(-0.500000, -0.500000, -0.500000, 0.000000)
22: add r0.xyz, r0.xyzx, r0.xyzx
23: mov r1.x, v0.w
24: mov r1.yz, v1.zzwz
25: mul r1.xyz, r0.yyyy, r1.xyzx
26: mad r1.xyz, v3.xyzx, r0.xxxx, r1.xyzx
27: mad r0.xyz, v2.xyzx, r0.zzzz, r1.xyzx
28: uge r1.x, l(0), v4.x
29: if_nz r1.x
30: dp3 r1.x, v2.xyzx, r0.xyzx
31: mul r1.xyz, r1.xxxx, v2.xyzx
32: mad r0.xyz, -r1.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), r0.xyzx
33: endif
34: sample_indexable(texture2d)(float,float,float,float) r1.xyz, v1.xyxx, t2.xyzw, s0
35: max r1.w, r1.z, r1.y
36: max r1.w, r1.w, r1.x
37: lt r1.w, l(0.200000), r1.w
38: movc r2.xyz, r1.wwww, r1.xyzx, l(0.120000, 0.120000, 0.120000, 0.000000)
39: add r2.xyz, -r1.xyzx, r2.xyzx
40: mad o2.xyz, v0.zzzz, r2.xyzx, r1.xyzx
41: lt r1.x, r0.w, l(0.330000)
42: mul r1.y, r0.w, l(0.950000)
43: movc r1.x, r1.x, r1.y, l(0.330000)
44: add r1.x, -r0.w, r1.x
45: mad o1.w, v0.z, r1.x, r0.w
46: dp3 r0.w, r0.xyzx, r0.xyzx
47: rsq r0.w, r0.w
48: mul r0.xyz, r0.wwww, r0.xyzx
49: max r0.w, abs(r0.y), abs(r0.x)
50: max r0.w, r0.w, abs(r0.z)
51: lt r1.xy, abs(r0.zyzz), r0.wwww
52: movc r1.yz, r1.yyyy, abs(r0.zzyz), abs(r0.zzxz)
53: movc r1.xy, r1.xxxx, r1.yzyy, abs(r0.yxyy)
54: lt r1.z, r1.y, r1.x
55: movc r1.xy, r1.zzzz, r1.xyxx, r1.yxyy
56: div r1.z, r1.y, r1.x
57: div r0.xyz, r0.xyzx, r0.wwww
58: sample_l(texture2d)(float,float,float,float) r0.w, r1.xzxx, t13.yzwx, s13, l(0)
59: mul r0.xyz, r0.wwww, r0.xyzx
60: mad o1.xyz, r0.xyzx, l(0.500000, 0.500000, 0.500000, 0.000000), l(0.500000, 0.500000, 0.500000, 0.000000)
61: mov o0.w, cb4[2].x
62: mov o2.w, l(0)
63: ret
Шейдер состоит из нескольких этапов. Я опишу каждую основную часть этого шейдера по отдельности.
Но сначала как обычно — скриншот со значениями из буфера констант:
Albedo
Начнём мы со сложных вещей. Это не просто
После сэмплирования RGB текстуры цвета (строка 1), следующие 14 строки — это то, что я называю «буфером снижения насыщенности». Давайте я покажу код на HLSL:
float3 albedoColorFilter( in float3 color, in float desaturationFactor, in float3 desaturationValue )
{
float sumColorComponents = color.r + color.g + color.b;
float averageColorComponentValue = 0.3333 * sumColorComponents;
float oneMinusAverageColorComponentValue = 1.0 - averageColorComponentValue;
float factor = 0.5 * (desaturationFactor - 1.0);
float avgColorComponent = lerp(averageColorComponentValue, oneMinusAverageColorComponentValue, saturate(factor));
float3 desaturatedColor = saturate(color * desaturationValue * 1.5);
float mask = saturate( avgColorComponent * abs(factor) );
float3 finalColor = lerp( color, desaturatedColor, mask );
return finalColor;
}
Для большинства объектов этот код не делает ничего, кроме возврата исходного цвета из текстуры. Это достигается соответствующими значениями «material cbuffer». cb4_v1.x имеет значение 1.0, что возвращает в маске, равной 0.0, и выдаёт входной цвет из инструкции lerp.
Однако существуют некоторые исключения. Наибольшее найденное мной значение desaturationFactor равно 4.0 (оно никогда не бывает меньше 1.0), а desaturatedColor зависит от материала. Оно может быть чем-то вроде (0.2, 0.3, 0.4); здесь нет строгих правил. Разумеется, я не удержался от реализации этого в своём собственном DX11-фреймворке, и вот результаты, где все значения desaturatedColor равны float3( 0.25, 0.3, 0.45 )
![](https://4.bp.blogspot.com/-Gl590y6OyFc/XCasdWZbNRI/AAAAAAAABMw/8YwkPPu9sww_hMhDEHbwnabI6NlwZr8rQCLcBGAs/s1600/Screenshot_2018-12-28_23-45-48.jpg)
desaturationFactor = 1.0 (не оказывает никакого эффекта)
![](https://1.bp.blogspot.com/-c9ke0mLH2Ac/XCasdtAiUPI/AAAAAAAABM0/zKF64ZWoTMMGGaxSKCn32IxHukuyDCo1ACLcBGAs/s1600/Screenshot_2018-12-28_23-45-56.jpg)
desaturationFactor = 2.0
![](https://2.bp.blogspot.com/-x7p6M_z7XZ4/XCasdvYQPnI/AAAAAAAABM4/Ixiz21kPMdg8MZkE5r7x32HofqRYfRQtQCLcBGAs/s1600/Screenshot_2018-12-28_23-46-03.jpg)
desaturationFactor = 3.0
![](https://4.bp.blogspot.com/-dxDz6mmdmaU/XCasfK2B7qI/AAAAAAAABM8/ALZqY2blbQMOIQ0s6eTD2Q6sZ6vE8Ze2gCLcBGAs/s1600/Screenshot_2018-12-28_23-46-09.jpg)
desaturationFactor = 4.0
Я уверен, что это просто применение параметров материалов, но выполняемое не конце части с albedo.
Строки 15-20 добавляют финальные штрихи:
15: max r1.w, r1.z, r1.y
16: max r1.w, r1.w, r1.x
17: lt r1.w, l(0.220000), r1.w
18: movc r1.w, r1.w, l(-0.300000), l(-0.150000)
19: mad r1.w, v0.z, r1.w, l(1.000000)
20: mul o0.xyz, r1.wwww, r1.xyzx
v0.z — это выходные данные из вершинного шейдера, и они равны нулю. Не забывайте об этом, потому что v0.z позже ещё будет использовано пару раз.
Похоже, что это какой-то коэффициент, и весь код похож на небольшое затемнение albedo, но так как v0.z равно 0, цвет остаётся неизменным. HLSL:
/* ALBEDO */
// опциональный фильтр снижения насыщенности (?)
float3 albedoColor = albedoColorFilter( colorTex, cb4_v1.x, cb4_v0.rgb );
float albedoMaxComponent = getMaxComponent( albedoColor );
// Понятия не имею, что это
// В большинстве случаев вершинный шейдер выводит "paramZ" со значением 0
float paramZ = Input.out0.z; // помните, чаще всего это 0
// Заметьте, что 0.70 и 0.85 отсутствуют в ассемблерном коде вывода
// Так как я хотел использовать здесь lerp, мне пришлось настроить их вручную.
float param = (albedoMaxComponent > 0.22) ? 0.70 : 0.85;
float mulParam = lerp(1, param, paramZ);
// Вывод
pout.RT0.rgb = albedoColor * mulParam;
pout.RT0.a = cb4_v2.x;
Что касается RT0.a, то, как мы видим, оно берётся из буфера констант материала, но так как у шейдера нет отладочной информации, сложно сказать, что это такое. Возможно просвечиваемость?
Мы закончили с первым render target!
Нормали
Начнём с распаковки карты нормалей, а затем как обычно выполним привязку нормалей:
/* НОРМАЛИ */
float3 sampledNormal = ((normalTex.xyz - 0.5) * 2);
// Данные для создания матрицы TBN
float3 Tangent = Input.TangentW.xyz;
float3 Normal = Input.NormalW.xyz;
float3 Bitangent;
Bitangent.x = Input.out0.w;
Bitangent.yz = Input.out1.zw;
// в реальном сценарии это насыщение удаляется; это хак, для того, чтобы умножение normal-tbn
// давало в ассемблерном коде инструкции 'mad' вместо кучи 'mov'
Bitangent = saturate(Bitangent);
float3x3 TBN = float3x3(Tangent, Bitangent, Normal);
float3 normal = mul( sampledNormal, TBN );
Пока ничего удивительного.
Посмотрите на строки 28-33:
28: uge r1.x, l(0), v4.x
29: if_nz r1.x
30: dp3 r1.x, v2.xyzx, r0.xyzx
31: mul r1.xyz, r1.xxxx, v2.xyzx
32: mad r0.xyz, -r1.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), r0.xyzx
33: endif
Мы можем приблизительно написать их следующим образом:
[branch] if (bIsFrontFace <= 0)
{
float cosTheta = dot(Input.NormalW, normal);
float3 invNormal = cosTheta * Input.NormalW;
normal = normal - 2*invNormal;
}
Не уверен, правильно ли так писать. Если вы знаете, что это за математическая операция, то дайте мне знать.
Мы видим, что пиксельный шейдер использует SV_IsFrontFace.
Что это такое? На помощью приходит документация (я хотел написать «msdn», но...):
Определяет, смотрит ли треугольник в камеру. Для линий и точек IsFrontFace имеет значение true. Исключением являются линии, нарисованные из треугольников (режим wireframe), которые задают IsFrontFace аналогично растеризации треугольника в solid mode. Запись в него может производиться шейдером геометрии, а считывание из него — пиксельным шейдером.
Я хотел проверить это самостоятельно. И в самом деле, эффект заметен только в каркасном (wireframe) режиме. Полагаю, этот фрагмент кода нужен для правильного вычисления нормалей (а значит и освещения) в режиме wireframe.
Вот сравнение: оба цвета каркаса готовой сцены при этом включенном/отключенном трюке, а также текстура нормалей gbuffer [0-1] при включенном/отключенном трюке:
![](https://1.bp.blogspot.com/-VUJokj6z34I/XCay9L14LjI/AAAAAAAABNU/njBnumAcAn4COL-S61HS8jRsNR39ZLswQCEwYBhgL/s1600/isfront_final_off.jpg)
Цвет сцены без трюка
![](https://1.bp.blogspot.com/-m-RlggZe4Q8/XCay-YpS0XI/AAAAAAAABNY/_K7M3D0k8y8n0DXqo4Fd9M5qxwSl13SpQCEwYBhgL/s1600/isfront_final_on.jpg)
Цвет сцены с трюком
![](https://4.bp.blogspot.com/-rcUxw16iTwM/XCazA3d733I/AAAAAAAABNo/gdgEcyKgdTYelKosDtsasV_UMHbzPxp_QCEwYBhgL/s1600/isfront_normals_off.png)
Нормали [0-1] без трюка
![](https://1.bp.blogspot.com/-gP0_7zgbrB0/XCazCx9OiwI/AAAAAAAABNs/2z8swLG9biIWfNWqYoHf6Of6AykfurbMQCEwYBhgL/s1600/isfront_normals_on.png)
Нормали [0-1] с трюком
Вы заметили, что каждый render target в GBuffer имеет формат R8G8B8A8_UNORM? Это означает, что на один компонент приходится 256 возможных значений. Достаточно ли этого для хранения нормалей?
Хранение в Gbuffer высококачественных нормалей с достаточным количеством байтов — известная проблема, но, к счастью, существует множество разных материалов по которым можно поучиться.
Возможно, некоторые из вас уже знают, какая техника здесь используется. Нужно сказать, что в целом проходе геометрии есть одна дополнительная текстура, прикреплённая к слоту 13...:
![](https://3.bp.blogspot.com/-5olczP0vhhI/XCa2wseLGgI/AAAAAAAABN0/qAsy2Urc1SQTIh6ZTTmGY_cJ9YdyFS_pACLcBGAs/s1600/bfn.jpg)
Ха! В The Witcher 3 используется техника под названием "Best Fit Normals". Здесь я не буду объяснять её подробно (посмотрите презентацию). Она была изобретена примерно в 2009-2010 году компанией Crytek, и поскольку CryEngine имеет открытые исходники, BFN тоже open source.
BFN придаёт текстуре нормалей «зернистый» вид.
После масштабирования нормалей с помощью BFN мы перекодируем их из интервала [-1;1] в [0, 1].
Specular
Начнём со строки 34, и сэмплируем текстуру specular:
34: sample_indexable(texture2d)(float,float,float,float) r1.xyz, v1.xyxx, t2.xyzw, s0
35: max r1.w, r1.z, r1.y
36: max r1.w, r1.w, r1.x
37: lt r1.w, l(0.200000), r1.w
38: movc r2.xyz, r1.wwww, r1.xyzx, l(0.120000, 0.120000, 0.120000, 0.000000)
39: add r2.xyz, -r1.xyzx, r2.xyzx
40: mad o2.xyz, v0.zzzz, r2.xyzx, r1.xyzx
Как видите, здесь есть знакомый нам по Albedo фильтр «затемнения»:
Вычисляем компонент с макс. значением, а затем вычисляем «затемнённый» цвет и интерполируем его с исходным specular color, взяв параметр из вершинного шейдера… который равен 0, поэтому на выходе мы получаем цвет из текстуры.
HLSL:
/* SPECULAR */
float3 specularTex = texture2.Sample( samplerAnisoWrap, Texcoords ).rgb;
// Тот же алгоритм, что и в Albedo. Вычисляем макс. компонент, сравниваем его с
// каким-то пороговым значением и при необходимости вычисляем значение "минимума".
// Так как в анализируемой сцене paramZ имеет значение 0, окончательным результатом будет
//значение из текстуры.
float specularMaxComponent = getMaxComponent( specularTex );
float3 specB = (specularMaxComponent > 0.2) ? specularTex : float3(0.12, 0.12, 0.12);
float3 finalSpec = lerp(specularTex, specB, paramZ);
pout.RT2.xyz = finalSpec;
Отражающая способность
Я понятия не имею, подходит ли это название для этого параметра, потому что не знаю, как он влияет на проход освещения. Дело в том, что альфа-канал входной карты нормалей содержит дополнительные данные:
![](https://1.bp.blogspot.com/-DwHBqe5kMjA/XCa6g_QIXtI/AAAAAAAABOA/voqX8kUcAXEWLZNwnN_Uw4ESREQ4TUmrACLcBGAs/s1600/reflectivity.jpg)
Альфа-канал текстуры «карты нормалей».
Ассемблерный код:
41: lt r1.x, r0.w, l(0.330000)
42: mul r1.y, r0.w, l(0.950000)
43: movc r1.x, r1.x, r1.y, l(0.330000)
44: add r1.x, -r0.w, r1.x
45: mad o1.w, v0.z, r1.x, r0.w
Поздоровайтесь с нашим старым другом — v0.z! Его смысл схож с albedo и с specular:
/* REFLECTIVITY */
float reflectivity = normalTex.a;
float reflectivity2 = (reflectivity < 0.33) ? (reflectivity * 0.95) : 0.33;
float finalReflectivity = lerp(reflectivity, reflectivity2, paramZ);
pout.RT1.a = finalReflectivity;
Отлично! Это конец анализа первого варианта пиксельного шейдера.
Вот сравнение моего шейдера (слева) с исходным (справа):
Эти различия не влияют на вычисления, поэтому моя работа здесь закончена.
Пиксельный шейдер: вариант «Albedo + нормали»
Я решил показать ещё один вариант, теперь только с картами albedo и нормалей, без текстуры specular. Ассемблерный код чуть длиннее:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb4[8], immediateIndexed
dcl_sampler s0, mode_default
dcl_sampler s13, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_resource_texture2d (float,float,float,float) t13
dcl_input_ps linear v0.zw
dcl_input_ps linear v1.xyzw
dcl_input_ps linear v2.xyz
dcl_input_ps linear v3.xyz
dcl_input_ps_sgv v4.x, isfrontface
dcl_output o0.xyzw
dcl_output o1.xyzw
dcl_output o2.xyzw
dcl_temps 4
0: mul r0.x, v0.z, cb4[0].x
1: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, v1.xyxx, t1.xyzw, s0
2: sample_indexable(texture2d)(float,float,float,float) r0.yzw, v1.xyxx, t0.wxyz, s0
3: add r2.x, r0.z, r0.y
4: add r2.x, r0.w, r2.x
5: add r2.z, l(-1.000000), cb4[2].x
6: mul r2.yz, r2.xxzx, l(0.000000, 0.333300, 0.500000, 0.000000)
7: mov_sat r2.w, r2.z
8: mad r2.x, r2.x, l(-0.666600), l(1.000000)
9: mad r2.x, r2.w, r2.x, r2.y
10: mul r3.xyz, r0.yzwy, cb4[1].xyzx
11: mul_sat r3.xyz, r3.xyzx, l(1.500000, 1.500000, 1.500000, 0.000000)
12: mul_sat r2.x, abs(r2.z), r2.x
13: add r2.yzw, -r0.yyzw, r3.xxyz
14: mad r0.yzw, r2.xxxx, r2.yyzw, r0.yyzw
15: max r2.x, r0.w, r0.z
16: max r2.x, r0.y, r2.x
17: lt r2.x, l(0.220000), r2.x
18: movc r2.x, r2.x, l(-0.300000), l(-0.150000)
19: mad r0.x, r0.x, r2.x, l(1.000000)
20: mul o0.xyz, r0.xxxx, r0.yzwy
21: add r0.xyz, r1.xyzx, l(-0.500000, -0.500000, -0.500000, 0.000000)
22: add r0.xyz, r0.xyzx, r0.xyzx
23: mov r1.x, v0.w
24: mov r1.yz, v1.zzwz
25: mul r1.xyz, r0.yyyy, r1.xyzx
26: mad r0.xyw, v3.xyxz, r0.xxxx, r1.xyxz
27: mad r0.xyz, v2.xyzx, r0.zzzz, r0.xywx
28: uge r0.w, l(0), v4.x
29: if_nz r0.w
30: dp3 r0.w, v2.xyzx, r0.xyzx
31: mul r1.xyz, r0.wwww, v2.xyzx
32: mad r0.xyz, -r1.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), r0.xyzx
33: endif
34: add r0.w, -r1.w, l(1.000000)
35: log r1.xyz, cb4[3].xyzx
36: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
37: exp r1.xyz, r1.xyzx
38: mad r0.w, r0.w, cb4[4].x, cb4[5].x
39: mul_sat r1.xyz, r0.wwww, r1.xyzx
40: log r1.xyz, r1.xyzx
41: mul r1.xyz, r1.xyzx, l(0.454545, 0.454545, 0.454545, 0.000000)
42: exp r1.xyz, r1.xyzx
43: max r0.w, r1.z, r1.y
44: max r0.w, r0.w, r1.x
45: lt r0.w, l(0.200000), r0.w
46: movc r2.xyz, r0.wwww, r1.xyzx, l(0.120000, 0.120000, 0.120000, 0.000000)
47: add r2.xyz, -r1.xyzx, r2.xyzx
48: mad o2.xyz, v0.zzzz, r2.xyzx, r1.xyzx
49: lt r0.w, r1.w, l(0.330000)
50: mul r1.x, r1.w, l(0.950000)
51: movc r0.w, r0.w, r1.x, l(0.330000)
52: add r0.w, -r1.w, r0.w
53: mad o1.w, v0.z, r0.w, r1.w
54: lt r0.w, l(0), cb4[7].x
55: and o2.w, r0.w, l(0.064706)
56: dp3 r0.w, r0.xyzx, r0.xyzx
57: rsq r0.w, r0.w
58: mul r0.xyz, r0.wwww, r0.xyzx
59: max r0.w, abs(r0.y), abs(r0.x)
60: max r0.w, r0.w, abs(r0.z)
61: lt r1.xy, abs(r0.zyzz), r0.wwww
62: movc r1.yz, r1.yyyy, abs(r0.zzyz), abs(r0.zzxz)
63: movc r1.xy, r1.xxxx, r1.yzyy, abs(r0.yxyy)
64: lt r1.z, r1.y, r1.x
65: movc r1.xy, r1.zzzz, r1.xyxx, r1.yxyy
66: div r1.z, r1.y, r1.x
67: div r0.xyz, r0.xyzx, r0.wwww
68: sample_l(texture2d)(float,float,float,float) r0.w, r1.xzxx, t13.yzwx, s13, l(0)
69: mul r0.xyz, r0.wwww, r0.xyzx
70: mad o1.xyz, r0.xyzx, l(0.500000, 0.500000, 0.500000, 0.000000), l(0.500000, 0.500000, 0.500000, 0.000000)
71: mov o0.w, cb4[6].x
72: ret
Разница между этим и предыдущим вариантами в следующем:
a) строки 1, 19: параметр интерполяции v0.z умножается на cb4[0].x из буфера констант, но это произведение используется только для интерполяции albedo в строке 19. Для других выходных данных используется «обычное» значение v0.z.
b) строки 54-55: o2.w теперь задаётся при условии, что (cb4[7].x > 0.0 )
Мы уже узнаём этот паттерн «какое-то сравнение — И» из вычисления гистограммы яркости. Его можно записать так:
pout.RT2.w = (cb4_v7.x > 0.0) ? (16.5/255.0) : 0.0;
c) строки 34-42: полностью другое вычисление specular.
Здесь нет текстуры specular. Давайте посмотрим отвечающий за эту часть ассемблерный код:
34: add r0.w, -r1.w, l(1.000000)
35: log r1.xyz, cb4[3].xyzx
36: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
37: exp r1.xyz, r1.xyzx
38: mad r0.w, r0.w, cb4[4].x, cb4[5].x
39: mul_sat r1.xyz, r0.wwww, r1.xyzx
40: log r1.xyz, r1.xyzx
41: mul r1.xyz, r1.xyzx, l(0.454545, 0.454545, 0.454545, 0.000000)
42: exp r1.xyz, r1.xyzx
Заметьте, что здесь мы использовали (1 — отражаемая способность). К счастью, в HLSL это написать довольно просто:
float oneMinusReflectivity = 1.0 - normalTex.a;
float3 specularTex = pow(cb4_v3.rgb, 2.2);
oneMinusReflectivity = oneMinusReflectivity * cb4_v4.x + cb4_v5.x;
specularTex = saturate(specularTex * oneMinusReflectivity);
specularTex = pow(specularTex, 1.0/2.2);
// продолжение как в первом варианте...
float specularMaxComponent = getMaxComponent( specularTex );
...
Добавлю, что в этом вариенте буфер констант с данными материалов чуть больше. Здесь эти дополнительные значения используются для эмуляции specular color.
Остальная часть шейдера такая же, как в предыдущем варианте.
72 строк ассемблерного кода — это слишком много для отображения в WinMerge, поэтому поверьте мне на слово: у меня код получился почти таким же, как в оригинале. Или вы можете скачать мой HLSLexplorer и убедиться в этом самостоятельно!
Подведём итог
… и если вы дочитали досюда, то, возможно, хотите ещё чуть больше углубиться.
То, что кажется простым в реальной жизни чаще всего таким не является, и передача данных в gbuffer The Witcher 3 не стала исключением. Я показал вам только простейшие варианты пиксельных шейдеров, отвечающих за неё, а также привёл общие наблюдения, которые относятся к отложенному затенению в целом.
Для самых терпеливых два варианта пиксельных шейдеров есть в pastebin:
Вариант 1 — с текстурой specular
Вариант 2 — без текстуры specular
Часть 10. Занавесы дождя в отдалении
В этой части мы рассмотрим замечательный атмосферный эффект, который мне очень нравится — далёкие занавесы дождя/освещения рядом с горизонтом. В игре их проще всего встретить на островах Скеллиге.
Лично мне очень нравится это атмосферное явление и было любопытно, как программисты графики CD Projekt Red его реализовали. Давайте разберёмся!
Вот два скриншота до и после применения занавесов дождя:
![](https://1.bp.blogspot.com/-GF2a8ieU86Y/XER-7XWBYdI/AAAAAAAABPg/7k1FQXMz4jQlsjSOyaR3aiWAZtXMYRxrwCLcBGAs/s1600/01.png)
До занавесов дождя
![](https://1.bp.blogspot.com/-LnC-matBdO8/XER-7ieelzI/AAAAAAAABPk/axKuLyy8SGQxRTszod-7Tm7Megc5RX5EwCLcBGAs/s1600/02.png)
После занавесов дождя
Геометрия
Сначала мы остановимся на геометрии. Идея заключается в использовании небольшого цилиндра:
Цилиндр в локальном пространстве
С точки зрения его позиции в локальном пространстве он достаточно мал — его позиция находится в интервале (0.0 — 1.0).
Входная схема для этого вызова отрисовки выглядит так…
Для нас здесь важно следующее: Texcoords и Instance_Transform.
Texcoords обёрнуты довольно просто: U верхнего и нижнего основания находятся в интервале [0.02777 — 1.02734]. V на нижнем основании равно 1.0, а на верхнем — 0.0. Как видите, можно довольно просто создать этот меш даже процедурно.
Получив этот небольшой цилиндр в локальном пространстве, мы умножаем его на матрицу мира, предоставляемую для каждого экземпляра элемента ввода INSTANCE_TRANSFORM. Давайте проверим значения этой матрицы:
![](https://2.bp.blogspot.com/-uiaVHBOKU7Q/XESE4_Ewa0I/AAAAAAAABQI/pz2a4zsiBH4vCi1qQyglpa6YTz8cAc10wCLcBGAs/s1600/05_xform_2.jpg)
Выглядит довольно пугающе, правда? Но не волнуйтесь, мы разберём эту матрицу и посмотрим, что она скрывает!
XMMATRIX mat( -227.7472, 159.8043, 374.0736, -116.4951,
-194.7577, -173.3836, -494.4982, 238.6908,
-14.16466, -185.4743, 784.564, -1.45565,
0.0, 0.0, 0.0, 1.0 );
mat = XMMatrixTranspose( mat );
XMVECTOR vScale;
XMVECTOR vRotateQuat;
XMVECTOR vTranslation;
XMMatrixDecompose( &vScale, &vRotateQuat, &vTranslation, mat );
// Матрица поворота...
XMMATRIX matRotate = XMMatrixRotationQuaternion( vRotateQuat );
Результаты очень интересны:
vRotateQuat: (0.0924987569, -0.314900011, 0.883411944, -0.334462732)
vScale: (299.999969, 300.000000, 1000.00012)
vTranslation: (-116.495102, 238.690796, -1.45564997)
Важно знать позицию камеры в этом конкретном кадре: (-116.5338, 234.8695, 2.09)
Как видите, мы отмасштабировали цилиндр, чтобы сделать его довольно большим в мировом пространстве (в TW3 ось Z направлена вверх), перенесли его относительно позиции камеры, и повернули.
Вот как выглядит цилиндр после преобразования вершинным шейдером:
Цилиндр после преобразования вершинным шейдером. Посмотрите, как он расположен относительно пирамиды видимости.
Вершинный шейдер
Входная геометрия и вершинный шейдер строго зависимы друг от друга.
Давайте внимательнее посмотрим на ассемблерный код вершинного шейдера:
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb1[7], immediateIndexed
dcl_constantbuffer cb2[6], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xy
dcl_input v4.xyzw
dcl_input v5.xyzw
dcl_input v6.xyzw
dcl_input v7.xyzw
dcl_output o0.xyz
dcl_output o1.xyzw
dcl_output_siv o2.xyzw, position
dcl_temps 2
0: mov o0.xy, v1.xyxx
1: mul r0.xyzw, v5.xyzw, cb1[6].yyyy
2: mad r0.xyzw, v4.xyzw, cb1[6].xxxx, r0.xyzw
3: mad r0.xyzw, v6.xyzw, cb1[6].zzzz, r0.xyzw
4: mad r0.xyzw, cb1[6].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
5: mad r1.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx
6: mov r1.w, l(1.000000)
7: dp4 o0.z, r1.xyzw, r0.xyzw
8: mov o1.xyzw, v7.xyzw
9: mul r0.xyzw, v5.xyzw, cb1[0].yyyy
10: mad r0.xyzw, v4.xyzw, cb1[0].xxxx, r0.xyzw
11: mad r0.xyzw, v6.xyzw, cb1[0].zzzz, r0.xyzw
12: mad r0.xyzw, cb1[0].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
13: dp4 o2.x, r1.xyzw, r0.xyzw
14: mul r0.xyzw, v5.xyzw, cb1[1].yyyy
15: mad r0.xyzw, v4.xyzw, cb1[1].xxxx, r0.xyzw
16: mad r0.xyzw, v6.xyzw, cb1[1].zzzz, r0.xyzw
17: mad r0.xyzw, cb1[1].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
18: dp4 o2.y, r1.xyzw, r0.xyzw
19: mul r0.xyzw, v5.xyzw, cb1[2].yyyy
20: mad r0.xyzw, v4.xyzw, cb1[2].xxxx, r0.xyzw
21: mad r0.xyzw, v6.xyzw, cb1[2].zzzz, r0.xyzw
22: mad r0.xyzw, cb1[2].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
23: dp4 o2.z, r1.xyzw, r0.xyzw
24: mul r0.xyzw, v5.xyzw, cb1[3].yyyy
25: mad r0.xyzw, v4.xyzw, cb1[3].xxxx, r0.xyzw
26: mad r0.xyzw, v6.xyzw, cb1[3].zzzz, r0.xyzw
27: mad r0.xyzw, cb1[3].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
28: dp4 o2.w, r1.xyzw, r0.xyzw
29: ret
Наряду с простой передачей Texcoords (строка 0) и Instance_LOD_Params (строка 8), для вывода нужны ещё два элемента: SV_Position (это очевидно) и Height (компонент .z) позиции в мире.
Помните, что локальное пространство находится в интервале [0-1]? Так вот, прямо перед применением матрицы мира вершинный шейдер использует масштаб и отклонение для изменения локальной позиции. Умный ход!
В данном случае scale = float3(4, 4, 2), а bias = float3(-2, -2, -1).<
Паттерн, который заметен между строками 9 и 28 — это умножение двух row-major-матриц.
Давайте просто посмотрим на готовый вершинный шейдер на HLSL:
cbuffer cbPerFrame : register (b1)
{
row_major float4x4 g_viewProjMatrix;
row_major float4x4 g_rainShaftsViewProjMatrix;
}
cbuffer cbPerObject : register (b2)
{
float4x4 g_mtxWorld;
float4 g_modelScale;
float4 g_modelBias;
}
struct VS_INPUT
{
float3 PositionW : POSITION;
float2 Texcoord : TEXCOORD;
float3 NormalW : NORMAL;
float3 TangentW : TANGENT;
float4 InstanceTransform0 : INSTANCE_TRANSFORM0;
float4 InstanceTransform1 : INSTANCE_TRANSFORM1;
float4 InstanceTransform2 : INSTANCE_TRANSFORM2;
float4 InstanceLODParams : INSTANCE_LOD_PARAMS;
};
struct VS_OUTPUT
{
float3 TexcoordAndZ : Texcoord0;
float4 LODParams : LODParams;
float4 PositionH : SV_Position;
};
VS_OUTPUT RainShaftsVS( VS_INPUT Input )
{
VS_OUTPUT Output = (VS_OUTPUT)0;
// простая передача данных
Output.TexcoordAndZ.xy = Input.Texcoord;
Output.LODParams = Input.InstanceLODParams;
// мировое пространство
float3 meshScale = g_modelScale.xyz; // float3( 4, 4, 2 );
float3 meshBias = g_modelBias.xyz; // float3( -2, -2, -1 );
float3 PositionL = Input.PositionW * meshScale + meshBias;
// Построение вручную матрицы instanceWorld из float4s:
float4x4 matInstanceWorld = float4x4(Input.InstanceTransform0, Input.InstanceTransform1,
Input.InstanceTransform2 , float4(0, 0, 0, 1) );
// Высота в мировом пространстве (.z)
float4x4 matWorldInstanceLod = mul( g_rainShaftsViewProjMatrix, matInstanceWorld );
Output.TexcoordAndZ.z = mul( float4(PositionL, 1.0), transpose(matWorldInstanceLod) ).z;
// SV_Posiiton
float4x4 matModelViewProjection = mul(g_viewProjMatrix, matInstanceWorld );
Output.PositionH = mul( float4(PositionL, 1.0), transpose(matModelViewProjection) );
return Output;
}
Сравнение моего шейдера (слева) и оригинального (справа):
Различия не влияют на вычисления. Я выполнил инъекцию моего шейдера в кадр и всё по-прежнему было в порядке!
Пиксельный шейдер
Наконец-то! Для начала я покажу вам входные данные:
Здесь используются две текстуры: текстура шума и буфер глубин:
![](https://1.bp.blogspot.com/-_NwpkaHh88c/XESoSeVVvKI/AAAAAAAABQ8/WkHY6-PieHUWCtVeimeJvpWgY4FVk4zAQCLcBGAs/s1600/09_depth.png)
Значения из буферов констант:
И ассемблерный код пиксельного шейдера:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[8], immediateIndexed
dcl_constantbuffer cb2[3], immediateIndexed
dcl_constantbuffer cb12[23], immediateIndexed
dcl_constantbuffer cb4[8], immediateIndexed
dcl_sampler s0, mode_default
dcl_sampler s15, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t15
dcl_input_ps linear v0.xyz
dcl_input_ps linear v1.w
dcl_input_ps_siv v2.xy, position
dcl_output o0.xyzw
dcl_temps 1
0: mul r0.xy, cb0[0].xxxx, cb4[5].xyxx
1: mad r0.xy, v0.xyxx, cb4[4].xyxx, r0.xyxx
2: sample_indexable(texture2d)(float,float,float,float) r0.x, r0.xyxx, t0.xyzw, s0
3: add r0.y, -cb4[2].x, cb4[3].x
4: mad_sat r0.x, r0.x, r0.y, cb4[2].x
5: mul r0.x, r0.x, v0.y
6: mul r0.x, r0.x, v1.w
7: mul r0.x, r0.x, cb4[1].x
8: mul r0.yz, v2.xxyx, cb0[1].zzwz
9: sample_l(texture2d)(float,float,float,float) r0.y, r0.yzyy, t15.yxzw, s15, l(0)
10: mad r0.y, r0.y, cb12[22].x, cb12[22].y
11: mad r0.y, r0.y, cb12[21].x, cb12[21].y
12: max r0.y, r0.y, l(0.000100)
13: div r0.y, l(1.000000, 1.000000, 1.000000, 1.000000), r0.y
14: add r0.y, r0.y, -v0.z
15: mul_sat r0.y, r0.y, cb4[6].x
16: mul_sat r0.x, r0.y, r0.x
17: mad r0.y, cb0[7].y, r0.x, -r0.x
18: mad r0.x, cb4[7].x, r0.y, r0.x
19: mul r0.xyz, r0.xxxx, cb4[0].xyzx
20: log r0.xyz, r0.xyzx
21: mul r0.xyz, r0.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
22: exp r0.xyz, r0.xyzx
23: mul r0.xyz, r0.xyzx, cb2[2].xyzx
24: mul o0.xyz, r0.xyzx, cb2[2].wwww
25: mov o0.w, l(0)
26: ret
Ого! Довольно большой объём, но на самом деле всё не так плохо.
Что же здесь происходит? Сначала мы вычисляем анимированные UV, воспользовавшись прошедшим временем из cbuffer (cb0[0].x) и масштабом/смещениями. Эти texcoords используются для сэмплирования из текстуры шума (строка 2).
Получив значение шума из текстуры, мы выполняем интерполяцию между значениями min/max (обычно 0 и 1).
Затем мы выполняем умножения, например на координату текстуры V (помните, что координата V идёт от 1 до 0?) — строка 5.
Таким образом мы вычислили «маску яркости» — она выглядит вот так:
![](https://2.bp.blogspot.com/--0daWUw7zs0/XESteHj16-I/AAAAAAAABRs/wb_BQdYE70wjXLly2uhZUwLKgbgTwKvEgCLcBGAs/s1600/11_noise_mask.png)
Заметьте, что далёкие объекты (маяк, горы...) пропали. Это произошло, потому что цилиндр проходит тест глубины — цилиндр не находится на дальней плоскости и отрисовывается поверх этих объектов:
![](https://2.bp.blogspot.com/-hAzVllLXW_M/XESvIhSV89I/AAAAAAAABSA/oIZFzNii_Vc6HwdOp2ljlriKenMeHlRegCLcBGAs/s1600/14_depth.png)
Тест глубины
Мы хотим имитировать то, что занавес дождя находится дальше (но необязательно на дальней плоскости). Чтобы сделать это, мы вычисляем ещё одну маску, «маску далёких объектов».
Вычисляется она по следующей формуле:
farObjectsMask = saturate( (FrustumDepth - CylinderWorldSpaceHeight) * 0.001 );
(0.001 берётся из буфера), что даёт нам нужную маску:
![](https://4.bp.blogspot.com/-rITAMcdy_9A/XEStd-Ho6eI/AAAAAAAABR4/Kc8BLkocib0pF4erwtCbfUKvLpBjJTpggCEwYBhgL/s1600/12_far_objects_mask.png)
(В части про эффект Sharpen я уже поверхностно объяснял, как из буфера глубин извлекается глубина пирамиды видимости.)
Лично мне кажется, что этот эффект можно было реализовать менее затратно, без вычисления высоты в мировом пространстве, умножением глубины пирамиды видимости на меньшее число, например 0.0004.
При перемножении обеих масок получается окончательная:
![](https://2.bp.blogspot.com/-3d5_Wnt0RaA/XESteljSzaI/AAAAAAAABR4/kB8au7lCHmgsDSOrphCvfBHGQF4qn5fggCEwYBhgL/s1600/13_combined_mask.png)
Получив эту окончательную маску (строка 16), мы выполняем ещё одну интерполяцию, поторая почти ничего не делает (по крайней мере, в протестированном случае), а затем умножаем окончательную маску на цвет занавесов (строка 19), выполняем гамма-коррекцию (строки 20-22) и окончательные умножения (23-24).
В конце мы возвращаем цвет с нулевым альфа-значением. Так делается, потому что на этом проходе включено смешивание:
FinalColor = SourceColor * 1.0 + (1.0 - SourceAlpha) * DestColor
Если вы не совсем хорошо понимаете, как работает смешивание, то вот краткое объяснение:
SourceColor — это выходные RGB-данные из пиксельного шейдера, а DestColor — это текущий RGB-цвет пикселя в render target. Так как SourceAlpha всегда равна 0.0, вышеупомянутое уравнение упрощается до:
FinalColor = SourceColor + DestColor
.Проще говоря, здесь мы выполняем аддитивное смешивание. Если пиксельный шейдер возвращает (0, 0, 0), то цвет останется таким же.
Вот готовый код на HLSL — думаю, что после объяснения понять его будет намного проще:
struct VS_OUTPUT
{
float3 TexcoordAndWorldspaceHeight : Texcoord0;
float4 LODParams : LODParams; // float4(1,1,1,1)
float4 PositionH : SV_Position;
};
float getFrustumDepth( in float depth )
{
// from [1-0] to [0-1]
float d = depth * cb12_v22.x + cb12_v22.y;
// special coefficents
d = d * cb12_v21.x + cb12_v21.y;
// return frustum depth
return 1.0 / max(d, 1e-4);
}
float4 EditedShaderPS( in VS_OUTPUT Input ) : SV_Target0
{
// * Input from Vertex Shader
float2 InputUV = Input.TexcoordAndWorldspaceHeight.xy;
float WorldHeight = Input.TexcoordAndWorldspaceHeight.z;
float LODParam = Input.LODParams.w;
// * Inputs
float elapsedTime = cb0_v0.x;
float2 uvAnimation = cb4_v5.xy;
float2 uvScale = cb4_v4.xy;
float minValue = cb4_v2.x; // 0.0
float maxValue = cb4_v3.x; // 1.0
float3 shaftsColor = cb4_v0.rgb; // RGB( 147, 162, 173 )
float3 finalColorFilter = cb2_v2.rgb; // float3( 1.175, 1.296, 1.342 );
float finalEffectIntensity = cb2_v2.w;
float2 invViewportSize = cb0_v1.zw;
float depthScale = cb4_v6.x; // 0.001
// sample noise
float2 uvOffsets = elapsedTime * uvAnimation;
float2 uv = InputUV * uvScale + uvOffsets;
float disturb = texture0.Sample( sampler0, uv ).x;
// * Intensity mask
float intensity = saturate( lerp(minValue, maxValue, disturb) );
intensity *= InputUV.y; // transition from (0, 1)
intensity *= LODParam; // usually 1.0
intensity *= cb4_v1.x; // 1.0
// Sample depth
float2 ScreenUV = Input.PositionH.xy * invViewportSize;
float hardwareDepth = texture15.SampleLevel( sampler15, ScreenUV, 0 ).x;
float frustumDepth = getFrustumDepth( hardwareDepth );
// * Calculate mask covering distant objects behind cylinder.
// Seems that the input really is world-space height (.z component, see vertex shader)
float depth = frustumDepth - WorldHeight;
float distantObjectsMask = saturate( depth * depthScale );
// * calculate final mask
float finalEffectMask = saturate( intensity * distantObjectsMask );
// cb0_v7.y and cb4_v7.x are set to 1.0 so I didn't bother with naming them :)
float paramX = finalEffectMask;
float paramY = cb0_v7.y * finalEffectMask;
float effectAmount = lerp(paramX, paramY, cb4_v7.x);
// color of shafts comes from contant buffer
float3 effectColor = effectAmount * shaftsColor;
// gamma correction
effectColor = pow(effectColor, 2.2);
// final multiplications
effectColor *= finalColorFilter;
effectColor *= finalEffectIntensity;
// return with zero alpha 'cause the blending used here is:
// SourceColor * 1.0 + (1.0 - SrcAlpha) * DestColor
return float4( effectColor, 0.0 );
}
С радостью могу сказать, что мой пиксельный шейдер создаёт тот же ассемблерный код, что и в оригинале.
Надеюсь, статья вам понравилась. Спасибо за прочтение!