> [!NOTE] > この記事は [# Unity Advent Calendar 2025](https://qiita.com/advent-calendar/2025/unity) に参加しています! ゲーム開発の文脈では「マスターデータ」というものが必ず登場します。そう、それです。 ウェッブサービスやなんかをつくる場合だと、つくり手側は膨大なコンテンツをあらかじめ用意せずとも、リリースしたらユーザーが勝手にコンテンツをつくってくれます。たとえば X(ペケ)とかいうサービスでは、放っておいてもみんなが終わらない喧嘩をし続けてくれるので、すごい勢いでサイト上に表示されるコンテンツが増えていくわけですね。そして、世界中のみんなが毎秒送信する億単位の悪口を永久保存するため、非常に可用性の高い分散データベース技術などが日々発展してきておりめでたしめでたしなわけです。 一方、ゲーム制作の場合、つくり手側があらかじめパッケージにたくさんのコンテンツを閉じこめて出荷します。 ここで関係してくるのが「マスターデータ」です。キャラクターの様々なパラメーター、敵の行動、スキル、アイテムの効能や各種パラメーター、風来のシレンで階段を降りると突然大部屋で敵に囲まれてタコ殴りされる確率、など、ビルド時には確定しているそこそこ大きなデータベースが必要になりがちで、これを「マスターデータ」とか呼びます。マスターデータをエディットする職人が日々ゲームバランスなどを支えています。 > [!NOTE] > もうすこし広く捉えると、ゲーム中に出てくるMAPデータ/ステージデータ、カットシーン、空間的な配置、時系列の出来事、バイトコード、NPCの思考の設定、など、ビルドしてパッケージにぶちこまれているべきデータは多岐に渡るので、マスターデータ的なものと絵や音などアセット的なものとの境界線はどこか、というとプロジェクト毎にまちまちかもしれない。 Unityの場合、アセットを編集するノリで、マスターデータもUnity内でエディットしよかな? そんな発想が Unity株を買っているみんな的には自然ですが、運用上はそうならない場合も多いです。 風来のシレンで弓をばんばん撃ちまくっているのに全然当たらない確率、などは、けっきょく豆山賊が何発の弓で死ぬのか、など、ほかの様々なパラメータと合わせてバランスを調整したいため、一覧性がありデータ同士の関連を高速で確認できるデータシートのようなUIで編集したかったりしたくなかったりします。Unity エディタはダイナミックに動く2D/3Dシーンやグラフビューなどをつくるのは得意ですが、データワーク的なUIを自前でつくるのはあまり向いてない、というか、そういうUIを自作すること自体が大変です。 また、Unityでエディットしたとして、出力したデータはUnityがなくても読めたほうが便利な場面ばかりです。Unityのシリアライゼーションはクローズドソースなので、なんであれUnityアセットを読み込むためには、メモリを大量消費し起動に時間がかかるUnity.app (応答なし) を開く以外に方法がなく、取り回しがよくはありません。 とくに、オンライン要素があったりサーバを使うゲームだと、Unity以外もマスターデータを読みたいんですよね。 ## マスターデータのためのソリューションいろいろ そんなこんなでプロジェクト毎にマスターデータ事情はいろいろです。 - 編集方法 - 外部ツールで編集するパターン - Unityエディタ内で編集するパターン - アプリにぶちこむデータのフォーマット - Unity アセットとして保存 - MessagPack/JSON/YAML/protobuf など、C#オブジェクトにデシリアライズできる汎用的なバイナリフォーマットとして保存する - 組み込みDB - SQLiteなどにデータを入れ、ゲームのアプリ内に処理系と一緒にぶちこむ ## 組み込みDB 編集方法は置いておいて、データフォーマットと読み込み方法について、今回注目したいのが、アプリに組み込みDBをのっける方法です。 Unityアセットを使う方法は、ポータビリティに難点があるほか、ロード/アンロードを動的にやりたい場合に管理が面倒になりがちです。Unityのメインスレッド以外からロードできないというのも欠点になってきます。 MessagPack/JSON/protobuf 等、汎用フォーマットの生データをアプリに入れる方法は、 Unity界隈でもっとも使われている方法かもしれません。 この場合、一括でデコードしきった後でないと中身を走査できないため、基本的には、とりあえずメモリに全部載っけとこう、という戦略に自然になります。 高速で採用事例も多い [Cysharp/MasterMemory](https://github.com/CySharp/MasterMemory) などもそうで、メモリに事前にすべて乗せることで、実行時の取得が間違いなく最速、という建てつけになってます。 しかし逆に言えばメモリ使用量がこの方法の欠点と言えます。マスターデータといってもあるシーンで必要なデータは一部だけだから、すべてメモリに載せていると、使ってない部分のメモリが無駄になってます。そのため、この方法を採用すると、我々は気がつかないうちに「メモリに全部乗るデータだけをマスターデータとして扱う」という戦略をとるようになります。 一方、アプリに組み込みDBが入っている場合はどうでしょう。DB は、どれだけデータを膨大に抱えこんでいても、読み込むまではメモリを消費しない、という利点をもっています。 すると**メモリ容量は気にせず動的に読みたいデータならなんでもかんでもDBにぶち込んどけばいいじゃん** 、という戦略がとれるようになります。 テキストデータとか、どれだけ長文を大量に書いてま、都度ロード、とかせず、DBにぜんぶつっこんでおけばよくなります。GCにまかせればアンロードする必要ありません。エンジニアが一生懸命、Unityのメモリ使用量を削減していても、無視。組み込みDBなら使ってないデータをメモリからオフロードできるのです。 たとえば、自分はゲームのシナリオやキャラクターの動作、タイライン的な制御に [[VitalRouter.MRuby|mruby]] を使ってるんですが、メモリに最適化するなら、必要なmruby のバイトコードを事前に先読みしたり、動的にロードしたりアンロードしたりなんちゃらかんちゃら制御することになりますが、それはだるいよ。 ところが組み込みDBを前提にするとどうか。mrubyバイトコードもう全部組み込みDBに入れとけばいいや。だって アンロードとかメモリリークとかまじで一切気にしなくていいし。DBが勝手に必要な部分だけメモリに読んでくれるし。使い終わったやつは適当に放っといてGCさせときゃいいや。あざっす。という戦略をとることが可能になってくるのです。 ### (番外編) 近年の組み込みDBについて クライアントサイドのアプリに埋め込むためのDBのソリューションについては色々な変遷があります。 現在でも、もっとも有名な製品はSQLiteでしょうか。 SQLiteは、DBを単一ファイルとして保存でき、同じマシン内のDBを読む専用のRDBMSです。iOS SDKのCore Dataのバックエンドに利用されていたり、FirefoxやChromeなどのWebブラウザも内部で使われていたりなど、PCやモバイル向けアプリの文脈でも実績が充分です。 とはいえ、クライアントサイドの用途では、SQLはオーバスペックです。サーバーサイドと違って複雑なクエリは必要ないし、マルチスレッド読み書き性能に最適化されている必要もなく、また、ふつうは取得したデータをC#とかプログラミング言語のオブジェクトにマッピングするわけですが、 SQL で取得した型つきレコードに対してそれをするいわゆるO/Rマッパーは複雑でヘヴィなものになりがち。昨今のサーバーサイド開発事情においても、一部プログラミング言語によっては 、コードファーストなO/Rマッパー的アプローチをほとんどあきらめている始末です。 当然の帰結として、SQLだのO/Rマッパーとかそんなでかいものクライアントサイドに入れたくないわ。と考える人々の手によって、データを各プログラミング言語のオブジェクトに変換できるSDKをビルトインした [Realm](https://github.com/realm) などのソリューションが過去には生まれたり消えたりしていました。 一方、Google Chrome は、SQLが不要な部分のためにもっと軽量で性能のよい LevelDB というものを開発し、採用しました。これはキー/バリュー型のデータベースで、仕様はSQLiteに比べて圧倒的に単純です。また、書き込みにはWAL 概念があり、つまりマルチスレッドでの write に特化しています。 その後、facebook が LevelDB をfork した RocksDB が後継として誕生し、どちらかというと後発のこちらのほうが評価されているようです。 RocksDBは、実はTiDBなどの分散データベースの実装において、各ノードが自身にローカルな状態を永続化する用途で使われてたりもしており、信頼性はかなり高く、またそれなりにクエリ機能なども充実していて、組み込み用途としてちょうどいい。 ということもあって、組み込みDBを調べてみるとRocksDBはベンチマーク対象としてかなりよく登場します。 Rust界隈の組み込みDBを実装する新興プロジェクトとかも、 キー/バリュー型で、インターフェイスとしてはRocksDB似よく似ているものが多いっぽいです https://github.com/cberner/redb?tab=readme-ov-file#benchmarks ## VKV じゃあRocksDBとかつかおっか? いいえ。我々はUnityで、かつマスターデータのためのDBがほしいのです。 マルチスレッドwrite性能はまったく必要ないし、なんならwriteできなくてもいいんですよね。 意外ですが、そういう用途に特化した組み込みdb実装はみつかりませんでいた。 また、DBだとC#で実装されたものがないので、既存のものを使うにはネイティブバインディング諸々が必要です。 そこで、C#とUnityに特化、かつマスターデータを想定した読み取り専用の組み込みDB 実装をつくりました。 [hadashiA/VKV](https://github.com/hadashiA/VKV) 左から読んでもVKV、右から読んでもVKV。「V」という名前は、 [hadashiA/VContainer](https://github.com/hadashiA/VContainer) の「UnityのUを研がらせてV」の踏襲です。イミュータブルにすることでread性能に特化してシンプルにする、というコンセプトがVContainerと共通です。 データベースと言うと実装がめちゃむずそうですが、実は読み取り専用の仕様に割り切ってしまえば極端に難易度が落ちます。ていうかめちゃ簡単です。 トランザクションがなければmvccもWALもなにもかも実装する必要がないし、ビルド時にデータが確定するならマルチスレッド下でのバグが生まれるリスクもない。組み込みDBなのでサーバーとしての機能も必要ないからプロトコルの設計も不要です。 必要なのはバイナリファイルを区分けしたページとして読み込むページキャッシュと、あと検索のためのB+Tree、ほぼそれだけです。 バイナリで構築されたB+Treeを走査したりする、みたいな処理は、そう、モダンC#が得意とする領域ですね。あまりにも得意すぎてSQLiteとの比較で プライマリ検索が最大120倍程度高速になってます (当社比) ``` | Method | Mean | Error | StdDev | |------------------- |------------:|----------:|----------:| | VKV_FindByKey | 37.57 us | 0.230 us | 0.120 us | | CsSqlite_FindByKey | 4,322.48 us | 44.492 us | 26.476 us | ``` (比較には 高速なSQLiteバインディングである [nuskey8/CsSqlite](https://github.com/nuskey8/CsSqlite) を使わせてもらいました) ### 特徴 機能としてはRocksDB系統に影響を受けた、ごく単純なキー/バリューストアですが、キーは順序の概念をもち、範囲検索が可能です。 あとテーブルを複数つくることが可能。 セカンダリインデックスは実装中です。プレフィックス検索もつけたいけどまだつくってません。 このへんの機能があればマスターデータを扱うには割と十分だったりするかなと。 valueはスキーマをもたず、任意のバイト列がなんでも入ります。マスターデータにこだわらずなんでも入れましょう。msgpack でC#へデシリアライズするプラグインはサポート済みですが、jsonでもprotobuf でも、エンコードすればなんでも入ります。 keyについては、テーブルごとにキーの型が設定できます。デフォルトはただのASCII文字列ですが、64bit整数をキーにする機能をサポート済みです。キーに型を指定できるとで、文字列以外をキーにしたい場合、圧倒的に高速になります。 keyの型を追加でカスタム定義することも可能です。キーには順序が必要なので、ほかに追加したいのはUlidやUUIDv7 くらいかなとおもってますが、そのうちプラグインとして追加予定です。 C# なので async/syncどちらでも使えます。 ```cs var table = db.GetTable("items"); // 検索 using var result = table.Get("key1"); using var result = table.GetAsync("key1", cancellationToken); // 範囲検索 (key1 >=) using var result = table.GetRange("key1", KeyRange.Unbound); using var result = table.GetRangeAsync("key1", KeyRange.Unbound); ``` asyncの場合、ファイルI/Oが発生した場合にそこが非同期になります。ゲームなのでスレッドを絶対にブロックしたくないよなみんな。 ところで 結果を `using var` で受けて Disposeが必要になっているのはなぜでしょうか? 実は、パフォーマンスに最適化するため、単に value を生バイト列として取得した時点では、ページキャッシュ上の任意の参照をスライスしているだけです。つまりゼロコピーです。 実際には、C#から取得したバイト列をつかう際に、なんらかデコードしたりデシリアライズしたりとかほぼ100%発生し、バイト列はすぐに必要なくなるケースが大半であることを前提に、こういう設計になってます。 各ページには参照カウンタが設けられており、マルチスレッドでクエリしまくってページキャッシュが入れ替わりまくっても安全になってます。 valueにMessagePackを入れている場合は以下のように型をつけられます。 ```cs var table = var table = db.GetTable<Item>("items", MessagePackOptions.Default); Item item = table.Get("key01"); ``` このように、 valueを即消費して解放する用途では using不要です。 ほか、テーブル全体をあくまで省メモリで走査したい場合のための iterator機能があります ```cs var iterator = table.CreateIterator(); // 現在の値(最小のキー) iterator.Current // 次へシーク iterator.MoveNext() iterator.Current //=> 次の値 // 指定した位置へシーク iterator.TrySeek("key01"u8) iterator.Current //=> key01に対応するvalue // asyncもサポートしている await iterator.TrySeekAsync(...); await iterator.MoveNextAsync(); // foreach、await foreach もサポートしている await foreach (var x in iterator) { // ... } ``` ### ページフィルター VKV では ページキャッシュが C# ですべて自前で実装されてます。 ということは、 DBからメモリへロードする際、C# で実装したフィルタをなんでもかますことが可能です。 SQLite の場合、 圧縮や暗号化のサードパーティプラグインが存在している模様で、 ゲームのマスターデータに対してもこういうのほしいことはままあるようです。 現在、VKV.Compression パッケージによって、zstd圧縮機能をサポートしてます。( powerd by [Cysharp/NativeCompression](https://github.com/Cysharp/NativeCompression)) また、C# でカスタムフィルタを書けるようになっており、しかもフィルタは何重にもかけられるしページサイズが可変になっても大丈夫な設計になってます。 ### Unity向け独自機能 VKVはUntiy でのユースケースを忘れていないので、 Unityに完全特化したページローダーを搭載しています。 ここでもページキャッシュが完全自前なことの柔軟性が活きており、ファイルからページを読み取る実装を切り替えられるようになってまふ。 Unityむけには ページを `NativeArray<byte>` として読み込む `AsyncReadManager` を使ったページローダーを提供しています。 これによって、でかめのページを読み込みまくってもGC の負荷は上がらないし、GC管理メモリの断片化リスクもなくなります。 ただのバイト列は ぜんぶUnity Native Allocator に乗せていく。これを来年の豊富としていきましょう。 これで、「C# でDBなんて実装したら 、でかページ読むときのGC 負荷高まりませんかね?」という組み込みDB としての弱点も克服しました。 ## そのほか細かい実装について 聞きかじった話によると、DB実装では ファイル読み込みにmmap が使われることが割とあり、初期のLevelDBもmmapを使っていたといいます。が、現在ではDB実装におけるmmapのデメリットがいろいろ指摘されているとのこと。 https://x.com/hmarui66/status/1640880930318594049?t=qIwE3RdKvSEg9T7Qkk6L2g&s=19 LevelDBは現在ではmmapをやめているし、RocksDBがLevelDBをforkした理由のひとつもmmapを止めたかったから、といってます。 一連の議論をみると、mmap だと I/Oが発生するタイミングが制御できないのがちょっと微妙(asyncサポートしたいし)かなと思いました。(とくにゲームとかだと) mmap の場合、ページの先頭への参照をただの仮想アドレスとして持てるため、その辺はパフォーマンス上有利、みたいな話もあるらしいのですが、 C# だとその利点を活かせなそうかなたぶん、と思います。 というわけでページキャッシュについては C# で自前実装していますが、 DB全体を読み取り専用にしたとしても、ページキャッシュをもっていると ここは スレッドセーフな制御が必要になってきます。 実際、キャッシュヒットする場合についてはクエリとかよりももはや ページキャッシュの排他制御がボトルネックになってます。 このへんのアルゴリズムと、あと ページの参照を検索するConcurrentDictionaryの実装とか入れかえるともうちょっとパフォーマンス伸びそうかも、という目算です。 B+Tree の検索については、ビッグエンディアン環境をがん無視し、リトルエンディアンエンコードされたバイナリを `Unsafe.ReadUnaligned<T>()` で直で C# の値として読み込む、というやつだらけになってます。これは意外にも [MRubyCS](https://github.com/hadashiA/MRubyCS) での経験がちょっと活きてます。 あとは、木を探索していく際に、どうしても メモリ内のあちこちを行ったり来たり ReadUnaligned しており、これをもうちょっと CPUキャッシュにやさしい形にできないかなあとかは考えてますがまだ最適なアレをみつけられてません。 書き込みがないので、アドレスとかを事前にバイナリに焼き放題だし アラインメントとかも調整し放題なので、読み取り専用ならではのフォーマットはもうちょっといろいろやりようがあるのかも。 ## そんなかんじなんですが VKVはまだ previewバージョンですが nugetパケにパケってみたのでよかったらウォッチしてみてください。