ведьмачье чутье без эффекта рыбьего глаза что это
Как реализован рендеринг «Ведьмака 3»: молнии, ведьмачье чутьё и другие эффекты
Часть 1. Молнии
В этой части мы рассмотрим процесс рендеринга молний в Witcher 3: Wild Hunt.
Рендеринг молний выполняется немного позже эффекта занавес дождя, но всё равно происходит в проходе прямого рендеринга. Молнии можно увидеть на этом видео:
Они очень быстро исчезают, поэтому лучше просматривать видео на скорости 0.25.
Можно увидеть, что это не статичные изображения; со временем их яркость слегка меняется.
С точки зрения нюансов рендеринга здесь есть очень много сходств с отрисовкой занавес дождя в отдалении, например, такие же состояния смешивания (аддитивное смешивание) и глубины (проверка включена, запись глубин не выполняется).
С точки зрения геометрии молнии в «Ведьмаке 3» — это древоподобные меши. Данный пример молнии представлен следующим мешем:
Он имеет UV-координаты и векторы нормалей. Всё это пригодится на этапе вершинного шейдера.
Вершинный шейдер
Давайте взглянем на ассемблерный код вершинного шейдера:
Здесь есть много сходств с вершинным шейдером занавес дождя, поэтому я не буду повторяться. Хочу показать вам важное отличие, которое есть в строках 11-18:
Во-первых, cb1[8].xyz — это позиция камеры, а r2.xyz позиция в мировом пространстве, то есть строка 11 вычисляет вектор из камеры к позиции в мире. Затем строки 12-15 вычисляют length( worldPos — cameraPos) * 0.000001.
v2.xyz — это вектор нормали входящей геометрии. Строка 16 расширяет его из интервала 1 до интервала [-1;1].
Затем вычисляется конечная позиция в мире:
finalWorldPos = worldPos + length( worldPos — cameraPos) * 0.000001 * normalVector
Фрагмент кода HLSL для этой операции будет примерно таким:
Эта операция приводит к небольшому «взрыву» меша (в направлении вектора нормали). Я поэкспериментировал, заменив 0.000001 на несколько других значений. Вот результаты:
Пиксельный шейдер
Отлично, мы разобрались с вершинным шейдером, теперь пора взяться за ассемблерный код пиксельного шейдера!
Хорошая новость: код не такой длинный.
… что это вообще такое?
Честно говоря, я не впервые вижу подобный кусок… ассемблерного кода в шейдерах «Ведьмака 3». Но когда я встретил его в первый раз, то подумал: «Что за фигня?»
Нечто подобное можно найти в некоторых других шейдерах TW3. Не буду описывать свои приключения с этим фрагментом, и просто скажу, что ответ заключается в целочисленном шуме:
Как видите, в пиксельном шейдере он вызывается дважды. Пользуясь руководствами с этого веб-сайта, мы можем понять, как правильно реализуется плавный шум. Я вернусь к этому через минуту.
Посмотрите на строку 0 — здесь мы выполняем анимацию на основании следующей формулы:
animation = elapsedTime * animationSpeed + TextureUV.x
Эти значения, после округления в меньшую сторону (floor) (инструкция round_ni) в дальнейшем становятся входными точками для целочисленного шума. Обычно мы вычисляем значение шума для двух целых чисел, а затем вычисляем окончательное, интерполированное значение между ними (подробности см. на веб-сайте libnoise).
Ну ладно, это целочисленный шум, но ведь все ранее упомянутые значения (тоже округлённые в меньшую сторону) являются float!
Заметьте, что здесь нет инструкций ftoi. Я предполагаю, что программисты из CD Projekt Red воспользовались здесь внутренней функцией HLSL asint, которая выполняет преобразование «reinterpret_cast» значений с плавающей запятой и обрабатывает их как целочисленный паттерн.
Вес интерполяции для двух значений вычисляется в строках 10-11
interpolationWeight = 1.0 — frac( animation );
Такой подход позволяет нам выполнять интерполирование между значения с учётом времени.
Для создания плавного шума этот интерполятор передается функции SCurve:
Функция Smoothstep [libnoise.sourceforge.net]
Эта функция известна под названием «smoothstep». Но как видно из ассемблерного кода, это не внутренняя функция smoothstep из HLSL. Внутренняя функция применяет ограничения, чтобы значения были верными. Но поскольку мы знаем, что interpolationWeight всегда будет находиться в интервале 1, эти проверки можно спокойно пропустить.
При вычислении окончательного значения используется несколько операций умножения. Посмотрите, как может меняться окончательное выходное значение альфы в зависимости от значения шума. Это удобно, потому что будет влиять на непрозрачность отрендеренной молнии, совсем как в реальной жизни.
Готовый пиксельный шейдер:
Подведём итог
В этой части я описал способ рендеринга молний в «Ведьмаке 3».
Я очень доволен тем, что получившийся из моего шейдера ассемблерный код полностью совпадает с оригинальным!
Часть 2. Глупые трюки с небом
Эта часть будет немного отличаться от предыдущих. В ней я хочу показать вам некоторые аспекты шейдеров неба Witcher 3.
Почему «глупые трюки», а не весь шейдер? Ну, на то есть несколько причин. Во-первых, шейдер неба Witcher 3 — довольно сложная зверюга. Пиксельный шейдер из версии 2015 года содержит 267 строк ассемблерного кода, а шейдер из DLC «Кровь и вино» — уже 385 строк.
Более того, они получают множество входных данных, что не очень способствует реверс-инжинирингу полного (и читаемого!) кода на HLSL.
Поэтому я решил показать из этих шейдеров только часть трюков. Если я найду что-то новое, то дополню пост.
Различия между версией 2015 года и DLC (2016 год) сильно заметны. В том, числе в них входят различия в вычислении звёзд и их мерцания, разный подход к рендерингу Солнца… Шейдер «Крови и вина» даже вычисляет ночью Млечный путь.
Я начну с основ, а потом расскажу о глупых трюках.
Основы
Как и в большинстве современных игр, в Witcher 3 для моделирования неба используется skydome. Посмотрите на полусферу, которую использовали для этого в Witcher 3 (2015). Примечание: в данном случае ограничивающий параллелепипед этого меша находится в интервале от [0,0,0] до [1,1,1] (Z — это ось, направленная вверх) и имеет плавно распределённые UV. Позже мы их используем.
Идея в основе skydome схожа с идеей скайбокса (единственная разница заключается в используемом меше). На этапе вершинного шейдера мы преобразуем skydome относительно наблюдателя (обычно в соответствии с позицией камеры), что создаёт иллюзию того, что небо и в самом деле находится очень далеко — мы никогда до него не доберёмся.
Если вы читали предыдущие части этой серии статей, то знаете, что в «Ведьмаке 3» используется обратная глубина, то есть дальняя плоскость имеет значение 0.0f, а ближняя — 1.0f. Чтобы вывод skydome целиком выполнялся на дальней плоскости, в параметрах окна обзора мы задаём MinDepth то же значение, что и MaxDepth:
Чтобы узнать, как поля MinDepth и MaxDepth используются во время преобразования окна обзора, нажмите сюда (docs.microsoft.com).
Вершинный шейдер
Давайте начнём с вершинного шейдера. В Witcher 3 (2015 год) ассемблерный код шейдера имеет следующий вид:
В данном случае вершинный шейдер передаёт на выход только texcoords и позицию в мировом пространстве. В «Крови и вине» он также выводит нормализованный вектор нормали. Я буду рассматривать версию 2015 года, потому что она проще.
Посмотрите на буфер констант, обозначенный как cb2:
Здесь у нас есть матрица мира (однородное масштабирование на 100 и перенос относительно позиции камеры). Ничего сложного. cb2_v4 и cb2_v5 — это коэффициенты масштаба/отклонения, используемые для преобразования позиций вершин из интервала 1 в интервал [-1;1]. Но здесь эти коэффициенты «сжимают» ось Z (направленную вверх).
Поэтому HLSL этого вершинного шейдера должен быть примерно таким:
Сравнение моего шейдера (слева) и оригинального (справа):
Отличным свойством RenderDoc является то, что он позволяет нам выполнить инъекцию собственного шейдера вместо оригинального, и эти изменения повлияют на конвейер до самого конца кадра. Как видите из кода HLSL, я предоставил несколько вариантов изменения масштаба и преобразования конечной геометрии. Можете поэкспериментировать с ними и получить очень забавные результаты:
Оптимизация вершинного шейдера
Вы заметили проблему оригинального вершинного шейдера? Повершинное перемножение матрицы на матрицу совершенно избыточно! Я обнаружил это по крайней мере в нескольких вершинных шейдерах (например, в шейдере занавес дождя в отдалении). Мы можем оптимизировать его, сразу же умножив PositionW на matViewProj!
Итак, мы можем заменить такой код на HLSL:
Оптимизированная версия даёт нам следующий ассемблерный код:
Как видите, мы уменьшили количество инструкций с 26 до 12 — довольно значительное изменение. Я не знаю, насколько широко распространена эта проблема в игре, но ради бога, CD Projekt Red, может, выпустите патч? 🙂
И я не шучу. Можете вставить мой оптимизированный шейдер вместо оригинального RenderDoc и вы увидите, что эта оптимизация визуально ни на что не влияет. Честно говоря, я не понимаю, зачем CD Projekt Red решила выполнять повершинное умножение матрицы на матрицу…
Солнце
В «Ведьмаке 3» (2015 год) вычисление атмосферного рассеяния и Солнца состоит из двух отдельных вызовов отрисовки:
Witcher 3 (2015) — до
Witcher 3 (2015) — с небом
Witcher 3 (2015) — с небом + Солнце
Рендеринг Солнца в версии 2015 года очень похож на рендеринг Луны с точки зрения геометрии и состояний смешивания/глубин.
С другой стороны, в «Крови и вине» небо с Солнцем рендерятся за один проход:
Ведьмак 3: Кровь и вино (2016 год) — до неба
Ведьмак 3: Кровь и вино (2016 год) — с небом и Солнцем
Как бы вы не рендерили Солнце, на каком-то этапе вам всё равно понадобится (нормализованное) направление солнечного света. Наиболее логичный способ получить этот вектор — использовать сферические координаты. По сути, нам нужно всего два значения, обозначающие два угла (в радианах!): фи и тета. Получив их, можно допустить, что r = 1, таким образом сократив его. Тогда для декартовых координат с направленной вверх осью Y можно написать следующий код на HLSL:
Обычно направление солнечного света вычисляется в приложении, а затем передаётся в буфер констант для дальнейшего использования.
Получив направление солнечного света, мы можем углубиться в ассемблерный код пиксельного шейдера «Крови и вина»…
Затем мы вычисляем скалярное произведение векторов cameraToWorld и sunDirection! Помните, что они должны быть нормализованными. Также мы насыщаем это полное выражение, чтобы ограничить его интервалом 1.
Отлично! Это скалярное произведение хранится в r1.x. Давайте посмотрим, где оно применяется дальше…
Троица «log, mul, exp» — это возведение в степень. Как видите, мы возводим наш косинус (скалярное произведение нормализованных векторов) в какую-то степень. Вы можете спросить зачем. Таким образом мы можем создать градиент, имитирующий Солнце. (И строка 155 влияет на непрозрачность этого градиента, чтобы мы, например, обнулить его, чтобы полностью скрыть Солнце). Вот несколько примеров:
Имея этот градиент, мы используем его для выполнения интерполяции между skyColor и sunColor! Чтобы избежать появления артефактов, нужно насытить значение в строке 120.
Стоит заметить, что этот трюк можно использовать для имитации венцов Луны (при низких значениях exponent). Для этого нам понадобится вектор moonDirection, который легко можно вычислить с помощью сферических координат.
Готовый код на HLSL может походить на следующий фрагмент:
Движение звёзд
Если сделать таймлапс чистого ночного неба Witcher 3, то можно заметить, что звёзды не статичны — они немного движутся по небу! Я заметил это почти случайно и захотел узнать, как это реализовано.
Давайте начнём с того факта, что звёзды в Witcher 3 представлены как кубическая карта размером 1024x1024x6. Если подумать, то можно понять, что это очень удобное решение, которое позволяет с лёгкостью привязывать направления для сэмплирования кубической карты.
Давайте рассмотрим следующий ассемблерный код:
Чтобы вычислить конечный вектор сэмплирования (строка 173), мы начинаем с вычисления нормализованного вектора worldToCamera (строки 159-162).
Затем мы вычисляем два векторных произведения (163-164, 165-166) с moonDirection, а позже рассчитываем три скалярных произведения, чтобы получить конечный вектор сэмплирования. Код на HLSL:
Примечание для себя: это очень хорошо продуманный код, и мне стоит исследовать его подробнее.
Примечание для читателей: если вы знаете больше об этой операции, то расскажите мне!
Мерцающие звёзды
Ещё один интересный трюк, который бы я хотел исследовать подробнее — это мерцание звёзд. Например, если вы будете бродить в окрестностях Новиграда при ясной погоде, то заметите, что звёзды мерцают.
Мне было любопытно, как это реализовано. Оказалось, что разница между версией 2015 года и «Кровью и вином» довольно велика. Для простоты я буду рассматривать версию 2015 года.
Итак, мы начинаем сразу после сэмплирования starsColor из предыдущего раздела:
Хм. Давайте взглянем в конец этого достаточно длинного ассемблерного кода.
После сэмплирования starsColor в строке 173 мы вычисляем какое-то значение offset. Это offset используется для искажения первого направления сэмплирования (r2.xyz, строка 235), а затем снова сэмплируем кубическую карту звёзд, выполняем гамма-коррекцию этих двух значений (237-242) и перемножаем их (243).
Просто, не правда ли? Ну, не совсем. Давайте немного подумаем об этом offset. Это значение должно быть разным на протяжении всего skydome — одинаково мерцающие звёзды выглядели бы очень нереалистично.
Чтобы offset было как можно более разнообразным, мы воспользуемся тем, что UV растянуты на skydome (v0.xy) и применим прошедшее время, хранящееся в буфере констант (cb[0].x).
Если вам незнакомы эти пугающие ishr/xor/and, то в части про эффект молний прочитайте об целочисленном шуме.
Как видите, целочисленный шум вызывается здесь четыре раза, но он отличается от того, который используется для молний. Чтобы сделать результаты ещё более случайными, входящее целое число для шума является суммой (iadd) и с ним выполняется инвертирование битов (внутренняя функция reversebits; инструкция bfrev).
Так, а теперь помедленнее. Давайте начнём с самого начала.
У нас есть 4 «итерации» целочисленного шума. Я проанализировал ассемблерный код, вычисления всех 4 итераций выглядят так:
Конечные выходные данные всех 4 итераций (чтобы найти их, проследите за инструкциями itof):
После последней itof (строка 216) мы имеем:
Эти строки вычисляют значения S-образной кривой для весов на основании дробной части UV, как и в случае с молниями. Итак:
Как и можно ожидать, эти коэффициенты используются для плавной интерполяции шума и генерации окончательного смещения для координат сэмплирования:
Вот небольшая визуализация вычисленного offset:
После вычисления starsColorDisturbed самая сложная часть завершена. Ура!
Следующий этап — выполнение гамма-коррекции и для starsColor, и для starsColorDisturbed, после чего они перемножаются:
Звёзды — финальные штрихи
У нас есть starsFinal in r1.xyz. В конце обработки звёзд происходит следующее:
Это гораздо проще по сравнению с мерцающими и движущимися звёздами.
Итак, мы начинаем с возведения starsFinal в степень 2.5 — это позволяет нам контролировать плотность звёзд. Довольно умно. Затем мы делаем так, чтобы максимальный цвет звёзд был равен float3(1, 1, 1).
cb0[9].w используется для управления общей видимостью звёзд. Поэтому можно ожидать, что в дневное время это значение равно 1.0 (что даёт умножение на ноль), а ночью — 0.0.
В конце мы увеличиваем видимость звёзд на 10. И на этом всё!
Часть 3. Ведьмачье чутьё (объекты и карта яркости)
Почти все описанные ранее эффекты и техники на самом деле не были связаны с Witcher 3. Такие вещи, как тональная коррекция, виньетирование или вычисление средней яркости присутствуют практически в каждой современной игре. Даже эффект опьянения распространён довольно широко.
Именно поэтому я решил внимательнее присмотреться к механикам рендеринга «ведьмачьего чутья». Геральт — ведьмак, а потому его чувства гораздо острее, чем у обычного человека. Следовательно, он может видеть и слышать больше, чем другие люди, что сильно помогает ему в расследованиях. Механика ведьмачьего чутья позволяет игроку визуализировать такие следы.
Вот демонстрация эффекта:
И ещё одна, с освещением получше:
Как видите, есть два типа объектов: те, с которыми Геральт может взаимодействовать (жёлтый контур) и следы, связанные с расследованием (красный контур). После того, как Геральт исследует красный след, он может превратиться в жёлтый (первое видео). Заметьте, что весь экран становится серее и добавляется эффект «рыбьего глаза (второе видео).
Этот эффект довольно сложен, поэтому я решил разделить его исследование на три части.
В первой я расскажу о выборе объектов, во второй — о генерации контура, а в третьей — о финальном объединении всего этого в одно целое.
Выбор объектов
Как я и говорил, существует два типа объектов, и нам нужно их различать. В Witcher 3 это реализовано с помощью стенсил-буфера. При генерации мешей GBuffer, которые должны быть помечены как „следы“ (красные), они рендерятся со stencil = 8. Меши, помеченные жёлтым цветом как „интересные“ объекты, рендерятся со stencil = 4.
Например, следующие две текстуры показывают пример кадра с видимым ведьмачьим чутьём и соответствующий стенсил-буфер:
Вкратце о стенсил-буфере
Стенсил-буфер довольно часто используется в играх для пометки мешей. Определённым категориям мешей назначается одинаковый ID.
Идея заключается в том, чтобы использовать функцию Always с оператором Replace, если стенсил-тест оказался успешным, и с оператором Keep во всех остальных случаях.
Вот как это реализуется с помощью D3D11:
Значение стенсила, которое нужно записать в буфер, передаётся как StencilRef в вызове API:
Яркость рендеринга
В этом проходе с точки зрения реализации есть одна полноэкранная текстура в формате R11G11B10_FLOAT, в которую интересные объекты и следы сохраняются в каналы R и G.
Зачем это нужно нам с точки зрения яркости? Оказывается, что чутьё Геральта имеет ограниченный радиус, поэтому объекты получают контуры, только когда игрок находится достаточно близко к ним.
Посмотрите на этот аспект в действии:
Мы начинаем с очистки текстуры яркости, заливая её чёрным цветом.
Затем выполняются два полноэкранных вызова отрисовки: первый для „следова“, второй — для интересных объектов:
Первый вызов отрисовки выполняется для следов — зелёный канал:
Второй вызов выполняется для интересных объектов — красный канал:
Ну ладно, но как нам определить, какие пиксели нужно учитывать? Придётся воспользоваться стенсил-буфером!
При каждом из этих вызовов выполняется стенсил-тест, и принимаются только те пиксели, которые были ранее помечены как „8“ (первый вызов отрисовки) или „4“.
Визуализация стенсил-теста для следов:
… и для интересных объектов:
Как в этом случае выполняется тест? Об основах стенсил-тестирования можно узнать в хорошем посте. В общем виде формула стенсил-теста имеет следующий вид:
где:
StencilRef — значение, передаваемое вызовом API,
StencilReadMask — маска, используемая для чтения значения стенсила (учтите, что она присутствует и на левой, и на правой части),
OP — оператор сравнения, задаётся через API,
StencilValue — значение стенсил-буфера в текущем обрабатываемом пикселе.
Важно понимать, что для вычисления операндов мы используем двоичные AND.
Познакомившись с основами, давайте посмотрим, как эти параметры используются в данных вызовах отрисовки:
Состояние стенсила для следов
Состояние стенсила для интересных объектов
Ха! Как мы видим, единственное отличие заключается в ReadMask. Давайте проверим это! Подставим эти значения в уравнение стенсил-теста:
Умно. Как видите, в этом случае мы сравниваем не значение стенсила, а проверяем задан ли определённый бит стенсил-буфера. Каждый пиксель стенсил-буфера имеет формат uint8, поэтому интервал значений составляет 236.
Примечание: все вызовы DrawIndexed(36) связаны с рендерингом отпечатков ног как следов, поэтому в этом конкретном кадре карта яркости имеет следующий окончательный вид:
Но перед стенсил-тестом есть пиксельный шейдер. И 28738, и 28748 используют одинаковый пиксельный шейдер:
Этот пиксельный шейдер выполняет запись только в один render target, поэтому строки 24-27 избыточны.
Первое, что здесь происходит — сэмплирование глубины (точечным сэмплером с ограничением значений), строка 1. Это значение используется для воссоздания позиции в мире умножением на специальную матрицу с последующим перспективным делением (строки 2-6).
Взяв позицию Геральта (cb3[7].xyz — учтите, что это не позиция камеры!), мы вычисляем расстояние от Геральта до этой конкретной точки (строки 7-9).
В этом шейдере важны следующие входные данные:
— cb3[0].rgb — цвет вывода. Он может иметь формат float3(0, 1, 0) (следы) или float3(1, 0, 0) (интересные объекты),
— cb3[6].y — коэффициент масштабирования расстояния. Непосредственно влияет на радиус и яркость финальных выходных данных.
Позже у нас идут довольно хитрые формулы для вычисления яркости в зависимости от расстояния между Геральтом и объектом. Могу предположить, что все коэффициенты подобраны экспериментально.
Финальными выходными данными являются color*intensity.
Код на HLSL будет выглядеть примерно так:
Небольшое сравнение оригинального (слева) и моего (справа) ассемблерного кода шейдера.
Это был первый этап эффекта ведьмачьего чутья. На самом деле, он самый простой.
Часть 4. Ведьмачье чутьё (карта контуров)
Ещё раз взглянем на исследуемую нами сцену:
В первой части разбора эффекта ведьмачьего чутья я показал, как генерируется „карта яркости“.
У нас есть одна полноэкранная текстура формата R11G11B10_FLOAT, которая может выглядеть вот так:
Зелёный канал обозначает „следы“, красный — интересные объекты, с которыми может взаимодействовать Геральт.
Получив эту текстуру, мы можем переходить к следующему этапу — я назвал его „карта контуров“.
Это немного странная текстура формата 512×512 R16G16_FLOAT. Здесь важно то, что она реализована в стиле „пинг-понг“. Карта контуров из предыдущего кадра является входящими данными (наряду с картой яркости) для генерации новой карты контуров в текущем кадре.
Буферы „пинг-понга“ можно реализовать множеством способов, но лично мне больше всего нравится следующий (псевдокод):
Такой подход, при котором на входе всегда [m_outlineIndex], а на выходе всегда [!m_outlineIndex], обеспечивает гибкость в отношении использования дальнейших постэффектов.
Давайте взглянем на пиксельный шейдер:
Как видите, выходная карта контуров разделена на четыре равных квадрата, и это первое, что нам нужно изучить:
Мы начинаем с вычисления floor( TextureUV * 2.0 ), что даёт нам следующее:
Для определения отдельных квадратов используется небольшая функция:
Заметьте, что функция возвращает 1.0 при входных данных float2(0.0, 0.0).
Этот случай возникает в левом верхнем углу. Чтобы получить ту же ситуацию в верхнем правом углу, нужно вычесть из округлённых texcoords float2(1, 0), для зелёного квадрата вычесть float2(0, 1), а для жёлтого — float2(1.0, 1.0).
Каждый из компонентов mask равен или нулю, или единице, и ответственен за один квадрат текстуры. Например, mask.r и mask.w:
Используемые для этой операции Texcoords можно вычислить как frac( TextureUV * 2.0 ). Поэтому результат этой операции может, например, выглядеть вот так:
Следующий этап очень умён — выполняется четырёхкомпонентное скалярное произведение (dp4):
Получив этот masterFilter, мы готовы к определению контуров объектов. Это не так сложно, как может показаться. Алгоритм очень похож на применённый при получении резкости — нам нужно получить максимальную абсолютную разность значений.
Вот что происходит: мы сэмплируем четыре тексела рядом с текущим обрабатываемым текселом (важно: в этом случае размер тексела равен 1.0/256.0!) и вычисляем максимальные абсолютные разности для красного и зелёного каналов:
Теперь если мы перемножим filter на maxAbsDifference…
Очень просто и эффективно.
Получив контуры, мы сэмплируем карту контуров из предыдущего кадра.
Затем, чтобы получить „призрачный“ эффект, мы берём часть параметров, вычисленных на текущем проходе, и значения из карты контуров.
Поздоровайтесь с нашим старым другом — целочисленным шумом. Он присутствует и здесь. Параметры анимации (cb3[0].zw) берутся из буфера констант и со временем изменяются.
Примечание: если вы захотите реализовать ведьмачье чутьё самостоятельно, то рекомендую ограничить целочисленный шум интервалом [-1;1] (как и сказано на его веб-сайте). В оригинальном шейдере TW3 ограничения не было, но без него я получал ужасные артефакты и вся карта контуров была нестабильной.
Затем, судя по ассемблерному коду, вычисляется разность между средним и значением этого конкретного пикселя, после чего выполняется искажение целочисленным шумом:
Следующим шагом будет искажение значения из „старой“ карты контуров с помощью шума — это основная линия, придающая выходной текстуре ощущение блочности.
Дальше идут другие вычисления, после чего, в самом конце, вычисляется „затухание“.
Вот небольшое видео, демонстрирующее в действии карту контуров:
Если вам интересен полный пиксельный шейдер, то он выложен здесь. Шейдер совместим с RenderDoc.
Интересно (и, если честно, слегка раздражает) то, что несмотря на идентичность ассемблерного кода с оригинальным шейдером из Witcher 3, окончательный внешний вид карты контуров в RenderDoc меняется!
Часть 5: Ведьмачье чутьё (»рыбий глаз» и окончательный результат)
Вкратце перечислим, что у нас уже есть: в первой части, посвящённой ведьмачьему чутью, сгенерирована полноэкранная карта яркости, сообщающая насколько заметен должен быть эффект в зависимости от расстояния. Во второй части я подробнее исследовал карту контуров, отвечающую за контуры и анимацию готового эффекта.
Мы подошли к последнему этапу. Всё это нужно объединить! Последний проход — это полноэкранный четырёхугольник. Входные данные: буфер цветов, карта контуров и карта яркости.
Ещё раз покажу видео с применённым эффектом:
Как видите, кроме наложения контуров на объекты, которые может увидеть или услышать Геральт, ко всему экрану применяется эффект «рыбьего глаза», и весь экран (особенно углы) становится сероватым, чтобы передать ощущение реального охотника за чудовищами.
Полный ассемблерный код пиксельного шейдера:
82 строки — значит, нам предстоит много работы!
Для начала взглянем на входящие данные:
Основное значение, ответственное за величину эффекта — это fisheyeAmount. Думаю, оно постепенно повышается с 0.0 до 1.0, когда Геральт начинает использовать своё чутьё. Остальные значения почти не меняются, но я подозреваю, что некоторые из них отличались бы, если бы пользователь отключил в опциях эффект fisheye (я это не проверял).
Первое, что здесь происходит — шейдер вычисляет маску, отвечающую за серые углы:
На HLSL мы можем записать это следующим образом:
Сначала вычисляется интервал [-1; 1] UV и их абсолютные значения. Затем имеет место хитрое «сжимание». Готовая маска выглядит следующим образом:
Позже я вернусь к этой маске.
Сейчас я намеренно пропущу несколько строк кода и внимательнее изучу код, отвечающий за эффект «зума».
Сначала вычисляются «удвоенные» координаты текстур и выполняется вычитание float2(1, 1):
Такие texcoord можно визуализировать так:
Затем вычисляется скалярное произведение dot(uv4, uv4), что даёт нам маску:
которая используется для умножения на вышеупомянутые texcoords:
Важно: в верхнем левом углу (чёрные пиксели) значения отрицательны. Они отображаются чёрным (0.0) из-за ограниченной точности формата R11G11B10_FLOAT. У него нет знакового бита, поэтому в нём нельзя хранить отрицательные значения.
Затем вычисляется коэффициент затухания (как я говорил выше, fisheyeAmount изменяется от 0.0 до 1.0).
Затем выполняется ограничение (max/min) и одно умножение.
Таким образом вычисляется смещение. Для вычисления конечных uv, которые будут использоваться для сэмплирования текстуры цвета, мы просто выполняем вычитание:
float2 colorUV = mainUv — offset;
Выполняя сэмплирование входной текстуры цвета colorUV, мы получаем рядом с углами искажённое изображение:
Контуры
Следующий этап — сэмплирование карты контуров для нахождения контуров. Это довольно просто, сначала мы находим texcoords для сэмплирования контуров интересных объектов, а затем то же самое делаем для следов:
Интересные объекты из карты контуров
Следы из карты контуров
Движение
Для реализации движения следов используется почти такой же трюк, как и в эффекте опьянения. Добавляется круг единичного размера и мы сэмплируем 8 раз карту контуров для интересных объектов и следов, а также текстуру цвета.
Заметьте, что мы только разделили найденные контуры на 8.0.
Поэтому прежде чем двигаться дальше, давайте узнаем, как вычисляется этот радиус. Для этого нам нужно вернуться к пропущенным строкам 15-21. Небольшая проблем с вычислением этого радиуса заключается в том, что его вычисление разбросано по шейдеру (возможно, из-за оптимизаций шейдера компилятором). Поэтому вот первая часть (15-21) и вторая (41-42):
Как видите, мы рассматриваем только текселы из [0.00 — 0.03] рядом с каждой поверхностью, суммируем их значения, умножаем 20 и насыщаем. Вот как они выглядят после строк 15-21:
А вот как после строки 41:
В строке 42 мы умножаем это на 0.03, это значение является радиусом круга для всего экрана. Как видите, ближе к краям экрана радиус становится меньше.
Теперь мы можем посмотреть на ассемблерный код, отвечающий за движение:
Давайте остановимся здесь на минуту. В строке 40 мы получаем временной коэффициент — просто elapsedTime * 0.1. В строке 43 у нас буфер для текстуры цвета, получаемой внутри цикла.
r0.x (строки 41-42) — это, как мы теперь знаем, радиус круга. r4.x (строка 44) — это контур интересных объектов, r4.y (строка 45) — контур следов (ранее разделённый на 8!), а r4.z (строка 46) — счётчик цикла.
Как можно ожидать, цикл имеет 8 итераций. Мы начинаем с вычисления угла в радианах i * PI_4, что даёт нам 2*PI — полный круг. Угол со временем искажается.
С помощью sincos мы определяем точку сэмплирования (единичный круг) и изменяем радиус с помощью умножения (строка 54).
После этого мы обходим пиксель по кругу и сэмплируем контуры и цвет. После цикла мы получим средние значения (благодаря делению на 8) контуров и цвета.
Сэмплирование цвета выполнятся почти так же, но к базовому colorUV мы прибавляем смещение, умноженное на «единичный» круг.
Яркости
После цикла мы сэмплируем карту яркости и изменяем финальные значения яркости (потому что карта яркости ничего не знает о контурах):
Серые углы и финальное объединение всего
Серый цвет ближе к углам вычисляется с помощью скалярного произведения (ассемблерная строка 69):
Затем идут две интерполяции. Первая комбинирует серый цвет с «цветом в круге» при помощи описанной мной первой маски, поэтому углы становятся серыми. Кроме того, существует коэффициент 0.6, снижающий насыщенность финального изображения:
Вторая сочетает первый цвет с приведённым выше, используя fisheyeAmount. Это означает, что экран становится постепенно темнее (благодаря умножению на 0.6) и серее по углам! Гениально.
Теперь мы можем перейти к добавлению контуров объектов.
Цвета (красный и жёлтый) берутся из буфера констант.
Фух! Мы почти у финишной черты!
У нас есть окончательный цвет, есть цвет ведьмачьего чутья… осталось их каким-то образом скомбинировать!
И для этого не подойдёт простое сложение. Сначала мы вычисляем скалярное произведение:
которое выглядит вот так:
И эти значения в самом конце используются для интерполяции между цветом и (насыщенным) ведьмачьим чутьём:
Полный шейдер выложен здесь.
Сравнение моего (слева) и оригинального (справа) шейдеров:
Надеюсь, вам понравилась эта статья! В механиках «ведьмачьего чутья» есть множество блестящих идей, а готовый результат очень правдоподобен.
[Предыдущие части анализа: первая и вторая.]