В мобильной разработке мы постоянно имеем дело с векторной графикой: ячейки — прямоугольные, аватарки — круглые, текст — это векторные формы. В пиксели это всё превращается как-то само.

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

В iOS 17 пришло значимое обновление — теперь добавить шейдер можно к любой View, а значит, вся интеграция стала проще и растровых эффектов появится больше.

Давайте разбираться, что за чудо-код надо написать, как это подключить и как в целом погрузиться в тему. Начнём с простых градиентов, а закончим сложным примером «как в Air Drop на iOS 17».

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

Что такое шейдер и почему его код сложно понять

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

У видеокарты иначе: много маленьких процессоров (несколько тысяч), каждый из них выполняет небольшую программу — шейдер. Шейдер применяется к каждому пикселю одновременно. Раз работу они выполняют параллельно, то можно добиться невероятной производительности и обрабатывать все пиксели на экране 60-120 раз в секунду. Это сотни миллионов операций в секунду для всего экрана. Сравните прошлое видео и новое из игры Billion Humans (тот же разработчик):

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

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

Если вы встречали код шейдеров, то, скорее всего, он был непонятный: много странных операций, необычный нейминг и т.д. Это нормально: все вычисления происходят с матрицами, поверх необычного фреймворка (Open GL или Metal). Но для всего нужна практика, и простые вещи разберём в этой статье.

Что появится в iOS 17

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

  • colorEffect — функция для расчёта цвета каждого пикселя внутри View. На вход передаётся координата пикселя и его цвет;

  • distortionEffect — функция для расчёта координаты каждого пикселя. На вход передаётся только текущая координата;

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

Применяется очень просто:

Image(systemName: "figure.run.circle.fill")
    .colorEffect(ShaderLibrary.checkerboard(.float(10), .color(.blue)))

Примеры

Теперь посмотрим примеры из iOS. Обычно, если вы не понимаете, как кто-то смог создать необычный эффект, то это шейдер :D

colorEffect

  • Apple Pay: смешивая влияние разных категорий покупок, можем получить итоговый аккуратный градиент.

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

  • Siri: не уверен, но это очень похоже на гиперболический парабалоид. Можно рассчитать его положение в трёхмерном пространстве, а потом спроецировать на экран — получим мягкие градиенты и анимацию.

distortionEffect

  • Passbook shredder: слегка загнуть кусочки проще всего тоже через шейдер.

layerEffect

  • blur и vibrance: пример для layerEffect, когда мы считаем не только значение текущего пикселя по какой-то формуле, но и опираемся на значения соседних пикселей, чтобы усреднить значение.

  • iBooks: Перелистывание страничек можно сделать через шейдер: по координате касания считать насколько должны изогнуться пиксели у страницы. Это layerEffect, потому что для текущего пикселя надо не только взять значение какого-то другого, но еще и тень добавить.

Где можно применять в приложениях

  • Рисование. Перед Новым годом мы сделали небольшой интерактив — замораживали интерфейс и давали на нём немного порисовать. Мы рассказывали о том, как сделать это через рисование кривыми Безье, но то же самое можно повторить через шейдер, будет работать очень быстро.

  • Дизеринг позволяет готовить любую цветную картинку к печати на термо-принтере.

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

  • Генерируемые индикаторы загрузки, чтобы скрасить ожидание человека.

  • Псевдо 3D-пиццы. Например, брать фотографию пиццы, применять к ней карту нормалей, а по положению гироскопа как бы задавать положение источника света. Так в играх все текустуры делают, чтобы получить более реалистичное изображение.

  • Модифицировать картинки. Например, мы хотим вращать картинку пиццы, но у нас огромный каталог картинок с тенями. Довольно просто написать шейдер, который отделит тень от пиццы.

Metal ????????

Итак, шейдер — это функция, которая вызывается для каждого пикселя на экране. Код для шейдера нужно писать в специальном .metal-файле. В файле нам нужно писать на C++ примерно такие функции:

#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 doNothing(float2 position, half4 color) {
    return color; // Just return color
}

[[ stitchable ]] half4 red(float2 position, half4 color) {
		half4 red = half4(1,  // Red  
										  0,  // Green
										  0,  // Blue
										  1); // Alpha
    return red;
}

