musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

描画の高速化(OpenGL VBO)

今回は音符を描画するために使用しているライブラリOpenGLの高速化の話。

描画の高速化(コマ落ち解消)



はじめに

musicLineでは、コミュニティでユーザーが投稿した曲を再生できるようになっており、再生している曲のイメージを可視化できるソングビジュアライゼーションという機能があります。

ソングビジュアライゼーション


Twitter#createbymusiclineタグから引っ張ってきたツイートからソングビジュアライゼーションのテーマを紹介します。
(すみません勝手に使ってます。)

ピアノロール
スペクトラム
バブル
サークル
波形

そして、やばい曲の到来

すみません。やばいという表現は少し失礼ですね。
音符をこれでもかと敷き詰めた物凄い曲を再生する時、ソングビジュアライゼーションの機能の動作がとても重くなる状態です。

音符を敷き詰めた物凄い曲
https://3musicline.com/community/149961 (アプリリンク)


通常の曲であれば、30FPS(1秒間に30フレーム描画)程度で動作しますが、この曲で音符が物凄くあるところは3FPSになります。
3FPSだと1秒間に3フレームしか描画しないので、とてもカクカク動きます。(最低でも10FPSは欲しいところ。。。)

音符が多くなるとカクカクした動きに


今回はソングビジュアライゼーションのテーマの中でもピアノロールを高速化ができそうだったのでしてみました。



描画の高速化

OpenGLでは様々な工程がありますが、その処理をGPUで行うことになります。

OpenGLでの描画の流れについての参考ページ


そのため、アプリ側のCPUから描画する頂点情報(位置、色等)をGPUに送ることになるのですが、この転送時間をいかに減らせるかが高速化の1つのポイントとなります。


VBOへ頂点情報を一気に送る

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


現在は1フレーム分の描画に必要な頂点情報収集して、毎回配列でGPUへ転送しています。

毎フレームで1フレーム分の頂点情報を転送


しかし、毎フレームで情報収集とGPU転送の時間が掛かるため、非効率です。特にピアノロールのような画面が左から右へ移り変わるような単純なアニメーションであれば、予め数フレーム分の頂点情報を一気に送ることが有効です。数フレーム分の頂点情報をストックしておいて、あとは毎フレームでスクロール位置のみGPUへ転送すれば、描画するフレーム部分をGPU側で計算できます。

数フレーム分の頂点情報を一気に転送
描画部分をGPU側で計算


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


IBOで重複する頂点情報を省略

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


例えば音符1つを描画するためには、三角形ポリゴンを2枚使います。頂点が各々3点なので合計6点となります。

三角形ポリゴン2枚で6頂点

しかし、2点は共有する頂点なので2点分の頂点情報が重複します。
通常は頂点配列を転送すると、前から3点ずつをグルーピングして三角形ポリゴンを描画しますが、三角形ポリゴンの頂点を指定することができます。
三角形ポリゴンの構成する頂点インデックス(頂点配列の前からの番号)を指定することで、重複する情報を省略して三角形ポリゴンを描画することができます。

頂点インデックスの使用

重複した無駄な情報を省くことで転送データ量が減り、高速化に繋がります。



実装

AndroidOpenGLのVBOとIBOを使用して、効率良く描画をしてみます。

参考ページ

処理の流れは

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

となります。


1. バッファを作成

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

bufferIds = IntArray(6).also {
    GLES20.glGenBuffers(6, it, 0)
}

この例では、6個のバッファ(VBO x 5 + IBO)を確保しています。

VBO

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

IBO

  • 頂点インデックス


ちなみに、使用後必要がなくなったバッファは削除することでメモリを節約します。

GLES20.glDeleteBuffers(6, bufferIds, 0)


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

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の解除

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


また、頂点インデックスはGLES20.GL_ELEMENT_ARRAY_BUFFERと指定して、IBOへ転送します。

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の解除


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

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の解除

2. と同様にglBindBufferでバッファIDを指定し、glVertexAttribPointerでShaderで使用する変数のLocationにバインドします。


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

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の第四引数を0に設定することで頂点インデックスを指定して描画します。



