乐于分享
好东西不私藏

Three.js 场景编辑器实战:从零实现拖拽选中、变换控制与属性面板

Three.js 场景编辑器实战:从零实现拖拽选中、变换控制与属性面板

你用过 Blender、Unity Editor,有没有想过——这些编辑器的核心交互逻辑,能不能在浏览器里用 Three.js 自己实现一套?答案是:完全可以,而且比你想象的简单得多。

本文目标很明确:从零搭一个能用的迷你 3D 场景编辑器,核心功能包括:

  • • ✅ 点击选中场景中的任意物体(高亮显示)
  • • ✅ TransformControls 控制平移 / 旋转 / 缩放
  • • ✅ 键盘快捷键切换变换模式(W / E / R)
  • • ✅ 属性面板实时显示并编辑选中物体的位置、旋转、缩放
  • • ✅ 多选支持(Shift + 点击)
  • • ✅ 撤销 / 重做(Ctrl+Z / Ctrl+Y)

一、为什么场景编辑器是学习 Three.js 的绝佳项目?

场景编辑器综合考验以下能力:

能力
对应知识点
鼠标拾取物体
Raycaster + 射线检测
拖拽变换
TransformControls
视角控制
OrbitControls
UI 面板联动
lil-gui / 自定义 DOM
撤销/重做
命令模式(Command Pattern)
高亮效果
材质替换 / 描边后处理

把这些能力串起来,你就掌握了 3D 编辑器的核心骨架——无论是在线设计工具、游戏地图编辑器还是工业可视化配置台,底层逻辑都是一样的。


二、项目准备

安装依赖

npm create vite@latest threejs-editor -- --template vanillacd threejs-editornpm install threenpm install lil-gui

目录结构

src/├── main.ts          # 入口├── Editor.ts        # 编辑器核心类├── SelectionManager.ts  # 选中管理├── HistoryManager.ts    # 撤销/重做├── ui/│   └── PropertiesPanel.ts  # 属性面板└── style.css

三、搭建基础场景

先把舞台搭好:

// Editor.tsimport * asTHREEfrom'three';import { OrbitControls } from'three/examples/jsm/controls/OrbitControls';import { TransformControls } from'three/examples/jsm/controls/TransformControls';exportclassEditor {rendererTHREE.WebGLRenderer;sceneTHREE.Scene;cameraTHREE.PerspectiveCamera;orbitControlsOrbitControls;transformControlsTransformControls;constructor(containerHTMLElement) {// 渲染器this.renderer = newTHREE.WebGLRenderer({ antialiastrue });this.renderer.setSize(container.clientWidth, container.clientHeight);this.renderer.setPixelRatio(window.devicePixelRatio);this.renderer.shadowMap.enabled = true;    container.appendChild(this.renderer.domElement);// 场景this.scene = newTHREE.Scene();this.scene.background = newTHREE.Color(0x1a1a2e);// 网格辅助线const grid = newTHREE.GridHelper(20200x4444660x333355);this.scene.add(grid);// 坐标轴const axes = newTHREE.AxesHelper(5);this.scene.add(axes);// 相机this.camera = newTHREE.PerspectiveCamera(60,      container.clientWidth / container.clientHeight,0.1,1000    );this.camera.position.set(888);this.camera.lookAt(000);// 灯光this.setupLights();// 轨道控制器this.orbitControls = newOrbitControls(this.camerathis.renderer.domElement);this.orbitControls.enableDamping = true;// 变换控制器this.transformControls = newTransformControls(this.camerathis.renderer.domElement);this.scene.add(this.transformControls);// TransformControls 激活时禁用 OrbitControls(防止冲突)this.transformControls.addEventListener('dragging-changed'(event) => {this.orbitControls.enabled = !event.value;    });// 添加示例物体this.addSampleObjects();// 启动渲染循环this.animate();// 响应窗口缩放window.addEventListener('resize'() =>this.onResize(container));  }privatesetupLights() {const ambient = newTHREE.AmbientLight(0xffffff0.4);this.scene.add(ambient);const directional = newTHREE.DirectionalLight(0xffffff1.0);    directional.position.set(101510);    directional.castShadow = true;this.scene.add(directional);  }privateaddSampleObjects() {const materials = [newTHREE.MeshStandardMaterial({ color0xe74c3c }),newTHREE.MeshStandardMaterial({ color0x3498db }),newTHREE.MeshStandardMaterial({ color0x2ecc71 }),    ];const geometries = [newTHREE.BoxGeometry(111),newTHREE.SphereGeometry(0.63232),newTHREE.ConeGeometry(0.61.232),    ];const positions = [newTHREE.Vector3(-30.50),newTHREE.Vector3(00.60),newTHREE.Vector3(30.60),    ];    geometries.forEach((geo, i) => {const mesh = newTHREE.Mesh(geo, materials[i]);      mesh.position.copy(positions[i]);      mesh.castShadow = true;      mesh.receiveShadow = true;      mesh.name = `Object_${i + 1}`;this.scene.add(mesh);    });  }privateanimate() {requestAnimationFrame(() =>this.animate());this.orbitControls.update();this.renderer.render(this.scenethis.camera);  }privateonResize(containerHTMLElement) {const w = container.clientWidth;const h = container.clientHeight;this.camera.aspect = w / h;this.camera.updateProjectionMatrix();this.renderer.setSize(w, h);  }}

