卡普空开源 RE:Dox — 这是你今天就能借鉴的数据引擎架构
概要
了解卡普空开源的 RE:Dox 数据引擎架构,学习如何用声明式 schema、索引字段访问、列式存储和自动迁移模式,优化游戏数据管线并提升设计师迭代速度,掌握数据定义、序列化与运行时内存解耦的核心设计,并将这些架构模式应用到你的 Unity、Godot 或自定义引擎的游戏项目中。
你的实体系统在启动时加载 14,000 个资源引用,设计师们迫切要求更快的迭代周期,而每次热重载都要 45 秒,因为你的数据管线将序列化与运行时内存布局耦合在一起。听起来很熟悉?卡普空刚刚开源了 RE Engine 中恰好解决这个问题的组件——这套架构值得借鉴。
RE:Dox 是卡普空的 C# 数据引擎,这款中间件负责管理游戏数据如何从编辑器工具经序列化流入运行时内存,服务于《Resident Evil Village》《Street Fighter 6》《Monster Hunter Rise》等作品。它处理 schema 定义、二进制序列化、资源引用,以及让设计师无需重新编译 C++ 引擎代码即可迭代的数据管线。
这不是玩具。RE Engine 驱动着在 Nintendo Switch 硬件上以 60fps 运行的游戏,同时管理数千种独特的数据类型——武器数值、AI 行为树、材质参数、动画图、任务标记、战利品表。每一种数据类型在触及游戏玩法代码之前,都要流经像 RE:Dox 这样的系统。
下面我们来拆解这套架构的实际作用、卡普空做出这些特定设计决策的原因,以及你如何将同样的模式应用到自己的引擎或项目中。
为什么 3A 工作室要构建数据引擎(以及你为什么也应该这样做)
大多数独立和中型项目通过以下三种方式之一管理游戏数据:
- ScriptableObjects 或 Resources(Unity):设计师可编辑,但序列化不透明,版本管理痛苦,交叉引用在规模扩大时会制造循环依赖噩梦。
- 原始 JSON/CSV 加自定义加载器:灵活但脆弱。没有 schema 校验,没有编辑器集成,每种新数据类型都要手动解析,而且完全没有编译期安全性。
- 硬编码数据表:运行时快,但无法维护。修改武器伤害值需要重新编译。
每种方法都会在不同的规模节点崩溃。ScriptableObjects 在 2,000–5,000 个独特资源时开始卡顿,因为 Unity 的序列化开销随引用深度线性增长。JSON 加载器会在两个设计师编辑同一文件时崩溃,而你在四个小时的 QA 会话后才发现合并冲突。硬编码表则会在你的团队中出现设计师的那一刻崩溃。
像 RE:Dox 这样的数据引擎解决了根本问题:将数据 schema 与运行时内存布局解耦,同时自动保持两者同步。
下面说明这在实际中的含义。在一个典型的 Unity 项目中,如果你想给武器数值添加一个新字段,你需要:
- 将字段添加到 C# 类中
- 希望 Unity 的序列化器能正确识别
- 手动更新每个现有的 ScriptableObject 实例(或编写迁移脚本)
- 重新构建 asset bundles
- 测试序列化往返不会破坏现有数据
使用像 RE:Dox 这样的数据引擎,工作流是:
- 在 schema 定义文件中定义字段
- 引擎自动生成序列化代码、编辑器 UI 和迁移路径
- 现有数据以合理的默认值获得新字段
- 无需重新构建无关资源
这个差异听起来很小,直到你拥有 847 种独特数据类型和 40,000 个资源实例,并面对一个 30 人的内容团队。
RE:Dox 架构:技术拆解
RE:Dox 完全用 C# 编写,作为一个独立库运行,可以集成到任何基于 .NET 的游戏项目中。它不绑定 Unity 或任何特定引擎——卡普空设计它用于配合自家的 RE Engine 工具链,这意味着这些抽象在本质上是与引擎无关的。
核心架构分为四层:
第一层:Schema 定义
RE:Dox 中的每种数据类型都始于 schema 定义。与 C# 类中运行时表示和序列化格式纠缠在一起不同,RE:Dox 中的 schema 是独立声明的。
可以把它想象成 Protocol Buffers 或 FlatBuffers——你用中立的格式定义数据的样子,系统生成处理它的代码。以下是一个简化后的武器数据类型 schema 定义示例:
// 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);
}
}
这里有三点值得注意:
内置版本跟踪。 每个 schema 都有一个整数版本。当你修改 schema 时,需要编写一个迁移函数,将旧记录转换为新格式。这会在加载时运行,而不是编辑时,因此现有数据文件永远不需要手动更新。卡普空的 RE Engine 自 2017 年起投入生产——这是跨越多个已发售作品的八年 schema 演进。如果没有自动迁移,仅数据维护成本就足以耗尽整个团队。
字段定义是声明式的。 你不需要编写属性——你描述数据是什么,系统生成访问器代码。这意味着序列化层无需反射就能精确地打包和解包每个字段,这引出了下一层。
引用是显式的。 Reference 字段类型会创建到另一个数据记录的强类型链接。与 Unity 的 SerializeReference 或原始对象引用不同,它会在 schema 加载时进行验证。如果目标记录不存在,你会在编辑器中看到错误,而不是在已发布构建的运行时出现空引用。
第二层:代码生成与访问器
一旦定义了 schema,RE:Dox 就会生成强类型访问器结构体。这对性能至关重要——生成的代码避免了装箱、避免了反射,并且可以被 JIT 编译器积极优化,或在 Unity 环境中进行 burst 编译。
以下是我们武器 schema 的生成访问器可能的样子:
// 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);
}
关键设计选择:字段通过索引访问,而不是名称。 schema 在加载时将字段名映射到索引,所有运行时访问都使用整数索引。这意味着属性访问会编译为对扁平字节缓冲区的简单偏移计算——没有哈希查找、没有字典探测、没有字符串比较。
对于像《Monster Hunter Rise》这样拥有数百种武器变体、每种变体在伤害计算中每帧被访问数十次的游戏,O(1) 索引访问与 O(log n) 字典查找之间的差异,就是能否达成帧预算的分水岭。
第三层:二进制序列化
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, ...]
为什么这对游戏很重要?有三个原因:
缓存局部性。 当你的战斗系统需要遍历所有武器伤害值来计算 DPS 排名时,列式布局意味着这些值在内存中紧密排列。CPU 预取器会自动将它们拉入 L1 缓存。而使用行式布局,你需要在每条记录中跳过无关字段(ID、名称、标签),导致缓存未命中。在现代硬件上,一次 L1 缓存命中约需 1ns;一次主内存读取约需 100ns。乘以 50,000 条记录,你看到的就是 50µs 与 5ms 的差距。
压缩。 列式数据的压缩效果显著更好,因为相邻值往往相似(增量编码、游程编码)。卡普空《Resident Evil Village》的 RE Engine 资源总计约 27GB——数据引擎的序列化格式有助于在 16GB 系统内存的硬件上保持可控的加载内存占用。
选择性加载。 只需要武器名称和伤害值来显示 UI 提示?列式格式可以让你完全跳过加载标签、升级路径和动画引用。数据引擎可以只读取你请求的列,从而减少 I/O 时间和内存分配。
第四层:编辑器集成
第四层——也可以说是对内容团队最有价值的一层——是编辑器集成。由于 schema 是声明式的,RE:Dox 可以自动生成编辑器 UI:
- 定义了新数据类型?编辑器面板无需代码更改即可出现。
- 字段有
defaultValue?编辑器会预填并高亮覆盖项。 - 字段是对另一个 schema 的
Reference?编辑器会提供可搜索的下拉列表,其中填充了有效目标。 - schema 版本升级?编辑器会在应用更改前显示迁移预览。
正是这种模式让数据引擎值得构建。定义 schema 和编写迁移代码的前期成本,会在设计师无需接触代码、无需等待重新编译、无需找工程师调试序列化错误就能创建、编辑和验证游戏数据时,获得数百倍的回报。
如何用 C# 构建一个最小数据引擎
你不需要复制 RE:Dox 的完整架构就能从这些模式中获益。下面是一个精简版数据引擎,实现了三个最有影响力的概念:声明式 schema、索引字段访问和自动迁移。
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. 将 Schema 与表示分离
RE:Dox 中最有影响力的模式是将数据定义与运行时类解耦。你的游戏玩法代码应该使用方便、强类型的访问器。你的存储格式应该紧凑、带版本,并且独立于这些访问器。
如果你的武器类发生变化,存档文件不应损坏。如果你的存档格式演进,武器类也不应需要重构。这是两个独立关注点,通过显式迁移层连接。
2. 为一切添加版本,自动迁移
每款已发售游戏都会积累 schema 债务。RE Engine 自 2017 年以来已在至少 12 款主要作品中运行——这是八年中数百次 schema 迁移。卡普空将迁移函数直接嵌入 schema 定义的做法,意味着每个数据文件都带有自己的版本号,引擎可以在加载时将任何旧文件升级为当前格式。
从第一天开始就为数据 schema 添加版本。即使你永远不会推出续作,你的 Early Access 版本也会生成旧格式的存档文件,这些文件需要能存活到 1.0 正式发布。
3. 对热路径数据使用索引访问
如果某个数据字段在每帧循环中被读取——伤害值、动画时序、AI 权重——它应该通过索引访问,而不是名称或哈希。单次访问的差异很小,但累计起来非常巨大。一个战斗系统在 60fps 下迭代 200 种活跃武器,意味着每秒 12,000 次字段读取。索引访问每次读取约 1ns(直接内存偏移);字典查找每次读取约 50-200ns。
对于加载时只读取一次的数据(显示名称、描述、背景文本),索引访问并不重要。在优化前先进行性能分析,但要知道这种模式在你需要时是存在的。
4. 大数据集使用列式存储
如果你的游戏有超过 1,000 条任何单一数据类型的记录,请考虑列式存储布局。缓存局部性的好处是真实且可衡量的——类似架构的基准测试显示,在聚合查询(求和、过滤、排序)中,列式存储的迭代速度比行式存储提升 3-8 倍。
这适用于战利品表、物品目录、对话树、任务数据库——任何你可能需要遍历大量记录来查找或计算某些内容的场景。
5. 从 Schema 生成编辑器 UI
数据引擎的生产力倍增器不是运行时性能——而是设计师的迭代速度。如果你的 schema 是声明式的,你就可以自动生成编辑器界面。这消除了设计师与工程师之间最常见的摩擦来源:“你能给这个东西加个字段吗?”
使用 schema 驱动的方法,添加字段只需一行 schema 更改,它会自动出现在每个编辑器面板中。无需修改 C#、无需自定义属性抽屉、无需 inspector 脚本。如果你正在为超过两个人的团队构建工具,仅这一点就足以证明投资的合理性。
后端系统的位置
RE:Dox 处理引擎端的数据管线——数据如何定义、序列化并加载到运行时内存。但现代游戏还需要管理玩家特定数据:跨设备同步的存档文件、由游戏玩法遥测驱动的排行榜、让你无需发布补丁即可调整掉率的远程配置。
如果你自己构建后端层,你将面对用户认证流程、并发写入的冲突解决、云存档的版本跟踪,以及一个你的团队真正能用的仪表盘。在编写任何游戏玩法功能之前,这就是数周的基础设施工作。
像 horizOn 这样的服务处理这些特定的后端问题——与账号绑定的云存档(支持版本感知读写)、排行榜、远程配置和崩溃报告——这样你就可以将工程时间集中在直接影响玩家体验的引擎端数据架构上。
将这些模式应用到当前项目
你不需要构建完整的数据引擎就能从 RE:Dox 架构中受益。从这些具体步骤开始:
- 审计当前的数据加载。 分析启动耗时以及时间花在哪里。如果超过 15% 的加载时间用于数据反序列化和验证,你就有优化空间。
- 识别最热的数据路径。 使用性能分析器找出游戏过程中访问最频繁的数据结构。这些是索引访问和列式存储的候选对象。
- 编写迁移框架。 即使只是在存档文件上添加一个简单的版本号,再加上一个迁移函数字典,也能获得 90% 的价值。你不需要完整的 schema 系统就能获得迁移的好处。
- 分离数据定义。 如果你的武器数值定义在 Unity 序列化到磁盘的同一个 C# 类中,那就是你的耦合点。将字段定义提取到独立的、带版本的 schema 中——即使只是一个用于校验的 JSON schema 文件。
- 在改动前后进行基准测试。 在做出更改前测量加载时间、内存使用和热路径迭代速度。数据架构的改进会随时间复利增长,但你需要一个基线来证明投资正在产生回报。
更大的图景:为什么开源在这里很重要
卡普空开源 RE:Dox 的意义超越了代码本身。3A 工作室历来将引擎技术视为专有优势。当卡普空分享这样的组件时,他们是在表明数据引擎层不是他们的竞争护城河——内容、游戏设计和美术方向才是。
对于独立和中型开发者来说,这意味着业界最成功的游戏引擎之一的模式和架构决策现在可以直接检视。你可以阅读卡普空如何处理 schema 迁移,研究他们的序列化选择,并将这些经验应用到自己的项目中。
代码本身可能无法直接放入你的 Unity 或 Godot 项目,但架构模式——声明式 schema、索引访问、列式存储、自动迁移、生成编辑器 UI——是通用的。无论引擎、语言或规模如何,它们都适用。
从一个模式开始。为数据添加版本。在此基础上构建。未来的你——以及你的设计师——会感谢你。