全コードを表示

class MidiNotesShader() {

    // region Property

    // Location
    private val noteVtPosLoc: Int // 頂点位置
    private val noteVtUvLoc: Int // UV位置
    private val noteColorLoc: Int // 頂点色
    private val noteRoundWidthLoc: Int // 角丸幅
    private val noteRangeLoc: Int // 音符のX範囲
    private val scrollLoc: Int  // スクロール
    private val barPosLoc: Int  // 再生バー位置
    // endregion

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

    private var vertexBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var uvBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var colorBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var roundWidthBuffer: FloatBuffer = toFloatBufferf(listOf())
    private var rangeBuffer: FloatBuffer = toFloatBufferf(listOf())

    private var faceIndexes = listOf<Int>()
    private var faceIndexesBufferCount = 0

    private val shaderProgramId: Int = GLES20.glCreateProgram()
    private val program = Program()
    private var vertexShader: Int? = null
    private var fragmentShader: Int? = null
    // endregion

    // region Initializer
    init {
        loadProgram()

        // VBO, IBO のバッファ領域確保
        bufferIds = IntArray(6).also {
            GLES20.glGenBuffers(6, it, 0)
        }

        // 頂点情報
        noteVtPosLoc = GLES20.glGetAttribLocation(shaderProgramId, program.vtPosAttr)
        noteColorLoc = GLES20.glGetAttribLocation(shaderProgramId, program.colorAttr)
        noteRoundWidthLoc = GLES20.glGetAttribLocation(shaderProgramId, program.roundWithAttr)
        noteRangeLoc = GLES20.glGetAttribLocation(shaderProgramId, program.rangeAttr)
        noteVtUvLoc = GLES20.glGetAttribLocation(shaderProgramId, program.vtUvAttr)

        // Uniform
        scrollLoc = GLES20.glGetUniformLocation(shaderProgramId, program.scrollUni)
        barPosLoc = GLES20.glGetUniformLocation(shaderProgramId, program.barPosUni)
    }

    fun dispose() {
        GLES20.glDeleteBuffers(6, bufferIds, 0)
        deleteProgram()
    }
    // endregion

    // region Method

    // 数フレーム分必要な音符の情報を計算する
    fun calcBufferData(frameCount: Int) {

        // 数フレーム分の音符を取得する
        val screenNotes = getNotesInScreen(frameCount)

        // 音符の頂点を集める
        vertexBuffer = screenNotes.getNotePointsBuffer()

        // 音符のUV座標を集める
        uvBuffer = screenNotes.getUVsBuffer()

        // 音符の色を集める
        colorBuffer = screenNotes.getColorsBuffer()

        // 音符の角丸の幅を集める
        roundWidthBuffer = screenNotes.getRoundWidthsBuffer()

        // 音符のX範囲を集める
        rangeBuffer = screenNotes.getNoteRangesBuffer()

        // 頂点インデックスを計算する
        val indexes = (0 until screenNotes.size * 4).groupBy { it / 4 }.values.flatMap { (a, b, c, d) ->
            listOf(a, b, c, d, c, b)
        }
        faceIndexes = indexes
    }

    // BufferDataをVBO, IBOへ転送する
    fun sendBufferData() {

        val floatByte = 4

        // 音符の頂点を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0])
        vertexBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, vertexBuffer.capacity() * floatByte, vertexBuffer, GLES20.GL_STATIC_DRAW)