四、选中管理器(核心)

这是整个编辑器的灵魂——用 Raycaster 检测鼠标点击的物体,并给选中物体添加高亮效果。

// SelectionManager.tsimport * asTHREEfrom'three';typeSelectionChangeCallback = (selected: THREE.Object3D[]) =>void;exportclassSelectionManager {privatesceneTHREE.Scene;privatecameraTHREE.Camera;privatecanvasHTMLCanvasElement;private raycaster = newTHREE.Raycaster();private mouse = newTHREE.Vector2();// 当前选中列表selectedTHREE.Object3D[] = [];// 高亮材质(替换法)private originalMaterials = newWeakMap<THREE.MeshTHREE.Material | THREE.Material[]>();private highlightMaterial = newTHREE.MeshStandardMaterial({color0xffff00,emissive0x555500,wireframefalse,  });// 外部回调privateonChange?: SelectionChangeCallback;constructor(scene: THREE.Scene,camera: THREE.Camera,canvasHTMLCanvasElement,onChange?: SelectionChangeCallback) {this.scene = scene;this.camera = camera;this.canvas = canvas;this.onChange = onChange;    canvas.addEventListener('click'(e) =>this.onCanvasClick(e));  }privateonCanvasClick(eventMouseEvent) {// 如果点击的是变换控制器手柄,忽略if ((event.targetasHTMLElement).classList.contains('transform-handle')) return;const rect = this.canvas.getBoundingClientRect();this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;this.raycaster.setFromCamera(this.mousethis.camera);// 只检测用户添加的 Mesh,排除辅助线、坐标轴、TransformControls 的手柄const pickable = this.scene.children.filter((obj) =>        obj instanceofTHREE.Mesh &&        !(obj asany).isTransformControlsGizmo    ) asTHREE.Mesh[];const intersects = this.raycaster.intersectObjects(pickable, true);if (intersects.length === 0) {// 点击空白处,清除选中if (!event.shiftKeythis.clearSelection();return;    }const hit = intersects[0].objectasTHREE.Mesh;if (event.shiftKey) {// Shift 多选const idx = this.selected.indexOf(hit);if (idx === -1) {this.addToSelection(hit);      } else {this.removeFromSelection(hit);      }    } else {// 单选this.setSelection([hit]);    }  }setSelection(objects: THREE.Object3D[]) {this.clearSelection(false);    objects.forEach((obj) =>this.highlight(obj asTHREE.Mesh));this.selected = [...objects];this.onChange?.(this.selected);  }addToSelection(obj: THREE.Object3D) {if (!this.selected.includes(obj)) {this.highlight(obj asTHREE.Mesh);this.selected.push(obj);this.onChange?.(this.selected);    }  }removeFromSelection(obj: THREE.Object3D) {const idx = this.selected.indexOf(obj);if (idx !== -1) {this.unhighlight(obj asTHREE.Mesh);this.selected.splice(idx, 1);this.onChange?.(this.selected);    }  }clearSelection(notify = true) {this.selected.forEach((obj) =>this.unhighlight(obj asTHREE.Mesh));this.selected = [];if (notify) this.onChange?.([]);  }privatehighlight(mesh: THREE.Mesh) {if (!mesh.materialreturn;this.originalMaterials.set(mesh, mesh.material);    mesh.material = this.highlightMaterial;  }privateunhighlight(mesh: THREE.Mesh) {const original = this.originalMaterials.get(mesh);if (original) {      mesh.material = original;this.originalMaterials.delete(mesh);    }  }}

