ピグパーティのフロントサイドを担当しているgomachan_7と申します。ピグパーティでは、自分やフレンドのアバターと、動画や静止画のテンプレートを組み合わせて自分だけの作品が作れる「スタジオ機能」を楽しむことができます。

本記事では、Android上で動くCocos2d-x製ゲームの画面録画機能の実装と、それに伴うOSバージョンの差異や機種依存問題にどのように対処していくか取り上げます。

スタジオ録画機能

まずはスタジオ機能における動画の録画の様子を紹介します。 図のように、自分やフレンドのアバターを選択し、テンプレートに乗せてが動かしている様子をその場で録画して保存します。

スタジオ機能利用イメージ

Cocos2d-x製ゲームで画面を録画するための方針

OpenGLのバッファを取得する

ピグパーティはCocos2d-xというゲームエンジンの上に実装されています。Cocos2d-xは内部的にはOpenGL ES2.0を利用しゲーム画面を描画する仕組みを提供しており、そのインタフェースがC++言語で定義されています。

Cocos2d-xの RenderTexture というAPIを利用すると、対象オブジェクトをオフスクリーンレンダリングできます。これにより得られたOpenGLのバッファを操作してオブジェクトの見た目を加工したり、1枚の画像として保存することができます。 このバッファを動画の1コマとして利用することで録画機能が実現できるのではないかと考えました。

エンコーダの選定

バッファをH.264コーデック/MP4コンテナ等、スマホで再生可能な動画として吐き出すためにはエンコーダが必要になります。 Androidには MediaCodec とよばれるAPI群がAndroid4.1(APIレベル16)以上に実装されており、低レベルなメディア機能にアクセスすることができます。 これを利用すると、Androidのハードウェアを利用した高速なエンコーディングが可能になります。現実的に、エンコード速度を考えるとハードウェアエンコード以外の選択肢はなく、MediaCodecの利用は避けられません。つまり、最終的に動画を生成する処理を記述する場所はJavaの上ということになります。 ここで、RenderTexureによってCocos2d-x(C++)上で得られたバッファをAndroidのMeciaCodec(Java)に流してあげる必要がありますが、毎フレーム発生するC++とJava間でのJNIのオーバヘッドを考慮し、録画のためのバッファ取得も直接Java上でやってしまおうという結論に至りました。

方針のまとめ

  • JAVA上でCocos2d-xの力を借りずにOpenGLのバッファを取得する
  • 得られたバッファはMediaCodecを利用してハードウェアエンコードする

OpenGLとバッファ操作

Java上でどのようにしてCocos2d-xの描画バッファを取得するかが問題になってきます。幸いなことに、Cocos2d-xはAndroidの GLSurfaceView を利用してゲーム画面のレンダリングをおこなっています。 Cocos2d-xが実装する GLSurfaceView.Renderer を継承した Cocos2dxRenderer.java にはフレームが描画される度に onDrawFrame というコールバックメソッドが存在し、毎フレームの描画タイミングがJava上で正確に把握できます。

バッファの流し先を画面からエンコーダへ変える

「画面からエンコーダへ変える」とは「オンスクリーンレンダリング後にオフスクリーンレンダリングする」ということです。 OpenGLはダブルバッファリング方式で描画しており、Cocos2d-xは一旦裏バッファにレンダリングした後に、画面のリフレッシュレートに応じた描画タイミングが来たところでバッファの交換をおこなうことで実際の画面に内容が出ていきます。

このとき、表バッファの向き先を、画面ではなくAndroidのエンコーダ(オフスクリーン)に変更することが可能です。 Androidが提供する Surface とよばれる描画用バッファをOpenGLの描画先に変更し、エンコーダとSurfaceを結びつけることでCocos2d-xのレンダリング結果をエンコーダに流すことができます。以下の図をみてください、これが録画中毎フレーム実行されます。1、2はCocos2d-xのゲームループ内で起こり、その直後に onDrawFrame が呼ばれるので、3、4、5の処理をこちらで実装します。

