📁

脱ファイルパス依存 C++でUnityみたいなAsset管理をしてみよう

2024/12/21に公開

この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2024の21日目の記事です。
https://qiita.com/advent-calendar/2024/kdgamegiken

はじめに

Unityのようなゲームエンジンで行われているAsset管理システムを、
C++で再現するにはどうすればいいか検討してみました。

記事の対象者

C++/DirectXなどのネイティブ環境でゲームを作成していて作品自体はある程度できてきたけど、
・ファイル管理に手を付けてみたいけど、どういったことから考えればよいかヒントがほしい。
・Assetのフォルダ構成やファイル名を整理・変更したら大量のエラーが出てきて困ったことがある。
・この作品をゲーム会社への就職活動に使うとして、もっとアピールできる材料がほしい。
・ImGuiなどでエディタチックなものを作ったので、もっと機能をグレードアップさせたい。
のような方のために記事にしてみました(該当者どれくらいいるんだろう…)

前提

・ファイル入出力にstd::ifstream std::ofstreamを使用しています
 https://cpprefjp.github.io/reference/fstream.html
・ディレクトリ操作にstd::filesystemを使用しています(C++17以降)
 https://cpprefjp.github.io/reference/filesystem.html
・Jsonの保存・解析にnlohmann/jsonを使用しています
 https://github.com/nlohmann/json

仕組み

Unityでゲームを作っているとプログラム中にファイルパスを打ち込むことが極端に少ない(無い)と感じるはずです。
これはAssetを管理する機能がUnityに組み込まれており、エンジン上であらゆるAssetの指定ができるようになっているからです。
エンジンで行われているAsset管理イメージ

具体的には、UnityはAssetファイル一つ一つにMetaファイルと言うAssetに付随するファイルを自動生成します。
Metaファイルの中には、エンジン上から設定した情報はもちろん「重複しないAsset固有のID」などを保存しています。

この固有のIDの事をGUID(Globally Unique Identifier)と呼び、エンジンではこのIDを元に読み込むAssetを特定しています。

しかしGUIDは128ビットの整数データのため、エンジン上でないと到底管理出来ません。
そこで今回は文字列にてこの任意のIDを振ってみようと思います。
この文字列で付けたIDを便宜上「AddressableName」とこの記事では呼称します。
本質は違いますが、Unityでも文字列でのAsset指定が可能です。

Metaファイルの作成

まずは、Assetが入っているフォルダを一つ指定してその中で対応している形式のファイル全てにMetaファイルを作成していきます。

KdAssetManager.h
class KdAssetManager
{
public:
	// Assetsフォルダ以下をクロールして、Metaファイルを更新していく
	void CreateMetaFileForAllFiles();

	// サポートしているファイル形式か確認する
	bool IsSupportedFile(const std::filesystem::path& filePath);

	// ファイル一つに対してのMetaファイル作成
	nlohmann::json CreateMetaFileForFile(const std::filesystem::path& srcFile);

	// 作成したMetaファイルを全部削除
	void DeleteAllMetaFiles();

	// 固定のファイルパスやらファイル名やら
	std::string _assetFilePass = "./Assets/";   // Assetファイルの先頭ディレクトリ
	std::string _metaFileExtentionName = ".kdfwmeta"; // 作成するメタファイルの拡張子
	std::string _logFileName = "AssetManager.log";  // Log保存場所

private:
	// 対応する拡張子
	std::list<std::string> _supportedExtensions;
};
KdAssetManager.cpp
// std
#include <filesystem>
#include <fstream>

// json
#include <nlohmann/json.hpp>

#include "KdAssetManager.h"

// Assetフォルダ以下の対応ファイル全てにMetaファイルを作っていく
void KdAssetManager::CreateMetaFileForAllFiles()
{
	// log出力先
	std::ofstream log(_logFileName);

	// 対応するAssetの拡張子を登録→最終的には外部ファイルに吐き出す
	_supportedExtensions.clear();
	_supportedExtensions.push_back("txt");
	// _supportedExtensions.push_back(".gltf");
	// _supportedExtensions.push_back(".png");
	// …more

	// 指定されたアセットフォルダ以下をクロール
	for(const std::filesystem::directory_entry &entry : std::filesystem::recursive_directory_iterator(_assetFilePass))
	{
		// AssetManagerがサポートしているファイルだった
		if(entry.is_regular_file() && IsSupportedFile( entry.path()) )
		{			
			// メタファイルが有るか
			std::filesystem::path metafilePath = entry.path();
			metafilePath.replace_filename(entry.path().filename().string() + _metaFileExtentionName);
			if( std::filesystem::exists(metafilePath) == false )
			{
				// メタファイルがなかったら新規作成
				std::ofstream metaFile(metafilePath);
				log << "CreateMetaFile!  " << metafilePath << std::endl;

				// メタファイルに書き込むデータの作成
				metaFile << CreateMetaFileForFile( entry.path() );
			}
		}
	}
}

