image

Когда говорят о «toon-контурах», то имеют в виду любую технику, которая рендерит линии вокруг объектов. Как и cel shading, контуры помогают игре выглядеть более стилизованной. Они могут создавать ощущение того, что объекты нарисованы красками или чернилами. Примеры такого стиля можно увидеть в таких играх, как Okami, Borderlands и Dragon Ball FighterZ.

В этом туториале вы научитесь следующему:

  • Создавать контуры с помощью инвертированного меша
  • Создавать контуры с помощью постобработки и свёрток
  • Создавать и использовать функции материалов
  • Сэмплировать соседние пиксели

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

Если вы не знакомы с постобработкой материалов, то вам сначала стоит изучить мой туториал по cel shading. В этой статье мы будем использовать некоторые из концепций, изложенных в туториале по cel shading.

Приступаем к работе


Для начала скачайте материалы этого туториала. Распакуйте их, перейдите в ToonOutlineStarter и откройте ToonOutline.uproject. Вы увидите следующую сцену:


Сначала мы создадим контуры с помощью инвертированного меша.

Контуры из инвертированного меша


Принцип реализации этого способа заключается в дублировании целевого меша. Затем дубликату присваивается сплошной цвет (обычно чёрный) и его размер увеличивается таким образом, чтобы он был немного больше исходного меша. Таким образом мы создадим силуэт.


Если использовать просто дубликат, то он полностью перекроет исходный меш.


Чтобы исправить это, мы можем инвертировать нормали дубликата. При включенном параметре backface culling (отсечение задних граней), мы будем видеть не внешние, а внутренние грани.


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


Преимущества:

  • У вас всегда будут чёткие линии, поскольку контур состоит из полигонов
  • Внешний вид и толщину контура легко регулировать перемещением вершин
  • При отдалении контуры становятся меньше (это может быть и недостатком).

Недостатки:

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


Создавать инвертированный меш лучше в программе 3D-моделирования, это даёт больше контроля над силуэтом. При работе со скелетными мешами это также позволяет выполнить скиннинг дубликата с исходным скелетом. Благодаря этому дубликат сможет двигаться вместе с исходным мешем.

Для этого туториала мы создадим меш не в 3D-редакторе. а в Unreal. Способ немного отличается, но концепция остаётся той же.

Сначала нам нужно создать материал для дубликата.

Создание материала для инвертированного меша


Для этого способа мы замаскируем полигоны с гранями наружу, и в результате у нас останутся полигоны с гранями внутрь.

Примечание: из-за маскировки этот способ немного более затратен, чем создание меша вручную.

Перейдите к папке Materials и откройте M_Inverted. Затем перейдите в панель Details и измените следующие параметры:

  • Blend Mode: выберите для него Masked. Это позволит нам помечать области как видимые или невидимые. Пороговое значение можно изменять редактированием Opacity Mask Clip Value.
  • Shading Model: выберите значение Unlit. Благодаря этому на меш не будет влиять освещение.
  • Two Sided: выберите значение enabled. По умолчанию Unreal отсекает задние грани. Включение этой опции отключает отсечение задних граней. Если оставить отсечение задних граней включенным, то мы не сможем увидеть полигоны с гранями внутрь.


Далее создайте Vector Parameter и назовите его OutlineColor. Он будет управлять цветом контура. Соедините его с Emissive Color.


Чтобы замаскировать полигоны с гранями наружу, создайте TwoSidedSign и умножьте его на -1. Присоедините результат с Opacity Mask.


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

Наконец, нам нужен способ управления толщиной контура. Для этого добавим выделенные ноды:


В движке Unreal мы можем изменять положение каждой вершины с помощью World Position Offset. Умножая нормаль вершины на OutlineThickness, мы делаем меш толще. Вот демонстрация с использованием исходного меша:


На этом мы закончили подготовку материала. Нажмите Apply и закройте M_Inverted.

Теперь нам нужно дублировать меш и применить только что созданный материал.

Дублирование меша


Перейдите в папку Blueprints и откройте BP_Viking. Добавьте компонент Static Mesh как дочерний элемент Mesh и назовите его Outline.


Выберите Outline и выберите для Static Mesh значение SM_Viking. Затем выберите для его material значение MI_Inverted.


MI_Inverted — это экземпляр M_Inverted. Он позволит нам изменять параметры OutlineColor и OutlineThickness без повторной компиляции.

Нажмите на Compile и закройте BP_Viking. Теперь у викинга будет контур. Мы можем изменять цвет и толщину контура, открыв MI_Inverted и регулируя его параметры.


И на этом мы закончили с этим способом! Попробуйте создать инвертированный меш в 3D-редакторе, а затем перенести его в Unreal.

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

Создание контуров постобработкой


Можно создавать контуры постобработки с помощью распознавания рёбер. Это техника, распознающая разрывы в областях изображения. Вот несколько типов разрывов, которые можно искать:


