【C++】第三回 C++を使ったゲーム開発 〜3Dゲーム開発基礎 OpenGLと座標変換編〜

スポンサーリンク
PC創作
マイケル
マイケル
みなさんこんにちは!
まいけるです!
エレキベア
エレキベア
お久しぶりクマ〜〜〜
マイケル
マイケル
今回は前回に引き続きC++で3Dゲームを作る予定でしたが・・・

↑前回の記事

マイケル
マイケル
3Dゲーム開発はとにかく覚えることが多い!
基本処理を作りながら、どうまとめるかでずっと悩んでいました…
エレキベア
エレキベア
一からだとそりゃそうなるクマ・・・
マイケル
マイケル
というわけで、下記のように追加で2回に分けて
3Dゲーム開発基礎編を書くことにしました!


[今後の予定]
第三回 3Dゲーム開発基礎 OpenGLと座標変換編
第四回 3Dゲーム開発基礎 fbx読込とシェーダ編
第五回 3Dゲーム開発編(予定)

エレキベア
エレキベア
しばらく座学クマか〜〜〜
マイケル
マイケル
今回は下記図の緑色の範囲を解説していきます!
モデル読込やシェーダの話は次回に回して、
主に全体の流れと座標変換周りを対象としています。
ScreenShot 2021 10 09 22 00 13 1
↑今回の記事の範囲
マイケル
マイケル
下記にサンプルコードも用意しています!
3Dモデルを描画するだけですが、勉強しながらなのもあり
1ヶ月近くかかっています・・・

GitHub – cpp-opengl-sample

01 opengl sample
↑数種類のシェーダ描画も行なっているがそれは次回解説予定である
エレキベア
エレキベア
おつかれさまクマ・・・
スポンサーリンク

参考書籍

マイケル
マイケル
基本的に理論や考え方は下記書籍をベースに作成しています。

ゲームプログラミングC++

エレキベア
エレキベア
これまでも使っていたクマね
マイケル
マイケル
そしてところどころ下記2冊とも照らし合わせながら
整理して実装しました!

ゲームプログラマになる前に覚えておきたい技術

ゲームを動かす数学・物理 R

マイケル
マイケル
どの本もゲーム開発の基本が
広く書かれているためおすすめです!
エレキベア
エレキベア
読み応えがありそうクマ〜〜〜
スポンサーリンク

ソース全体構成

マイケル
マイケル
作成したプロジェクトの構成としては
下記のようになっています。

GitHub – cpp-opengl-sample

.
├── Actors
│   ├── Actor.cpp
│   ├── Actor.h
│   ├── Camera.cpp
│   ├── Camera.h
│   ├── Saikoro.cpp
│   └── Saikoro.h
├── Commons
│   ├── Math.h
│   ├── Mesh.cpp
│   ├── Mesh.h
│   ├── Renderer.cpp
│   ├── Renderer.h
│   ├── Shader.cpp
│   ├── Shader.h
│   ├── Texture.cpp
│   ├── Texture.h
│   ├── VertexArray.cpp
│   └── VertexArray.h
├── Components
│   ├── Component.cpp
│   ├── Component.h
│   ├── MeshComponent.cpp
│   ├── MeshComponent.h
│   ├── SpriteComponent.cpp
│   └── SpriteComponent.h
├── Shaders
│   ├── BasicFrag.glsl
│   ├── BasicVert.glsl
│   ├── PhongFrag.glsl
│   ├── PhongVert.glsl
│   ├── SpriteFrag.glsl
│   └── SpriteVert.glsl
├── Game.cpp
├── Game.h
└── main.cpp

マイケル
マイケル
2Dゲーム開発の時と基本構成は同じですが、
3Dモデル用のクラスを複数作成しています!
また、描画関連の処理に関しては Rendererクラス に分割しました!
ScreenShot 2021 10 06 22 32 04
エレキベア
エレキベア
多くはなったが必要最低限って感じクマね
スポンサーリンク

3Dグラフィックスの基本

マイケル
マイケル
3DグラフィックスはSDLでは扱えないため、
OpenGL と組み合わせることで3Dモデルを描画します。
そのためまずは座標変換の話に入る前に、
そのようなグラフィックスライブラリを使った3D描画の考え方を解説します!
エレキベア
エレキベア
ついに3Dグラフィックスクマ〜〜〜

頂点座標と三角ポリゴン

マイケル
マイケル
まずSDLとの描画と比較して大きく考え方が異なる点があります。
それは

1. 頂点ごとに座標情報を持つ
2. ポリゴン単位で描画する

の2点になります。
頂点ごとに座標情報を持つ
マイケル
マイケル
位置座標のみで描画できていたこれまでとは異なり、
3Dグラフィックスでは頂点ごとに座標情報を持たせるのが基本となります。
ScreenShot 2021 10 09 21 57 57 1
↑(左)全体の位置座標のみ (右)頂点ごとの位置座標
エレキベア
エレキベア
それは急に複雑になりそうクマね
マイケル
マイケル
今回は位置座標のみ持たせているけど、
法線やUV座標を持たせることもできるよ!
これらはライティングに必要な情報となるので、次回解説していきます!
ポリゴン単位で描画する
マイケル
マイケル
そしていくつかの頂点座標のまとまりごとに、
ポリゴン単位での描画 を行います!
マイケル
マイケル
この考えは多角形のポリゴンにも適用できますが、
基本となるのは三角ポリゴンでの描画です。
例えば、2D描画の場合は下記のように
2つのポリゴンに分割して描画処理を行います。
ScreenShot 2021 10 10 12 15 04
↑2つの三角ポリゴンに分けて描画する
エレキベア
エレキベア
2Dの描画でも分ける必要があるクマね〜〜
マイケル
マイケル
確かに2Dで分割するのは手間に感じてしまうかもしれないね。
でもこれは 3D描画にとても効率的な手段 で、
下記のように 全て三角ポリゴンに分割することで、
大量の計算をせずに描画することができる
んだ!
ScreenShot 2021 10 09 21 58 11 3
↑3Dグラフィクスのポリゴン分割
エレキベア
エレキベア
この情報を全ての3Dモデルが持っているなんて壮大クマ〜〜〜

