23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AndroidAdvent Calendar 2020

Day 21

SurfaceViewをやめてOpenGL ES 2.0/3.0で描画しよう(2D)

Posted at

この記事は、Android Advent Calendar 2020 の21日目の記事です。

丁寧にまとめてたら最後は執筆中になってしまいました :bow:

はじめに

Androidで図形表示と画像表示を始めると、最初はまず Canvas を使うと思います。そうして、Canvasでの描画に限界を感じると SurfaceView を使い始めると思います。ある程度までは「SurfaceViewは早い!」で通じますが、それでもやっぱり遅いことはあります。そこで登場するのがグラフィックスAPIのOpenGLです。OpenGLとは、 グラフィックス表示をGPUを通じて行えるAPI だと思ってください。( 正確にはAPIの仕様のこと で、公式が「OpenGLとは?」について回答しているのでこれが一番正しいです。What is OpenGL?

Androidには、組み込みシステム用のOpenGL for Embedded Systems(OpenGL ES)が採用されています。これにより高速でリッチなコンテンツをGPUの支援を受けてアプリを作成することができます。

今回はその OpenGLES を使うための GLSurfaceView を使って、画像を表示してアニメーションするまでを簡単にまとめようと思います!

GLSurfaceView

Androidで、OpenGLESを使うためには、 GLSurfaceViewを使います。いままでのCanvasSurfaceViewをあわせてまとめるとこんな感じです。

Canvas SurfaceView GLSurfaceView
処理 CPU CPU GPU
スレッド UIスレッド 別スレッド(演算) + UIスレッド(描画) GLThread(このスレッドはCPUで動く)
目的 静的なコンテンツ 動的なコンテンツ 動的なコンテンツ(高速)
難易度 ★☆☆ ★★☆ ★★★

OpenGL ESGLSurfaceView に関して、Googleがドキュメントをまとめてくれているので助かります(やっぱり公式ドキュメント

何も表示しない描画をまず作る

まずは、何も表示しない GLSurfaceView を作成します。Googleのドキュメントでは、GLSurfaceViewを継承したクラスを作成していますが、今回はActivityに直にかきます。

まずは、 GLSurfaceView のインスタンスを生成して GLSurfaceView#setEGLContextClientVersion で使用するOpenGL ESのバージョンを指定します。ここで注意するべきは、Android OSやデバイスに応じて使えるOpenGL ESのバージョンが異なる点、OpenGL ESのバージョンによって使える機能や書き方が異なる点です。とりあえず最初は OpenGL ES 1.0 を使おうと思うので 1 を設定します。

インスタンスの生成とバージョンの指定
gLView = GLSurfaceView(this)
gLView.setEGLContextClientVersion(1)

次に、 GLSurfaceView.Renderer の実装をします。基本的に、描画処理は GLSurfaceView.Renderer インターフェース を実装したクラスに書きます。一旦は何も表示しないのでメソッドだけ実装しておきます。実装するべきメソッドは以下の3つです。

メソッド タイミング
onSurfaceCreated (gl: GL10?, config: EGLConfig?) OpenGLESの描画用Surfaceが生成されたときに呼ばれる、基本的に、OpenGLESの描画の初期化(バッファーの作成/シェーダーの作成/テクスチャの読み込み)を行う
onSurfaceChanged (gl: GL10?, width: Int, height: Int) surfaceのサイズが変わったときに呼ばれる。引数で、OpenGLESの描画領域のピクセル値を取得できる
onDrawFrame (gl: GL10?) 描画する度に呼ばれる。 GLSurfaceView#renderMode によって呼ばれるタイミングが異なる

最後に GLSurfaceView.RendererGLSurfaceView にセットしてレンダリングモードを決めます。
GLSurfaceView#setRenderMode には2つモードがあります。

  • RENDERMODE_CONTINUOUSLY : 継続的に onDrawFrame が呼ばれる。
  • RENDERMODE_WHEN_DIRTY : サーフェイス作成時と GLSurfaceView#requestRender メソッドが呼ばれた時のみ onDrawFrame が呼ばれる。
GLSurfaceView.RendererをGLSurfaceViewにセットしてレンダリングモードを決める
mRenderer = GLRenderer()
gLView.setRenderer(mRenderer)
gLView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY

全部まとめるとこうなります。

MainActivity.kt
import android.opengl.GLSurfaceView
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10

class MainActivity : AppCompatActivity() {

    private lateinit var gLView: GLSurfaceView
    private lateinit var mRenderer: GLSurfaceView.Renderer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        gLView = GLSurfaceView(this)
        gLView.setEGLContextClientVersion(1)

        mRenderer = GLRenderer()
        gLView.setRenderer(mRenderer)
        gLView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
        setContentView(gLView)
    }

    class GLRenderer : GLSurfaceView.Renderer {

        override fun onSurfaceCreated(gl: GL10, config: EGLConfig?) {

        }

        override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) {

        }

        override fun onDrawFrame(gl: GL10) {

        }

    }
}

