Введение

Рендеринг обводки (контуров) — это техника, часто используемая в играх или из эстетических, или из геймплейных соображений. Например, в игре Sable контуры применяются для создания стиля, напоминающего комиксы, а Last of Us контуры используются для выделения врагов, когда игрок переходит в режим скрытности.

Sable.
Sable.
The Last of Us.
Last of Us.

В этом посте мы расскажем о пяти способах рендеринга контура вокруг объекта.

Эффект каёмки

Rim effect outline.

Техника

Один из самых простых способов реализации эффекта контура — применение так называемого эффекта Френеля для рендеринга контура по краю объекта. Эффект Френеля описывает отражение/распространение света при падении на прозрачную поверхность. Впрочем, при рендеринге контуров это физическое значение эффекта не важно. Контур формируется при помощи следующей формулы:

Сначала мы находим скалярное произведение между нормализованным вектором нормали N и нормализованным направлением обзора V. Затем это значение возводится в степень P. Важно отметить. что это лишь аппроксимация эффекта Френеля, но для наших контуров его вполне достаточно.

Fresnel effect.
Эффект Френеля

При наложении на сферу контура, полученного эффектом Френеля, мы видим, что при приближении к углу скольжения (краю объекта) эффект усиливается.

Реализация

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

float edge1 = 1 - _OutlineWidth;
float edge2 = edge1 + _OutlineSoftness;
float fresnel = pow(1.0 - saturate(dot(normalWS, viewWS)), _OutlinePower);
return lerp(1, smoothstep(edge1, edge2, fresnel), step(0, edge1)) * _OutlineColor;

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

Rim effect outline (hard).
Жёсткий контур
Rim effect outline (soft).
Плавный контур

Для этой техники характерно то, что она хорошо работает с объектами наподобие сфер и капсул, имеющих плавные и скруглённые края, но ломается с объектами или более сложными моделями, имеющими острые грани.

Rim effect outline (cube).
Эффект контура на кубе
Rim effect outline (complex model).
Эффект контура на сложной модели

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

Контуры, реализованные эффектом каёмки, просты, но хорошо работают только со сферическими объектами.

Экструдирование вершин

Vertex extrusion outline.
Контур, реализованный экструдированием вершин

Техника

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

Направление экструдирования

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

1. Позиция вершин

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

// Перемещаем вершину вдоль позиции вершины в пространстве объекта
positionOS += positionOS * width;

Так мы «надуваем» объект.

Move along vertex position in object space.

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

// Перемещаем вершину вдоль нормализованной позиции вершины в пространстве объекта
positionOS += normalize(positionOS) * width;

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

Move along normalized vertex position in object space.

Однако из-за того, что мы работаем в пространстве объекта, контур всё равно не имеет идеально равную ширину. Эту проблему мы решим позже.

2. Вектор нормали

Второй способ — перемещение вершин вдоль их вектора нормали.

// Перемещаем вершину вдоль вектора нормали в пространстве объекта
positionOS += normalOS * width;

В результате для объектов с плавными краями наподобие сфер и капсул получается довольно красивый контур. Мы по-прежнему работаем в пространстве объекта, поэтому контур тоже не будет идеально равной ширины.

Move along normal vector in object space.

В случае объектов с более резкими краями, например, для кубов, мы получим заметные зазоры в контуре. У любой модели с резкими краями будут возникать подобные артефакты.

Outline gaps on objects with sharp corners.

Эту проблему можно решить при помощи специально настроенных нормалей из следующего способа.

3. Цвет вершин

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

// Перемещаем вершину вдоль вектора нормали в пространстве объекта
positionOS += vertexColor * width;

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

Blurred buffer outline.
Blurred buffer outline.

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

Пространство экструдирования

Определившись с направлением, в котором мы хотим перемещать вершины, нам нужно выбрать, в каком пространстве координат должно выполняться это экструдирование. На этапе обработки вершин в шейдере координаты вершин изначально определяются в пространстве объекта, а затем преобразуются в пространство clip space. Это выполняется при помощи применения матрицы MVP (model/view/projection). На протяжении всего конвейера рендеринга координаты вершин проходят через эти пространства.

1. пространство объекта/модели/локальное пространство

2. мировое пространство

3. пространство камеры/глаза/обзора

4. clip space (однородное)

5. экранное пространство

6. пространство окна/вьюпорта

Ниже будет объяснена важность этих пространств координат для контуров.

Пространство объекта

Первый способ — это перенос вершин в пространстве объекта.

// Перемещаем вершину вдоль позиции вершины в пространстве объекта
IN.positionOS.xyz += IN.positionOS.xyz * width;

