《游戏引擎实现原理》11.3 从 OOP 到批处理的重构方法

把 for (const e of entities) e.update(dt) 这样一段面向对象的更新循环,一步步改造成按组件列分组的批处理。本节给出改造前后两份可编译的 helio 源码与一份基准,在本机实测出耗时约 1.5 倍、堆增量 16.6 MB 对 0 MB 的差距,并说明哪些场景根本不该改。

11.3 从 OOP 到批处理的重构方法

11.2 讲了三种布局的差异,但那是「静态数据」的对照。这一节把它落到一段真实的更新循环上:从 for (const e of entities) e.update(dt) 这种最自然的面向对象写法出发,一步步改造成「按组件列分组、对整列数组批量 step」的批处理写法,然后用基准把改造前后的耗时与堆增量摆在一起。

要解决的其实是一个很具体的问题:当你决定上数据导向时,代码到底怎么改? 这不是「把 class 换成 TypedArray」这么一句口号能回答的——字段怎么分组、行为挂在哪、生命周期怎么管、改造后怎么验证,都有讲究。本节给出 helio 内核里一份可直接编译的两版实现,以及配套的基准。

改造前:每个实体自己 update

先看最自然的写法。每个实体是一个对象,位置、速度、寿命都是它的字段,行为 update 挂在对象上,更新循环就是遍历对象数组、逐个调用。这是绝大多数教程的起点,也是可读性最好的写法。

// file: src/core/entity-oop.ts
// tier: L1
// verify: npx vitest run src/core/entity-oop.test.ts
//
// 面向对象风格的实体:每个实体一个对象,行为(update)挂在对象上。
// 这是 11.3 重构「之前」的写法,用来和批处理版对照。

export interface SpawnArgs {
  x?: number;
  y?: number;
  z?: number;
  vx?: number;
  vy?: number;
  vz?: number;
  life?: number;
}

export class Entity {
  x = 0;
  y = 0;
  z = 0;
  vx = 0;
  vy = 0;
  vz = 0;
  life = 1;

  /** 每个实体自己推进一帧:读自己的字段、写自己的字段。 */
  update(dt: number): void {
    this.x += this.vx * dt;
    this.y += this.vy * dt;
    this.z += this.vz * dt;
    this.life -= dt;
  }

  get alive(): boolean {
    return this.life > 0;
  }
}

export function spawn(args: SpawnArgs = {}): Entity {
  const e = new Entity();
  e.x = args.x ?? 0;
  e.y = args.y ?? 0;
  e.z = args.z ?? 0;
  e.vx = args.vx ?? 0;
  e.vy = args.vy ?? 0;
  e.vz = args.vz ?? 0;
  e.life = args.life ?? 1;
  return e;
}

/** 重构前的更新循环:一次迭代一个实体,每个实体各自 update。 */
export function updateAll(entities: Entity[], dt: number): void {
  for (const e of entities) e.update(dt);
}

这份代码的问题不在「慢」,而在两个结构性的代价:一是每个实体是一个堆对象,1M 个实体就是 1M 次分配、1M 份对象头开销;二是更新循环里,每个实体的 {x,y,z,vx,vy,vz,life} 是各自一块内存,遍历时在堆上跳来跳去,正是 11.1 说的「指针追逐」。

改造后:按组件列批量 step

改造的核心动作只有一句:把「按实体分组」改成「按字段分组」。 每个字段变成一条平行的 Float32Array,行为从「对象上的方法」变成「作用于整列数组的函数」。同一个下标 i 在七条数组里指向同一个实体,但它不再是一个对象,而是七个数。

// file: src/core/entity-batch.ts
// tier: L1
// verify: npx vitest run src/core/entity-batch.test.ts
//
// 批处理风格:组件按类型分组、SoA 存储,行为是「作用于整列数组的函数」。
// 每个字段一个 Float32Array —— 底层 ArrayBuffer 是外部内存,不进 JS 堆。
// 更新循环按索引扫列,没有逐实体的方法调用。

export class EntityBatch {
  readonly x: Float32Array;
  readonly y: Float32Array;
  readonly z: Float32Array;
  readonly vx: Float32Array;
  readonly vy: Float32Array;
  readonly vz: Float32Array;
  readonly life: Float32Array;
  #count = 0;

