一、为什么官方给你 3 个 Behavior,却还要自己拼?
Babylon.js 自带:
-
BoundingBoxBehavior------ 可视化包围盒 + 8 角拖拽缩放 -
PointerDragBehavior/SixDoFDragBehavior------ 平移 -
PointerRotateBehavior------ 旋转
但它们各自为政:
-
缩放时不会同步更新旋转 gizmo 的位置;
-
拖拽时不会把数据写回自定义组件;
-
没有「选中态」概念,多选直接抓瞎。
所以「编辑器级」需求必须再包一层「组合 Behavior」:
-
统一监听拖拽、旋转、缩放事件 → 写回 TransformNode;
-
维护「选中集合」→ 高亮 + 成组操作;
-
把每一次改动序列化成 JSON → 支持 Ctrl+Z / Ctrl+Y;
-
提供插件接口,让任何 Mesh 一键变成「可编辑实体」。
下面给出一份「生产可用」的最小模板,复制即可嵌入你的工具链。
二、整体架构:一个主 Behavior 统领三员大将
┌-------------------------┐
│ EditableBehavior │ ← 我们自己写
│ = 统领 + 选中态 + 撤销池 │
└----------┬--------------┘
│ 组合(attach 阶段动态创建)
▼
┌-------------------------┐
│ BoundingBoxBehavior │ ← 官方:8 角缩放
│ PointerDragBehavior │ ← 官方:平面拖拽
│ PointerRotateBehavior │ ← 官方:绕轴旋转
└-------------------------┘
EditableBehavior 负责三件事:
-
生命周期:attach 时按优先级依次添加三个子 Behavior;detach 时全部清理;
-
事件聚合:监听
onDragObservable/onRotateObservable/onScaleObservable→ 统一写回target.position/rotation/scaling; -
数据通道:每一次「写回」都生成一条
ICommand,推进撤销栈。
三、代码:EditableBehavior 最小可用版
TypeScript
import {
Behavior, TransformNode, Mesh, BoundingBoxBehavior,
PointerDragBehavior, PointerRotateBehavior,
Observable, Vector3, Quaternion
} from '@babylonjs/core';
export interface IEditableCommand {
type: 'move' | 'rotate' | 'scale';
old: { pos?: Vector3, rot?: Quaternion, scale?: Vector3 };
cur: { pos?: Vector3, rot?: Quaternion, scale?: Vector3 };
}
export class EditableBehavior implements Behavior<TransformNode> {
public name = 'editable';
/* ---- 子 Behavior ---- */
private bbox = new BoundingBoxBehavior();
private drag = new PointerDragBehavior();
private rotate = new PointerRotateBehavior();
/* ---- 外部可注入 ---- */
public onCommand = new Observable<IEditableCommand>();
private _enabled = true;
/* ---- 生命周期 ---- */
attach(target: TransformNode): void {
// 1. 附加子 Behavior
target.addBehavior(this.bbox);
target.addBehavior(this.drag);
target.addBehavior(this.rotate);
// 2. 注册事件 → 写回 + 发命令
this._wireEvents();
}
detach(): void {
target.removeBehavior(this.bbox);
target.removeBehavior(this.drag);
target.removeBehavior(this.rotate);
}
/* ---- 事件聚合 ---- */
private _wireEvents(): void {
// 拖拽
this.drag.onDragObservable.add(e => this._raise('move', e));
// 旋转
this.rotate.onRotateObservable.add(e => this._raise('rotate', e));
// 缩放(BoundingBoxBehavior 没有 Observable,我们劫持角点)
this.bbox.onScaleObservable.add(e => this._raise('scale', e));
}
private _raise(type: IEditableCommand['type'], e: any): void {
if (!this._enabled) return;
const node = e.target as TransformNode;
const cmd: IEditableCommand = {
type,
old: {
pos: node.position.clone(),
rot: node.rotationQuaternion?.clone() ?? Quaternion.RotationYawPitchRoll(node.rotation.y, node.rotation.x, node.rotation.z),
scale: node.scaling.clone()
},
cur: { /* 将在 commit 阶段填充 */ }
};
// 延迟到拖拽结束再发,避免连续刷屏
e.dragEndObservable.addOnce(() => {
cmd.cur = {
pos: node.position.clone(),
rot: node.rotationQuaternion?.clone() ?? Quaternion.RotationYawPitchRoll(node.rotation.y, node.rotation.x, node.rotation.z),
scale: node.scaling.clone()
};
this.onCommand.notifyObservers(cmd);
});
}
/* ---- 选中态开关 ---- */
setEnabled(v: boolean): void {
this._enabled = v;
this.bbox.enabled = v;
this.drag.enabled = v;
this.rotate.enabled = v;
}
}
四、撤销 / 重做:再包一层 CommandManager
TypeScript
export class CommandManager {
private stack: IEditableCommand[] = [];
private idx = -1;
push(cmd: IEditableCommand) {
this.stack.splice(this.idx + 1); // 丢弃重做分支
this.stack.push(cmd);
this.idx++;
}
undo() {
if (this.idx < 0) return;
const cmd = this.stack[this.idx--];
this._apply(cmd.old, cmd.type);
}
redo() {
if (this.idx >= this.stack.length - 1) return;
const cmd = this.stack[++this.idx];
this._apply(cmd.cur, cmd.type);
}
private _apply(state: IEditableCommand['old'], type: IEditableCommand['type']) {
const node = state.target; // 这里简化,真实场景要在 cmd 里存 nodeId
if (state.pos) node.position = state.pos.clone();
if (state.rot) node.rotationQuaternion = state.rot.clone();
if (state.scale) node.scaling = state.scale.clone();
}
}
使用时:
TypeScript
const cmdMgr = new CommandManager();
scene.onKeyboardObservable.add(kb => {
if (kb.type === BABYLON.KeyboardEventTypes.KEYDOWN) {
if (kb.event.ctrlKey && kb.event.key === 'z') cmdMgr.undo();
if (kb.event.ctrlKey && kb.event.key === 'y') cmdMgr.redo();
}
});
五、多选 & 成组操作:把 Behavior 挂到「选择集」上
EditableBehavior 只负责单节点,多选时我们给「选择集」再挂一个「代理 Behavior」:
TypeScript
export class MultiEditableBehavior implements Behavior<TransformNode> {
public name = 'multiEditable';
private _selection: TransformNode[] = [];
attach(): void {
// 1. 计算共同中心
const center = this._getCenter();
// 2. 临时创建一个空节点当「组轴心」
const proxy = Mesh.CreateBox('__proxy', 0.01, scene);
proxy.position = center;
proxy.isVisible = false;
// 3. 挂 EditableBehavior
const eb = new EditableBehavior();
proxy.addBehavior(eb);
// 4. 监听命令 → 把改动分摊到每个成员
eb.onCommand.add(cmd => this._distribute(cmd));
}
private _distribute(cmd: IEditableCommand) {
this._selection.forEach(node => {
if (cmd.type === 'move') node.position.addInPlace(cmd.deltaPos);
if (cmd.type === 'rotate') node.rotationQuaternion = cmd.deltaRot.multiply(node.rotationQuaternion);
if (cmd.type === 'scale') node.scaling.multiplyInPlace(cmd.deltaScale);
});
}
}
六、把配置写回磁盘:序列化约定
Babylon 原生 .babylon 格式不会保存 Behavior 参数,所以我们约定:
-
每个可编辑节点在
metadata里插一个editable: true标记; -
保存场景时,把
CommandManager.stack里所有IEditableCommand导出成 JSON; -
下次加载后,按顺序
redo()一遍,即可还原用户操作。
TypeScript
const sceneStr = JSON.stringify(scene, null, 2);
const cmdStr = JSON.stringify(cmdMgr.stack, EditableCommand.Replacer);
localStorage.setItem('scene', sceneStr);
localStorage.setItem('cmds', cmdStr);
七、性能 & 体验锦囊
-
缩放时关闭旋转 :
rotate.enabled = false直到拖拽结束,避免 Gizmo 打架; -
大场景只显示 1 个 BoundingBox :选中切换时把旧的
setEnabled(false),新的setEnabled(true),DrawCall 不会爆炸; -
角点贴图替换 :
bbox.dragTexture = new Texture('corner.png'),让 8 个角一眼就能点; -
手机端加
SixDoFDragBehavior:自动识别 XR 与普通指针,一套代码多端复用。
八、一行总结
把「官方三件套」包进一个 EditableBehavior,再外挂 CommandManager + MultiEditableBehavior,
你就拥有:
✅ 包围盒可视化缩放
✅ 平面拖拽 + 绕轴旋转
✅ Ctrl+Z / Ctrl+Y
✅ 多选成组
✅ 序列化还原
复制上文代码,10 分钟就能让任何 Babylon 场景秒变「零代码」3D 编辑器。
下次需求评审再听到"能不能像 Unity 那样拖一拖就调好",你可以直接甩链接:"能,已经上线了。"