euphonictechnologies’s diary

Haskell超初心者の日記です。OCamlが好きです。

follow us in feedly

Haskell - GHC for iOS : OpenGLで3Dグラフィックスを表示してみる、の描画の詳細編

前回は描画の流れを眺めてみました

XcodeのOpenGLテンプレートプロジェクトは以下の流れで描画を行うことがわかりました。

  1. 画面をクリアする
  2. モデルデータからVAOオブジェクトを作り出す
  3. モデル描画に使う行列を計算する
  4. シェーダプログラムに計算した行列を流し込む
  5. 描画コマンドを発行する

これをXcodeのコードとHaskellのコードと対応付けて眺めてみます。

画面をクリアする

これは簡単ですね。

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
    glClearColor(0.65f, 0.65f, 0.65f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

この部分がHaskellでは

drawFrame :: IORef Double -> Double -> Double -> Double -> IO()
drawFrame rotRef timeSinceLastUpdate width height = do
    GL.clearColor $= Color4 0.65 0.65 0.65 1.0
    GL.clear [GL.ColorBuffer,GL.DepthBuffer]

こうなります。大きな違いはありません。

モデルデータからVAOオブジェクトを作り出す

Xcodeのプロジェクトでは大きな配列にモデルの頂点座標とそれぞれの頂点の法線ベクトルが6つの小数点を一組、1つの頂点として、それが頂点数分並んでいます。

GLfloat gCubeVertexData[216] = 
{
    // Data layout for each line below is:
    // positionX, positionY, positionZ,     normalX, normalY, normalZ,
    0.5f, -0.5f, -0.5f,        1.0f, 0.0f, 0.0f,
    0.5f, 0.5f, -0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, -0.5f, 0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, -0.5f, 0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, 0.5f, -0.5f,          1.0f, 0.0f, 0.0f,
...

こんな感じの配列です。ちなみにこれはHaskellでは

gCubeVertexData :: [GLfloat]
gCubeVertexData = [
    -- Data layout for each line below is:
    -- positionX, positionY, positionZ,     normalX, normalY, normalZ,
    0.5, -0.5, -0.5,        1.0, 0.0, 0.0,
    0.5, 0.5, -0.5,         1.0, 0.0, 0.0,
    0.5, -0.5, 0.5,         1.0, 0.0, 0.0,
    0.5, -0.5, 0.5,         1.0, 0.0, 0.0,
...

という感じになっています。GLfloatというOpenGL用の小数点型は普通のDoubleとコンパチなので、そのまま書き下せば大丈夫です。

で、それを頂点アレイとしてバッファにバインドします。Objective-Cでは具体的には以下のコードです。

- (void)setupGL
{
    ...
    glGenBuffers(1, &_vertexBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(gCubeVertexData), gCubeVertexData, GL_STATIC_DRAW);
    

これに対応するHaskellのコードは以下の部分です。

createVBO :: [GLfloat] -> IO BufferObject
createVBO elems = do
    [vertexBuffer] <- GL.genObjectNames 1
    GL.bindBuffer GL.ArrayBuffer $= Just vertexBuffer
    arr <- newListArray (0, len-1) elems
    let bufSize = toEnum $ len * sizeOf (head elems)
    withStorableArray arr $ \ptr ->
        GL.bufferData GL.ArrayBuffer $= (bufSize,ptr,GL.StaticDraw)
    GL.bindBuffer GL.ArrayBuffer $= Nothing
    return vertexBuffer
    where
        len = length elems

createVBO関数は[GLfloat]というOpenGLで使うための小数点型のリストを受け取ってIO BufferObjectを返します。コードの形は大幅に違いますが、雰囲気は似ているのを感じてもらえると思います。

genObjectNamesを使って使ってもいいオブジェクトのIDのようなものを取得します。配列に格納されて返されるので、それをパターンマッチで配列から取り出します。そのIDをbindBufferしたら、そのバッファにbufferDataを使って実際のデータをポインタを通じて流し込みます。

具体的には、GL.bufferDataのGL.ArrayBufferに[GLfloat]であるelemsをSTArrayであるarrに変換します。配列のポインタをwithStorableArrayを使ってGL.bufferDataに渡します。一応最後は使い終わったvertexBufferをバインド状態から外してあげます。

モデル描画に使う行列を計算する

まずはObjective-Cのコードをから見ていきます。

- (void)update
{
    float aspect = fabsf(self.view.bounds.size.width / self.view.bounds.size.height);
    GLKMatrix4 projectionMatrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(65.0f), aspect, 0.1f, 100.0f);
    
//    self.effect.transform.projectionMatrix = projectionMatrix;
    
    GLKMatrix4 baseModelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -4.0f);
    baseModelViewMatrix = GLKMatrix4Rotate(baseModelViewMatrix, _rotation, 0.0f, 1.0f, 0.0f);
    
    // Compute the model view matrix for the object rendered with GLKit
    GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -1.5f);
    modelViewMatrix = GLKMatrix4Rotate(modelViewMatrix, _rotation, 1.0f, 1.0f, 1.0f);
    modelViewMatrix = GLKMatrix4Multiply(baseModelViewMatrix, modelViewMatrix);
    
//    self.effect.transform.modelviewMatrix = modelViewMatrix;
    
    // Compute the model view matrix for the object rendered with ES2
    modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, 1.5f);
    modelViewMatrix = GLKMatrix4Rotate(modelViewMatrix, _rotation, 1.0f, 1.0f, 1.0f);
    modelViewMatrix = GLKMatrix4Multiply(baseModelViewMatrix, modelViewMatrix);
    
    _normalMatrix = GLKMatrix3InvertAndTranspose(GLKMatrix4GetMatrix3(modelViewMatrix), NULL);
    
    _modelViewProjectionMatrix = GLKMatrix4Multiply(projectionMatrix, modelViewMatrix);
    
    _rotation += self.timeSinceLastUpdate * 0.5f;
}

