Playground
本页把 @tripo3d/engine 以最佳实践方式拆成 System / Script / Component,覆盖: 本地 GLB/GLTF 加载、BVH 加速 raycaster、OrbitControls、Transform Gizmo、Viewport Gizmo、Stats 性能监控、指针事件日志。
架构总览
源码目录:
_playground-demos/
├── Playground.vue # UI 编排(不含 Three.js 逻辑)
├── engine-setup.ts # 引擎工厂:光照 + 网格 + 注册 5 个 system
├── systems/
│ ├── camera-system.ts # OrbitControls + ViewportGizmo
│ ├── transform-system.ts # TransformControls + pointer lock
│ ├── model-loader-system.ts # GLB 加载 + BVH + 拾取脚本装配
│ ├── stats-system.ts # Stats.js 面板
│ └── pointer-log-system.ts # 全局事件日志 + miss handler
├── scripts/
│ └── pickable-mesh.ts # 挂在 mesh entity 上的 click/hover script
├── components/
│ └── bvh-index.ts # 已建 BVH 的 marker component
└── utils/
├── bvh.ts # BVH 原型补丁 + build/dispose 工具
└── glb-loader.ts # GLTFLoader 封装 + geometry/material 释放三层抽象的职责切分
System (engine-wide singleton)状态机,有 setup 与生命周期钩子(onReady/onUpdate/onDispose 等)。适合「一个 engine 里只需要一份」的能力CameraSystemOrbitControls + ViewportGizmo,suspendControls() 提供引用计数暂停TransformSystemTransformControls;拖拽时 engine.pointer.suspend() + 相机 suspend,rAF 延迟释放避免 click 穿透ModelLoaderSystem编排 glb-loader → BVH → world.registerTree → 附加 PickableMesh 脚本的完整流程StatsSystemStats.js 面板,onBeforeRender(-100)/onAfterRender(1000) 精确夹住 renderPointerLogSystem响应式日志数组 + 订阅 engine.pointer.onMissed;Script 和 UI 共享同一份
Script (per-entity behavior)挂在 Entity 上,有自己的生命周期,entity 销毁时自动清理PickableMeshonInit 注册 click/pointerenter/pointerleave,onDestroy 注销;通过 engine.getSystem 调用其他 system
Component (per-entity data)纯数据/工厂,用于标记实体状态,给 Query 做批量操作BvhIndex标记 mesh entity 已建 BVH;world.query(BvhIndex) 可批量索引
为什么要这样拆?
1. UI 组件只剩编排
Playground.vue 通过 createPlaygroundEngine() 拿到 { engine, cameraSystem, transformSystem, modelSystem, ... } 几个句柄,模板里只调它们的公共方法——完全不 import three.js、不管 OrbitControls 怎么初始化、也不知道 BVH 怎么构建。
async function onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
await modelSystem.loadFile(file);
cameraSystem.focus(modelSystem.root);
}2. System 之间通过 engine.getSystem 松耦合
TransformSystem 需要暂停相机:
releaseCamera = engine.getSystem(CameraSystem)?.suspendControls() ?? null;不需要 import 相机实例、不需要事件总线手动协调,也不需要 DI 容器——engine 本身就是 service locator。
3. Script 自动生命周期
PickableMesh 的 onInit 注册指针订阅,onDestroy 注销。当 ModelLoaderSystem.unload() 调 world.removeEntity(rootEntity) 时,整棵子树的 Script 都会触发 onDestroy——不会有悬空的 pointer handler。
export const PickableMesh = createScript('PickableMesh', ({ engine, entity }) => {
const handlers: (() => void)[] = [];
return {
onInit() {
handlers.push(entity.onPointer('click', (e) => { /* ... */ }));
},
onDestroy() {
handlers.forEach(off => off());
},
};
});4. Component 是查询的锚点
给每个 mesh entity 附 BvhIndex 后,未来要批量操作(统计、重建、可视化 helper)时,一行查询搞定:
const entities = world.query(BvhIndex).list();
entities.forEach(e => { /* ... */ });两个关键陷阱的处理
拖拽 Gizmo 时点击穿透到模型
engine 的指针运行时用 pointerRuntime.flushFrame() 在每帧开头批处理 DOM 事件队列——如果在 mouseUp 里同步释放 pointer.suspend(),当前帧 flush 时 suspend 已经是 false,click 还是会被派发。
TransformSystem 里的解法:
const releaseLocksDeferred = () => {
const pendingPointer = releasePointer;
releasePointer = null;
requestAnimationFrame(() => pendingPointer?.()); // 下一帧再释放
};
controls.addEventListener('mouseUp', releaseLocksDeferred);ViewportGizmo 需要独立 DOM 容器
直接 new ViewportGizmo(camera, renderer) 不传 container 的话,gizmo 没有地方挂自己的 canvas。CameraSystem 在 onReady 时动态创建一个 120×120 的 absolute div 加到 view.rootEl:
gizmoContainer = document.createElement('div');
Object.assign(gizmoContainer.style, { position: 'absolute', right: '0', top: '0', width: '120px', height: '120px', pointerEvents: 'auto' });
view.rootEl!.appendChild(gizmoContainer);
gizmo = new ViewportGizmo(view.camera, view.renderer!, { container: gizmoContainer, /* ... */ });源码参考
完整实现见仓库 packages/doc/src/engine/playground/_playground-demos/。