Уроки Unreal Engine

Unreal Engine 4: Цветовой фильтр

В этом занятии, посвященном Unreal Engine 4 вы узнаете, вы узнаете, как сделать вашу игру похожей на рисунок с помощью фильтрации Kuwahara.

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

Не фотореалистичный рендеринг включает в себя широкий спектр методов рендеринга. Они отвечают за затенение графики, контуры графических объектов, штриховку и многое другое. При желании вы даже сможете сделать свою игру похожей на картину! Одним из методов для достижения такого эффекта является фильтрация Kuwahara.

В этом уроке вы научитесь:

  • Рассчитать средний показатель дисперсии для нескольких ядер;
  • Выводить среднее значение ядра с наименьшей дисперсией;
  • Использовать инструмент Sobel, чтобы найти локальную ориентацию пикселя;
  • Изменять положение ядер выборки на основе локальной ориентации пикселя.

Примечание: Предполагается, что вы уже знаете основы использования Unreal Engine, однако если вы забыли какую-то информацию или один из пунктов вам покажется непонятным, то вы можете ознакомится с серией уроков, посвященных изучению Unreal Engine для начинающих.

Это руководство является четвертой частью серии, посвященной шейдерам в Unreal Engine:

Введение

Вы можете скачать сразу все материалы, которые вам понадобятся в ходе этого занятия. Разархивируйте файл PaintFilterStarter и откройте PaintFilter.uproject. Вы должны увидеть следующую сцену:

Для экономии времени проект уже содержит схему с использованием соединения Post Process Volume и PP_Kuwahara. Теперь вам нужно внести изменения в готовый шаблон:

Фильтр Kuwahara

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

Распространенным способом устранения шума является использование фильтра нижних частот, например, размытие. Посмотрите на изображение ниже, на котором демонстрируется эффект шума после размытия рамки с радиусом = 5:

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

Как работает фильтр Kuwahara

Подобно свертке, фильтр Kuwahara использует ядра, но вместо одного сразу четыре. Ядра расположены так, что каждое из них перекрываются на один (текущий) пиксель. Посмотрите, как выглядит пример ядер для фильтра Kuwahara размером 5 × 5:

В первую очередь необходимо вычислить среднее значение (средний цвет) для каждого ядра. Это значительно размоет ядро, что приведет к сглаживанию шума.

Кроме того, для каждого ядра нужно вычислить значение дисперсии. Это необходимо для того, чтобы узнать насколько может ядро варьироваться в цвете. Например, ядро с похожим набором цветов будет иметь низкую дисперсию, однако если эти цвета различаются, это же ядро будет иметь высокий показатель дисперсии.

Примечание: Если вы не знакомы с понятием что такое дисперсия и как ее рассчитать, рекомендуем вам обратиться почитать об этом в этом источнике.

Далее вам потребуется найти ядро с самой низкой дисперсией и вывести его среднее значение. Таким образом вы сможете добиться сохранения четких контуров изображения при помощи фильтра Kuwahara.

Примеры фильтров Kuwahara

Ниже представлено изображение в градациях серого оттенка и размером 10 × 10 квадратов. Вы можете видеть, что на нем есть область, идущая от нижнего левого угла к верхнему правому. Вы также можете заметить, что некоторые области изображения имеют шум:

Для начала выберите пиксель и определите, какое ядро имеет самую низкую дисперсию. Посмотрите, так выглядит крайний пиксель и связанные с ним ядра:

Как видите, ядра, которые находятся с краю, имеют разные оттенки. Это указывает на высокую дисперсию и означает, что фильтр не будет выбирать их. Исключая использование ядер, которые находятся с самого края изображения, фильтр устраняет проблему размытых краев.

Для этого пикселя фильтр выберет ядро, выделенное в примере зеленым цветом, поскольку оно является наиболее однородным по цвету. Выходными данными будут средние значения ядра, цвет которого близок к черному. Вот еще один пример пикселя и его ядра:

На этот раз ядро имеет наименьшую дисперсию, поэтому в конечном итоге будет использоваться квадрат, обозначенный желтым цветом. Ниже приведено сравнение между обычным эффектом размытия и фильтром Kuwahara, в обоих случаях использован радиус = 5:

Как видите, фильтрация Kuwahara отлично подходит для подавления шума и сохранения при этом четкости изображения. Кстати, эта опция может придать изображению довольно живописный вид. Поскольку мазки кисти обычно имеют жесткие края и низкий уровень шума, фильтр Kuwahara является отличным выбором для преобразования реалистичных изображений в живописный стиль:

Создание фильтра Kuwahara

В этом уроке фильтр разделен на два файла шейдеров: Global.usf и Kuwahara.usf. Первый файл должен хранить функцию для вычисления среднего значения и дисперсии ядра. Второй файл является точкой входа узла фильтра и будет применять вышеупомянутую функцию для каждого ядра.

В первую очередь нужно создать новую функцию для вычисления среднего значения дисперсии. Для этого откройте папку проекта в вашей ОС, а затем перейдя в папку Shaders запустите файл Global.usf. Вы должны увидеть функцию GetKernelMeanAndVariance (). Перед тем как вносить какие-либо изменения в свойства функции, измените ее подпись на:

float4 GetKernelMeanAndVariance(float2 UV, float4 Range)

Для сэмплирования в сетке вам понадобятся два параметра for: один для горизонтальных смещений, а другая для вертикальных смещений. Первые два канала Range будут содержать границы для горизонтального цикла. Вторые два будут содержать границы для вертикального цикла. Например, если вы выбираете ядро в верхнем левом углу, а фильтр имеет радиус 2, значение Range будет:

Range = float4(-2, 0, -2, 0);

Выбор пикселей

В первую очередь вам нужно создать два цикла for, добавив данные GetKernelMeanAndVariance () (под переменными):

for (int x = Range.x; x <= Range.y; x++)

{

    for (int y = Range.z; y <= Range.w; y++)

    {


    }

}

Это даст вам все варианты значений смещения для ядра. Например, если вы производите выборку из левого верхнего ядра, а фильтр имеет радиус 2, смещения будут в диапазоне от (0, 0) до (-2, -2):

Теперь вам нужно указать используемый цвет для образца пикселя. Добавьте следующие данные во внутренний цикл for:

float2 Offset = float2(x, y) * TexelSize;

float3 PixelColor = SceneTextureLookup(UV + Offset, 14, false).rgb;

Первая строка отвечает за получение данных смещения образца выбранного пикселя и преобразование его в пространство UV. Вторая строка использует смещение, чтобы получить цвет образца пикселя.

Расчет значений Mean и Variance

Чтобы вычислить среднее значение Mean необходимо суммировать все цвета и затем разделить полученное число на количество образцов. Для определения дисперсии Variance вам нужно используете формулу, указанную ниже, где x обозначает цвет образца пикселя:

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

Mean += PixelColor;

Variance += PixelColor * PixelColor;

Samples++;

Теперь впишите информацию для цикла for:

Mean /= Samples;

Variance = Variance / Samples - Mean * Mean;

float TotalVariance = Variance.r + Variance.g + Variance.b;

return float4(Mean.r, Mean.g, Mean.b, TotalVariance);

Первые две строчки вычисляют среднее значение и дисперсию, а третья строка суммирует все каналы, чтобы получить общую дисперсию.

Теперь, когда у вас есть функция для вычисления среднего значения Mean и дисперсии Variance, но вам также нужно вызывать ее для каждого ядра. Вернитесь в папку Shaders и откройте Kuwahara.usf. Замените код внутри следующим содержанием:

float2 UV = GetDefaultSceneTextureUV(Parameters, 14);

float4 MeanAndVariance[4];

float4 Range;

Расшифровка действия каждой переменной:

  • UV создает координаты UV для текущего пикселя;
  • MeanAndVariance создает массив для хранения среднего значения и дисперсии для каждого ядра;
  • Range используется для хранения границ цикла for для текущего ядра.

Теперь вам нужно создать действие GetKernelMeanAndVariance () для каждого ядра. Для этого добавьте следующие данные:

Range = float4(-XRadius, 0, -YRadius, 0);

MeanAndVariance[0] = GetKernelMeanAndVariance(UV, Range);

Range = float4(0, XRadius, -YRadius, 0);

MeanAndVariance[1] = GetKernelMeanAndVariance(UV, Range); 

Range = float4(-XRadius, 0, 0, YRadius);

MeanAndVariance[2] = GetKernelMeanAndVariance(UV, Range); 

Range = float4(0, XRadius, 0, YRadius);

MeanAndVariance[3] = GetKernelMeanAndVariance(UV, Range);

Таким образом вы сможете получить среднее значение и дисперсию для каждого ядра в следующем порядке: верхний левый, верхний правый, нижний левый и затем правый нижний квадраты.

Выбор ядра с минимальной дисперсией

