Мультяшный рендеринг

Введение

Cel-shading (он же toon shading) — один из примеров нефотореалистичного рендеринга, используемый в фильмах и компьютерных играх для создания т.н. «мультяшной графики». Наиболее известные игры, в которых была применена эта технология: The Legend of Zelda, Prince of Persia, Borderlands:

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

  • Подготовка моделей
  • Модель освещения (shading)
  • Пост обработка (post-processing)

В данной статье мы коснёмся только двух последних пунктов: подготовка моделей не относится к программирования и лежит в зоне ответственности гейм-дизайнера (режиссёра) и контент менеджеров. Как и в других статьях по компьютерной графике на этом сайте мы будем использовать Unity Engine, чтобы не тратить время на миллион раз решённые вопросы типа загрузки текстур и получения карты глубины сцены.

Модель освещения

Итак, начнём с начала. Давайте рассмотрим самую базовую модель освещения, применяемую в компьютерной графике: модель Ламберта. Как известно, принцип расчёта освещённости точки очень простой: чем больше угол между нормалью в точке и направлением из точки на источник света, тем хуже освещена данная точка поверхности.

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

Развернуть
fixed4 frag(fragment_data i) :COLOR
{
    float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
    float3 N = normalize(i.normal);

    float lambert = max(dot(L, N) + _Ambient, 0.0);
    float3 color = tex2D(_DiffTex, i.uv);

    return float4(color * lambert, 1.0);
}
Исходная сцена
(освещение по Ламберту)

Более подробно про модель Ламберта и другие базовые модели освещения можно почитать тут.

Ступенчатая функция освещения

Модель Ламберта даёт нам гладкую функцию освещения (т.к. собственно, косинус, лежащий в её основе — гладкая функция). Это — первое, что нам нужно поменять: для cel-shading'а характерно ступенчатое освещение (когда явно видимы переходы между уровнями освещённости).

Модифицируем модель Ламберта, добавив константу, отвечающую за количество ступеней steps. Рассчитаем, как будет меняться освещённость с каждым шагом step_size, и какой уровень освещённости brightness_level соответствует обрабатываемой точке поверхности.

Развернуть
fixed4 frag(fragment_data i): COLOR
{
    float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
    float3 N = normalize(i.normal);

    float lambert = pow(max(dot(L, N) + _Ambient, 0.0), 0.8);

    int steps = 3;
    float step_size = 1.0 / steps;
    int brightness_level = round(lambert / step_size);
    float result = brightness_level * step_size;

    float3 color = tex2D(_DiffTex, i.uv);
    return float4(color * result, 1.0);
}

Здесь и далее все параметры заданы в прямо в коде для наглядности.

В production коде они, разумеется, должны быть вынесены в настройки шейдера.

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

Использование LUT текстур

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

Таким образом, если угол между нормалью и направлением на источник света равен нулю (точка освещена максимально), то ф-я Ламберта будет равна 1 и мы будем брать в качестве значения освещённости крайнюю правую точку LUT текстуры.

Если же нормаль в точке поверхности перпендикулярна направлению на источник света (точка освещена максимально), то ф-я Ламберта будет равна 0 и мы будем брать в качестве значения освещённости крайнюю левую точку LUT текстуры.

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

Развернуть
fixed4 frag(fragment_data i): COLOR
{
    float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
    float3 N = normalize(i.normal);

    float lambert = max(dot(L, N), 0.0);
    float result = tex2D(_LUTTex, float2(lambert, 0.5));

    float3 color = tex2D(_DiffTex, i.uv);
    return float4(color * lambert, 1.0);
}

Использование LUT текстур имеет немаловажный плюс: настраивать освещение можно не модифицируя исходный код.

Добавление бликовой составляющей

Добавим к нашей модели освещения блики. Бликовую составляющую вы возьмём из модели Фонга, но, опять же, модифицируем. В модели Фонга блик, рассчитываемый по углу между вектором взгляда и отражённым относительно нормали «вектором света», получается гладкий (плавный, мягкий, как угодно).

