Геометрические фильтры: «игрушечные» шейдеры Metal. Анимация процессинга.

Параллельные прямые не пересекаются, если вы не наклоняете одну из них или обе.
И да, никогда не пейте перед дифференцированием!
(Народная мудрость)

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

Ко всему прочему, до этого случая, мне никогда не приходилось заниматься практической геометрией и компьютерной графикой, да и интереса я в этом не находил. Но тут внезапно настиг фан и появилась замечательная возможность попрактиковаться в графике и заодно разобраться с Metal-ческим рендеренгом более плотно. Результатом этого стала попытка создать работающее приложение, позволяющее динамически модифицировать геометрию фотографии: размеры (кроп), область видимости (сдвиг, масштаб), горизонт (поворот). А еще, в одном из следующих постов, поэкспериментируем с исправлением геометрических искажений (warp) — это, если помните, когда прямоугольник становится просто многоугольником.

При этом при всём, будем продолжать думать в парадигме image processing и работать с геометрией как с фильтрами — называть такие фильтры геометрическими, с вытекающими отсюда возможностями менять их параметры в реальном времени с отображением на экране и значит получить в виде бонуса возможность анимировать действия. И без всяких UIImageView/UIScrollView. Вот без этого всего. Только hardcode! Только Metal! Ну и немножко физики из UIKit.

Мы все любим 3D

Обычно GPU и программирование под GPU ассоциируется с 3D графикой, а 3D графика с анимацией в игрушках. А анимация с геометрией,  геометрия с линалом, а линал с матричными вычислениями и в общем так дальше по кругу. И как ни странно, но именно матричные операции в большей степени хорошо исследованы, адаптированы и наиболее часто применяются в практике решения огромного класса задач. Нам в этом плане интересны геометрические преобразования изображений, которые тоже можно рассматривать как фильтры, модифицирующие картинки в пространственном домене. По сути эта категория задач ничем не отличается от задач, которые решают разработчики игровых движков, но уже в промышленных масштабах.

Очевидно, что «игрушечные» техники работы с графикой можно адаптировать под «неигрушечные» проблемы обработки изображений. В итоге наших экспериментов получим опыт использования графических шейдеров и, заодно, симулятора физических движений UIDynamicAnimator из UIKit, а будет все это выглядет как-то так:

В целом, 3D тут совершенно не причем, конечно, и для начала нам достаточно работать в 2-мерном пространстве, но кто нам мешает в будущем задействовать акселерометр и гироскоп? Поэтому не будем мелочиться и начнем сразу думать о моделях плоского изображения в терминах 3D-объекта.

Работа с графическими примитивами в Metal

Использование графического контекста Metal прилично разобрано самой Apple, энтузиастами и профессионалами разработки видеоигр, что не удивительно. Я не буду углубляться в концепцию работы с графическими объектами. Детальный разбор есть в документации Graphics Rendering: Render Command Encoder, и блоги с примерами, тоже есть: Instanced Rendering in MetalMetal Tutorial with Swift: Getting Started. В общем, гугл вам в помощь. Из всей этой истории нам нужно иметь, по сути, не очень большой набор инструментов, но тем не менее инструментов важных: движок рендеренга объектов, несколько инструментов работы с матричными операциями для определения трансформаций изображения и движений для анимации в UI, а так же небольшой набор геометрических операций для работы с границами изображений. На этом этапе нам нужно определиться с каким объектами рендеренга мы работаем, определить их структуру понятную для Metal и набор элементарных операций над ними.

Из операций нам нужно будет уметь:

  1. Изменять масштаб
  2. Перемещать
  3. Вращать, в том числе по трем осям, для исправления перспективы
  4. Вырезать
  5. Определять границы выхода объекта за границы области выдимости частей и полностью

Все это делают в компьютерной графике с момента её возникновения, нам надо просто рутинно воспроизвести уже пройденный другими путь. В терминах 3D моделирования нашу модель можно описать как одну из сторон куба (в общем случае многогранника) с толщиной в 0. Если на поверхность этой стороны натянуть изображение в виде текстуры, то мы по сути получим все, что нам нужно для проектирования геометрического «фильтра» изображения. Можно назвать такой объект фото-пластиной, например.

Если  у вас нет времени, и все дальнейшие упражнение в графоманстве не интересны, то готовый код проекта, реализующий весь функционал, можно сразу взять из примера: ImageMetalling-11 репозитория блога ImageMetalling. Для сборки проекта потребуется pod  IMProcessing, в нем же определены структуры фильтров проекта.

