Cel‑shading: некоторые приёмы, о которых вы могли не знать

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

Cel‑shading: некоторые приёмы, о которых вы могли не знать

Для демонстрации я буду использовать персонажа из своего хобби-проекта. Если вы хотите повторять все шаги из статьи, вам понадобится модель с готовой развёрткой и diffuse-текстурой.

Ключевые понятия

Для тех, кто не очень знаком с понятием цел-шейдинга, определение из Википедии.

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

Википедия

Итак, ключевые моменты цел-шейдинга:

  • плоская заливка светом;
  • жёсткие границы светотени (отсутствие плавных градиентов);
  • наличие контура.

Начинаем писать шейдер

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

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

Итак, открываем Unity и создаем Unlit Shader…

Базовая реализация

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

// make fog work #pragma multi_compile_fog UNITY_FOG_COORDS(1) UNITY_TRANSFER_FOG(o,o.vertex); // apply fog UNITY_APPLY_FOG(i.fogCoord, col);

Создаем на основе шейдера материал, назначаем текстуру и применяем материал к нашей модели.

​Получили плоскую заливку без освещения
​Получили плоскую заливку без освещения

Backface culling

У нашего персонажа имеются односторонние поверхности, например волосы и набедренная повязка. Они корректно отображаются с внешней стороны, но невидимы с внутренней. Давайте это исправим и сделаем наш материал двухсторонним. Для этого нужно добавить Cull Off в начале Pass. Должно получиться вот так.

