written: 2013-08-04 .. 2023-09-08

誰しも一度通る道? SurfaceView

Android ゲームプログラマーが誰しもが一度は目にするであろうグラフィックスの技術情報、SurfaceView を使った描画方法。最も基本的な静的なアプリの場合は View を、ゲームなどの動的なアプリの場合は View の派生クラスである SurfaceView か、そのまた派生クラスである GLSurfaceView を使う。GLSurfaceView が最も強力だが、同時に最も専門的(OpenGL ES によってプログラミングすることになるため)。GLSurfaceView を使うレベルのものを作る場合、自分で直接 API を操作するよりは、バックエンドで OpenGL を駆使してくれる libGDX のようなフレームワークを利用した方がいいかもしれない。

ViewSurfaceViewGLSurfaceView
描画CanvasOpenGL ES
処理の舞台CPUGPU
スレッドMain UI専用
画像の適性静画動画
電力消費省力高消費
文字表示
描画する箇所onDrawlockCanvas ~ unlockCanvasAndPostonDrawFrame

ただし、libGDX で一つだけ問題があり、それはフォント描画。libGDX では、一旦、ライブラリー側でフォントからビットマップのスプライトを作成した上で表示するような形となっており、普通のアウトラインフォントを表示させると、非常に汚く潰れた字になってしまう。ビットマップフォントであればドットバイドットでクッキリと表示させることができるが、そうしようとするとフォントの選択肢が限られきれいに表示できるサイズも限定される。ただし、libGDX でアウトラインのスムージングを行う方法はないわけではない(DistanceFieldFont)。とはいえ、スムージングの可否によらず、現状では libGDX の非欧文フォントに対する対応はあまり十分な感じではないので、動的グラフィック主体ではない、静的グラフィックスとテキスト主体のゲーム(パズルやクイズゲーム等)の場合は、libGDX で無理に作ろうとするよりは、直接、Android OS の API を学習・駆使してしまった方が手っ取り早いかもしれない。

SurfaceView の使い方

ネット上の情報を見渡しても、大枠は同じで、おそらく Android 公式のサンプルである Lunar Lander が大本の由来ではないかと思う。

つまり、

  1. SurfaceHolder.Callback インターフェイスを実装する(surfaceChanged, surfaceCreated, surfaceDestroyed の 3 種類のコールバック関数の定義を行う)。
  2. Thread(Runnable) を使って Main UI とは独立した専用のスレッドを用意して描画処理を行う。
  3. その 専用 Thread の中で、a) SurfaceHolder をロックして Canvas を取得し、b) その Canvas に描画処理を行い、c) SurfaceHolder のロックを解除する。

という 3 つのポイントになる。1, 3 が Android OS 固有の話であり、2 は Java でリアルタイムゲームを作る場合でも利用される常套的手段である。

どこの情報でも上記 3 点については共通している。わかりやすいサンプルは、libGDX の作者 Mario Zechner 氏の著書『Android ゲームプログラミング A to Z』で紹介されているサンプルプログラムかもしれない。

以下は、Mario Zechner 版 SurfaceView を参考にしたオリジナルの Hata 版サンプルプログラムである。今時の Android プログラミングらしく Kotlin で記述しているのはいいとして、Zechner 版は SurfaceView を拡張(extends)して SurfaceView に SurfaceHolder.Callback を実装させているが、Hata 版では SurfaceView は素のまま使用して SurfaceHolder.Callback は MainActivity の方に実装させている。

MainActivity.kt


import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.appcompat.app.AppCompatActivity

private val TAG = MainActivity::class.simpleName

class MainActivity : AppCompatActivity(), SurfaceHolder.Callback, Runnable {

    private lateinit var surfaceView: SurfaceView
    
    @Volatile
    private var isRunning: Boolean = false
    private lateinit var renderThread: Thread

    private lateinit var paint: Paint

    private var centerX = 0f
    private var centerY = 0f
    private var radius = 0f
    private var maxRadius = 0f

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.v(TAG, "onCreate")

        surfaceView = SurfaceView(this)

        // CallBack を設置
        surfaceView.holder.addCallback(this)