Фото-пластина

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

  • Модели операторов проекции и трансформации объекта
  • Протокол работы с вершиной и модель представления множества вершин
  • Частный случай множества  вершин: фото-пластина

 

//
// Модель трансформации можно свести к одной матрице, но удобнее оперировать разными
// для разных типов трансформации. Финальная матрица будет произведением исходных.
// В этом случае будет не нужно следить за порядком выполнения операций, единственное место
// перемножения будет исполнятся в модели трансформации.
//
// Так же нам будет необходимо производить не вращение объекта,
// а вращение сцены, поскольку поворот изображения должен быть вокруг центра видимой части,
// а не центра объекта.
//
public struct IMPProjectionModel{

var projectionMatrix = float4x4(matrix_identity_float4x4)

public var fovy:Float = M_PI.float/2
public var aspect:Float = 1
public var near:Float = 0
public var far:Float = 1

// ...
}

public struct IMPTransfromModel{
// матрица вращения сцены
var rotationMatrix = float4x4(matrix_identity_float4x4)
// матрица трансляции пластины
var translationMatrix = float4x4(matrix_identity_float4x4)
// матрица масштабирования
var scaleMatrix = float4x4(matrix_identity_float4x4)

// матрица проекции камеры
public var projection = IMPProjectionModel()

// ...

public var matrix:float4x4 {
//
// результирующая трансформациия для шейдера, как видно вращение производится до перемещения
//
return projection.matrix * (rotationMatrix * translationMatrix * scaleMatrix)
}

//...
}

Полностью код модели трансформации. Линейные операторы вращения, перемещения и масштабирования реализованы через расширение матриц float4x4 SIMD-библиотеки доступной не только на платформах Apple. Все тоже самое, также, можно реализовать через методы GLKMatrix4. Сути решения задачи это не меняет. Мне больше нравится набор готовых операций с simd-объектами (float2, float3, float4x4 и т.п.) обернутыми расширениями Swift — лаконично и удобно. Тем более, что они прозрачно совместимы с аналогичными объектами Metal Shading Language.

///
/// Вершину задаем как структуру в общем .h-файле для проекта Swift и Metal
///
typedef struct {
/// в позиция вершины в пространстве модели
float3 position;
/// координата текстуры модели
float3 texcoord;
} IMPVertex ;

///
/// Специально для фрагментного шейдера задаем структуру вершин фрагментов.
/// В Metal API, структуры вершинных шейдеров и фрагментных могут быть произвольными.
/// Но в нашем случае все достаточно просто и традиционно.
///
typedef struct {
float4 position;
float2 texcoord;
} IMPVertexOut;

///
/// Расширяем модель вершины в Swift-овом контексте
public extension IMPVertex{
public init(x:Float, y:Float, z:Float, tx:Float, ty:Float){
// будем передавать только позицию вершины
self.position = float3(x,y,z)
// и координаты связанной с ней тектсуры
self.texcoord = float3(tx,ty,1)
}

/// Буфер вершины подготовленный для передачи в шейдер
public var raw:[Float] {
return [position.x,position.y,position.z,texcoord.x,texcoord.y,1]
}
}

Вершины соберем в массив и определим базовый набор операций над ними через протокол:

/// Вершины
public protocol IMPVertices{
var vertices:[IMPVertex] {get}
}

// Основные свойства
public extension IMPVertices{

/// Представление вершин в виде буфера для графического шейдера
public var raw:[Float]{
var vertexData = [Float]()
for vertex in vertices{
vertexData += vertex.raw
}
return vertexData
}

/// Размерность
public var count:Int {
return vertices.count
}

/// Длина буфера
public var length:Int{
return vertices.count * sizeofValue(vertices[0])
}

/// Координаты точек проекции на плоскость XY
///
/// - parameter model: 3D модель трансформации
///
/// - returns: список точек проекции
public func xyProjection(model model:IMPTransfromModel) -> [float2] {
var points = [float2]()
for v in vertices {

let xyzw = float4(v.position.x,v.position.y,v.position.z,1)
let result = model.matrix * xyzw
let t = (1+result.z)/2
let xy = result.xy/t

points.append(xy)

}
return points
}

}

 

Зададим структуру четырех-угольника для расчета частных случаев геометрических отношений фото-пластины и её области видимости:

/// Определим четырех угольник через относительные координаты его вершин.
/// По факту - это прокси объект фото-пластины как множества вершин
/// представленной вершинами двух вписанных треугольников.
/// Такое представление немного сузит и упростит, в дальнейшем,
/// решение частных случаем геометрических задач:
/// 1. пересечение сторон четырех-угольников
/// 2. нахождение относительного сдвига
/// 3. нахождение матрицы деформации и т.п.
///
public struct IMPQuad {
/// Left bottom point of the quad
public var left_bottom = float2( -1, -1)

/// Left top point of the quad
public var left_top = float2( -1, 1)

/// Right bottom point of the quad
public var right_bottom = float2( 1, -1)

/// Right top point of the quad
public var right_top = float2( 1, 1)
}

Ну и наконец саму пластину:

///
/// Модель пластины в отношениях через вершины треугольников
///
public class IMPPhotoPlate: IMPVertices{

/// Вершины пластины
public let vertices:[IMPVertex]

/// Соотношение сторон
public let aspect:Float

///
/// Проекция пластины в виде четырех-угольника на плоскость XY окна отображения,
/// которое можно воспринимать как просмотровый стол операторской...
///
public func quad(model model:IMPTransfromModel) -> IMPQuad {
let v = xyProjection(model: model)
var q = IMPQuad(left_bottom: v[1], left_top: v[0], right_bottom: v[2], right_top: v[5])
q.aspect = aspect
return q
}

public init(aspect a:Float = 1, region r:IMPRegion = IMPRegion()){
aspect = a
region = r

// Вершины треугольников фото-пластины
let A = IMPVertex(x: -1*aspect, y: 1, z: 0, tx: region.left, ty: region.top) // left-top
let B = IMPVertex(x: -1*aspect, y: -1, z: 0, tx: region.left, ty: 1-region.bottom) // left-bottom
let C = IMPVertex(x: 1*aspect, y: -1, z: 0, tx: 1-region.right, ty: 1-region.bottom) // right-bottom
let D = IMPVertex(x: 1*aspect, y: 1, z: 0, tx: 1-region.right, ty: region.top) // right-top

vertices = [
A,B,C, A,C,D,
]
}
}

Вот собственно все, что нужно для подготовки объектов хостовой системы для передачи в шейдеры Metal.

Графические шейдеры геометрического фильтра

Вся вычислительная часть работы с геометрией будет производиться, конечно же, в коде приложения, но вот расчет каждой позиции объекта, который нам нужно получить на выходе в виде новой текстуры производим в графических функциях. Графические функции в MSL устроены точно также как и в OpenGL — разбиты на два типа: вершинные, обрабатывающие поток вершин объекта, и фрагментные, формирующие финальную текстуру на сформированном объекте. Каждый объект должен состоять из графических примитивов. В нашем случае это склеенные по одной из сторон два треугольника. Все. Хотя это мог бы быть и более сложный объект, загруженный из mesh-файла. Но, повторюсь, не будем усложнять, на этом этапе мы имеем дело с простым вырожденным случаем: четырех-угольником (именно 4-х угольником, а не прямоугольником, в нескольких последующих статьях нам это обобщение пригодится. Во первых: это правильно, а во вторых: как вы наверняка знаете, не только фото-отпечаток может быть не прямоугольным, но и проекция изображения на пластину тоже, а значит нам нужны операции с произвольным многоугольником, ну или, хотя бы, с 4-х для начала, но об этом чуть позже).

