DxLibでリソース管理システムを作ってみた
- 前提:投稿者はふだん学校でDxLibを用いたゲーム開発を教えていて、そこで出くわしたトラブルや「こうしたらどうだろう」と言うのをQiitaにしてみようという考えで書いています。
このため、内容としては学生向けですし、DxLibというライブラリに関するお話し限定となります
動機
- C++のshared_ptrやstd::vectorなどを使用することで、メモリのリークをかなり防げるようになったのですが、リソースの解放し忘れと言うトラブルからは逃れられていません。
- これをどうにかして、shared_ptrのように自動でそれぞれのリソースを解放できるような仕組みにできないかと考えました。
- もう一つの動機としては 「多重ロード防止」を実装したかった というのがあります。DxLibは同じものをロードしようとすると内部的にロード済みのリソースを別ハンドルで返しているようなのですが、そのこと自体はマニュアルに明記されていないようですし、学生さんが将来的にDirectXで開発する時にどっちにしろ自分で実装しなければなりません。と言う事で、今回の実装にはこの要件も含みます。
ということで、やりたい事としては
- 不要になったリソースは自動で解放されるようにしたい
- 多重ロードを防止したい
- 常駐リソースは常駐フラグを立てて、アプリケーション終了直前まで保持したい
こんなところです
要件
要件に関しては前述の通りですが、大雑把な実装の構想も含めて書くとこう
-
不要になったリソースは自動で解放する
内部的に「参照カウンタ」に相当するものをもっていて、ロード命令のたびにこれがインクリメントされ、持ち主がいなくなるか、スコープを外れれば参照カウンタをデクリメントする。
最終的にこのカウンタが0になったときに実際にDeleteGraphなりDeleteSoundMemなりする
Deleteは対象が画像の時とサウンドの時で解放の仕方が違うため、ここはポリモーフィズムで分けておく -
多重ロードを防止する
これは、「ファイルマネージャ」的なクラスを用意しておき、unordered_mapのキーをファイルパスに、値の方をファイルオブジェクトとして管理する。LoadImageFile関数、LoadSoundFile関数を用意しておき、これらの関数の内部で、パスを検索し、すでにロード済みならばそのハンドルを再利用し、まだロード済みでないならば新たにロードを行う -
常駐リソースも持てるように
「常駐フラグ」的なフラグを持たせておき、これがtrueならば、参照カウンタ=0でもDeleteせず、アプリケーション終了時まで保持する。アプリケーション終了時には残っているリソースを参照カウンタに関わらず全て解放する
こんな感じですね。
設計
正確なUML図と言えないかもしれませんが、だいたいこんな感じです。
要件と重複すると思いますが
- Fileを基底クラスとする
- そしてImageFile,SoundFile,ModelFileが派生クラスとなる
- FileのデストラクタがFileManagerのDelete関数を呼ぶ
- Delete関数ではポリモーフィズムによりそれぞれのリソース解放関数が呼ばれる
- FileManagerがパス⇒ファイルオブジェクトのマップを持っている
- ロード時はファイルパスでマップを検索し、すでにロードしてるかをチェック
- それぞれのファイルオブジェクトはshared_ptrで保持されている
- Load関数ではこのshared_ptrをそのまま返すのではなく、そのコピーを返す
- Delete関数が呼ばれたとき、内部カウント=0が確認されたらFileクラスのDelete関数を呼ぶ
実装
ファイル基底クラス
//File.h
#include<string>
class FileManager;
class File
{
friend FileManager;
protected:
FileManager& manager_;
int handle_=0;
int count_=0;
std::wstring path_=L"";
bool isEternal_ = false;//常駐フラグ
public:
File(FileManager& manager);
~File();
virtual void Delete() = 0;
int GetHandle()const;
};
FileManagerに対してはfriend指定しています。危ないかもしれないけど、FileとFileManagerの仲だから、多少はね。
cpp側はGetHandleとコンストラクタ、デストラクタだけ実装します。シンプルに
//File.cpp
#include "File.h"
#include"FileManager.h"
File::File(FileManager& manager) :manager_(manager) {
}
File::~File() {
manager_.Delete(path_);
}
int
File::GetHandle()const {
return handle_;
}
ファイルマネージャクラス(定義のみ)
で、これを束ねるFileManagerをまず作ります。今はヘッダ側だけ定義します。
//FileManager.h
#include<memory>
#include<string>
#include<unordered_map>
class File;
class FileManager
{
private:
std::unordered_map<std::wstring, std::shared_ptr<File>> fileTable_;
public:
~FileManager();
/// <summary>
/// 画像ファイルをロード
/// </summary>
/// <param name="path">パス</param>
/// <returns>画像ファイルを表すファイルオブジェクト</returns>
std::shared_ptr<File> LoadImageFile(const wchar_t* path);
/// <summary>
/// サウンドファイルをロード
/// </summary>
/// <param name="path">パス</param>
/// <returns>サウンドファイルを表すファイルオブジェクト</returns>
std::shared_ptr<File> LoadSoundFile(const wchar_t* path);
/// <summary>
/// モデルファイルをロード
/// </summary>
/// <param name="path">パス</param>
/// <returns>モデルファイルを表すファイルオブジェクト</returns>
std::shared_ptr<File> LoadModelFile(const wchar_t* path);
void Delete(const std::wstring& path);
};
派生クラス(ImageFile,SoundFile,ModelFile)
では、派生クラスを作っていきましょうか。こちらもシンプルなので一気に作ります。まずは画像ファイル(ImageFile)からです。
//ImageFile.h
#include "File.h"
class ImageFile :
public File
{
public:
ImageFile(FileManager& manager);
void Delete();
};
//ImageFile.cpp
#include "ImageFile.h"
#include<DxLib.h>
ImageFile::ImageFile(FileManager& manager):File(manager)
{
}
void ImageFile::Delete()
{
DeleteGraph(handle_);
}
派生クラスの役割は実際のDelete系関数を呼ぶところだけです。SoundFileとModelFileもやっちまいましょう。
//SoundFile.h
#include "File.h"
class SoundFile :
public File
{
public:
SoundFile(FileManager& manager);
void Delete();
};
//SoundFile.cpp
#include "SoundFile.h"
#include<DxLib.h>
SoundFile::SoundFile(FileManager& manager):File(manager)
{
}
void SoundFile::Delete()
{
DeleteSoundMem(handle_);
}
//ModelFile.h
#include "File.h"
class ModelFile :
public File
{
public:
ModelFile(FileManager& manager);
void Delete();
};
//ModelFile.cpp
#include "ModelFile.h"
#include<DxLib.h>
ModelFile::ModelFile(FileManager& manager):File(manager)
{
}
void ModelFile::Delete()
{
MV1DeleteModel(handle_);
}
というわけで、Fileやその派生クラスは大したことをやってないのが分かると思います。
面倒なのはむしろここからです。
再びFileManagerクラス(関数実装)
まずはLoadImageFile関数で、多重ロードを防止していきましょう。それ自体はunordered_mapが分かってれば楽勝ですが、今回shared_ptrを使っていますがテーブルの中身をそのまま返すわけにはいきません。コピーを返すので、ちょっとそこがややこしいです。
なんでわざわざコピーを返すのかと言うと持ち主消滅時にFileManagerのDelete関数を呼んでほしいからです。
忘れた人はファイル基底クラスのデストラクタ部分を今一度見てみてください。マネージャのDelete関数が呼ばれてますよね?これ普通にshared_ptrを返すとコピー元がテーブルに残っているので呼ばれないんですよ。
std::shared_ptr<File>
FileManager::LoadImageFile(const wchar_t* path) {
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {//もし初回ロードだったらLoadGraphでロードする
auto handle = LoadGraph(path);
assert(handle > 0);//ファイルが見つからない等はダメ
if (handle <= 0) {
return nullptr;
}
fileTable_[path] = std::make_shared<ImageFile>(*this);
auto& file = fileTable_[path];
file->handle_ = handle;
file->count_ = 0;
file->path_ = path;
}
//ここに来た時点で既にテーブル内にファイルは登録されているはず
//テーブルのコピーを返す(コピーコンストラクタの使用)。
// 返す際に参照カウントを1上げる
auto& file = fileTable_[path];
++file->count_;
//「ImageFileのコピー」を作りたいので、ダウンキャストする
auto imgFile = std::dynamic_pointer_cast<ImageFile>(file);
if (imgFile == nullptr) {
return nullptr;
}
return std::make_shared<ImageFile>(*imgFile);
}
ご覧のように少しややこしいことをやっています。見つからなかった時にLoadGraphしているのはいいのですが、コピーを返すときに妙な事をやっていますね?
わざわざダウンキャストしています。なおdynamic_pointer_castは、shared_ptrに対してdynamic_castする関数だと思っていただければ結構です。
要はImageFileのコピーを作りたいので、コピーコンストラクタを作るためにダウンキャストしたうえで参照化してmake_sharedでコピーを作っているわけです。
はい、ともかくこれで参照のたびにコピーが作られ、かつ、作られるたびにカウントが上がっていくような仕組みが出来上がりました。
では次にFileManager::Delete関数側です。まずパスで対象のファイルを探し、あればカウンタをデクリメントします。そしてその時にカウンタが==0になれば、派生クラスのDelete関数を呼び出し、本当にリソースを解放します。
void
FileManager::Delete(const std::wstring& path)
{
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {
return;
}
auto& file = it->second;
--file->count_;
if (file->count_ == 0) {
file->Delete();
}
}
ひとまずここまでで、LoadImageFileで返されたshared_ptrの持ち主が解放されるもしくはshared_ptrのスコープが外れるかしたときに、解放処理が走るのを確認しましょう。
では、次に常駐フラグについてです
常駐フラグを立てられるように
はい、忘れてませんよ、常駐フラグ。これはロード時のデフォルト引数を用いて、trueの時は常駐フラグが立つようにします。
//ヘッダ側
/// <summary>
/// 画像ファイルをロード
/// </summary>
/// <param name="path">パス</param>
/// <param name="isEternal>常駐フラグ(デフォルトfalse)</param>
/// <returns>画像ファイルを表すファイルオブジェクト</returns>
std::shared_ptr<File> LoadImageFile(const wchar_t* path,bool isEternal=false);
//CPP側
std::shared_ptr<File>
FileManager::LoadImageFile(const wchar_t* path, bool isEternal) {
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {//もし初回ロードだったらLoadGraphでロードする
(中略)
file->isEternal_ = isEternal;//追加!
}
(略)
︙
まぁ、こいつはisEternalフラグを立てるだけなので、大したことなくて大事なのはDelete部分と、デストラクタ部分です
//Delete関数
void
FileManager::Delete(const std::wstring& path)
{
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {
return;
}
auto& file = it->second;
//ここが追加分
if (file->isEternal_) {//常駐フラグがONならばDelete無効
return;
}
--file->count_;
if (file->count_ == 0) {
file->Delete();
}
}
はい、ご覧のように常駐フラグがONならカウント関係ないので中途リターンしています。こいつが解放されるのはアプリケーション終了時のみ…というかこのファイルマネージャが解放される時のみです。
//デストラクタ
FileManager::~FileManager()
{
//残ったファイルを全て削除
for (auto& file : fileTable_) {
file.second->Delete();
}
fileTable_.clear();//不要だけど、ここでやることでデバッグしやすく
}
fileTable_.clear()しなくても、ファイルマネージャ解放とともにclear()されるのですが、ここでバグが起きた時に、clear時にバグったことが分かりやすいようデストラクタが終わる前に明示的にclear()しています。
FileManager使用時の注意点
このようにFileManagerは自身の解放時にDeleteGraph等が走る事があります。DxLibのルールではこれらの関数はDxLib_End()前に呼ばなければなりません。
なのでFileManagerをシングルトンクラスなどにしないでください
例えばこうしてください。
void Application::Run()
{
FileManager fileManager;
SceneManager sceneManager(fileManager);
sceneManager.ChangeScene(std::make_shared<TitleScene>(sceneManager));
Input input;
while (ProcessMessage() != -1) {
ClearDrawScreen();
input.Update();
sceneManager.Update(input);
sceneManager.Draw();
ScreenFlip();
}
}
void Application::Terminate()
{
DxLib_End();
}
このようにメインループを持つRun関数内のローカルオブジェクトとして宣言するか
{
FileManager fileManager;
SceneManager sceneManager(fileManager);
sceneManager.ChangeScene(std::make_shared<TitleScene>(sceneManager));
Input input;
while (ProcessMessage() != -1) {
ClearDrawScreen();
input.Update();
sceneManager.Update(input);
sceneManager.Draw();
ScreenFlip();
}
}
DxLib_End();
このようにして、DxLib_End()前にfileManagerが解放されるようにしてください。
残りのロード関数も作る(LoadSoundFile,LoadModelFile)
どちらもLoadImageFileと同じようなつくりなので、もうちょっとまとめたかったですが、パッと思いつかなかったためそのまま書きます(こうすればいいよ!的なアイデアあったら、優しく教えてくださいね♡)
std::shared_ptr<File>
FileManager::LoadSoundFile(const wchar_t* path,bool isEternal) {
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {
auto handle = LoadSoundMem(path);
assert(handle > 0);//ファイルが見つからない等はダメ
if (handle <= 0) {
return nullptr;
}
fileTable_[path] = std::make_shared<SoundFile>(*this);
auto& file = fileTable_[path];
file->handle_ = handle;
file->count_ = 0;
file->path_ = path;
file->isEternal_ = isEternal;
}
auto& file = fileTable_[path];
++file->count_;
auto sndFile = std::dynamic_pointer_cast<SoundFile>(file);
if (sndFile == nullptr) {
return nullptr;
}
return std::make_shared<SoundFile>(*sndFile);
}
std::shared_ptr<File>
FileManager::LoadModelFile(const wchar_t* path, bool isEternal)
{
auto it = fileTable_.find(path);
if (it == fileTable_.end()) {
auto handle = MV1LoadModel(path);
assert(handle > 0);//ファイルが見つからない等はダメ
if (handle <= 0) {
return nullptr;
}
fileTable_[path] = std::make_shared<ModelFile>(*this);
auto& file = fileTable_[path];
file->handle_ = handle;
file->count_ = 0;
file->path_ = path;
file->isEternal_ = isEternal;
}
auto& file = fileTable_[path];
++file->count_;
auto mdlFile = std::dynamic_pointer_cast<ModelFile>(file);
if (mdlFile == nullptr) {
return nullptr;
}
return std::make_shared<ModelFile>(*mdlFile);
}
まとめというか、完走した感想ですが
今回、即興で作ったものなので、設計、実装ともにもうちょっと良くできなかったかな~という課題感が残りました。
もうちょっと時間かけてリファクタリングして気づいた点があれば再走したいかなと思います。
読んだ人から何かアイデアかヒント頂ければ助かりますので、よろしくお願いいたします。
(※マサカリじゃなく、やさしくお願いね←ここ大事)