  constructor(capacity: number) {
    this.x = new Float32Array(capacity);
    this.y = new Float32Array(capacity);
    this.z = new Float32Array(capacity);
    this.vx = new Float32Array(capacity);
    this.vy = new Float32Array(capacity);
    this.vz = new Float32Array(capacity);
    this.life = new Float32Array(capacity);
  }

  get count(): number {
    return this.#count;
  }

  /** 追加一个实体,返回它的槽位索引。 */
  spawn(
    x = 0, y = 0, z = 0,
    vx = 0, vy = 0, vz = 0,
    life = 1,
  ): number {
    const i = this.#count++;
    this.x[i] = x;
    this.y[i] = y;
    this.z[i] = z;
    this.vx[i] = vx;
    this.vy[i] = vy;
    this.vz[i] = vz;
    this.life[i] = life;
    return i;
  }

  /** 批处理更新:一次扫过每一列,无逐实体调用。 */
  step(dt: number): void {
    const n = this.#count;
    const x = this.x, y = this.y, z = this.z;
    const vx = this.vx, vy = this.vy, vz = this.vz;
    const life = this.life;
    for (let i = 0; i < n; i++) {
      x[i] = x[i]! + vx[i]! * dt;
      y[i] = y[i]! + vy[i]! * dt;
      z[i] = z[i]! + vz[i]! * dt;
      life[i] = life[i]! - dt;
    }
  }

  isAlive(i: number): boolean {
    return this.life[i]! > 0;
  }
}

对照两版代码,改造带来的三个变化值得点名:

  • 行为从对象搬到了数组。 原来 update 是 Entity 的方法,现在 step 是 EntityBatch 的方法、作用于整列。这正是「数据与行为分离」的字面含义。
  • 容量必须预分配。 构造函数要求 capacity,因为 Float32Array 不能像数组那样随意增长。这是 SoA 的固定成本——换来的是运行期零分配。
  • 访问改成了下标。 原来 e.x,现在 x[i];i 就是 11.2 提到的「ID→下标」映射的结果,也是后面 ECS 里实体索引的雏形。

可运行产物:改造前后的耗时与堆增量

下面是配套的基准:同样的 20 万实体 × 60 帧,分别跑 OOP 版和批处理版,各测 9 次取中位数。它同时记录构建后的堆增量——这正是 11.2 里「62.6 MB 对 0」在小规模上的复现。

// file: bench/oop-vs-batch.ts
// tier: B
// verify: node bench/oop-vs-batch.ts
//
// 把「每实体一个对象的 update 循环」重构成「按组件列批量 step」,
// 跑同样的 N 实体 × M 帧,对比耗时与堆增量。
// 堆增量用 process.memoryUsage().heapUsed 在构建前后各取一次:
//   OOP 版每实体一个对象 → 堆增长约 N × 对象大小;
//   批处理版是 TypedArray → 底层 ArrayBuffer 属外部内存,不计入 heapUsed。

import { Entity, spawn, updateAll } from '../src/core/entity-oop.ts';
import { EntityBatch } from '../src/core/entity-batch.ts';

const N = 200_000;
const FRAMES = 60;
const DT = 1 / 60;

function median(xs: number[]): number {
  const s = [...xs].sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
}

function buildOop(): Entity[] {
  const es: Entity[] = new Array(N);
  for (let i = 0; i < N; i++) es[i] = spawn({ x: i, vx: 1, vy: 2, vz: 3, life: 10 });
  return es;
}

function buildBatch(): EntityBatch {
  const b = new EntityBatch(N);
  for (let i = 0; i < N; i++) b.spawn(i, 0, 0, 1, 2, 3, 10);
  return b;
}

const beforeOop = process.memoryUsage().heapUsed;
const oop = buildOop();
const oopHeapMB = (process.memoryUsage().heapUsed - beforeOop) / 1048576;

const beforeBatch = process.memoryUsage().heapUsed;
const batch = buildBatch();
const batchHeapMB = (process.memoryUsage().heapUsed - beforeBatch) / 1048576;

const time = (f: () => void): number => {
  const t0 = process.hrtime.bigint();
  f();
  return Number(process.hrtime.bigint() - t0) / 1e6;
};

// 预热:让 JIT 把两条更新路径都编译到热点。
for (let w = 0; w < 5; w++) {
  updateAll(oop, DT);
  batch.step(DT);
}