        // 音符のUV座標を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[1])
        uvBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, uvBuffer.capacity() * floatByte, uvBuffer, GLES20.GL_STATIC_DRAW)

        // 音符の色を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[2])
        colorBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, colorBuffer.capacity() * floatByte, colorBuffer, GLES20.GL_STATIC_DRAW)

        // 音符の角丸の幅を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[3])
        roundWidthBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, roundWidthBuffer.capacity() * floatByte, roundWidthBuffer, GLES20.GL_STATIC_DRAW)

        // 音符のX範囲を転送
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[4])
        rangeBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, rangeBuffer.capacity() * floatByte, rangeBuffer, GLES20.GL_STATIC_DRAW)

        // 頂点インデックスを転送
        val indexBuffer = toIntBuffers(faceIndexes)
        val intByte = 4
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5])
        indexBuffer.position(0)
        GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.capacity() * intByte, indexBuffer, GLES20.GL_STATIC_DRAW)
        faceIndexesBufferCount = faceIndexes.size

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
    }

    fun draw() {

        // GLSL設定
        GLES20.glUseProgram(shaderProgramId)

        // 機能有効
        GLES20.glEnable(GLES20.GL_BLEND)
        GLES20.glDisable(GLES20.GL_DEPTH_TEST)

        // 機能設定
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_DST_COLOR)

        // 変数有効
        GLES20.glEnableVertexAttribArray(noteVtPosLoc)
        GLES20.glEnableVertexAttribArray(noteVtUvLoc)
        GLES20.glEnableVertexAttribArray(noteColorLoc)
        GLES20.glEnableVertexAttribArray(noteRoundWidthLoc)
        GLES20.glEnableVertexAttribArray(noteRangeLoc)

        // 変数設定
        val crossTime = 6_000 // 6秒で画面を横切る
        val playBarPosition = 0.25 // 0(左端) ~ 1(右端)
        val barPos = playBarPosition * 2f - 1f // -1 ~ 1
        GLES20.glUniform1f(scrollLoc, time / crossTime * 2f - barPos)

        GLES20.glUniform1f(barPosLoc, barPos)

        // 音符の頂点をnoteVtPosLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[0])
        GLES20.glVertexAttribPointer(noteVtPosLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 音符のUVをnoteVtUvLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[1])
        GLES20.glVertexAttribPointer(noteVtUvLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 音符の色をnoteColorLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[2])
        GLES20.glVertexAttribPointer(noteColorLoc, 4, GLES20.GL_FLOAT, false, 0, 0)

        // 音符の角丸幅をnoteRoundWidthLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[3])
        GLES20.glVertexAttribPointer(noteRoundWidthLoc, 1, GLES20.GL_FLOAT, false, 0, 0)

        // 音符のX範囲をnoteRangeLocにバインド
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferIds[4])
        GLES20.glVertexAttribPointer(noteRangeLoc, 2, GLES20.GL_FLOAT, false, 0, 0)

        // 頂点インデックスをバインド
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, bufferIds[5])
        // 描画
        GLES20.glDrawElements(GLES20.GL_TRIANGLES, faceIndexesBufferCount, GLES20.GL_UNSIGNED_INT, 0)


        // 変数解除
        GLES20.glDisableVertexAttribArray(noteVtPosLoc)
        GLES20.glDisableVertexAttribArray(noteVtUvLoc)
        GLES20.glDisableVertexAttribArray(noteColorLoc)
        GLES20.glDisableVertexAttribArray(noteRoundWidthLoc)
        GLES20.glDisableVertexAttribArray(noteRangeLoc)

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)

        // 機能解除
        GLES20.glDisable(GLES20.GL_BLEND)
    }



    // Shaderプログラムを読み込む
    private fun loadProgram() {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, program.vertexCode).also {
            vertexShader = it
        }
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, program.fragmentCode).also {
            fragmentShader = it
        }

        GLES20.glAttachShader(shaderProgramId, vertexShader)
        GLES20.glAttachShader(shaderProgramId, fragmentShader)
        GLES20.glLinkProgram(shaderProgramId)
    }

    // Shaderプログラムを削除する
    private fun deleteProgram() {
        GLES20.glDeleteProgram(shaderProgramId)
        vertexShader?.let { GLES20.glDetachShader(shaderProgramId, it) }
        fragmentShader?.let { GLES20.glDetachShader(shaderProgramId, it) }

        GLES20.glDeleteShader(GLES20.GL_VERTEX_SHADER)
        GLES20.glDeleteShader(GLES20.GL_FRAGMENT_SHADER)
    }

    // endregion

    // region InnerClass
    class Program {

        // region Property

        // uniform
        val scrollUni = "u_scroll"
        val barPosUni = "u_barPos"

        // attribute
        val vtPosAttr = "v_pos"
        val colorAttr = "v_color"
        val roundWithAttr = "v_round_width"
        val vtUvAttr = "v_uv"
        val rangeAttr = "v_range"

        // varying
        private val colorVary = "f_color"
        private val roundWithVary = "f_round"
        private val vtUvVary = "f_uv"
        private val bright = "f_bright"
        // endregion

        // region Code
        // uniform: プリミティブごとの情報(描画呼び出し全体で一定)
        // attribute: 頂点毎の情報(通常:位置、法線、色、UVなど)
        // varying: フラグメント(ピクセル)ごとの情報。頂点間で値が補完される(Vertexの入力→Fragmentで補間された出力: 1頂点=>多ピクセル)
        val vertexCode = """
            attribute vec2 $vtPosAttr;
            attribute vec4 $colorAttr;
            attribute float $roundWithAttr;
            attribute vec2 $vtUvAttr;
            attribute vec2 $rangeAttr;
            
            varying vec4 $colorVary;
            varying vec2 $vtUvVary;
            varying float $roundWithVary;
            varying float $bright;
            
            uniform float $scrollUni;
            uniform float $barPosUni;
            
            void main() {
                gl_Position = vec4($vtPosAttr - vec2($scrollUni, 0.), 0.0, 1.0);
                
                $colorVary = $colorAttr;
                $roundWithVary = $roundWithAttr;
                $vtUvVary = $vtUvAttr;
                
                float start = $rangeAttr.x;
                float end = $rangeAttr.y;
                // noteの左がスクロールバーの左の時(鳴り終わり)
                float barPos = $barPosUni + $scrollUni;
                if (end < barPos) {
                    float lengthTime = end - start; // endからの経過時間
                    float power = clamp((1.3 - lengthTime) / 1.3, 0., 1.); //1~0 4秒で0
                    float endBright = power * 0.7;

                    float elapsedScroll = barPos - end; // endからの経過時間
                    float decay = clamp(elapsedScroll / 0.2, 0., 1.); //0=1 0.5秒で1 減衰
                    
                    $bright = clamp(endBright - decay, 0., 1.);
                 } else if(barPos < start) {
                    // まだ鳴ってない
                    $bright = 0.;
                 } else {
                    // 鳴っている
                    float elapsedScroll = barPos - start;
                    float power = clamp((1.3 - elapsedScroll) / 1.3, 0., 1.); //1~0 4秒で0
                    $bright = power * 0.7;
                 }
            }
        """.trimIndent()

        val fragmentCode = """
            precision mediump float;
            varying vec4 $colorVary;
            varying float $roundWithVary;
            varying vec2 $vtUvVary;
            varying float $bright;
            
            void main() {

                vec2 pos = $vtUvVary-vec2(0.5);
                float s = -0.5 + $roundWithVary;
                float e = 0.5 - $roundWithVary;
                
                // 丸角にする
                float dis0 = length(vec2((pos.x - s) * (0.5 / $roundWithVary), pos.y));
                float dis1 = length(vec2((pos.x - e) * (0.5 / $roundWithVary), pos.y));
                if((0.5 < dis0 && pos.x < s) || (0.5 < dis1 && e < pos.x)) discard;
                      
                gl_FragColor = vec4(mix($colorVary.rgb, vec3(1.), $bright), $colorVary.a);
            }
        """.trimIndent()

        // endregion
    }
    // endregion
}



おわりに

今回はVBOとIBOを用いてOpenGLの高速化を行いました。

改善後の動作

ご覧のように音符が物凄く増えても30FPSを維持し、スムーズな動作になりました。
ピアノロール以外のテーマでも高速化できそうであれば行うかもしれません。

ちなみに作曲画面で物凄く音符がある時は、ソロボタンや画面を拡大して音符の描画量を減らすことで遅延を少なく画面移動できます。
それでも、音符が物凄く多いと少し編集するだけでも動作が重いと思いますが。。。(すみません)

とにかく作者の執念を感じられる作品でした。