关键点解析

为什么用”材质替换”而不是颜色叠加?

直接修改 mesh.material.color 会永久改变材质,且如果多个 Mesh 共享同一个材质实例,会导致所有使用该材质的物体都变色。替换法(缓存原材质 → 换上高亮材质 → 恢复)更安全可靠。

如果你想要更漂亮的描边高亮,可以结合 Post-Processing 的 OutlinePass(见上一篇文章)。


五、变换控制(W / E / R 快捷键)

// 在 Editor.ts 中扩展setupKeyboardShortcuts() {window.addEventListener('keydown'(e) => {if (!this.selectionManager.selected.lengthreturn;switch (e.key.toLowerCase()) {case'w'// 平移this.transformControls.setMode('translate');break;case'e'// 旋转this.transformControls.setMode('rotate');break;case'r'// 缩放this.transformControls.setMode('scale');break;case'escape'// 取消选中this.selectionManager.clearSelection();this.transformControls.detach();break;case'delete'// 删除选中this.deleteSelected();break;case'z':if (e.ctrlKey || e.metaKey) {          e.shiftKey            ? this.historyManager.redo()            : this.historyManager.undo();        }break;    }  });}// 当选中物体变化时,更新 TransformControlsonSelectionChange(selected: THREE.Object3D[]) {if (selected.length === 1) {this.transformControls.attach(selected[0]);  } else {this.transformControls.detach();  }this.propertiesPanel.update(selected[0] ?? null);}

TransformControls 的三种模式

模式
快捷键
说明
translate
W
平移(显示 XYZ 三轴箭头)
rotate
E
旋转(显示 XYZ 三轴圆环)
scale
R
缩放(显示 XYZ 三轴方块)

💡 注意:TransformControls 和 OrbitControls 会争抢鼠标事件,必须监听 dragging-changed 事件,拖动变换控制器时禁用 OrbitControls。


六、撤销/重做系统(命令模式)

这是编辑器的基础设施,使用经典的命令模式(Command Pattern)

// HistoryManager.ts// 命令接口interfaceCommand {execute(): void;undo(): void;}exportclassHistoryManager {privateundoStackCommand[] = [];privateredoStackCommand[] = [];private maxHistory = 50;execute(commandCommand) {    command.execute();this.undoStack.push(command);// 执行新命令后清空 redo 栈this.redoStack = [];if (this.undoStack.length > this.maxHistory) {this.undoStack.shift();    }  }undo() {const command = this.undoStack.pop();if (command) {      command.undo();this.redoStack.push(command);    }  }redo() {const command = this.redoStack.pop();if (command) {      command.execute();this.undoStack.push(command);    }  }getcanUndo() { returnthis.undoStack.length > 0; }getcanRedo() { returnthis.redoStack.length > 0; }}// ====== 具体命令实现 ======// 平移命令exportclassTranslateCommandimplementsCommand {privateoldPositionTHREE.Vector3;privatenewPositionTHREE.Vector3;constructor(privateobject: THREE.Object3D,newPos: THREE.Vector3) {this.oldPosition = object.position.clone();this.newPosition = newPos.clone();  }execute() {this.object.position.copy(this.newPosition);  }undo() {this.object.position.copy(this.oldPosition);  }}// 删除命令exportclassDeleteCommandimplementsCommand {privateparentTHREE.Object3D | null = null;constructor(privatescene: THREE.Scene,privateobject: THREE.Object3D) {this.parent = object.parent;  }execute() {this.scene.remove(this.object);  }undo() {if (this.parent) {this.parent.add(this.object);    } else {this.scene.add(this.object);    }  }}// 属性修改命令(通用)exportclassSetPropertyCommandimplementsCommand {privateoldValueany;constructor(privateobjectany,privatepropertystring,privatenewValueany) {this.oldValue = object[property] instanceofTHREE.Vector3      ? object[property].clone()      : object[property];  }execute() {if (this.object[this.propertyinstanceofTHREE.Vector3) {this.object[this.property].copy(this.newValue);    } else {this.object[this.property] = this.newValue;    }  }undo() {if (this.object[this.propertyinstanceofTHREE.Vector3) {this.object[this.property].copy(this.oldValue);    } else {this.object[this.property] = this.oldValue;    }  }}