// Вершинные функции на входе в терминах MSL получают данные из памяти устройства и
// адресуются через квалификаторы с номерами буферов задаваемых в коде приложения.
// Код приложения должен подготовить и передать в GPU массив вершин объекта и в нашем
// случае матрицу операций над координатами объекта.
//
// Вершинная функция всегда возвращает структуру вершины фрагмента.
//
vertex IMPVertexOut vertex_transformation(
const device IMPVertex* vertex_array [[ buffer(0) ]],
const device float4x4& matrix_model [[ buffer(1) ]],
unsigned int vid [[ vertex_id ]]) {

IMPVertex in = vertex_array[vid]; // вершина треугольника фото-пластины
float3 position = float3(in.position); // текущая позиция впространстве

IMPVertexOut out;
// все что делаем в шейдере не очень просто, а тривиально:
// применяем результирующую матрицу к координате позиции вершины
out.position = matrix_model * float4(position,1); // новая позиция
out.texcoord = float2(float3(in.texcoord).xy); // координата текстуры

return out;
}
// Для организации конвеера данных между двумя типами функций, фрагментный шейдер
// должен определить вершину рассчитанную на первой стадии квалификатором stage_in.
// Ну так вот придумали разработчики Metal Shading Language.
// Вернуть мы всегда должны цвет текстуры в конкретной точке фрагмента.
//
// В качестве дополнительного бонуса передачи произвольных параметров в функции шейдеров,
// добавим оператор отображения картинки по горизонтали или вертикали фото-пластины.
// Отображение можно было бы реализовать через матричные операции с пластиной:
// просто отзеркалировать позиции, но, мне представляется более интересным и более универсальным
// выполнить трансформацию всего объекта отдельно, а зеркалирование текстуры на объекте отдельно
//
// Вот для этого просто передаем флаг-flip: немудреное правило перестановки координат текстуры.
//
fragment float4 fragment_transformation(
IMPVertexOut in [[stage_in]],
const device float4 &flip [[ buffer(0) ]],
texture2d<float, access::sample> texture [[ texture(0) ]]
) {
constexpr sampler s(address::clamp_to_edge, filter::linear, coord::normalized);

float2 flipHorizontal = flip.xy;
float2 flipVertical = flip.zw;

float2 xy = float2(flipHorizontal.x+in.texcoord.x*flipHorizontal.y, flipVertical.x+in.texcoord.y*flipVertical.y);

return texture.sample(s, xy);
}

Геометрический фильтр

Под геометрическими фильтрами будем понимать класс объектов с протоколом определяющим все семейство фильтров наших проектов из IMProcessing.

public protocol IMPFilterProtocol:IMPContextProvider {
var source:IMPImageProvider? {get set}
var destination:IMPImageProvider? {get}
func apply() -> IMPImageProvider
}

В source будем закидывать исходную текстуру, в destination получать результат, из фильтров строить цепочки. В этом случае можно легко смешивать традиционные фильтры и фильтры геометрические. Однако для геометрического фильтра нам было бы неплохо определить несколько сервисных классов:

  • прокси к конвееру команд к контексту исполнения графических шейдеров с соответствующими свойствами необходимыми для последующего рендеренга исходных текстур в целевые
  • класс рендеринга изображений в устройстве по сконструированным из вершин объектами и модели трансформации

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

///
/// Класс "проксирующий" команды от хостовой системы в
/// Metal Shading Language
///
public class IMPGraphics: NSObject, IMPContextProvider {

///
/// Имя вершинной функции в коде MSL
///
public let vertexName:String
///
/// Имя фрагментной функции в коде MSL
///
public let fragmentName:String
///
/// Контекст устройства GPU
///
public var context:IMPContext!

public lazy var library:MTLLibrary = {
return self.context.defaultLibrary
}()

/// Под pipeline state в Metal подразумевается протокол обеспечивающий настройку
/// и конфигурацию очереди трансляции команд ядрам GPU для исполнения растеризации
/// объекта.
///
/// Здесь мы конструируем "трубопровод" команд с необходимыми нам настройками, в том числе
/// описываем конструкцию структуры вершин передаваемых из памяти CPU в область памяти GPU
///
public lazy var pipeline:MTLRenderPipelineState? = {
do {
///
/// Дескрпитор конфигуратора "трубы"
///
let renderPipelineDescription = MTLRenderPipelineDescriptor()

///
/// Настраиваем трубу команд рестеризатора: тут про структутру вершины
///
renderPipelineDescription.vertexDescriptor = self.vertexDescriptor

///
/// формат пикселов текстуры
///
renderPipelineDescription.colorAttachments[0].pixelFormat = IMProcessing.colors.pixelFormat

///
/// Имя вершинной функции MSL из библиотеки шейдеров
///
renderPipelineDescription.vertexFunction = self.context.defaultLibrary.newFunctionWithName(self.vertexName)

///
/// Имя фрагментной функции
///
renderPipelineDescription.fragmentFunction = self.context.defaultLibrary.newFunctionWithName(self.fragmentName)

///
/// Собственно труба для приема команд из приложения для закидывания в контекст GPU и
/// их исполнения на устройстве
///
return try self.context.device.newRenderPipelineStateWithDescriptor(renderPipelineDescription)
}
catch let error as NSError{
fatalError(" *** IMPGraphics: \(error)")
}
}()

public required init(context:IMPContext, vertex:String, fragment:String, vertexDescriptor:MTLVertexDescriptor? = nil) {
self.context = context
self.vertexName = vertex
self.fragmentName = fragment
self._vertexDescriptor = vertexDescriptor
}

//
// Вот тут про то как устроен буфер вершины. Это такая удобная фишка, которая, похоже есть
// пока только в Metal - мы можем управлять тем что и как закидываем в GPU. И не напрягаясь
// будем интерпретировать структуры в MSL именно так как описали в хостовой части приложения.
//
// Это дико удобно, прозрачно и красиво.
//
lazy var _defaultVertexDescriptor:MTLVertexDescriptor = {
var v = MTLVertexDescriptor()
v.attributes[0].format = .Float3;
v.attributes[0].bufferIndex = 0;
v.attributes[0].offset = 0;
v.attributes[1].format = .Float3;
v.attributes[1].bufferIndex = 0;
v.attributes[1].offset = sizeof(float3);
v.layouts[0].stride = sizeof(IMPVertex)

return v
}()

var _vertexDescriptor:MTLVertexDescriptor?
public var vertexDescriptor:MTLVertexDescriptor {
return _vertexDescriptor ?? _defaultVertexDescriptor
}
}

 

