Оутлайн через постпроцессинг.

Введение.

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

Глубина контура.

Мы начинаем с шейдера и скрипта C# из урока постпроцесингу с нормалями.
Первые изменения, которые мы делаем, это удаляем свойства и переменные, которые были специфичны для шейдера «цвет сверху». Т.е. значение отсечки и цвета. Мы также удаляем матрицу преобразования из пространства камеры в мировое пространство, потому что наши контуры не имеют определенного вращения в мире, поэтому мы можем его игнорировать. Затем мы удаляем весь код после той части, где мы вычисляем глубину и нормали.
// показываем значения для редактирования в инспекторе
Properties{
    [HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}
//фрагентный шейдер
fixed4 frag(v2f i) : SV_TARGET{
    // читаем глубину/нормаль
    float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

    //декодируем глубину / нормаль
    float3 normal;
    float depth;
    DecodeDepthNormal(depthnormal, depth, normal);

    // получаем глубину как расстояние от камеры
    depth = depth * _ProjectionParams.z;
}
Затем мы удаляем часть, где мы записываем матрицу камеры в шейдер из нашего сценария C#.
// метод который автоматически вызывается Unity после того как камера все отрендерила
private void OnRenderImage(RenderTexture source, RenderTexture destination){
    // рисуем пиксели из текстуры источника в текстуру приемник
    Graphics.Blit(source, destination, postprocessMaterial);
}
То, как мы собираемся рассчитывать контуры, состоит в том, что мы будем читать из нескольких пикселей вокруг пикселя, который мы обсчитываем, и вычисляем разницу в глубине и нормалях к центральному пикселю. Чем они сильнее отличаются друг от друга, тем сильнее контур.
Чтобы вычислить положение соседних пикселей, нам нужно знать, насколько большой пиксель. К счастью, мы можем просто добавить переменную с определенным именем, и Unity сообщит нам размер. Поскольку технически мы работаем с пикселями текстуры (текселями), это называется размером текселя.
Мы можем просто создать переменную texturename_TexelSize для любой текстуры и получить размер текселя.
//Текстура глубины/нормалей
sampler2D _CameraDepthNormalsTexture;
//размер текселя текстуры
float4 _CameraDepthNormalsTexture_TexelSize;
Затем мы скопируем код для доступа к глубине и нормалям, но изменим имена, и получим пиксель из текстуры по координатам сдвинутым вправо.
//читаем соседний пиксель
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture, 
        uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;
Теперь, когда у нас есть два образца, мы можем вычислить разницу и нарисовать ее на экране.
float difference = depth - neightborDepth;
return difference;

Мы уже видим контуры в левой части объектов. Прежде чем перейти к следующему образцу, я хотел бы поместить код для чтения образца и сравнения его с центральным значением в отдельную функцию, чтобы нам не нужно было писать его 4 раза. Эта функция нуждается в глубине центрального пикселя, координатах uv центрального пикселя и смещении в качестве аргументов. Мы будем определять смещение в пикселях.
Мы просто копируем код из нашей фрагментной функции в новый метод и заменяем имена глубины и uv именами аргументов. Чтобы использовать смещение, мы умножаем его на координаты x и y размера текселя, а затем добавляем результат к координатам uv, как и ранее.
После того как мы настроим новый метод, мы вызываем его в фрагментной функции и выводим результат на экран.
void Compare(float baseDepth, float2 uv, float2 offset){
    // читаем соседний пиксель
    float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture, 
            uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
    float3 neighborNormal;
    float neighborDepth;
    DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
    neighborDepth = neighborDepth * _ProjectionParams.z;

    return baseDepth - neighborDepth;
}
…
    float depthDifference = Compare(depth, i.uv, float2(1, 0));

    return depthDifference;
}
Результат должен выглядеть так же, как и раньше, но теперь проще расширить шейдер, чтобы читать образцы в нескольких направлениях. Аналогично мы выбираем пиксели вверх, вправо и вниз и добавляем результаты всех образцов вместе.
// фрагментный шейдер
fixed4 frag(v2f i) : SV_TARGET{
    // читаем глубину/нормаль
    float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

    // декодируем глубину/нормаль
    float3 normal;
    float depth;
    DecodeDepthNormal(depthnormal, depth, normal);

    // получаем глубину как расстояние от камеры
    depth = depth * _ProjectionParams.z;

    float depthDifference = Compare(depth, i.uv, float2(1, 0));
    depthDifference = depthDifference + Compare(depth, i.uv, float2(0, 1));
    depthDifference = depthDifference + Compare(depth, i.uv, float2(0, -1));
    depthDifference = depthDifference + Compare(depth, i.uv, float2(-1, 0));

    return depthDifference;
}

Нормали контура.