これで何も表示しない描画ができました。

image.png

三角形を描画

次は、三角形を表示してみます。三角形の表示は グラフィック界のHello World みたいなものです。三角形は、コンピュータグラフィックスにおける図形の最初単位で、三角形が描画できれば、すべての形を表現できるといっても過言では無いはずです。

OpenGL ES での描画は Canvas を使った描画とは違って、頂点を使ったポリゴンという形で描画命令を記述して行きます。
Canvas の場合、メソッド名からできること(パスを書くとか、画像を表示するとか)がわかりやすいですが、OpenGL ES では Canvas より抽象的な印象があります(どうやって画面に図形が表示されているのがわかると、仕組みに即した設計であることがわかる)。OpenGL ES では頂点情報、色情報、画像情報をGPUに転送して描画をします。描画処理をGPUになげるからこそ、OpenGL ESを使ったGLSurfaceViewが高速である理由です。

Canvasでの三角形の描画
val paint = Paint()
paint.setStyle(Paint.Style.FILL_AND_STROKE)

val path = Path()
path.moveTo(10.0f, 10.0f)
path.lineTo(200.0f, 150.0f)
path.lineTo(150.0f, 200.0f)
path.close()

canvas.drawPath(path, paint)

座標について考える

まずは、三角形を描画するための頂点を用意します。Canvasでは左上が原点で右にX座標が正、下にY座標が正で画面サイズのピクセル値が最大でしたがOpenGL ESでは違います。OpenGL ESでは座標を 画面の中心が原点右にX座標が正上にY座標が正-1から1の範囲で表現します。なので、Canvas座標での原点をOpenGL ESで考えると(-1,1)になるし、OpenGL ES座標での原点をCanvasで考えると(width,height)になります。

image.png

この座標をもとに三角形の頂点を用意します。2DなのでXY座標でもいいですが慣習的??にXYZ座標で用意しました。基本的に(x,y,z)順で1次元配列に座標点をならべていきます。2Dだとあまりポリゴンの表と裏を気にすることは無いですが、カリング(ポリゴンの裏表で描画するかどうかを決める)を意識して反時計周りに頂点を定義しておきましょう。(本当にカリングを意識するときは頂点インデックスを使うときです

image.png

定義したFloatの配列はJVM上のオブジェクトで、このままでは頂点情報をGPUに転送できません。なので、ダイレクトバッファにしてネイティブ領域においておきます。values.size * 4はアロケートするバッファのサイズで、Float型が4バイトあるので4をかけています。

private lateinit var vertexBuffer: FloatBuffer

override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {
     gl.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)

     val values = floatArrayOf(
         -0.5f, -0.5f, 0.0f,
          0.5f, -0.5f, 0.0f,
          0.0f, 0.5f, 0.0f
     )

     vertexBuffer =
        ByteBuffer.allocateDirect(values.size * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer().apply {
                put(values)
                position(0)
            }
}


座標には関係ないですが、 onSurfaceCreatedglClearColorを使ってglClearで塗り潰す色を設定しておきます。glClearとは変数の初期化みたいなもので、ディスプレイに表示するピクセルをすべてglClearColorで設定した色で塗りつぶします。

glClearで塗り潰す色を設定する
gl.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)

座標を転送して三角形を描画

これで、用意した頂点をGPUへ教えてあげて、三角形を描画します。

OpenGL ESでは前のonDrawFrameで行われた描画が残ってしまいます。そのため、glClearを使って毎回まっさらな状態にしておきます。