// ファイル拡張子がサポートしている形式か調べる
bool KdAssetManager::IsSupportedFile(const std::filesystem::path& filePath)
{
	for (auto& ext : _supportedExtensions)
	{
		if (filePath.extension().string() == ext) { return true; }
	}
	return false;
}

やっていることは非常に簡単で指定フォルダ(_assetFilePass)以下のフォルダを検索して、
対応したいファイル拡張子(_supportedExtensions)であった場合新しいMetaファイルを作っているだけです。

例えば以下のようなフォルダ構成だった場合、今回は.txtだけを対応拡張子にしていますので以下のように作成されます。
CreateMetaFileForAllFiles()実行前

└── Project/
    └── Assets/
        ├── test1.txt
        └── aaa/
            ├── test2.txt
            └── title.bmp

実行後

└── Project/
    └── Assets/
        ├── test1.txt
        ├── test1.txt.kdfwmeta
        └── aaa/
            ├── test2.txt
            ├── test2.txt.kdfwmeta
            └── title.bmp

AddressableNameの作成

文字列でIDを管理しますので、このIDの付け方のルールを决めておきます。
今回はファイル名の@以下拡張子までの文字列をID(AddressableName)とします。
AddressableName命名ルール

これも独自にエディタを作ったのであればそこから决めれるようにしましょう。

KdAssetManager.cpp
// AddressableName作成
std::string KdAssetManager::AddressableName(const std::filesystem::path& srcFile) const
{
	auto filename = srcFile.filename().string();

	// AddreassableNameの指定が有るか
	auto at = filename.find("@");
	if (at != std::string::npos)
	{
		auto dot = filename.find(".");
		return filename.substr(at + 1, dot - at - 1);
	}
	// Addressableが指定されていない場合、ファイルパスをそのまま
	return srcFile.relative_path().string();
}

また、これまでファイルパス指定をしていた場合を考えて、AddressableNameを决めていないのであればファイルパスをそのままAddressableNameとします。

最後にこの情報をMetaファイル内に保存します。

KdAssetManager.cpp
// 渡されたファイルに対してMetaファイルを作成する
nlohmann::json KdAssetManager::CreateMetaFileForFile(const std::filesystem::path& srcFile)
{
	nlohmann::json j;
	j["SourceFile"] = srcFile.filename().string();
	j["AddressableName"] = AddressableName(srcFile);
	return j;
}

仮にtest1.txtの名前をtest1@addName.txtに変更した場合「addName」がAddressableNameとなります。
また、Metaファイルは「test1@addName.txt.kdfwmeta」となり、内容は以下のようになります。

{
 "AddressableName":"addName",
 "SourceFile":"test1@addName.txt"
}

※本来改行は入りませんが、分かりやすくするために改行しました。

全削除

また、Metaファイルを作り直すために全削除関数も作っておきましょう。
運用しだしたら基本的には使いません。

KdAssetManager.cpp
// Metaファイル残削除関数、めっちゃ危険
void KdAssetManager::DeleteAllMetaFiles()
{
	// Log保存場所
	std::ofstream log(_logFileName);

	// Assetフォルダをクロール
	for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(_assetFilePass))
	{
		// Metaファイルじゃなかったら無視
		if(entry.path().extension().string() != _metaFileExtentionName){ continue; }

		// 削除
		if( std::filesystem::remove(entry.path()) )
		{
			log << "Delete Mata File! " << entry.path().string() << std::endl;
		}
	}
}

AddressableNameからファイルを参照する

ここまでで準備完了です。
後はプログラム内部でAddressableNameからファイルを取得します。
アプリケーション側で使用するメンバをKdAssetManagerに追加します。

KdAssetManager.h
// For Application--------------------------------->
public:
	struct MetaData
	{
		std::string filePath;
	};

	// Metaファイルを探してApplicationで使用するリストを作成する
	void CreateAddressablesList();

	// AddressableNameを指定してファイルパスの作成
	std::string GetFilePathFromAddressableName(const std::string& addressableName);

private:
	// AddressableNameとメタ情報のリスト
	std::unordered_map<std::string, MetaData> _addressables;

実行時にはMetaファイルを探して以下の組み合わせでリストを作成しておきます。

[AddressableName]-[Metaファイルから取得したデータ]