Для наших целей блики нужны более жёсткие, поэтому мы воспользуемся встроенной в GLSL функцией smoothstep. Данная функция принимает три аргумента (нижнюю и верхнюю границу и основной параметр) и работает следующим образом:

  • Если основной параметр меньше нижней границы, то возвращается 0
  • Если основной параметр больше верхней границы, то возвращается 1
  • Если основной параметр между нижней и верхней границей, то возвращается значение от 0 до 1 (происходит плавная, нелинейная интерполяция.

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

Развернуть
float3 V = normalize(_WorldSpaceCameraPos - i.pos_world); // вектор взгляда
float3 R = reflect(-L, N); // отражённый вектор света

int specularPower = 100;
float phongSpec = pow(saturate(dot(R, V)), specularPower);
float specularSmooth = smoothstep(0.005, 0.01, phongSpec);
result += specularSmooth;

Подсветка границ (rim lighting)

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

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

Развернуть
float4 rim = 1 - dot(V, N);
float rimAmount = 0.8;
float rimIntensity = smoothstep(rimAmount - 0.01, rimAmount + 0.01, rim);
result += rimIntensity;

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

Итоговая версия шейдера

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

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

Развернуть
Shader "karonator/Shading/Toon"
{
    Properties
    {
        _DiffTex("Diffuse texture", 2D) = "white" {}

        _StepsCount("Steps count", Int) = 4
        _LUTTex("Toon LUT texture", 2D) = "white" {}

        _Ambient("Ambient coeff", Range(0, 1)) = 0.1
        _RimAmount("Rim Amount", Range(0, 1)) = 0.8

        _SpecMin("Specular range min", Range(0, 1)) = 0.02
        _SpecMax("Specular range max", Range(0, 1)) = 0.07
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardAdd" }
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _DiffTex;

            int _StepsCount;
            sampler2D _LUTTex;

            float _Ambient;
            float _RimAmount;

            float _SpecMin;
            float _SpecMax;

            struct vertex_data
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL0;
                float2 uv: TEXCOORD0;
            };

            struct fragment_data
            {
                float4 pos: POSITION;
                float4 pos_world: TEXCOORD1;

                float3 normal: NORMAL0;
                float2 uv : TEXCOORD0;
            };

            fragment_data vert(vertex_data v)
            {
                fragment_data o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.pos_world = mul(unity_ObjectToWorld, v.vertex);

                o.uv = v.uv;
                o.normal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz);
                return o;
            }

            fixed4 frag(fragment_data i) :COLOR
            {
                float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
                float3 N = normalize(i.normal);
                float3 V = normalize(_WorldSpaceCameraPos - i.pos_world);
                
                float lambert = max(dot(L, N) + _Ambient, 0.0);

                // stepped lambert shading (variant 1)
                float step_size = 1.0 / _StepsCount;
                float result = round(lambert / step_size) * step_size;

                // using lut texture (variant 2)
                // float result = tex2D(_LUTTex, float2(lambert, 0.5));

                // specular
                float3 R = reflect(-L, N);
                float spec = pow(saturate(dot(R, V)), 100);
                float specularSmooth = smoothstep(_SpecMin, _SpecMax, spec);
                result += specularSmooth;

                // rim
                float4 rim = 1 - dot(V, N);
                float rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rim);
                result += rimIntensity;

                float3 color = tex2D(_DiffTex, i.uv);
                return float4(color * result, 1.0);
            }

            ENDCG
        }
    }

    Fallback "VertexLit"
}

Post-processing и результат

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

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

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

Всё что нам нужно сделать — это применить оператор Собеля к карте глубины (получим границы объектов) и добавить эти границы к нашему кадру.

Для получения карты глубины мы воспользуемся встроенной в Unity переменной _CameraDepthTexture. Оператор Собеля считается следующим образом: для каждой точки изображения с использованием окружающих точек считаются две компоненты Gx и Gy с использованием в качестве коэффициентов следующих матриц:

В приведённых ниже формулах A — значение цвета в обрабатываемой точке изображения.

После получения компонент Gx и Gy рассчитываем значение оператора для нашей точки по формуле:

Думаю что если объяснение выше не совсем понятно, стоит посмотреть код шейдера пост обработки - на самом деле всё несколько проще чем звучит:

Развернуть
fixed4 frag (v2f inp): SV_Target
{
    float3x3 KernelX = { -1, 0, 1, -2, 0, 2, -1, 0, 1 };
    float3x3 KernelY = { -1, -2, -1, 0, 0, 0, 1, 2, 1 };

    float dx = 1.0 / _ScreenParams.x;
    float dy = 1.0 / _ScreenParams.y;

    float GX = 0;
    float GY = 0;

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            float2 tex_coord = float2(inp.uv.x + (i - 1) * dx, inp.uv.y + (j - 1) * dy);

            float depth = tex2D(_CameraDepthTexture, tex_coord).r;
            depth = Linear01Depth(depth);
            
            GX += KernelX[i][j] * depth;
            GY += KernelY[i][j] * depth;
        }
    }
    float sobel_result = sqrt(pow(GX, 2) + pow(GY, 2));

    float3 color = tex2D(_MainTex, inp.uv);
    return float4(color * (1.0 - sobel_result), 1.0);
}

Обратите внимание, что перед тем как применить оператор мы не просто берём значение из карты глубины, но делаем это с использованием функции Linear01Depth, которая возвращает значение от 0 до 1 (где 1 — самые удалённые части сцены, 0 — самые близкие).

Финальный скриншот - модель освещения и обводка:

Ссылки и файлы

Cel shading
Toon shading