1フレームの間に起こること

この、「描画先を画面に変える<->エンコーダに変える」という切り替え処理を簡略化するためにOpenGLのAPIラッパを用意しました。OpenGLはステートマシンなので、APIを叩くことでCocos2d-xが利用しているOpenGLのコンテキストをどこからでも簡単に取得できます。

public class GL {
  private EGL10 mEgl;
  private EGLDisplay mEglDisplay;
  private EGLContext mEglContext;
  private EGLSurface mEglSurface;

  // Grab Default Window Surface
  public GL(){
    mEgl = (EGL10)EGLContext.getEGL();
    mEglDisplay = mEgl.eglGetCurrentDisplay();
    mEglContext = mEgl.eglGetCurrentContext();
    mEglSurface = mEgl.eglGetCurrentSurface(EGL10.EGL_DRAW);
  }

  // For Input Surface
  public GL(Surface surface){
    mEgl = (EGL10)EGLContext.getEGL();
    mEglDisplay = mEgl.eglGetCurrentDisplay();
    mEglContext = mEgl.eglGetCurrentContext();
    mEglSurface = mEgl.eglCreateWindowSurface(mEglDisplay, chooseConfig(), surface, null);
  }

  public EGLSurface getEglSurface(){
    return mEglSurface;
  }

  public void makeCurrent() {
    mEgl.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext);
  }

  public boolean makeCurrentReadFrom(EGLSurface readSurface) {
    return mEgl.eglMakeCurrent(mEglDisplay, mEglSurface, readSurface, mEglContext);
  }

  public boolean swapBuffers() {
    return mEgl.eglSwapBuffers(mEglDisplay, mEglSurface);
  }
}

使い方はこんな感じです。

MediaCodec encoder = MediaCodec.createEncoderByType("video/avc");
Surface inputSurface = encoder.createInputSurface();
GL defaultWindowSurface = new GL(); // grab default
GL inputWindowSurface = new GL(inputSurfae); // specify input

// 画面とエンコーダに同じ内容を交互に流し込む
for(;;) {
  // バッファ流し先を画面へ戻す
  defaultWindowSurface.makeCurrent();
  GLES20.glViewport(0, 0, mWinWidth, mWinHeight);

  // draw to screen
  drawSomthingToGL();
  dfaultWindowSurface.swapBuffer();

  // 流し先をエンコーダに変更
   inputWindowSurface.makeCurrent();
   GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
   GLES20.glViewport(0, 0, mWinWidth, mWinHeight);

  // draw to encoder
  drawSomethingToGL();
  inputWindowSurface.swapBuffer();
}

1フレームの間に同じ描画内容を画面とエンコーダに2度流す方法

google/grafikaサンプル が非常に参考になりました。

主に以下の3パターンがあります。

  • Cocos2d-xで2回レンダリングする
  • 1度レンダリングしたものをテクスチャ化し、使い回す
  • glBlitFramebufferを利用する

ピグパーティでは Cocos2d-xで2回レンダリングする 方式を採用しました。 これは、シンプルに1フレーム分の時間の間にCcocos2d-xで同じシーンやオブジェクトを2回 visit() します。1度目のレンダリングはフレームワークが設定されたフレームレートに従い自動でおこない、その後 Cocos2dxRenderer.onDrawFrame() コールバックが呼ばれたタイミングでバッファの流し込み先をエンコーダに切り替え、もう一度手動で visit() を呼ぶという上の図で紹介した方式です。

パフォーマンス低下の懸念がありましたが、フレーム落ちが発生しないレベルであり、問題ないことがわかりました。 その他の手法では検証方法が悪かったのか,テクスチャが乱れてしまったり、そもそもcocos2d-xが利用するOpenGLのバージョンの都合上blitbufferが利用でないなどの理由により採用できませんでした。

エンコーダの実装

MediaCodecはエンコーダにアクセスするインタフェースしか提供していないので、ピグパーティの要件にあったエンコーダの挙動を自前で実装する必要がありました。詳細は割愛しますが、こちらが参考になります。

