この記事は、Android Advent Calendar 2020 の21日目の記事です。
丁寧にまとめてたら最後は執筆中になってしまいました
はじめに
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を使います。いままでのCanvas
とSurfaceView
をあわせてまとめるとこんな感じです。
Canvas | SurfaceView | GLSurfaceView | |
---|---|---|---|
処理 | CPU | CPU | GPU |
スレッド | UIスレッド | 別スレッド(演算) + UIスレッド(描画) | GLThread(このスレッドはCPUで動く) |
目的 | 静的なコンテンツ | 動的なコンテンツ | 動的なコンテンツ(高速) |
難易度 | ★☆☆ | ★★☆ | ★★★ |
OpenGL ES
やGLSurfaceView
に関して、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.Renderer
を GLSurfaceView
にセットしてレンダリングモードを決めます。
GLSurfaceView#setRenderMode には2つモードがあります。
-
RENDERMODE_CONTINUOUSLY
: 継続的にonDrawFrame
が呼ばれる。 -
RENDERMODE_WHEN_DIRTY
: サーフェイス作成時とGLSurfaceView#requestRender
メソッドが呼ばれた時のみonDrawFrame
が呼ばれる。
mRenderer = GLRenderer()
gLView.setRenderer(mRenderer)
gLView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
全部まとめるとこうなります。
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) {
}
}
}
これで何も表示しない描画ができました。
三角形を描画
次は、三角形を表示してみます。三角形の表示は グラフィック界のHello World みたいなものです。三角形は、コンピュータグラフィックスにおける図形の最初単位で、三角形が描画できれば、すべての形を表現できるといっても過言では無いはずです。
OpenGL ES
での描画は Canvas
を使った描画とは違って、頂点を使ったポリゴンという形で描画命令を記述して行きます。
Canvas
の場合、メソッド名からできること(パスを書くとか、画像を表示するとか)がわかりやすいですが、OpenGL ES
では Canvas
より抽象的な印象があります(どうやって画面に図形が表示されているのがわかると、仕組みに即した設計であることがわかる)。OpenGL ES
では頂点情報、色情報、画像情報をGPUに転送して描画をします。描画処理をGPUになげるからこそ、OpenGL ES
を使ったGLSurfaceView
が高速である理由です。
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)
になります。
この座標をもとに三角形の頂点を用意します。2DなのでXY座標
でもいいですが慣習的??にXYZ座標
で用意しました。基本的に(x,y,z)
順で1次元配列に座標点をならべていきます。2Dだとあまりポリゴンの表と裏を気にすることは無いですが、カリング(ポリゴンの裏表で描画するかどうかを決める)を意識して反時計周りに頂点を定義しておきましょう。(本当にカリングを意識するときは頂点インデックスを使うときです
定義した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)
}
}
座標には関係ないですが、 onSurfaceCreated
でglClearColor
を使ってglClear
で塗り潰す色を設定しておきます。glClear
とは変数の初期化みたいなもので、ディスプレイに表示するピクセルをすべてglClearColor
で設定した色で塗りつぶします。
gl.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)
座標を転送して三角形を描画
これで、用意した頂点をGPUへ教えてあげて、三角形を描画します。
OpenGL ES
では前のonDrawFrame
で行われた描画が残ってしまいます。そのため、glClear
を使って毎回まっさらな状態にしておきます。
gl.glClear(GLES10.GL_COLOR_BUFFER_BIT)
テクスチャ(画像)の影響を受けないようにしておきます。(これはあってものなくても大丈夫でした
gl.glDisable(GL10.GL_TEXTURE_2D)
用意した頂点座標をOpenGL ES
に教えてあげます。これで、GPUに頂点情報が渡されます。渡したバッファが頂点情報として有効になるようにglEnableClientState(GL10.GL_VERTEX_ARRAY)
を実行しておきます。
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
で頂点を結んでできた面を塗りつぶす色を指定することができます。
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)
}
これでようやく、赤い三角形を描画することができました。
頂点に色をつけてみる
赤のベタ塗りだけだと、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
を使います。
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)
}
これで、グラデーションの綺麗な三角形ができました。各頂点は指定した色、塗りつぶされたところは各頂点の色から補間された色になっています。
そういえば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で頂点情報や色情報を中継するシェーダーを書きます。
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)
画像を表示する
🚧 工事中です 🚧
スプライトアニメーションをする
🚧 工事中です 🚧
VBOを使う
🚧 工事中です 🚧
圧縮テクスチャを使う
🚧 工事中です 🚧
Tips: デバッグをする
🚧 工事中です 🚧