Растеризатор объекта запускающего графические функции на устройстве:

///
/// Класс объекта-исполняющего команды растеризации модели объекта по модели его трансформации
///
public class IMPRenderNode: IMPContextProvider {

///
/// Модель трансформации, см. выше
///
public var model:IMPTransfromModel {
return currentMatrixModel
}

///
/// Конструктор растеризатора по модели объекта из вершин
///
public init(context: IMPContext, vertices: IMPVertices){
self.context = context
defer{
self.vertices = vertices
self.currentMatrixModel = matrixIdentityModel
}
}

/// Метод запускающий процесс вычислений растеризации в GPU
///
/// - parameter commandBuffer: буфер команд контекста устройства
/// - parameter pipelineState: конвеер команд конкретного растеризатора связанного
/// с функциями в MSL
/// - parameter source: исходная текстура
/// - parameter destination: целевая текстура
public func render(commandBuffer: MTLCommandBuffer,
pipelineState: MTLRenderPipelineState,
source: IMPImageProvider,
destination: IMPImageProvider
) {

currentDestination = destination

if let input = source.texture {
if let texture = destination.texture {
render(commandBuffer, pipelineState: pipelineState, source: input, destination: texture)
}
}
}

public func render(commandBuffer: MTLCommandBuffer,
pipelineState: MTLRenderPipelineState,
source: MTLTexture,
destination: MTLTexture
) {

let width = destination.width
let height = destination.height
let depth = destination.depth

currentDestinationSize = MTLSize(width: width,height: height,depth:depth)

///
/// Настройка дескриптора прохода растеризатора: целевая текстура, свойтва
/// плоскости проекции и способ смешивания областей растеризации разных объектов.
///
renderPassDescriptor.colorAttachments[0].texture = destination
renderPassDescriptor.colorAttachments[0].loadAction = .Clear
renderPassDescriptor.colorAttachments[0].clearColor = clearColor
renderPassDescriptor.colorAttachments[0].storeAction = .Store

///
/// Сюда складируем команды, которые будут помещены в очередь на исполнение на устройстве
///
let renderEncoder = commandBuffer.renderCommandEncoderWithDescriptor(renderPassDescriptor)

/// Говорим, что делать с отбракованными, не попавшими на основную поверхность объекта
/// пикселами текстуры
renderEncoder.setCullMode(.Front)

renderEncoder.setRenderPipelineState(pipelineState)

/// поток вершин растерезуемого объекта
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, atIndex: 0)
/// модель трансформации
renderEncoder.setVertexBuffer(matrixBuffer, offset: 0, atIndex: 1)
/// настройка зеркалирования текстуры для фрагментной функции
renderEncoder.setFragmentBuffer(flipVectorBuffer, offset: 0, atIndex: 0)
/// исходная текстура объекта
renderEncoder.setFragmentTexture(source, atIndex:0)

/// как и почем запускаем на рисование примитивы из которых состоит растеризуемый объект
renderEncoder.drawPrimitives(.Triangle, vertexStart: 0, vertexCount: vertices.count, instanceCount: vertices.count/3)
/// отправляем закодированный конвеер в очередь
renderEncoder.endEncoding()
}

