Cel-shading (он же toon shading) — один из примеров нефотореалистичного рендеринга, используемый в фильмах и компьютерных играх для создания т.н. «мультяшной графики». Наиболее известные игры, в которых была применена эта технология: The Legend of Zelda, Prince of Persia, Borderlands:
Вообще говоря, строгого описания как реализовывать эту технологию не существует, и в каждом проекте она реализовывается по своему. Я бы выделил три основные блока, встречающиеся так или иначе в каждом проекте, использующем мультяшный рендеринг:
В данной статье мы коснёмся только двух последних пунктов: подготовка моделей не относится к программирования и лежит в зоне ответственности гейм-дизайнера (режиссёра) и контент менеджеров. Как и в других статьях по компьютерной графике на этом сайте мы будем использовать 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 текстур. Примеры таких текстур приведены ниже. Результат функции Ламберта в данном случае используется как текстурная координата.
Таким образом, если угол между нормалью и направлением на источник света равен нулю (точка освещена максимально), то ф-я Ламберта будет равна 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
. Данная функция принимает три аргумента (нижнюю и верхнюю границу и основной параметр) и работает следующим образом:
В нашем случае, если разница между нижней и верхней границей будет маленькой, то мы получим блик с очень узкими, но всё же «мягкими» краями. Ниже приводится фрагмент шейдера, добавляющий бликовую составляющую, а также скриншоты с бликовой составляющей отдельно (для наглядности) и вместе со ступенчатой функцией освещения.
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;
Ещё один эффект, используемый при реализации мультяшного рендеринга — подсветка границ объектов. С точки зрения графики реализовать его достаточно просто: мы возьмём значение, равное косинусу угла между нормалью к поверхности и вектором взгляда. Очевидно, что значение это будет меняться плавно и менять свой знак на той части модели, которая в данный момент не видна (обратная сторона объекта).
Использование функции 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"
}
Последнее, о чём хотелось бы поговорить в этой статье — это пост обработка получившегося в результате работы модели освещения кадра. При реализации 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 — самые близкие).
Финальный скриншот - модель освещения и обводка: