Three.js 场景编辑器实战:从零实现拖拽选中、变换控制与属性面板
你用过 Blender、Unity Editor,有没有想过——这些编辑器的核心交互逻辑,能不能在浏览器里用 Three.js 自己实现一套?答案是:完全可以,而且比你想象的简单得多。
本文目标很明确:从零搭一个能用的迷你 3D 场景编辑器,核心功能包括:
-
• ✅ 点击选中场景中的任意物体(高亮显示) -
• ✅ TransformControls 控制平移 / 旋转 / 缩放 -
• ✅ 键盘快捷键切换变换模式(W / E / R) -
• ✅ 属性面板实时显示并编辑选中物体的位置、旋转、缩放 -
• ✅ 多选支持(Shift + 点击) -
• ✅ 撤销 / 重做(Ctrl+Z / Ctrl+Y)
一、为什么场景编辑器是学习 Three.js 的绝佳项目?
场景编辑器综合考验以下能力:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
把这些能力串起来,你就掌握了 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 {renderer: THREE.WebGLRenderer;scene: THREE.Scene;camera: THREE.PerspectiveCamera;orbitControls: OrbitControls;transformControls: TransformControls;constructor(container: HTMLElement) {// 渲染器this.renderer = newTHREE.WebGLRenderer({ antialias: true });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(20, 20, 0x444466, 0x333355);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(8, 8, 8);this.camera.lookAt(0, 0, 0);// 灯光this.setupLights();// 轨道控制器this.orbitControls = newOrbitControls(this.camera, this.renderer.domElement);this.orbitControls.enableDamping = true;// 变换控制器this.transformControls = newTransformControls(this.camera, this.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(0xffffff, 0.4);this.scene.add(ambient);const directional = newTHREE.DirectionalLight(0xffffff, 1.0); directional.position.set(10, 15, 10); directional.castShadow = true;this.scene.add(directional); }privateaddSampleObjects() {const materials = [newTHREE.MeshStandardMaterial({ color: 0xe74c3c }),newTHREE.MeshStandardMaterial({ color: 0x3498db }),newTHREE.MeshStandardMaterial({ color: 0x2ecc71 }), ];const geometries = [newTHREE.BoxGeometry(1, 1, 1),newTHREE.SphereGeometry(0.6, 32, 32),newTHREE.ConeGeometry(0.6, 1.2, 32), ];const positions = [newTHREE.Vector3(-3, 0.5, 0),newTHREE.Vector3(0, 0.6, 0),newTHREE.Vector3(3, 0.6, 0), ]; 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.scene, this.camera); }privateonResize(container: HTMLElement) {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 {privatescene: THREE.Scene;privatecamera: THREE.Camera;privatecanvas: HTMLCanvasElement;private raycaster = newTHREE.Raycaster();private mouse = newTHREE.Vector2();// 当前选中列表selected: THREE.Object3D[] = [];// 高亮材质(替换法)private originalMaterials = newWeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>();private highlightMaterial = newTHREE.MeshStandardMaterial({color: 0xffff00,emissive: 0x555500,wireframe: false, });// 外部回调privateonChange?: SelectionChangeCallback;constructor(scene: THREE.Scene,camera: THREE.Camera,canvas: HTMLCanvasElement,onChange?: SelectionChangeCallback) {this.scene = scene;this.camera = camera;this.canvas = canvas;this.onChange = onChange; canvas.addEventListener('click', (e) =>this.onCanvasClick(e)); }privateonCanvasClick(event: MouseEvent) {// 如果点击的是变换控制器手柄,忽略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.mouse, this.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.shiftKey) this.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.material) return;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.length) return;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 |
|
|
rotate |
|
|
scale |
|
|
💡 注意:TransformControls 和 OrbitControls 会争抢鼠标事件,必须监听
dragging-changed事件,拖动变换控制器时禁用 OrbitControls。
六、撤销/重做系统(命令模式)
这是编辑器的基础设施,使用经典的命令模式(Command Pattern):
// HistoryManager.ts// 命令接口interfaceCommand {execute(): void;undo(): void;}exportclassHistoryManager {privateundoStack: Command[] = [];privateredoStack: Command[] = [];private maxHistory = 50;execute(command: Command) { 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 {privateoldPosition: THREE.Vector3;privatenewPosition: THREE.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 {privateparent: THREE.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 {privateoldValue: any;constructor(privateobject: any,privateproperty: string,privatenewValue: any) {this.oldValue = object[property] instanceofTHREE.Vector3 ? object[property].clone() : object[property]; }execute() {if (this.object[this.property] instanceofTHREE.Vector3) {this.object[this.property].copy(this.newValue); } else {this.object[this.property] = this.newValue; } }undo() {if (this.object[this.property] instanceofTHREE.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 { HistoryManager, SetPropertyCommand } from'../HistoryManager';exportclassPropertiesPanel {privategui: GUI;privatepositionFolder: GUI;privaterotationFolder: GUI;privatescaleFolder: GUI;privatecurrentObject: THREE.Object3D | null = null;// 用于 GUI 绑定的中间对象(避免直接操作 Object3D)private proxy = {name: '',px: 0, py: 0, pz: 0,rx: 0, ry: 0, rz: 0,sx: 1, sy: 1, sz: 1, };constructor(privatehistory: HistoryManager) {this.gui = newGUI({ title: '属性面板', width: 280 });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.currentObject) this.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, -20, 20, 0.01) .name(axis.toUpperCase()) .listen() .onChange((v: number) => {if (!this.currentObject) return;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, -180, 180, 1) .name(axis.toUpperCase()) .listen() .onChange((v: number) => {if (!this.currentObject) return;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.01, 5, 0.01) .name(axis.toUpperCase()) .listen() .onChange((v: number) => {if (!this.currentObject) return;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.innerWidth, window.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. 视口(Viewport):Three.js 渲染场景 -
2. 选中管理器(SelectionManager):Raycaster 射线检测 + 高亮效果 -
3. 历史管理器(HistoryManager):命令模式实现撤销/重做 -
4. 属性面板(PropertiesPanel):GUI 绑定 + 实时同步
这四个模块都是解耦的——选中管理器不知道属性面板的存在,历史管理器不知道渲染器的存在,它们通过回调函数(或事件总线)松耦合地协作。
掌握这个架构后,你完全可以在此基础上扩展:添加场景层级树面板、材质编辑器、灯光控制、导入导出 GLTF……Blender 能做的,Three.js 同样可以做——只是需要你一砖一瓦地建起来。
夜雨聆风