GPUImage という、GPUベースで画像や動画処理を行ってくれるiOS向けオープンソースフレームワークがあります。
2012年からある超有名OSSで、多くのアプリで使われていて実績も十分なのですが、Core Image も GPU ベースだし、WWDC 2015 のセッション によると最近は Core Image の下回りが OpenGL ES じゃなくて Metal になった(全部ではない)という話もあって、同様のフィルタが Core Image にあるなら、サードパーティ製の GPUImage を使うより、Apple 純正の Core Image を使いたい、という思いがあります。
ただどっちを使うか、を検討するにあたって、「GPUImage ではその機能をどう実現しているのか」がわからないと、比較しづらいなと。
個人的に直面している状況をもっと具体的にいうと、
- GIFアニメーションのような繰り返しのアニメーションを、カメラからの入力にリアルタイムでオーバーレイして、
- 動画としてエクスポートする
ということをやりたい場合に、AVFoundation + Core Image でやるとすると、
- カメラ入力の各フレームの CMSampleBuffer を CIImage に変換する
- Core Image でオーバーレイしたいアニメーションの当該フレーム画像をブレンドする
- GLKView および MTKView に描画する(リアルタイムプレビュー)
- AVAssetWriter でエクスポート
とやればいいなと見当がつくわけですが、GPUImage で同様のことをやりたい場合はこのへんどうなるんだろう、と。各種 BlendFilter があるのは知ってるけど、何か動画にアニメーションを合成するための素晴らしい機構があるのかどうか、とか。
そういう動機で、「ざっくり」コードを追いかけてみました。
(本当に追いかけただけで、あんまり詳しい解説とかはありません。あしからずご了承ください)
画像をオーバーレイするフィルタクラス
リポジトリに同梱されている FilterShowcase というサンプルで "blend" と付くものをためしてみると、単純に画像をオーバーレイするフィルタのクラスは GPUImageNormalBlendFilter のようです。(名前から GPUImageOverlayBlendFilter かと思いきや、試してみたところそうではない)
クラスの継承関係はこんな感じ。
GPUImageOutput <GPUImageInput>
└ GPUImageFilter
└ GPUImageTwoInputFilter
└ GPUImageNormalBlendFilter
親クラスである GPUImageTwoInputFilter は、その名の通り、入力を2つ取る(→ブレンド処理なので)フィルタ、というわけですね。
GPUImageのフィルタ処理の流れ
同じくリポジトリに同梱されている、SimpleSwiftVideoFilterExample というシンプルなフィルタ処理の実装を見てみると、次のようになっています。
videoCamera = GPUImageVideoCamera(sessionPreset: AVCaptureSessionPreset640x480, cameraPosition: .Back)
videoCamera!.outputImageOrientation = .Portrait;
filter = GPUImagePixellateFilter()
videoCamera?.addTarget(filter)
filter?.addTarget(self.view as! GPUImageView)
videoCamera?.startCameraCapture()
GPUImageVideoCamera が AVFoundation のカメラ入力をラップしたクラスであることは察しがつくので、その辺を除外して上記コードを見てみると、GPUImageにおけるフィルタ処理の肝としては、「GPUImageVideoCamera オブジェクトに、フィルタオブジェクトを addTarget:
するだけでそのフィルタが適用されるようになる」というところっぽいです。
videoCamera?.addTarget(filter)
このサンプルで使用しているフィルタはブレンド処理ではなくてピクセレートフィルタ(いわゆるモザイク)ですが、GPUImagePixellateFilter の親クラスは GPUImageFilter であり、「フィルタをかける」ということ自体の基本的な考え方はブレンド処理でもピクセレートフィルタでも同じだと思うので、この addTarget を起点として、「addTarget:
されたフィルタオブジェクトがどのようにカメラからの入力にリアルタイムで適用されるのか」という観点でコードを追ってみます。
GPUImageOutput addTarget:
前述の addTarget:
は、GPUImageVideoCamera の親クラスである GPUImageOutput で実装されています。
- (void)addTarget:(id<GPUImageInput>)newTarget;
{
NSInteger nextAvailableTextureIndex = [newTarget nextAvailableTextureIndex];
[self addTarget:newTarget atTextureLocation:nextAvailableTextureIndex];
// 略
}
- (void)addTarget:(id<GPUImageInput>)newTarget atTextureLocation:(NSInteger)textureLocation;
{
// 略
cachedMaximumOutputSize = CGSizeZero;
runSynchronouslyOnVideoProcessingQueue(^{
[self setInputFramebufferForTarget:newTarget atIndex:textureLocation];
[targets addObject:newTarget];
[targetTextureIndices addObject:[NSNumber numberWithInteger:textureLocation]];
allTargetsWantMonochromeData = allTargetsWantMonochromeData && [newTarget wantsMonochromeInput];
});
}
引数には GPUImageInput プロトコルに適合したクラスのオブジェクトを渡せるようになっており、GPUImageFilter は GPUImageInput プロトコルに適合しているので、この引数にはフィルタオブジェクトを渡せることがわかります。
@interface GPUImageFilter : GPUImageOutput <GPUImageInput>
addTargets:
から呼ばれている addTarget:atTextureLocation:
で、引数に渡したフィルタオブジェクトが targets
配列に格納されることが確認できます。
フレームごとのカメラ入力(サンプルバッファ)を受け取ったときの処理
iOSでカメラ入力に対してリアルタイム処理を行う場合、AVCaptureVideoDataOutputSampleBufferDelegate の captureOutput:didOutputSampleBuffer:fromConnection:connection
に渡されてくる CMSampleBuffer 対して処理を行うことになります。で、GPUImage ではそのへんの AVFoundation 周りの処理は GPUImageVideoCamera が持っています。
同クラスの同デリゲートメソッドの実装を見てみると、サンプルバッファを取得したら、processVideoSampleBuffer:
メソッドにそのサンプルバッファを渡し、同メソッド内で、実際の動画のフレームに対する実際のフィルタ処理を行う updateTargetsForVideoCameraUsingCacheTextureAtWidth:height:time:
を呼んでいます。
- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer;
{
// 略
if ([GPUImageContext supportsFastTextureUpload] && captureAsYUV)
{
// 略
if (CVPixelBufferGetPlaneCount(cameraFrame) > 0) // Check for YUV planar inputs to do RGB conversion
{
// 略
[self updateTargetsForVideoCameraUsingCacheTextureAtWidth:rotatedImageBufferWidth height:rotatedImageBufferHeight time:currentTime];
// 略
}
// 略
}
else
{
// 略
[self updateTargetsForVideoCameraUsingCacheTextureAtWidth:bytesPerRow / 4 height:bufferHeight time:currentTime];
// 略
}
}
そのメソッド内で、targets
に入っているフィルタ(以外もある)を逐次取り出し、newFrameReadyAtTime:atIndex:
メソッドをコールしています。
for (id<GPUImageInput> currentTarget in targets)
{
if ([currentTarget enabled])
{
// 略
if (currentTarget != self.targetToIgnoreForUpdates)
{
[currentTarget newFrameReadyAtTime:currentTime atIndex:textureIndexOfTarget];
}
}
}
フィルタの適用
newFrameReadyAtTime:atIndex:
は、GPUImageInput プロトコルで定義されているメソッドで、GPUImageFilter では次のように実装されています。
- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex;
{
static const GLfloat imageVertices[] = {
-1.0f, -1.0f,
1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f,
};
[self renderToTextureWithVertices:imageVertices textureCoordinates:[[self class] textureCoordinatesForRotation:inputRotation]];
[self informTargetsAboutNewFrameAtTime:frameTime];
}
ここで呼ばれている renderToTextureWithVertices:textureCoordinates:
は GPUImageInput プロトコルで定義されているメソッドで、GPUImageFilter では次のように実装されています。
- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates;
{
// 略
[GPUImageContext setActiveShaderProgram:filterProgram];
outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:[self sizeOfFBO] textureOptions:self.outputTextureOptions onlyTexture:NO];
[outputFramebuffer activateFramebuffer];
// 略
[self setUniformsForProgramAtIndex:0];
glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha);
glClear(GL_COLOR_BUFFER_BIT);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]);
glUniform1i(filterInputTextureUniform, 2);
glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, vertices);
glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
// 略
}
フィルタ処理の実体であるシェーダが実行されるのはこのへんのようです。
GPUImageFilter の初期化メソッドである initWithVertexShaderFromString:fragmentShaderFromString:fragmentShaderString;
の実装を見ると、
filterProgram = [[GPUImageContext sharedImageProcessingContext] programForVertexShaderString:vertexShaderString fragmentShaderString:fragmentShaderString];
という箇所があるので、filterProgram
というメンバ変数がシェーダの処理内容を持っているようです。
まとめ
addTarget:
したフィルタの行方がだいたい把握できたところで、個人的にはここまででわりと当初の目的が達成できました。正直なところ CoreVideo や OpenGL ES の細かいところは全然掴めてないのですが、とにかく
- GPUImage も、その「骨格」自体はベーシックな AVFoundation を用いたカメラ入力処理アプリである
- カメラのフレームごとの入力は
captureOutput:didOutputSampleBuffer:fromConnection:connection
の引数に CMSampleBuffer として渡されてくる - その入力を OpenGL ES でゴニョゴニョするのが GPUImage
- カメラのフレームごとの入力は
ということが確認できて、かつ、カメラ入力にアニメーションをリアルタイム合成したい場合に、リアルタイム処理とは別に、書き出すときにオフラインレンダリングするとか、複数フレームをまとめて処理とか、そういう特殊なことはやってなさそう、ということも確認できました。
というわけで、結論的には、前述した今自分がやりたいことについては Core Image で良さそうだなと。(パフォーマンス面での比較はやってませんが、どっちも処理の考え方としては(大まかには)同じようなもので、どっちもGPUで処理してるなら、純正をとりたい)
今回の目的としてはここまでですが、がっつり OpenGL について勉強したいというのもあるので、また別の観点から GPUImage のコードは読んでみたいと思います。