Cel‑shading: некоторые приёмы, о которых вы могли не знать
В этой статье мы напишем с нуля свой цел-шейдер (также известный как toon-шейдер) для Unity. По ходу дела я расскажу и покажу некоторые приёмы, которые, на мой взгляд, очень полезны, но которыми мало кто пользуется.
Для демонстрации я буду использовать персонажа из своего хобби-проекта. Если вы хотите повторять все шаги из статьи, вам понадобится модель с готовой развёрткой и diffuse-текстурой.
Ключевые понятия
Для тех, кто не очень знаком с понятием цел-шейдинга, определение из Википедии.
Тип нефотореалистичного рендеринга, результатом которого является компьютерное изображение, в некоторой мере имитирующее результат рисования вручную. Cel — сокращение от celluloid (целлулоид — из этого материала делаются прозрачные листы, на которых рисуется традиционная мультипликация). Такой тип рендеринга часто используется для одушевления комиксов, для которых характерны жёсткие контуры, ограниченное число цветов, неплавные переходы светотени.
Итак, ключевые моменты цел-шейдинга:
- плоская заливка светом;
- жёсткие границы светотени (отсутствие плавных градиентов);
- наличие контура.
Начинаем писать шейдер
Сразу оговорюсь, я не буду подробно разжёвывать синтаксис шейдерного языка HLSL и CG, потому что это тема для отдельной статьи, плюс на эту тему уже есть достаточно туториалов и мануалов. Я буду объяснять только то, что касается именно реализации цел-шейдинга.
Есть несколько основных подходов к написанию шейдеров. Некоторые предпочитают за основу брать Surface-шейдер, некоторые — Unlit-шейдер. Я покажу реализацию на основе Unlit-шейдера, который по умолчанию не использует никакую модель освещения. Освещение в цел-шейдинге достаточно простое, по этому мы напишем свою упрощенную модель, чтобы не тратить лишние ресурсы на рассчёт более сложного освещения, которое нам не нужно.
Итак, открываем Unity и создаем Unlit Shader…
Базовая реализация
При создании шейдера, Unity по умолчанию добавляет туда поддержку тумана. В нашем случае он не нужен, по этому давайте удалим все строки, связанные с туманом, в качестве небольшой оптимизации.
Создаем на основе шейдера материал, назначаем текстуру и применяем материал к нашей модели.
Backface culling
У нашего персонажа имеются односторонние поверхности, например волосы и набедренная повязка. Они корректно отображаются с внешней стороны, но невидимы с внутренней. Давайте это исправим и сделаем наш материал двухсторонним. Для этого нужно добавить Cull Off в начале Pass. Должно получиться вот так.
Свет и тень
Наша модель будет учитывать освещение только от одного основного направленного источника света (Directional Light). Чтобы избежать лишних вычислений на графическом процессоре, добавим Pass Tags, которые будут накладывать описанное выше ограничение.
Нам нужно знать насколько сильно будет затеняться поверхность, на которую не попадает свет (мы же не хотим, чтобы она была идеально чёрной). Для этого добавим свойство _ShadowStrength.
При этом в инспекторе материала появится слайдер, с помощью которого мы можем регулировать степень затенения.
Также надо добавить эту переменную в тело CGPROGRAM.
Чтобы определить, какая часть поверхности будет в тени, а какая на свету, нам нужно знать направление нормали поверхности. Unity предоставляет нам эту информацию, но чтобы получить её, нужно «сказать» об этом в appdataи в v2f struct.
Добавим в vert функцию преобразование нормали поверхности из локальных координат в мировые:
Теперь добавим в функцию fragкод, который будет считать затенение.
Результат.
Степень затенения можно регулировать слайдером. В моём проекте персонаж всегда освещается одинаково, по этому я не учитываю цвет источника света, а также ambient-освещение. Если вам интересно, как реализовать добавление цвета источника света, ambient, а также контурный свет, рекомендую это статью (на английском).
Добавляем контур
Контур также можно реализовать несколькими способами. Самые распространённые — post-effect и inverted hull.
Пост-эффект затрагивает все изображение на экране. В моём проекте я хочу иметь возможность разделять, какие объекты будут отображаться с цел-шейдингом, а какие нет. По этому для себя я выбрал inverted hull метод.
Для начала добавим переменную _OutlineWidth, которая позволит нам регулировать толщину контура в инспекторе.
Контур будет рисоваться отдельным Pass. Код в нём достаточно простой, по этому всё описание будет в комментариях к коду.
Добавляем следующий код сразу после закрытия фигурной скобки первого Pass.
Результат.
Вот мы и закончили базовую реализацию цел-шейдинга. Можно было бы на этом остановиться, если бы не несколько критичных моментов, которые ломают всю картину... Давайте их обозначим и исправим.
Исправляем контур
При более близком рассмотрении можно заметить множество артефактов, созданных контуром.
В основном все артефакты связаны с тем, что контур «залезает» на основной силуэт. При этом внешний контур получился достаточно неплохо. Давайте сделаем так, чтобы контур рисовался только снаружи основного силуэта и никогда не «залезал» внутрь. Для этого используем Stencil buffer.
Добавьте следующий код в первый Pass, в котором мы рисуем основной силуэт, между Cull Off и CGPROGRAM.
По умолчанию, значение Stencil для всех пикселей равно 0. Код, который мы написали, сравнивает значение пикселя со значением Ref, в данном случае с 1.
Comp Always означает, что проверка всегда проходится с положительным результатом, при этом не важно, значение больше, меньше или равно.
Pass Replace означает, что если проверка прошла успешно, то значение Stencil для данного пикселя заменяется значением Ref.
По сути, для всех пикселей основного силуэта мы принудительно переписали значение Stencil на 1.
Теперь добавим следующий код во второй Pass, который рисует контур. При этом заменим Cull Front на Cull Off, чтобы в формировании силуэта принимали участие все полигоны, а не только те, которые отвернуты от камеры.
Таким образом, будут рендериться только те пиксели, для которых значение Ref (в нашем случае 1), больше, чем значение в буфере. Для пикселей основного силуэта мы присвоили значение 1, а значит проверка на «больше» не пройдёт и пиксели отображаться не будут.
Теперь контур выглядит гораздо чище и не создает артефактов внутри основного силуэта.
Но есть ещё одна проблема.
Контур «живет» в пространстве игрового мира, а не в пространстве экрана. Поэтому при приближении камеры толщина его пропорционально увеличивается и, если показывать персонажа крупным планом, то контур будет слишком толстым.
Также толщина контура может меняться, в зависимости от угла наклона поверхности и направления взгляда.
Обе проблемы решаются переносом рассчета контура в clip space(пространство экрана).
Заменим код в фунции vert на следующий.
Теперь контур всегда одинаковой толщины не зависимо от того, на каком расстоянии от камеры находится объект.
Если Вам интересно узнать больше о технике 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), но не может сделать его толще.
В теории, можно ещё использовать альфа-канал, но я пока не нашел ему хорошего применения, мне достаточно трёх.
Когда мы определились с тем, за что отвечает каждый канал, надо покрасить вертексы нашей модели соответствующими цветами. Это можно сделать почти в любом пакете для трёхмерного моделирования.
Мой персонаж после покраски выглядит так.
Можно легко увидеть, что синим цветом прокрашены зоны, которым нужно дополнительное затенение (складки ткани, мышцы и так далее). Синий цвет означает, что там отсутствует блик (зеленый канал = 0) и что порог затенения тоже очень низкий (красный канал либо = 0, либо очень низкое значение), при этом толщина контура сохраняется (синий канал = 1).
Теперь нужно добавить поддержку vertex color в наш шейдер.
Добавим соответствующие строки в appdata, v2fи vert.
Теперь обновляем код для фунцкции frag:
Теперь, вместо простого lightIntensityмы используем более сложную маску тени (shadow mask) на основе красного канала.
Результат.
Меняем оттенок в тени
Сейчас в затенённой зоне у нас меняется лишь интенсивность цвета, при этом оттенок остаётся тем же самым. Но что если мы хотим сделать тени более «холодными»? Или скорректировать цвет, в зависимости от окружения?
Можно добавить поддержку Ambient Light, как описано в этой статье, а можно задавать изменение оттенка вручную.
Чтобы задать оттенок тени вручную, нам нужно поменять 3 строки.
Заменить свойство _ShadowStrength на _ShadowTint.
Объявить новую переменную в теле CGPROGRAM.
Заменить строку в функции frag.
Теперь в инспекторе можно выбрать более «холодный» оттенок, чтобы добиться желаемого эффекта.
Добавляем блик
Для создания блика мы реализуем модель Blinn-Phong, которая использует half vector (средний вектор между направлением взгляда и направлением к источнику света).
Мы условимся, что в нашей стилизованной модели, блик всегда абсолютно белый.
Чтобы использовать модель Blinn-Phong, нам надо знать направление взгляда. Unity предоставляет нам эту информацию, но чтобы получить к ней доступ, добавим соответствующую семантику.
Для начала реализуем блик фиксированного размера.
Результат.
На персонаже появились блики, но теперь он выглядит немного «пластиковым».Такой эффект создаётся, потому что в реальном мире различные материалы имеют различный блик (или вообще не имеют).
В стандартном пайплайне наличие, размер, цвет и интенсивность блика контролируется текстурами Specular Map и Glossiness Map. Но, опять же, что если мы не хотим добавлять дополнительные текстуры? Мы условились, что блик всегда белый, значит отпадает потребность котролировать цвет и интенсивность. Осталось только разобраться с наличием и размером. Для этого нам снова поможет vertex color, в частности, зелёный канал.
Будут действовать следующие правила:
- если зелёный канал = 0, блик полностью отсутствует;
- значение от 0 до 1 контролирует размер блика.
Давайте реализуем это в нашем шейдере. Для этого обновим всего одну строку, в которой мы считаем specularIntensity.
На моей модели не так много металлических деталей или других элементов, которым подошёл бы стилизованный блик, по этому я оставил его только на волосах и на пряжке ремня.
Более подробно о physically based рендеринге можно почитать в блоге Alan'a Zucconi.
Исправляем затенение на внутренней стороне
В начале статьи мы сделали так, что можно видеть тыльную сторону меша. Но если присмотреться, внешняя и внутренняя сторона освещаются и затеняются одинаково. Если свет падает с внешней стороны, внутренняя сторона тоже будет освещена. Давайте исправим это, используя строенную переменную VFACE.
Результат.
Контролируем толщину контура с помощью Vertex Color
Если у Вашей модели есть тонкие элементы и добавление контура ухудшает их силуэт или детализацию, мы можем сделать контур в этих местах тоньше, используя синий канал vertex color.
Добавим следующий код во второй Pass, в котором мы рисуем контур:
А в функции vert измените одну строку, в которой мы считаем offset.
Теперь можно контролировать толщину контура, изменяя значение синего канала на вершинах меша.
Корректируем нормали на лице
Лицо зачастую является проблематичным местом. Если модель низкополигональная и не использует текстуру нормалей (как в нашем случае), при затенении могут возникнуть неприятные артефакты. Чаще всего это происходит в районе глазниц, носа и на щеках.
В качестве решения без использования текстуры нормали, можно скорректировать нормали на лице вручную. Сейчас практически все современные программы для трёхмерного моделирования имеют такой иструментарий.
Для свой модели я сделал следующее:
- все нормали на лбу, переносице, кончике носа, носогубной складке и подбородке «смотрят» ровно вперёд;
- нормали на бровях, щеках и скулах развёрнуты, примерно, на 30 градусов в стороны и параллельны друг другу;
- нормали на висках и по бокам челюсти развёрнуты на 80 градусов;
- после этого я немного сгладил нормали, чтобы убрать жёсткие переходы.
Вот так теперь выглядит затенение лица в динамике.
Выглядит менее реалистично, но зато более чистенько и аккуратно. Тут уже вопрос личных предпочтений.
Отбрасываем тень
Если вы обратили внимани, в движке наша модель не отбрасывает тень и не попадает в тень от других объектов (включая тень, отбрасываему самим собой на себя).
Отрасывание тени реализуется добавлением уже готового Pass. Добавьте эту строку после того как закрыли фигурную скубку второго Pass.
В данном случае self shadow и тени от других объектов скорее добавят больше визуальных артефактов, по этому их реализовывать не будем. Если вам все таких хочется добавить «получение» теней от других объектов, то в той же статье Roystan'a описано, как это можно сделать.
Вот мы и закончили написание своего стилизованного цел-шейдера.
Полный исходный код можно взять отсюда.
Статья получилась гораздо длиннее, чем я рассчитывал, но, надеюсь, она вам понравилась и вы узнали из неё что-то новое и полезное.
Комментарий недоступен
Я тут больше про технологию, а не про художественную составляющую. Моя цель была показать разные малоизвестные техники. Arc system works с подобной техникой делают офигенно выглядящие модели. Мой уровень, к сожалению, пока ещё далёк от их.
Какая вообще разница в технической статье? Скажем спасибо, что вполне себе человеческая фигура, а не чайник из 3дмакса.
Комментарий недоступен
целшейдинг
Мне кажется важно то, что парни статьи пишут, спасибо им за это. И те спасибо автор. Разделил Геймдев мне лично нравится, а вот изыски геймера в виде их статей, что им по ночам снится, вызывают нек. Эее как бы это поточнее сказать..
Как-будто на хабру попал