Необычными выглядят типы float2 и half4. Это tuple, в конце указано, сколько чисел в нём содержится. Интересно, что к таким типам обращаются не через квадратные скобки, как к массиву, а через переменные, в смысловом порядке: x, y, z или r, g, b, a — выбор названия переменной влияет только на читаемость кода, а фактически мы обращаемся к значению по его порядковому номеру.

Вызвать шейдер в SwiftUI достаточно просто с помощью модификатора .colorEffect:

import SwiftUI

struct Badge: View {
    var body: some View {
        Rectangle()
            .foregroundColor(.blue)
            .frame(width: 300, height: 200)
            .colorEffect(
                ShaderLibrary.red() // <-- Название функции шейдера
            )
    }
}

#Preview {
    Badge()
}

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

SwiftUI-шейдеры нельзя протестировать через брейкпойнт, потому что он вызывается миллионы раз в секунду. Но можно открыть оба файла в split-view и открыть в SwiftUI Preview: после изменения .metal-файла нажмите ⌘+S — превью скомпилируется и обновится, даже фокус не нужно ставить в SwiftUI-код.

Стандартные функции в metal

Все математические операции выполняются немного непривычно. Если вы добавляете к кортежу значений единицу, то она добавится к каждому значению в кортеже. Если вы один кортеж прибавляете к другому, то значения сложатся по номерам. Например, (1, 2, 3) + (4, 5, 6) = (1+4, 2+5, 3+6) = (5, 7, 9). Тоже самое со всеми остальными операциями: вычитанием, деление, вычислением синуса и т.д.

float3 first = (1, 2, 3)
float3 second = first + 2        // = (3, 4, 5)
float3 third = first + (1, 0, 0) // = (2, 2, 3)

В metal очень много уникальных стандартных функций. Полный список можно посмотреть в Metal Shading Language Specification, а в наших примерах будет встречаться несколько типовых:

  • clamp(x, 0, 100) — ограничит значение x, чтобы оно было в диапазоне, например 0 до 100. Эквивалент для max(0, min(100, x)) ;

  • saturate(x) — более короткий вызов для clamp(t, 0, 1). Чаще всего используется для цветов, ведь именно они должны попадать в диапазон от 0 до 1;

  • length(xy) — считает гипотенузу, если представить, что координаты это катеты. Удобно для расчетов удаленности относительно центра координат;

  • sin(t) и cos(t) часто используются для создания цикличных плавных размытий. В качестве параметра мы будем передавать время.

Ультра-простой пример — градиент

Программировать шейдеры начнём с простых примеров — нарисуем градиент через весь спектр цветов. Нам нужно вернуть 4 значения для RGB-цвета, но удобнее задавать градиент в пространстве HSB, так мы сможем менять только один параметр — hue.

RGB справа и HSB слева.
RGB справа и HSB слева.

Формулу для конвертации можно подсмотреть в википедии, а функция saturate просто ограничивает диапазон каждого значения от 0 до 1, чтобы оно оставалось в корректном диапазоне. На самом деле, цвета могут быть и больше 1, но это уже в сторону HDR.

// Standard convert from RGB to HUE
float3 Hue(float H) {
    half R = abs(H * 6 - 3) - 1;
    half G = 2 - abs(H * 6 - 2);
    half B = 2 - abs(H * 6 - 4);
    float3 color = float3(R,G,B);
    return saturate(color);
}

float4 HSBtoRGB(float3 HSB) {
	float3 hue = ((Hue(HSB.x) - 1) * HSB.y + 1) * HSB.z
    return float4(hue,
				  1); // Alpha
}

Значения из переменной HSB извлекаются через свойства x, y и z, но на самом деле их стоит читать как x=hue, y=saturation, z=brightness. Затем мы конвертируем цвет из HSB в RGB и укажем, что alpha должна быть равна одному.

Код для шейдера будет простой: в качестве hue возьмём горизонтальную координату пикселя, но в процентном соотношении к ширине элемента, а ширину передадим в качестве параметра функции. Такое приведение значение диапазона к интервалу от 0 до 1 часто называют нормализацией.

В качестве компонент saturation и brightness просто поставим 1, а в конце все сконвертируем в rgb.

