Pull to refresh

Знакомство с шейдерами на примере GPUImage

Reading time 12 min
Views 31K


В этой статье я собираюсь описать разработку приложения для iphone, которое будет в реальном времени обрабатывать видео с камеры устройства. Для этого мы будем использовать GPUImage фреймворк, напишем собственный шейдер на OpenGL ES и попробуем разобраться в том, что представляют из себя фильтры для обработки изображений.


GPUImage фреймворк


GPUImage это библиотека для iOS, написанная Брэдом Ларсоном и распространяемая под BSD – лицензией, позволяющая применять фильтры и другие эффекты при помощи GPU к фильмам, живой видеосъемке и изображениям.

GPU vs CPU


Каждый iphone оснащен двумя процессорами: CPU и GPU, каждый из которых имеет свои сильные и слабые стороны.
Когда вы пишите на C или Objective-C в Xcode, вы создаете инструкции, которые будут выполняться исключительно на CPU. GPU же напротив, представляет собой специализированный чип, особенно хорошо подходящий для вычислений, которые можно разделить на множество мелких, независимых операций, таких, к примеру, как рендеринг графики. Типы инструкций для графического процессора кардинально отличаются от CPU, поэтому мы пишем код на другом языке, на OpenGL (или более точно на шейдерном языке GLSL).
Сравнивая производительность рендеринга видео на CPU и GPU, заметно, что различия просто огромны:

Частота кадров: CPU vs. GPU (Больший FPS лучше)

Вычисления GPU FPS CPU FPS Δ
Пороговое значение ☓ 1 60.00 4.21 14.3
Пороговое значение ☓ 2 33.63 2.36 14.3
Пороговое значение ☓ 100 1.45 0.05 28.7


GPUImage vs Core Image


Core Image — стандартный фреймворк для обработки изображений и видео практически в реальном времени. Появился начиная с ios 5, и для этой версии имел не такой уж большой набор фильтров (хотя для большинства задач вполне достаточно), с выходом ios 6 количество фильтров значительно увеличилось. Core Image кроме того позволяет производить обработку как на CPU, так и на GPU.

Основные преимущества GPUImage над Core Image:

  • GPUImage позволяет записывать (создавать) ваши собственные фильтры (Core Image позволяет это сделать пока только на OS X, не на iOS);
  • GPUImage быстрее чем Core Image;
  • GPUImage использует язык GLSL вместо собственного языка;
  • GPUImage это Open Source;


GPUImage к тому же еще и хороший способ начать изучение OpenGL, т.к существует масса примеров, документации и готовых решений. Вы сразу можете перейти к более увлекательным вещам, к примеру, написанию новых фильтров, и уже в скором времени увидеть результаты!

Структура GPUImage


GPUImage является по своей сути абстракцией на Objective-C вокруг конвейера рендеринга. Изображения из внешнего источника, будь то камера, сеть или диск, загружаются и модифицируются проходя через цепочку фильтров и выдавая результат в виде изображения (UIImage), непосредственного рендеринга на экран (через GPUImageVIew) или просто потока данных.



Говоря другим языком, в API GPUImage заложены тысячи приложений для камеры, которые только и ждут правильной комбинации фильтров и немного воображения.
Например, к изображению с видео камеры можно применить фильтр Color Levels для имитации различных видов цветовой слепоты и отобразить их в реальном времени.
Color Levels
GPUImageVideoCamera *videoCamera = [[GPUImageVideoCamera alloc]
    initWithSessionPreset:AVCaptureSessionPreset640x480
           cameraPosition:AVCaptureDevicePositionBack];
videoCamera.outputImageOrientation = UIInterfaceOrientationPortrait;

GPUImageFilter *filter = [[GPUImageLevelsFilter alloc] initWithFragmentShaderFromFile:@"CustomShader"];
[filter setRedMin:0.299 gamma:1.0 max:1.0 minOut:0.0 maxOut:1.0];
[filter setGreenMin:0.587 gamma:1.0 max:1.0 minOut:0.0 maxOut:1.0];
[filter setBlueMin:0.114 gamma:1.0 max:1.0 minOut:0.0 maxOut:1.0];
[videoCamera addTarget:filter];

GPUImageView *filteredVideoView = [[GPUImageView alloc] initWithFrame:self.view.bounds)];
[filter addTarget:filteredVideoView];
[self.view addSubView:filteredVideoView];

