OpenGLのVBOを活用した高速化

OpenGLで描画する際にVBO(頂点の格納場所)をうまく活用することで高速化した話。  

高速した後は描画のコマ落ちが解消されています。
（左：高速化前、右：高速化後）

<img width="250" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230722/20230722121252.gif" title="高速化前"> 
<img width="250" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230722/20230722121326.gif" title="高速化後">

開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>

<br>
<br>

# はじめに

musicLineはMIDI楽譜をピアノロールで表示します。

<img width="600" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230722/20230722140128.jpg" title="描画する音符が膨大"> 

しかし描画する音符が膨大になると、動作がカクカクと重くなる状態でした。
通常は30FPSでの描画のところ、再生位置によって描画オブジェクトが増加し、3FPS程度まで下がります。  

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230722/20230722121252.gif" title="描画FPSの低下"> 

<br>

そのため、OpenGLのVBO等で描画の前処理を最適化し、描画の高速化を図ってみました。

<br>
<br>

# 描画の高速化

OpenGLでは様々な工程がありますが、その処理をGPUで行います。
そのため、アプリ側のCPUからGPUへ描画する頂点情報（位置、色等）を転送することになります。この転送時間がボトルネックとなるので、効率よく転送することが高速化に繋がります。

## 高速化1: VBOへ頂点情報を一気に送る

VBO（Vertex Buffer Object）はGPU側の領域で頂点情報を格納しておく場所です。


現在は頂点情報をフレーム毎に**配列でGPUへ転送**しています。
しかし、毎フレームで情報収集とGPU転送の時間が掛かるため非効率です。

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230724/20230724183817.png" title="毎フレームで1フレーム分の頂点情報を転送"> 

<br>

これは、予め数フレーム分の頂点情報を**VBOでGPUへ転送**しておき、VBOを参照することで毎回頂点情報を転送しなくて済みます。
特にピアノロールのような画面がスクロールするだけでオブジェクトの位置が変化しない場合に有効です。VBOに頂点情報を格納して、毎フレームでスクロール位置のみGPUへ転送すれば、描画するフレーム部分をGPU側で計算できます。    


<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230724/20230724183813.png" title="数フレーム分の頂点情報を一気に転送"> 


<br>

つまり、毎フレームCPU側で計算した頂点情報を転送するのではなく、数フレーム分の頂点情報を一気に送ってGPU側で描画部分を計算することで、転送回数を削減することができます。

<br>

## 高速化2: IBOで重複する頂点情報を省略

IBO（Index Buffer Object）はGPU側の領域で頂点インデックス情報を格納しておく場所です。

例えば音符(四角形)1個を描画する場合、三角形ポリゴンを2枚使います。頂点が各々3点なので合計6点となります。  
しかし、2点は共有する頂点なので2点分の頂点情報が重複します。  


<img width="300" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230725/20230725000031.png" title="三角形ポリゴン2枚で6頂点"> 


頂点配列を転送した場合、（頂点インデックスを指定しなければ）順番に3点ずつで三角形ポリゴンを描画することになります。
三角形ポリゴンの構成する頂点インデックス（頂点配列の前からの番号）を指定することで、重複する情報を省略して三角形ポリゴンを描画することができます。

<img width="300" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230725/20230725000034.png" title="頂点インデックスの使用"> 

このように重複した情報を省くことで転送データ量が減り、転送時間を短縮します。
なお頂点インデックスもVBOと同様に予めIBOに転送しておくことで、転送回数を削減することができます。


<br>
<br>

# 実装

Androidでは`GLSurfaceView`をViewに貼り付けて、OpenGLES（ver 2.0）を使用します。

https://developer.android.com/reference/android/opengl/GLSurfaceView

実装方法はこちらの記事がわかりやすいです。

https://qiita.com/cha84rakanal/items/f82691c033679e53684d

`GLSurfaceView`で描画ができていることを前提に、VBOとIBOを使用した実装を考えていきます。

バッファ（VBO/IBO）を使用する手順は

1. バッファを作成
1. 頂点情報をバッファへ転送
1. 頂点情報をLocationにバインド
1. 頂点インデックスを指定して描画

となります。

<br>

## 1. バッファを作成

```kotlin
val bufferIds: IntArray // VBO, IBO の確保領域のIDリスト

bufferIds = IntArray(6).also {
    GLES20.glGenBuffers(6, it, 0)
}
```
この例では、6個のバッファ（VBO x 5 + IBO）を確保しています。

VBO  

* 座標
* UV位置
* 色
* 角丸幅
* 音符のX範囲

IBO  

* 頂点インデックス


<br>

ちなみに、使用後必要がなくなったバッファは削除することでメモリを解放します。
```kotlin
GLES20.glDeleteBuffers(6, bufferIds, 0)
```

<br>

## 2. 頂点情報をバッファへ転送

```kotlin
val vertexBuffer: FloatBuffer = ... // 音符の4頂点座標を集めてFloatBufferに変換する
val floatByte = 4  // floatは4byte

GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0]) // バッファIDの指定

vertexBuffer.position(0) // バッファのポインターを先頭へ
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, vertexBuffer.capacity() * floatByte, vertexBuffer, GLES20.GL_STATIC_DRAW) // 頂点を転送

GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) // バッファIDの解除
```
この例では、頂点座標をVBOへ転送しています。  
`glBindBuffer`の第一引数に`GLES20.GL_ARRAY_BUFFER`と指定することでVBOへ転送します。  
`glBindBuffer`の第二引数に確保したバッファIDを指定します。


<br>

次の例では、頂点インデックスをIBOへ転送しています。 

```kotlin
val indexBuffer: IntBuffer = ... // 頂点インデックスを計算してIntBufferに変換する
val intByte = 4

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5]) // バッファIDの指定

indexBuffer.position(0)
GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.capacity() * intByte, indexBuffer, GLES20.GL_STATIC_DRAW) // 頂点インデックスを転送

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0) // バッファIDの解除
```

glBindBufferの第一引数に`GLES20.GL_ELEMENT_ARRAY_BUFFER`と指定することでIBOへ転送します。

<br>

## 3. 頂点情報をLocationにバインド

```kotlin
GLES20.glEnableVertexAttribArray(noteVtPosLoc) // Location有効
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0]) // バッファIDの指定

GLES20.glVertexAttribPointer(noteVtPosLoc, 2, GLES20.GL_FLOAT, false, 0, 0) // 頂点情報をLocationにバインド

GLES20.glDisableVertexAttribArray(noteVtPosLoc) // Location解除
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0) // バッファIDの解除
```
Shaderで使用できるように、VBOに格納してある頂点情報をShader変数にバインドします。
`2. ` と同様に`glBindBuffer`でバッファIDを指定します。`glVertexAttribPointer`でShaderで使用する変数のLocationを指定します。

<br>

## 4. 頂点インデックスを指定して描画

```kotlin
var faceIndexesBufferCount = // 頂点インデックスの数

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5]) // バッファIDの指定

GLES20.glDrawElements(GLES20.GL_TRIANGLES, faceIndexesBufferCount, GLES20.GL_UNSIGNED_INT, 0) // 描画

GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0) // バッファIDの解除
```
`2. ` と同様に`glBindBuffer`でバッファIDを指定します。
`glDrawElements`の第二引数で参照するインデックス数、第四引数でIBOの参照オフセットを設定します。


<br>

ソースコード等の詳細は元記事へ

https://musicline-developer.hatenablog.com/entry/2023/08/23/094104


開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>

<br>