頂点バッファとインデックスバッファ

マイケル
マイケル
次はこの座標情報をどのように渡すのかについて!
下記はOpenGLの例ですが、画面の座標情報は デカルト座標系といって、
-1〜+1の範囲 で持っています。
ScreenShot 2021 10 09 21 57 57 2
↑OpenGLの座標情報(デカルト座標系)
マイケル
マイケル
そしてこれは画面の大きさに関係なくこの範囲で決まっているので、
全て0.5で指定したとしても、画面比率によって大きさは変動します。
ScreenShot 2021 10 09 21 58 11 1
↑画面比率によって大きさは変動する
エレキベア
エレキベア
最終的には座標位置を計算して渡さないといけないクマね
マイケル
マイケル
その通り!
そして上の図を見て感づいた方もいるかもしれませんが、
三角ポリゴンを描画するにあたっては同じ頂点を何度か指定 することになります。
マイケル
マイケル
そのため、下記のように各頂点に番号を振って管理することで、
バッファする情報を削減
します。
ScreenShot 2021 10 09 21 58 11 2
↑各頂点に番号を割り振る(インデックスバッファ)
マイケル
マイケル
このように振った番号でポリゴン描画を持ったものを インデックスバッファ といい、
対して各頂点の座標情報は 頂点バッファ と呼びます。
この四角形の例をソースコードで表すと下記のようにして渡します!
    // 頂点バッファ(x,y,z,u,v)
    float vertices[] = {
            -0.5f,  0.5f, 0.0f,  // TOP LEFT
             0.5f,  0.5f, 0.0f,  // TOP RIGHT
             0.5f, -0.5f, 0.0f,  // BOTTOM RIGHT
            -0.5f, -0.5f, 0.0f   // BOTTOM LEFT
    };
    // インデックスバッファ
    unsigned int indices[] = {
            0, 1, 2,
            2, 3, 0
    };
    mVertices = new VertexArray(vertices, 4, indices, 6);


VertexArray::VertexArray(const float *vertices,
                         unsigned int numVertices,
                         const unsigned int *indices,
                         unsigned int numIndices)
:mNumVertices(numVertices)
,mNumIndices(numIndices)
{
    // 頂点配列オブジェクトの作成
    glGenVertexArrays(1, &mVertexArray);
    glBindVertexArray(mVertexArray);

    // 頂点バッファの作成
    glGenBuffers(1, &mVertexBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, mVertexBuffer);
    glBufferData(GL_ARRAY_BUFFER,                 // バッファの種類
                 numVertices * 3 * sizeof(float), // コピーするバイト数(x, y, z)
                 vertices,                        // コピー元
                 GL_STATIC_DRAW);                 // データの利用方法

    // インデックスバッファの作成
    glGenBuffers(1, &mIndexBuffer);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mIndexBuffer);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER,           // バッファの種類
                 numIndices * sizeof(unsigned int), // コピーするバイト数
                 indices,                           // コピー元
                 GL_STATIC_DRAW);                   // データの利用方法

    // 頂点レイアウトの指定
    // 頂点属性0: x,y,z
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 3, 0);
}
マイケル
マイケル
これで設定した情報がシェーダに渡されて、描画処理を行うわけです!
エレキベア
エレキベア
なんとなく仕組みが分かってきたクマ〜〜

シェーダ

マイケル
マイケル
そしてシェーダでは受け取った座標情報を元に計算を行い、描画を行います。
頂点シェーダフラグメントシェーダ というように役割が分かれているのですが、
これは次回詳しく解説していきます!
ScreenShot 2021 10 09 21 59 57
↑シェーダの描画プロセス
マイケル
マイケル
例として、全面で青色で出力するだけの
シンプルなプログラム例を描きに載せておきます!
#version 330

uniform mat4 uWorldTransform; // ワールド変換座標
uniform mat4 uViewProjection; // ビュー射影行列

in vec3 inPosition;

void main() {
    // w成分を加える
    vec4 pos = vec4(inPosition, 1.0);
    // ローカル座標 * ワールド変換座標 * ビュー射影行列
    // を逆に計算して、クリップ空間座標に変換
    gl_Position = uViewProjection * uWorldTransform * pos;
}
#version 330

out vec4 outColor;

void main() {
    // 青色を出力
    outColor = vec4(0.0, 0.0, 1.0, 1.0);
}
エレキベア
エレキベア
座標計算と色合い調整で分かれているのクマね

GLEWの使用