命令模式的精髓:每个操作都是一个对象,同时携带”执行”和”撤销”两种能力。操作历史就是一个命令对象数组,撤销就是逆序调用 undo(),重做就是再次调用 execute()


七、属性面板

用 lil-gui 实现一个轻量级属性面板,实时显示并允许编辑选中物体的变换属性:

// ui/PropertiesPanel.tsimportGUIfrom'lil-gui';import * asTHREEfrom'three';import { HistoryManagerSetPropertyCommand } from'../HistoryManager';exportclassPropertiesPanel {privateguiGUI;privatepositionFolderGUI;privaterotationFolderGUI;privatescaleFolderGUI;privatecurrentObjectTHREE.Object3D | null = null;// 用于 GUI 绑定的中间对象(避免直接操作 Object3D)private proxy = {name'',px0py0pz0,rx0ry0rz0,sx1sy1sz1,  };constructor(privatehistoryHistoryManager) {this.gui = newGUI({ title'属性面板'width280 });this.gui.domElement.style.position = 'fixed';this.gui.domElement.style.top = '10px';this.gui.domElement.style.right = '10px';const infoFolder = this.gui.addFolder('基本信息');    infoFolder.add(this.proxy'name').name('名称').listen()      .onChange(() => {if (this.currentObjectthis.currentObject.name = this.proxy.name;      });this.positionFolder = this.gui.addFolder('位置 (Position)');    ['px''py''pz'].forEach((key, i) => {const axis = ['x''y''z'][i];this.positionFolder.add(this.proxy, key, -20200.01)        .name(axis.toUpperCase())        .listen()        .onChange((vnumber) => {if (!this.currentObjectreturn;const oldVal = this.currentObject.position[axis as'x' | 'y' | 'z'];const newVec = this.currentObject.position.clone();          newVec[axis as'x' | 'y' | 'z'] = v;this.history.execute(newSetPropertyCommand(this.currentObject.position, axis, v)          );        });    });this.rotationFolder = this.gui.addFolder('旋转 (Rotation / deg)');    ['rx''ry''rz'].forEach((key, i) => {const axis = ['x''y''z'][i];this.rotationFolder.add(this.proxy, key, -1801801)        .name(axis.toUpperCase())        .listen()        .onChange((vnumber) => {if (!this.currentObjectreturn;this.currentObject.rotation[axis as'x' | 'y' | 'z'] = THREE.MathUtils.degToRad(v);        });    });this.scaleFolder = this.gui.addFolder('缩放 (Scale)');    ['sx''sy''sz'].forEach((key, i) => {const axis = ['x''y''z'][i];this.scaleFolder.add(this.proxy, key, 0.0150.01)        .name(axis.toUpperCase())        .listen()        .onChange((vnumber) => {if (!this.currentObjectreturn;this.currentObject.scale[axis as'x' | 'y' | 'z'] = v;        });    });this.update(null);  }// 选中物体变化时调用update(object: THREE.Object3D | null) {this.currentObject = object;if (!object) {this.gui.title('属性面板(未选中)');return;    }this.gui.title(`属性面板 — ${object.name || 'Object'}`);this.proxy.name = object.name;// 同步位置this.proxy.px = parseFloat(object.position.x.toFixed(3));this.proxy.py = parseFloat(object.position.y.toFixed(3));this.proxy.pz = parseFloat(object.position.z.toFixed(3));// 旋转转换为角度this.proxy.rx = parseFloat(THREE.MathUtils.radToDeg(object.rotation.x).toFixed(1));this.proxy.ry = parseFloat(THREE.MathUtils.radToDeg(object.rotation.y).toFixed(1));this.proxy.rz = parseFloat(THREE.MathUtils.radToDeg(object.rotation.z).toFixed(1));// 缩放this.proxy.sx = parseFloat(object.scale.x.toFixed(3));this.proxy.sy = parseFloat(object.scale.y.toFixed(3));this.proxy.sz = parseFloat(object.scale.z.toFixed(3));  }}