glClearColorでセットした色で画面を塗りつぶし
gl.glClear(GLES10.GL_COLOR_BUFFER_BIT)

テクスチャ(画像)の影響を受けないようにしておきます。(これはあってものなくても大丈夫でした

テクスチャを無効にする
gl.glDisable(GL10.GL_TEXTURE_2D)

用意した頂点座標をOpenGL ESに教えてあげます。これで、GPUに頂点情報が渡されます。渡したバッファが頂点情報として有効になるようにglEnableClientState(GL10.GL_VERTEX_ARRAY) を実行しておきます。

用意した頂点をGPUへ教える
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertexBuffer)
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY)

OpenGL ESのAPIでセットした情報をもとに描画命令を出します。 三角形なので、第一引数に GL10.GL_TRIANGLESを指定します。

描画
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3)

まとめるとこうなります。 glColor4f で頂点を結んでできた面を塗りつぶす色を指定することができます。

onDrawFrameでの処理
override fun onDrawFrame(gl: GL10) {
    gl.glClear(GLES10.GL_COLOR_BUFFER_BIT)

    gl.glDisable(GL10.GL_TEXTURE_2D)
    gl.glColor4f(1.0f, 0.0f, 0.0f, 1.0f)
    gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertexBuffer)
    gl.glEnableClientState(GL10.GL_VERTEX_ARRAY)
    gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3)
}

これでようやく、赤い三角形を描画することができました。

image.png

頂点に色をつけてみる

赤のベタ塗りだけだと、Canvasでやってる感(???)があるので、色情報を送って OpenGL ESでやってる感を出してみようと思います。

頂点座標と同じように、まず、色情報を(r,g,b,a)の順で1次元配列へ格納します。1個目の(r,g,b,a)が1個目の頂点の色に対応します。これもJVMのオブジェクトなので、ダイレクトバッファにします。

頂点に対応する色情報を持つバッファを生成
private lateinit var colorBuffer: FloatBuffer

override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {

    /// 省略

    val colors = floatArrayOf(
        1.0f, 1.0f, 0.0f, 1.0f,
        0.0f, 1.0f, 1.0f, 1.0f,
        1.0f, 0.0f, 1.0f, 1.0f
    )

    colorBuffer = ByteBuffer.allocateDirect(colors.size * 4)
        .order(ByteOrder.nativeOrder())
        .asFloatBuffer().apply {
            put(colors)
            position(0)
        }
}

ダイレクトバッファにしたら、頂点情報と同じように、onDrawFrameで色情報もGPUへ教えてあげます。頂点のときは、glVertexPointer ですが、色のときは glColorPointerを使います。

onDrawFrameの処理
override fun onDrawFrame(gl: GL10) {

    /// 省略

    gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertexBuffer)
    gl.glEnableClientState(GL10.GL_VERTEX_ARRAY)
    gl.glColorPointer(4, GL10.GL_FLOAT, 0, colorBuffer)
    gl.glEnableClientState(GL10.GL_COLOR_ARRAY)
    gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3)
}

これで、グラデーションの綺麗な三角形ができました。各頂点は指定した色、塗りつぶされたところは各頂点の色から補間された色になっています。

image.png

そういえばOpenGL ES2.0は?

お気づきかもしれませんが、先ほど、setEGLContextClientVersion1を設定したので、ここまではOpenGL ES1.0で描画処理を書いていました。これからsetEGLContextClientVersion2で設定して三角形を描画をしようと思います。
なぜ、最初からOpenGL ES2.0で、描画しなかったというと、OpenGL ES2.0では、固定機能パイプラインが備わっていなく、自分でレンダリングパイプラインの一部をシェーダーという形で実装しなければいけません。なので、実装なしで描画できるOpenGL ES1.0GLSurfaceViewの導入を行い、シェーダーはあとから実装する形をとりました。※ OpenGL ES1.0には後方互換性はないです

これからはOpenGL ES2.0で描画を行って行きます。特に記述が無い限りはOpenGL ESOpenGL ES2.0のことになります。シェーダーからの話は、「頂点情報を画面にどうやってピクセルにして表示するのか」というレンダリングの話がわかると理解しやすいです。

*独学で 1 ヶ月間 OpenGL を学んで得た基礎知識のまとめ ~ 2D 編 ~*
http://tkengo.github.io/blog/2014/12/20/opengl-es-2-2d-knowledge-0/

