その後のその後

iOSエンジニア 堤 修一のブログ github.com/shu223

GLKView の描画内容を AVAssetWriter を用いて動画としてエクスポートする

すごい雑なメモですが、ちゃんと書くにはまだ理解が足りてなくて、かといってローカルに放置するとまた同じことを一から自分で調べそうな気がするので、とりあえずアップ。


※古いコードを流用している部分もあるため、一部ObjC、一部Swiftです。

やりたかったこと

標題の通り、「GLKView の描画内容を AVAssetWriter に渡して動画として書き出す」ということがやりたかった。リアルタイムに処理する必要があって、要件としては20fps。


AVAssetWriterInputPixelBufferAdaptor オブジェクトがあって、

@property (nonatomic, strong) AVAssetWriterInputPixelBufferAdaptor *pixelBufferAdaptor;


こう生成した CVPixelBufferRef があって、

CVPixelBufferRef pixelBuffer = NULL;
CVPixelBufferPoolCreatePixelBuffer(nil, self.pixelBufferAdaptor.pixelBufferPool, &pixelBuffer);


ここに GLKView への描画内容を反映すれば、

[self.pixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:timestamp];

こんな感じで AVAssetWriter で動画エクスポートできる。


・・・ということはわかっていたけど、CVPixelBuffer に GLKView の描画内容をどうやったら渡せるのかがわからなかった。

試した方法1: テクスチャキャッシュを利用して OpenGL の FBO を書き出す

ググッてこちらで見つけた方法。以下にコードがある。

`CVOpenGLESTextureCacheCreate` と `CVOpenGLESTextureCacheCreateTextureFromImage` なるメソッドを使用してテクスチャキャッシュなるものを生成して Frame Buffer Object (FBO) を云々する(処理内容を理解できていないので適切な説明ができない)。GPUImage と同様の方法らしい。


自分のプロジェクトに移植してみたがうまく動かず、上のコードと何が違うのかにらめっこしていたが、READMEをよく読むと 元コードもちゃんと動作してない と書いてあり、リファレンスコードなしであれこれ試行錯誤する時間もないので、いったん別の方法を考えることに。

試した方法2: GLKView のスナップショットを取得する

UIView のスナップショット取得はそれなりに重い印象があったのと、リアルタイム処理(20fps)の必要がありなるべくUIKitのレイヤーで処理したくなかったので当初は思いついても頭から除外していた選択肢。


ただ方法1が頓挫して、今日中に落とし所を見つけたかったので、少々フレームレートが落ちてもいいので試してみよう、ということでこっちでやることに。


下記によると、GLKView の描画内容は `renderInContext:` ではスナップショットとれないらしい。Core Graphics だからそりゃそうか。

You need to grab the view's framebuffer pixel data using OpenGL ES. You can't do it with renderInContext:.


で、同回答にリンクがあったのが下記。

GPUImage の作者 Brad Larson さんの回答。`glReadPixels()` でピクセルデータを取り出す方法と、テクスチャキャッシュを利用する方法が提示されている。後者は方法1で断念した方向なので厳しい・・・


前者の `glReadPixels()` を利用する方法は、下記に UIImage に変換するまでのコードがあった。

- (UIImage*)snapshotRenderBuffer {

    // Bind the color renderbuffer used to render the OpenGL ES view
    // If your application only creates a single color renderbuffer which is already bound at this point, 
    // this call is redundant, but it is needed if you're dealing with multiple renderbuffers.
    // Note, replace "_colorRenderbuffer" with the actual name of the renderbuffer object defined in your class.
    glBindRenderbufferOES(GL_RENDERBUFFER_OES, viewRenderbuffer);

    NSInteger dataLength = backingWidth * backingHeight * 4;
    GLubyte *data = (GLubyte*)malloc(dataLength * sizeof(GLubyte));

    // Read pixel data from the framebuffer
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(0.0f, 0.0f, backingWidth, backingHeight, GL_RGBA, GL_UNSIGNED_BYTE, data);

    // Create a CGImage with the pixel data
    // If your OpenGL ES content is opaque, use kCGImageAlphaNoneSkipLast to ignore the alpha channel
    // otherwise, use kCGImageAlphaPremultipliedLast
    CGDataProviderRef ref = CGDataProviderCreateWithData(NULL, data, dataLength, NULL);
    CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
    CGImageRef iref = CGImageCreate(
                                    backingWidth, backingHeight, 8, 32, backingWidth * 4, colorspace, 
                                    kCGBitmapByteOrder32Big | kCGImageAlphaNoneSkipLast,
                                    ref, NULL, true, kCGRenderingIntentDefault);

    // (sayeth abd)
    // This creates a context with the device pixel dimensions -- not points. 
    // To be compatible with all devices, you're meant to keep everything as points and a scale factor;  but,
    // this gives us a scaled down image for purposes of saving.  So, keep everything in device resolution,
    // and worry about it later...
    UIGraphicsBeginImageContextWithOptions(CGSizeMake(backingWidth, backingHeight), NO, 0.0f);
    CGContextRef cgcontext = UIGraphicsGetCurrentContext();
    CGContextSetBlendMode(cgcontext, kCGBlendModeCopy);
    CGContextDrawImage(cgcontext, CGRectMake(0.0, 0.0, backingWidth, backingHeight), iref);

    // Retrieve the UIImage from the current context
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();

    UIGraphicsEndImageContext();

    // Clean up
    free(data);

    return image;
}

(結局 Core Graphics を経由するのか。。)


で、最終的に以下の回答に行きつく。


あれ、`snapshot` なんてメソッドあったっけ?と思って調べてみたら、GLKView に元から用意されていた・・・!

@property (readonly, strong) UIImage *snapshot;

最終的にうまくいった方法

guard let cisnapshot = CIImage(image: snapshot) else { fatalError() }
drawImage = cisnapshot.imageByCompositingOverImage(drawImage)

glView.ciContext.render(
    drawImage,
    toCVPixelBuffer: pixelBuffer,
    bounds: drawImage.extent,
    colorSpace: CGColorSpaceCreateDeviceRGB())

上記コードの前提

  • GLKView の `snapshot` メソッドで取得した UIImage が `snapshot` に入っている
  • `drawImage` は諸々の処理を経由した CIImage オブジェクト