7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++で作るゲームエンジン自作シリーズ

Part1 設計編 Part2 ウィンドウ Part3 OpenGL Part4 シェーダー Part5 テクスチャ Part6 3Dモデル Part7 当たり判定
- - - - - 👈 Now -

はじめに

三角形も四角形も描けるようになった。

でも3Dゲームを作るなら、モデルデータが必要

BlenderとかMayaで作ったモデルを読み込んで表示してみる。

OBJファイルとは

最もシンプルな3Dモデルフォーマット。テキスト形式で人間が読める

# これはコメント
# 頂点位置
v 0.0 1.0 0.0
v 1.0 0.0 0.0
v -1.0 0.0 0.0

# 法線
vn 0.0 0.0 1.0

# テクスチャ座標
vt 0.5 1.0
vt 1.0 0.0
vt 0.0 0.0

# 面(頂点インデックス/テクスチャ/法線)
f 1/1/1 2/2/1 3/3/1

プレフィックス

プレフィックス 意味
v 頂点位置 (x y z)
vn 法線ベクトル (x y z)
vt テクスチャ座標 (u v)
f 面(頂点インデックス)
# コメント
o オブジェクト名
mtllib マテリアルファイル
usemtl マテリアル使用

面のフォーマット

OBJファイルの面は色々なフォーマットがある:

f 1 2 3              # 頂点インデックスのみ
f 1/1 2/2 3/3        # 頂点/テクスチャ
f 1/1/1 2/2/1 3/3/1  # 頂点/テクスチャ/法線
f 1//1 2//1 3//1     # 頂点//法線(テクスチャなし)

OBJパーサーを作る

#pragma once

#include <string>
#include <vector>
#include <fstream>
#include <sstream>
#include <glm/glm.hpp>

struct Vertex {
    glm::vec3 position;
    glm::vec3 normal;
    glm::vec2 texCoord;
};

struct Mesh {
    std::vector<Vertex> vertices;
    std::vector<unsigned int> indices;
};

パース本体

class ObjLoader {
public:
    static Mesh load(const std::string& filepath) {
        Mesh mesh;
        
        std::vector<glm::vec3> positions;
        std::vector<glm::vec3> normals;
        std::vector<glm::vec2> texCoords;
        
        std::ifstream file(filepath);
        if (!file.is_open()) {
            std::cerr << "Failed to open: " << filepath << std::endl;
            return mesh;
        }
        
        std::string line;
        while (std::getline(file, line)) {
            std::istringstream iss(line);
            std::string prefix;
            iss >> prefix;
            
            if (prefix == "v") {
                glm::vec3 pos;
                iss >> pos.x >> pos.y >> pos.z;
                positions.push_back(pos);
            }
            else if (prefix == "vn") {
                glm::vec3 normal;
                iss >> normal.x >> normal.y >> normal.z;
                normals.push_back(normal);
            }
            else if (prefix == "vt") {
                glm::vec2 tex;
                iss >> tex.x >> tex.y;
                texCoords.push_back(tex);
            }
            else if (prefix == "f") {
                parseFace(iss, positions, normals, texCoords, mesh);
            }
        }
        
        return mesh;
    }
};

面のパース

一番面倒なのが、面(f)のパース。フォーマットがバラバラだから。