[videoCamera startCameraCapture]


Серьезно, приложение для представления фильтров, которое идет в комплекте в качестве примера для GPUImage может быть размещено на Apple Store приблизительно по 3,99 $ безо всяких изменений. А добавив интеграцию твиттера и пару звуковых эффектов, вы можете поднять цену и до каких-то 6,99$.

Вершинные шейдеры GPUImage


Шейдер — программа для графического процессора, управляющая поведением одной стадии графического конвейера и занимающаяся обработкой соответствующих входных данных.
Для более лучшего понимания, того, что происходит в основной части статьи, коснемся для начала вершинных шейдеров.
При работе с изображением большую часть времени мы имеем дело с двумерными объектами. Изображение выводится на плоскость, которая представляет собой прямоугольник. Это необходимо для OpenGL, т.к. всё существует в трехмерном пространстве. Если мы хотим что-то нарисовать, то мы сначала должны создать поверхность, где мы будем рисовать. OpenGL ES 2.0 может рисовать только треугольники (а также точки и линии, но не прямоугольники), поэтому плоскость построена из двух треугольников.
Вершинные шейдеры — это небольшая программа для обработки ОДНОЙ вершины.
Вот так выглядит стандартный вершинный шейдер на GPUImage:
Вершинный шейдер
attribute vec4 position;
attribute vec4 inputTextureCoordinate;
varying vec2 textureCoordinate;

void main()
{
   gl_Position = position;
   textureCoordinate = inputTextureCoordinate.xy;
}


В шейдер передаются три типа переменных: attributes, varyings, and uniforms. Для каждой вершины передаются свои атрибуты — положение в пространстве, текстурные координаты (как текстура будет отображаться на фигуру), цвет, нормаль и т.д.
Varying-переменные представляют собой связь между вершинным и фрагментым шейдерами. Переменные данного типа объявляются и инициализируются в вершинном шейдере и затем передаются во фрагментный. Но, т.к. фрагментный шейдер оперирует точками на всей фигуре — то данные значения линейно интерполируются. Например, если вершины левой половины фигуры имеют белый цвет, а правой — черный, то цвет фигуры будет представлять градиент от белого к черному.
Uniforms — переменные необходимы для связи шейдера с внешним миром (самой программой). Они одинаковые для всех вершин и фрагментов.
В GPUImage мы передаем два набора координат — это координаты самой плоскости и текстурные координаты. Как правило, заботиться об этом нам не придется, поэтому не будем углубляться в подробности.
Мы не будем включать вершинный шейдер в наш разрабатываемый фильтр, а будем использовать стандартный шейдер из класса GPUImageFilter.

Cоздаем новый проект GPUImage


Создадим новый проект для iphone. Для этого выполняем: File-> Project → Single View Application. Вы можете оставить или убрать галочку со storyboards и ARC.
Далее подключаем фреймворки в наш созданный проект (правой кнопкой мыши по папке с фреймворком → Add files). Затем нам следует добавить некоторые фреймворки и библиотеки, показанные на скриншоте:


Наконец, в настройках сборки проекта, нам необходимо добавить флаг -ObjC к остальным флагам компоновщика и указать расположение папки с GPUImage фреймворком в “header search paths”.
Теперь всё готово и мы можем приступать к написанию собственного фильтра! Мы собираемся создать шейдер polar pixellate и расширить его, добавив скручивание и возможность постеризации (уменьшение количества отображаемых цветов на экране).

Фильтр Polar Pixellate Posterize


Наш фильтр будет использовать полярную систему координат для пикселизации входящего изображения.
Первое, что нужно сделать, это создать новый класс, унаследованный от GPUImageFilter. Назовем его GPUImagePolarPixellatePosterizeFilter.
GPUImageFilter
#import "GPUImageFilter.h"
 
@interface GPUImagePolarPixellateFilterPosertize : GPUImageFilter {
    GLint centerUniform, pixelSizeUniform;
}
 
// The center about which to apply the distortion, with a default of (0.5, 0.5)
@property(readwrite, nonatomic) CGPoint center;
// The amount of distortion to apply, from (-2.0, -2.0) to (2.0, 2.0), with a default of (0.05, 0.05)
@property(readwrite, nonatomic) CGSize pixelSize;
 
