5.4K Views
February 09, 24
スライド概要
C++ MIX #9で発表した資料になります。
https://cppmix.connpass.com/event/305699/
フロントエンジニアです。
C++とWIN32 APIを利用した中品質なリソースファイルのホットリロード 荻野雄季
自己紹介 荻野雄季(おぎのゆうき) 有限会社テクニカルアーツ所属(時々個人事業主としても活動) https://www.techarts.co.jp / 4年前からゲーム開発(C++)に関わっています。元々はWeb系のフロントエンジニア 最近の趣味はキャンプとバイク Twitter @YuukiOgino
はじめに スライドはすぐに公開します。メモは取らなくて大丈夫です。 今回発表したコードは既にGitHubに公開しています。 https://github.com/YuukiOgino/SimpleResourceHotReload MITライセンスですが、著作権表示は求めません。MITライセンスのURL表記だけ守ってください。
きっかけ 引用:カービィの開発ツールをつなげて便利に! —ツール・ゲーム連携手法の紹介—(CEDEC2020) https://cedil.cesa.or.jp/cedil_sessions/view/2210
C#では『FileSystemWatcherクラス』を使う方法がある では、C++では? 引用:カービィの開発ツールをつなげて便利に! —ツール・ゲーム連携手法の紹介—(CEDEC2020) 47ページより https://cedil.cesa.or.jp/cedil_sessions/view/2210
ReadDirectoryChangesを使えば…!! https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-readdirectorychangesw
なぜWin32 APIを利用? ・Win32 API以外使用できないプロジェクトだったため
Win32 APIでは以下の二つでディレクトリ監視ができる ・ReadDirectoryChangesW ・FindFirstChangeNotification ※AとWがありますが、意図的に抜いています。
実装してみた 今回はReadDirectoryChangesWを使用します
FindFirstChangeNotificationを使用しなかった理由 ・ファイル名を取得したかった →FindFirstChangeNotificationはディレクトリのみ監視 ・時間の都合上、高品質のホットリロードを作るつもりはなかった →ある程度の取りこぼしは許容する ・監視したいリソースファイルが膨大だった →ファイルが限定的ならFindFirstChangeNotificationで十分
実装環境 VisualStudio2022 C++20(実際に作った環境はC++17) JET BRAINS ReSharper C++2023.3
CreateFileで監視元のHANDLEを取得 // 監視元のディレクトリを取得(サンプルは作業ディレクトリ) const std::wstring p = std::filesystem::current_path().wstring(); HANDLE h_dir = CreateFileW( p.c_str(), FILE_READ_DATA, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr );
ReadDirectoryChangesWを使用(その1) //最終書き込み日時変更のみフィルタリング constexpr DWORD dw_notify_filter = FILE_NOTIFY_CHANGE_LAST_WRITE; auto buffer_size = 256; std::vector<wchar_t> buf(buffer_size); void* p_buf = &buf[0]; void* event = CreateEvent(nullptr, true, false, nullptr);
ReadDirectoryChangesWを使用(その2) // イベントの手動リセット ResetEvent(event); OVERLAPPED olp = {}; olp.hEvent = event; if (!ReadDirectoryChangesW(h_dir, p_buf, buffer_size, true, dw_notify_filter, nullptr, &olp, nullptr)) { // ここに来たらエラー return; }
ファイルの変更通知を待つ // 変更通知まち const auto wait_result = WaitForSingleObject(event, 1000); if (wait_result != WAIT_TIMEOUT) { // 変更通知があった場合 (イベントがシグナル状態になった場合) break; } WaitForSingleObjectが実行された時点でスレッドが止まるため、 std::thread等で別スレッドで監視させる
ファイルの変更通知を中断させる場合(通常) if (h_dir != nullptr) { CloseHandle(event); CancelIoEx(h_dir, &olp); h_dir = nullptr; event = nullptr; }
ファイルの変更通知を中断させる場合(マルチスレッド時) // 途中終了するなら非同期I/Oも中止し、 // Overlapped構造体をシステムが使わなくなるまで待機する必要がある. if (h_dir != nullptr) { CancelIoEx(h_dir, &olp); h_dir = nullptr; event = nullptr; } WaitForSingleObject(event, INFINITE);
ファイルの変更通知を検知後
// 非同期I/Oの結果を取得する.
DWORD size = 0;
if (!GetOverlappedResult(h_dir, &olp, &size, false))
{
// 結果取得に失敗した場合
CloseHandle(event);
CancelIoEx(h_dir, &olp);
h_dir = nullptr;
event = nullptr;
return;
}
auto* lp_information = static_cast<FILE_NOTIFY_INFORMATION*>(p_buf);
リソースを再読込(全文)
while (true)
{
// ReadDirectoryChangesWでは文字列はwstringのため、必要であればstringへ変換をかける
std::wstring w_file_name = lp_information->FileName;
std::string filename(w_file_name.begin(), w_file_name.end());
// ホットリロード対象のファイルである場合は再読み込みを実行させる
if (validate_file_name(filename))
{
switch (lp_information->Action)
{
case FILE_ACTION_MODIFIED:
{
// 書き込むタイミングによってはサイズが0の場合があるため、チェックを行う
std::filesystem::path path = filename;
if (const auto file_size = std::filesystem::file_size(path); file_size > 0)
{
if (callback_list.contains(filename))
{
auto end = std::chrono::system_clock::now();
// 短時間で連続2回以上更新の通知がくる場合があるので、待ち時間を過ぎた場合のみコールバックを実行する
if (std::chrono::duration_cast<std::chrono::milliseconds>(end - callback_list[filename]->last_exec_time).count() > wait_millisecond_time)
{
callback_list[filename]->last_exec_time = end;
// 何かしらの方法でファイル読み込みを実行(各自で使用しているライブラリに合わせてください。)
std::ifstream file(filename);
//読込が終わったらコールバックに渡す(サンプルは雑に渡してます。バイナリデータを渡してください)
callback_list[filename]->reload_cb(filename, file, file_size);
file.close();
}
}
}
break;
}
default:
break;
}
}
// オフセットが0の場合はbreak
if (lp_information->NextEntryOffset == 0) break;
lp_information = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(reinterpret_cast<unsigned char*>(lp_information) + lp_information->NextEntryOffset);
}
リソースを再読込(ループ文の中身その1)
// ReadDirectoryChangesWでは文字列はwstringのため、stringへ変換をかける
std::wstring w_file_name = lp_information->FileName;
std ::string filename(w_file_name.begin(), w_file_name.end());
// 必要であればバックスラッシュを変換する
std ::string old = "\\";
std ::string rep = "/";
変換処理は省略(指定の文字列を全置換処理するには自作する必要があるため)
参考:https://learningprog.com/cpp-replace/
リソースを再読込(ループ文の中身その2)
switch (lp_information->Action)
{
case FILE_ACTION_MODIFIED:
{
// 書き込むタイミングによってはサイズが0の場合があるため、チェックを行う
std::filesystem::path path = filename;
if (const auto file_size = std::filesystem::file_size(path); file_size > 0)
{
if (callback_list.contains(filename))
{
auto end = std::chrono::system_clock::now();
// 短時間で連続2回以上更新の通知がくる場合があるので、待ち時間を過ぎた場合のみコールバックを実行する
if (std::chrono::duration_cast<std::chrono::milliseconds>(end - callback_list[filename]->last_exec_time).count() >
wait_millisecond_time)
{
callback_list[filename]->last_exec_time = end;
// 何かしらの方法でファイル読み込みを実行(各自で使用しているライブラリに合わせてください。)
std::ifstream file(filename);
//読込が終わったらコールバックに渡す(サンプルは雑に渡してます。バイナリデータを渡してください)
callback_list[filename]->reload_cb(filename, file, file_size);
file.close();
}
}
}
break;
}
default:
break;
}
ファイルパスのバリデートチェック
/**
* @brief ファイル名のバリデートチェック
* @details ファイル名のバリデートチェック。共通でホットリロード対象外のファイル名、フォルダが送られてきた場合は
falseを返す。
*/
bool SimpleHotReload::validate_file_name(const std::string& filepath)
{
// .がない場合はほぼフォルダのみの更新のため、false
if (filepath.find('.') == std::string::npos) return false;
// svnフォルダの中身を読み込ませるのは必要ないため、false
if (filepath.find(".svn") != std::string::npos) return false;
// exeは読み込ませる必要はないため、false
if (filepath.find(".exe") != std::string::npos) return false;
// .gitは読み込ませる必要はないため、false
if (filepath.find(".git") != std::string::npos) return false;
// .dllは読み込ませる必要はないため、false
if (filepath.find(".dll") != std::string::npos) return false;
return true;
}
逆に読み込みたい拡張子が限定されている場合は、含まれてたらtrueを返すでいいかも
リソースを再読込(ループ文の中身その3) // オフセットが0の場合はbreak if (lp_information->NextEntryOffset == 0) break; lp_information = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(reinterpret_cast<unsigned char*>(lp_information) + lp_information->NextEntryOffset);
バッファ初期化→ReadDirectoryChangesWを再実行 // バッファを初期化する buf.clear(); buf.resize(buffer_size); p_buf = &buf[0];
まとめ WaitForSingleObjectをメインスレッドで行うと何かしらのファイル変更を検知するまで止 まってしまうので、監視は別スレッドで行う 書き込むタイミングによってはサイズが0の場合があるため、必ずサイズチェックを行う 短時間で2回以上、更新通知がくる場合があるので、待ち時間を設ける Git等、短時間でリソースファイルを更新された場合は検知しきれないことがある(手動は ほぼ問題なし) ホットリロードを組み込む場合、初期の段階からホットリロードによる更新がある前提で実 装してもらうこと(超大事!!) →読み込んだバイナリを各自メモリコピー後、自由に処理していたため、ホットリロードの仕組 みができた段階で、アプリに組み込むのが無理な段階になっていたorz
参考資料 カービィの開発ツールをつなげて便利に! —ツール・ゲーム連携手法の紹介—(CEDEC2020) https://cedil.cesa.or.jp/cedil_sessions/view/2210 ReadDirectoryChangesW関数でフォルダ監視をする https://zenn.dev/ken_sss/articles/fee99b1363cb38 ファイル監視のための、いくつかの方法 https://gist.github.com/seraphy/2623100 ファイルの変更を監視する方法 https://seraphy.hatenablog.com/entry/20120506/p1
ご清聴 ありがとうございました