static void parseFace(std::istringstream& iss,
                      const std::vector<glm::vec3>& positions,
                      const std::vector<glm::vec3>& normals,
                      const std::vector<glm::vec2>& texCoords,
                      Mesh& mesh) {
    std::string vertex;
    std::vector<unsigned int> faceIndices;
    
    while (iss >> vertex) {
        unsigned int posIdx = 0, texIdx = 0, normIdx = 0;
        
        // スラッシュの位置を探す
        size_t slash1 = vertex.find('/');
        
        if (slash1 == std::string::npos) {
            // "1" - 頂点のみ
            posIdx = std::stoi(vertex) - 1;  // OBJは1-indexed!
        } else {
            posIdx = std::stoi(vertex.substr(0, slash1)) - 1;
            size_t slash2 = vertex.find('/', slash1 + 1);
            
            if (slash2 == std::string::npos) {
                // "1/2" - 頂点/テクスチャ
                texIdx = std::stoi(vertex.substr(slash1 + 1)) - 1;
            } else {
                // "1/2/3" または "1//3"
                std::string texPart = vertex.substr(slash1 + 1, slash2 - slash1 - 1);
                if (!texPart.empty()) {
                    texIdx = std::stoi(texPart) - 1;
                }
                normIdx = std::stoi(vertex.substr(slash2 + 1)) - 1;
            }
        }
        
        // 頂点を構築
        Vertex v;
        v.position = positions[posIdx];
        v.normal = normIdx < normals.size() ? normals[normIdx] : glm::vec3(0, 0, 1);
        v.texCoord = texIdx < texCoords.size() ? texCoords[texIdx] : glm::vec2(0, 0);
        
        mesh.vertices.push_back(v);
        faceIndices.push_back(mesh.vertices.size() - 1);
    }
    
    // 三角形分割(ファンで分割)
    // 4頂点以上の場合: 0-1-2, 0-2-3, 0-3-4, ...
    for (size_t i = 1; i + 1 < faceIndices.size(); i++) {
        mesh.indices.push_back(faceIndices[0]);
        mesh.indices.push_back(faceIndices[i]);
        mesh.indices.push_back(faceIndices[i + 1]);
    }
}

注意点

  1. OBJは1-indexed - インデックスが1から始まる
  2. 四角形以上の面 - 三角形に分割する必要がある
  3. 負のインデックス - -1 は最後の頂点を意味する(今回は未対応)

3Dレンダリング

ビュー行列と投影行列

2Dと違って、3Dではカメラの概念が必要。

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

// カメラ位置
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);

// ビュー行列
glm::mat4 view = glm::lookAt(cameraPos, cameraTarget, cameraUp);

// 投影行列(透視投影)
float fov = 45.0f;  // 視野角
float aspectRatio = 800.0f / 600.0f;
float nearPlane = 0.1f;
float farPlane = 100.0f;
glm::mat4 projection = glm::perspective(glm::radians(fov), aspectRatio, nearPlane, farPlane);

モデル行列

モデルの位置・回転・スケールを表す:

glm::mat4 model = glm::mat4(1.0f);  // 単位行列

// 回転(時間で回す)
model = glm::rotate(model, (float)glfwGetTime(), glm::vec3(0.5f, 1.0f, 0.0f));

シェーダー

頂点シェーダー

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;

out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoord;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal;  // 法線の変換
    TexCoord = aTexCoord;
    
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

フラグメントシェーダー(Phongライティング)

#version 330 core
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoord;

out vec4 FragColor;

uniform vec3 lightPos;
uniform vec3 viewPos;
uniform vec3 lightColor;
uniform vec3 objectColor;

void main() {
    // Ambient(環境光)
    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;
    
    // Diffuse(拡散反射)
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
    
    // Specular(鏡面反射)
    float specularStrength = 0.5;
    vec3 viewDir = normalize(viewPos - FragPos);
    vec3 reflectDir = reflect(-lightDir, norm);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
    vec3 specular = specularStrength * spec * lightColor;
    
    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);
}

VAOの設定

頂点属性が増えた:

// Vertex構造体: position(3) + normal(3) + texCoord(2) = 8 floats
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, position));
glEnableVertexAttribArray(0);

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, normal));
glEnableVertexAttribArray(1);

glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, texCoord));
glEnableVertexAttribArray(2);

深度バッファ

3Dでは深度テストが必要。奥のものが手前より先に描画されると変になる。

// 深度テストを有効化
glEnable(GL_DEPTH_TEST);

// 描画ループで深度バッファもクリア
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

完全なメインコード

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include "obj_loader.h"

