Как устроен Soft Mask

Кто работал с UI в Unity, мог столкнутся с одной особенностью стандартного компонента Mask: он работает через stencil и потому не поддерживает прозрачность. Пиксель либо полностью виден, либо полностью маскирован. В некоторых случаях это не проблема, поскольку мы можем “прикрыть” маскированный элемент какой-нибудь рамкой:

Обратите внимание на пикселированные края картинки слева. Но иногда нам нужен плавный переход:

Добиться такого эффекта в Unity без сторонних компонентов или написания своих шейдеров невозможно. Именно этот недостаток стандартной маски и призван устранить мой ассет — Soft Mask.

Soft Mask

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

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

На этом видео можно посмотреть что получилось в итоге:

Возможно ли это?

Я изначально не знал, как добиться такого поведения и не был уверен, что это вообще возможно. Поэтому стал изучать Unity UI и искать способы. Довольно быстро обнаружился интерфейс IMaterialModifier, который мне показался куда более правильным способом подменять материалы на дочерних элементах. Альтернативным вариантом, который я применял изначально, была замена Graphic.material. Мне это очень не нравилось, поскольку, во-первых, забирало некоторую свободу у пользователя и, во-вторых, делало присутствие маски заметным за пределами маски, что не соответствовало моей мечте об элегантности.

Поскольку действие IMaterialModifier распространяется только на тот GameObject, на котором он висит, следующей идеей стало автоматическое навешивание этого компонента на дочерние объекты. А как же элегантность? Отчасти она достигается тем, что этот компонент остается сокрытым. Так что в большинстве случаев пользователь не знает о том, что на дочерние элементы что-то добавляется. Вернее, знать-то ему даже полезно, особенно если пользователь — программист. Но то, что чисто служебный компонент не мозолит глаза, мне кажется хорошим решением. Такой компонент сложнее случайно удалить или еще как-нибудь сломать.

Заметить сокрытый компонент все же можно, если создать префаб из маскированного элемента. Когда выбираешь префаб, то в Inspector’е Unity показывает сокрытые компоненты, игнорируя флаги. Идеально было не сериализовать этот компонент вообще, чтобы он не добавлялся ни в префабы, ни в сцены при сохранении, а каждый раз создавался на лету. К сожалению, когда пытаешься сделать несериализуемый компонент внутри сериализуемого объекта, Unity делается плохо. По крайней мере так было во времена 5.3.

Реализация ключевой фичи

Нам нужно не просто добавить SoftMaskable (так называется этот компонент) на все дочерние объекты в Awake(). Нужно сделать так, чтобы когда у элемента есть родительский SoftMask, то на нем SoftMaskable был. А когда родительской маски нет, SoftMaskable не было. Именно это и обеспечивает ту самую элегантность – сложную логику изменения состояния мы прячем за простым правилом. И нужно сделать, чтобы это работало очень надежно. Потому что если эта элегантность “протечет”, эффект будет даже хуже, чем если бы пользователь назначал материалы вручную.

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

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

  1. SoftMaskable всегда создается SoftMask’ом и удаляется самим собой. С созданием, думаю, все понятно, тут вариантов особо нет. А вот удаление самого себя позволяет гарантированно убить неприкаянный SoftMaskable, оставшийся без маски.
  2. SoftMaskable следит за изменением иерархии и уведомляет SoftMask о них.
  3. SoftMask также следит за изменением иерархии и при необходимости создает новые SoftMaskable.

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

  • надо уметь работать в edit mode
  • надо переживать изменения кода как в edit mode, так и в game mode
  • надо, собственно, еще и основную задачу решать — маскировать элементы :-)

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

Тестирование

Я не использовал полностью автоматизированное тестирование в Soft Mask, вместо этого я применил что-то вроде полу-автоматизированного. Я создал набор сцен, каждая из которых проверяла более-менее атомарный кейс: работа со спрайтами из атласов, правильное обновление при использовании в анимации, перенос Soft Mask из одного Canvas в другой и т.п. Большинство этих сцен использовали специальные тестовые скрипты, которые выполняли некоторую работу в game mode. Некоторые сцены вообще не содержали логики, в них я выполнял все нужные проверки вручную. В основном это касалось работы в edit mode. Да, можно было бы слегка автоматизировать и это, но я счет мой вариант достаточно хорошим.

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

Да, кстати, на этом же скриншоте можно заметить папку Extra. Я помещаю сюда все ассеты, которые мне нужны при разработке, но которые не нужны в конечном пакете. При экспорте пакета я просто выбираю SoftMask в качестве корневой папки.

Маскирование

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

Когда-то давно, в 2014, Tim C из Unity написал на форуме, что он видит два подхода к решению этой задачи: “fun with alpha blending or fun with custom shaders”. Собственно, я выбрал второй путь. После релиза я задумался о том, как круто было бы переделать на alpha blending, но не придумал достаточно универсального способа.

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

Сэмплинг

Одной из приятных особенностей стандартной маски Unity является то, что маска реально рисуется. Это значит, что геометрия маски может быть любой и даже шейдер может быть любым до тех пор, пока он совместим со стандартным UI шейдером Unity. Например, в качестве маски можно запросто использовать текст. Хотя, конечно, текст без анти-алиасинга — зрелище так себе.

Принцип работы Soft Mask не позволяет достичь такого же эффекта. Можно, скажем, рисовать элемент в текстуру, а потом использовать ее в качестве маски. Я даже подумываю об этом как о идее для одного из следующий обновлений. Но это явно не тянет на базовое решение.

