spacelyのブログ

Spacely Engineer's Blog

[Kotlin] GLSurfaceViewをキャプチャーしてbitmapを取得したい

まえがき

株式会社スペースリー Androidエンジニアのふかまちです。 普段は弊社サービスのツールであるAndroidアプリ「Spacely Photo Task」を開発しています。 現在、後述するパノラマ画像の360°Viewerをキャプチャーした動画を生成する開発に取り組んでおり、 本記事では開発で得た知見を元に、GLSurfaceViewの活用方法を一部ご紹介します。

Android x OpenGLならGLSurfaceView

GLSurfaceViewとは、OpenGL ESをサポートしている、Androidの3D描画を行うためのViewです。描画にはGPUを活用していることでメインスレッドの負荷を軽減でき、高速なフレームレートでの描画が可能だという特徴があります。 弊社では、パノラマ画像の360°Viewerで使用しています。

(参考)GLSurfaceViewを使ったパノラマ画像の360°Viewer

CustomRendererでのbitmap生成

今回360°Viewerを動画化するにあたって、以下のステップを設定しました。

  1. viewerの表示領域をbitmapとしてフレーム単位で取得
  2. 取得したbitmapのlistを繋ぎ合わせて、videoへ変換

本記事はステップ1のbitmapsをどう作ったかという部分になります。

GLSurfaceViewクラスはRendererを持っています。GLSurfaceViewは通常、このInterfaceを継承したclassを作成し、setRendererを呼び出してRendererをGLSurfaceViewに渡します。 Rendererにより、以下の制御が可能です。

  • onDrawFrame(gl: GL10):現在のフレーム描画。フレーム毎に呼ばれる。
  • onSurfaceChanged(gl: GL10, width: Int, height: Int):GLSurfaceViewの表示サイズが更新されたら呼ばれる。
  • onSurfaceCreated(gl: GL10, config: EGLConfig):GLSurfaceView作成時に呼ばれる。

GL10を活用できそうなので、以下のように書きました。

override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {
  super.onSurfaceCreated(gl, config)
  this.gl = gl
}

override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) {
  super.onSurfaceChanged(gl, width, height)
  this.width = width
  this.height = height
  val buffer = ByteBuffer.allocateDirect(width * height * 4)
  buffer.order(ByteOrder.nativeOrder())
  this.buffer = buffer.asIntBuffer()
}

override fun onDrawFrame(gl: GL10) {
  super.onDrawFrame(gl)
  createBitmap()
}

private fun createBitmap() {
  gl?.let {
    val intArray = IntArray(width * height)
    buffer.position(0)
    it.glReadPixels(0, 0, width, height, GL10.GL_RGBA, GL10.GL_UNSIGNED_BYTE, buffer)
    buffer.get(intArray)
      
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    buffer.position(0)
    bitmap.copyPixelsFromBuffer(buffer)
    buffer.clear()
      
    listener.setBitmap(bitmap)
  }
}

GL10を保持させて、シンプルにbitmapを生成させることができました。 結果は以下のとおりです。

生じた課題と対処法

生成物から、以下3つの課題を考えました。

画像が上下反転している

  GLSurfaceViewからbitmapを取得すると、下から上に向かってpixelを取得するので、そのままだと上下反転になるようです。Matrixを使ってbitmapを変換させる必要がありました。

val matrix = Matrix()
matrix.setScale(1f, -1f) //Y軸を反転させる
val flippedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) //持っているbitmapを変換する

縦横比がうまく反映されていない

  扱うGL10に対して適切に高さと横幅を指定しないと、正方形になってしまうようです。glViewPortをsetする必要がありました。

gl.glViewport(0, 0, width, height)

フレームレートを制限できていない

  今のままだと過剰にbitmapを生成してメモリを圧迫しかねないので、30fps~45fps程度に制限する必要がありました。

val currentTime = System.currentTimeMillis()
val elapsedTime = currentTime - lastCalledPreviewTime
if (elapsedTime >= 1000 / fps) { // 1000msあたり何回呼ぶかを制限
  lastCalledPreviewTime = currentTime
  //do something
}

修正

上記解消案を反映した結果が、以下のとおりです。

override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {
  super.onSurfaceCreated(gl, config)
  this.gl = gl
}

override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) {
  super.onSurfaceChanged(gl, width, height)
  gl.glViewport(0, 0, width, height) //表示領域
  this.width = width
  this.height = height
  val buffer = ByteBuffer.allocateDirect(width * height * 4)
  buffer.order(ByteOrder.nativeOrder())
  this.buffer = buffer.asIntBuffer()
}

override fun onDrawFrame(gl: GL10) {
  super.onDrawFrame(gl)
  val currentTime = System.currentTimeMillis()
  val elapsedTime = currentTime - lastCalledPreviewTime
  if (elapsedTime >= 1000 / fps) { // フレームレート制限
    lastCalledPreviewTime = currentTime
    createBitmap()
  }
}

private fun createBitmap() {
  gl?.let {
    val intArray = IntArray(width * height)
    buffer.position(0)
    it.glReadPixels(0, 0, width, height, GL10.GL_RGBA, GL10.GL_UNSIGNED_BYTE, buffer)
    buffer.get(intArray)
      
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    buffer.position(0)
    bitmap.copyPixelsFromBuffer(buffer)
    buffer.clear()

    val matrix = Matrix()
    matrix.setScale(1f, -1f) //上下反転
    val flippedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    listener.setBitmap(flippedBitmap)
  }
}

期待した結果を得ることができました。

最後に

spacelyでは一緒に働いてくださる方を大募集中です。