glTFを読み込むために、まずは構造を把握しておきましょう。今回はVulkanではなくglTFの話になります。
tinygltfの導入
glTF形式を読み込むのに便利なライブラリとして、ここではtinygltfを用います。
vcpkgを利用している場合は以下のコマンドでインストールできます。
vcpkg install tinygltf
CMakeLists.txtに2行追加します。
cmake_minimum_required(VERSION 3.22)
project(vulkan-test)
set(CMAKE_CXX_STANDARD 17)
add_executable(app main.cpp)
find_package(Vulkan REQUIRED)
target_include_directories(app PRIVATE ${Vulkan_INCLUDE_DIRS})
target_link_libraries(app PRIVATE ${Vulkan_LIBRARIES})
find_package(glfw3 CONFIG REQUIRED)
target_link_libraries(app PRIVATE glfw)
find_path(TINYGLTF_INCLUDE_DIRS "tiny_gltf.h") # 追加
target_include_directories(app PRIVATE ${TINYGLTF_INCLUDE_DIRS}) # 追加
これでビルドすればtinygltfが使えるはずです。ソースコードに以下の記述を追加しましょう。
#define TINYGLTF_IMPLEMENTATION
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "tiny_gltf.h"
問題なくビルドが通ればOKです。
glTFの構造
glTFは汎用性の高いファイル形式のため、その分それなりに複雑なデータ構造を持ちます。なので面食らわないように出来るだけ丁寧に解説を入れていきます。
scene・node・meshの階層構造
glTFは単に3Dモデル1つを表すだけでなく、モデルが色々配置された1つの場面を記録することもできるようになっています。これにはscene(シーン)という名前が付いており、glTFで最も上位の構造となっています。
sceneの中にはnode(ノード)があり、これが3D物体を表す単位となっています。nodeは階層構造を持ち、1つのnodeにいくつもの子nodeが付けるようになっています。これによって、例えば部品や関節部を持つような複雑なモデルデータが表現できます。
nodeはmesh(メッシュ)を持ちます。そしてmeshが表示されるポリゴンを表しています。
nodeも物体を表す単位ですが、実際のポリゴンを表すのはmeshであることに注意しましょう。nodeはあくまで位置姿勢の基準となる仮想的な存在であり、node自体はポリゴンを表しません。
meshの中身
さらにmeshの中には1つあるいは複数のprimitive(プリミティブ)があります。これの1つ1つがポリゴン集合を表しています。meshは表示されるポリゴンを表しますが必ずしも1つのmesh=単一のポリゴン塊という訳ではなく、複数のポリゴン塊に分かれたものとして記録できるようになっています。
これによって例えば、色やテクスチャの違う複数の部品で出来たmeshなどが実現できるようになっています。
primitiveの中身
1つのprimitiveは一塊のポリゴン集合を表しています。またprimitiveが保持しているのは単に各頂点の「位置」だけではなく、「色」「法線」「テクスチャ座標」など描画に必要な種々のデータを保持できるようになっています。
こうした各頂点が持っている各種データのそれぞれをattribute(アトリビュート)と呼びます。glTFの仕様では標準的なattributeの名前がいくつか規定されています。
POSITION
(頂点位置)TEXCOORD_0
(テクスチャ座標)NORMAL
(法線ベクトル)- など
また、頂点ごとではなくprimitiveで1つだけ持つデータもあります。例えばそのプリミティブのテクスチャなどです。
階層構造を見てみる
試しにglTFの階層構造を表示してみましょう。
#define TINYGLTF_IMPLEMENTATION
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <iostream>
#include <tiny_gltf.h>
void printPrimitive(const tinygltf::Primitive &primitive, const tinygltf::Model &model, int depth) {
std::cout << std::string(depth * 2, ' ') << "(primitive)" << std::endl;
for (const auto &[attributeName, accessorIndex] : primitive.attributes) {
std::cout << std::string(depth * 2 + 2, ' ') << attributeName << std::endl;
}
}
void printMesh(const tinygltf::Mesh &mesh, const tinygltf::Model &model, int depth) {
std::cout << std::string(depth * 2, ' ') << "(mesh)" << mesh.name << std::endl;
for (int i = 0; i < mesh.primitives.size(); i++) {
printPrimitive(mesh.primitives[i], model, depth + 1);
}
}
void printNode(const tinygltf::Node &node, const tinygltf::Model &model, int depth) {
std::cout << std::string(depth * 2, ' ') << "(node)" << node.name << std::endl;
if (node.mesh != -1)
printMesh(model.meshes[node.mesh], model, depth + 1);
for (int i = 0; i < node.children.size(); i++) {
printNode(model.nodes[node.children[i]], model, depth + 1);
}
}
void printScene(const tinygltf::Scene &scene, const tinygltf::Model &model) {
std::cout << "(scene)" << scene.name << std::endl;
for (int i = 0; i < scene.nodes.size(); i++) {
printNode(model.nodes[scene.nodes[i]], model, 1);
}
}
void printModel(const tinygltf::Model &model) {
for (int i = 0; i < model.scenes.size(); i++) {
printScene(model.scenes[i], model);
}
}
int main() {
tinygltf::TinyGLTF loader;
tinygltf::Model model;
std::string err, warn;
loader.LoadBinaryFromFile(&model, &err, &warn, "model.glb");
printModel(model);
}
例としてこちらのモデル(リンク)を読み込ませてみます。
(scene)
(node)
(node)
(node)
(mesh)body
(primitive)
NORMAL
POSITION
(node)
(mesh)body
(primitive)
NORMAL
POSITION
(node)
(mesh)Lifter_123-923_0_Parts_1
(primitive)
NORMAL
POSITION
(primitive)
NORMAL
POSITION
...(以下省略)
いくつかのnode, mesh, primitveが出てきたのではないでしょうか。これがモデルの階層構造を表しています。
モデルによっては一部にmeshや子nodeを持っていないnodeがあるかもしれませんが、これについては後の節で説明します。
データを表すbuffer・bufferView・accessor
primitiveがポリゴンを表していることは分かりましたが、どのように頂点バッファのデータを持っているのでしょうか。
primitiveの各attributeはaccessor(アクセサ)に紐づいており、これによってデータが表されています。ただしaccessorはデータの取り出し方を表すもので、データそのものではありません。データそのものはbufferおよびbufferViewによって表されています。
buffer(バッファ)はデータ本体です。バイナリデータそのものを表しています。
bufferView(バッファビュー)はbufferのある部分を表すものです。例えばあるbufferの何バイト目から何バイトといったものを表しています。
accessorには「あるbufferViewの」「ある場所に」「ある型のデータが」「何個並んでいる」という情報が記録されています。これに基づいてbufferのバイト列にアクセスすることでデータを取り出すことができます。
コード
文章で説明してもよく分からないと思うので、コードで見てみましょう。
const auto &accessor = model.accessors[accessorIndex];
// accessor.bufferView: bufferViewの番号
// accessor.type: データの型(int, floatなど)
// accessor.componentType: データの型(2次元ベクトル, 3次元ベクトルなど)
// accessor.byteOffset: データの位置
// accessor.count: データの個数
const auto &bufferView = model.bufferViews[accessor.bufferView];
// bufferView.buffer: bufferの番号
// bufferView.byteOffset: 位置
// bufferView.byteLength: 長さ
const auto &buffer = model.buffers[bufferView.buffer];
// buffer.data: データ本体
const unsigned char *bufferViewBegin = buffer.data.data() + bufferView.byteOffset; // bufferViewの開始位置
const unsigned char *bufferViewEnd = bufferViewBegin + bufferView.byteLength; // bufferViewの終了位置
const unsigned char *accessorDataBegin = bufferViewBegin + accessor.byteOffset; // accessorのデータ開始位置
int stride = accessor.ByteStride(bufferView); // 1データあたりのバイト幅、tinygltfの機能で算出
各データに順番にアクセスするコードを書くとしたら、以下のようなfor文で出来ます。
for (int i = 0; i < accessor.count; i++) {
const unsigned char *p = accessorDataBegin + i * stride; // i番目のデータ
}
1データあたりのバイト数はデータの型(type
, componentType
)によって決まるため、そこの算出はtinygltfの機能に任せています。
それぞれのデータ型は例えば「float型3つが並んだ3次元ベクトル」といったものです。中身を表示しようと思ったら以下のようなコードになるでしょう。
struct Vec2 {
float x, y;
};
struct Vec3 {
float x, y, z;
};
struct Vec4 {
float x, y, z, w;
};
// 2次元ベクトル
if (accessor.type == TINYGLTF_TYPE_VEC2 && accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT) {
const auto data = reinterpret_cast<const Vec2*>(p);
std::cout << data->x << ',' << data->y << std::endl;
}
// 3次元ベクトル
else if (accessor.type == TINYGLTF_TYPE_VEC3 && accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT) {
const auto data = reinterpret_cast<const Vec3*>(p);
std::cout << data->x << ',' << data->y << ',' << data->z << std::endl;
}
// 4次元ベクトル
else if (accessor.type == TINYGLTF_TYPE_VEC4 && accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT) {
const auto data = reinterpret_cast<const Vec4*>(p);
std::cout << data->x << ',' << data->y << ',' << data->z << ',' << data->w << std::endl;
}
ここでは型情報によって場合分けをしています。しかし実際に読み込む際には、例えばPOSITION
ならVec3
、TEXCOORD
ならVec2
などと決め打ってしまって良いでしょう(そうでない型のデータが入っていたところでエラーにするほかありません)。そもそもattributeの種類によって可能な型は仕様で決まっています。(参考)
最も重要な部分は以上ですが、その他にも把握しておくべき仕様を紹介します。
インデックスバッファ
glTFにおいては、インデックスバッファのデータもaccessorで表されています。primitive.indices
がそのaccessorの添え字となっています。
なお、nodeにインデックスバッファが無い場合もあり、その場合はインデックスバッファなしで描画することになります。drawIndexed
ではなくdraw
で描画するということです。
モデル変換行列
各nodeにはモデル変換の指定が付いていることがあります。モデル変換については5章14節をご参照ください。
node.translation; // 平行移動、3次元ベクトル(要素3の配列)
node.rotation; // 回転、クォータニオン(要素4の配列)
node.scale; // 拡大縮小、3次元ベクトル(要素3の配列)
これらは頭文字をとってTRSプロパティと名が付いています。
また、translate/rotation/scaleの指定の代わりに4x4行列そのままが付いていることもあります。
node.matrix; // 4x4モデル変換行列(16要素の配列)
これらの指定はオプション的なものなので、存在しないこともあります。その場合は特に変換は行いません。また、translate/rotation/scaleのうち一部だけが存在することもあります。
気を付けるべき点は、この変換は子nodeに伝播させることになっている点です。この仕様のおかげで、例えば人のモデルを表すnodeを移動させたら持っているものとかパーツとかも一緒に付いてくることになります。
glTFの基本的な構造はこんなところです。次節ではいよいよモデルのポリゴンを表示します。なお、テクスチャの読み込みは次々節で行います。