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

Выделение важных элементов интерфейса (и убирание лишних) является одним из основных инструментов улучшения восприятия пользователя. Это особенно ярко проявляется при работе с мобильными приложениями. Данное переключение внимания может делаться разными способами — плавным убеганием кнопок за края экрана, затемнением фона, динамичным поведением и др. К ним же и относится размытие (blur) изображения. Этот эффект, с одной стороны, уменьшает контраст элементов фона, и за него глазу становится тяжелее зацепиться. С другой стороны, размытие имеет подсознательный эффект, когда изображение становится не в фокусе, и мы воспринимаем его по-другому. Такой подход довольно часто используется в играх, и из известных примеров игр на Unity можно назвать моменты выбора меню квестов Hearthstone или на экране завершения поединка.

Теперь представим, что мы хотим необходимо сделать то же самое или подобное в нашем любимом приложении. И при этом мы без бюджета и не имеем большого опыта в Unity.

Часть первая — Шейдер


Первая возникшая мысль — это все уже должно быть реализовано. Наверняка с помощью шейдера. И непременно должно быть в коробке Unity. Быстрый поиск и попытка установки показали, что такой шейдер в Unity есть, но как оказывается, он является частью Standard Assets, а для бесплатной лицензии он недоступен.

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

Для первой реализации был взят этот шейдер. Для его пробы достаточно добавить шейдер как файл с исходным кодом в проект, ассоциировать его с материалом, привязать материал к Image.

Часть вторая — Интерфейс пользователя


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

Как следствие, для дальнейших действий нужно трогать то, что получилось, и далее думать и настраивать так, чтобы вызвать необходимое восприятие пользователя. Этот момент во многом зависит от контекста. В зависимости от контрастности изображения, разнообразия цветов и других факторов могут выбираться различные значения и дополнительные инструменты. В нашем случае на этом этапе хороший результат был получен при радиусе размытия 25 (параметр шейдера) и дополнительном Transparent в 70% (вне шейдера через отдельный Image). Однако, это только финальная фоновая картинка. Сам переход, по ощущениям на телефоне, оказался слишком резким.

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

Часть третья — Производительность


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

После завершения предыдущего этапа на очередном запуске с эффектом фонового размытия после некоторого удержания телефона в руке появилось ощущение нагревания. Если же провести в меню больше времени, то все становилось намного хуже. И даже если пользователь в меню предположительно и скорее всего не проводит много времени, то высадить батарею совсем не хотелось бы.

Анализ показал, что выбранный выше шейдер имеет асимптотическую сложность O( r2 ) в зависимости от выбранного радиуса. Другими словами, кол-во вычислений на точку становится в 625 раз больше, если увеличить радиус размытия с 1 до 25. При этом вычисления происходят на каждом кадре.

Первым шагом в решении стала мысль о том, что не все шейдеры одинаково полезны, а эффект blur можно реализовать по-разному. Для этого можно взять separate blur, суть которого заключается в размытии сначала только строк по горизонтали, и далее только строк по вертикали. Как следствие, получаем O( r ), и при прочих равных сложность падает на порядок. Дополнительным способом может послужить взятие mipmap меньшего размера, который уже берет на себя часть работы по размытию.

В качестве основы был взят этот шейдер. Однако, его эффект размытия оказался недостаточным, и при высоком контрасте графики изображение становилось “щербатым”. Для получения более качественного эффекты было изменено распределение (элементы GRABPIXEL). Если в шейдере по ссылке с 0.18 до 0.05 радиуса 4, то в нашем варианте от 0.14 до 0.03 радиуса 6 (есс-но, сумма всех должна быть 1).

Таким образом, сложность обработки была уменьшена на 1-2 порядка. Но на этом останавливаться не обязательно.

Часть четвертая — Статический фон


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

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

_width = Screen.width / 2;
_height = Screen.height / 2;
_texture = new Texture2D(_width, _height);

var fillColor = Color.white;
var fillColorArray = _texture.GetPixels();
for (var i = 0; i < fillColorArray.Length; ++i)
{
	fillColorArray[i] = fillColor;
}
_texture.SetPixels(fillColorArray);
_texture.Apply();

_from.GetComponent<Image>().sprite = Sprite.Create(_texture, new Rect(0, 0, _texture.width, _texture.height), new Vector2(0.5f, 0.5f));

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

RenderTexture temp = RenderTexture.GetTemporary(_width, _height);
Graphics.Blit(_from_image.mainTexture, temp, _material);
RenderTexture.active = temp;

_texture.ReadPixels(new Rect(0, 0, temp.width, temp.height), 0, 0, false);
_texture.Apply();

_to_image.sprite = Sprite.Create(_texture, new Rect(0, 0, _texture.width, _texture.height), new Vector2(0.5f, 0.5f));
RenderTexture.ReleaseTemporary(temp);

Для снятия изображения c mainTexture с использованием шейдера (на _material) используется Graphics.Blit. Далее запоминаем результат в подготовленной текстуре и помещаем в целевой Image.

Все бы было хорошо, он на практике столкнулись с таким эффектом, что в зависимости от устройства фоновое изображение оказывается перевернутым. Причина после некоторого изучения вопроса и отладки становится понятна — различие систем координат Direct3D и OpenGL, которые Unity не удается спрятать. При этом наш шейдер определяет UV и корректно обрабатывает, а перевертывание происходит уже в среде Unity (ReadPixels). В сети оказывается много советов, таких как «если у вас перевернулось, переверните обратно сами», или «измените знак UV». Применение макроса UNITY_UV_STARTS_AT_TOP не помогло получить хорошее обобщение для всех проверяемых устройств. Кроме того, столкнулись например с таким случаем, что если подготовить сборку для эмуляции в XCode на iPhone, и при этом Unity не задействовал Metal, а только OpenGLES, то нужно перехватывать и такие случаи, как эмуляция устройства, работающего на другом программном обеспечении.

После проб ряда вариантов остановились на двух таблетках. Первая из них, это форсирование рендеринга камеры (установка forceIntoRenderTexture на время снятия изображения). Вторая, это определение на лету типа графической системы через SystemInfo.graphicsDeviceType, что дает возможность определить OpenGL-like или Direct3D-like (первая группа это OpenGLES2, OpenGLES3, OpenGLCore и Vulkan). Далее, в нашей реализации для Direct3D-like нужно переворачивание, что выполняется процедурно (например так).

Заключение


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