Pass { Cull Off // Добавляем эту строку CGPROGRAM #pragma vertex vert #pragma fragment frag
​Слевы — было, справа — стало. Теперь можно видеть тыльную сторону меша
​Слевы — было, справа — стало. Теперь можно видеть тыльную сторону меша

Свет и тень

Наша модель будет учитывать освещение только от одного основного направленного источника света (Directional Light). Чтобы избежать лишних вычислений на графическом процессоре, добавим Pass Tags, которые будут накладывать описанное выше ограничение.

Pass { Tags { "LightMode" = "ForwardBase" "PassFlags" = "OnlyDirectional" } Cull Off

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

Properties { _MainTex ("Texture", 2D) = "white" {} _ShadowStrength ("Shadow Strength", Range(0, 1)) = 0.5 // Добавляем

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

​По умолчанию поверхность в тени будет на 50% темнее, чем поверхность на свету.
​По умолчанию поверхность в тени будет на 50% темнее, чем поверхность на свету.

Также надо добавить эту переменную в тело CGPROGRAM.

sampler2D _MainTex; float4 _MainTex_ST; float _ShadowStrength; // Добавляем эту строку

Чтобы определить, какая часть поверхности будет в тени, а какая на свету, нам нужно знать направление нормали поверхности. Unity предоставляет нам эту информацию, но чтобы получить её, нужно «сказать» об этом в appdataи в v2f struct.

struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; // Добавляем эту строку }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : NORMAL; // Добавляем эту строку };

Добавим в vert функцию преобразование нормали поверхности из локальных координат в мировые:

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); // Добавляем эту строку return o; }

Теперь добавим в функцию fragкод, который будет считать затенение.

fixed4 frag (v2f i) : SV_Target { // Нормализуем вектор нормали, чтобы его длина равнялась 1 float3 normal = normalize(i.worldNormal); // Считаем Dot Product для нормали и направления к источнику света // _WorldSpaceLightPos0 - встроенная переменная Unity float NdotL = dot(_WorldSpaceLightPos0, normal); // Cчитаем интенсивность света на поверхности // Если поверхность повернута к источнику света (NdotL > 0), // то она полностью освещена. // В противном случае учитываем Shadow Strength для затенения float lightIntensity = NdotL > 0 ? 1 : _ShadowStrength; // sample the texture fixed4 col = tex2D(_MainTex, i.uv); // Применяем затенение col *= lightIntensity; return col; }

Результат.

​Слева — без затенения, справа — с затенением
​Слева — без затенения, справа — с затенением

Степень затенения можно регулировать слайдером. В моём проекте персонаж всегда освещается одинаково, по этому я не учитываю цвет источника света, а также ambient-освещение. Если вам интересно, как реализовать добавление цвета источника света, ambient, а также контурный свет, рекомендую это статью (на английском).

Добавляем контур

Контур также можно реализовать несколькими способами. Самые распространённые — post-effect и inverted hull.

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

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

Properties { _MainTex ("Texture", 2D) = "white" {} _ShadowStrength ("Shadow Strength", Range(0, 1)) = 0.5 _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.01 // Добавляем }

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

Добавляем следующий код сразу после закрытия фигурной скобки первого Pass.

Pass { // Скрываем полигоны, повернутые к камере Cull Front CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 vertex : SV_POSITION; }; // Объявляем переменные half _OutlineWidth; static const half4 OUTLINE_COLOR = half4(0,0,0,0); v2f vert (appdata v) { // Смещаем вершины по направлению нормали на заданное расстояние v.vertex.xyz += v.normal * _OutlineWidth; v2f o; o.vertex = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag () : SV_Target { // Все пиксели контура имеют один и тот же цвет return OUTLINE_COLOR; } ENDCG }

Результат.

Cel‑shading: некоторые приёмы, о которых вы могли не знать

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

Исправляем контур

При более близком рассмотрении можно заметить множество артефактов, созданных контуром.

​Тысячи их...
​Тысячи их...

В основном все артефакты связаны с тем, что контур «залезает» на основной силуэт. При этом внешний контур получился достаточно неплохо. Давайте сделаем так, чтобы контур рисовался только снаружи основного силуэта и никогда не «залезал» внутрь. Для этого используем Stencil buffer.

Добавьте следующий код в первый Pass, в котором мы рисуем основной силуэт, между Cull Off и CGPROGRAM.

Stencil { Ref 1 Comp Always Pass Replace }

По умолчанию, значение Stencil для всех пикселей равно 0. Код, который мы написали, сравнивает значение пикселя со значением Ref, в данном случае с 1.

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

Pass Replace означает, что если проверка прошла успешно, то значение Stencil для данного пикселя заменяется значением Ref.

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

Теперь добавим следующий код во второй Pass, который рисует контур. При этом заменим Cull Front на Cull Off, чтобы в формировании силуэта принимали участие все полигоны, а не только те, которые отвернуты от камеры.

Cull Off Stencil { Ref 1 Comp Greater }

Таким образом, будут рендериться только те пиксели, для которых значение Ref (в нашем случае 1), больше, чем значение в буфере. Для пикселей основного силуэта мы присвоили значение 1, а значит проверка на «больше» не пройдёт и пиксели отображаться не будут.

​Слева — без Stencil, справа — со Stencil
​Слева — без Stencil, справа — со Stencil

Теперь контур выглядит гораздо чище и не создает артефактов внутри основного силуэта.

Но есть ещё одна проблема.

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

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

​Пример изменения толщины контура, в зависимости от угла наклона поверхности VIDEO POETICS
​Пример изменения толщины контура, в зависимости от угла наклона поверхности VIDEO POETICS

Обе проблемы решаются переносом рассчета контура в clip space(пространство экрана).

Заменим код в фунции vert на следующий.

v2f vert (appdata v) { // Конвертируем положение и нормаль вертекса в clip space float4 clipPosition = UnityObjectToClipPos(v.vertex); float3 clipNormal = mul((float3x3) UNITY_MATRIX_VP, mul((float3x3) UNITY_MATRIX_M, v.normal)); // Считаем смещение вершины по направлению нормали. // Также учитываем перспективное искажение и домножаем на компонент W, // чтобы сделать смещение постоянным, // вне зависимости от расстояния до камеры float2 offset = normalize(clipNormal.xy) * _OutlineWidth * clipPosition.w; // Т.к. рассчет теперь ведется в пространстве экрана, // надо учитывать соотношение сторон // и сделать толщину контура постоянной при любом aspect ratio. // _ScreenParams - встроенная переменная Unity float aspect = _ScreenParams.x / _ScreenParams.y; offset.y *= aspect; // Применяем смещение clipPosition.xy += offset; v2f o; o.vertex = clipPosition; return o; }

Теперь контур всегда одинаковой толщины не зависимо от того, на каком расстоянии от камеры находится объект.

Если Вам интересно узнать больше о технике Inverse hull outline, какие могут возникнуть проблемы, как их решить, а также как сделать pixel perfect outline рекомендую эту статью (опять же, на английском).

Корректируем затенение

У нашего персонажа есть затенённые зоны, но они выглядят очень плоско и, из-за невысокой детализации меша, не подчёркивают некоторые детали. Также отсутствует эффект ambient occlusion (дополнительное затенение в тех местах, куда трудно проникает свет).

Чтобы модель выглядела более реалистично, нужно добавить тени:

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

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

Такой подход заметно «удорожает» процесс разработки и при этом не решает проблему отсутствия ambient occlusion. И что делать, если мы не хотим использовать дополнительные ресурсы и дополнительные текстуры, чтобы сэкономить память системы?

Используем Vertex Color

Альтерантивным решением будет использование цвета вершин (vertex color). Основу этой техники я подсмотрел в докладе на GDC, в котором технический художник Arc System Works рассказал о пайплайне создания персонажей для Guilty Gear. Всем, кому интересен процесс создания стилизованных персонажей, смотреть обязательно.

Итак, для каждой вершины меша у нас есть 3 цветовых канала, которые могут принимать значение от 0 до 1 и в которые мы можем закодировать определённые данные.

Для себя я выбрал следующее.

  • КРАСНЫЙ — отвечает за затенение. Значение по умолчанию 0,5. Чем ниже значение, тем быстрее поверхность уйдёт в тень, даже если нормаль всё ещё «смотрит» в направлении источника света. При значении равном 0, поверхность в этой точке ВСЕГДА будет в тени, даже если на неё падает прямой луч света. Если значение выше 0,5, то поверхность в этой точке будет оставаться освещённой, даже если нормаль уже «отвернулась» от источника света. При значении равном 1, поверхность будет всегда освещена, даже если она отвернута от источника света на 180 градусов.
  • ЗЕЛЁНЫЙ— отвечает за размер блика. Значение по умолчанию 0. Про то, как добавить блик, расскажу чуть позднее. В целом, если значение равно 0, то блик отсутствует. Значения между 0 и 1 задают размер блика (чем значение больше, тем блик меньше).
  • СИНИЙ — отвечает за толщину контура. Значение по умолчанию 1. Если мы хотим сделать контур более тонким для некоторых частей персонажа (например, на тонких объектах типа пальцев или волос, чтобы не терять детализацию) мы можем задать синему каналу значение 0,5 и тогда контур в этой области будет в 2 раза тоньше. Этот параметр может сделать контур тоньше или вообще убрать его (если значение 0), но не может сделать его толще.

В теории, можно ещё использовать альфа-канал, но я пока не нашел ему хорошего применения, мне достаточно трёх.

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

Мой персонаж после покраски выглядит так.

Cel‑shading: некоторые приёмы, о которых вы могли не знать

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

Теперь нужно добавить поддержку vertex color в наш шейдер.

Добавим соответствующие строки в appdata, v2fи vert.

struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; half4 color : COLOR; // Добавляем эту строку }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : NORMAL; half4 color : COLOR; // Добавляем эту строку }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.color = v.color; // Добавляем эту строку return o; }

Теперь обновляем код для фунцкции frag:

fixed4 frag (v2f i) : SV_Target { // Нормализуем вектор нормали, чтобы его длина равнялась 1 float3 normal = normalize(i.worldNormal); // Считаем Dot Product для нормали и направления к источнику света // _WorldSpaceLightPos0 - встроенная переменная Unity float NdotL = dot(_WorldSpaceLightPos0, normal); // Пересчитываем NdotL, чтобы он был в диапазоне от 0 до 1, // чтобы сравнивать его со значением красного канала float NdotL01 = NdotL * 0.5 + 0.5; // Т.к. пороговое значение для затенения теперь может быть разным // для разных пикселей, мы рассчитываем маску, // по которой будем затенять пиксели. // Используем step функцию и красный канал в качестве порогового значения. // "1 - step" инвертирует маску. По умолчанию у нас 1 в освещенной зоне, // а 0 в затенённой. Нам же надо иметь 1 в затенённой зоне (маска). half shadowMask = 1 - step(1 - i.color.r, NdotL01); // Получаем цвет с текстуры fixed4 texCol = tex2D(_MainTex, i.uv); // Применяем затенение по маске half4 shadowCol = texCol * shadowMask * _ShadowStrength; // Смешивем цвет текстуры (освещенная часть) и цвет тени по маске half4 col = lerp(texCol, shadowCol, shadowMask); return col; }

Теперь, вместо простого lightIntensityмы используем более сложную маску тени (shadow mask) на основе красного канала.

Результат.

​Слева — без учёта красного канала, справа — с учётом красного канала
​Слева — без учёта красного канала, справа — с учётом красного канала

Меняем оттенок в тени

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

Можно добавить поддержку Ambient Light, как описано в этой статье, а можно задавать изменение оттенка вручную.

Чтобы задать оттенок тени вручную, нам нужно поменять 3 строки.

Заменить свойство _ShadowStrength на _ShadowTint.

Properties { _MainTex ("Texture", 2D) = "white" {} _ShadowTint("Shadow Tint", Color) = (1,1,1,1) // Заменяем тут _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.01 }

Объявить новую переменную в теле CGPROGRAM.

sampler2D _MainTex; float4 _MainTex_ST; half4 _ShadowTint; // Заменяем тут

Заменить строку в функции frag.

// Применяем затенение по маске half4 shadowCol = texCol * shadowMask * _ShadowTint;

Теперь в инспекторе можно выбрать более «холодный» оттенок, чтобы добиться желаемого эффекта.

Слева — без изменения оттенка тени, справа — с изменением оттенка.
Слева — без изменения оттенка тени, справа — с изменением оттенка.

Добавляем блик

Для создания блика мы реализуем модель Blinn-Phong, которая использует half vector (средний вектор между направлением взгляда и направлением к источнику света).

​Blinn-Phong model Alan Zucconi
​Blinn-Phong model Alan Zucconi

Мы условимся, что в нашей стилизованной модели, блик всегда абсолютно белый.

Чтобы использовать модель Blinn-Phong, нам надо знать направление взгляда. Unity предоставляет нам эту информацию, но чтобы получить к ней доступ, добавим соответствующую семантику.

struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : NORMAL; half4 color : COLOR; float3 viewDir : TEXCOORD1; // Добавляем эту строку }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.color = v.color; o.viewDir = WorldSpaceViewDir(v.vertex); // Добавляем эту строку return o; }

Для начала реализуем блик фиксированного размера.

static const half4 SPECULAR_COLOR = half4(1, 1, 1, 1); fixed4 frag (v2f i) : SV_Target { float3 normal = normalize(i.worldNormal); float NdotL = dot(_WorldSpaceLightPos0, normal); float NdotL01 = NdotL * 0.5 + 0.5; half shadowMask = 1 - step(1 - i.color.r, NdotL01); // Рассчитываем блик float3 viewDir = normalize(i.viewDir); // Считаем half vector float3 halfVector = normalize(_WorldSpaceLightPos0 + viewDir); // Ограничиваем значение NdotH между 0 и 1 float NdotH = saturate(dot(halfVector, normal)); // Рассчитываем фиксированный размер блика float specularIntensity = pow(NdotH, 50); // Создаём маску блика half specularMask = step(0.5, specularIntensity); // Умножаем маску блика на инвертированную маску тени, // чтобы блик не появлялся в затенённой области specularMask *= (1 - shadowMask); fixed4 texCol = tex2D(_MainTex, i.uv); half4 shadowCol = texCol * shadowMask * _ShadowTint; half4 col = lerp(texCol, shadowCol, shadowMask); // Добавляем блик к финальному цвету пикселя по маске col = lerp(col, SPECULAR_COLOR, specularMask); return col; }

Результат.

Cel‑shading: некоторые приёмы, о которых вы могли не знать

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

В стандартном пайплайне наличие, размер, цвет и интенсивность блика контролируется текстурами Specular Map и Glossiness Map. Но, опять же, что если мы не хотим добавлять дополнительные текстуры? Мы условились, что блик всегда белый, значит отпадает потребность котролировать цвет и интенсивность. Осталось только разобраться с наличием и размером. Для этого нам снова поможет vertex color, в частности, зелёный канал.

Будут действовать следующие правила:

  • если зелёный канал = 0, блик полностью отсутствует;
  • значение от 0 до 1 контролирует размер блика.

Давайте реализуем это в нашем шейдере. Для этого обновим всего одну строку, в которой мы считаем specularIntensity.

float specularIntensity = i.color.g == 0 ? 0 : pow(NdotH, i.color.g * 500);

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

Cel‑shading: некоторые приёмы, о которых вы могли не знать

Более подробно о physically based рендеринге можно почитать в блоге Alan'a Zucconi.

Исправляем затенение на внутренней стороне

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

fixed4 frag (v2f i, half facing : VFACE) : SV_Target // Добавляем VFACE { float3 normal = normalize(i.worldNormal); // Разворачиваем нормаль внутрь, если // нормаль направлена от камеры half sign = facing > 0.5 ? 1.0 : -1.0; normal *= sign;

Результат.

Слева — без использования VFACE (внутренняя сторона некорректно освещена), справа — после добавления VFACE (корректное затенение на внутренней стороне, которая не повернута к источнику света)​
Слева — без использования VFACE (внутренняя сторона некорректно освещена), справа — после добавления VFACE (корректное затенение на внутренней стороне, которая не повернута к источнику света)​

Контролируем толщину контура с помощью Vertex Color

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

Добавим следующий код во второй Pass, в котором мы рисуем контур:

struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; half4 color : COLOR; // Добавляем эту строку };

А в функции vert измените одну строку, в которой мы считаем offset.

// Добавляем красный канал как множитель float2 offset = normalize(clipNormal.xy) * _OutlineWidth * clipPosition.w * v.color.b;

Теперь можно контролировать толщину контура, изменяя значение синего канала на вершинах меша.

​Например, тут для демонстрации я сделал, что в районе запястья контур отсутствует, а на кисти и пальцах он в два раза тоньше, чем у всей остальной модели
​Например, тут для демонстрации я сделал, что в районе запястья контур отсутствует, а на кисти и пальцах он в два раза тоньше, чем у всей остальной модели

Корректируем нормали на лице

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

​Можно увидеть то повяляющиеся, то пропадающие тени под бровями и на щеке

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

Для свой модели я сделал следующее:

  • все нормали на лбу, переносице, кончике носа, носогубной складке и подбородке «смотрят» ровно вперёд;
  • нормали на бровях, щеках и скулах развёрнуты, примерно, на 30 градусов в стороны и параллельны друг другу;
  • нормали на висках и по бокам челюсти развёрнуты на 80 градусов;
  • после этого я немного сгладил нормали, чтобы убрать жёсткие переходы.
Слева — до коррекции, справа — после. Выглядит немного криповато, но что поделать...​
Слева — до коррекции, справа — после. Выглядит немного криповато, но что поделать...​

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

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

Отбрасываем тень

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

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

UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"
​Теперь наш персонаж отбрасывает тень
​Теперь наш персонаж отбрасывает тень

В данном случае self shadow и тени от других объектов скорее добавят больше визуальных артефактов, по этому их реализовывать не будем. Если вам все таких хочется добавить «получение» теней от других объектов, то в той же статье Roystan'a описано, как это можно сделать.

Вот мы и закончили написание своего стилизованного цел-шейдера.

Полный исходный код можно взять отсюда.

Статья получилась гораздо длиннее, чем я рассчитывал, но, надеюсь, она вам понравилась и вы узнали из неё что-то новое и полезное.

Если вам интересны подобные материалы, можете подписаться на меня в Instagram и Twitter, а также вступить в группу VK, в которой я публикую апдейты, материалы и WIP'ы, по моему проекту King, Witch and Dragon.

Спасибо за внимание!

#лонг #unity #графика #опыт

205205
42 комментария

Комментарий недоступен

45

Я тут больше про технологию, а не про художественную составляющую. Моя цель была показать разные малоизвестные техники. Arc system works с подобной техникой делают офигенно выглядящие модели. Мой уровень, к сожалению, пока ещё далёк от их.

51

Какая вообще разница в технической статье? Скажем спасибо, что вполне себе человеческая фигура, а не чайник из 3дмакса.

15

Комментарий недоступен

30

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

4

Как-будто на хабру попал

1