Чтобы выбрать ядро с наименьшей дисперсией, добавьте следующие строки:

// 1

float3 FinalColor = MeanAndVariance[0].rgb;

float MinimumVariance = MeanAndVariance[0].a;
 

// 2

for (int i = 1; i < 4; i++)

{

    if (MeanAndVariance[i].a < MinimumVariance)

    {

        FinalColor = MeanAndVariance[i].rgb;

        MinimumVariance = MeanAndVariance[i].a;

    }

}

 

return FinalColor;

Вот что делает каждая строка:

  1. Создаются две переменные для хранения данных об окончательном цвете и минимальной дисперсии. Инициализируются оба из этих значений для получения Mean и Variance ядра.
  2. Происходит цикл над оставшимися тремя ядрами. Если текущая дисперсия ядра ниже минимальной, ее среднее значение и дисперсия становятся новыми данными FinalColor и MinimumVariance.

Вернитесь в Unreal и перейдите к Materials PostProcess и откройте PP_Kuwahara. Вам нужно сделать любое фиктивное действие, нажать Apply и вернуться в главный редактор, чтобы увидеть результаты изменений:

Изображение выглядит неплохо, но если присмотреться, то можно увидеть несколько областей, где пиксели смотрятся неестественно. Посмотрите, эти области выделены красным цветом:

Чтобы это исправить вам нужно воспользоваться возможностями фильтра Directional Kuwahara.

Фильтр Directional Kuwahara

Этот фильтр похож на оригинальный Kuwahara, за исключением того, что в этом случае ядра выравниваются относительно локальной ориентации пикселя. Вот пример ядра размером 3 × 5 с применением фильтра Directional Kuwahara:

Примечание: поскольку вы можете представлять ядро в виде матрицы, размеры указываются в виде высоты и ширины Х.

Здесь фильтр определяет ориентацию пикселя вдоль края с соответствующим перемещением. Чтобы вычислить параметр локальную ориентацию пикселей, фильтр использует возможности Sobel.

Как работает Sobel

Вместо одного ядра Sobel может использовать сразу два:

Gx даст вам градиент в горизонтальном направлении, а Gy в вертикальном. Давайте использовать в качестве примера следующее изображение размером 3 × 3 пикселя в градациях серого:

Сначала вычислите средний пиксель для каждого ядра:

Если вы используете каждое значение в построении графика, то увидите, что итоговый вектор указывает в том же направлении, что и крайняя точка изображения:

Чтобы найти угол между вектором и осью X, нужно подставить значения градиента в функцию арктангенса (atan). Затем вы можете использовать полученный угол для поворота ядра.

Таким образом вы можете использовать возможности Sobel, чтобы определить локальную ориентацию нужного вам пикселя.

Поиск локальной ориентации

Откройте Global.usf и добавьте следующие данные внутри GetPixelAngle ():

float GradientX = 0;

float GradientY = 0;

float SobelX[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};

float SobelY[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};

int i = 0;

Примечание: обратите внимание, что последняя скобка GetPixelAngle () отсутствует. Это сделано специально! Посмотрите наш учебник по пользовательским шейдерам в HLSL, чтобы понять, зачем это нужно.

Вот для чего нужна каждая переменная кода:

  • GradientX: удерживает градиент для горизонтального направления
  • GradientY: удерживает градиент для вертикального направления
  • SobelX: использует горизонтальное ядро Sobel в качестве образца
  • SobelY: использует вертикальное ядро Sobel в качестве образца
  • i: используется для доступа к каждому элементу в SobelX и SobelY

Теперь вам необходимо команду свертку с использованием ядер SobelX и SobelY, добавив следующие данные:

for (int x = -1; x <= 1; x++)

{
    for (int y = -1; y <= 1; y++)

    {

        // 1

        float2 Offset = float2(x, y) * TexelSize;

        float3 PixelColor = SceneTextureLookup(UV + Offset, 14, false).rgb;

        float PixelValue = dot(PixelColor, float3(0.3,0.59,0.11));

        

        // 2

        GradientX += PixelValue * SobelX[i];

        GradientY += PixelValue * SobelY[i];

        i++;

    }
}

Вот для чего нужен каждый раздел:

  1. Первые две строки отвечают за получение цвет образца пикселя. Третья строка обесцвечивает пиксели, чтобы преобразовать их цвет в одно значение оттенка серого. Это облегчает вычисление градиентов изображения в целом вместо получения градиентов для каждого цветового канала.
  2. Вычисляется конечный результат для помещения данных в соответствующую переменную градиента.

