Skip to content

ECS

@tripo3d/engine 的 ECS 子系统采用哈希表而非 archetype 实现:每个 Entity 在内部是一条记录,Component/Script 以 Symbol 作为键挂在实体上,Component 同时被登记到全局的「键 → 实体集合」索引中,供 Query 做候选集优化。它不追求在上千万实体上榨取 CPU 缓存,而是在一个 Three.js 场景的复杂度下,提供足够简洁的实体-组件-脚本抽象,并和 Three.js 的对象树无缝互通。

一图概览

  • EngineWorldECS 入口,由 createEngine 生成,挂在 engine.world
    • Entity包装一个 THREE.Object3D,持有 components / scripts / children
      • Component纯数据/工厂,描述「实体是什么」
      • Script带生命周期钩子,描述「实体每帧做什么」
    • Query根据 Component 组合筛选 Entity,带候选集优化

Entity

Entity 永远包着一个 THREE.Object3D。你可以让 world 新建一个 Group 包裹起来,也可以把现成的 Object3D(例如 glTF 加载回来的根节点)注册进来——后者不会触碰 Three.js 对象本身的结构,只是在 ECS 层登记。

同一个 Object3D 不会被注册两次:registerObject 幂等,遇到已注册的对象直接返回现成的 Entity。

ts
// 新建一个 Group 并包装为 Entity
const node = engine.world.createEntity();

// 把一个现有 Object3D 注册进来
const gltfEntity = engine.world.registerObject(gltf.scene);

// 整棵子树递归注册
engine.world.registerTree(gltf.scene);

方法

字段 / 方法签名说明
idstring自增生成的实体 id,调试/日志用
namestring(读写)代理 object.name,赋值同步到 Three.js
objectTHREE.Object3D底层被包装的 Three.js 对象
enabledboolean(读写)切换启用状态,会触发下属所有脚本的 onEnable/onDisable
parentEntity | null由 object.parent 推导;父对象若未注册则为 null
childrenEntity[]由 object.children 过滤得到的已注册子实体
addComponent<T>(def: ComponentDefinition<T>) => T附加一个组件实例;同一 def 已存在时返回旧实例
getComponent<T>(def) => T | undefined读取组件实例
hasComponent<T>(def) => boolean判断是否存在组件
removeComponent<T>(def) => void移除组件,触发其 onDispose 回调
addScript<T>(def: ScriptDefinition<T>) => T & ScriptHooks附加一个脚本实例;同一 def 已存在时返回旧实例
getScript<T>(def) => (T & ScriptHooks) | undefined读取脚本实例
hasScript<T>(def) => boolean判断是否存在脚本
removeScript<T>(def) => void移除脚本,依次触发 onDisable / onDestroy
addChild(child: Entity) => Entityobject.add(child.object) 的便捷封装
removeChild(child: Entity) => void仅在 child.object.parent === this.object 时解除挂接
traverse(cb: (e: Entity) => void) => void深度优先遍历已注册的子实体(跳过未注册节点)
onPointer(type: 'pointerdown' | 'pointerup' | 'pointermove' | 'pointerenter' | 'pointerleave' | 'click', handler) => () => void订阅指针事件,返回取消函数
destroy() => void等价于 world.removeEntity(this),会递归清理整棵子树

Entity 本身是一个对象而不是 class 实例,属性以 getter/setter 的形式转发给底层 Object3D 与内部记录,所以 Vue 插件可以无压力地把它裹进 shallowReactive

Component

Component 通过 createComponent(name, setup, meta?) 定义。setup(context) 在实体首次 addComponent 时执行,返回值就是组件实例——随后 getComponent(def) 都会拿到同一个引用。

setup 里可以:

  • context.entity / context.object 改 Three.js 对象
  • context.engine / context.world 访问其他子系统
  • context.onDispose(cb) 登记清理回调,removeComponent 或 entity 销毁时会批量触发
ts
import { createComponent } from '@tripo3d/engine';
import { Mesh, MeshStandardMaterial, SphereGeometry } from 'three';

export const Orb = createComponent('Orb', ({ object, onDispose }) => {
  const geometry = new SphereGeometry(0.5, 16, 16);
  const material = new MeshStandardMaterial({ color: 0xff8844 });
  const mesh = new Mesh(geometry, material);
  object.add(mesh);

  onDispose(() => {
    object.remove(mesh);
    geometry.dispose();
    material.dispose();
  });

  return { mesh, material };
});

同一个 Orb definition 可以被附加到任意多个 Entity 上,每个 Entity 都会走一次 setup、各自持有独立实例;但同一个 Entity 上 Orb 只会存在一份——重复 addComponent(Orb) 返回的是既有实例,不会重跑 setup。

Script

Script 结构上和 Component 很像——createScript(name, setup, meta?)——区别是 setup 的返回值允许包含一组生命周期钩子,由引擎在对应阶段调度。

名称 类型 默认值 说明
onInit() => void脚本被 addScript 时立即触发,只触发一次
onEnable / onDisable() => voidEntity 的 enabled 切换时触发;addScript 时若实体已启用也会触发 onEnable
onReady() => void引擎 mount 后首次渲染前;晚加入的脚本若此刻已 ready 也会立即触发
onStart() => void引擎首帧开始时;晚加入的脚本若此刻已 started 也会立即触发
onUpdate(frame: EngineFrameContext) => void每帧 lifecycle.onUpdate 阶段执行(实体禁用时跳过)
onLateUpdate(frame) => voidonUpdate 之后,渲染开始前
onBeforeRender(frame) => void每个 pipeline stage 渲染前
onAfterRender(frame) => void每个 pipeline stage 渲染后
onResize(size: EngineViewSize) => void视口尺寸变化时
onDestroy() => voidremoveScript 或 Entity 销毁时
ts
import { createScript } from '@tripo3d/engine';