録画機能アーキテクチャ

以下のスライドに示した図が録画機能の全容です。1フレームを録画するための一連の処理の流れも説明しています。MIAMIとはピグパーティの開発コードです。

Android4.3未満に対応する

前項までで紹介した実装はAndroid4.3以上に限った実装です。特にMediaCodecの挙動がAndroid4.3未満ではかなり異なっており、より低レベルかつ高負荷な実装方法を採る必要が出てきます。

ピグパーティがサポートするバージョンは Android4.0.3以上 なので、このままではスタジオ機能の動画テンプレートを利用できないユーザが多数出てきてしまいます。 開発当時の2016年5月現在では、Android4.3未満のOSシェアは20%弱と安易に無視できない数字であり、対応していこうという話になりました。この半年でシェアはかなり落ちてきていますが、未だに15%のユーザが利用しています。

Androidバージョン別シェア

エンコーダの実装をストラテジ化する

先ほど紹介したAndroid4.3以上の実装と共通のインタフェースをもつエンコーダをAndroid4.3未満用に用意しました。

public abstract class Encoder {
  protected EncoderListener mListener;

  abstract public boolean initialize(int width, int height, float ratio, int bitRate, File outputFile, boolean isLive) throws IOException;

  abstract public void startEncoder();

  abstract public void stopEncoder(boolean withSaving);

  abstract public boolean isRunning();

  abstract public void beginCaptureFrame();

  abstract public void endCaptureFrame();

  void setEncoderListener(EncoderListener listener) {
    mListener = listener;
  }

  interface EncoderListener {
    void onRecorded(String path);
    void onRecordFailure(MediaType errorType);
  }
}

これらストラテジはランタイムでOSバージョンを判別して振り分けます。 また、機種依存問題に対応する特殊なストラテジが何個か用意されています。これは開発中に発覚し、利用ユーザが多いと思われる端末に対して実装したものや、リリース後のお問い合わせに対応する形で実装したものがあります。 対応方法は様々で、特定のコーデック名を明示しないと動かない例もありました。

public enum RecordingType {
  UNKNOWN(-1, ""),
  FULL_NATIVE(0, ""),
  MEDIA_CODEC(1, ""),
  DISABLED(2, ""),
  MEDIA_CODEC_USING_SEC_AVC_ENCODER(3, "OMX.SEC.AVC.Encoder"),
  SPECIAL_COLOR_ORDER_RGB(4, ""),
  ;

  private int typeId;
  private String codecName;
  private RecordingType(int typeId, String codecName) {
    this.typeId = typeId;
    this.codecName = codecName;
  }
}

OSバージョンで切り分けたストラテジに限っては上位互換性があり、Android4.3以上の端末でもAndroid4.3未満のストラテジで録画することが可能です。また,バージョンの高い最新の端末であっても,稀に通常の実装でうまくいかず,特殊なストラテジに振り分けるとうまくいく場合もあり、未知の機種に対して既存の機種依存ストラテジを適用させることになる可能性もあります。 ピグパーティでは、端末とOSの組み合わせによるブラックリスト方式で、利用するストラテジを振り分けるWebAPIを用意しました。

こうすることで,アプリの修正リリースを待たずに問題を解決できる可能性が高まります。今回は特に機種依存問題が生じ易い機能を実装していたので、こういった工夫があると安心です。

こんな場合にもさくっと対応できるのは素晴らしいです。

Androidバージョン別シェア

Android4.3未満対応のつらい点

全てはAndroid MediaCodec stuff FAQのQ9に書かれています。

その1. MediaCodecにOpenGLのバッファをSurface経由で渡せない

端末が搭載しているハードウェアエンコーダは、入力として受け付けるカラーフォーマットにばらつきがあります。またカラーフォーマットの数も膨大で、手動で分岐したりバッファを該当のカラーフォーマットに変換する作業もかなり遅く、困難です。以下のコードはGLReadPixelsでえられたRGB配列をYUV420配列へ変換するためのものです。非常に処理が重いため、フレームが多少落ちます。