        // SurfaceView 単独を ContentView として使う(layout.xml は使わない)
        setContentView(surfaceView)
    }

    override fun onDestroy() {
        Log.v(TAG, "onDestroy")

        // CallBack を解除
        surfaceView.holder.removeCallback(this)

        super.onDestroy()
    }

    override fun onResume() {
        super.onResume()
        Log.v(TAG, "onResume")

        isRunning = true
        renderThread = Thread(this)
        renderThread.start()
    }

    override fun onPause() {
        Log.v(TAG, "onPause")

        isRunning = false // run で無限ループさせるのをやめる
        while (true) {
            try {
                // 無限ループさせるのをやめる前に開始された処理でまだ途中のものが残っていれば、終わるまで待つ
                renderThread.join()
                break
            } catch (e: InterruptedException) {
                // retry
            }
        }

        super.onPause()
    }

    /**
     * SurfaceHolder.Callback (1/3)
     */
    override fun surfaceCreated(holder: SurfaceHolder) {
        Log.v(TAG, "surfaceCreated")

        // 描画の仕方(ブラシ)に関する各種設定
        paint = Paint()
        paint.style = Paint.Style.FILL // 塗り潰し
        paint.color = Color.WHITE // 白色で
        paint.isAntiAlias = false // アンチエイリアスは off(ピクセラレート)
    }

    /**
     * SurfaceHolder.Callback (2/3)
     */
    override fun surfaceDestroyed(holder: SurfaceHolder) {
        Log.v(TAG, "surfaceDestroyed")
    }

    /**
     * SurfaceHolder.Callback (3/3)
     */
    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
        Log.v(TAG, "surfaceChanged")

        centerX = width / 2f
        centerY = height / 2f
        maxRadius = height / 2f
    }

    /**
     * Runnable
     *
     * この中身がいわゆるレンダリング専用スレッドで、メイン UI スレッドから独立した別のスレッド。
     * lockCanvas ~ unlockCanvasAndPost の間、メイン UI スレッド側の Surface(の Canvas)をロックして、
     * 描画することができる。
     */
    override fun run() {
        // isRunning = true である限り、無限ループさせる
        while (isRunning) {
            if (!surfaceView.holder.surface.isValid) {
                // 今、lockCanvas が可能な状況でなければ、スキップする
                continue
            }

            val canvas: Canvas = surfaceView.holder.lockCanvas() // ロック開始

            canvas.drawColor(Color.RED) // 赤で消去(背景色)

            // 3 ピクセル半径を増大(maxRadius を越したらリセット)
            radius = if (radius > maxRadius) 0f else radius + 3f
            // 半径 radius 中心座標 (centerX, centerY) で白円を描く
            canvas.drawCircle(centerX, centerY, radius, paint)

            surfaceView.holder.unlockCanvasAndPost(canvas) // ロック解除
        }
    }
}

キャプチャー動画

実は、Mario Zechner 氏のサンプルコードは、学習用としては最適であるが、実用的には理想的ではない。実用的に理想的なものは、Google 公式のサンプルプログラムである、Grafika (HardwareScalerActivity.java)である。それは、レンダリング専用スレッドの駆動(ループ)の仕方による。

Zechner 版では、単純に Java の無限 while ループを使用してループさせているので、必要以上に無駄にループが可能な限り爆走し続けることになる。ハードウェアの rsync が 60fps であったとして、120fps でも 180fps でも、可能な限り速く描画を繰り返しうるのである(この 60fps を上回る部分はハードウェアで描画されず、無駄になるだけである)。

そこで、rsync に合わせた周期でコールバックする Choreographer(コレオグラファー、いわゆる「振付師」)を使って、ハードウェアの rsync に合わせたペースでレンダリングスレッドを駆動させているのが、Grafika のサンプルプログラムというわけだ。

しかし、Grafika のコードを見ればわかるように、Choreographer を使った方式は、Choreographer から post されるメッセージをレンダリングスレッド側で受け取れるようにするため、オリジナル Handler を実装し、while ループではなく Looper クラスによってループさせる……というかなり壮大なものとなっている。

Mario Zechner 氏のサンプルはそもそも GLSurfaceView へと本格的に学習を進める前段階として SurfaceView による簡単なサンプルを提示するのが目的であり、SurfaceView で本格運用用のガチガチのプログラムを提示しても却って学習の弊害となりかねない。

Grafika にしても、これはこれで SurfaceView と OpenGL ES を組み合わせて使うためのサンプルであり、実は、SurfaceView + Canvas の使い方をするためのサンプルではない。Grafika はおそらく、GLSurfaceView がまだ登場する前の時期のもので、この SurfaceView + Choreographer + Handler/Looper に相当するものを後に GlSurfaceView としてまとめた感じに思われる。

しかしもし、どうしても OpenGL ES を使った GLSurfaceView ではなくて SurfaceView で本格的なエンジンを作りたい場合であれば、Grafika のように Choreographer + Handler/Looper による方式があるということは知っておいた方がいいだろう。僕は実際に、Grafika をベースにして、OpenGL ES ではなく、Canvas で動くエンジンも試作してちゃんと動くことを確認している(しかし結局そこまで拘るのなら、正直言って、GLSurfaceView で OpenGL ES の部分で凝った方がいい世界)。


Android