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]);
}
}
注意点
- OBJは1-indexed - インデックスが1から始まる
- 四角形以上の面 - 三角形に分割する必要がある
-
負のインデックス -
-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ライティングで、ちゃんと光の当たり方が変わる。
よくあるバグ
モデルが表示されない
- OBJファイルのパスが正しいか
- 深度テスト有効にしてるか
- カメラがモデルに向いてるか
面が欠けて見える
カリング(背面除去) が原因かも:
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 # テクスチャ
今回は省略したけど、本格的にやるなら対応が必要。
まとめ
今回やったこと:
- OBJファイルフォーマットを理解
- OBJパーサーを自作
- 3Dレンダリング(MVP行列)
- Phongライティング実装
- 深度テスト
これでBlenderで作ったモデルが表示できるようになった!
次回は、いよいよ当たり判定を実装する。
次回予告
Part7: 当たり判定、AABB実装
ゲームエンジンに必須の衝突判定を実装する!