🔑 关键设计:GUI 绑定的是 proxy 中间对象而不是直接绑 mesh.position.x,这样可以在 onChange 中插入历史记录逻辑,实现撤销支持。同时旋转值用角度制显示(弧度制对用户不友好)。


八、把所有模块串起来

// main.tsimport'./style.css';import { Editor } from'./Editor';import { SelectionManager } from'./SelectionManager';import { HistoryManager } from'./HistoryManager';import { PropertiesPanel } from'./ui/PropertiesPanel';const container = document.getElementById('app')!;// 初始化各模块const editor = newEditor(container);const history = newHistoryManager();const propertiesPanel = newPropertiesPanel(history);const selectionManager = newSelectionManager(  editor.scene,  editor.camera,  editor.renderer.domElement,(selected) => {// 更新 TransformControlsif (selected.length === 1) {      editor.transformControls.attach(selected[0]);    } else {      editor.transformControls.detach();    }// 更新属性面板    propertiesPanel.update(selected[0] ?? null);  });// TransformControls 拖拽结束时记录历史editor.transformControls.addEventListener('mouseUp'() => {const obj = editor.transformControls.object;if (!obj) return;// 这里简化处理:直接快照当前位置到历史// 生产中应在拖拽开始时记录 oldValue,结束时提交命令console.log(`变换结束: ${obj.name}`, obj.position);});// 键盘快捷键window.addEventListener('keydown'(e) => {switch (e.key.toLowerCase()) {case'w': editor.transformControls.setMode('translate'); break;case'e': editor.transformControls.setMode('rotate'); break;case'r': editor.transformControls.setMode('scale'); break;case'escape':      selectionManager.clearSelection();      editor.transformControls.detach();break;case'delete':case'backspace':      selectionManager.selected.forEach((obj) => {        editor.scene.remove(obj);      });      selectionManager.clearSelection();break;case'z':if (e.ctrlKey || e.metaKey) {        e.preventDefault();        e.shiftKey ? history.redo() : history.undo();      }break;  }});

九、进阶扩展:Outline 描边高亮

上面用的是”替换材质”方案,效果基本够用。如果想要更专业的描边高亮,结合 Post-Processing 的 OutlinePass

import { EffectComposer } from'three/examples/jsm/postprocessing/EffectComposer';import { RenderPass } from'three/examples/jsm/postprocessing/RenderPass';import { OutlinePass } from'three/examples/jsm/postprocessing/OutlinePass';const composer = newEffectComposer(renderer);composer.addPass(newRenderPass(scene, camera));const outlinePass = newOutlinePass(newTHREE.Vector2(window.innerWidthwindow.innerHeight),  scene,  camera);outlinePass.edgeStrength = 3;outlinePass.edgeGlow = 0.5;outlinePass.edgeThickness = 2;outlinePass.visibleEdgeColor.set('#ffff00');composer.addPass(outlinePass);// 选中物体时,更新 OutlinePass 的目标functiononSelectionChange(selected: THREE.Object3D[]) {  outlinePass.selectedObjects = selected;}// 渲染时用 composer.render() 代替 renderer.render()functionanimate() {requestAnimationFrame(animate);  orbitControls.update();  composer.render();  // 替换这里}