Использование глубины уже дает нам довольно хорошие очертания, но мы можем пойти дальше, используя также предоставленные нам нормали. Нормали мы также будем выбирать в нашей функции сравнения. Однако функция может возвращать только одно значение в hlsl, поэтому мы не можем использовать возвращаемое значение здесь. Вместо использования возвращаемого значения мы можем добавить два новых аргумента с ключевым словом inout. С помощью этого ключевого слова значение, которое мы передаем в функцию, может быть перезаписано в ней, и изменения применяются к версии переданной в функцию переменной, а не только к ее версии в функции. Еще одна вещь, которая нам нужна для создания контуров из нормалей, - это контур центрального пикселя, поэтому мы добавляем это также к списку наших аргументов.
void Compare(inout float depthOutline, inout float normalOutline, 
    float baseDepth, float3 baseNormal, float2 uv, float2 offset){
После того, как мы изменили это, вернемся к фрагментной функции, создадим новую переменную для разницы нормалей и изменим способ, которым мы вызываем метод сравнения, чтобы соответствовать нашим новым аргументам.
void Compare(inout float depthOutline, inout float normalOutline, 
        float baseDepth, float3 baseNormal, float2 uv, float2 offset){
    // читаем соседний пиксель
    float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture, 
            uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
    float3 neighborNormal;
    float neighborDepth;
    DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
    neighborDepth = neighborDepth * _ProjectionParams.z;

    float depthDifference = baseDepth - neighborDepth;
    depthOutline = depthOutline + depthDifference;
}
float depthDifference = 0;
float normalDifference = 0;

Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));

return depthDifference;
Это не изменит выход метода, но новая архитектура позволяет нам также измерять разницу в нормалях. Легкий и быстрый способ сравнить два нормализованных вектора - взять dot продукт. Проблема dot продукт заключается в том, что когда векторы указывают в одном и том же направлении, он равен 1, а когда векторы отходят друг от друга, он становится меньше. Это противоположно тому, что мы хотим. Исправление состоит в том, чтобы вычесть dot продукт из 1. Тогда, когда результатом dot произведения является 1, общий результат равен 0, а когда результат dot произведения становится ниже, общий результат увеличивается. После того, как мы вычислим нормальную разницу, мы добавим ее в общую разницу, и изменим результат, чтобы показать разницу нормалей.
float3 normalDifference = baseNormal - neighborNormal;
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
normalOutline = normalOutline + normalDifference;
return normalDifference;

С этими изменениями мы можем видеть контуры, но они отличаются от тех что у нас были раньше, потому что они генерируются из нормалей вместо глубины. Затем мы можем объединить два контура для создания комбинированного контура.
return depthDifference + normalDifference;

Настройка контура.

Следующий шаг - сделать контуры более настраиваемыми. Чтобы сделать это, мы добавляем две переменные для контуров глубины и нормалей. Множитель, чтобы контуры выглядели сильнее или слабее, и смещение, которое может сделать серее те части контуров, которые мы, возможно, не захотим потерять.
// показываем значения для редактирования в инспекторе
Properties{
    [HideInInspector]_MainTex ("Texture", 2D) = "white" {}
    _NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
    _NormalBias ("Normal Outline Bias", Range(1,4)) = 1
    _DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
    _DepthBias ("Depth Outline Bias", Range(1,4)) = 1
}
// переменные для настройки эффекта
float _NormalMult;
float _NormalBias;
float _DepthMult;
float _DepthBias;
Чтобы использовать переменные, после получения всех разностей пикселей, мы просто умножаем разностные переменные на множители, затем ограничиваем их между 0 и 1 и передаем разницу в степень смещения. Ограничение между 0 и 1 важно, потому что в противном случае показатель экспоненты отрицательного числа может привести к неверным результатам. У HLSL есть собственная функция для фиксации переменной между 0 и 1, называемая «saturate».
depthDifference = depthDifference * _DepthMult;
depthDifference = saturate(depthDifference);
depthDifference = pow(depthDifference, _DepthBias);

normalDifference = normalDifference * _NormalMult;
normalDifference = saturate(normalDifference);
normalDifference = pow(normalDifference, _NormalBias);

return depthDifference + normalDifference;
С этим вы теперь можете немного настроить свои контуры в инспекторе - я немного увеличил контуры как нормалей, так и глубины, и уменьшил шум, увеличив смещение, но лучше всего поиграть с настройками и посмотреть, что лучше подходит для вашей сцены.

Наконец, мы хотим добавить наши контуры на сцену, а не просто иметь их как отдельную вещь. Для этого мы сначала объявляем цвет контура как свойство и переменную шейдера.
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
float4 _OutlineColor;
Чтобы применить контуры, в конце фрагментной функции, мы читаем исходную текстуру и выполняем линейную интерполяцию от исходного цвета к нашему контурному цвету через комбинированный контур, таким образом, пиксели, которые ранее были черными, теперь являются исходным цветом а белые имеют контурный цвет.
float outline = normalDifference + depthDifference;
float4 sourceColor = tex2D(_MainTex, i.uv);
float4 color = lerp(sourceColor, _OutlineColor, outline);
return color;