マイケル
マイケル
これらの機能をSDL+OpenGLの組み合わせで実装しましたが、
GLEWというOpenGLの拡張ライブラリを使用することで簡単に機能を有効化
することができます。
マイケル
マイケル
下記はサンプルプロジェクトの初期化例になります。
それぞれ glewInitでGLEWの初期化SDL_GL_SetAttributeで描画の設定
を行なっています。
bool Renderer::Initialize()
{
    // SDL関連初期化
    if (!InitSDL())
    {
        SDL_Log("%s", SDL_GetError());
        return false;
    }

    // GLEWの初期化
    glewExperimental = GL_TRUE;
    if (glewInit() != GLEW_OK) return false;
    glGetError();

    return true;
}

bool Renderer::InitSDL()
{
    // 初期化に失敗したらfalseを返す
    bool success = SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO) == 0;
    if (!success) return false;

    // OpenGL設定
    // コアOpenGLプロファイル
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
    // バージョン3.3を指定
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
    // RGBA各チャネル8ビットのカラーバッファ
    SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
    // Zバッファのビット数
    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
    // ダブルバッファ
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    // ハードウェアアクセラレーション
    SDL_GL_SetAttribute(SDL_GL_ACCELERATED_VISUAL, 1);

    // OpenGLウィンドウの作成
    mWindow = SDL_CreateWindow("OpenGLTest",
                               100, 100,mGame->ScreenWidth, mGame->ScreenHeight,
                               SDL_WINDOW_OPENGL);
    if (!mWindow) return false;

    // OpenGLコンテキストの作成
    mContext = SDL_GL_CreateContext(mWindow);
    if (!mContext) return false;

    return true;
}

↑OpenGL機能の初期化

エレキベア
エレキベア
OpenGLを簡単に使えるようにするライブラリクマね

Zバッファとアルファブレンド

マイケル
マイケル
そして最後に補足となりますが、
3D描画ではZバッファという仕組みを利用して
手前にある座標のみ描画するようにしています。
マイケル
マイケル
これは便利な反面、半透明なオブジェクトは使用できないといった問題点もあります。
そのため、
3D描画ではZバッファ有効化&アルファブレンド無効化
2D描画ではZバッファ無効化&アルファブレンド有効化
と切り替えて描画処理を行なっています。
void Renderer::Draw()
{
    // 背景色をクリア
    glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // Zバッファ有効、アルファブレンド無効
    glEnable(GL_DEPTH_TEST);
    glDisable(GL_BLEND);

    // メッシュ描画
    for (auto meshComp : mMeshComps)
    {
        meshComp->Draw();
    }

    // Zバッファ無効、アルファブレンド有効
    glDisable(GL_DEPTH_TEST);
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    // スプライト描画
    m2DSpriteShader->SetActive();
    m2DSpriteShader->SetViewProjectionUniform(m2DViewProjection);
    m2DSpriteVertexArray->SetActive();
    for (auto sprite : mSpriteComps)
    {
        sprite->Draw(m2DSpriteShader);
    }

    // バックバッファとスワップ(ダブルバッファ)
    SDL_GL_SwapWindow(mWindow);
}

↑Zバッファ、アルファブレンドの有効切替

マイケル
マイケル
これで基本的な流れとしては以上になります!
エレキベア
エレキベア
ざっくりと分かった気はするクマ〜〜・・・
スポンサーリンク

座標変換

マイケル
マイケル
基本の流れがわかったところで、座標変換の解説に入っていきます!
エレキベア
エレキベア
なんだか難しそうクマ〜〜〜

座標変換の考え方

マイケル
マイケル
座標変換はざっくりいうと、
モデル自体の頂点座標(ローカル座標)から
-1〜+1までの描画する座標(クリップ座標)に変換するまでの行程

のことなんだ!
ScreenShot 2021 10 09 21 59 21 1
↑座標変換のプロセス
マイケル
マイケル
ローカル座標→ワールド座標→クリップ座標と変換していき、
下記は図で例を表したものになります。
(あくまでイメージで座標値についてはこの通りになるわけではないです。)
マイケル
マイケル
まず モデルを読み込んだ時の頂点座標 は下記のように、
オブジェクト自体の位置(原点)を0,0として位置が決まっています。
ScreenShot 2021 10 09 21 59 21 2
↑ローカル座標のイメージ
マイケル
マイケル
そしてそのオブジェクトは ワールド上の任意の位置に移動させたり、
回転・拡大縮小を行った際に各頂点の座標も変化
することになります。
これをワールド座標といいます。
ScreenShot 2021 10 09 21 59 21 3
↑ワールド座標のイメージ
マイケル
マイケル
変換した座標は最終的に画面に出力するため、
-1〜+1の範囲に変換する必要があります。
このような画面に出力するための座標を クリップ座標 といいます。
ScreenShot 2021 10 09 21 59 21 4
↑クリップ座標のイメージ
マイケル
マイケル
このように出力するためには座標情報を変換していく必要があるわけです。
エレキベア
エレキベア
長い道のりクマね〜〜〜〜
マイケル
マイケル
そしてこの計算を行うには数学で出てきた 行列式(Matrix) を使用することで、
非常に計算しやすくなります。
3Dグラフィックスでは x,y,zの情報にwを加えた4×4の行列で計算 することが多いです。
そのため下記のように行列クラスを使って計算を行うことになります。
// 4*4行列
class Matrix4
{
public:
    float matrix[4][4];

・・・略・・・