床井研究室
http://marina.sys.wakayama-u.ac.jp/~tokoi/oglarticles.html

The Book of Shaders
https://thebookofshaders.com/?lan=jp

シェーダーの用意

さて、OpenGL ES2.0で三角形を描画していきます。最初にシェーダーの用意が加わっただけで、基本は頂点情報を用意して、描画命令を実行するだけです。

まずは、シェーダを用意します。シェーダーは頂点を処理する バーテックスシェーダー、各画素それぞれを処理するフラグメントシェーダーの2つがあります。C言語likeの言語で記述することできます。プログラムファイルとしていても良いですが、文字列で用意しておくと楽なので、クラス内に書いておきます。今回は三角形を描画するだけなのでGPUで頂点情報や色情報を中継するシェーダーを書きます。

シェーダーの記述
class GLRenderer : GLSurfaceView.Renderer {

    private val vertexShaderCode = """
        attribute vec4 vPosition;
        attribute vec4 color;
        varying   vec4 vColor;
        void main() {
            vColor = color;
            gl_Position = vPosition;
        }
        """

    private val fragmentShaderCode = """
        precision mediump float;
        
        varying vec4 vColor;
        void main() {
            gl_FragColor = vColor;
        }
        """
}

このシェーダーはこのままだと単なる文字列なので、GPUが処理できるようにコンパイルします。シェーダーのコンパイルをloadShaderメソッドにまとめておきます。ロードが終わったら、バーテックスシェーダーとフラグメントシェーダーが一つにまとめて、レンダリングパイプラインの1つとして、GPUに登録します。このパイプラインのIDを shaderProgram にいれておき、あとからこのIDで参照して、描画命名を出します。

シェーダーのコンパイルとロード
private var shaderProgram = 0

private fun loadShader(type: Int, shaderCode: String): Int {
    val shader = GLES20.glCreateShader(type)
    GLES20.glShaderSource(shader, shaderCode)
    GLES20.glCompileShader(shader)
    return shader
}

override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {

    /// 省略

    val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
    val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)
    shaderProgram = GLES20.glCreateProgram()
    GLES20.glAttachShader(shaderProgram, vertexShader)
    GLES20.glAttachShader(shaderProgram, fragmentShader)
    GLES20.glLinkProgram(shaderProgram)
}

シェーダーの用意ができたら、頂点情報と色情報をシェーダーへ渡して、三角形を描画していきます。GLES20#glUseProgramで描画で使うシェーダーを設定します。次に、glGetAttribLocationでシェーダーに登録しているう変数がある場所を示すIDを取得します。glEnableVertexAttribArrayをつかって、その変数へのアクセスを有効にします。最後に、glVertexAttribPointerを使ってAndroidアプリケーション側で持っている情報をGPUへ転送します。

頂点情報と色情報を転送したら、 glDrawArrays を使って、描画命名を送ります。情報を送ってからは OpenGL ES1.0 と変わらないですね。

シェーダー
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)

GLES20.glUseProgram(shaderProgram)
val positionAttribute = GLES20.glGetAttribLocation(shaderProgram, "vPosition")
GLES20.glEnableVertexAttribArray(positionAttribute)

GLES20.glVertexAttribPointer(
    positionAttribute,
    3,
    GLES20.GL_FLOAT,
    false,
    0,
    vertexBuffer
)

val colorAttribute = GLES20.glGetAttribLocation(shaderProgram, "color")
GLES20.glEnableVertexAttribArray(colorAttribute)

GLES20.glVertexAttribPointer(
    colorAttribute,
    4,
    GLES20.GL_FLOAT,
    false,
    0,
    colorBuffer
)

GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)

GLES20.glDisableVertexAttribArray(positionAttribute)
GLES20.glDisableVertexAttribArray(colorAttribute)

これで、OpenGL ES2.0で三角形が描画できました。
image.png

画像を表示する

🚧 工事中です 🚧

スプライトアニメーションをする

🚧 工事中です 🚧

VBOを使う

🚧 工事中です 🚧

圧縮テクスチャを使う

🚧 工事中です 🚧

Tips: デバッグをする

🚧 工事中です 🚧

23
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?