@end



Мы собираемся передать две uniform-переменные в этом фильтре. Переменная centerUniform — это точка, откуда берет начало полярная система координат — по умолчанию это 0.5, 0.5, т.е центр экрана. Система координат в OpenGL колеблется от 0.0, 0.0 до 1.0, 1.0 с началом в нижнем левом углу (прим. речь о текстурных координатах). Значение pixellate определяет насколько большие будут 'пиксели' после примения фильтра. Поскольку мы используем полярную систему координат, значение «x» является радиусом (расстоянием от «центра»), а другое значение представляет собой угол в радианах.
И хотя GPUImageFilter берет на себя ответственность за настройку OpenGL и создания необходимых фреймбуферов, нам все же необходимо написать сам шейдер и передать в него необходимые uniform-переменные.

Наш первый шейдер


Добавьте следующий код перед строкой @ implementation:
Code
NSString *const kGPUImagePolarPixellatePosterizeFragmentShaderString = SHADER_STRING
(
 varying highp vec2 textureCoordinate;
 uniform highp vec2 center;
 uniform highp vec2 pixelSize;
 uniform sampler2D inputImageTexture;
 
 void main()
 {
     highp vec2 normCoord = 2.0 * textureCoordinate - 1.0;
     highp vec2 normCenter = 2.0 * center - 1.0;
 
     normCoord -= normCenter;
 
     highp float r = length(normCoord); // to polar coords
     highp float phi = atan(normCoord.y, normCoord.x); // to polar coords
 
     r = r - mod(r, pixelSize.x) + 0.03;
     phi = phi - mod(phi, pixelSize.y);
 
     normCoord.x = r * cos(phi);
     normCoord.y = r * sin(phi);
 
     normCoord += normCenter;
 
     mediump vec2 textureCoordinateToUse = normCoord / 2.0 + 0.5;
     mediump vec4 color = texture2D(inputImageTexture, textureCoordinateToUse );
      
     color = color - mod(color, 0.1);
     gl_FragColor = color;
  }
 );




Код шейдера заключен в макрос SHADER_STRING(), для представления его в виде строки NSString.
Несколько слов о типах данных и операциях в GLSL. Основные используемые типы данных это int, float, vector (vec2, vec3, vec4) и матрицы (mat2, mat3, mat4). Вы можете выполнять над матрицами и векторами простые арифметические операции, такие как, к примеру, сложение vec2+ vec2. Кроме того, разрешены операции по умножению вектора на число (int или float), к примеру: float*vec2 = vec2.x*float, vec2.y*float. Еще можно использовать обращение: vec4.xyz, если вы хотите получить vec3. Полный список поддерживаемых типов можно найти тут.
Давайте разберемся, что же здесь происходит. Varying textureCoordinate типа vec2 поступает к нам из вершинного шейдера по умолчанию. Uniform — переменные center и pixelSize — являются переменными, которые мы передаем из нашего класса-фильтра. И, наконец, у нас есть переменная inputImageTexture типа sampler2D. Эта uniform — переменная устанавливается у суперкласса, GPUImageFilter, и представляет собой двухмерную текстуру изображения, которое мы хотим обработать.
Возможно, вы обратили внимание, что мы все время используем классификатор highp. Это делается для того чтобы сказать GLSL об уровне точности наших типов данных. Как вы можете предположить, чем выше точность, тем больше будет точность у наших типов данных. Но это не всегда является актуальным — для простого рендера на экран сгодится и меньшая точность, что позволит производить вычисления немного быстрее. Классификаторы точности бывают lowp, mediump, highp. Более подробно узнать о точности и фактических ограничениях вы можете тут.
Шейдер всегда имеет основную функцию main (). Результатом работы фрагментного шейдера является цвет, который будет установлен для обрабатываемого фрагмента. Данный цвет в нашем случае берется из исходного изображения по координатам normCoord. Мы будем пользоваться этим значением для произведения пикселизации в зависимости от позиции в полярной системе координат.