У создания контура в пространстве объекта есть две серьёзные проблемы, связанные с тем, что при работе в пространстве объекта MVP-преобразования ещё не применяются. Эти преобразования меняют форму контура, искажая его в процессе. Проблемы возникают следующие:

  1. Масштабирование контура

    -> при переходе из пространства объекта в мировое пространство (применении матрицы модели M)

  2. Перспективное сокращение

    -> из-за перспективного деления, происходящего, когда мы переходим из clip space в экранное пространство

Blurred buffer outline.

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

Clip space

Второй способ — это выполнение переносов вершин в clip space.

// Преобразуем вершину из пространства объекта в clip space.
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);

// Преобразуем вектор нормали из пространства объекта в clip space.
float3 normalHCS = mul((float3x3)UNITY_MATRIX_VP, mul((float3x3)UNITY_MATRIX_M, IN.normalOS));

// Перемещаем вершину вдоль вектора нормали в clip space.
OUT.positionHCS.xy += normalize(normalHCS.xy) / _ScreenParams.xy * OUT.positionHCS.w * width * 2;

На первом этапе позиция вершины и вектор нормали преобразуются из пространства объекта в clip space. На втором этапе вершина переносится вдоль её вектора нормали. Так как теперь мы работаем в 2D-пространстве, изменяются только координаты x и y позиций вершин. Смещение делится на ширину и высоту экрана, чтобы принять в расчёт соотношение его сторон. Затем смещение умножается на компоненту w позиции вершины в clip space. Это делается, потому что на следующем этапе координаты в clip space будут преобразованы в координаты экранного пространства при помощи так называемого перспективного деления (perspective divide), при котором мы делим координаты x/y/z в clip space на координату w в clip space. Так как мы хотим получить тот же контур после этого преобразования в экранное пространство, то предварительно выполняем умножение на эту координату w в clip space, чтобы перспективное деление никак не повлияло на контур. В конце смещение умножается на нужную ширину контура и коэффициент 2, чтобы единица измерения ширины 1 соответствовала ровно одному пикселю на экране.

Да, сложно!

Рекомендую прочитать пост о создании контура в clip space. Всегда полезно изучить другие объяснения.

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

Move along normal vector in clip space.

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

Move along normal vector in clip space.

Маскирование

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

Cull front.

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

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

Размытый буфер

Blurred buffer outline.

Техника

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

1. Буфер силуэта

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

Blurred buffer outline.
Blurred buffer outline.

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

2. Проход размытия

Проход размытия используется для расширения буфера силуэта. Обычно он реализуется при помощи линейного размытия (box blur) или размытия по Гауссу. Для повышения производительности можно уменьшить масштаб буфера силуэта перед размытием. Это выгодно, потому что проходы размытия могут быть затратными, ведь им приходится обрабатывать множество пикселей на каждый пиксель, чтобы получить (взвешенное) среднее пикселей, окружающий целевой пиксель.

Blurred buffer outline.

Кроме того, проход размытия должен происходить в два прохода. Это снижает сложность алгоритма с O(N2) до O(2N). Это можно сделать, если используемый алгоритм размытия — это так называемый раздельный фильтр, что справедливо и для линейного размытия, и для размытия по Гауссу. При выполнении размытия в два прохода пиксели сначала размываются по вертикали, а затем вертикально размытый буфер размывается по горизонтали.

Blurred buffer outline.
Вертикальное размытие
Blurred buffer outline.
Горизонтальное размытие

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

// Вертикальное линейное размытие
half4 sum = 0;
int samples = 2 * _KernelSize + 1;
for (float y = 0; y < samples; y++)
{
    float2 offset = float2(0, y - _KernelSize);
    sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv + offset * _MainTex_TexelSize.xy);
}
return sum / samples;

// Горизонтальное линейное размытие
half4 sum = 0;
int samples = 2 * _KernelSize + 1;
for (float x = 0; x < samples; x++)
{
    float2 offset = float2(x - _KernelSize, 0);
    sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv + offset * _MainTex_TexelSize.xy);
}
return sum / samples;

Шириной контура управляет параметр _KernelSize шейдера размытия.

3. Проход контура

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

Blurred buffer outline.

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

Blurred buffer outline.

Маскирование

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

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

Алгоритм Jump Flood

Четвёртый способ использует для рендеринга контуров алгоритм Jump Flood. Основное преимущество этой техники в том, что она может рендерить очень широкие контуры при достаточно умеренных затратах производительности. В данном случае я не буду вдаваться в подробности, потому что эта техника хорошо объяснена в статье Бена Голуса.

Контуры, полученные алгоритмом Jump flood — отличный выбор, если вам нужны широкие контуры с высокой производительностью.

Распознавание краёв

Edge detection outline.

Техника

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

Выявление неоднородностей

Перекрёстный оператор Робертса