const oopT: number[] = [];
const batchT: number[] = [];
for (let r = 0; r < 9; r++) {
  oopT.push(time(() => { for (let f = 0; f < FRAMES; f++) updateAll(oop, DT); }));
  batchT.push(time(() => { for (let f = 0; f < FRAMES; f++) batch.step(DT); }));
}

console.log(`N = ${N}, FRAMES = ${FRAMES}`);
console.log(`OOP   ${median(oopT).toFixed(2)} ms   heap +${oopHeapMB.toFixed(1)} MB   ${(oopHeapMB * 1048576 / N).toFixed(1)} B/entity`);
console.log(`batch ${median(batchT).toFixed(2)} ms   heap +${batchHeapMB.toFixed(1)} MB   ${(batchHeapMB * 1048576 / N).toFixed(1)} B/entity`);
console.log(`speedup oop/batch = ${(median(oopT) / median(batchT)).toFixed(2)}x`);

本机跑出的结果(20 万实体 × 60 帧):

版本中位耗时堆增量每实体堆占用
OOP(对象数组)44.4 ms+16.6 MB86.9 B
批处理(SoA)29.9 ms+0.0 MB0 B
对比快 1.49×差 16.6 MB—

结论和 11.2 一脉相承:速度只快约 1.5 倍,但堆增量从 16.6 MB 掉到 0。 后者才是这次重构的主要收益。20 万实体每帧一次全量更新,OOP 版每帧都在触碰 16.6 MB 的 GC 托管对象;批处理版碰的是预分配好的外部内存,GC 完全看不见。把规模放大到 1M 实体,这个差距就是 11.2 里的 62.6 MB。

重构的六个步骤

把上面这套对照抽象成可复用的方法,就是下面六步。它们和第 5 章引入对象池、11.2 迁移 SoA 的思路完全一致——局部替换、双轨验证、可回退:

  • 第一步:给热点循环做剖析。 确认 updateAll 这类循环真的占了帧时间的大头,且剖析里能看到 GC 停顿。没有这一步,后面全是赌博。
  • 第二步:列出循环真正读写的字段。 只抽这些字段成平行数组,不要动业务对象的全部数据。
  • 第三步:把行为改写成「作用于整列的纯函数」。 e.update(dt) 变成 batch.step(dt),逻辑逐行平移,别趁机改算法。
  • 第四步:加一个适配层,让业务仍按 ID 访问。 需要读单个实体的地方,适配层临时把几条列拼回一个对象,避免调用方大改。
  • 第五步:双轨跑、对结果。 同时跑 OOP 版与批处理版,断言两者推进后的位置与寿命一致(entity-batch.test.ts 里就是这么做的),再切流量。
  • 第六步:量收益、决定去留。 记录切换前后的帧时间与 GC 停顿;收益没超过可读性的代价,就退回去。

这六步里,最容易跳过、也最不该跳过的是第五步。数据导向重构最隐蔽的 bug 是「下标对错了列」——位置更新到了速度列上,结果不崩溃、只是慢慢漂移,跑几十帧才看得出来。双轨断言是唯一能在早期抓住这类 bug 的手段。

什么时候不值得改

不是所有 for (const e of entities) e.update(dt) 都值得改。下面这些情况,改了只会让代码变难读:

  • 实体数量少。 几百个以内,1.5× 的差距淹没在噪声里,重构纯属自找麻烦。
  • 每个实体的 update 逻辑差异很大。 如果 update 内部是一大串 if/else、还要调用别的子系统,硬拆成「对整列做一步」会把代码碎成十几段。
  • 生命周期频繁增删。 SoA 的删除要 swap-remove、打乱顺序;如果实体频繁生灭且顺序敏感,维护成本会盖过收益。
  • 你还没确认这是瓶颈。 帧时间可能花在渲染、物理、IO 上;在这些地方省下的 1.5× 一分钱都不值。

反过来,当实体数量上万、每帧全量更新、update 逻辑简单无分支时——粒子系统、变换传播、可见性筛选——批处理就是明显更优的选择。这也正是第 12 章 ECS 要接着往下走的地方:本节这套「按字段分组的平行数组」加上「ID→下标的映射」,就是最朴素的组件存储。

下一节从这一节停下的地方继续:如果「面向对象」「组合」「数据导向」是三种组织代码的方式,它们各自的代价是什么、又该在什么时候切换。

阅读导航:上一节:11.2 SoA / AoS / AoSoA 的真实差异 · 下一节:12.1 从继承到组合到数据 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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