Преимущества:

  • Способ с лёгкостью применим ко всей сцене
  • Неизменные вычислительные затраты, поскольку шейдер всегда выполняется для каждого пикселя
  • Толщина линии всегда остаётся одинаковой вне зависимости от расстояния (это может быть и недостатком).
  • Линии не отсекаются геометрией, поскольку это эффект постобработки

Недостатки:

  • Обычно для распознавания всех рёбер требуется несколько распознавателей рёбер. Это снижает скорость.
  • Способ подвержен шуму. Это значит, что рёбра будут появляться в областях, где есть большая вариативность.

Обычно распознавание рёбер выполняется свёрткой каждого пикселя.

Что такое «свёртка»?


В области обработки изображений свёртка — это операция над двумя группами чисел для вычисления одного числа. Сначала мы берём сетку чисел (известную, как ядро) и располагаем центр над каждым пикселем. Ниже представлен пример того, как ядро движется поверх двух строк изображения:


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


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


Наконец, складываем результаты вместе. Это будет новым значением для центрального пикселя. В нашем случае новое значение равно 0.5 + 0.5 или 1. Вот, как выглядит изображение после выполнения свёртки для каждого пикселя:


От используемого ядра зависит получаемый эффект. Ядро из показанных выше примеров используется для распознавания рёбер. Вот несколько примеров других рёбер:


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

Для распознавания рёбер изображения можно использовать лапласово распознавание рёбер.

Лапласово распознавание рёбер


Во-первых, каким будет ядро для лапласова распознавания рёбер? На самом деле это ядро мы уже видели в примерах предыдущего раздела!


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

Чтобы вы разобрались в этом, давайте рассмотрим лапласиан в одном измерении. Ядро для него будет следующим:


Сначала расположим ядро над пикселем ребра, а затем выполним свёртку.


Это даст нам значение 1, которое показывает, что здесь произошло большое изменение. То есть целевой пиксель скорее всего будет ребром.

Далее давайте выполним свёртку области с меньшей вариативностью.


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

Ниже показано изображение после свёртки и график всех значений. Можно заметить, что пиксели на ребре сильнее отклоняются от нуля.


Да уж, мы рассмотрели довольно много теории, но не волнуйтесь — сейчас начнётся интересное. В следующем разделе мы создадим материал постобработки, который будет выполнять лапласово распознавание рёбер в буфере глубин.

Построение лапласова распознавателя рёбер


Перейдите в папку Maps и откройте PostProcess. Вы увидите чёрный экран. Так получилось потому, что карта содержит Post Process Volume, использующий пустой материал постпроцессинга.


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

Чтобы получить позицию текущего пикселя, мы можем использовать TextureCoordinate. Например, если текущий пиксель находится посередине, то он вернёт (0.5, 0.5). Этот двухкомпонентный вектор называется UV.


Для сэмплирования другого пикселя нам достаточно просто добавить к TextureCoordinate смещение. У изображения 100?100 каждый пиксель в UV-пространстве имеет размер 0.01. Для сэмплирования пикселя справа нужно прибавить 0.01 по оси X.


Однако, здесь возникает проблема. При изменении разрешения изображения размер пикселя тоже изменяется. Если использовать то же смещение (0.01, 0) для изображения 200?200, то будет сэмплировано два пикселя справа.

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


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


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

Примечание: функция материала похожа на функции, которые используются в Blueprints или в C++.

В следующем разделе мы вставим в функцию дублирующие ноды и создадим вход для смещения.

Создания функция сэмплирования пикселей


Для начала перейдите в папку Materials\PostProcess. Для создания функции материала нажмите на Add New и выберите Materials & Textures\Material Function.


Переименуйте её в MF_GetPixelDepth и откройте её. В графе будет один нод FunctionOutput. Именно сюда мы будем присоединять значение сэмплированного пикселя.


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


Когда мы будем в дальнейшем использовать функцию, он будет входным контактом.

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

  • InputName: Offset
  • InputType: Function Input Vector 2. Поскольку буфер глубин является 2D-изображением, смещение должно иметь тип Vector 2.
  • Use Preview Value as Default: Enabled. Если вы не передадите входное значение, то функция будет использовать значение из Preview Value.


Далее нам нужно умножить смещение на размер пикселя. Затем нужно прибавить результат к TextureCoordinate. Для этого добавьте выделенные ноды:


Наконец, нам нужно сэмплировать с помощью UV буфер глубин. Добавьте SceneDepth и соедините всё следующим образом:


Примечание: также можно использовать вместо этого SceneTexture со значением SceneDepth.

Подведём итог:

  1. Offset получает Vector 2 и умножает его на SceneTexelSize. Это даёт нам смещение в UV-пространстве.
  2. Прибавляем смещение к TextureCoordinate, чтобы получить пиксель, находящийся на расстоянии (x, y) пикселей от текущего.
  3. SceneDepth будет использовать переданные UV для сэмплирования соответствующего пикселя, а затем выводить его.

И на этом работа с функцией материала закончена. Нажмите на Apply и закройте MF_GetPixelDepth.