ここで大切なことはAddressableNameの重複は許さないことです。
文字列指定の場合、ここの重複はかなりありそうですが今回は致し方ありません。
念の為重複した場合Assertで警告を出しておきます。

KdAssetManager.cpp
// Metaファイルを探してAddressablesListを作成する
void KdAssetManager::CreateAddressablesList()
{
	std::ofstream log(_logFileName);

	for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(_assetFilePass))
	{
		// メタファイルかどうか
		if (entry.is_regular_file() && entry.path().extension().string() == _metaFileExtentionName)
		{
			std::ifstream meta( entry.path().string() );
			nlohmann::json j;
			meta >> j;

			// AddressableName
			std::string addressable = j["AddressableName"];

			// 今回実行時のファイルパスの作成
			std::string filePath = j["SourceFile"];

			std::filesystem::path onlydi = entry.path();
			onlydi.remove_filename();
			std::string directory = onlydi.relative_path().string();
			filePath = directory + filePath;

			// メタデータから参照ファイル情報の作成
			MetaData metaData;
			metaData.filePath = filePath;

			// Addressableの被りは許さず
			if( _addressables.find(addressable) != _addressables.end() )
			{
				log << "error! : " << addressable << " This AddressableName is Conflict!"  << " filePath : " << metaData.filePath << std::endl;
				assert( 0 && "AddressableNameが被っています!! Logファイルを参照して下さい");
			}

			// AddressableNameをキーにしてデータを覚えておく
			_addressables[addressable] = metaData;
		}
	}
}

最後にAddressableNameからファイルパスを取得する関数を作って一旦完成

KdAssetManager.cpp
// AddressableNameからファイルパス取得
std::string KdAssetManager::GetFilePathFromAddressableName(const std::string& addressableName)
{
	if (_addressables.find(addressableName) == _addressables.end())
	{
		assert(0 && "指定されたAddressableNameが見つかりません!");
	}
	
	return _addressables[addressableName].filePath;
}

実行例

最後に簡単にではありますが、実行例です。

#include <fstream>
void Application::Start()
{
	KdAssetManager mgr;
	mgr.DeleteAllMetaFiles();
	mgr.CreateMetaFileForAllFiles();
	mgr.CreateAddressablesList();

	std::string path = mgr.GetFilePathFromAddressableName("addName");
	std::ifstream ifs(path);
	if( ifs.fail() == false )
	{
		std::cout << "AddressableNameでファイルが読み込めた!" << std::endl;
	}

	path = mgr.GetFilePathFromAddressableName("./Assets/aaa/test2.txt");
	std::ifstream ifs2(path);
	if (ifs2.fail() == false)
	{
		std::cout << "AddressableNameが設定されていない時はPath=AddressableName" << std::endl;
	}

	std::ifstream ifs3("./Assets/aaa/test2.txt");
	if (ifs3.fail() == false)
	{
		std::cout << "直接パス指定での読み込みも残したまま" << std::endl;
	}
}

メリット

一見2つの違いは無いように思いますが、test2.txtはファイル名かフォルダ構成を変えた瞬間に読み込めなくなります。

AddressableNameを振ったtest1は以後@以下さえ変えなければ、フォルダ構成を変更してもファイル名を変更しても読み込むことが出来ます。
ファイルを移動する時は作成したMetaファイルを一緒に連れていきます。これはUnityと同じですね。

今回は毎回作り直していますが、Metaファイルは初回に一度だけ作っておけば後は作る必要はありません。

全てのアセットをAddressableNameで管理しておけば、開発中のディレクトリ構造を維持する必要がなくなります(これが一番便利だと思います)。

拡張性と言うかやり残したこと

・結局の所AddressableNameはファイル名に依存してしまっているので、別途指定する手段がほしい→つまりエディタを作ろう!
・Pathを返さずにファイル読み込みと管理も隠蔽してしまうのが良いと思います→ResoueceManagerを作って関連付けよう!
・AddressableNameを指定した段階で重複チェックがしたい→全ファイルをクロールしてしまっているので変更のあったものだけチェックを入れる仕組みがほしい!つまりエディタを作ろう!
・やっぱり文字列管理はやめておいたほうが…→GUIDで管理したいですね。つまりエディタを作ろう!
・Metaファイルを充実させて行けば、Debug実行時とRelease実行時で読み込むファイルを変更したりなどいろいろできそう→つまり(ry

終わり

如何だったでしょうか?
普段学生作品を見ていると、ファイルパスに依存してしまっている学生がとても多いので(その手の授業を出来ていないことが心苦しいです)
これでちょっとでもファイルパス依存から解放される方が増えてくれる事を祈っています。

神戸電子専門学校ゲーム技術研究部

Discussion