update関数全てです。ですが、GLKitに渡す部分とOpenGL部分で使うものとが混在しており、実際には省けるコードがあるので、その部分を省いています(2行だけですが)。

最終的に2つの行列が必要なことがわかります。normalMatrixとmodelViewProjectionMatrixです。normalMatrixは法線ベクトルを変換してきちんとシェーディングを行うための行列です。modelViewProjectionMatrixはモデルの位置、カメラの位置、カメラへの映り方を素モデルの頂点配列に適用するための行列です。詳しく追いかけるのはさておき、素直にこれをHaskellに戻してみます。

getMatrices :: Double -> Double -> (M.Matrix Double, M.Matrix Double)
getMatrices aspect rotation =
    (modelViewProjectionMatrix, normalMatrix)
    where
        modelViewProjectionMatrix = modelViewMatrix * projectionMatrix
        normalMatrix = M.transpose $ MH.inverse $ M.submatrix 1 3 1 3 modelViewMatrix
        projectionMatrix = MH.matrixPerspective (MH.radiansFromDegrees 65.0) aspect 0.1 100.0
        modelViewMatrix = modelViewMatrixE * baseModelViewMatrix
        baseModelViewMatrix = MH.matrixRotate (MH.matrixTranslation 0.0 0.0 (-4.0)) rotation 0.0 1.0 0.0
        modelViewMatrixE = MH.matrixRotate (MH.matrixTranslation 0.0 0.0 1.5) rotation 1.0 1.0 1.0

非常に素直な置換えになっています。modelViewMatrixEの部分は少し苦しいですが、だいたい同じように置き換えられています。実際にはこのMH.inverseやMH.matrixRotateといったMH(MatrixHelper)部分が肝なのですが、リポジトリにあるので興味のある方はMatrixHelper.hsご覧ください。

行列演算を本気で高速化しようとするとMatrixパッケージではなくてLinearパッケージやステンシル計算ができるrepaなどを利用すべきだと思いますが、ここではそれほど高速でなくてもよく、さらにghc-iosならではの制限もあるので、素直な実装にしました。超巨大なモデルを必要としなければ大丈夫なはず。

シェーダプログラムに計算した行列を流し込む/描画コマンドを発行する

同じようにObjective-Cのコードから見てみます。

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
...
    glBindVertexArrayOES(_vertexArray);
...    
    // Render the object again with ES2
    glUseProgram(_program);
    
    glUniformMatrix4fv(uniforms[UNIFORM_MODELVIEWPROJECTION_MATRIX], 1, 0, _modelViewProjectionMatrix.m);
    glUniformMatrix3fv(uniforms[UNIFORM_NORMAL_MATRIX], 1, 0, _normalMatrix.m);
    
    glDrawArrays(GL_TRIANGLES, 0, 36);
}

