档案室:Archetype 与两种存储
本书欠了两笔旧账。第 1 章:“同类组件在内存中连续存储……细节在第 11 章展开”;第 3 章:“World 把组件组合完全相同的实体放在同一张子表里……存储细节在第 11 章”。现在跟艾达进档案室,一次结清。
一种组合,一册档案
那个“子表”的正式名字是 Archetype(原型):组件组合完全相同的实体归同一册。册数有多少,World 直接报:
println!("空世界的档案册数:{}", world.archetypes().len());
let roland = world.spawn((Name::new("罗兰"), Resident)).id();
world.spawn((Name::new("老蔫儿"), Resident));
println!("住进两户(组件组合相同):{} 册", world.archetypes().len());
world.spawn((Name::new("杂货铺老板"), Resident, Shop));
println!("再住进一户(多一个组件):{} 册", world.archetypes().len());Listing 11-7(其一):archetypes().len()——眼看着册数涨
空世界的档案册数:1
住进两户(组件组合相同):2 册
再住进一户(多一个组件):3 册空世界自带一册(编号 ArchetypeId(0),空组合专用),罗兰和老蔫儿组合相同、合用一册,多带一个 Shop 的杂货铺老板另开一册。查询的匹配单位就是册:Query<&Name, With<Resident>> 先筛出“含 Name 和 Resident 的册”,再顺着册扫——这就是第 3 章“遍历按子表逐张扫、册间次序是引擎内务”的全部背景,也是“永远不要依赖遍历顺序”的根源。
每册档案的数据默认放在一张 Table(表)里,列式存储:同一种组件排成连续一列,一行对应一个实体。查询扫表就是顺着列走,内存连续、缓存命中率高——第 1 章承诺的“高性能的主要来源”,机制就这么朴素。
搬家
列式存储有个直接推论:实体的组件组合一变,它就不再属于原来那册——整行数据得搬进新册的表里。亲眼看。EntityLocation 记录每个实体“在哪册、哪张表”:
let before = world.entities().get_spawned(roland).unwrap();
world.entity_mut(roland).insert(Registered); // 盖章:组件组合变了
let after = world.entities().get_spawned(roland).unwrap();
println!(
"盖章(Table 组件):archetype {:?} → {:?},table {:?} → {:?}",
before.archetype_id, after.archetype_id, before.table_id, after.table_id,
);
let before = after;
world.entity_mut(roland).insert(Flagged); // 贴记号:SparseSet 组件
let after = world.entities().get_spawned(roland).unwrap();
println!(
"贴记号(SparseSet 组件):archetype {:?} → {:?},table {:?} → {:?}",
before.archetype_id, after.archetype_id, before.table_id, after.table_id,
);Listing 11-7(其二):insert 一次,搬一次家
盖章(Table 组件):archetype ArchetypeId(1) → ArchetypeId(3),table TableId(1) → TableId(3)
贴记号(SparseSet 组件):archetype ArchetypeId(3) → ArchetypeId(4),table TableId(3) → TableId(3)盖章(insert 一个普通组件)让罗兰从 ArchetypeId(1) 搬进了新开的 ArchetypeId(3),table 跟着换——这就是“搬家”:旧表里他那行被抽走(表尾一行顶上来补位,行号重排),新表添一行。现在上一节的悬案可以结了:为什么多户同借时只给降档的 EntityMut——任何一户 insert 都可能引发整表行号重排,另一只手里的句柄立刻作废。也顺手解释了 world.query() 为什么要 &mut World:首次见到新组合要登记册目。
第二行输出更有意思:贴记号换了 archetype(身份毕竟变了),table 却纹丝不动。因为 Flagged 声明了另一种存储:
/// 盘点期间的临时记号:频繁贴撕,声明用 SparseSet 存储
#[derive(Component)]
#[component(storage = "SparseSet")]
struct Flagged;Listing 11-7(其三):声明 SparseSet 存储——不进表的组件
SparseSet(稀疏集)存储的组件不进 Table,单独放在旁边一个“实体 → 值”的柜子里。插拔它不搬数据行,代价是查询扫到它时要按实体逐个去柜子里查,没有连续内存可言。取舍一句话:
| Table(默认) | SparseSet | |
|---|---|---|
| 遍历 | 快(列式连续) | 慢(逐实体查柜) |
| insert / remove | 慢(搬家) | 快(不搬家) |
| 适合 | 绝大多数组件 | 频繁穿脱的临时标记 |
拿不准就用默认值;等性能分析真指向“某个标记每帧插拔几千次”,再把那一个组件改成 SparseSet。
翻档案:inspect_entity
检查器的核心一招藏在档案室:不知道实体身上有什么,问 World 要它的档案页——
print!("罗兰户的档案页:");
for info in world.inspect_entity(roland).unwrap() {
print!("[{}] ", info.name().shortname());
}
println!();Listing 11-7(其四):inspect_entity——逐项报出组件名
罗兰户的档案页:[Name] [Resident] [Registered] [Flagged]inspect_entity 给出一串 ComponentInfo,name() 报类型名(能看到人话全靠 Cargo.toml 里开的 debug feature,否则只剩匿名编号),.shortname() 去掉模块前缀。EntityRef::contains 是“点名问有没有”,inspect_entity 是“有什么全报上来”——终章的存档就靠它。
档案室到此参观完毕。回到地面:独占系统好用,但写起来满手 world.query_filtered::<...>(),柜台时代的舒服姿势全没了。下一节把姿势找回来。