ECS
@tripo3d/engine 的 ECS 子系统采用哈希表而非 archetype 实现:每个 Entity 在内部是一条记录,Component/Script 以 Symbol 作为键挂在实体上,Component 同时被登记到全局的「键 → 实体集合」索引中,供 Query 做候选集优化。它不追求在上千万实体上榨取 CPU 缓存,而是在一个 Three.js 场景的复杂度下,提供足够简洁的实体-组件-脚本抽象,并和 Three.js 的对象树无缝互通。
一图概览
EngineWorldECS 入口,由 createEngine 生成,挂在 engine.worldEntity包装一个 THREE.Object3D,持有 components / scripts / childrenComponent纯数据/工厂,描述「实体是什么」Script带生命周期钩子,描述「实体每帧做什么」
Query根据 Component 组合筛选 Entity,带候选集优化
Entity
Entity 永远包着一个 THREE.Object3D。你可以让 world 新建一个 Group 包裹起来,也可以把现成的 Object3D(例如 glTF 加载回来的根节点)注册进来——后者不会触碰 Three.js 对象本身的结构,只是在 ECS 层登记。
同一个 Object3D 不会被注册两次:registerObject 幂等,遇到已注册的对象直接返回现成的 Entity。
// 新建一个 Group 并包装为 Entity
const node = engine.world.createEntity();
// 把一个现有 Object3D 注册进来
const gltfEntity = engine.world.registerObject(gltf.scene);
// 整棵子树递归注册
engine.world.registerTree(gltf.scene);方法
| 字段 / 方法 | 签名 | 说明 |
|---|---|---|
id | string | 自增生成的实体 id,调试/日志用 |
name | string(读写) | 代理 object.name,赋值同步到 Three.js |
object | THREE.Object3D | 底层被包装的 Three.js 对象 |
enabled | boolean(读写) | 切换启用状态,会触发下属所有脚本的 onEnable/onDisable |
parent | Entity | null | 由 object.parent 推导;父对象若未注册则为 null |
children | Entity[] | 由 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) => Entity | object.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 销毁时会批量触发
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 | () => void | — | Entity 的 enabled 切换时触发;addScript 时若实体已启用也会触发 onEnable |
onReady | () => void | — | 引擎 mount 后首次渲染前;晚加入的脚本若此刻已 ready 也会立即触发 |
onStart | () => void | — | 引擎首帧开始时;晚加入的脚本若此刻已 started 也会立即触发 |
onUpdate | (frame: EngineFrameContext) => void | — | 每帧 lifecycle.onUpdate 阶段执行(实体禁用时跳过) |
onLateUpdate | (frame) => void | — | onUpdate 之后,渲染开始前 |
onBeforeRender | (frame) => void | — | 每个 pipeline stage 渲染前 |
onAfterRender | (frame) => void | — | 每个 pipeline stage 渲染后 |
onResize | (size: EngineViewSize) => void | — | 视口尺寸变化时 |
onDestroy | () => void | — | removeScript 或 Entity 销毁时 |
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)时,引擎会自动调用 onDisable → onDestroy 释放订阅,不需要脚本自己维护取消。
Query
Query 按 Component 组合筛 Entity。world.createQuery({ all, any, none }) 返回一个 EntityQuery,每次调用 .list() / .count() / .first() 都会重新扫描——它是懒查询,不缓存结果。
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
all | ComponentDefinition[] | — | 实体必须同时拥有数组里每个组件 |
any | ComponentDefinition[] | — | 实体至少拥有数组里的一个组件 |
none | ComponentDefinition[] | — | 实体不能拥有数组里的任何组件 |
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(),直接拿到数组:
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 串起来:注册一批对象、按组件批量禁用、再一次性清理。
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里候选集最小的那一项作为起点。想让「被选中的可旋转方块」之类的查询飞起来,就让至少一项约束是稀疏标记组件,而不是铺满场景的通用组件。
下一步
- createEngine —— 从 options 开始装配 engine
- 架构总览 —— ECS 在整个 engine 里的位置