glUniformMatrix4fvというのが4次元行列を流しこんでいる場所です。glUniformMatrix3fvは3次元行列です。uniforms[UNIFORM...]はその配列を割り当てるシェーダプログラム上の場所を示しています。バーテックスシェーダプログラムは2つのインプット、uniform mat4 modelViewProjectionMatrix; uniform mat3 normalMatrix;を受け取ります。その場所をシェーダプログラムのコンパイル時にuniformsに保存しておいて、このように使います。その場所はシェーダのコンパイル時に決まるので、事前に計算しておくことはできません。その場所にそれぞれglUniformMatrix~関数を使って流し込みます。

その上でglBindVertexArrayOES(_vertexArray);によって頂点情報を割り当てし、glDrawArrayを実行すると、指定された頂点情報、行列、シェーダプログラムを使ってシェーダが描画を行います。

Haskell版は以下のようになっています。

draw :: BufferObject -> VertexArrayObject -> IO ()
draw vertexBuffer vao = do
    GL.vertexAttribArray (GL.AttribLocation 0) $= GL.Enabled
    GL.vertexAttribArray (GL.AttribLocation 1) $= GL.Enabled
    GL.bindBuffer GL.ArrayBuffer               $= Just vertexBuffer
    GL.bindVertexArrayObject $= Just vao
    GL.vertexAttribPointer (GL.AttribLocation 0) $= (GL.ToFloat, pos_descriptor)
    GL.vertexAttribPointer (GL.AttribLocation 1) $= (GL.ToFloat, norm_descriptor)
    GL.drawArrays GL.Triangles 0 36
    GL.bindBuffer GL.ArrayBuffer $= Nothing
    GL.vertexAttribArray (GL.AttribLocation 0) $= GL.Disabled
    GL.vertexAttribArray (GL.AttribLocation 1) $= GL.Disabled

    GL.flush
    where
        pos_descriptor = GL.VertexArrayDescriptor 3 GL.Float 24 nullPtr
            :: GL.VertexArrayDescriptor GLfloat
        norm_descriptor = GL.VertexArrayDescriptor 3 GL.Float 24 (plusPtr nullPtr 12)
            :: GL.VertexArrayDescriptor GLfloat

uniformLocate :: (GLint -> GLsizei -> GLboolean -> Ptr GLfloat -> IO ())
              -> VS.Vector GLfloat
              -> (Maybe GL.Program)
              -> String
              -> IO ()
uniformLocate uniformFn matrix (Just program) string = do
    loc <- GL.get $ GL.uniformLocation program string
    let (UniformLocation locId) = loc in
        VS.unsafeWith matrix $ \ptr -> uniformFn locId 1 0 ptr
uniformLocate uniformFn matrix Nothing string = do
    fail ("uniformLocate failed. Possibly program doesn't exist" )

drawFrame :: IORef Double -> Double -> Double -> Double -> IO()
drawFrame rotRef timeSinceLastUpdate width height = do
...
    let (modelViewProjectionMatrix, normalMatrix) = getMatrices aspect rotation

    mprog <- (GL.get GL.currentProgram)
    uniformLocate glUniformMatrix4fv (glFloatVectorFromMatrix4 modelViewProjectionMatrix) mprog "modelViewProjectionMatrix"
    uniformLocate glUniformMatrix3fv (glFloatVectorFromMatrix3 normalMatrix) mprog "normalMatrix"

    draw vbo vertexArrayId
    flush

drawFrameは割とストレートフォワードです。行列2つをglUniformMatrix~関数を使ってバインドしています。通常の行列をポインタを取り出して割り当てを行うためのuniformLocateヘルパ関数があります。シェーダプログラムのメタ情報から行列を流し込む位置を取得しています。その後でVS.unsafeWithを使ってポインタを取り出してglUniformMatrix4fvコマンドをポインタとともに発行することができます。

draw関数はObjective-C版でいうglDrawArraysを実行しています。お膳立てが色々必要なので、ヘルパ関数として分離しています。その実態はOpenGLコマンドの塊です。

まとめ

これで、描画部分をObjective-Cに分離することが出来ました。分離後のObjective-Cレイヤをもう一度見てみると

- (void)update
{
}

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
    extern void (*drawFrame)(double, double, double);
    drawFrame(self.timeSinceLastUpdate, self.view.bounds.size.width, self.view.bounds.size.height);
}

drawFrameが行列演算も行うので、drawFrame関数の呼び出しだけで問題ありません。すべての描画コマンドがHaskellレイヤに移植できたことがわかります。

もう一つ、Haskellレイヤに全て移植できることがあります。シェーダプログラムのロードとコンパイル、配置です。次回はその部分について眺めてみます。最終的にCocoa touchに必要なビューの初期化とGLKitを使うための最小限のコードだけがObjective-Cレイヤに残ります。