Чтобы получить нужный угол, вам нужно использовать функцию atan () и подключить значения градиента. Добавьте следующие данные для цикла for:

return atan(GradientY / GradientX);

Теперь, когда у вас есть функция для получения угла пикселя, вам нужно каким-то образом использовать ее для вращения ядра. Отличный вариант для этих целей — использование матрицы.

Примечание: вы также можете сделать вращение с помощью тригонометрии.

Что такое матрица?

Матрица — это двумерный набор чисел. Например, вот матрица 2 × 3 (которая состоит из двух строк и трех столбцов):

Сама по себе матрица выглядит не очень интересно, однако ее истинная сила проявляется, когда вы умножаете вносите в нее данные вектора. Это позволит вам производить вращение и масштабирование (в зависимости от типа матрицы).

В системе координат у вас будет вектор для каждого измерения. Это ваши базисные векторы, которые определяют положительные направления осей. Ниже приведены несколько примеров различных базисных векторов для 2D-системы координат. Красная стрелка определяет положительное направление X, а зеленая стрелка определяет положительное направление Y:

Чтобы повернуть вектор, вы можете использовать эти инструменты для построения матрицы вращения. В качестве примера представьте, что у вас есть вектор (оранжевая стрелка) в положении (1, 1):

Допустим, вы хотите повернуть его на 90 градусов по часовой стрелке. Сначала вы поворачиваете базисные векторы на одинаковое расстояние:

Затем вы необходимо выстроить матрицу 2 × 2, используя новые данные позиций базовых векторов. Первый столбец — это позиция красной стрелки, а второй — зеленой. Таким образом у вас получается матрица вращения:

В конечном итоге вам нужно выполнить умножение значений матриц, чтобы получить новые координаты для оранжевого вектора:

Примечание: вам не обязательно нужно знать, как выполнять умножение матриц, поскольку в HLSL уже есть встроенная функция. Но если вы хотите этому научиться, то рекомендуем посмотреть уроки на этом сайте.

Кроме того, вы можете использовать такую матрицу для поворота любого 2D-вектора на 90 градусов по часовой стрелке. Таким образом получается, что вам нужно построить матрицу вращения только один раз для каждого пикселя, а затем использовать ее в качестве шаблона для всего ядра.

Вращение ядра

Вам нужно изменить данные GetKernelMeanAndVariance (), чтобы можно было использовать матрицу 2 × 2. Внесите следующие данные:

float4 GetKernelMeanAndVariance(float2 UV, float4 Range, float2x2 RotationMatrix)

Затем измените первую строку во внутреннем цикле for на данные:

float2 Offset = mul(float2(x, y) * TexelSize, RotationMatrix);

Команда mul () выполнит умножение матрицы, используя данные смещения и RotationMatrix.

Создание матрицы вращения

Чтобы построить матрицу вращения, вам нужно создать функцию синуса и косинуса в соответствии с формулой:

Закройте Global.usf, а затем откройте Kuwahara.usf, чтобы добавить внизу списка переменных следующую информацию:

float Angle = GetPixelAngle(UV);

float2x2 RotationMatrix = float2x2(cos(Angle), -sin(Angle), sin(Angle), cos(Angle));

Первая строка рассчитывает угол для текущего пикселя, тогда как вторая создает матрицу вращения, используя эти данные. Кроме того, вам нужно указать RotationMatrix для каждого ядра. Измените GetKernelMeanAndVariance () следующим образом:

GetKernelMeanAndVariance(UV, Range, RotationMatrix)

На этом работа с Directional Kuwahara завершена. Закройте Kuwahara.usf и вернувшись в PP_Kuwahara совершите любое фиктивное действие, нажмите Apply, а затем закройте его.

На изображении ниже вы можете посмотреть в чем заключается различие между оригинальным Kuwahara и Directional Kuwahara:

Что делать дальше?

Вы можете скачать готовый проект.

Если вы хотите узнать больше о фильтре Kuwahara, рекомендуем вам прочитать статью о возможностях Anisotropic Kuwahara.

Используя ваши новые знания попробуйте поэкспериментировать с работой матриц, чтобы создать новые эффекты. Например, вы можете использовать комбинацию матриц вращения и размытия.

Если вы хотите больше узнать о матрицах и о том, как они работают, рекомендуем посмотреть серию занятий 3Blue1Brown «Сущность линейной алгебры»

Перевод
Оригинал на англ.
Показать еще

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Back to top button