Примечание: В панели Stats вы можете увидеть ошибку, сообщающую, что из глубины сцены могут выполнять считывание только просвечивающие материалы или материалы постобработки. Можно спокойно проигнорировать эту ошибку. Так как мы будем использовать функцию в материале постообработки, то всё будет работать.

Далее нам нужно использовать функцию для выполнения свёртки буфера глубин.

Выполнение свёртки


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

Откройте PP_Outline и создайте четыре узла Constant2Vector. Задайте им следующие параметры:

  • (-1, 0)
  • (1, 0)
  • (0, -1)
  • (0, 1)


Далее нам нужно сэмплировать пять пикселей в ядре. Создайте пять нодов MaterialFunctionCall и выберите для каждого MF_GetPixelDepth. Затем соедините каждое смещение со своей собственной функцией.


Так мы получим значения глубин для каждого пикселя.

Следующим идёт этап умножения. Поскольку множитель для соседних пикселей равен 1, мы можем пропустить умножение. Однако нам всё равно нужно умножить центральный пиксель (нижняя функция) на -4.


Далее нам нужно суммировать все значения. Создайте четыре узла Add и соедините их следующим образом:


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


Подведём итог:

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

Нажмите на Apply и вернитесь к основному редактору. На всём изображении теперь появились линии!


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

Для исправления этого можно использовать пороговые значения.

Реализация порогов


Сначала мы исправим линии, которые появляются из-за незначительных разностей глубин. Вернитесь к редактору материалов и создайте показанную ниже схему. Задайте для Threshold значение 4.


Позже мы соединим результа распознавания рёбер с A. Он будет выводить значение 1 (обозначающее ребро), если значение пикселя выше 4. В противном случае он будет выводить 0 (ребра нет).

Далее мы избавимся от линий на фоне. Создайте схему, показанную ниже. Присвойте DepthCutoff значение 9000.


При этом на выход будет передаваться значение 0 (ребра нет), если глубина текущего пикселя больше 9000. В противном случае, на выход будет передаваться значение от A < B.

Наконец, соединим всё следующим образом:


Теперь линии будут отображаться только тогда, когда значение пикселя больше 4 (Threshold) и его глубина меньше 9000 (DepthCutoff).

Нажмите на Apply и вернитесь в основной редактор. Маленьких линий и линий на фоне больше нет!


Примечание: можно создать экземпляр материала PP_Outline для управления Threshold и DepthCutoff.

Распознавание рёбер работает достаточно хорошо. Но что, если нам понадобятся линии потолще? Для этого нам нужно увеличить размер ядра.

Создание более толстых линий


В общем случае чем больше размер ядра, тем больше оно влияет на скорость, потому что нам нужно сэмплировать больше пикселей. Но есть ли способ увеличить ядра, сохранив ту же скорость, что и при ядре 3?3? Здесь нам пригодится расширенная свёртка.

При расширенной свёртке мы просто распространяем смещения дальше. Для этого мы умножаем каждое смещение на скаляр, называемый коэффициентом расширения. Он определяет расстояние между элементами ядра.


Как вы видите, это позволяет нам увеличить размер ядра, при этом сэмплируя то же количество пикселей.

Теперь давайте реализуем расширенную свёртку. Вернитесь в редактор материалов и создайте ScalarParameter под названием DilationRate. Задайте ему значение 3. Затем умножим каждое смещение на DilationRate.


Так мы отодвинем каждое смещение на расстояние 3 пикселей от центрального пикселя.

Нажмите на Apply и вернитесь в основной редактор. Вы увидите, что линии стали намного толще. Вот сравнение линий с разными коэффициентами расширения:


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

Добавление линий к исходному изображению


Вернитесь к редактору материалов и создайте показанную ниже схему. Порядок здесь важен!


Далее соедините всё следующим образом:


Теперь Lerp будет выводить изображение сцены, если альфа достигает нуля (чёрный цвет). В противном случае он выводит LineColor.

Нажмите на Apply и закройте PP_Outline. Теперь у исходной сцены есть контуры!



Куда двигаться дальше?


Готовый проект можно скачать здесь.

Если вы хотите ещё поработать с распознаванием рёбер, то попробуйте создать распознавание, работающее с буфером нормалей. Это даст вам некоторые рёбра, которые не появляются в распознавателе рёбер по глубинам. Затем вы сможете объединить оба типа распознавания рёбер вместе.

Свёртки — это обширная тема, которая находит активное применение, в том числе в искусственном интеллекте и обработке звука. Рекомендую изучить свёртки, создавая другие эффекты, например, увеличение резкости и размытие. Для некоторых из них достаточно просто изменить значения ядра! Посмотрите интерактивное объяснение свёрток в Images Kernels explained visually. Также там описаны ядра для некоторых других эффектов.

Также настоятельно рекомендую посмотреть презентацию с GDC по графическому стилю Guilty Gear Xrd. Для внешних линий в этой игре тоже используется способ с инвертированными мешами. Однако для внутренних линий разработчики создали простую, но гениальную технику с использованием текстур и UV.

Комментарии (0)