int main() {
    // GLFW/GLAD初期化(省略)
    
    // OBJファイルを読み込む
    Mesh mesh = ObjLoader::load("models/cube.obj");
    
    // VAO/VBO/EBO作成
    unsigned int VAO, VBO, EBO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    glGenBuffers(1, &EBO);
    
    glBindVertexArray(VAO);
    
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, mesh.vertices.size() * sizeof(Vertex), 
                 mesh.vertices.data(), GL_STATIC_DRAW);
    
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, mesh.indices.size() * sizeof(unsigned int),
                 mesh.indices.data(), GL_STATIC_DRAW);
    
    // 頂点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)(3*sizeof(float)));
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)(6*sizeof(float)));
    glEnableVertexAttribArray(2);
    
    // 深度テスト有効化
    glEnable(GL_DEPTH_TEST);
    
    // メインループ
    while (!glfwWindowShouldClose(window)) {
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        
        glUseProgram(shader);
        
        // 行列の設定
        glm::mat4 model = glm::rotate(glm::mat4(1.0f), (float)glfwGetTime(), 
                                      glm::vec3(0.5f, 1.0f, 0.0f));
        glm::mat4 view = glm::lookAt(glm::vec3(0, 0, 5), glm::vec3(0), glm::vec3(0, 1, 0));
        glm::mat4 projection = glm::perspective(glm::radians(45.0f), 800.0f/600.0f, 0.1f, 100.0f);
        
        glUniformMatrix4fv(glGetUniformLocation(shader, "model"), 1, GL_FALSE, &model[0][0]);
        glUniformMatrix4fv(glGetUniformLocation(shader, "view"), 1, GL_FALSE, &view[0][0]);
        glUniformMatrix4fv(glGetUniformLocation(shader, "projection"), 1, GL_FALSE, &projection[0][0]);
        
        // ライティング
        glUniform3f(glGetUniformLocation(shader, "lightPos"), 2.0f, 2.0f, 2.0f);
        glUniform3f(glGetUniformLocation(shader, "viewPos"), 0.0f, 0.0f, 5.0f);
        glUniform3f(glGetUniformLocation(shader, "lightColor"), 1.0f, 1.0f, 1.0f);
        glUniform3f(glGetUniformLocation(shader, "objectColor"), 1.0f, 0.5f, 0.2f);
        
        // 描画
        glBindVertexArray(VAO);
        glDrawElements(GL_TRIANGLES, mesh.indices.size(), GL_UNSIGNED_INT, 0);
        
        glfwSwapBuffers(window);
        glfwPollEvents();
    }
    
    return 0;
}

実行結果

回転するキューブが表示される!

Phongライティングで、ちゃんと光の当たり方が変わる。

よくあるバグ

モデルが表示されない

  1. OBJファイルのパスが正しいか
  2. 深度テスト有効にしてるか
  3. カメラがモデルに向いてるか

面が欠けて見える

カリング(背面除去) が原因かも:

glEnable(GL_CULL_FACE);  // 有効だと裏面が描画されない
glDisable(GL_CULL_FACE); // 無効にして確認

法線がおかしい

OBJファイルに法線がない場合、自分で計算する必要がある:

glm::vec3 calculateNormal(glm::vec3 v0, glm::vec3 v1, glm::vec3 v2) {
    glm::vec3 edge1 = v1 - v0;
    glm::vec3 edge2 = v2 - v0;
    return glm::normalize(glm::cross(edge1, edge2));
}

MTLファイル(マテリアル)

OBJファイルにはMTLファイル(マテリアル定義)が付属することが多い。

# material.mtl
newmtl Material1
Ka 0.1 0.1 0.1    # ambient色
Kd 0.8 0.0 0.0    # diffuse色
Ks 1.0 1.0 1.0    # specular色
Ns 100.0          # shininess
map_Kd texture.png  # テクスチャ

今回は省略したけど、本格的にやるなら対応が必要。

まとめ

今回やったこと:

  1. OBJファイルフォーマットを理解
  2. OBJパーサーを自作
  3. 3Dレンダリング(MVP行列)
  4. Phongライティング実装
  5. 深度テスト

これでBlenderで作ったモデルが表示できるようになった!

次回は、いよいよ当たり判定を実装する。

次回予告

Part7: 当たり判定、AABB実装

ゲームエンジンに必須の衝突判定を実装する!

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?