В мобильной разработке мы постоянно имеем дело с векторной графикой: ячейки — прямоугольные, аватарки — круглые, текст — это векторные формы. В пиксели это всё превращается как-то само.
Но есть ещё и мир растровых эффектов — когда какая-то трансформация происходит с каждым пикселем по отдельности. С таким почти не приходится работать или используются уже готовые решения. Например, тени — типичный растровый эффект. Или блюр, который стал популярен, начиная с 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.
Формулу для конвертации можно подсмотреть в википедии, а функция 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 в Телеграме. Там же вакансии — мы собираемся устроить большой редизайн приложения, присоединяйтесь, если очень любите заниматься интерфейсами.
Ссылки из статьи с бонусом
Бонус — моя бесплатная книга «Про доступность iOS»
Примеры
Документация
Википедия
Другие статьи
Про Swift
skippetr
Познавательно, спасибо