Babylon.js内置行为介绍之一:用 BoundingBoxBehavior + Gizmo 组合打造「零代码」3D 编辑器

一、为什么官方给你 3 个 Behavior,却还要自己拼?

Babylon.js 自带:

  • BoundingBoxBehavior ------ 可视化包围盒 + 8 角拖拽缩放

  • PointerDragBehavior / SixDoFDragBehavior ------ 平移

  • PointerRotateBehavior ------ 旋转

但它们各自为政:

  • 缩放时不会同步更新旋转 gizmo 的位置;

  • 拖拽时不会把数据写回自定义组件;

  • 没有「选中态」概念,多选直接抓瞎。

所以「编辑器级」需求必须再包一层「组合 Behavior」:

  1. 统一监听拖拽、旋转、缩放事件 → 写回 TransformNode;

  2. 维护「选中集合」→ 高亮 + 成组操作;

  3. 把每一次改动序列化成 JSON → 支持 Ctrl+Z / Ctrl+Y;

  4. 提供插件接口,让任何 Mesh 一键变成「可编辑实体」。

下面给出一份「生产可用」的最小模板,复制即可嵌入你的工具链


二、整体架构:一个主 Behavior 统领三员大将

复制代码
┌-------------------------┐
│  EditableBehavior       │  ← 我们自己写
│  = 统领 + 选中态 + 撤销池        │
└----------┬--------------┘
           │ 组合(attach 阶段动态创建)
           ▼
┌-------------------------┐
│ BoundingBoxBehavior     │  ← 官方:8 角缩放
│ PointerDragBehavior     │  ← 官方:平面拖拽
│ PointerRotateBehavior   │  ← 官方:绕轴旋转
└-------------------------┘

EditableBehavior 负责三件事:

  1. 生命周期:attach 时按优先级依次添加三个子 Behavior;detach 时全部清理;

  2. 事件聚合:监听 onDragObservable / onRotateObservable / onScaleObservable → 统一写回 target.position/rotation/scaling

  3. 数据通道:每一次「写回」都生成一条 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 参数,所以我们约定:

  1. 每个可编辑节点在 metadata 里插一个 editable: true 标记;

  2. 保存场景时,把 CommandManager.stack 里所有 IEditableCommand 导出成 JSON;

  3. 下次加载后,按顺序 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 那样拖一拖就调好",你可以直接甩链接:"能,已经上线了。"

相关推荐
ttod_qzstudio4 小时前
把“行为”做成乐高——Babylon.js Behavior 开发套路
生命周期·behavior·babylon.js·内存安全·非空断言
ttod_qzstudio18 小时前
从一个隐蔽的 Bug 谈 Babylon.js 对象生命周期管理
babylon.js
ttod_qzstudio1 天前
Babylonjs中手搓OutlineLayer:替代HighlightLayer的高性能轮廓线
babylon.js
ttod_qzstudio5 天前
MirrorReflectionBehaviorEditor 开发心得:Babylon.js 镜面反射的实现与优化
babylon.js·mirrortexture
ttod_qzstudio5 天前
从Unity的C#到Babylon.js的typescript:“函数重载“变成“类型魔法“
typescript·c#·重载·babylon.js
ttod_qzstudio11 天前
Babylon.js TransformNode.clone() 的隐形陷阱:当 null 不等于 null
babylon.js
ttod_qzstudio15 天前
备忘录之Babylon.js 子对象获取方法
babylon.js
ttod_qzstudio22 天前
深入理解 Babylon.js:TransformNode.setParent 与 parent 赋值的核心差异
babylon.js
ttod_qzstudio1 个月前
Babylon.js中欧拉角与四元数转换的完整指南
babylon.js