Выявление неоднородностей можно выполнять при помощи оператора распознавания краёв, например перекрёстного оператора Робертса. Он выполняет роль разностного оператора, вычисляющего сумму квадратов разностей между диагональными пикселями, образующими крестообразный паттерн. На практике операторы распознавания краёв могут применяться свёрткой исходного изображения при помощи ядер. Есть два ядра, по одному для направлений X и Y. В случае перекрёстного оператора Робертса диагональные пиксели сэмплируются и свёртываются при помощи этих ядер. Ядра имеют размер 2x2.

static const int RobertsCrossX[4] = {
    1, 0,
    0, -1
};

static const int RobertsCrossY[4] = {
    0, 1,
    -1, 0
};

Далее эти ядра можно использовать следующим образом.

horizontal += samples[0] * RobertsCrossX[0]; // левый верхний (коэффициент +1)
horizontal += samples[3] * RobertsCrossX[3]; // правый нижний (коэффициент -1)

vertical += samples[2] * RobertsCrossY[2]; // левый нижний (коэффициент -1)
vertical += samples[1] * RobertsCrossY[1]; // правый верхний (коэффициент +1)

edge =  sqrt(dot(horizontal, horizontal) + dot(vertical, vertical));

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

Оператор Собеля

Ещё один способ — использование оператора Собеля. В нём тоже используются два ядра, но на этот раз они имеют размер 3x3.

static const int SobelX[9] = {
    1, 0, -1,
    2, 0, -2,
    1, 0, -1
};

static const int SobelY[9] = {
    1, 2, 1,
    0, 0, 0,
    -1, -2, -1
};

Для целевого пикселя берутся девять сэмплов. Ядра Собеля можно использовать следующим образом.

horizontal += samples[0] * SobelX[0]; // левый верхний (коэффициент +1)
horizontal += samples[2] * SobelX[2]; // правый верхний (коэффициент -1)
horizontal += samples[3] * SobelX[3]; // левый центральный (коэффициент +2)
horizontal += samples[4] * SobelX[4]; // правый центральный (коэффициент -2)
horizontal += samples[5] * SobelX[5]; // левый нижний (коэффициент +1)
horizontal += samples[7] * SobelX[7]; // правый нижний (коэффициент -1)

vertical += samples[0] * SobelY[0]; // левый верхний (коэффициент +1)
vertical += samples[1] * SobelY[1]; // центральный верхний (коэффициент +2)
vertical += samples[2] * SobelY[2]; // правый верхний (коэффициент +1)
vertical += samples[5] * SobelY[5]; // левый нижний (коэффициент -1)
vertical += samples[6] * SobelY[6]; // центральный нижний (коэффициент -2)
vertical += samples[7] * SobelY[7]; // правый нижний (коэффициент -1)

edge = sqrt(dot(horizontal, horizontal) + dot(vertical, vertical));

Если хотите разобраться, как работают фильтры Собеля, то можно прочитать пост о них.

Источники неоднородности

Часто выполняют поиск неоднородностей в текстурах, генерируемых конвейером рендеринга для сцены, например, в текстурах глубин, текстурах нормалей или цвета.

Edge detection outline.
Edge detection outline.
Edge detection outline.

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

Blurred buffer outline result.
Blurred buffer outline result.

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

Модуляция распознавания краёв

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

depthThreshold *= _DepthDistanceModulation * SampleSceneDepth(uv);

Второй распространённый артефакт — нежелательные края, отображаемые на объектах с малыми углами скольжения. Чтобы решить эту проблему, можно выполнить модулирование при помощи маски, сгенерированной из скалярного произведения вектора нормали N и направлением обзора V. Это та же маска Френеля, которая использовалась в первом способе отрисовки контуров.

float fresnel = pow(1.0 - saturate(dot(normalWS, viewWS)), _Power);
float grazingAngleMask = saturate((fresnel + _GrazingAngleMaskPower - 1) / _GrazingAngleMaskPower);
depthThreshold *= 1 + smoothstep(0, 1 - _GrazingAngleMaskHardness, grazingAngleMask);

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

Произвольный источник неоднородностей

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

Blurred buffer outline result.
Blurred buffer outline result.

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

? Дополнительная информация: https://linework.ameye.dev/section-map.

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

Заключение

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

Дополнительные ресурсы

Экструдирование вершин

https://www.videopoetics.com/tutorials/pixel-perfect-outline-shaders-unity

Алгоритм Jump Flood

https://bgolus.medium.com/the-quest-for-very-wide-outlines-ba82ed442cd9

Распознавание краёв

https://roystan.net/articles/outline-shader.html

https://jameshfisher.com/2020/08/31/edge-detection-with-sobel-filters

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


  1. QDeathNick
    14.02.2025 12:57

    Откуда у вас такие картинки, Доктор?