13.1 查询匹配与缓存命中
第 12 章把组件存进了稀疏集与 archetype,但存储只是前提。系统每帧真正要问的是同一句话:「哪些实体同时拥有 A 和 B、又不含 C?」这一节把这个问题拆成两步:先用位掩码做匹配,再把匹配结果缓存起来,只在世界真的变了时才重算。
本章与站内既有内容的分工
第 13 章是 ECS 的「查询与调度」层。站内已经有三篇文章把 ECS 的概念与 API 设计讲透了,本节不重复它们,只做从零实现与实测。
| 既有文章 | 它讲什么 | 本节只做什么 |
|---|---|---|
/game-ecs-architecture/ | 游戏项目里 ECS 的落地与选型 | 不讨论选型,给出 QueryEngine 的可编译实现 |
/game-engine-architecture-ecs/ | 用一篇文章讲清 ECS 引擎的整体骨架 | 不画骨架,只把「查询匹配 + 缓存」这一层拆出来 |
/game-engine-ecs-architecture-deep-dive/ | 用版本化 ID + 稀疏集串起整个 ECS 骨架 | 只做查询缓存命中率的实测,不碰存储层 |
一句话:既有文章回答「ECS 查询是什么、API 该怎么设计」,本节回答「从零怎么实现、缓存到底省了多少」。
用位掩码描述「实体拥有哪些组件」
匹配的本质是一次集合包含判断。把组件类型编号成 0..k-1,一个实体的组件集合就是一个 k 位掩码:位 i 置 1 表示拥有组件 i。于是「同时拥有 A、B」变成 (mask & all) === all,「不含 C」变成 (mask & none) === 0。
| 表达式 | 含义 |
|---|---|
maskOf([0, 2, 3]) | 组件集合 → 掩码 0b1101 |
(mask & q.all) === q.all | 必须全部拥有 |
(mask & q.none) === 0 | 必须一个都没有 |
掩码用一个 32 位整数就够撑 32 种组件类型;要更多类型,把掩码加宽成两个数或 BigInt 即可。本节保持单个 number,先讲清机制。查询被表示成 { all, none } 两个掩码,none 让「排除某类实体」和「包含某类实体」一样便宜——这也是 ECS 查询能表达「移动的、非静态的、有渲染组件的实体」这类组合的原因。
举一个具体例子。设组件类型编号为 transform=0、velocity=1、static=2,某实体掩码是 0b011(有 transform、velocity,无 static)。查询「要 transform、要 velocity、不要 static」表示为 all = 0b011、none = 0b100:
| 检查 | 计算 | 结果 |
|---|---|---|
全有 all | 0b011 & 0b011 === 0b011 | 通过 |
全无 none | 0b011 & 0b100 === 0 | 通过 |
| 匹配 | 两项都通过 | true |
同一套位运算对任何组件组合都成立,不需要按组件类型写分支,也不需要遍历组件的集合对象——这正是掩码比 Set 快的根本原因:一次按位与就是一次匹配。
缓存与失效:一个 revision 管全部
无缓存查询每次都要扫过全部实体,实体一多、查询一多,成本就是 O(实体数 × 查询数)。缓存把重复查询压成一次 O(1) 命中。
失效策略选了最保守的一种:任何一次组件增删都把全局 revision 加一,于是所有缓存条目同时失效。它比「按实体精确失效」更粗,但正确性一眼可证——不会出现「改了组件却读到旧结果」。下面就是完整实现,层级 L1,Node 直跑。
// file: src/core/query-cache.ts
// tier: L1
// verify: npx vitest run src/core/query-cache.test.ts
//
// ECS 查询匹配与结果缓存:实体用位掩码表示「拥有哪些组件」,
// 查询用 all / none 两个掩码描述「必须都有 / 必须都没有」。
// 缓存按查询键存结果,用一个全局 revision 判断是否失效。
export type EntityId = number;
/** 把组件类型 id 列表压成一个位掩码;位 i 置位表示拥有组件 i。 */
export function maskOf(types: readonly number[]): number {
let m = 0;
for (const t of types) m |= 1 << t;
return m >>> 0;
}
export interface Query {
/** 必须全部拥有的组件掩码。 */
all: number;
/** 必须一个都不拥有的组件掩码。 */
none: number;
}
export function makeQuery(all: readonly number[], none: readonly number[] = []): Query {
return { all: maskOf(all), none: maskOf(none) };
}
/** 单个实体是否满足查询:all 全置位、none 全清位。 */
export function matches(mask: number, q: Query): boolean {
return (mask & q.all) === q.all && (mask & q.none) === 0;
}
interface CacheEntry {
entities: EntityId[];
revision: number;
}
export interface QueryStats {
hits: number;
misses: number;
rebuilds: number;
}
/**
* 查询引擎:持有实体掩码表,提供带缓存与不带缓存两种查询。
* 任何一次组件增删都会把 revision 加一,使所有缓存条目同时失效——
* 这是最保守、也最容易证明正确的失效策略。
*/
export class QueryEngine {
#masks = new Map<EntityId, number>();
#revision = 0;
#cache = new Map<string, CacheEntry>();
#hits = 0;
#misses = 0;
#rebuilds = 0;
get revision(): number {
return this.#revision;
}
get size(): number {
return this.#masks.size;
}
get stats(): QueryStats {
return { hits: this.#hits, misses: this.#misses, rebuilds: this.#rebuilds };
}
/** 直接设置一个实体的组件掩码;掩码为 0 视为移除该实体。 */
setMask(id: EntityId, mask: number): void {
if (mask === 0) this.#masks.delete(id);
else this.#masks.set(id, mask >>> 0);
this.#revision++;
}
addComponent(id: EntityId, type: number): void {
this.setMask(id, (this.#masks.get(id) ?? 0) | (1 << type));
}
removeComponent(id: EntityId, type: number): void {
this.setMask(id, (this.#masks.get(id) ?? 0) & ~(1 << type));
}
getMask(id: EntityId): number {
return this.#masks.get(id) ?? 0;
}
#key(q: Query): string {
return `${q.all}:${q.none}`;
}
/** 无缓存查询:每次从头扫描全部实体。遍历顺序 = 插入顺序(稳定)。 */
queryUncached(q: Query): EntityId[] {
const out: EntityId[] = [];
for (const [id, mask] of this.#masks) {
if (matches(mask, q)) out.push(id);
}
return out;
}
/**
* 带缓存查询:revision 未变则直接返回上次结果(命中),
* 变了则重扫一次并覆盖缓存(失效重建)。
*/
query(q: Query): EntityId[] {
const key = this.#key(q);
const e = this.#cache.get(key);
if (e !== undefined && e.revision === this.#revision) {
this.#hits++;
return e.entities;
}
this.#misses++;
this.#rebuilds++;
const entities = this.queryUncached(q);
this.#cache.set(key, { entities, revision: this.#revision });
return entities;
}
/** 需要一份可安全修改的拷贝时用它——缓存返回的是共享数组。 */
queryCopy(q: Query): EntityId[] {
return [...this.query(q)];
}
}
query 命中时返回的是同一个数组引用,省掉了拷贝——但这也意味着调用方不能就地修改它,否则会污染缓存。要改就调 queryCopy。这是「缓存返回共享数据」的通用契约:谁拿到共享结果,谁负责只读。
值得强调的是:缓存键是查询本身,不是调用者。同一个 { all, none } 无论被哪个系统调用,都命中同一条缓存;不同系统写出的等价查询因此天然共享结果。这也是为什么键要由掩码的十进制值拼出来,而不是用对象引用——查询是「值」,两个掩码相同就是同一个查询。
可运行产物:缓存命中率实测
基准脚本建 20000 个实体、8 种组件类型、8 个选择性不同的查询,跑 200 轮。对照「每轮无缓存重扫」与「缓存命中」两种模式,外加一个「每轮改一个实体、逼缓存失效」的抖动模式。核心两个计时循环如下:
// snippet: bench/query-cache-bench.ts(节选)
function runUncached(e: QueryEngine): number {
const t0 = performance.now();
for (let r = 0; r < ROUNDS; r++) {
for (const q of QUERIES) sink += e.queryUncached(q).length;
}
return performance.now() - t0;
}
function runCachedStatic(e: QueryEngine): { ms: number; hits: number; rebuilds: number } {
e.query(QUERIES[0]!); // 预热一次,让缓存填满
for (const q of QUERIES) e.query(q);
const t0 = performance.now();
for (let r = 0; r < ROUNDS; r++) {
for (const q of QUERIES) sink += e.query(q).length;
}
const ms = performance.now() - t0;
const s = e.stats;
return { ms, hits: s.hits, rebuilds: s.rebuilds };
}
本机(Node 26.5.0)实跑,取 7 次重复的中位数:
N = 20000, TYPES = 8, QUERIES = 8, ROUNDS = 200
[A static] uncached 219.11 ms
[A static] cached 0.20 ms
[A static] speedup 1096.26x
[A static] hits = 1601, rebuilds = 8
[B churn ] cached 225.59 ms
[B churn ] hits = 0, rebuilds = 1600
静态场景里 200 轮 × 8 个查询 = 1600 次查询全部命中,只重建了 8 次(每个查询首次构建一次)。无缓存是 219.11 ms,有缓存 0.20 ms——这个「一千倍」不是玄学:无缓存要跑 20000 × 8 × 200 ≈ 3200 万次掩码判断,缓存只是把 8 个现成数组读回来。请把重点放在方向上:缓存把重复查询从 O(实体数) 压到 O(1),倍数只反映这个差距。
抖动场景更值得看:每轮改一个实体就让全局 revision 前进,所有 8 个查询一起失效,于是 1600 次查询一次都没命中,耗时反而回到 225.59 ms,和全量重扫持平。这就是全局失效策略的代价——写入一密集,缓存就白搭。真实引擎会做「按 archetype 增量更新」,把失效范围收窄到受影响的查询;那是存储层(12.2)与查询层配合的结果,本节先把这个边界量出来。
测试:命中、失效与拷贝
// snippet: src/core/query-cache.test.ts(节选)
it('缓存命中:同一 revision 下第二次调用不重建', () => {
const e = new QueryEngine();
e.setMask(1, maskOf([0]));
e.setMask(2, maskOf([0]));
const q = makeQuery([0]);
const a = e.query(q);
const b = e.query(q);
expect(b).toBe(a); // 返回同一数组引用
expect(e.stats.hits).toBe(1);
expect(e.stats.rebuilds).toBe(1);
});
it('失效重建:组件变更后结果更新', () => {
const e = new QueryEngine();
e.setMask(1, maskOf([0]));
const q = makeQuery([0]);
expect(e.query(q)).toEqual([1]);
e.addComponent(2, 0);
expect(e.query(q)).toEqual([1, 2]);
expect(e.stats.rebuilds).toBe(2);
});
npx vitest run src/core/query-cache.test.ts 全部通过(8 个用例)。第二个用例正是失效策略的守门人:加一个组件后,缓存必须重建出 [1, 2],而不是继续吐旧的 [1]。
常见坑
- 就地修改缓存数组。
query返回共享引用,push/sort它会污染缓存。要改就用queryCopy。 - 缓存键写漏字段。键必须同时包含
all与none,只拼all会让两个none不同的查询互相顶掉。 - 失效粒度想当然。全局
revision正确但粗糙;每帧都有组件写入时命中率会掉到 0,这不是 bug,是策略代价,要么减少写、要么换成增量失效。 - 把掩码宽度用满。32 位只够 32 类组件,超过就会静默错位。到上限前就把掩码加宽。
revision溢出。本节用 JS 的number计数,安全整数范围内不会回绕;换成 32 位整数时记得处理回绕,否则极久运行后缓存会「意外命中」。
13.2 要解决的就是上面第三条:既然每帧都有写,就让查询只重算「真正变了的那部分」——靠的是稳定的迭代顺序与脏标记。
阅读导航:上一节:目录 · 下一节:13.2 迭代顺序、变更检测与脏标记 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。