[[ stitchable ]] half4 gradient(float2 position, half4 color, float width) {
    float hue = position.x/width;
    
    float3 hueColor = float3(hue, 
							 1,  // Saturation 
						     1); // Brightness

    float4 rgbColor = HSVtoRGB(hueColor);
    return half4(rgbColor);
}

Получится такой результат — по горизонтали меняется hue:

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

В SwiftUI такой параметр надо прокинуть явно: указать его тип и значение. Обратите внимание, что конвертация типов происходит через функцию .float(), а стандартные position и color передавать не нужно.

import SwiftUI

struct Gradient: View {
	let size = CGSize(width: 300, height: 200)

    var body: some View {
        Rectangle()
            .foregroundColor(.blue)
            .frame(width: size.width, height: size.height)
            .colorEffect(
                ShaderLibrary.gradient(
				   .float(size.width)
				)
            )
    }
}

#Preview {
    Gradient()
}

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

Анимируем градиент. Для этого передадим в функцию ещё и время. В SwiftUI нужно завернуть элементы в TimelineView, тогда обновление экрана будет происходить каждый кадр:

import SwiftUI

struct Gradient: View {
	let size = CGSize(width: 300, height: 200)

	let startDate = Date() // Сохранили время первой отрисовки

    var body: some View {
		TimelineView(.animation) { _ in // Заставляем элементы перерисовываться от времени
	        Rectangle()
	            .foregroundColor(.blue)
	            .frame(width: size.width, height: size.height)
	            .colorEffect(
	                ShaderLibrary.animatedGradient(
						.float(size.width),
						.float(startDate.timeIntervalSinceNow) // Передаем разницу 
                    ) 
  	            )
		}
    }
}

#Preview {
    Gradient()
}

Шейдер похож на предыдущий, но с несколькими отличиями. Сначала посмотрите на полный код, а затем разберём.

[[ stitchable ]] half4 animatedGradient(float2 position, half4 color, float width, float time) {
    float gradient = position.x/width;
    
    float normalizedTime = (sin(time)+1) / 2; // sin(time)=[-1; 1], но сдвинули до [0; 1]
    float hue = abs(gradient - normalizedTime); // повторяем анимацию
  
    float3 hueColor = float3(hue, 1, 1);
    float4 rgbColor = HSVtoRGB(hueColor);
    return half4(rgbColor);
}

Время, которое мы передали, будет постоянно увеличиваться. Если мы хотим зациклить анимацию, то нам нужна круговая функция. Синус или косинус для этого хорошо подходят: на вход мы передаём радианы, функция пересчитывает их в значение от -1 до 1. Разница только в том, что вернётся, если передать на вход 0: синус начнёт возвращать значения с 0, а косинус — с единицы.

При этом нам надо сдвинуть диапазон [-1, 1] до диапазона [0, 1]. Для этого мы добавим 1 (диапазон сместится в [0, 2]) и разделим на 2 (иногда умножают 0.5, это чуть быстрее), чтобы получился [0, 1]. Вычисления могут показаться сложными, но их легко проводить, думая о краевых значениях по отдельности.

float normalizedTime = (sin(time)+1) / 2; // sin(time)=[-1; 1], но сдвинули до

Для анимации градиента мы начинаем его сдвигать на получившееся время. Но нам нужно, чтобы значения были в пределах от 0 до 1, поэтому используем функцию модуля abs (от английского absolute), чтобы отбросить знак у числа:

float hue = abs(gradient - normalizedTime);

Готово: градиент будет перемещаться назад и вперёд.

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


На этом первая статья заканчивается. Мы посмотрели что нового принесла iOS 17, какие базовые операции есть и чуть-чуть попрограммировали. Этого ещё мало, чтобы начать писать свои эффекты с нуля, но уже достаточно, чтобы понять, как работают шейдеры в статье от hackingwithswift.com.

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

Чтобы не пропустить следующую статью, подписывайтесь на канал DodoMobile в Телеграме. Там же вакансии — мы собираемся устроить большой редизайн приложения, присоединяйтесь, если очень любите заниматься интерфейсами.

Ссылки из статьи с бонусом

Комментарии (1)


  1. skippetr
    13.09.2023 11:15

    Познавательно, спасибо