たぶんほとんどの人は使うことのないシリーズ第二弾(^o^)/
Surface/SurfaceTexture 経由で受け取った映像を表示できる
Drawable を作ってみたのだ?
ソースコードはGitHub上のリポジトリにあるぞよSurfaceDrawable
Androidのアプリでカメラ映像や再生した動画を表示するには
SurfaceView/GLSurfaceView/TextureView/VideoView などを使うのが普通です。でも色んな大人の事情で
Drawable で表示できたらいいなって思ったことはありませんか?さきちゃんはあります。
ということで、
Surface/SurfaceTexture 経由で受け取った映像を表示できる
Drawable を作ってみました。名付けて✨✨✨
SurfaceDrawable✨✨✨
ところで Drawable へ何かを表示しようとすると draw(Canvas)メソッドが呼び出された時に Canvas に対して描画する必要があります。そして Canvas へ映像を描画するには Canvas#drawBitmap系メソッドを使うしかありません。つまりな Surface/SurfaceTexture を経由してテクスチャとして受け取った映像を Bitmap に変換してから Canvas へ描画することになります。つまり処理の流れとしては次のようになります。
- Drawable 内で Surface/SurfaceTexture 生成
- Surface/SurfaceTexture が映像を受け取ったことを通知してもらえるように OnFrameAvailableListenerをセット
- カメラ等から Surface/SurfaceTexture 経由で映像を受け取ることができるように設定(これは Drawable 外での処理)
- Surface/SurfaceTexture が映像受け取った時に SurfaceTexture#updateTexImage/#getTransformMatrixを呼び出す
- どげんかして Bitmap に映像を読み込む
- Drawable#draw(Canvas)が呼ばれた時に映像を読み込んだBitmapを Canvas#drawBitmap 系メソッドで描画する
簡単ですね、1-2時間もあれば実装できるでしょう。
ということおしまい…じゃないよ?
実装する自体はそうたいしたことはないものの、それぞれを実装するにはいぃっぱいの予備知識が必要なうえに、実際に自分で最初から作ってみればわかるのですが、意外とハマる部分があるのです。例えば、「 Drawable 内で Surface/SurfaceTexture 生成」と簡単に書いていますが、好き勝手にどこでも Surface/SurfaceTexture を生成できるわけではありませんし、「どげんかして Bitmap に映像を読み込む」といっても下調べせずには自力で実装できない人も多いでしょう。
1.Drawable内で Surface/SurfaceTexture 生成する
SurfaceTexture が生成できれば Surface(SurfaceTexture)コンストラクタを使って簡単に Surface を生成することができます。でも SurfaceTexture を生成するには…SurfaceTexture @ Android DevelopersのPublic constructorsを見れば分かる通りtexNameというのが必要です。texNameってなんぞやねんというとOpenGL|ESでのテクスチャ名/テクスチャハンドルです。つまり SurfaceTexture を生成するにはOpenGL|ESの処理を実行できる状態でないとだめということになります。OpenGL|ESの処理を実行できる状態というのはGLコンテキストが存在するスレッド上で実行すること…スレッド上でGLコンテキストがコンテキストを生成するには…あれれ~?
OpenGL|ESの処理を実行できるようにする
専門用語を専門用語で説明するというドツボな状態に?面倒くさいので詳細は各自でグルグルしてもらうとして、おおよその処理を書くと次のようになります。
- ワーカースレッドを実装する
- ワーカースレッド上でEGLを使ってEGLコンテキストを生成する
- EGLコンテキストを使ってGLコンテキストを生成する
- OpenGL|ES関係の処理はこのワーカースレッド上で実行する
- 必要がなくなったらGLコンテキスト・EGLコンテキストを破棄する
ちなみに、 GLSurfaceView の場合はここらの面倒な処理をあらかじめ実行して必要なタイミングでコールバックしてくれるので楽ちんだったのですが Drawable 内では GLSurfaceView は使えませんので自前で実装する必要があります。
ということで SurfaceDrawableの該当コードを載せましょう(^o^)/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
/** * コンストラクタ * @param imageWidth * @param imageHeight * @param callback */ public SurfaceDrawable(final int imageWidth, final int imageHeight, @NonNull final Callback callback) { ... mEglTask = new EglTask(3, null, 0, imageWidth, imageHeight) { @Override protected void onStart() { handleOnStart(); } @Override protected void onStop() { handleOnStop(); } @Override protected Object processRequest(final int request, final int arg1, final int arg2, final Object obj) throws TaskBreak { return handleRequest(request, arg1, arg2, obj); } }; ... new Thread(mEglTask, TAG).start(); mEglTask.offer(REQUEST_RECREATE_MASTER_SURFACE); } |
あれっ!?
EglTaskってでてきちゃった?自分で作ってみればわかるけどここらへんの処理を毎回フレームワークのAPIを叩いて作るのは面倒くさいのであらかじめオレオレヘルパークラス/メソッド作っておくのが常識なのです。まじめに書くとそれだけでシリーズ記事になってしまうので、細かいことをは気にしないでおこう?
先の
EglTaskは
GLSurfaceViewで実装されているEGL/GLコンテキストの生成・破棄やワーカースレッド上での実行関係の処理と類似の処理を行うためのヘルパークラスなのです。どうしても気になる人は自分でグルグルするか、ソースコードを見ておくれ、GitHubのリポジトリlibcommon
Surface/Surfacetextureを生成する
気を取り直してSurface/Surfacetextureを生成する部分はこんな感じ、あいかわらずヘルパークラスの呼び出しばかり…
ここで大事なのは次の2つ
- SurfaceTexture へ引き渡すテクスチャは GL_TEXTURE_EXTERNAL_OES が必須で普通の GL_TEXTURE_2D テクスチャは使用不可
- Android4.1以降なら SurfaceTexture#setDefaultBufferSize を呼び出してテクスチャバッファサイズを設定しておく
この2つすごく大事、 SurfaceTexture を自前で生成するときには絶対に忘れてはいけない。これはテストに出…ないけど忘れると映像も出なくなるから?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
/** * 映像入力用SurfaceTexture/Surfaceを再生成する */ @SuppressLint("NewApi") @WorkerThread protected void handleReCreateInputSurface() { if (DEBUG) Log.v(TAG, "handleReCreateInputSurface:"); synchronized (mSync) { mEglTask.makeCurrent(); handleReleaseInputSurface(); mEglTask.makeCurrent(); if (isGLES3()) { mTexId = com.serenegiant.glutils.es3.GLHelper.initTex( GL_TEXTURE_EXTERNAL_OES, GLES30.GL_TEXTURE0, GLES30.GL_NEAREST); } else { mTexId = com.serenegiant.glutils.es2.GLHelper.initTex( GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE0, GLES20.GL_NEAREST); } mInputTexture = new SurfaceTexture(mTexId); mInputSurface = new Surface(mInputTexture); if (BuildCheck.isAndroid4_1()) { mInputTexture.setDefaultBufferSize(getIntrinsicWidth(), getIntrinsicHeight()); } mInputTexture.setOnFrameAvailableListener(mOnFrameAvailableListener); } onCreateSurface(mInputSurface); } |
どげんかしてBitmapに映像を読み込む
2, 3, 4は簡単だから省略してどげんかして
SurfaceTexture を使ってテクスチャとして受け取った映像を
Bitmapに読み込む方法を書いてみよう。
まず必要なのは、読み込み先の
Bitmapオブジェクトでし。
1 2 3 4 5 6 7 8 9 10 11 12 |
@NonNull private final Bitmap mBitmap; private final ByteBuffer mWorkBuffer; ... public SurfaceDrawable( final int imageWidth, final int imageHeight, @NonNull final Callback callback) { ... mBitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888); mWorkBuffer = ByteBuffer.allocateDirect(imageWidth * imageHeight * 4); ... } |
としてあらかじめ生成しておきます。 Bitmap は ARGB_8888 を指定して生成してください(より正確にはEGL初期化時にEGLConfigで選択したピクセルフォーマット)。またテクスチャからの映像読み込み& Bitmap の更新に使用するダイレクト ByteBuffer も同時に生成しておきます(ちなみに SurfaceDrawable 内ではバイトアクセスしかしないのバイトオーダーは気にしなくていいぞい)。
ここで大事なのは映像更新のたびに Bitmapを生成しちゃだめということです。これは「可能な限りループ中や繰り返し呼ばれる箇所でオブジェクトの生成やメモリーの確保・破棄を行わない」というプログラマーとしての常識です。 SurfaceDrawable ではコンストラクタに映像サイズを引き渡しており以降映像サイズを変更することがないのでコンストラクタで Bitmapを生成しています。
ここでテクスチャから映像をCPU側のメモリー(Java/Kotlinだとダイレクト ByteBuffer )へ読み込むことができればあとは、 Bitmap#copyPixelsFromBuffer を使ってBitmapを更新できるのですが、残念ながら GL_TEXTURE_EXTERNAL_OESなテクスチャを直接読み込む方法はありません。
OpenGLであれば glGetTexImageというその名もずばりな関数があるのですが、残念ながらAndroidで使用できるOpenGL|ESにはありません。無いものは仕方がないので代わりに glReadPixelsを使うことになるのですが… glReadPixels はレンダリングバッファからしか読み込めないのです。つまり glReadPixelsを呼ぶ前にテクスチャをレンダリングバッファへ書き込む(描画する)必要があります。
ということで Bitmap への読み込み部分です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
@WorkerThread protected void handleDraw() { if (DEBUG && ((++drawCnt % 100) == 0)) Log.v(TAG, "handleDraw:" + drawCnt); mEglTask.removeRequest(REQUEST_DRAW); try { mEglTask.makeCurrent(); mInputTexture.updateTexImage(); mInputTexture.getTransformMatrix(mTexMatrix); } catch (final Exception e) { Log.e(TAG, "handleDraw:thread id =" + Thread.currentThread().getId(), e); return; } // OESテクスチャをオフスクリーン(マスターサーフェース)へ描画 mDrawer.draw(mTexId, mTexMatrix, 0); // オフスクリーンから読み取り mWorkBuffer.clear(); GLES20.glReadPixels(0, 0, mImageWidth, mImageHeight, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, mWorkBuffer); // Bitmapへ代入 mWorkBuffer.clear(); synchronized (mBitmap) { // 排他制御が必要 mBitmap.copyPixelsFromBuffer(mWorkBuffer); } invalidateSelf(); } @Override public void draw(@NonNull final Canvas canvas) { synchronized (mBitmap) { // 排他制御が必要 canvas.drawBitmap(mBitmap, mTransform, mPaint); } } |
Androidの場合はGLコンテキストを有効にするには有効な EGLSurface が必須なので、GLコンテキスト保持用の EGLSurface をオフスクリーンとみなしてテクスチャを描画してそれを glReadPixelsで読み取るようにしています。
テクスチャの読み込み処理はEGL/GLコンテキストを保持しているワーカースレッド上での処理を行う必要があります。一方、 Drawable へ表示するには draw(Canvas) で Bitmap を表示する必要があります。ですので Drawable 自体へ更新要求するためにBitmapを更新した後 Drawable#invalidateSelf を呼び出します。あとは draw(Canvas) で Canvas#drawBitmapを呼び出すだけですが、テクスチャで受け取った映像で Bitmap を更新する処理と draw(canvas) は違うスレッドで実行されているので排他処理を忘れてはいけませんよ。
今回は glReadPixelsを同期的に呼び出しているのでちょっと余分に時間がかかっています。もし glReadPixels呼び出しがパフォーマンス上ボトルネックになる場合でOpenGLES3.xが使用可能な環境であれば PBO と glMapBufferRange と glUnmapBuffer を使って glReadPixelsを非同期的に呼び出すようにするとパフォーマンスが向上するかもしれません。
ちなみに Surface/SurfaceTextue を使ってテクスチャとして映像を受け取る代わりにダイレクト ByteBuffer として映像を受け取るようにすると Surface/SurfaceTexture の生成関係や Bitmap への映像読み込み処理がかなり省略できるの簡単になります。が今回は作っていません。
パフォーマンス
どうしても映像のコピーが増えてしまう& Canvas 経由での描画ということでパフォーマンスが悪いかもと危惧していたのですが、カメラ側を60fpsで映像取得できるように設定して実測すると約60fps@1280×720 on Moto g6(plus)ということで最近の端末上で実行するのであればまぁ気にしなくていいかなぁと思います?
でもなんか射影行列がおかしいのか端末を傾けたりすると映像が歪むんだよなぁ…そのうちなんとかしよう、たぶん?
ということでおしまい
(^^)/~~~