    friend Matrix4 operator*(const Matrix4& a, const Matrix4& b)
    {
        // 4*4行列の計算
        // |a11 a12 a13 a14| |b11 b12 b13 b14|
        // |a21 a22 a23 a24| |b21 b22 b23 b14|
        // |a31 a32 a33 a34| |b31 b32 b33 b14|
        // a11b11+a12b21+a13b31+a14b41, a11b12+a12b22+a13b32+a14b42, a11b13+a12b23+a13b33+a14b43, a11b14+a12b24+a13b34+a14b44
        // a21b11+a22b21+a23b31+a24b41, a21b12+a22b22+a23b32+a24b42, a21b13+a22b23+a23b33+a24b43, a21b14+a22b24+a23b34+a24b44
        // a31b11+a32b21+a33b31+a34b41, a31b12+a32b22+a33b32+a34b42, a31b13+a32b23+a33b33+a34b43, a31b14+a32b24+a33b34+a34b44
        // a41b11+a42b21+a43b31+a44b41, a41b12+a42b22+a43b32+a44b42, a41b13+a42b23+a43b33+a44b43, a41b14+a42b24+a43b34+a44b44
        Matrix4 ret;
        // row1
        ret.matrix[0][0] = a.matrix[0][0]*b.matrix[0][0] + a.matrix[0][1]*b.matrix[1][0] + a.matrix[0][2]*b.matrix[2][0] + a.matrix[0][3]*b.matrix[3][0];
        ret.matrix[0][1] = a.matrix[0][0]*b.matrix[0][1] + a.matrix[0][1]*b.matrix[1][1] + a.matrix[0][2]*b.matrix[2][1] + a.matrix[0][3]*b.matrix[3][1];
        ret.matrix[0][2] = a.matrix[0][0]*b.matrix[0][2] + a.matrix[0][1]*b.matrix[1][2] + a.matrix[0][2]*b.matrix[2][2] + a.matrix[0][3]*b.matrix[3][2];
        ret.matrix[0][3] = a.matrix[0][0]*b.matrix[0][3] + a.matrix[0][1]*b.matrix[1][3] + a.matrix[0][2]*b.matrix[2][3] + a.matrix[0][3]*b.matrix[3][3];
        // row2
        ret.matrix[1][0] = a.matrix[1][0]*b.matrix[0][0] + a.matrix[1][1]*b.matrix[1][0] + a.matrix[1][2]*b.matrix[2][0] + a.matrix[1][3]*b.matrix[3][0];
        ret.matrix[1][1] = a.matrix[1][0]*b.matrix[0][1] + a.matrix[1][1]*b.matrix[1][1] + a.matrix[1][2]*b.matrix[2][1] + a.matrix[1][3]*b.matrix[3][1];
        ret.matrix[1][2] = a.matrix[1][0]*b.matrix[0][2] + a.matrix[1][1]*b.matrix[1][2] + a.matrix[1][2]*b.matrix[2][2] + a.matrix[1][3]*b.matrix[3][2];
        ret.matrix[1][3] = a.matrix[1][0]*b.matrix[0][3] + a.matrix[1][1]*b.matrix[1][3] + a.matrix[1][2]*b.matrix[2][3] + a.matrix[1][3]*b.matrix[3][3];
        // row3
        ret.matrix[2][0] = a.matrix[2][0]*b.matrix[0][0] + a.matrix[2][1]*b.matrix[1][0] + a.matrix[2][2]*b.matrix[2][0] + a.matrix[2][3]*b.matrix[3][0];
        ret.matrix[2][1] = a.matrix[2][0]*b.matrix[0][1] + a.matrix[2][1]*b.matrix[1][1] + a.matrix[2][2]*b.matrix[2][1] + a.matrix[2][3]*b.matrix[3][1];
        ret.matrix[2][2] = a.matrix[2][0]*b.matrix[0][2] + a.matrix[2][1]*b.matrix[1][2] + a.matrix[2][2]*b.matrix[2][2] + a.matrix[2][3]*b.matrix[3][2];
        ret.matrix[2][3] = a.matrix[2][0]*b.matrix[0][3] + a.matrix[2][1]*b.matrix[1][3] + a.matrix[2][2]*b.matrix[2][3] + a.matrix[2][3]*b.matrix[3][3];
        // row4
        ret.matrix[3][0] = a.matrix[3][0]*b.matrix[0][0] + a.matrix[3][1]*b.matrix[1][0] + a.matrix[3][2]*b.matrix[2][0] + a.matrix[3][3]*b.matrix[3][0];
        ret.matrix[3][1] = a.matrix[3][0]*b.matrix[0][1] + a.matrix[3][1]*b.matrix[1][1] + a.matrix[3][2]*b.matrix[2][1] + a.matrix[3][3]*b.matrix[3][1];
        ret.matrix[3][2] = a.matrix[3][0]*b.matrix[0][2] + a.matrix[3][1]*b.matrix[1][2] + a.matrix[3][2]*b.matrix[2][2] + a.matrix[3][3]*b.matrix[3][2];
        ret.matrix[3][3] = a.matrix[3][0]*b.matrix[0][3] + a.matrix[3][1]*b.matrix[1][3] + a.matrix[3][2]*b.matrix[2][3] + a.matrix[3][3]*b.matrix[3][3];
        return ret;
    }

・・・略・・・
}

↑4×4行列クラス

エレキベア
エレキベア
行列式懐かしクマ〜〜〜〜