//
// При каждом обновлении вершин заполняем буфер передаваемый в контекст GPU
//
public var vertices:IMPVertices! {
didSet{
vertexBuffer = context.device.newBufferWithBytes(vertices.raw, length: vertices.length, options: .CPUCacheModeDefaultCache)
}
}

}

Фильтр трансформации  организует совместную работу двух этих классов, предоставляет API к методам трансформации (пересчитывает матрицу) и реализует протокол фильтрации. Для того, чтобы организовать полноценный фото-редактор нам пожалуй осталось реализовать последний слой: отрезание лишнего или crop. В Metal API, по мимо всего прочего, есть доступ к встроенным процедурам акселерации прямого доступа к памяти и операций над ними. В частности операций копирования текстур. Потенциально без загрузки функций, что может быть немного быстрее чем через ядра или графику. Такие операции в терминах Metal реализуют протокол blit encoder. Реализуем crop через blit-интерфейс.

<br />// ...

let blit = commandBuffer.blitCommandEncoder()

let w = texture.width
let h = texture.height
let d = texture.depth

// начальная точка прямоугольника кропа
let oroginSource = MTLOrigin(x: (self.region.left * w.float).int, y: (self.region.top * h.float).int, z: 0)

// результирующий размер
let destinationSize = MTLSize(
width: (self.region.width * w.float).int,
height: (self.region.height * h.float).int, depth: d)

if destinationSize.width != provider.texture?.width || destinationSize.height != provider.texture?.height{

//
// создаем текстуру в памяти GPU если ее нет
//
let descriptor = MTLTextureDescriptor.texture2DDescriptorWithPixelFormat(
texture.pixelFormat,
width: destinationSize.width, height: destinationSize.height,
mipmapped: false)

provider.texture = self.context.device.newTextureWithDescriptor(descriptor)
}

// копируем исходную текстуру в текстуру целевую
blit.copyFromTexture(
texture,
sourceSlice: 0,
sourceLevel: 0,
sourceOrigin: oroginSource,
sourceSize: destinationSize,
toTexture: provider.texture!,
destinationSlice: 0,
destinationLevel: 0,
destinationOrigin: MTLOrigin(x:0,y:0,z:0))

blit.endEncoding()

// ...

Фильтр редактирования геометрии и анимация действий

В этом месте, наверное, имело бы смысл остановиться на том как пересчитывать действия пользователя, т.е. координаты движений по тачскрину в координаты сцены и объекта, но, пожалуй вынесу эту тему в отдельный пост. Сегодня добьем конструирование финального фильтра и привязку его свойств трансформирующих объект к протоколу UIDynamicItem необходимому для организации анимационных «спецэффектов». Т.е. разберем как вычисления, которые реализуем в следующий раз, трансформируются в движение картинки на экране телефона. Понимаю, что выглядит это немного не последовательно, но будем считать, что в этом месте проектируем движок «сверху-вниз», т.е. сначала реализуем функционал верхнего уровня и как-бы осознаем, что нам необходимо реализовать «внизу».

Скорее всего, вы уже сталкивались с примерами организации эмуляции «физических» движений на iOS. Начиная с iOS версии 7, в UIKit доступен функционал выделенный в отдельный модуль: UIDynamicAnimator. Все эти примеры в основном показывают как двигать различные контролы или окна подклассов UIView. Но на самом деле аниматор позволяет работать не только с готовыми графическими объектами, но и вообще с произвольными кастомными классами объектов. И не обязательно имеющими графическое представление. К примеру, можно просто расчитывать траекторию движения какой-то модели с заполнением таблицы данных с последующим анализом этих данных. В каких-то учебных или исследовательских целях. По факту UIView, уже реализует протокол UIDynamicItem, и это несколько облегчает разработку примеров ее применения. В нашем случае, таким Item будет фильтр редактирования, а объектом, который в итоге будет анимироваться, фото-пластина.

Все что нужно реализовать в качестве конкретного кода протокола — обернуть работу с несколькими свойствами: center — привязка объекта к центру анимации, bounds — размеры объекта и transform — матрица трансформации объекта. Последнее свойство используется только для реализации вращательных движений.