Основными недостатками постпроцессных контуров являются то, что вы должны применять их ко всем объектам в сцене. Как система решает, что контур, а что нет, может не соответствовать стилю, который вы имеете в виду, и вы получаете артефакты алиасинга (видимые «лесенки») довольно быстро.
Хотя нет никаких простых исправлений для первых двух проблем, вы можете смягчить последний из них, используя сглаживание в вашей постобработке, например, FXAA или TXAA (стеки постобработки Unity предоставляют их вам, но если вы используете v2, вам придется переделать эффект на эффект в стеке).
Еще один важный момент, который следует иметь в виду, заключается в том, что вам нужно использовать модели, которые соответствуют этому способу построения контуров - если вы добавите слишком много деталей в свою геометрию, эффект будет рисовать большую часть ваших объектов черным, что, вероятно, не является предполагаемым поведением.

Исходники.

Shader "Tutorial/019_OutlinesPostprocessed"
{
    //показываем значения для редактирования в инспекторе
    Properties{
        [HideInInspector]_MainTex ("Texture", 2D) = "white" {}
        _OutlineColor ("Outline Color", Color) = (0,0,0,1)
        _NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
        _NormalBias ("Normal Outline Bias", Range(1,4)) = 1
        _DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
        _DepthBias ("Depth Outline Bias", Range(1,4)) = 1
    }

    SubShader{
        // Показываем что нам не нужен кулинг и сравнение/запись в буфер глубины
        Cull Off
        ZWrite Off 
        ZTest Always

        Pass{
            CGPROGRAM
            // включаем полезные фугкции
            #include "UnityCG.cginc"

            // определяем вершинный и фрагментный шейдеры
            #pragma vertex vert
            #pragma fragment frag

            // текстура для визуализации экрана
            sampler2D _MainTex;
            // текстура глубины/нормалей
            sampler2D _CameraDepthNormalsTexture;
            //размер текселя текстуры глубины/нормалей
            float4 _CameraDepthNormalsTexture_TexelSize;

            //переменный для настройки эффекта
            float4 _OutlineColor;
            float _NormalMult;
            float _NormalBias;
            float _DepthMult;
            float _DepthBias;

            // данные передаваемые в вершинный шейдер
            struct appdata{
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            // данные передаваемые в фрагментный шейдер
            struct v2f{
                float4 position : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            // вершинный шейдер
            v2f vert(appdata v){
                v2f o;
                //конвертируем позицию из пространства объекта в пространство экрана
                o.position = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            void Compare(inout float depthOutline, inout float normalOutline, 
                    float baseDepth, float3 baseNormal, float2 uv, float2 offset){
                //читаем соседний пиксель
                float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture, 
                        uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
                float3 neighborNormal;
                float neighborDepth;
                DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
                neighborDepth = neighborDepth * _ProjectionParams.z;

                float depthDifference = baseDepth - neighborDepth;
                depthOutline = depthOutline + depthDifference;

                float3 normalDifference = baseNormal - neighborNormal;
                normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
                normalOutline = normalOutline + normalDifference;
            }

            //фрагментный шейдер
            fixed4 frag(v2f i) : SV_TARGET{
                // читаем глубину/нормаль
                float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

                //декодируем глубину/нормаль
                float3 normal;
                float depth;
                DecodeDepthNormal(depthnormal, depth, normal);

                // получаем глубину как расстояние от камеры 
                depth = depth * _ProjectionParams.z;

                float depthDifference = 0;
                float normalDifference = 0;

                Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
                Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
                Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
                Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));

                depthDifference = depthDifference * _DepthMult;
                depthDifference = saturate(depthDifference);
                depthDifference = pow(depthDifference, _DepthBias);

                normalDifference = normalDifference * _NormalMult;
                normalDifference = saturate(normalDifference);
                normalDifference = pow(normalDifference, _NormalBias);

                float outline = normalDifference + depthDifference;
                float4 sourceColor = tex2D(_MainTex, i.uv);
                float4 color = lerp(sourceColor, _OutlineColor, outline);
                return color;
            }
            ENDCG
        }
    }
}
using UnityEngine;
using System;

//скрипт котоый висит на камеры
public class OutlinesPostprocessed : MonoBehaviour {
    // материал который используется для постпроцесинга
    private Material postprocessMaterial;

    private Camera cam;

    private void Start(){
        // получаем камеру и говорим ей визуализировать текстуру глубины/нормалей
        cam = GetComponent();
        cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
    }

    // метод, который автоматически вызывается Unity после камера закончила визуализацию
    private void OnRenderImage(RenderTexture source, RenderTexture destination){
        // рисуем пиксели их текстуры-источника в текстуру приемник
        Graphics.Blit(source, destination, postprocessMaterial);
    }
}
Надеюсь, я смог показать вам, как добавить приятные контуры в вашу игру и как они работают.



Перевод Беляев В.А. ака seaman.

Комментариев нет:

Отправить комментарий