OutlinePass 的效果比替换材质好得多——选中物体会有明亮的黄色描边,而不是整体变色,视觉上更专业。


十、完整键盘快捷键一览

快捷键
功能
W
切换到平移模式
E
切换到旋转模式
R
切换到缩放模式
Escape
取消选中,隐藏变换控制器
Delete

 / Backspace
删除选中物体
Ctrl + Z
撤销
Ctrl + Shift + Z
重做
Shift + 点击
多选

十一、常见坑与解决方案

坑1:TransformControls 的点击事件和 Raycaster 冲突

现象:点击变换控制器的手柄时,会同时触发 Raycaster 的选中逻辑,导致选中清空。

解决:在 Raycaster 的 intersectObjects 时,只检测场景中的用户 Mesh,排除 TransformControls 的子对象:

const pickable = scene.children.filter((obj) => obj.type === 'Mesh' && !obj.userData.isHelper);

或者更准确的做法:监听 transformControls.dragging 状态,拖动时禁用点击检测。

坑2:Raycaster 射线检测到不可见物体

// 过滤掉不可见和辅助对象const pickable = scene.children.filter((obj) => obj.visible && obj instanceofTHREE.Mesh);

坑3:GUI 的 .listen() 值不同步

.listen() 让 GUI 在每帧自动读取绑定变量的值。但如果绑的是基础数据类型(如 proxy.px),需要在渲染循环中手动同步:

functionanimate() {requestAnimationFrame(animate);// 如果有选中物体,持续同步属性到 proxy(用于外部拖拽时面板实时更新)if (currentObject) {    propertiesPanel.sync(currentObject);  }  composer.render();}

坑4:多选时 TransformControls 的行为

TransformControls.attach() 只支持单个对象。多选时:

  • • 方案 A:只附加到最后一个选中的对象
  • • 方案 B:创建一个临时的空 Group,把所有选中对象挂入,再 attach 到 Group
  • • 方案 C:禁用 TransformControls,改用自定义拖拽逻辑

方案 B 是最符合 Blender/Unity 使用习惯的:

const group = newTHREE.Group();scene.add(group);selected.forEach((obj) => group.attach(obj));transformControls.attach(group);// 取消选中时,把对象从 group 还原回 scenefunctiondetachGroup() {  group.children.slice().forEach((obj) => scene.attach(obj));  scene.remove(group);  transformControls.detach();}

十二、总结:场景编辑器的架构模型

┌─────────────────────────────────────────────────────┐│                    Scene Editor                     │├──────────────┬──────────────┬─────────────────────  ││  Viewport    │  Selection   │  History              ││  (Three.js   │  Manager     │  (Command Pattern     ││   Renderer)  │  (Raycaster) │   undo/redo stack)    │├──────────────┴──────────────┴─────────────────────  ││         TransformControls (W/E/R modes)             │├─────────────────────────────────────────────────────││         Properties Panel (lil-gui / Custom DOM)     │└─────────────────────────────────────────────────────┘

一个完整的场景编辑器由四大模块组成:

  1. 1. 视口(Viewport):Three.js 渲染场景
  2. 2. 选中管理器(SelectionManager):Raycaster 射线检测 + 高亮效果
  3. 3. 历史管理器(HistoryManager):命令模式实现撤销/重做
  4. 4. 属性面板(PropertiesPanel):GUI 绑定 + 实时同步

这四个模块都是解耦的——选中管理器不知道属性面板的存在,历史管理器不知道渲染器的存在,它们通过回调函数(或事件总线)松耦合地协作。


掌握这个架构后,你完全可以在此基础上扩展:添加场景层级树面板、材质编辑器、灯光控制、导入导出 GLTF……Blender 能做的,Three.js 同样可以做——只是需要你一砖一瓦地建起来。