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 MB | 86.9 B |
| 批处理(SoA) | 29.9 ms | +0.0 MB | 0 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 从继承到组合到数据 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。