///
/// Контейнер геометрических операцие над изобраением и
/// расширений для анимационного дивжка UIDynamicAnimator
///
public class IMPPhotoEditor: IMPFilter, UIDynamicItem{

//// MARK - Поддержка протокола динамических айтемов движка UIDynamicAnimator

///
/// Центр пластины привязываем к левому нижнему углу
///
public var center:CGPoint {
set{
if let size = viewPort?.size {
translation = float2(newValue.x.float,newValue.y.float) / (float2(size.width.float,size.height.float)/2)
}
}
get {
if let size = viewPort?.size {
return CGPoint(x: translation.x.cgfloat*size.width/2, y: translation.y.cgfloat*size.height/2)
}
return CGPoint()
}
}

///
/// Фиксируем относительный размер в относительных координатах для определения отношения
/// с другими объектами
///
public var bounds:CGRect {
get {
return CGRect(x: 0, y: 0, width: 1, height: 1)
}
}

///
/// Трансформации оставляем пустыми - будем управлять ими самостоятельно, тем более что UIDynamics
/// по факту работает только с поворотами
///
public var transform = CGAffineTransform()

// ...
}

Полный код можно исследовать непосредственно в проекте.

Теперь с фото-редактором мы можем работать как с объектом поддерживающим анимации:

///
/// Аниматор "физических" движений из UIKit
///
lazy var animator:UIDynamicAnimator = UIDynamicAnimator(referenceView: self.imageView)

///
/// Декселератор дивжения на "толкание" пластины
///
var deceleration:UIDynamicItemBehavior?

///
/// Пружинка цепляется к краям пластины при пересечении границ стола просмотра или фото-ножниц
///
var spring:UIAttachmentBehavior?

var oscilations = 0

///
/// Обработка события движения и проверка границ пластины на столе и под ножницами
///
func updateBounds(){

guard let anchor = photoEditor.anchor else { return }

let spring = UIAttachmentBehavior(item: photoEditor, attachedToAnchor: anchor)

if self.oscilations >= 1 {
//
// Снижаем осциляцию динамики при приближении к краям кропа
//
self.deceleration?.resistance = 50
return
}

spring.action = {
self.oscilations += 1
}

spring.length = 0
spring.damping = 0.5
spring.frequency = 2

animator.addBehavior(spring)
self.spring = spring
}

///
/// Двигать до границ
///
func decelerateToBonds(gesture:UIPanGestureRecognizer? = nil) {

oscilations = 0

var velocity = CGPoint()

if let g = gesture {
velocity = g.velocityInView(imageView)
let o = imageView.orientation
if UIDeviceOrientationIsPortrait(o) {
velocity = CGPoint(x: velocity.x, y: -velocity.y)
}
else if UIDeviceOrientationIsLandscape(o){
let s:CGFloat = (o == .LandscapeLeft ? 1 : -1)
velocity = CGPoint(x: s * velocity.y, y: s * velocity.x)
}
}

//
// Пересчитываем вектор скорости относительно угла наклона viewPort (камеры)
//
velocity = IMPTransfromModel.with(angle:-photoEditor.model.angle).transform(point: velocity)

velocity = velocity * UIScreen.mainScreen().scale.float

let decelerate = UIDynamicItemBehavior(items: [photoEditor])
decelerate.addLinearVelocity(velocity, forItem: photoEditor)
decelerate.resistance = 10

decelerate.action = {
let v = distance(float2(point: decelerate.linearVelocityForItem(self.photoEditor)), float2(0))
let o = distance(self.photoEditor.outOfBounds, float2(0))
if o >= 0.5 || v < 50 {
self.updateBounds()
}
}
self.animator.addBehavior(decelerate)
self.deceleration = decelerate
}

Итого

Не думаю, что такой подход сильно упрощает реализацию работы с редактором изображений, но и не усложняет катастрофически. Для себя, я вижу в таком способе одно явное преимущество перед традиционными заходами в эту область — общая стилистика кода фильтров картинки и полное разделение функций на UI-представление, действий, анимаций и непосредственно фильтрации. Более того если цепочка фильтров предполагает анализ изображения, то результат работы такой фильтрации будет виден сразу — постараюсь показать как это работает в одном из следующих постов. Enjoy!

 


Авторы блога не преследуют целей быть предельно корректным, но если заметили явную ашипку, если написали явную глупость, если что-то не понятно, есть идеи улучшения: комментируйте или пишите на: imagemetalling [*] gmail.com.

Реклама

Геометрические фильтры: «игрушечные» шейдеры Metal. Анимация процессинга.: Один комментарий

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s