Чтобы как-то приблизится к идеалу стандартной маски, я добавил специальную поддержку Image и RawImage — компонентов, которые, на мой взгляд, используются в качестве маски чаще всего. Из режимов работы Image поддерживаются simple, sliced и tiled. Из RawImage поддерживаются все настройки.

Когда юнити рисует sliced или tiled картинку, то он составляет ее из множества квадов. Но в маске нам нужно проделать подобную логику с дочерними элементами, а не с самой маской. В принципе, был вариант именно так и поступить: кроме IMaterialModifier в юнити есть также IMeshModifier. Если его повесить на дочерние элементы так же, как я повесил IMaterialModifier, то можно нарезать их меши на кусочки. Но я решил пойти по другому пути и реализовал учет sliced и tiled режимов прямо в шейдере. То есть, в пиксельном шейдере дочернего элемента мы вызываем функцию, которая по текущей позиции пикселя в пространстве маски выдает значение маски, с учетом выбранного режима отображения. Это, пожалуй, самый хитрый код в Soft Mask:

Макрос __SOFTMASK_USE_BORDER определен, когда маска работает в режиме sliced или tiled. Зависимо от этого определения меняется реализация функции __SoftMask_GetMaskUI(). В случае простой маски все довольно тривиально: вызываем вспомогательную функцию, которая переводит 2D-точку из одного прямоугольника в другой:

Почему 2 прямоугольника нарезаны так хитро в аргументах функции? Как раз для того, чтобы ее можно было удобно использовать из реализации со включенным __SOFTMASK_USE_BORDER.

Когда маска работает в режиме с рамкой, SoftMask_GetMaskUV() делегирует работу функции __SoftMask_XY2UV(). Единственное назначение этого делегирования — разложить данные по более удобным переменным.

Аргументы __SoftMask_XY2UV() похожи на аргументы показанной ранее _Inset(). Разница в том, что вместо просто двух прямоугольников, она принимает два прямоугольника с рамками. Прямоугольник-с-рамками задается четверкой значений: левая граница, левая рамка, правая рамка, правая граница. Если вы представите себе 9-stretch картинку, то сразу поймете, о чем речь:

Все четыре точки задаются в единой системе координат.

Как, собственно, работает тот жуткий код? Если вам интересно, можете покопаться самостоятельно. Дам пару наводок:

  1. С помощью step’ов мы определяем сектор, в котором лежит наша координата. s1 == 1 означает, что точка лежит правее a2, s2 == 1 — правее a3. Обратите внимание, что s1 и s2 — тоже векторы, т.е. на самом деле они хранят результат сравнения и на “правее”, и на “ниже”.
  2. В конечном итоге функция также вызывает _Inset(), передавая в нее прямоугольник выбранного сектора. А весь код между step и return просто вычисляет координаты этого прямоугольника.

Можете заметить, что _Inset() иногда вызываться с дополнительным параметром. Он используется для tiled-режима и определяет сколько раз картинка повторяется внутри данного сегмента. Для угловых сегментов туда передается 1, для всех остальных — вычисляемое значение.

Немного трудностей

В процессе работы над Soft Mask я, конечно, столкнулся с некоторыми сложностями. Об одной из них хочу рассказать поподробней.

В какой-то момент обнаружилось, что при сильном сжатии sliced- или tiled-картинки проявляется вот такой артефакт:

Долгое же время я боролся с ним! Изначально было предположение о делении на ноль в шейдере. Поэтому я немного подправил расчет параметров рамки в C#, чтобы деления на 0 не происходило. Это привело к тому, что маска стала немного не соответствовать поведению Image в аналогичном режиме: при сильном сжатии один из сегментов растягивался на 1 пиксель. Для первой версии я удовлетворился этим решением.

Уже не помню, почему я вернулся к этой проблеме в первом апдейте. То ли обнаружил, что я на самом деле не исправил изначальную проблему. То ли просто захотел, чтобы sliced и tiled режимы идеально соответствовали Image. Как выяснилось, проблема действительно была не в делении на ноль. Расширение сегмента на 1 пиксель на самом деле просто маскировало истинную проблему: слишком резкое изменение uv координат на стыке сегментов.

Слева показан спрайт, который используется в качестве маски. Цифрами подписаны примерные текстурные координаты по оси X. Когда мы сильно сжимаем 9-stretch картинку, то у нас полностью исчезает центральный сегмент. При этом текстурная координата должна скачком изменится с 0.3 до 0.7 между соседними пикселями. При использовании анизотропной фильтрации такое резкое изменение по одной из осей увеличивает разброс сэмплов, что и приводит к виденному ранее артефакту. Так что решение этой проблемы оказалось простым: отключить анизотропию для текстуры, которую хочется использовать в качестве маски.

Дальнейшие планы

Пользователи периодически спрашивают, поддерживает ли маска TextMesh Pro. Да и на форумах других похожих ассетов встречаются такие вопросы. Добавить интеграцию с TextMesh Pro я хотел давно, и это желание лишь усилилось после того, как TextMesh Pro перешел к Unity Technologies и стал доступен бесплатно.

Недавно мне удалось достичь заметного прогресса в этом деле: добиться такого же уровня интеграции при работе с TextMesh Pro как и при работе со стандартными элементами. Правда, для этого пришлось немного пропатчить TextMesh Pro и теперь многое зависит от того, включит ли автор эти изменения в свой продукт :-)

Но в любом случае, в моей реализации поддержки TextMesh Pro есть пара занятных моментов, так что ждите технических подробностей в следующих постах!

Leave a Reply