独自ファイルからオブジェクトを作るためのチャンククラス
ゲーム制作をする上で、独自のファイルからテクスチャやモデルなどいわゆる「オブジェクト」を作る局面がいつか必ず確実に訪れます。その時、ファイルにあるデータからオブジェクトを作るまでの過程はなるべく統一しそして隠蔽したいものです。
例えばDirectX9のテクスチャ(IDirect3DTexture9)をファイルから作るとします。この時、サンプル作成などでさくっとやりたい時はD3DXCreateTextureFromFile関数などを用いるのが普通です。しかし、この関数は.bmpとか.pngなど厳密なフォーマットでないと読み込みに失敗します。ですから、たった1バイトでも「自分用のヘッダー情報」などをファイルの先頭に追加しても即アウトとなります。もちろん複数の絵のファイルをパックしてもダメです。
少し状況を一般化すると、ファイルのある部分から「絵の情報ですよ〜」という範囲があって、それを読み込んでIDirect3DTexture9を作り上げる機構が必要になります。そういう「あるオブジェクトを作るためのリソースデータ範囲」の事を「チャンク(Chunk)」と呼んだりします。
この章ではそういうチャンクを扱うためのチャンククラスをどう考えてどう作るかを試行錯誤してみます。
@ チャンクに必要な物
イメージしやすくするため、独自のファイルからIDirect3DTexture9を作る過程を例としてチャンクを考えてみたいと思います。
そもそも、「ファイル内のテクスチャが欲しい」というのは何らかの読み込みをしている最中に発生します。この時ほぼ間違いなく「テクスチャリソースが欲しい」という事と「〇〇というテクスチャが必要」という事が同時発生します。つまり「リソースタイプ」と「リソース名」がセットになっている訳です。
タイプと名前から、まずオープンスべきファイルを決定します。これは名前だけから判断出来る場合もあるでしょうし、絵のリソースをパックしている場合はタイプからパックファイルを判断します。
判断後、ファイルを指定してオープンしたとします。さて、このファイルのどこに欲しいチャンクがあるのか?これはヘッダーに情報が埋め込まれているかもしれません。もしくは線形に探せるかもしれません。とりあえず何らかの方法で目的のチャンクの先頭にたどり着いたとします。
話はここからです。
目的のチャンク位置にたどり着いたとして、情報を読み取るために何が必要でしょうか?少なくともちょっとしたチェック機構とチャンクの範囲(サイズ)、そして識別マークが必要です。そのために次のようなヘッダー情報を付記します:
チャンクヘッダー情報 チャンクマーク チャンクサイズ チャンクタイプ チャンク名
チャンクマークは何でも良いです。「Chunk」という文字列でも良いですし、マジックナンバーでも構いません。チャンクサイズはチャンクの先頭からのサイズです。チャンクタイプは例えば「テクスチャである」とか「マテリアルである」などの識別マークです。これも識別できれば何でもOKです。チャンク名はそのチャンクの個別名です。テクスチャ名とかノード名など一意の名前である必要がきっとあります。
このチャンクヘッダーはどのチャンクでも共通です。
チャンクヘッダーの次にデータ部が来ます:
チャンクヘッダー チャンクデータ
これはもう、何でも構いません。バイナリでもテキストでもいいわけです。今はテクスチャを作りたいので、ここにテクスチャ情報が格納されています。
ファイル内の1つのチャンク情報としては、きっとこのくらいで十分です。ところが何です。チャンクは1つとは限らないんです。ここが、実はチャンクの肝だったりします。
A チャンクは連なる!
テクスチャの場合、1つのチャンクで大抵事足ります。しかし、1つのチャンクで表現し切れないリソースもあります。例えばモデル。モデルの中には沢山の違った種類の情報を格納する必要があります。一つはメッシュの情報。複数のメッシュがあれば、それは別のチャンクとして保持するべきです。マテリアルの情報も沢山必要ですし、貼り付けるテクスチャが複数であればその分のチャンクが存在するはずです。つまり、チャンクは連なるんです。
この時、「チャンク内チャンク(入れ子チャンク)」にするか「線形チャンク」にするかが迷いどころです。入れ子チャンクは、チャンクデータの中にチャンクが入っている状態です。一方線形チャンクは、1つのチャンクの次に別のチャンクが連なる状態です:
<チャンクな図>
さてどちらが良いか…。データをひとまとめにすると言う意味では入れ子チャンクが理想なのですが、入れ子チャンクはとにかく作りにくいんですよねぇ〜。モデルなどは大抵非常に複雑な樹状構造になるわけで、入れ子の入れ子の入れ子の…となると、サイズ計算など何か間違えそうです。
線形チャンクは1つ1つのチャンクが独立しているので作りやすさは抜群です。欠点としては構造を作りにくいというのがあります。何せ構造上は線形なのですから、それを樹状にするなどは難しいわけです。
結論としてはちょっと線形、概ね入れ子かなぁと思います。
例えばマテリアルチャンクを作ったとします。その時、diffuseカラーとそれに対応するテクスチャ情報を埋め込もうと思ったら、
struct MaterialChunk {
struct Elem {
DWORD color;
ImageChunk image;
};
Elem diffuse;
Elem specular;
...
};
と書くのが自然の流れでしょう。さらにメッシュを表現しようとしたら、
struct MeshChunk {
MaterialChunk material;
VertexBufferChunk vertex;
DWORD primitiveType;
...
};
のようにマテリアルのチャンク、頂点バッファのチャンク、プリミティブタイプ、etc...などのように組み合わせて使えた方が俄然わかりやすいです。ただ、例えばテクスチャなど使いまわされまくる物をマテリアルの中に入れ込むのは明らかにデータ過多です。そういうのは例えばIDとか名前などで代替しておいて、テクスチャ自体はファイルの最初などにまとめてしまうのが吉かと思います。そういう時のテクスチャ群は線形チャンクとして並ぶはずです。結局例えばモデルを表現しようとしたら、
テクスチャA テクスチャB テクスチャC モデルノード メッシュ1 マテリアル テクスチャID(A) メッシュ2 マテリアル テクスチャID(C) モデルノード メッシュ3 メッシュ4 メッシュ5 モデルノード メッシュ6
という結構複雑なチャンクを作らざるを得ないわけです。では、こういうチャンクファイルをさっくりと作るにはどうしたら良いか?そのためには細かく丁寧にチャンククラスを作っていく事にあるんです。
B チャンククラス
チャンクの大元となるチャンククラスはすべてのチャンクに共通するメソッドを提供します。すべてに共通なのは「チャンクヘッダー」です:
チャンクヘッダー情報 チャンクマーク チャンクサイズ チャンクタイプ チャンク名
こんな感じでした。まぁ、このままでも良いのですが、ちょっとサイズが気になる所です。マーク、サイズ、タイプをint型にすると32bitCPUなら12byte、それに名前が32文字とかにすると44byte…ん〜〜。少し減らしたいですね。
例えばビットマップのマークは「BM」で2byte。チャンクマークもきっとこんなので十分です。「CK」とかにしておきます。チャンクサイズは4byte(=4GB表現)必要かと言うときっといりません。とはいえ3byteだと実はちょっと不安です。3byte=16MBですが、大きいテクスチャはこのサイズを軽く超えます。さすがにここは4byte使うことにしましょう。チャンクタイプはいくらなんでも4byteいりません。1byteで絶対に十分です。最後のチャンク名ですが、これはHash化すると4byteでほぼ十分に一意になれます。マルペケのツールに文字列を4byteの整数ハッシュにするDix::Hash32クラスがありますので、それを利用します。これで2+4+1+4=11byte。…中途半端ですね。チャンクタイプを2byteにして合計12byteにしましょう:
チャンクヘッダー情報 型 サイズ チャンクマーク char[2] 2 チャンクサイズ unsigned 4 チャンクタイプ unsigned short 2 チャンク名 Dix::Hash32 2
次にチャンクに共通する物は「チャンクデータ」です。ただ、これはどのようなデータになるかは親クラスではわかりません。子チャンクにすべてを任せましょう。子チャンクには具体的にデータを格納するためのメソッドを追加します。
他に共通することとしては「シリアライズ」が挙げられます。チャンクに格納されている情報をメモリやファイルに書き出し、今度はそれを綺麗に読み込んでチャンクを復元出来る事が極めて重要です。チャンクを復元出来さえすれば、そこからモデルやテクスチャを作る道筋が立てられます。
もう一つチャンクのサイズ計算は案外複雑です。そのため、自分のチャンクサイズを訪ねるsizeメソッドも追加しておきましょう。
以上をまとめるとチャンククラスの宣言は次のようになりそうです:
チャンククラス(chunk.h) #include "dixhash32.h"
#include <fstream>
class Chunk {
public:
struct Header {
char mark[2]; // チャンクマーク
unsigned size; // サイズ
unsigned short type; // チャンクタイプ
Dix::Hash32 name; // 名前
Header() : size(), type() {
mark[0] = 'C', mark[1] = 'K';
}
};
Header header; // ヘッダー
Chunk();
virtual ~Chunk();
// シリアライズ
virtual unsigned sirialize(std::ofstream &ofs) = 0;
// デシリアライズ
virtual bool desirialize(std::ifstream &ifs) = 0;
// サイズ取得
virtual unsigned size() = 0;
};
このクラスを親として、例えばテクスチャチャンクを作るとこうなります:
テクスチャチャンククラス #include "chunkid.h" // チャンクタイプがあります
#include "chunk.h"
class TextureChunk {
public:
struct Info {
unsigned short w, h; // 幅高
unsigned short pitch; // ピッチ
char format; // フォーマット
};
private:
Dix::Hash32 name; // テクスチャ識別名
Info info; // テクスチャ情報
std::vector<char> data; // テクスチャデータ
public:
TextureChunk();
virtual ~TextureChunk();
// シリアライズ
virtual unsigned sirialize(std::ofstream &ofs) {
// ヘッダー生成
Header header;
header.type = (unsigned short)CHUNKID_TEXTURE;
header.name = name;
header.size = size();
// データ格納
ofs.write((char*)&header, sizeof(Header));
ofs.write((char*)&info, sizeof(Info));
ofs.write((char*)&data[0], data.size());
return header.size;
}
// デシリアライズ
virtual bool desirialize(std::ifstream &ifs) {
// ヘッダー生成
Header header;
ifs.read(&header, sizeof(Header));
// データ復元
ifs.read(&info, sizeof(Info));
unsigned size = info.w * info.h * info.pitch;
data.resize(size);
ifs.read(&data[0], size);
return true;
}
// サイズ取得
unsigned size() {
return sizeof(Header) + sizeof(Info) + data.size();
}
// テクスチャデータを格納
void setData(Dix::Hash32 &texName, Info &dataInfo, std::vector<char> &pixelData) {
name = texName
info = dataInfo;
data = pixelData;
}
// テクスチャデータを取得
void getData(Dix::Hash32 &texName, Info &dataInfo, std::vector<char> &pixelData) {
texName = name;
dataInfo = info;
pixelData = data;
}
};
シリアライズとデシリアライズメソッドを具体的に実装します。またチャンクに直接データを格納する術も必要なのでsetDataメソッドもいります。デシリアライズしたデータを取得して活用するのにはgetDataメソッドもいります。
たぶんここで考えているチャンククラスの殆どは上のメソッドで事足りるかと思います。チャンクの中にチャンクが入っている、入れ子チャンクの例も見てみましょう。1つのメッシュチャンクにマテリアルや頂点バッファなどの構成チャンクが入っている事を想定してみます:
メッシュチャンククラス #include "chunkid.h" // チャンクタイプがあります
#include "chunk.h"
class MeshChunk {
public:
struct Info {
MaterialChunk material; // マテリアル
VertexBufferChunk vertex; // 頂点バッファチャンク
};
private:
Dix::Hash32 name; // メッシュ識別名
Info info; // メッシュ構成情報
public:
MeshChunk();
virtual ~MeshChunk();
// シリアライズ
virtual unsigned sirialize(std::ofstream &ofs) {
// ヘッダー生成
header.type = (unsigned short)CHUNKID_MESH;
header.name = name;
header.size = size();
// データ格納
ofs.write((char*)&header, sizeof(Header));
info.material.sirialize(ofs);
info.vertex.sirialize(ofs);
return header.size;
}
// デシリアライズ
virtual bool desirialize(std::ifstream &ifs) {
// ヘッダー生成
ifs.read(&header, sizeof(Header));
// データ復元
info.material.desirialize(ifs);
info.vertex.desirialize(ifs);
return true;
}
// サイズ取得
unsigned size() {
return sizeof(Header) + info.material.size() + info.vertex.size();
}
// メッシュデータを格納
void setData(Dix::Hash32 &meshName, Info &dataInfo) {
name = meshName;
info = dataInfo;
}
// メッシュデータを取得
void getData(Dix::Hash32 &meshName, Info &dataInfo) {
meshName = name;
dataInfo = info;
}
};
シリアライズではヘッダーを書き込んだ後にInfo構造体にある各チャンク(マテリアル、頂点バッファ)のシリアライズメソッドを呼び出して続きを書き込んでもらっています。逆にデシリアライズの方では格納順に取り出しています。このように子チャンクがいる場合でもシリアライズとデシリアライズのメソッドを呼び出すだけで良いので楽です。
後はゲームを再現するのに必要な様々なリソース(サウンド、カーブ、エフェクト等)のチャンクを丹念に用意してあげれば、チャンクを媒介としたデータの書き込みと読み込みは再現できるかなと思います。
C ディープコピーかナローコピーか?
ところで、上の各チャンクは実はちょっと困った性質を持ち合わせています。例えばメッシュチャンクは内部にInfo構造体を『実体』として持っています。と言うことは、メッシュチャンクのコピーを行うと、それらの実体もハードコピーされます。マテリアルチャンク内にはテクスチャもあるかもしれません。その場合数メガバイト単位の強烈なディープコピーが発生します。これは、やばいわけです。
これを解決するにはInfo構造体を実体で持つのではなくてポインタに置き換えればいいんです。そうすれば、非常に軽いコピーだけですみます。ただ、生ポインタだと削除時に確実にはまるので、スマートポインタにします。つまりこういう感じです:
メッシュチャンククラス(内部スマートポインタ) #include "chunkid.h" // チャンクタイプがあります
#include "chunk.h"
#include "dixsmartptr.h" // スマートポインタ
class MeshChunk {
public:
struct Info {
MaterialChunk material; // マテリアル
VertexBufferChunk vertex; // 頂点バッファチャンク
};
private:
Dix::Hash32 name; // メッシュ識別名
Dix::sp<Info> info; // メッシュ構成情報をスマートポインタに
public:
MeshChunk() : info(new Info) {}
virtual ~MeshChunk() {}
// シリアライズ
virtual unsigned sirialize(std::ofstream &ofs) {
// ヘッダー生成
header.type = (unsigned short)CHUNKID_MESH;
header.name = name;
header.size = size();
// データ格納
ofs.write((char*)&header, sizeof(Header));
info->material.sirialize(ofs);
info->vertex.sirialize(ofs);
return header.size;
}
// デシリアライズ
virtual bool desirialize(std::ifstream &ifs) {
// ヘッダー生成
ifs.read(&header, sizeof(Header));
// データ復元
info.SetPtr(new Info);
info->material.desirialize(ifs);
info->vertex.desirialize(ifs);
return true;
}
// サイズ取得
unsigned size() {
return sizeof(Header) + info->material.size() + info->vertex.size();
}
// メッシュデータを格納
void setData(Dix::Hash32 &meshName, Dix::sp<Info> &dataInfo) {
name = meshName;
info = dataInfo;
}
// メッシュデータを取得
void getData(Dix::Hash32 &meshName, Dix::sp<Info> &dataInfo) {
meshName = name;
dataInfo = info;
}
};
こうするとMeshChunkをハードコピーしても、内部ではナローコピーとなります。こういう内部スマートポインタは様々な状況において重宝します。