bit-ecs:稀疏集合 + 密集数组撑起来的高性能 ECS

它解决什么问题

弹幕游戏、大型 RTS、需要同屏处理成百上千个单位的游戏,最容易被面向对象那一套写法拖垃:继承层级一深,一个”会飞的会打的会加血的怪”到底该继承哪个基类就能吵半天,而且对象越多,遍历判断类型的开销越大,GC 也跟着遭罪。

ECS(实体-组件-系统)架构把”是什么”(组件,纯数据)和”做什么”(系统,纯逻辑)拆开,实体只是个数字 ID,靠组件的组合决定它的行为。bit-ecs 在这个基础上,用稀疏集合加密集数组的数据布局,让频繁增删实体和组件这件事本身也做到高性能。

安装

npm install @gongxh/bit-ecs

零依赖,独立使用。

核心概念

  • 实体(Entity)——就是个数字 ID,本身不携带任何数据
  • 组件(Component)——纯数据结构,比如位置、血量、速度
  • 系统(System)——逻辑代码,处理”同时拥有某几种组件”的所有实体
  • 世界(World)——管理实体、组件、系统的容器

核心用法

定义组件

组件必须实现 reset(),因为组件是从对象池里回收复用的,不重置会带着上一个实体的脏数据:

import { Component, _ecsdecorator } from '@gongxh/bit-ecs';
const { ecsclass, ecsprop } = _ecsdecorator;

@ecsclass('Position')
export class PositionComponent extends Component {
    @ecsprop({ type: 'float' }) x: number = 0;
    @ecsprop({ type: 'float' }) y: number = 0;

    reset(): void {
        this.x = 0;
        this.y = 0;
    }
}

定义系统

系统在 onInit() 里配置要查询哪些实体,update(dt) 里写每帧逻辑:

import { System, _ecsdecorator } from '@gongxh/bit-ecs';
const { ecsystem } = _ecsdecorator;

@ecsystem('Move')
export class MoveSystem extends System {
    protected onInit(): void {
        this.matcher.allOf(PositionComponent, VelocityComponent);
    }

    update(dt: number): void {
        this.query.iterate2(PositionComponent, VelocityComponent, (entity, pos, vel) => {
            pos.x += vel.x * dt;
            pos.y += vel.y * dt;
        });
    }
}

allOf 表示实体必须同时有这两个组件才会被这个系统处理,还有 anyOf(任一)、excludeOf(必须不含)、optionalOf(可选,查询时一并带出但不作为过滤条件)可以组合。

iterate2 这类按数量区分的迭代器(1~4 个组件)是零 GC 的,比通用的 iterate(...types)(支持最多 8 个组件,几乎无 GC)性能更好,热路径(每帧都跑的系统)优先用固定数量版本。

创建世界,跑起来

import { World } from '@gongxh/bit-ecs';

const world = new World('battle', 1024); // 最大实体数建议 2 的指数
world.addSystem(new MoveSystem());
world.initialize(); // 必须调用一次

const entity = world.createEmptyEntity();
world.addComponent(entity, PositionComponent);
world.addComponent(entity, VelocityComponent);

// 每帧调用
world.update(dt);

也可以用 SystemGroup 把系统分组,配置 frameInterval 让某些不需要每帧都跑的系统降频,比如 AI 决策系统每 3 帧跑一次:

const aiGroup = world.SystemGroup('AI', 3);

命令缓冲:为什么删除组件不是立刻生效

removeEntityaddComponentremoveComponent 都不是调用后立刻改变数据结构,而是延迟到下一帧 update() 前统一处理。这是命令缓冲模式,为的是避免系统正在遍历实体的过程中,数据结构被中途修改导致遍历错乱或者漏处理。

代价是:你在同一帧内调用 removeComponent 之后,马上再 getComponent 去读,读到的还是删除前的数据——它还没真正被移除。这个延迟生效的特性容易让人以为是 bug,实际是设计使然。

性能特点

稀疏集合加密集数组这套布局,对”频繁加实体、删实体、加组件、删组件”这类场景做了偏向优化,系统遍历性能也不错,代价是内存占用会比纯稠密数组方案高一些——用空间换的是增删的时间。查询器本身会做条件复用,同样的查询条件多次调用不会重复计算,用了掩码做匹配,实体一多的时候也不会线性扫描全部组件类型。

避坑提醒

  • 组件的 reset() 一定要把所有字段都清干净,漏一个字段在对象池复用时就是隐藏的脏数据 bug,而且这种 bug 通常要跑很多局才会暴露,很难复现。
  • 增删操作是延迟生效的,同一帧内”删除再查询”拿到的是旧数据,不要依赖同帧内立即生效的假设写逻辑。
  • 世界的 maxEntityCount 建议设成 2 的指数(比如 512、1024),跟内部数据结构的扩容策略更契合。

项目信息

如果你的项目更偏向”节点少、逻辑相对简单”的中小型游戏,不需要 ECS 这么重的架构,可以看看更轻量的 bit-ec