Первое, что мы делаем, это превращаем нашу координатную систему в систему с полярными координатами. Переменная TextureCoordinate определена на промежутке от 0.0, 0.0 до 1.0, 1.0. Uniform-переменная Center определена в том же диапазоне. Для того чтобы описать наш экран в полярных координатах нам нужны координаты от -1.0, -1.0 до 1.0, 1.0. Первые две строки выполняют данное преобразование. Третья строка вычитает из центра normCoord. Т.е. мы просто произвели сдвиг координатной системы в новую точку с центром в normCoord. Найдем значение радиуса и угла phi, после чего мы снова вернемся в декартову систему координат, сместив её центр на прежнее место. Таким образом мы получим диапазон 0.0, 0.0 1.0, 1.0, который нужен для поиска текстур. Для этого вызовем функцию texture2D(), которая принимает на вход два параметра: двухмерный объект-текстуру (в нашем случае inputImage) и координаты (textureCoordinateToUse).
Наконец, мы уменьшаем цветовую гамму для красного, зеленого, синего (и альфа, но альфа всегда 1,0 в нашем случае так ...) от 256 значений для каждого компонента (16,8 млн. цветов) до 10 (1000 цветов ).
Это наш фрагментный шейдер и он будет работать очень быстро. Если бы нам потребовалось проделать ту же операцию на процессоре (CPU) для достижения такой же цели, то это заняло бы куда больше времени. Во многих случаях на GPU можно осуществить фильтрацию видео в реальном времени, что было бы невозможно с использованием CPU.

Завершаем разработку фильтра


После того, как мы написали шейдер, единственное, что нам осталось — это setter'ы для uniform — переменных. При инициализации мы передаем в суперкласс текст шейдера, определяем указатели на uniform — переменные и задаем некоторые значения по умолчанию.
Добавьте следующий код после @implementation:
Code
@synthesize center = _center;
@synthesize pixelSize = _pixelSize;
 
#pragma mark -
#pragma mark Initialization and teardown
- (id)init {
    if (!(self = [super initWithFragmentShaderFromString:kGPUImagePolarPixellatePosterizeFragmentShaderString])) {
                return nil;
    }
    
    pixelSizeUniform = [filterProgram uniformIndex:@"pixelSize"];
    centerUniform = [filterProgram uniformIndex:@"center"];
    
    self.pixelSize = CGSizeMake(0.05, 0.05);
    self.center = CGPointMake(0.5, 0.5);
    
    return self;
}



При вызове initWithFragmentShaderFromString наш шейдер проходит через соответствующие методы для проверки и компиляции, чтобы он был готов к запуску на GPU. Если бы мы таким же образом хотели подать вершинный шейдер, то для него есть такой же вызов, чтобы пройти все те же операции.
Мы должны вызвать [filterProgram uniformIndex:] для кажой uniform — переменной, содержащейся в нашем шейдере. Данный метод возвращает указатель типа Glint на Uniform-переменную, с помощью которого мы сможем устанавливать значение переменной.
Наконец, мы устанавливаем некоторые значения по умолчанию на этапе инициализации, чтобы наш фильтр работал без участия пользователя.
Последнее, что нам нужно сделать это задать setter'ы и getter'ы для наших uniform – переменных:
Code
- (void)setPixelSize:(CGSize)pixelSize
{
    _pixelSize = pixelSize;
    [self setSize:pixelSize forUniform: pixelSizeUniform program:filterProgram];
}
 
- (void)setCenter:(CGPoint)newValue;
{
    _center = newValue;
    [self setPoint:newValue forUniform: centerUniform program:filterProgram];
}



Создаем приложение


Теперь создадим простое приложение для работы с видео. Перейдем в класс View Controller который мы взяли в качестве шаблона и настроили ранее. Измените следующие строки в данном файле:
Code
#import "JGViewController.h"
#import "GPUImage.h"
#import "GPUImagePolarPixellatePosterizeFilter.h"
 
@interface JGViewController () {
    GPUImageVideoCamera *vc;
    GPUImagePolarPixellatePosterizeFilter *ppf;
}
 
@end
 
@implementation JGViewController
 
