《游戏引擎实现原理》13.1 查询匹配与缓存命中

实体用位掩码表示拥有哪些组件,查询用 all / none 两个掩码描述条件;结果按查询键缓存,任一组件增删都把全局 revision 加一使缓存整体失效。本节给出 helio 内核可编译的 QueryEngine,并实测有缓存与无缓存跑同一批查询的耗时差、命中与失效次数。

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:

检查计算结果
全有 all0b011 & 0b011 === 0b011通过
全无 none0b011 & 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 迭代顺序、变更检测与脏标记 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径