カプコンがRE:Doxをオープンソース化 — 今すぐ取り入れられるデータエンジンアーキテクチャ
要点まとめ
RE:Doxのデータエンジンアーキテクチャを徹底解説。カプコンがオープンソース化した設計を、スキーマ定義からカラム型バイナリシリアライゼーション、自動マイグレーション、エディタUI自動生成まで網羅し、AAAスタジオの設計パターンを自社のゲームエンジンやUnityプロジェクトに応用する方法を学ぶ。
エンティティシステムが起動時に14,000件のアセット参照を読み込み、デザイナーはイテレーションの高速化を求め、データパイプラインがシリアライゼーションとランタイムのメモリレイアウトを結合しているせいでホットリロードに毎回45秒かかっている——心当たりはあるだろうか? カプコンはRE Engineのこの問題を解決するまさにその部品をオープンソース化した。そのアーキテクチャは盗む価値がある。
RE:DoxはカプコンのC#製データエンジンであり、『バイオハザード ヴィレッジ』『ストリートファイター6』『モンスターハンターライズ』といったタイトルにおいて、エディタツールからシリアライゼーションを経てランタイムメモリに至るゲームデータの流れを管理するミドルウェアだ。スキーマ定義、バイナリシリアライゼーション、アセット参照、そしてデザイナーがC++エンジンコードを再コンパイルせずにイテレーションできるようにするデータパイプラインを処理する。
これはおもちゃではない。RE EngineはNintendo Switchハードウェア上で60fpsで動作しながら、何千もの固有データ型——武器ステータス、AIビヘイビアツリー、マテリアルパラメータ、アニメーショングラフ、クエストフラグ、ルートテーブル——を管理するゲームを支えている。これらのデータ型はすべて、ゲームプレイコードに触れる前にRE:Doxのようなシステムを通過する。
このアーキテクチャが実際に何をするのか、なぜカプコンがこれらの特定の設計判断を下したのか、そして同じパターンを自分のエンジンやプロジェクトにどう適用できるのかを分解してみよう。
なぜAAAスタジオはデータエンジンを構築するのか(そしてあなたもそうすべき理由)
ほとんどのインディーおよび中規模プロジェクトは、次の3つのアプローチのいずれかでゲームデータを管理している:
- ScriptableObjectsまたはResources(Unity):デザイナーが編集可能だが、シリアライゼーションは不透明で、バージョニングは苦痛で、相互参照はスケール時に循環依存の悪夢を生む。
- カスタムローダーを使った生のJSON/CSV:柔軟だが壊れやすい。スキーマ検証がなく、エディタ統合もなく、新しいデータ型ごとに手動パースが必要で、コンパイル時の安全性はゼロ。
- ハードコードされたデータテーブル:ランタイムは高速だが、メンテナンスは不可能。武器のダメージ値を変更するのに再コンパイルが必要。
各アプローチは異なるスケールポイントで破綻する。ScriptableObjectsは、Unityのシリアライゼーションオーバーヘッドが参照深度に比例して増加するため、約2,000〜5,000の固有アセットあたりで詰まり始める。JSONローダーは、2人のデザイナーが同じファイルを編集し、4時間のQAセッション後にマージ競合を発見した瞬間に壊れる。ハードコードされたテーブルは、チームにデザイナーが存在した瞬間に壊れる。
RE:Doxのようなデータエンジンは根本的な問題を解決する:データスキーマをランタイムのメモリレイアウトから切り離し、両方を自動的に同期させ続けること。
これが実際に何を意味するか説明しよう。典型的なUnityプロジェクトで、武器ステータスに新しいフィールドを追加したい場合、あなたは:
- C#クラスにフィールドを追加する
- Unityのシリアライザが正しく拾うことを願う
- 既存のすべてのScriptableObjectインスタンスを手動で更新する(またはマイグレーションスクリプトを書く)
- アセットバンドルを再ビルドする
- シリアライゼーションのラウンドトリップが既存データを破損しないことをテストする
RE:Doxのようなデータエンジンを使う場合、ワークフローは:
- スキーマ定義ファイルにフィールドを定義する
- エンジンがシリアライゼーションコード、エディタUI、マイグレーションパスを自動生成する
- 既存データは適切なデフォルト値で新しいフィールドを獲得する
- 無関係なアセットの再ビルドは不要
この違いは、30人のコンテンツチームが847の固有データ型と40,000のアセットインスタンスを持つまで小さく聞こえる。
RE:Doxのアーキテクチャ:技術的解説
RE:Doxは完全にC#で書かれており、あらゆる.NETベースのゲームプロジェクトに統合できるスタンドアロンライブラリとして動作する。Unityや特定のエンジンに縛られていない——カプコンは自社のカスタムRE Engineツールチェーンで動作するように設計したため、抽象化は必然的にエンジン非依存になっている。
コアアーキテクチャには4つのレイヤーがある:
レイヤー1:スキーマ定義
RE:Doxのすべてのデータ型はスキーマ定義から始まる。ランタイム表現とシリアライゼーション形式が絡み合うC#クラスとは異なり、RE:Doxのスキーマは独立して宣言される。
Protocol BuffersやFlatBuffersのようなものだと考えてほしい——ニュートラルな形式でデータの構造を定義し、システムがそれを扱うコードを生成する。武器データ型の簡略化されたスキーマ定義は次のようになる:
// Schema definition — this is NOT your runtime class
// RE:Dox uses a declarative format to describe data structures
public class WeaponSchema : DataSchema
{
public override string TypeName => "WeaponData";
public override int Version => 3; // bump when fields change
protected override void DefineFields(SchemaBuilder builder)
{
builder.String("Id", flags: FieldFlags.PrimaryKey);
builder.String("DisplayName");
builder.Int32("BaseDamage", defaultValue: 10);
builder.Float("AttackSpeed", defaultValue: 1.0f);
builder.Float("CritMultiplier", defaultValue: 1.5f);
builder.Int32("MaxDurability", defaultValue: 100);
builder.Enum<DamageType>("DamageType");
builder.Array<string>("Tags");
builder.Reference("UpgradePath", "WeaponUpgradeTree");
}
// Migration from version 2 → 3: added CritMultiplier
protected override void MigrateV2ToV3(DataRecord record)
{
record.SetFloat("CritMultiplier", 1.5f);
}
}
ここで注目すべき3つのポイント:
バージョントラッキングが組み込まれている。 すべてのスキーマには整数バージョンがある。スキーマを変更するときは、古いレコードを新しい形式に変換するマイグレーション関数を書く。これは編集時ではなくロード時に実行されるため、既存のデータファイルを手動で更新する必要はない。カプコンのRE Engineは2017年から本番稼働している——複数の出荷タイトルにわたる8年間のスキーマ進化だ。自動マイグレーションがなければ、データメンテナンスコストだけでチーム全体を消費していただろう。
フィールド定義は宣言的である。 プロパティを書くのではなく、データが何であるかを記述し、システムがアクセサコードを生成する。つまり、シリアライゼーションレイヤーはリフレクションなしで各フィールドを正確にパック/アンパックする方法を知っている。これが次のレイヤーにつながる。
参照は明示的である。 Referenceフィールド型は、別のデータレコードへの型付きリンクを作成する。UnityのSerializeReferenceや生のオブジェクト参照とは異なり、これはスキーマロード時に検証される。ターゲットレコードが存在しない場合、出荷ビルドのランタイムでヌル参照が発生するのではなく、エディタでエラーが発生する。
レイヤー2:コード生成とアクセサ
スキーマが定義されると、RE:Doxは強く型付けされたアクセサ構造体を生成する。これはパフォーマンスにとって重要だ——生成されたコードはボクシングを回避し、リフレクションを回避し、JITコンパイラによる積極的な最適化やUnityコンテキストでのBurstコンパイルが可能になる。
武器スキーマの生成アクセサは次のようになる:
// Generated by RE:Dox — do not edit manually
public readonly ref struct WeaponDataAccessor
{
private readonly DataRecord _record;
public WeaponDataAccessor(DataRecord record)
{
_record = record;
}
public string Id => _record.GetString(0); // field index 0
public string DisplayName => _record.GetString(1); // field index 1
public int BaseDamage => _record.GetInt32(2); // field index 2
public float AttackSpeed => _record.GetFloat(3); // field index 3
public float CritMultiplier => _record.GetFloat(4); // field index 4
public int MaxDurability => _record.GetInt32(5); // field index 5
public DamageType DamageType => (DamageType)_record.GetEnum(6);
public ArraySegment<string> Tags => _record.GetStringArray(7);
// Reference returns a lazy-loaded record handle
public DataHandle UpgradePath => _record.GetReference(8);
}
重要な設計判断:フィールドは名前ではなくインデックスでアクセスされる。 スキーマはロード時にフィールド名をインデックスにマップし、すべてのランタイムアクセスは整数インデックスを使用する。つまり、プロパティアクセスはフラットなバイトバッファへの単純なオフセット計算にコンパイルされる——ハッシュルックアップも辞書プローブも文字列比較もない。
『モンスターハンターライズ』のように数百の武器バリエーションがあり、ダメージ計算中に各武器が1フレームあたり数十回アクセスされるゲームでは、O(1)インデックスアクセスとO(log n)辞書ルックアップのこの差は、フレーム予算を達成するか否かの差だ。
レイヤー3:バイナリシリアライゼーション
RE:Doxはシリアライズデータにカラム型バイナリ形式を使用する。これがほとんどのカスタムソリューションが間違える部分だ——各レコードを個別にシリアライズする(行指向)と、小規模データセットでは問題ないが、起動時に50,000レコードをロードする必要がある場合に崩壊する。
カラム型形式は、単一フィールドのすべての値をメモリ内で連続して格納する:
Row-oriented: [id_1, dmg_1, spd_1, crit_1] [id_2, dmg_2, spd_2, crit_2] ...
Columnar: [id_1, id_2, id_3, ...] [dmg_1, dmg_2, dmg_3, ...] [spd_1, spd_2, spd_3, ...]
なぜこれがゲームにとって重要なのか? 3つの理由がある:
キャッシュ局所性。 戦闘システムがDPSランキングを計算するためにすべての武器ダメージ値を反復処理する必要がある場合、カラム型レイアウトはそれらの値がメモリ内で互いに隣接してパックされていることを意味する。CPUプリフェッチャが自動的にL1キャッシュに取り込む。行指向レイアウトでは、レコードごとに無関係なフィールド(ID、名前、タグ)を飛び越えることになり、キャッシュミスが発生する。現代のハードウェアでは、L1キャッシュヒットは約1ns、メインメモリフェッチは約100nsかかる。それを50,000レコードで掛け算すると、50µsと5msの差になる。
圧縮。 カラム型データは、隣接する値が類似する傾向があるため(デルタエンコーディング、ランレングスエンコーディング)、劇的に圧縮率が向上する。カプコンのRE Engineアセットは『バイオハザード ヴィレッジ』で合計約27GB——データエンジンのシリアライゼーション形式は、16GBシステムRAMのハードウェアでロード済みメモリフットプリントを管理可能に保つのに役立っている。
選択的ロード。 UIツールチップに武器名とダメージ値だけが必要な場合、カラム型形式ならタグ、アップグレードパス、アニメーション参照を完全にスキップできる。データエンジンはリクエストしたカラムだけを読み取ることができ、I/O時間とメモリ割り当ての両方を削減する。
レイヤー4:エディタ統合
4番目のレイヤー——そしておそらくコンテンツチームに最も価値を提供するレイヤー——はエディタ統合だ。スキーマが宣言的であるため、RE:DoxはエディタUIを自動生成できる:
- 新しいデータ型を定義した? コード変更なしでエディタパネルが表示される。
- フィールドに
defaultValueがある? エディタが事前入力し、オーバーライドをハイライトする。 - フィールドが別のスキーマへの
Reference? エディタは有効なターゲットで埋められた検索可能なドロップダウンを提供する。 - スキーマバージョンが上がった? エディタは変更を適用する前にマイグレーションプレビューを表示する。
これがデータエンジンを構築する価値がある理由のパターンだ。スキーマ定義とマイグレーションコードを書く初期コストは、デザイナーがコードに触れたり、再コンパイルを待ったり、シリアライゼーションエラーのデバッグをエンジニアに依頼したりすることなく、ゲームデータを作成・編集・検証できるようになると、何百倍にもなって報われる。
C#で最小限のデータエンジンを構築する方法
RE:Doxの完全なアーキテクチャを再現する必要はない。ここでは、最も影響力のある3つのコンセプト——宣言的スキーマ、インデックス付きフィールドアクセス、自動マイグレーション——を実装した簡素化されたデータエンジンを示す。
using System;
using System.Collections.Generic;
// === Core Data Record (flat byte storage) ===
public class DataRecord
{
private readonly byte[] _data;
private readonly Schema _schema;
public DataRecord(Schema schema, byte[] data = null)
{
_schema = schema;
_data = data ?? new byte[schema.TotalSize];
}
public int GetInt32(string fieldName)
{
int offset = _schema.GetOffset(fieldName);
return BitConverter.ToInt32(_data, offset);
}
public void SetInt32(string fieldName, int value)
{
int offset = _schema.GetOffset(fieldName);
BitConverter.TryWriteBytes(_data.AsSpan(offset), value);
}
public float GetFloat(string fieldName)
{
int offset = _schema.GetOffset(fieldName);
return BitConverter.ToSingle(_data, offset);
}
public void SetFloat(string fieldName, float value)
{
int offset = _schema.GetOffset(fieldName);
BitConverter.TryWriteBytes(_data.AsSpan(offset), value);
}
public byte[] Serialize() => _data;
}
// === Schema Definition ===
public class Schema
{
private readonly Dictionary<string, FieldInfo> _fields = new();
private int _currentOffset = 0;
public int TotalSize => _currentOffset;
public int Version { get; set; } = 1;
public void Int32(string name, int defaultValue = 0)
{
_fields[name] = new FieldInfo(_currentOffset, defaultValue);
_currentOffset += 4;
}
public void Float(string name, float defaultValue = 0f)
{
_fields[name] = new FieldInfo(_currentOffset, defaultValue);
_currentOffset += 4;
}
public int GetOffset(string fieldName) =>
_fields.TryGetValue(fieldName, out var fi)
? fi.Offset
: throw new KeyNotFoundException($"Field '{fieldName}' not in schema.");
public bool HasField(string name) => _fields.ContainsKey(name);
private readonly record struct FieldInfo(int Offset, object DefaultValue);
}
// === Migratable Data Store ===
public class DataStore
{
private readonly Dictionary<string, DataRecord> _records = new();
private readonly Schema _schema;
private readonly Dictionary<int, Action<DataRecord>> _migrations = new();
public DataStore(Schema schema)
{
_schema = schema;
}
public void RegisterMigration(int fromVersion, Action<DataRecord> migrate)
{
_migrations[fromVersion] = migrate;
}
public void Insert(string id, DataRecord record, int recordVersion)
{
// Apply migrations in order
for (int v = recordVersion; v < _schema.Version; v++)
{
if (_migrations.TryGetValue(v, out var migrate))
migrate(record);
}
_records[id] = record;
}
public DataRecord Get(string id) => _records.GetValueOrDefault(id);
}
// === Usage Example ===
public static class WeaponDataExample
{
public static void Run()
{
// Define schema
var schema = new Schema();
schema.Version = 2;
schema.Int32("BaseDamage", defaultValue: 10);
schema.Float("AttackSpeed", defaultValue: 1.0f);
schema.Int32("MaxDurability", defaultValue: 100);
// Create store with migration from v1 → v2
var store = new DataStore(schema);
store.RegisterMigration(fromVersion: 1, record =>
{
// v1 records had no MaxDurability; set default
record.SetInt32("MaxDurability", 100);
});
// Insert a record saved under the old v1 schema
var sword = new DataRecord(schema);
sword.SetInt32("BaseDamage", 25);
sword.SetFloat("AttackSpeed", 1.3f);
store.Insert("iron_sword", sword, recordVersion: 1);
// Migration applied: MaxDurability now defaults to 100
var loaded = store.Get("iron_sword");
Console.WriteLine($"Damage: {loaded.GetInt32("BaseDamage")}"); // 25
Console.WriteLine($"Durability: {loaded.GetInt32("MaxDurability")}"); // 100 (migrated)
}
}
これはおよそ100行で、インデックス付きフィールドアクセス、フラットなバイナリストレージ、バージョン付きマイグレーションを提供する。RE:Doxの代わりにはならないが、データエンジンが機能する中核パターン——データが何であるかと、それがどう保存されるかを分離し、その定義に加えたすべての変更を追跡すること——を示している。
あなたのゲームのデータアーキテクチャへの教訓
1. スキーマと表現を分離する
RE:Doxから得られる最も影響力のあるパターンは、データ定義をランタイムクラスから切り離すことだ。ゲームプレイコードは便利で強く型付けされたアクセサで動作するべきだ。ストレージ形式はコンパクトでバージョン管理され、それらのアクセサから独立しているべきだ。
武器クラスが変更されても、セーブファイルは壊れるべきではない。セーブ形式が進化しても、武器クラスはリファクタリングを必要とするべきではない。これらは明示的なマイグレーションレイヤーによって接続された2つの別々の関心事だ。
2. すべてをバージョン管理し、自動的にマイグレーションする
出荷されたすべてのゲームはスキーマ負債を蓄積する。RE Engineは2017年から少なくとも12のメジャータイトルで稼働している——8年間で数百のスキーママイグレーションだ。カプコンがマイグレーション関数をスキーマ定義に直接埋め込むアプローチは、すべてのデータファイルがバージョン番号を保持し、エンジンが古いファイルをロード時に現在の形式にアップグレードできることを意味する。
初日からデータスキーマのバージョニングを始めよう。続編を出荷しなくても、Early Accessビルドは1.0ローンチを生き残る必要がある古い形式のセーブファイルを生成する。
3. ホットパスデータにはインデックス付きアクセスを使用する
データフィールドがフレームごとのループ内で読み取られる場合——ダメージ値、アニメーションタイミング、AIウェイト——名前やハッシュではなくインデックスでアクセスするべきだ。差はアクセスごとには小さいが、集計では大きなものになる。200のアクティブ武器を60fpsで反復処理する戦闘システムは、毎秒12,000回のフィールド読み取りを意味する。インデックス付きアクセスは読み取りあたり約1ns(直接メモリオフセット)で完了する。辞書ルックアップは読み取りあたり約50〜200nsかかる。
ロード時に一度だけ読み取られるデータ(表示名、説明文、ロアテキスト)には、インデックス付きアクセスは重要ではない。最適化する前にプロファイリングしよう。ただし、必要なときに使えるパターンが存在することを知っておいてほしい。
4. 大規模データセットにはカラム型ストレージ
ゲームに単一データ型のレコードが1,000件以上ある場合、カラム型ストレージレイアウトを検討しよう。キャッシュ局所性の利点は現実的で測定可能だ——同様のアーキテクチャのベンチマークでは、集計クエリ(合計、フィルタ、ソート)の反復速度が行指向ストレージ比で3〜8倍向上する。
これはルートテーブル、インベントリカタログ、ダイアログツリー、クエストデータベース——多くのレコードを反復処理して何かを見つけたり計算したりするあらゆるものに適用される。
5. スキーマからエディタUIを生成する
データエンジンの生産性倍率はランタイムパフォーマンスではなく、デザイナーのイテレーション速度だ。スキーマが宣言的であれば、エディタインターフェースを自動生成できる。これにより、デザイナーとエンジニアの摩擦の最も一般的な原因——「あの項目にフィールドを追加してもらえますか?」——が排除される。
スキーマ駆動アプローチでは、フィールドの追加は1行のスキーマ変更であり、すべてのエディタパネルに自動的に反映される。C#の修正も、カスタムプロパティドロワーも、インスペクタスクリプトも不要だ。2人以上のチーム向けにツールを構築しているなら、これだけで投資を正当化できる。
バックエンドシステムの位置付け
RE:Doxはエンジン側のデータパイプライン——データが定義され、シリアライズされ、ランタイムメモリにロードされる方法——を処理する。しかし、現代のゲームはプレイヤー固有のデータも管理する必要がある:デバイス間で同期されるセーブファイル、ゲームプレイテレメトリによって供給されるリーダーボード、パッチを出荷せずにドロップ率を調整できるリモート設定。
そのバックエンドレイヤーを自分で構築する場合、ユーザー認証フロー、並行書き込みの競合解決、クラウドセーブのリビジョントラッキング、チームが実際に使えるダッシュボードが必要になる。ゲームプレイ機能を1つ書く前に、数週間のインフラ作業だ。
horizOnのようなサービスは、これらの特定のバックエンド関心事——リビジョン認識の読み書きを備えたアカウントバインドのクラウドセーブ、リーダーボード、リモート設定、クラッシュレポート——を処理するので、プレイヤー体験に直接影響するエンジン側のデータアーキテクチャにエンジニアリング時間を集中できる。
現在のプロジェクトにこれらのパターンを適用する
完全なデータエンジンを構築する必要はない。RE:Doxのアーキテクチャから利益を得るには、次の具体的なステップから始めよう:
現在のデータロードを監査する。 起動時間がどれくらいかかり、どこに時間が使われているかをプロファイリングしよう。ロード時間の15%以上がデータのデシリアライゼーションと検証に費やされているなら、最適化の余地がある。
最もホットなデータパスを特定する。 プロファイラを使って、ゲームプレイ中に最も頻繁にアクセスされるデータ構造を見つけよう。それらがインデックス付きアクセスとカラム型ストレージの候補だ。
マイグレーションフレームワークを書く。 セーブファイルの単純なバージョン番号とマイグレーション関数の辞書だけで、価値の90%が得られる。完全なスキーマシステムはマイグレーションの利点を得るために必要ない。
データ定義を分離する。 武器ステータスがUnityがディスクにシリアライズする同じC#クラスに定義されているなら、それが結合ポイントだ。フィールド定義を別のバージョン管理されたスキーマに抽出しよう——検証対象のJSONスキーマファイルだけでも構わない。
変更前後をベンチマークする。 変更を加える前に、ロード時間、メモリ使用量、ホットパスの反復速度を測定しよう。データアーキテクチャの改善は時間とともに複利で効くが、投資が報われていることを証明するにはベースラインが必要だ。
より大きな視点:なぜオープンソース化が重要なのか
カプコンがRE:Doxをオープンソース化したことは、コード自体を超えて重要だ。AAAスタジオは歴史的にエンジン技術を独自の優位性として扱ってきた。カプコンがこのようなコンポーネントを共有するとき、彼らはデータエンジンレイヤーが競争上の堀ではない——コンテンツ、ゲームデザイン、アートディレクションこそがそれだ——というシグナルを送っている。
インディーおよび中規模デベロッパーにとって、これは業界で最も成功したゲームエンジンの1つの背後にあるパターンとアーキテクチャ決定が、今や直接検査可能であることを意味する。カプコンがスキーママイグレーションをどう処理しているかを読み、シリアライゼーションの選択を研究し、それらの教訓を自分のプロジェクトに適用できる。
コード自体はUnityやGodotプロジェクトに直接ドロップできないかもしれないが、アーキテクチャパターン——宣言的スキーマ、インデックス付きアクセス、カラム型ストレージ、自動マイグレーション、生成エディタUI——は普遍的だ。エンジン、言語、スケールに関係なく機能する。
1つのパターンから始めよう。データをバージョン管理しよう。そこから構築しよう。未来の自分——そしてデザイナー——が感謝するだろう。