- (void)viewDidLoad {
    [super viewDidLoad];
    vc = [[GPUImageVideoCamera alloc] initWithSessionPreset:AVCaptureSessionPreset640x480 cameraPosition:AVCaptureDevicePositionBack ];
    vc.outputImageOrientation = UIInterfaceOrientationPortrait;
    ppf = [[GPUImagePolarPixellatePosterizeFilter alloc] init];
    [vc addTarget:ppf];
    GPUImageView *v = [[GPUImageView alloc] init];
    [ppf addTarget:v];
    self.view = v;

    [vc startCameraCapture];
}
 
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    CGPoint location = [[touches anyObject] locationInView:self.view];
    CGSize pixelS = CGSizeMake(location.x / self.view.bounds.size.width * 0.5, location.y / self.view.bounds.size.height * 0.5);
    [ppf setPixelSize:pixelS];
}
 
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    CGPoint location = [[touches anyObject] locationInView:self.view];
    CGSize pixelS = CGSizeMake(location.x / self.view.bounds.size.width * 0.5, location.y / self.view.bounds.size.height * 0.5);
    [ppf setPixelSize:pixelS];
}




Создаем видеокамеру с определенным разрешением и расположением GPUImageVideoCamera и наш фильтр GPUImagePolarPixellatePosterizeFilter.
И устанавливаем GPUImageView как главное view нашего view-контроллера.
Таким образом наш конвейер выглядит следующим образом: видео с камеры — фильтр пикcелизации и пастеризации (polarpixellateposterizefilter) — GPUImageView, который мы будем использовать для отображения видео на экране телефона.

На этой стадии мы уже можем запустить приложение и получить работающий фильтр. Но было бы неплохо добавить еще некоторую интерактивность! Для этого мы используем методы touchesmoved и touchesbegan, которые фиксируют нажатие, влияя на uniform-переменную pixelSize нашего фильтра.
Для получения наименьших 'пикселей' достаточно дотронуться в верхнем левом углу изображения, а для наибольших — в нижнем правом. Теперь вы можете самостоятельно экспериментировать с фильтром, для получения совершенно различных результатов.
Поздравляю! Вы написали свой первый шейдер!

Другие примеры обработки изображений с использованием шейдеров


Уменьшение уровней красного и зеленого цвета в изображении, увеличение синего:
Code
lowp vec4 color = sampler2D(inputImageTexture, textureCoordinate);
lowp vec4 alter = vec4(0.1, 0.5, 1.5, 1.0);
gl_FragColor = color * alter;


Уменьшение яркости:
Code
lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
gl_FragColor = vec4((textureColor.rgb + vec3(-0.5)), textureColor.w);



Популярное размытие изображения:
Code
mediump float texelWidthOffset = 0.01; 
mediump float texelHeightOffset = 0.01; 
 
vec2 firstOffset = vec2(1.5 * texelWidthOffset, 1.5 * texelHeightOffset);
vec2 secondOffset = vec2(3.5 * texelWidthOffset, 3.5 * texelHeightOffset);
 
mediump oneStepLeftTextureCoordinate = inputTextureCoordinate - firstOffset;
mediump twoStepsLeftTextureCoordinate = inputTextureCoordinate - secondOffset;
mediump oneStepRightTextureCoordinate = inputTextureCoordinate + firstOffset;
mediump twoStepsRightTextureCoordinate = inputTextureCoordinate + secondOffset;
 
mediump vec4 fragmentColor = texture2D(inputImageTexture, inputTextureCoordinate) * 0.2;
fragmentColor += texture2D(inputImageTexture, oneStepLeftTextureCoordinate) * 0.2;
fragmentColor += texture2D(inputImageTexture, oneStepRightTextureCoordinate) * 0.2;
fragmentColor += texture2D(inputImageTexture, twoStepsLeftTextureCoordinate) * 0.2;
fragmentColor += texture2D(inputImageTexture, twoStepsRightTextureCoordinate) * 0.2;
gl_FragColor = fragmentColor;




Начать работать с GPUImage достаточно легко и он довольно мощный чтобы воплотить всё то, о чем вы мечтали. И даже больше, GPUImage — это головокружительное число фильтров, цветовые настройки, режимы смешивания, и визуальные эффекты о которых вы могли только мечтать (или же даже не знали о их существовании). Вы можете найти кучу примеров с использованием современных фильтров включающих в себя распознавание контуров, «рыбий глаз» и тонну других крутых штук.
Исходники
Введение в OpenGL ES 2.0 (GLSL) и устройство графического конвейера: раз, два, три.
Статья является творческой адаптацией с переработкой знаний и переводом: источник 1, источник 2
Tags:
Hubs:
+39
Comments 5
Comments Comments 5

Articles