public class RgbToYUV420 {

  public static byte[] asPlanar(byte[] rgba, int width, int height) {
    final int frameSize = width * height;
    int yIndex = 0;
    int uIndex = frameSize;
    int vIndex = frameSize + (frameSize / 4);
    int r, g, b;
    int index = 0;
    byte[] yuv420p = new byte[width * height * 3 / 2];

    for (int j = 0; j < height; ++j) {
      for (int i = 0; i < width; ++i) {
        // rとbの位置が逆。また,pixelの上下が逆なので変換する。
        int pixelIdx = ((height - j - 1) * width + i) * 4;
        r = rgba[pixelIdx] & 0xff;
        g = rgba[pixelIdx + 1] & 0xff;
        b = rgba[pixelIdx + 2] & 0xff;

        // transform to YUV
        yuv420p[yIndex++] = (byte)(((66 * r + 129 * g + 25 * b) >> 8) + 16); // y 
        if (j % 2 == 0 && index % 2 == 0) {
          yuv420p[uIndex++] = (byte)(((112 * r + -94 * g + -18 * b) >> 8) + 128); // u
          yuv420p[vIndex++] = (byte)(((-38 * r + -74 * g + 112 * b) >> 8) + 128); // v
        }

      index++;
    }
  }

  return yuv420p;
}

MediaCodecに対し,Surface経由でバッファを入力すると、ハードウェアの差異を吸収してくれる上、処理も早くこちらは何も考える必要がなくなります。 Android4.3未満はSurfaceを利用したバッファの流し込みに対応していないため、上記の作業を自前でやる必要がでてきます。 幸いなことに、大抵のエンコーダは YUV420PlanarYUV420SemiPlanar に対応しているため,どちらか2択に絞って処理することで話が簡単になります。

その2. H.264へエンコードできるが,MP4コンテナには自分で入れる必要がある

エンコーダが面倒をみてくれるのはバッファをH.264の生フレームに変換するところまでで、それを適切にMP4コンテナに入れる作業はこちらの責務になります。

その3. ストライド問題

スタジオ機能で録画する動画は640px*640pxです。大抵のエンコーダは16pxの倍数の画像を渡してあげれば問題は起こらないようなのですが,特定の端末のみ,横幅640pxの動画を書き出すと各ピクセル行のオフセットの微妙にずれた壊れた動画が生成されてしまいます。これは機種依存問題として特殊なストラテジで対処するしかありません。

その他考慮すべき検討項目

コマ落ちを許容するか

端末のCPUの利用状況によっては稀に1,2フレームコマ落ちしてしまったり、そもそもスペックが低く常にコマ落ちしてしまう機種も相当数あります。今回紹介した実装では、ユーザが画面でみたままの動画が生成されてしまうため、コマ落ちもきっちり再現されます。 ピグパーティのスタジオ機能では、録画時だけコマ落ちを許容しないようCocos2d-xの設定を変更して,毎回再現性のある動画生成を実現しています。 そのためには、録画中は今までスキップされていたフレームも画面に表示されるよう強制させる必要があり、端末によってはスローモーションに見えてしまいます。

録音

テンプレートによってはBGMが入る動画もありますが、それは MP4Parser というライブラリであらかじめ用意していた音声と録画した動画をマージして実現しています。 今回はゲーム内の音声を録音しないので録音方法は考えませんが、動画と同時に別口で音声を録画し、最後にMP4Parserのようなライブラリで合成するのが一般的かと思われます。

まとめ

Androidの録画機能はかなり面白く実装しがいのある機能でした。またAndroid4.3未満へ対応する手段の知見があまり見つからず、試行錯誤を繰り返しながらの実装になりました。Android4系のシェアもだいぶ落ち着きつつありますが、バージョンに限らず同じような実装に悩んでいる方のヒントになれば幸いです。

2015年度入社のフロントエンジニアです。趣味でWebと音ゲーの開発しています。