ワールド座標変換

ScreenShot 2021 10 09 21 59 31 1
マイケル
マイケル
まずはワールド座標への変換について!
オブジェクトの変換としては
・拡大縮小
・回転
・平行移動

の3種類があり、それぞれ下記の行列式で表すことができます!
拡大縮小
ScreenShot 2021 10 09 0 11 24 1
    // スケール行列
    static Matrix4 CreateScale(float x, float y, float z)
    {
        float temp[4][4] =
        {
            {    x, 0.0f, 0.0f, 0.0f },
            { 0.0f,    y, 0.0f, 0.0f },
            { 0.0f, 0.0f,    z, 0.0f },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
回転
ScreenShot 2021 10 09 0 11 24 2
    // 回転行列
    static Matrix4 CreateRotationX(float radians)
    {
        float temp[4][4] =
        {
            { 1.0f, 0.0f, 0.0f, 0.0f },
            { 0.0f, cosf(radians), -sinf(radians), 0.0f },
            { 0.0f, sinf(radians), cosf(radians), 0.0f },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
    static Matrix4 CreateRotationY(float radians)
    {
        float temp[4][4] =
        {
            { cosf(radians), 0.0f, sinf(radians), 0.0f },
            { 0.0f, 1.0f, 0.0f, 0.0f },
            { -sinf(radians), 0.0f, cosf(radians), 0.0f },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
    static Matrix4 CreateRotationZ(float radians)
    {
        float temp[4][4] =
        {
            { cosf(radians), -sinf(radians), 0.0f, 0.0f },
            { sinf(radians), cosf(radians), 0.0f, 0.0f },
            { 0.0f, 0.0f, 1.0f, 0.0f },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
平行移動
ScreenShot 2021 10 09 0 11 24 3
    // 平行移動行列
    static Matrix4 CreateTranslation(float x, float y, float z)
    {
        float temp[4][4] =
        {
            { 1.0f, 0.0f, 0.0f, x },
            { 0.0f, 1.0f, 0.0f, y },
            { 0.0f, 0.0f, 1.0f, z },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
マイケル
マイケル
これらの行列式を掛け合わせることで、
ワールド変換用の行列式としてまとめる ことができます!
エレキベア
エレキベア
オブジェクト側で1つの行列にまとめられたら
計算が楽になりそうクマね
クォータニオンによる回転
マイケル
マイケル
しかし、上記の回転の行列式には1点問題があります。
それは ジンバルロック といって、
各回転軸の向きによって回転の自由度が下がってしまうことがある ということです。
マイケル
マイケル
そのため3D描画の回転は代わりに クォータニオン
という表現方法を用いることが多いです。
エレキベア
エレキベア
クォータニオン・・・噂には聞いたことがあるクマ・・・。
マイケル
マイケル
クォータニオンは下記のように、ベクトルとスカラーの成分を持ち
正規化された回転軸axisと回転θで計算 することができます。
ScreenShot 2021 10 09 0 12 11 1
↑クォータニオンの構成
マイケル
マイケル
そしてクォータニオンは、
クォータニオン同士を連結することで回転を加える ことができます。
クォータニオンqにクォータニオンrを加えた時の計算式 は下記になります。
ScreenShot 2021 10 09 0 12 11 2
↑クォータニオンに回転を加える
エレキベア
エレキベア
よくわからないクマがこの計算式で回転させることができるクマね
マイケル
マイケル
クォータニオンの概念は難しいから、
とりあえず使い方だけ覚えてぼちぼち勉強しましょう…。。
マイケル
マイケル
以上のことをクラスにしたものが以下になります。
// クォータニオン
class Quaternion
{
public:
    float x, y, z; // ベクトル qv
    float w;       // スカラー qs

    Quaternion()
    :x(0.0f)
    ,y(0.0f)
    ,z(0.0f)
    ,w(1.0f)
    {}

    Quaternion(float inX, float inY, float inZ, float inW)
    :x(inX)
    ,y(inY)
    ,z(inZ)
    ,w(inW)
    {}

    // 正規化された回転軸と回転角からクォータニオンを計算する
    Quaternion(const Vector3& axis, float angle)
    {
        // qv = axis * sin(angle/2.0f)
        float scalar = sinf(angle / 2.0f);
        x = axis.x * scalar;
        y = axis.y * scalar;
        z = axis.z * scalar;
        // qs = cos(angle/2.0f)
        w = cosf(angle / 2.0f);
    }

    // クォータニオン結合処理
    // qにpを加えたクォータニオンを返却する
    static Quaternion Concatenate(const Quaternion& q, const Quaternion& p)
    {
        Vector3 qv(q.x, q.y, q.z);
        Vector3 pv(p.x, p.y, p.z);

        // グラスマン積でベクトルとスカラを計算する(引数順で計算)
        Vector3 outVec = p.w*qv + q.w*pv + Vector3::Cross(pv, qv);
        float outW = p.w*q.w - Vector3::Dot(pv, qv);

        // クォータニオンとして返却
        return Quaternion(outVec.x, outVec.y, outVec.z, outW);
    }

    // クォータニオンでベクトルを回転させる
    static Vector3 RotateVec(const Vector3& v, const Quaternion& q)
    {
        // vec = 2.0*qv・(qv・v + qw*v)
        Vector3 qv(q.x, q.y, q.z);
        Vector3 retVec = v;
        retVec += 2.0f * Vector3::Cross(qv, Vector3::Cross(qv, v) + q.w*v);
        return retVec;
    }
};

↑クォータニオンクラス

マイケル
マイケル
また、回転軸の単位軸は下記のように定数として定義しておくと便利です!
    // vec3
    static const Vector3 VEC3_ZERO   = Vector3(0.0f, 0.0f, 0.0f);
    static const Vector3 VEC3_UNIT   = Vector3(1.0f, 1.0f, 1.0f);
    static const Vector3 VEC3_UNIT_X = Vector3(1.0f, 0.0f, 0.0f);
    static const Vector3 VEC3_UNIT_Y = Vector3(0.0f, 1.0f, 0.0f);
    static const Vector3 VEC3_UNIT_Z = Vector3(0.0f, 0.0f, 1.0f);

↑単位軸の定義

マイケル
マイケル
そして最後に、クォータニオンは行列式でも定義 することができます!
下記式を使用することで、拡大縮小や平行移動と同様、1つのワールド変換行列としてまとめることができるということです。
ScreenShot 2021 10 09 0 11 36
    // クォータニオン行列
    static Matrix4 CreateQuaternion(const class Quaternion& q)
    {
        // 単位クォータニオンの場合の式になる
        // | 1-2qy^2-2qz^2 2qxqy-2qwqz   2qxqz+2qwqy   0 |
        // | 2qxqy+2qwqz   1-2qx^2-2qz^2 2qyqz-2qwqx   0 |
        // | 2qxqz-2qwqy   2qyqz+2qwqx   1-2qx^2-2qy^2 0 |
        // | 0             0             0             1 |
        float temp[4][4];
        // row1
        temp[0][0] = 1.0f - 2.0f*q.y*q.y - 2.0f*q.z*q.z;
        temp[0][1] = 2.0f*q.x*q.y - 2.0f*q.w*q.z;
        temp[0][2] = 2.0f*q.x*q.z + 2.0f*q.w*q.y;
        temp[0][3] = 0.0f;
        // row2
        temp[1][0] = 2.0f*q.x*q.y + 2.0f*q.w*q.z;
        temp[1][1] = 1.0f - 2.0f*q.x*q.x - 2.0f*q.z*q.z;
        temp[1][2] = 2.0f*q.y*q.z - 2.0f*q.w*q.x;
        temp[1][3] = 0.0f;
        // row3
        temp[2][0] = 2.0f*q.x*q.z - 2.0f*q.w*q.y;
        temp[2][1] = 2.0f*q.y*q.z + 2.0f*q.w*q.x;
        temp[2][2] = 1.0f - 2.0f*q.x*q.x - 2.0f*q.y*q.y;
        temp[2][3] = 0.0f;
        // row4
        temp[3][0] = 0.0f;
        temp[3][1] = 0.0f;
        temp[3][2] = 0.0f;
        temp[3][3] = 1.0f;
        return Matrix4(temp);
    }
マイケル
マイケル
ワールド変換で使用する数式は以上になります!
エレキベア
エレキベア
クォータニオンは置いといて分かってきたクマ〜〜〜

クリップ座標変換

ScreenShot 2021 10 09 21 59 31 2
マイケル
マイケル
次はクリップ座標の変換を行ないます!
3D描画では、見える向きや範囲を指定するビュー行列と、
3Dから2Dの画面に投影するための射影行列
の2つを掛け合わせることで表現することができます!
ビュー射影行列(2D)
ScreenShot 2021 10 09 21 58 11 1
マイケル
マイケル
まずは簡単な例として2Dの場合をあげてみます!
これは3Dから2Dへの変換は不要のため、1つの行列で表すことができます。
マイケル
マイケル
画面の幅でそれぞれ調整してあげるだけの式になります。
ScreenShot 2021 10 09 0 11 51 1
    // ビュー射影行列(2D用)
    static Matrix4 CreateSimpleViewProjection(float width, float height)
    {
        float temp[4][4] =
        {
            { 2.0f/width, 0.0f, 0.0f, 0.0f },
            { 0.0f, 2.0f/height, 0.0f, 0.0f },
            { 0.0f, 0.0f, 1.0f, 0.0f },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
エレキベア
エレキベア
これはシンプルでわかりやすいクマね
マイケル
マイケル
2D用の変換式として定義しておきましょう!
ビュー行列
マイケル
マイケル
3Dでは先ほど言った通り、ビュー行列と射影行列の2種類が必要になります。
まずビュー行列についてですが、3Dの場合は 描画範囲を決めるために、視点の位置と向きの情報が必要となります。
ScreenShot 2021 10 09 21 59 43 1
↑視点の位置と向き
マイケル
マイケル
これをサンプルプロジェクトではCameraクラスとして定義しています。
そしてビュー行列の式は下記のように表すことができます。
ScreenShot 2021 10 09 0 11 51 2
    // ビュー行列
    // eye:自身の位置
    // target:注視対象の位置
    // up:上方向ベクトル
    static Matrix4 CreateLookAt(const Vector3& eye, const Vector3& target, const Vector3& up)
    {
        Vector3 k = Vector3::Normalize(target - eye);
        Vector3 i = Vector3::Normalize(Vector3::Cross(up, k));
        Vector3 j = Vector3::Normalize(Vector3::Cross(k, i));
        Vector3 t;
        t.x = -Vector3::Dot(i, eye);
        t.y = -Vector3::Dot(j, eye);
        t.z = -Vector3::Dot(k, eye);

        float temp[4][4] =
        {
            { i.x,  i.y,  i.z,  t.x },
            { j.x,  j.y,  j.z,  t.y },
            { k.x,  k.y,  k.z,  t.z },
            { 0.0f, 0.0f, 0.0f, 1.0f },
        };
        return Matrix4(temp);
    }
エレキベア
エレキベア
ターゲットへの向きとカメラの回転情報から計算するクマね
射影行列
マイケル
マイケル
そして次に3Dから2Dに変換するための射影行列について!
これは主な射影方法として、
・正射影
・透視射影
の2種類があります!

・正射影の場合

03 ortho
マイケル
マイケル
正射影は奥行きが無く、カメラへの距離に関わらず同じ大きさになる射影方法 です。

near、farを定義することでカメラの見える範囲を指定しています!

ScreenShot 2021 10 09 21 59 43 2
↑正射影の見える範囲
マイケル
マイケル
行列式は下記のように定義できます。
ScreenShot 2021 10 09 0 11 51 3
    // 正射影行列
    // near、far:近接、遠方の見える範囲
    static Matrix4 CreateOrtho(float width, float height,
                               float near, float far)
    {
        float temp[4][4] =
        {
            { 2.0f/width, 0.0f,        0.0f, 0.0f },
            { 0.0f,       2.0f/height, 0.0f, 0.0f },
            { 0.0f,       0.0f,        1.0f/(far-near), near/(near-far) },
            { 0.0f,       0.0f,        0.0f, 1.0f },
        };
        return Matrix4(temp);
    }

・透視射影の場合

02 perspective
マイケル
マイケル
そして透視射影は奥行きがある射影方法です。
3Dゲームではこちらの方が馴染みがあるかと思います。
マイケル
マイケル
near、farに加えて、垂直画角(fov)を指定することで
カメラ中心の角度を指定します。
ScreenShot 2021 10 09 21 59 43 3
↑透視射影の見える範囲
マイケル
マイケル
行列式は少し複雑になりますが、下記で定義できます。
ScreenShot 2021 10 10 11 27 46
    // 透視射影行列
    // fov:縦方向に画面に入る範囲(垂直画角)
    // near、far:近接、遠方の見える範囲
    static Matrix4 CreatePerspectiveFOV(float fov, float width, float height,
                                        float near, float far)
    {
        // yScale = cot(fov/2.0f)
        // xScale = yScale・(height/width)
        float yScale = 1.0f / tanf(fov/2.0f);
        float xScale = yScale * height / width;

        float temp[4][4] =
        {
            { xScale, 0.0f,   0.0f, 0.0f },
            { 0.0f,   yScale, 0.0f, 0.0f },
            { 0.0f,   0.0f,   far/(far-near), -near*far/(far-near) },
            { 0.0f,   0.0f,   1.0f, 0.0f },
        };
        return Matrix4(temp);
    }
マイケル
マイケル
これで一通り必要な行列式は揃いました!
あとはこれらを使って計算していきましょう!
エレキベア
エレキベア
やっと準備完了クマ〜〜〜〜

計算処理

マイケル
マイケル
それでは計算処理をコーディングしていきましょう!
ワールド変換座標
マイケル
マイケル
ワールド座標変換は処理を行う順番も重要になり、
基本的には 拡大縮小→回転→平行移動 の順番で行います。
最初の位置を V0 とした場合、下記のような計算になります。
ScreenShot 2021 10 09 0 12 02 1
↑ワールド座標の計算
マイケル
マイケル
これを見てお気づきの通り、
ワールド変換行列は Mt*Mr*Msとなり、処理とは逆の順番で掛け合わせる必要がある ことが分かります。
エレキベア
エレキベア
なんだか混乱してしまうクマ〜〜〜
マイケル
マイケル
この計算がどうしても混乱してしまうという方は、
逆マトリクスを使用する という手もあります。
下記のように行列式の項目を入れ替えることで表現でき、
処理の順番で掛け合わせることができるようになります!
ScreenShot 2021 10 09 0 12 02 2
ScreenShot 2021 10 09 0 12 02 3
↑逆マトリクスの計算は逆になる
マイケル
マイケル
参考書によっては逆マトリクスの状態で
公式が載っている場合もあるので注意しましょう!
今回は 逆に掛け合わせるということもしっかり意識するために、一般的な行列式で計算 します。
エレキベア
エレキベア
計算方法を体に染み込ませるクマね
マイケル
マイケル
計算は各オブジェクトごとに行う必要があるため、
サンプルプロジェクトではActorクラス内で定義 しています!
    Vector3 mPosition;    // 位置
    Vector3 mScale;       // 大きさ
    Quaternion mRotation; // 回転
    Matrix4 mWorldTransform;         // ワールド変換座標
    bool mRecalculateWorldTransform; // 再計算フラグ
// ワールド変換座標計算処理
void Actor::CalculateWouldTransform()
{
    // ワールド変換座標を再計算する
    if (mRecalculateWorldTransform)
    {
        // 拡大縮小 -> 回転 -> 平行移動
        // を逆の順番で乗算する。
        mRecalculateWorldTransform = false;
        mWorldTransform = Matrix4::CreateTranslation(mPosition.x, mPosition.y, mPosition.z);
        mWorldTransform *= Matrix4::CreateQuaternion(mRotation);
        mWorldTransform *= Matrix4::CreateScale(mScale.x, mScale.y, mScale.z);
    }
}
クリップ変換座標
マイケル
マイケル
そして クリップ座標についてはワールドに1つあればいいため、
描画クラス内で計算して持っておきます。
    // ビュー射影座標を設定
    mViewMatrix = Matrix4::CreateLookAt(Math::VEC3_ZERO, Math::VEC3_UNIT_Z, Math::VEC3_UNIT_Y); // カメラ無しの初期値
    mProjectionMatrix = Matrix4::CreatePerspectiveFOV(Math::ToRadians(50.0f),
                                                      mGame->ScreenWidth, mGame->ScreenHeight,
                                                      25.0f, 10000.0f);
マイケル
マイケル
ただし ビュー行列についてはカメラの位置や向きにとって変化するため、
Cameraクラス内で再設定するよう記述しましょう!
void Camera::UpdateActor(float deltaTime)
{
    Actor::UpdateActor(deltaTime);

    // カメラ位置よりビュー変換座標を設定する
    Vector3 position = GetPosition();
    Vector3 target = GetPosition() + 100.0f*GetForward(); // 100.0f前方がターゲット
    if (mTargetActor) target = mTargetActor->GetPosition() - GetPosition(); // ターゲットが設定されている場合
    Vector3 up = Math::VEC3_UNIT_Y;
    Matrix4 viewMatrix = Matrix4::CreateLookAt(position, target, up);
    GetGame()->GetRenderer()->SetViewMatrix(viewMatrix);
}
エレキベア
エレキベア
ワールドごとに持つことで計算量を減らすクマね
シェーダでの計算
マイケル
マイケル
最後にワールド変換座標、クリップ変換座標をシェーダに設定します!
クリップ変換座標については、ビュー変換→射影変換の順番になりますが、
ワールド変換座標と同様、逆に掛け合わせることには注意しましょう!
ScreenShot 2021 10 09 0 12 02 6
↑クリップ座標変換
void MeshComponent::Draw()
{
    if (!mMesh) return;
    if (!mShader) return;

    // シェーダをアクティブにする
    mShader->SetActive();
    // ビュー射影行列、ライティングパラメータを設定
    auto renderer = mActor->GetGame()->GetRenderer();
    mShader->SetViewProjectionUniform(renderer->GetProjectionMatrix() * renderer->GetViewMatrix());
    mShader->SetLightingUniform(renderer);
    // ワールド座標を設定
    Matrix4 world = mActor->GetWorldTransform();
    mShader->SetWorldTransformUniform(world);

    // テクスチャをアクティブにする
    auto texture = mMesh->GetTexture();
    if (texture) texture->SetActive();

    // 頂点座標をアクティブにする
    auto vertexArray = mMesh->GetVertexArray();
    vertexArray->SetActive();

    // シェーダを描画する
    glDrawElements(
        GL_TRIANGLES,   // 描画するポリゴンの種類
        vertexArray->GetNumIndices(), // インデックスバッファの数
        GL_UNSIGNED_INT, // インデックスの型
        nullptr   // 設定済のためnullptr
        );
}
マイケル
マイケル
そして受け取った座標情報を元にシェーダ内で計算を行います!
こちらもローカル座標→ワールド座標→クリップ座標という順番の逆で掛け合わせます!
#version 330

uniform mat4 uWorldTransform; // ワールド変換座標
uniform mat4 uViewProjection; // ビュー射影行列

in vec3 inPosition;

void main() {
    // w成分を加える
    vec4 pos = vec4(inPosition, 1.0);
    // ローカル座標 * ワールド変換座標 * ビュー射影行列
    // を逆に計算して、クリップ空間座標に変換
    gl_Position = uViewProjection * uWorldTransform * pos;
}
マイケル
マイケル
これで座標変換の計算は以上になります!!
エレキベア
エレキベア
長い道のりだったクマ〜〜〜〜〜〜
マイケル
マイケル
なお2D描画の際には、
2D用のビュー射影行列を設定しなおすようにしましょう!
void Renderer::Draw()
{

・・・略・・・

    // スプライト描画
    m2DSpriteShader->SetActive();
    m2DSpriteShader->SetViewProjectionUniform(m2DViewProjection);
    m2DSpriteVertexArray->SetActive();
    for (auto sprite : mSpriteComps)
    {
        sprite->Draw(m2DSpriteShader);
    }

    // バックバッファとスワップ(ダブルバッファ)
    SDL_GL_SwapWindow(mWindow);
}

↑2D描画を行う場合

スポンサーリンク

おわりに

マイケル
マイケル
というわけで今回は3Dグラフィックスの基本と座標変換について解説しました!
どうだったかな??
エレキベア
エレキベア
計算式が多くて頭が痛いクマ〜〜〜
でもなんとなく基本は分かってきたクマ
マイケル
マイケル
普段Unityとか触ってると想像以上に大変に感じたと思う・・・
ゲームエンジン内部で普段見えない部分!
きっと基礎は役に立つからゆっくり試していこう!
マイケル
マイケル
それでは今日はこの辺で!
アデュー!!!
エレキベア
エレキベア
クマ〜〜〜〜〜〜〜

【C++】第三回 C++を使ったゲーム開発 〜3Dゲーム開発基礎 OpenGLと座標変換編〜 〜完〜

 ※続きはこちら!

コメント