Поговорим про градиенты в Unity

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

0419f84d4101fa7247471269357e7f9e.png

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

Плюсы и минусы градиентов в виде спрайтов

3940af1dd3f5f273010775b045cdadbd.png

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

В случае простых градиентов в плане веса текстур есть, конечно, хитрость. Сделать текстуру в 1 пиксель на высоту градиента, и тогда проблема отпадает. Но это подходит в основном для простых линейных градиентов.

В чём же плюс градиентов в виде спрайтов? Он, по сути, всего один — батчинг. Используя градиент в атласе с остальными спрайтами можно отрисовать весь интерфейс в один draw call, что, несомненно, является плюсом, но не всегда настолько значимым.

Альтернативное решение

94881020cd7a1a6a76ea63735587719d.png

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

Тут стоит сказать о том, как в юнити рендерятся интерфейсы. Возьмём для примера тот же Image в режиме Simple и без флага Use Sprite Mesh в них всё немного сложнее. Image — это наследник Maskable Graphic, который в режиме Simple генерирует quad. По умолчанию при обработке меша наследники класса Graphic записывают цвет, указываемый в параметр color у vertex. И то поле, которое в Image отвечает за Sprite передаёт текстуру в _MainTex шейдера SpriteDefault. Шейдер умножает картинку на цвет, записанный в вершинах, и мы получаем компонент Image. Дальше этот меш с данным шейдером отрисовывает CanvasRenderer. Всё довольно просто.

Что же нам это даёт? Так как в юнити в интерфейсах мы можем:


1) Генерировать меши
2) Переписывать параметры вершин


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

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

На разных градиентах, артефакты выглядят по-разному, и на некоторых они почти незаметны. Возьмём за основу градиенты, которые поддерживаются той же Figma. Их 4 типа: линейный, конический, сферический и ромбовидный. Давайте про них и поговорим.

Генерация текстуры градиента

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

public Texture2D GenerateTexture(bool makeNoLongerReadable = false)
{
    Texture2D tex = new Texture2D(1, (int)_GradientResolution, TextureFormat.ARGB32, false, true);
    tex.wrapMode = WrapMode;
    tex.filterMode = FilterMode.Bilinear;
    tex.anisoLevel = 1;
    Color[] colors = new Color[(int)_GradientResolution];
    float div = (float)(int)_GradientResolution;
    for (int i = 0; i < (int)_GradientResolution; ++i)
    {
      float t = (float)i/div;
      colors[i] = _Gradient.Evaluate(t);
    }
    tex.SetPixels(colors);
    tex.Apply(false, makeNoLongerReadable);

    return tex;
}

Параметры filter mode и wrap mode зависят в целом от удобства использования в конкретном шейдере. Gradient Resolution — это параметр, отвечающий за «высоту» нашей текстуры градиента. С помощью него можно регулировать какая именно текстура нас устраивает, чтобы как можно меньше терять в качестве.

Текстуру мы сгенерировали, теперь пора перейти к шейдерам.

Линейный градиент

unity figma gradient - градиент сделанный шейдером, остальные текстурамиunity figma gradient — градиент сделанный шейдером, остальные текстурами

Пожалуй самый простой в реализации градиент. Единственная математика, которая в нём потребуется — это поворот uv координат через матрицу поворота в вертексной части шейдера. Угол поворота мы будем передавать, записав его в uv1 канал меша. 

v2f vert (appdata v)
{
  const float PI = 3.14159;
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.color = v.color;
  o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  o.uv.xy -= 0.5;
  float s = sin (2 * PI * (-v.uv2.x) /360);
  float c = cos (2 * PI * (-v.uv2.x) /360);
  float2x2 rotationMatrix = float2x2( c, -s, s, c);
  rotationMatrix *=0.5;
  rotationMatrix +=0.5;
  rotationMatrix = rotationMatrix * 2-1;
  o.uv.xy = mul (o.uv.xy, rotationMatrix );
  o.uv.xy += 0.5;
  return o;
}

