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

この記事は、[Android Advent Calendar 2020](https://qiita.com/advent-calendar/2020/android) の21日目の記事です。

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

# はじめに
Androidで図形表示と画像表示を始めると、最初はまず [Canvas](https://developer.android.com/reference/android/graphics/Canvas) を使うと思います。そうして、Canvasでの描画に限界を感じると [SurfaceView](https://developer.android.com/reference/android/view/SurfaceView) を使い始めると思います。ある程度までは「SurfaceViewは早い！」で通じますが、それでもやっぱり遅いことはあります。そこで登場するのがグラフィックスAPIの[OpenGL](https://www.opengl.org/)です。`OpenGL`とは、 **グラフィックス表示をGPUを通じて行えるAPI** だと思ってください。( **正確にはAPIの仕様のこと** で、公式が「OpenGLとは?」について回答しているのでこれが一番正しいです。[What is OpenGL?](https://www.khronos.org/opengl/wiki/FAQ#What_is_OpenGL.3F)

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

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

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

||[Canvas](https://developer.android.com/reference/android/graphics/Canvas)|[SurfaceView](https://developer.android.com/reference/android/view/SurfaceView)|[GLSurfaceView](https://developer.android.com/reference/android/opengl/GLSurfaceView)|
|---|---|---|---|
|処理|CPU|CPU|**GPU**|
|スレッド|UIスレッド|別スレッド(演算) + UIスレッド(描画)|GLThread(このスレッドはCPUで動く)|
|目的|静的なコンテンツ|動的なコンテンツ|**動的なコンテンツ(高速)**|
|難易度|★☆☆|★★☆|★★★|

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

- [OpenGL ES](https://developer.android.com/guide/topics/graphics/opengl?hl=ja)
- [OpenGL ES によるグラフィックスの表示](https://developer.android.com/training/graphics/opengl?hl=ja)
- [SurfaceView と GLSurfaceView](https://source.android.com/devices/graphics/arch-sv-glsv?hl=ja)
- [EGLSurface と OpenGL ES](https://source.android.com/devices/graphics/arch-egl-opengl?hl=ja)

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

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

```Kotlin:インスタンスの生成とバージョンの指定
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.Renderer` を `GLSurfaceView` にセットしてレンダリングモードを決めます。
[GLSurfaceView#setRenderMode](https://developer.android.com/reference/android/opengl/GLSurfaceView.html#setRenderMode(int)) には2つモードがあります。

- `RENDERMODE_CONTINUOUSLY` : 継続的に `onDrawFrame` が呼ばれる。
- `RENDERMODE_WHEN_DIRTY` : サーフェイス作成時と `GLSurfaceView#requestRender` メソッドが呼ばれた時のみ `onDrawFrame` が呼ばれる。

```Kotlin:GLSurfaceView.RendererをGLSurfaceViewにセットしてレンダリングモードを決める
mRenderer = GLRenderer()
gLView.setRenderer(mRenderer)
gLView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
```

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

```Kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/983bb637-396d-1432-a976-b739548405fe.png)

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

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

```Kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/2832af72-6e6b-54e5-3843-430193e56e0b.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/454987e0-fb4a-303e-acf0-3cc091cf45e6.png)

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

```Kotlin:
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)
            }
}
```

座標には関係ないですが、 `onSurfaceCreated`で`glClearColor`を使って`glClear`で塗り潰す色を設定しておきます。`glClear`とは変数の初期化みたいなもので、ディスプレイに表示するピクセルをすべて`glClearColor`で設定した色で塗りつぶします。

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

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

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

```Kotlin:glClearColorでセットした色で画面を塗りつぶし
gl.glClear(GLES10.GL_COLOR_BUFFER_BIT)
```
テクスチャ(画像)の影響を受けないようにしておきます。(これはあってものなくても大丈夫でした

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

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

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

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

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

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

```Kotlin: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)
}
```

<!--
https://ooo.iiyudana.net/htm/gl_chp4frame.htm
https://maku77.github.io/android/ui/opengl.html
-->

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/23674b72-3382-25f9-891c-44b71023c26d.png)

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

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

```Kotlin:頂点に対応する色情報を持つバッファを生成
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`を使います。

```Kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/aae95b93-7017-b217-4052-b92ad3ea6062.png)

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

これからは`OpenGL ES2.0`で描画を行って行きます。特に記述が無い限りは`OpenGL ES`は`OpenGL 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で頂点情報や色情報を中継するシェーダーを書きます。

```Kotlin:シェーダーの記述
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で参照して、描画命名を出します。

```Kotlin:シェーダーのコンパイルとロード
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` と変わらないですね。

```Kotlin:シェーダー
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/93a6826a-3f7a-7038-78ae-a91d00eda20e.png)

# 画像を表示する
🚧 工事中です 🚧

# スプライトアニメーションをする
🚧 工事中です 🚧

# VBOを使う
🚧 工事中です 🚧

# 圧縮テクスチャを使う
🚧 工事中です 🚧

# Tips: デバッグをする
🚧 工事中です 🚧