export const Rotating = createScript<{ speed: number }>(
  'Rotating',
  ({ object }) => {
    const state = { speed: 1 };

    return {
      get speed() {
        return state.speed;
      },
      set speed(next) {
        state.speed = next;
      },
      onUpdate({ delta }) {
        object.rotation.y += state.speed * delta;
      },
    };
  },
);

Script 和 Entity 绑定——Entity 销毁(或脚本被 removeScript)时,引擎会自动调用 onDisableonDestroy 释放订阅,不需要脚本自己维护取消。

Query

Query 按 Component 组合筛 Entity。world.createQuery({ all, any, none }) 返回一个 EntityQuery,每次调用 .list() / .count() / .first() 都会重新扫描——它是懒查询,不缓存结果。

名称 类型 默认值 说明
allComponentDefinition[]实体必须同时拥有数组里每个组件
anyComponentDefinition[]实体至少拥有数组里的一个组件
noneComponentDefinition[]实体不能拥有数组里的任何组件
ts
const movable = engine.world.createQuery({
  all: [Transform, Velocity],
  none: [Frozen],
});

movable.list().forEach((entity) => {
  // ...
});

候选集优化

筛选不是硬扫描整张实体表:world 内部为每个出现过的 Component 维护了一个「拥有该组件的实体集合」。当描述符里有 all 时,查询会挑出其中规模最小的那个集合作为候选集,再按 all/any/none 过滤;只有 any、或者没有任何组件约束时才退化成扫全表。换句话说,让 all 里至少有一个稀疏的 Component(例如「当前被选中」这种一次只附加一两个实体的标记组件),查询就会自动落在最小的候选集上。

常用快捷方式

查单一组件时,world.query(def) 等价于 createQuery({ all: [def] }).list(),直接拿到数组:

ts
const cameras = engine.world.query(CameraComponent);

World API

方法签名说明
createEntity(object?: Object3D) => Entity新建 Entity;未传 object 时自动建一个 Group
registerObject(object: Object3D) => Entity把已有 Object3D 包成 Entity;幂等,已注册直接返回
registerTree(root: Object3D) => Entity递归包装整棵子树,返回根 Entity
removeEntity(entity: Entity) => void清理整棵子树的 component / script / pointer 绑定,并把 object 从 scene 里摘掉
getEntities() => Entity[]当前所有已注册实体的快照
getEntityByObject(object: Object3D) => Entity | undefined反查:已注册对象对应的 Entity
createQuery(d: WorldQueryDescriptor) => EntityQuery构造一个可复用的查询对象
query<T>(def: ComponentDefinition<T>) => Entity[]单组件查询的快捷方式,直接返回数组
walk(target: Entity | Object3D, cb) => void深度优先遍历某个对象或实体子树里所有已注册 Entity
clear() => void清空整个 world(由 engine.dispose 调用,一般不手动用)

world 上没有 getEntity(object) / hasEntity(object) 这种单独方法——判断一个 Object3D 是否已被注册,直接用 getEntityByObject(object) 的返回值是否为 undefined

完整示例

把前面的 Rotating 串起来:注册一批对象、按组件批量禁用、再一次性清理。

ts
import { createComponent, createEngine, createScript } from '@tripo3d/engine';
import { BoxGeometry, Mesh, MeshStandardMaterial } from 'three';

// 标记组件:表示「这是一个可旋转的方块」
const Spinner = createComponent('Spinner', ({ object }) => {
  const mesh = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
  object.add(mesh);
  return { mesh };
});

// 行为脚本
const Rotating = createScript('Rotating', ({ object }) => ({
  onUpdate({ delta }) {
    object.rotation.y += delta;
  },
}));

const engine = createEngine();
engine.mount(document.querySelector('#host')!);

// 生成 10 个带 Spinner + Rotating 的实体
for (let index = 0; index < 10; index += 1) {
  const entity = engine.world.createEntity();
  entity.object.position.x = index - 5;
  entity.addComponent(Spinner);
  entity.addScript(Rotating);
}

// 之后:批量暂停所有 Spinner
engine.world.query(Spinner).forEach((entity) => {
  entity.enabled = false; // 触发 Rotating.onDisable
});

// 或者只挑还启用的:createQuery 是懒查询,每次 list 都重新算
const running = engine.world.createQuery({ all: [Spinner] });
console.log(running.count());

最佳实践

  • Component 存数据,Script 存行为:Component 的 setup 适合挂 Mesh、算派生数据、登记 onDispose;每帧逻辑放到 Script 里,利用引擎调度的 onUpdate / onBeforeRender,避免手动订阅 lifecycle
  • name 唯一:createComponent(name, ...)Symbol(name) 生成 key,但 key 只在模块加载时各自生成一次;只要你把 definition 作为单例 import 使用就没问题。如果同一个 name 被 createComponent 调用了两次,就是两个不同的 definition,不会共享实例,这点对做热更新/测试时要留意。
  • 每个 Entity 上每个 definition 唯一:重复 addComponent(def) 不会重跑 setup,只会返回既有实例——需要「多份」时,就定义多个不同 definition。
  • all 包含稀疏组件:Query 会挑 all 里候选集最小的那一项作为起点。想让「被选中的可旋转方块」之类的查询飞起来,就让至少一项约束是稀疏标记组件,而不是铺满场景的通用组件。

下一步

基于 MIT 协议发布(内部使用)