В фрагментной части мы просто отображаем текстуру с нашими модифицированными uv координатами. Функция SampleSpriteTexture — это вспомогательная функция необходимая для поддержки ряда внутренних функций Unity UI.

fixed4 SampleSpriteTexture (float2 uv)
{
	fixed4 color = tex2D (_MainTex, uv);

#if UNITY_TEXTURE_ALPHASPLIT_ALLOWED
	if (_AlphaSplitEnabled)
	color.a = tex2D (_AlphaTex, uv).r;
#endif //UNITY_TEXTURE_ALPHASPLIT_ALLOWED

	return color;
}
fixed4 frag (v2f i) : SV_Target
{
	fixed4 col = SampleSpriteTexture ( i.uv) * i.color;
	return col;
}

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

Конический градиент

98196cb285599ad21a66f9d8bcbc7263.jpg

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

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

v2f vert (appdata v)
{
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.color = v.color;
  o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  o.center = v.center;
  return o;
}

fixed4 frag (v2f i) : SV_Target
{
  const float PI = 3.14159;
  float x =  (i.uv.x - i.center.x);
  float y =  (i.uv.y - i.center.y);
  float angle = acos(dot(float2(0, 1),normalize(float2(x, y))));
  float sign = (x) / abs(x);
  float TwoPI = PI * 2;
  float2 uv = ( sign*angle - i.center.z / 360 * TwoPI) / TwoPI;
  fixed4 col = SampleSpriteTexture (uv) * i.color; 
  return col;
}

Если идти по шагам, что мы сделали в преобразовании uv координат. Перенесли центр координат и дальше использовали угол между вектором up и вектором из нашего центра до соответствующей uv координаты в качестве uv маппинга. Может звучать достаточно сложно для тех, кто не так много сталкивался с шейдерами. Но написав 5–10 шейдеров с таким принципом и покрутив его параметры — начинаешь понимать в чём смысл.

Сферический/радиальный градиент

46da6b582cbaefc97ebb89aa6892adbb.jpg

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

v2f vert (appdata v)
{
  const float PI = 3.14159;
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.color = v.color;
  o.uv = TRANSFORM_TEX(v.uv, _MainTex);

  float s = sin (2 * PI * (-v.params.z) /360);
  float c = cos (2 * PI * (-v.params.z) /360);
  float2x2 rotationMatrix = float2x2( c, -s, s, c);
  rotationMatrix *=0.5;
  rotationMatrix +=0.5;
  rotationMatrix = rotationMatrix * 2-1;
  o.uv.xy = mul (o.uv.xy - v.center.xy, rotationMatrix );

  o.params = v.params;
  o.center = v.center;
  return o;
}

fixed4 frag (v2f i) : SV_Target
{
	float x =  i.uv.x;
	float y =  i.uv.y;
	float r1 = i.params.x / 2;
	float r2 = i.params.y / 2;
 	float2 uv = sqrt(x * x / r1 + y * y / r2);
	fixed4 col = SampleSpriteTexture (uv) * i.color; 
	return col;
}

Поворот координат мы используем из нашего линейного градиента, только не смещая центр системы координат. А в фрагментной части у нас просто уравнение эллипса.

Ромбовидный градиент

9e15c98f632da3d390a2d48305cb7a96.jpg

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

fixed4 frag (v2f i) : SV_Target
{
	float x =  i.uv.x;
	float y =  i.uv.y;
	float r1 = i.params.x / 2;
	float r2 = i.params.y / 2;
	float2 uv = abs(x) / r1 + abs(y) / r2;
	fixed4 col = SampleSpriteTexture (uv) * i.color; 
	return col;
}

Собственно вот и все шейдеры, которые необходимы для отрисовки градиентов из Figma в интерфейсах.

Заключение

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

Спасибо за внимание, надеюсь статья была для вас полезна!

© Habrahabr.ru