Сегодняшнее вдохновение пришло от этой удивительной анимации.
Now it’s time to develop this button.
P.S. Massive love to everyone who’s joined the YouTube channel — you are the best! pic.twitter.com/A0SOWpsO3b
— Alex Barashkov (@alex_barashkov) September 10, 2024
Если разложить детали, то можно выделить три основные части этого взаимодействия:
- Светящаяся граница
- Пульсирующая волна
- Облако частиц
Для эффектов свечения и пульсации мы изучим и применим шейдерные эффекты SwiftUI. Когда облако частиц будет реализовано, мы копнем немного глубже и создадим вычислительный конвейер с помощью Metal.
Если вы не можете ждать и хотите попробовать сделать это самостоятельно, то репозиторий с конечным результатом можно найти в конце статьи.
Прежде всего, определите базовую структуру нашего представления. В дальнейшем мы будем улучшать ее шаг за шагом.
import SwiftUI struct ReactiveControl: View { var body: some View { GeometryReader { proxy in ZStack { Capsule() .fill(.black) Capsule() .strokeBorder( Color.white, style: .init(lineWidth: 1.0) ) } } } } #Preview { ZStack { Color.black.ignoresSafeArea() ReactiveControl() .frame( width: 240.0, height: 100.0 ) } }
Светящаяся граница
Писать код шейдера сложно без планирования ожидаемого поведения. Первичным триггером для эффекта свечения является место прикосновения пользователя. Правило таково:
Чем ближе граница к точке касания, тем она ярче. И наоборот, чем дальше, тем она прозрачнее.
Схематично это можно изобразить так. Более толстый прямоугольник обозначает более яркие пиксели, более тонкий — более тусклые.
Таким образом, одним из основных параметров для работы шейдера является местоположение касания, более того, нам нужно отличать первое касание от всех остальных, которые происходят, когда пользователь проводит пальцем.
Обработка касания
Давайте играть от этого и определим инфраструктуру для обработки касаний в исходном представлении, здесь DragState
моделирует два состояния касаний пользователя.
struct ReactiveControl: View { private enum DragState { case inactive case dragging } @GestureState private var dragState: DragState = .inactive @State private var dragLocation: CGPoint? ... }
Затем обновите иерархию представлений с помощью экземпляра DragGesture
. Мы устанавливаем минимальное расстояние .zero
, чтобы жест начинался сразу после прикосновения пользователя к экрану. С помощью модификатора updating
мы связываем жест с ранее определенным DragGesture
.
var body: some View { GeometryReader { proxy in ZStack { ... } .gesture( DragGesture( minimumDistance: .zero ) .updating( $dragState, body: { gesture, state, _ in } ) ) } }
На этом этапе нам нужно определить логику обработки состояний жестов. Каждый раз, когда пользователь инициирует жест, мы начинаем с неактивного состояния, которое SwiftUI обрабатывает автоматически. Наша задача — принять местоположение и перейти к следующему состоянию.
Когда пользователь перетаскивает палец, мы также обновляем местоположение в зависимости от размера представления.
.updating( $dragState, body: { gesture, state, _ in switch state { case .inactive: dragLocation = gesture.location state = .dragging case .dragging: let location = gesture.location let size = proxy.size dragLocation = CGPoint( x: location.x.clamp( min: .zero, max: size.width ), y: location.y.clamp( min: .zero, max: size.height ) ) } } )
Здесь clamp
— это математическая функция, которая обеспечивает сохранение текущего значения в заданных пределах. Если значение находится в этих пределах, оно возвращается как есть; в противном случае возвращается ближайшее предельное значение.
Например, 3.clamp(min: 4, max: 6)
вернет 4, потому что значение 3 находится ниже минимального предела, равного 4.
extension Comparable where Self: AdditiveArithmetic { func clamp(min: Self, max: Self) -> Self { if self < min { return min } if self > max { return max } return self } }
Светящийся шейдер
Теперь мы можем приступить к созданию самого шейдера. Начнем с создания ViewModifier
для инкапсуляции связанной с ним логики. Место касания, необходимое для расчета интенсивности свечения, представлено параметром origin
.
struct ProgressiveGlow: ViewModifier { let origin: CGPoint func body(content: Content) -> some View {} }
Заполните тело функции вызовом модификатора visualEffect
.
func body(content: Content) -> some View { content.visualEffect { view, proxy in } }
Согласно документации, этот модификатор предоставляет информацию о геометрии представления, не влияя на его компоновку. Он служит хорошей альтернативой GeometryProxy
для анимации.
Один из эффектов, который мы хотим использовать в данном случае, — colorEffect
. Этот модификатор создает экземпляр визуального эффекта SwiftUI на основе указанного шейдера Metal.
content.visualEffect { view, proxy in view.colorEffect( ShaderLibrary.default.glow( .float2(origin), .float2(proxy.size) ) ) }
Благодаря типу ShaderLibrary
SwiftUI может получать и взаимодействовать с шейдерами Metal, не требуя от разработчика вручную настраивать взаимодействие между этими двумя областями (jб этом мы расскажем в заключительной части статьи).
Синтаксис ShaderLibrary.default
означает, что поиск кода шейдеров будет происходить в основном бандле. Если файлы .metal
находятся в пакете, отличном от .main
, используйте конструктор ShaderLibrary.bundle(_:)
.
Мы получаем доступ к нужному шейдеру, вызывая его как обычную функцию Swift, благодаря атрибуту @dynamicMemberLookup
в ShaderLibrary
. В нашем случае мы предполагаем, что имя функции шейдера, определенное в файле .metal
, будет glow
.
В вызове метода мы передаем необходимые параметры, которыми в нашем случае являются местоположение касания и размер представления. Чтобы сделать их совместимыми с Metal, мы оборачиваем их в примитив float2
, который представляет собой вектор или упорядоченную пару из двух значений. Эта пара соответствует CGPoint
или CGSize
.
Посмотрите тип Shader.Argument
, чтобы найти другие примитивы, доступные для передачи.
Давайте напишем код шейдера. Создайте новый файл .metal
и добавьте в него этот начальный код.
#include <SwiftUI/SwiftUI.h> #include <metal_stdlib> using namespace metal; [[ stitchable ]] half4 glow( float2 position, half4 color, float2 origin, float2 size ) { }
Во-первых, нам нужно нормализовать полученные координаты в рамках текущего представления, чтобы мы могли выполнять дальнейшие вычисления, не полагаясь на абсолютные размеры.
Для этого мы делим координаты на размер представления.
float2 uv_position = position / size; float2 uv_origin = origin / size;
Далее мы вычисляем расстояние от точки касания до пикселя на границе.
float distance = length(uv_position - uv_origin);
Интенсивность свечения будет зависеть от экспоненты отрицательного квадрата расстояния. Эта функция идеально подходит для нашей задачи — при увеличении расстояния результирующее значение стремится к нулю.
float glowIntensity = exp(-distance * distance);
Вы можете использовать graphtoy для проверки поведения описанных функций.
Затем мы немного изменим интенсивность, чтобы еще больше ограничить распространение свечения. Здесь функция smoothstep
действует так же, как и функция clamp
, которую мы определили ранее.
glowIntensity *= smoothstep(0.0, 1.0, (1.0 - distance));
Вот демонстрация этих функций:
И наконец, все, что нам нужно, — это вернуть результирующий цвет с примененным коэффициентом интенсивности.
return color * glowIntensity;
Проверьте полученный код, чтобы убедиться, что ничего не пропущено:
[[ stitchable ]] half4 glow( float2 position, half4 color, float2 origin, float2 size ) { float2 uv_position = position / size; float2 uv_origin = origin / size; float distance = length(uv_position - uv_origin); float glowIntensity = exp(-distance * distance); glowIntensity *= smoothstep(0.0, 1.0, (1.0 - distance)); return color * glowIntensity; }
Вернемся к коду SwiftUI, нам нужно применить этот шейдер.
ZStack { ... Capsule() .glow(fill: .palette, lineWidth: 4.0) .modifier( ProgressiveGlow( origin: dragLocation ?? .zero ) ) }
Эффект свечения, который мы применили к Capsule
, использует SwiftUI-реализацию, которая подробно описана в этой статье.
Вы можете заметить, что свечение начинается в левом верхнем углу, более того, оно отображается постоянно, независимо от того, активно ли прикосновение или нет.
Выбор времени свечения
Чтобы исправить это, нам нужно ввести понятие прогресса свечения, предполагая, что его значение может варьироваться в диапазоне от 0.0 до 1.0. Здесь же мы вводим декларацию амплитуды, которая поможет нам управлять интенсивностью свечения извне.
Поскольку в SwiftUI мы называем этот шейдер как colorEffect
, в Metal нам нужно определить его интерфейс особым образом. Вкратце, [[ stitchable ]]
позволяет нам делегировать поиск и вызов метода в SwiftUI, параметры position
и color
также передаются SwiftUI автоматически.
Подробнее об атрибутах в Metal можно прочитать в официальной спецификации языка.
[[ stitchable ]] half4 glow( float2 position, half4 color, float2 origin, float2 size, float amplitude, float progress ) { ... }
Сначала измените исходную функцию интенсивности, умножив ее на амплитуду и прогрессию. Это обеспечит постепенное изменение интенсивности по длине при изменении прогрессии.
float glowIntensity = smoothstep(0.0, 1.0, progress) * exp(-distance * distance) * amplitude;
Затем сделайте так, чтобы модуляция зависела от прогресса. Благодаря этому изменению интенсивность будет постепенно распространяться от ближайшей к прикосновению точки к самой удаленной по мере изменения прогресса.
glowIntensity *= smoothstep(0.0, 1.0, (1.0 - distance / progress));
Вернувшись в SwiftUI, мы добавим прогресс в качестве параметра ProgressiveGlow
. Нам также нужно указать значения для только что определенных параметров шейдера. Здесь значение амплитуды принимается равным 3.0, но вы можете изменить его на более удобное для вас.
struct ProgressiveGlow: ViewModifier { let origin: CGPoint let progress: Double func body(content: Content) -> some View { content.visualEffect { view, proxy in view.colorEffect( ShaderLibrary.default.glow( .float2(origin), .float2(proxy.size), .float(3.0), .float(progress) ) ) } } }
Осталось реализовать механизм анимации свечения, сердцебиение которого будет основано на анимации ключевых кадров. Добавьте переменную состояния glowAnimationID
, которая определяет активную анимацию свечения.
struct ReactiveControl: View { @State private var glowAnimationID: UUID? ... }
Затем замените прямое назначение modifier
на обертку keyframeAnimator
. Здесь ранее определенный glowAnimationID
выступает в качестве триггера анимации и запускает ее при каждом изменении его значения. Параметр elapsedTime
, предоставляемый при замыкании содержимого анимации, представляет собой, прогресс этой анимации для наших целей.
Capsule() .glow(fill: .palette, lineWidth: 4.0) .keyframeAnimator( initialValue: .zero, trigger: glowAnimationID, content: { view, elapsedTime in view.modifier( ProgressiveGlow( origin: dragLocation ?? .zero, progress: elapsedTime ) ) }, keyframes: { _ in ... } )
С помощью замыкания keyframes
мы можем контролировать значение elapsedTime
. Проверяя наличие значения glowAnimationID
, мы решаем, следует ли нам отобразить свечение или полностью скрыть его. MoveKeyframe
позволяет установить начальное значение для elapsedTime
, а LinearKeyframe
— изменить это значение на новое для указанного промежутка времени.
Таким образом, если glowAnimationID
не равно nil
, мы изменяем значение elapsedTime
с 0.0 до 1.0 за 0.4 секунды и наоборот.
keyframes: { _ in if glowAnimationID != nil { MoveKeyframe(.zero) LinearKeyframe( 1.0, duration: 0.4 ) } else { MoveKeyframe(1.0) LinearKeyframe( .zero, duration: 0.4 ) } }
Нам также нужно обновить обработку жестов, назначая новый идентификатор анимации каждый раз, когда пользователь начинает новый цикл жестов.
.updating( $dragState, body: { gesture, state, _ in switch state { case .inactive: dragLocation = gesture.location glowAnimationID = UUID() state = .dragging case .dragging: ... } } )
И очистить идентификатор, как только пользователь завершит цикл жестов.
.updating( $dragState, body: { gesture, state, _ in ... } ) .onEnded { _ in glowAnimationID = nil }
Что ж, на данный момент проделана огромная работа.
Пульсирующая волна
Последние версии SwiftUI отмечены повышенным вниманием со стороны Apple к тому, чтобы Metal выглядел не так пугающе и его можно было легко интегрирован в компоненты пользовательского интерфейса. Так, на WWDC24 был представлен этот отличный туториал по созданию пользовательских визуальных эффектов, который содержит именно тот эффект пульсации, который мы хотим получить.
Давайте лучше разберемся в математике, лежащей в основе этого шейдера.
Сначала мы вычислим расстояние между началом волны и некоторой точкой на экране.
Вот простой код:
float distance = length(position - origin);
Далее мы моделируем время, необходимое для того, чтобы волна достигла точки на экране. Если точка находится слишком далеко от начала координат (т.е. задержка больше текущего времени), мы зажимаем значение до нуля, что означает отсутствие эффекта пульсации на этом расстоянии. По сути, более высокая скорость приводит к меньшей задержке, что ведет к более широкому распространению волны в секунду, а значит, пульсация затрагивает большее количество пикселей.
float delay = distance / speed; time = max(0.0, time - delay);
Далее мы определяем основное значение этого эффекта: величину пульсации. В данном примере пульсация определяется синусоидальной функцией времени. Частота модулирует количество пиков, а амплитуда определяет высоту этих пиков. Экспоненциальная функция затухания помогает постепенно уменьшать эффект с течением времени.
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);
Вот ссылка на graphtoy, чтобы лучше понять функции. На графике видно, что значение результирующей функции быстро растет (на это указывают более яркие пиксели волны) в течение короткого периода, а затем постепенно уменьшается по мере затухания волны. В результате мы получим один пик с яркими значениями, представляющий волну, движущуюся от места касания к границам представления.
Хотя graphtoy предоставляет собственную переменную для времени, мы не используем ее при объяснении формул. Наша переменная времени представлена значениями на оси Х.
Эта часть может оказаться сложной: newPosition
— это координата пикселя на экране, который заменит текущий пиксель. Это создает эффект искажения, который становится более выраженным при более высоких значениях частоты и амплитуды.
Код:
float2 direction = normalize(position - origin); float2 newPosition = position + rippleAmount * direction;
После этого мы используем newPosition
для получения заменяющего пикселя, в частности, информации о его цвете.
half4 color = layer.sample(newPosition);
Осталось только смоделировать яркость цвета, пропорциональную величине пульсации, и текущую альфу этого цвета.
color.rgb += (rippleAmount / amplitude) * color.a; return color;
Вот полный код этого шейдера из учебника Apple. Обратите внимание, что интерфейс этой функции также сделан особым образом, в SwiftUI он соответствует вызову layerEffect
.
#include <SwiftUI/SwiftUI.h> #include <metal_stdlib> using namespace metal; [[ stitchable ]] half4 ripple( float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed ) { // The distance of the current pixel position from `origin`. float distance = length(position - origin); // The amount of time it takes for the ripple to arrive at the current pixel position. float delay = distance / speed; // Adjust for delay, clamp to 0. time = max(0.0, time - delay); // The ripple is a sine wave that Metal scales by an exponential decay function. float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time); // A vector of length `amplitude` that points away from position. float2 direction = normalize(position - origin); // Scale `n` by the ripple amount at the current pixel position and add it // to the current pixel position. // // This new position moves toward or away from `origin` based on the // sign and magnitude of `rippleAmount`. float2 newPosition = position + rippleAmount * direction; // Sample the layer at the new position. half4 color = layer.sample(newPosition); // Lighten or darken the color based on the ripple amount and its alpha component. color.rgb += (rippleAmount / amplitude) * color.a; return color; }
Далее, как и в случае со свечением, мы объявляем модификатор вида и дублируем все свойства из интерфейса шейдера.
struct RippleModifier: ViewModifier { let origin: CGPoint let elapsedTime: TimeInterval let duration: TimeInterval let amplitude: Double let frequency: Double let decay: Double let speed: Double func body(content: Content) -> some View { ... } }
Мы начинаем тело модификатора с обращения к библиотеке шейдеров для создания экземпляра функции шейдера.
func body(content: Content) -> some View { let shader = ShaderLibrary.default.ripple( .float2(origin), .float(elapsedTime), .float(amplitude), .float(frequency), .float(decay), .float(speed) ) }
И в завершение мы создаем шейдерный эффект в обертке visualEffect
, чтобы SwiftUI мог выполнять анимацию, не влияя на расположение элементов.
func body(content: Content) -> some View { ... let maxSampleOffset = CGSize( width: amplitude, height: amplitude ) let elapsedTime = elapsedTime let duration = duration content.visualEffect { view, _ in view.layerEffect( shader, maxSampleOffset: maxSampleOffset, isEnabled: 0...duration ~= elapsedTime ) } }
Определение времени пульсации
На последнем этапе нам нужно связать действия пользователя с вызовом шейдера. Давайте добавим идентификатор для анимации пульсации и отдельную переменную для отслеживания начальной точки касания.
struct ReactiveControl: View { @State private var rippleAnimationID: UUID? @State private var rippleLocation: CGPoint? ... }
Примените keyframeAnimator
к самому нижнему виду в иерархии. Используемые здесь параметры пульсации создают равномерную волну, которая примерно соответствует остальной анимации, которую мы разрабатываем. Мы также можем добавить сюда sensoryFeedback
, чтобы придать эффекту еще большее воздействие.
ZStack { Capsule() .fill(.black) .keyframeAnimator( initialValue: 0, trigger: rippleAnimationID, content: { view, elapsedTime in view.modifier( RippleModifier( origin: rippleLocation ?? .zero, elapsedTime: elapsedTime, duration: 1.0, amplitude: 2.0, frequency: 4.0, decay: 10.0, speed: 800.0 ) ) }, keyframes: { _ in ... } ) .sensoryFeedback( .impact, trigger: rippleAnimationID ) ... }
Ключевые кадры описывают движение пульсации только в одном направлении, поскольку мы начинаем анимацию в момент первого прикосновения пользователя к экрану.
keyframes: { _ in MoveKeyframe(.zero) LinearKeyframe( 1.0, duration: 2.0 ) }
Чтобы запустить анимацию, мы обновляем колбек обработки жестов для неактивного состояния, назначая новый идентификатор для пульсации и задавая местоположение касания.
.updating( $dragState, body: { gesture, state, _ in switch state { case .inactive: rippleAnimationID = UUID() rippleLocation = gesture.location ... case .dragging: ... } } )
Вот и все! Теперь мы можем проверить анимацию.
Облако частиц
Чтобы нарисовать облако частиц, нам сначала нужно понять его математику. Каждая частица описывается своим местоположением, скоростью и временем жизни. Для целей пользовательского интерфейса мы также можем включить в этот набор радиус и цвет.
Для облака частиц мы будем поддерживать его центр, который определяется местом касания пользователя. Исходя из этой точки, мы можем рассчитать направление движения каждой частицы.
Начнем с определения структур для описанных концепций. SIMD-типы являются векторами, поэтому можно считать SIMD2<Float>
в Swift и float2
в Metal одинаковыми типами. Переменная progress
в ParticleCloudInfo
имеет то же определение, что и описанное нами для эффекта свечения.
struct Particle { let color: SIMD4<Float> let radius: Float let lifespan: Float let position: SIMD2<Float> let velocity: SIMD2<Float> } struct ParticleCloudInfo { let center: SIMD2<Float> let progress: Float }
Для реализации описанного поведения существующих вариантов взаимодействия между SwiftUI и Metal недостаточно. Нам нужно пойти немного глубже и использовать взаимодействие между UIKit и MetalKit. Объявите тип, соответствующий UIViewRepresentable
, чтобы адаптировать экземпляр MTKView
для использования в SwiftUI.
import MetalKit struct ParticleCloud: UIViewRepresentable { let center: CGPoint? let progress: Float private let metalView = MTKView() func makeUIView(context: Context) -> MTKView { metalView } func updateUIView( _ view: MTKView, context: Context ) {} }
Чтобы рисовать на экземпляре MTKView
, нам нужно создать тип, соответствующий MTKViewDelegate
. Рендерер будет управлять всем необходимым для отображения частиц. Сначала мы добавим ссылку на MTKView
и переменную для точки касания, которая будет иметь нормализованные значения. По умолчанию мы будем считать, что точка касания находится в центре вида.
Мы также поддерживаем здесь переменную progress
, определенную аналогично переменной в шейдере свечения. Она влияет на все облако в зависимости от того, началась или закончилась анимация касания. Если прогресс равен нулю, мы отключаем рендеринг частиц и скрываем их.
final class Renderer: NSObject { var center = CGPoint(x: 0.5, y: 0.5) var progress: Float = 0.0 { didSet { metalView?.isPaused = progress == .zero } } private weak var metalView: MTKView? init(metalView: MTKView) { self.metalView = metalView } }
Далее мы вручную настроим взаимодействие между UIKit и MetalKit. Ядром этого взаимодействия является тип MTLDevice
, который представляет экземпляр GPU на устройстве и позволяет нам отправлять команды на выполнение. Мы получаем такой тип, вызывая MTLCreateSystemDefaultDevice()
.
Для отправки команд MTLDevice
предоставляет посредника под названием MTLCommandQueue
. Его можно примерно сравнить с GCD, где DispatchQueue
служит отправителем команд.
final class Renderer: NSObject { ... private let commandQueue: MTLCommandQueue init(metalView: MTKView) { ... guard let device = MTLCreateSystemDefaultDevice(), let commandQueue = device.makeCommandQueue() else { fatalError("GPU not available") } self.commandQueue = commandQueue } }
Далее нам нужно создать представление функций Metal, аналогичное тому, что мы имеем в SwiftUI. Сначала мы создаем MTLLibrary
, используя пакет, содержащий ожидаемые функции Metal, а затем создаем эти функции, используя их имена. У нас пока нет соответствующих функций, но мы вскоре рассмотрим их.
Чтобы использовать описанные функции, мы создаем состояние конвейера, в частности экземпляры типа MTLComputePipelineState
. Вы можете представить себе состояние конвейера как кисть, которую графический процессор использует для рендеринга — разные кисти дают разные результаты рендеринга.
final class Renderer: NSObject { ... private let cleanState: MTLComputePipelineState private let drawState: MTLComputePipelineState init(metalView: MTKView) { ... do { let library = try device.makeDefaultLibrary( bundle: .main ) let clearFunc = library.makeFunction( name: "cleanScreen" )! let drawFunc = library.makeFunction( name: "drawParticles" )! cleanState = try device.makeComputePipelineState( function: clearFunc ) drawState = try device.makeComputePipelineState( function: drawFunc ) } catch { fatalError("Library not available: \(error)") } super.init() } }
Нам также нужно настроить данные о частицах. Здесь вы найдете предопределенные значения, которые соответствуют существующим анимациям, но не стесняйтесь вводить свои собственные, чтобы лучше понять, как работает конвейер.
Чтобы отслеживать ход рендеринга и правильно задавать динамику частиц, мы храним эти данные локально. Код шейдера будет полностью обрабатывать и обновлять эти данные. Чтобы сделать их доступными для шейдера, мы храним их в виде экземпляра MTLBuffer
.
Используемый нами билдер принимает байтовый указатель и размер памяти по этому указателю. Предоставление этих параметров позволяет Metal правильно выделить память для параметров во время выполнения шейдера.
final class Renderer: NSObject { ... private var particleBuffer: MTLBuffer! var particleCount: Int = 32 var colors: [SIMD4<Float>] = Array( repeating: .init( Float.random(in: 0.0..<0.3), Float.random(in: 0.3..<0.7), Float.random(in: 0.7..<1.0), 1.0 ), count: 3 ) init(metalView: MTKView) { ... let particles: [Particle] = (0..<particleCount).map { i in let vx = Float(5.0) let vy = Float(5.0) return Particle( color: colors[i % colors.count], radius: Float.random(in: 4..<30), lifespan: .zero, position: SIMD2<Float>(.zero, .zero), velocity: SIMD2<Float>(vx, vy) ) } particleBuffer = device.makeBuffer( bytes: particles, length: MemoryLayout<Particle>.stride * particleCount ) } }
Наконец, нам нужно сообщить MTKView
, что описываемый нами рендерер будет выполнять функции его делегата. Мы также установили для параметра backgroundColor
значение clear
, чтобы поведение UIView не влияло на шейдер, и отключили буфер кадров, чтобы разрешить дальнейшие операции, которые мы собираемся выполнить.
final class Renderer: NSObject { ... init(metalView: MTKView) { ... metalView.device = device metalView.delegate = self metalView.framebufferOnly = false metalView.backgroundColor = .clear } }
Соответствие MTKViewDelegate
требует реализации двух методов, в статье мы сосредоточимся только на рисовании.
extension Renderer: MTKViewDelegate { func draw(in view: MTKView) { ... } func mtkView( _ view: MTKView, drawableSizeWillChange size: CGSize ) {} }
Метод draw
представляет собой единый цикл рисования, аналогичный тому, что можно найти, например, в UIView
.
Мы начинаем с настройки основных элементов для этапа рендеринга. Drawable
служит холстом для наших рисунков, в то время как texture
содержит цвета и содержимое. Чтобы сгруппировать все команды для одного цикла рендеринга, мы используем commandBuffer
из CommandQueue
. Наконец, commandEncoder
преобразует вызовы Swift-методов в Metal-инструкции. В самом конце мы задаем текстуру в кодере, которую он передаст в Metal-шейдеры.
func draw(in view: MTKView) { guard let drawable = view.currentDrawable else { return } let texture = drawable.texture let commandBuffer = commandQueue.makeCommandBuffer() let commandEncoder = commandBuffer?.makeComputeCommandEncoder() commandEncoder?.setTexture(texture, index: 0) }
Далее мы должны запрограммировать состояния для цикла рисования. Первое — это clearState
, задача которого — очистить холст — стереть частицы, которые могли остаться после предыдущего цикла рисования. Текстуру для работы мы передали ранее, но весь код очистки оставим в шейдере. Здесь нам нужно рассказать, как правильно обработать холст с точки зрения вычислительных возможностей устройства.
Вызывая dispatchThreads
, мы даем команду кодировщику применить текущее состояние. Первый параметр определяет общее количество обрабатываемых элементов, рассчитанное в трех измерениях. Поскольку мы работаем с 2D-изображением, нам нужно указать только ширину и высоту холста.
Второй параметр определяет, сколько элементов устройство обрабатывает одновременно. Поскольку ресурсы ограничены, графический процессор обрабатывает эти элементы группами. Для более сложных задач это помогает оптимизировать рабочую нагрузку и повысить производительность. В нашем примере мы можем полагаться на базовые значения, предоставляемые устройством. Мы используем threadExecutionWidth
как количество потоков в горизонтальной группе и вычисляем высоту группы, деля общую площадь (maxTotalThreadsPerThreadgroup
) на ширину.
func draw(in view: MTKView) { ... commandEncoder?.setComputePipelineState(cleanState) let w = cleanState.threadExecutionWidth let h = cleanState.maxTotalThreadsPerThreadgroup / w commandEncoder?.dispatchThreads( MTLSize( width: texture.width, height: texture.height, depth: 1 ), threadsPerThreadgroup: MTLSize( width: w, height: h, depth: 1 ) ) }
Используя dispatchThreads
, нам не нужно беспокоиться о том, соответствует ли количество обрабатываемых элементов количеству потоков в группе. Metal автоматически справляется с этой задачей, если архитектура процессора поддерживает nonuniform размеры. Если архитектура не поддерживает такую возможность, вызов метода приведет к ошибке во время выполнения. В этом случае учитывайте неравномерность размеров в расчетах и вызывайте dispatchThreadgroups
.
Эта часть кода приведена в демонстрационных целях, не добавляйте ее в проект. Если в конце вы получите вышеуказанную ошибку при запуске шейдера, вернитесь сюда и измените этот код.
commandEncoder?.dispatchThreadgroups( MTLSize( width: texture.width, height: texture.height, depth: 1 ), threadsPerThreadgroup: MTLSize( width: (texture.width + w - 1) / w, height: (texture.height + h - 1) / h, depth: 1 ) )
Далее мы кодируем drawState
. Первым шагом после изменения состояния является установка буфера частиц. Используя setBuffer
, мы даем Metal шейдеру ссылку на этот буфер, позволяя ему считывать и записывать данные частиц. Затем мы подготавливаем облачную информацию и передаем ее с помощью setBytes
, который копирует данные непосредственно в графический процессор. Этого достаточно, поскольку шейдер не будет изменять эту структуру.
Последним шагом в настройке этого состояния является повторный вызов dispatchThreads
, но на этот раз количество элементов соответствует количеству частиц, которые мы хотим отобразить. Количество потоков в одной группе потоков также останется значением по умолчанию.
func draw(in view: MTKView) { ... commandEncoder?.setComputePipelineState(drawState) commandEncoder?.setBuffer( particleBuffer, offset: 0, index: 0 ) var info = ParticleCloudInfo( center: SIMD2<Float>(Float(center.x), Float(center.y)), progress: progress ) commandEncoder?.setBytes( &info, length: MemoryLayout<ParticleCloudInfo>.stride, index: 1 ) commandEncoder?.dispatchThreads( MTLSize( width: particleCount, height: 1, depth: 1 ), threadsPerThreadgroup: MTLSize( width: drawState.threadExecutionWidth, height: 1, depth: 1 ) ) }
Вот еще соображения относительно неравномерных размеров.
Эта часть кода приведена в демонстрационных целях, не добавляйте ее в проект. Если в конце вы получите вышеуказанную ошибку при запуске шейдера, вернитесь сюда и измените этот код.
commandEncoder?.dispatchThreadgroups( MTLSize( width: (particleCount + w - 1) / w, height: 1, depth: 1 ), threadsPerThreadgroup: MTLSize( width: drawState.threadExecutionWidth, height: 1, depth: 1 ) )
Последним шагом в нашем цикле рендеринга является завершение энкодинга, представление текущего объекта отрисовки и отправка команд для обработки.
func draw(in view: MTKView) { ... commandEncoder?.endEncoding() commandBuffer?.present(drawable) commandBuffer?.commit() }
Прежде чем вернуться к написанию шейдера, давайте интегрируем рендерер в описание ParticleCloud
. При создании и обновлении представления мы назначаем анимацию прогресса, чтобы рендеринг частиц оставался актуальным. Мы также предварительно нормализуем точку касания, чтобы шейдер оставался независимым от размеров представления.
struct ParticleCloud: UIViewRepresentable { ... func makeUIView( context: Context ) -> MTKView { context.coordinator.progress = progress return metalView } func updateUIView( _ view: MTKView, context: Context ) { context.coordinator.progress = progress guard let center else { return } let bounds = view.bounds context.coordinator.center = CGPoint( x: center.x / bounds.width, y: center.y / bounds.height ) } func makeCoordinator() -> Renderer { Renderer(metalView: metalView) } }
Шейдер для облака
Первый шаг — продублировать описания структуры, сделав их доступными для шейдеров.
struct Particle { float4 color; float radius; float lifespan; float2 position; float2 velocity; }; struct ParticleCloudInfo { float2 center; float progress; };
Далее мы опишем шейдер для очистки холста. Параметры включают в себя output
текстуру для обработки, доступ к которой осуществляется с помощью атрибута [[texture]]
. Этот атрибут ссылается на таблицу параметров, заданную ранее в методе draw
, с текстурой, расположенной в индексе 0. Параметр id
соответствует индексу потока обработки и обрабатываемому элементу.
Чтобы очистить холст, мы устанавливаем прозрачность каждого пикселя, используя значение half 4(0)
, которое возвращает цвет, для которого все компоненты имеют значение 0.
kernel void cleanScreen ( texture2d<half, access::write> output [[ texture(0) ]], uint2 id [[ thread_position_in_grid ]] ) { output.write(half4(0), id); }
Перейдем к рисованию частиц. Вместе с текстурой извлекаем из буфера данные о частицах и облаке в целом, их индексы совпадают с указанными ранее в методе draw
.
С помощью первой операции мы преобразуем нормализованные значения центрального положения в текстурные координаты, чтобы можно было дальше работать с пикселями.
kernel void drawParticles ( texture2d<half, access::write> output [[ texture(0) ]], device Particle *particles [[ buffer(0) ]], constant ParticleCloudInfo &info [[ buffer(1) ]], uint id [[ thread_position_in_grid ]] ) { float2 uv_center = info.center; float width = output.get_width(); float height = output.get_height(); float2 center = float2(width * uv_center.x, height * uv_center.y); }
Далее мы определяем правила движения частицы. Помните, что параметр id
соответствует текущему элементу, с его помощью мы получаем данные о частице из буфера.
По умолчанию мы задаем три условия для “перерождения” частицы:
- Если она находится слишком близко к центру.
- Если она только что появилась (ее координаты равны 0.0).
- Если ее время жизни превышает 100 тактов.
В случае выполнения одного из условий мы назначаем частице случайное положение в границах текстуры и сбрасываем время ее жизни. В противном случае мы перемещаем его к центру и увеличиваем его тик на единицу.
После вычисления обновленных данных мы обновляем частицу и сохраняем ее обратно в буфер.
kernel void drawParticles ( texture2d<half, access::write> output [[ texture(0) ]], device Particle *particles [[ buffer(0) ]], constant ParticleCloudInfo &info [[ buffer(1) ]], uint id [[ thread_position_in_grid ]] ) { ... Particle particle = particles[id]; float lifespan = particle.lifespan; float2 position = particle.position; float2 velocity = particle.velocity; if ( length(center - position) < 20.0 || position.x == 0.0 && position.y == 0.0 || lifespan > 100 ) { position = float2(rand(id) * width, rand(id + 1) * height); lifespan = 0; } else { float2 direction = normalize(center - position); position += direction * length(velocity); lifespan += 1; } particle.lifespan = lifespan; particle.position = position; particles[id] = particle; }
Функция rand
выдает несколько псевдослучайных значений в диапазоне от 0.0 до 1.0, чего достаточно для нашего контрола.
float rand(int passSeed) { int seed = 57 + passSeed * 241; seed = (seed << 13) ^ seed; seed = (seed * (seed * seed * 15731 + 789221) + 1376312589) & 2147483647; seed = (seed * (seed * seed * 48271 + 39916801) + 2147483647) & 2147483647; return ((1.f - (seed / 1073741824.0f)) + 1.0f) / 2.0f; }
Теперь нам нужно нарисовать частицы. Время жизни каждой частицы определяет интенсивность ее цвета при перемещении по холсту, в то время как общий прогресс влияет на интенсивность цвета всех частиц в облаке. Этот процесс контролируется сенсорной анимацией, которая начинается, когда пользователь прикасается к экрану, и заканчивается, когда он отпускает его.
Чтобы нарисовать, мы перебираем пиксели в квадрате размером 200 на 200 и визуализируем только те пиксели, которые попадают в круг.
В данной реализации вместо использования координат пикселя для вычисления радиуса мы используем его порядковый номер в цикле for
.
kernel void drawParticles ( texture2d<half, access::write> output [[ texture(0) ]], device Particle *particles [[ buffer(0) ]], constant ParticleCloudInfo &info [[ buffer(1) ]], uint id [[ thread_position_in_grid ]] ) { ... half4 color = half4(particle.color) * (lifespan / 100) * info.progress; uint2 pos = uint2(position.x, position.y); for (int y = -100; y < 100; y++) { for (int x = -100; x < 100; x++) { float s_radius = x * x + y * y; if (sqrt(s_radius) <= particle.radius * info.progress) { output.write(color, pos + uint2(x, y)); } } } }
Синхронизация облака
Возвращаясь к интерфейсу Swift, теперь нам нужно анимировать облако частиц с помощью ключевых кадров. Поскольку мы объявили ParticleCloud
как представление, а не модификатор представления, мы по-другому преобразовали его в ключевые кадры, напрямую используя экземпляр KeyframeAnimator
. Это единственное отличие; в остальном содержание и логика анимации остаются такими же, как мы реализовали для эффекта свечения. Убедитесь, что вы поместили облако частиц поверх представления пульсации.
ZStack { Capsule() .fill(.black) .keyframeAnimator( ... ) KeyframeAnimator( initialValue: 0.0, trigger: glowAnimationID ) { value in ParticleCloud( center: dragLocation, progress: Float(value) ) .clipShape(Capsule()) } keyframes: { _ in if glowAnimationID != nil { MoveKeyframe(.zero) LinearKeyframe( 1.0, duration: 0.4 ) } else { MoveKeyframe(1.0) LinearKeyframe( .zero, duration: 0.4 ) } } ... }
Вывод
Это, безусловно, было напряженное, но увлекательное путешествие. Мы изучили различные подходы к работе с шейдерами и их применению при создании интерактивных компонентов пользовательского интерфейса, в частности, используя конвейер вычислений как простой способ реализации системы частиц.
Что делать дальше? Вы могли бы, например, провести оптимизацию, переместив рендеринг частиц из вычислительного конвейера в конвейер рендеринга, что потенциально могло бы повысить производительность. Или добавить еще больше деталей о каждой частице, включая светящуюся границу и изменения геометрии.
Как и было обещано, вот ссылка на исходный код этой анимации. Также делюсь полезными материалами для более глубокого погружения в работу с шейдерами:
- https://theswiftdev.com/memory-layout-in-swift/
- https://blog.jacobstechtavern.com/p/metal-in-swiftui-how-to-write-shaders
- https://developer.apple.com/documentation/metal/compute_passes
- https://metalkit.org/
- https://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
- https://thebookofshaders.com/
- https://iquilezles.org/articles/