1. 为什么我不建议你从零手写渲染循环很多人第一次接触 three.js 编辑器开发脑子里蹦出来的第一个念头是我要自己写一套渲染管线。这个想法本身没错但如果你真的从requestAnimationFrame开始一行行搭大概率会在第三天卡在为什么拖拽物体时场景会闪烁这种问题上。我见过太多类似的案例包括我自己早期做的一个小工具当时为了追求所谓的完全可控硬是用原生 WebGL 写了个简易场景管理器结果光是处理相机矩阵和物体拾取的坐标转换就耗掉了整整一周。three.js 的价值不在于它帮你省了多少行代码而在于它把场景图Scene Graph、相机投影、光照模型、材质系统这些概念用一套统一的抽象封装好了。你要做的是一个编辑器不是渲染引擎。编辑器的核心诉求是让用户能直观地操作三维空间中的对象而不是证明我能手写矩阵运算。所以我的建议很明确用 three.js 做渲染底座把精力全部放在编辑器交互逻辑上。具体来说你需要 three.js 帮你解决这几件事场景管理THREE.Scene作为根节点所有物体挂上去就能被渲染相机控制OrbitControls直接给你轨道旋转、平移、缩放射线拾取Raycaster帮你把屏幕坐标转成三维空间中的物体引用变换控制TransformControls提供拖拽手柄省去自己写 gizmo 的功夫这四个模块是编辑器的骨架。剩下的工作比如属性面板、层级树、撤销重做、序列化保存才是你真正要花时间打磨的地方。注意TransformControls和OrbitControls在 three.js 的 examples 目录里不是核心包的一部分。你需要从three/examples/jsm/controls/路径导入别傻乎乎地去 npm 上找单独的包。我见过有人为了减少依赖把这两个控件用原生鼠标事件重写了一遍结果拖拽手柄的旋转轴判断逻辑写了八百行还没搞定。这不是勤奋这是跟自己过不去。2. 编辑器的最小可用骨架长什么样在动手写代码之前先把编辑器的功能边界划清楚。一个简单编辑器到底要支持什么我的定义是能加载模型、能选中物体、能变换物体、能修改材质、能保存场景。这五件事覆盖了 80% 的使用场景剩下的高级功能可以后续迭代。2.1 目录结构与模块划分我习惯把编辑器拆成四个独立模块每个模块只干一件事editor/ core/ SceneManager.js // 场景、相机、渲染器初始化 SelectionManager.js // 射线拾取与选中状态管理 TransformManager.js // 变换控制器封装 ui/ HierarchyPanel.js // 层级树面板 PropertyPanel.js // 属性编辑面板 Toolbar.js // 顶部工具栏 io/ SceneSerializer.js // 场景序列化与反序列化 main.js // 入口组装所有模块这样拆的好处是每个模块可以单独测试。比如你想验证射线拾取是否准确只需要在SelectionManager里写个测试用例不用启动整个编辑器。2.2 初始化场景的三个关键参数SceneManager的初始化看起来简单但有几个参数如果设错了后面会很难受import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; class SceneManager { constructor(container) { this.scene new THREE.Scene(); this.scene.background new THREE.Color(0x2a2a2a); const { clientWidth, clientHeight } container; this.camera new THREE.PerspectiveCamera( 50, // fov clientWidth / clientHeight, // aspect 0.1, // near 2000 // far ); this.camera.position.set(5, 5, 5); this.renderer new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true // 截图功能需要 }); this.renderer.setSize(clientWidth, clientHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled true; this.renderer.shadowMap.type THREE.PCFSoftShadowMap; container.appendChild(this.renderer.domElement); this.controls new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping true; this.controls.dampingFactor 0.05; this.setupLights(); this.setupGrid(); } }这里有几个点值得展开说。fov 选 50 而不是 75。75 是很多教程的默认值视野大、看起来开阔但在编辑器场景里75 的透视畸变太严重拖拽物体时距离感会失真。50 更接近人眼观察习惯操作精度更高。pixelRatio 限制在 2 以内。现在很多手机和笔记本的 devicePixelRatio 是 3 甚至 4如果不限制渲染分辨率会飙升到 4K 以上帧率直接掉到个位数。限制在 2 是画质和性能的平衡点。preserveDrawingBuffer 设为 true。这个参数默认是 false开启后会略微降低性能但如果你要做导出截图功能不开这个参数toDataURL()拿到的是一片空白。我当初排查这个问题花了两个小时最后发现是浏览器在渲染完成后清空了缓冲区。2.3 网格与光照的配置细节网格线GridHelper和坐标轴AxesHelper是编辑器的空间参照物没有它们用户根本分不清东南西北setupGrid() { const grid new THREE.GridHelper(20, 20, 0x444444, 0x333333); grid.material.opacity 0.5; grid.material.transparent true; this.scene.add(grid); const axes new THREE.AxesHelper(3); this.scene.add(axes); } setupLights() { const ambient new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambient); const directional new THREE.DirectionalLight(0xffffff, 1.2); directional.position.set(10, 20, 10); directional.castShadow true; directional.shadow.mapSize.set(2048, 2048); directional.shadow.camera.left -15; directional.shadow.camera.right 15; directional.shadow.camera.top 15; directional.shadow.camera.bottom -15; this.scene.add(directional); }环境光给 0.6 是为了让背光面不至于全黑方向光给 1.2 是为了让正面有足够的对比度。阴影相机的范围要手动设置默认范围很小物体一多阴影就会消失。提示阴影贴图设为 2048 是画质和显存的折中。如果你在低端设备上跑可以降到 1024肉眼几乎看不出差别但显存占用少一半。3. 射线拾取编辑器交互的命门编辑器的核心交互是点击选中物体这个功能的底层依赖是射线拾取Raycasting。原理不复杂从相机位置发出一条穿过鼠标点击位置的射线检测这条射线与场景中哪些物体相交取最近的那个。但实际写起来坑比想象的多。3.1 坐标转换的完整链路鼠标事件的clientX/clientY是屏幕坐标原点在左上角。而 three.js 的射线拾取需要的是归一化设备坐标NDC原点在屏幕中心范围是 -1 到 1。转换公式如下const rect renderer.domElement.getBoundingClientRect(); const ndcX ((event.clientX - rect.left) / rect.width) * 2 - 1; const ndcY -((event.clientY - rect.top) / rect.height) * 2 1;注意 Y 轴要取负因为屏幕坐标 Y 向下NDC 的 Y 向上。这个符号搞反了点击位置会上下颠倒排查起来很隐蔽。然后设置射线const raycaster new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera); const intersects raycaster.intersectObjects(scene.children, true);intersectObjects的第二个参数true表示递归检测子物体。如果你只传false那么挂在 Group 下面的 Mesh 就选不中。3.2 为什么你的点击总是选错物体我遇到过三种典型的选错场景每一种的原因都不一样第一种选中了辅助对象。网格线、坐标轴、变换手柄这些辅助对象也会被射线检测到。解决方案是给它们打标记拾取时过滤掉const intersects raycaster .intersectObjects(scene.children, true) .filter(hit !hit.object.userData.isHelper);第二种选中了不可见物体。有些物体你设了visible false但射线检测依然会命中。需要在过滤时加判断.filter(hit hit.object.visible)第三种选中了背面。默认情况下射线会穿透物体检测背面。如果你只想选中正面需要设置材质的side属性为THREE.FrontSide或者在拾取后检查法线方向。3.3 选中高亮的实现方式选中物体后要给它加个高亮效果最直观的做法是加一个包围盒BoxHelperselectObject(object) { if (this.currentHelper) { this.scene.remove(this.currentHelper); this.currentHelper.dispose(); } this.currentHelper new THREE.BoxHelper(object, 0x00ff00); this.scene.add(this.currentHelper); this.selected object; }BoxHelper会自动跟随物体的变换更新不用手动计算包围盒。但要注意切换选中物体时一定要dispose()掉旧的 helper否则会内存泄漏。我见过一个项目跑了一下午后浏览器卡死最后发现是每次点击都 new 一个 BoxHelper 但从不销毁。另一种高亮方式是修改材质的emissive属性但这种方式会污染原始材质撤销选中时还得恢复不如 BoxHelper 干净。4. 变换控制器拖拽手柄的封装与陷阱TransformControls是 three.js 官方提供的变换手柄支持平移、旋转、缩放三种模式。用起来很简单但有几个坑必须提前知道。4.1 模式切换与快捷键绑定import { TransformControls } from three/examples/jsm/controls/TransformControls.js; this.transformControls new TransformControls(this.camera, this.renderer.domElement); this.transformControls.addEventListener(dragging-changed, (event) { this.orbitControls.enabled !event.value; }); this.scene.add(this.transformControls); // 快捷键 window.addEventListener(keydown, (e) { 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; } });dragging-changed事件是关键。拖拽手柄时如果不禁用 OrbitControls相机会跟着一起转操作体验极差。这个事件在拖拽开始时触发value true结束时value false。4.2 变换手柄的层级问题TransformControls本身是一个Object3D它需要被添加到场景中才能渲染。但如果你把它加到场景根节点它会出现在层级树面板里用户可能会误选中它。我的做法是把它加到一个独立的helperScene里或者给它打上userData.isHelper true标记在层级树渲染时过滤掉。4.3 吸附与步进编辑器里经常需要精确移动比如每次移动 0.5 个单位。TransformControls支持吸附设置this.transformControls.setTranslationSnap(0.5); this.transformControls.setRotationSnap(THREE.MathUtils.degToRad(15)); this.transformControls.setScaleSnap(0.1);按住 Shift 键时吸附生效松开则自由变换。这个交互逻辑和主流三维软件一致用户上手成本低。注意吸附值不要设得太小比如 0.01那样和自由变换没区别用户感觉不到吸附的存在。0.5 或 1 是比较合适的平移步进。5. 层级树与属性面板的联动编辑器的左侧通常是层级树右侧是属性面板。这两个面板的数据源都是场景图但更新时机不同。5.1 层级树的递归渲染层级树本质上是场景图的树形展示。递归遍历scene.children为每个节点生成一个列表项function buildTree(object, depth 0) { if (object.userData.isHelper) return null; const node { id: object.uuid, name: object.name || object.type, children: [] }; object.children.forEach(child { const childNode buildTree(child, depth 1); if (childNode) node.children.push(childNode); }); return node; }每次场景变化时重建整棵树是最简单的做法但性能差。更好的方式是监听scene的childAdded和childRemoved事件做增量更新。不过对于简单编辑器来说场景里通常不超过几百个物体全量重建完全够用。5.2 属性面板的双向绑定属性面板要显示选中物体的位置、旋转、缩放、材质颜色等信息。难点在于双向绑定面板改数值要同步到物体物体被拖拽后数值也要刷新到面板。我的做法是面板输入框的change事件触发时更新物体属性在渲染循环里检查选中物体的变换是否变化如果变化则刷新面板// 渲染循环中 const pos this.selected.position; if (!pos.equals(this.lastPosition)) { this.propertyPanel.updatePosition(pos); this.lastPosition.copy(pos); }用equals比较而不是直接比较引用因为 three.js 的 Vector3 是可变对象拖拽时引用不变但值变了。5.3 材质编辑的实时预览修改材质颜色时如果每次都重建材质性能开销很大。正确做法是直接修改现有材质的属性onColorChange(hex) { if (this.selected this.selected.material) { this.selected.material.color.set(hex); this.selected.material.needsUpdate true; } }needsUpdate在修改颜色时其实不需要设但修改贴图、透明度等属性时必须设否则 GPU 不会重新编译着色器。6. 场景序列化保存与加载的完整方案编辑器的最终产出是一个场景文件用户下次打开时能恢复之前的状态。three.js 提供了Object3D.toJSON()方法但直接用它有几个问题。6.1 toJSON 的局限性toJSON()会序列化物体的几何体、材质、变换等信息但不会保存自定义的userData外部加载的贴图引用只保存 URL编辑器的特定状态比如当前选中的物体所以我的做法是在toJSON的基础上做一层包装serialize() { const sceneData this.scene.toJSON(); return { version: 1.0, timestamp: Date.now(), scene: sceneData, editorState: { cameraPosition: this.camera.position.toArray(), cameraTarget: this.orbitControls.target.toArray() } }; }6.2 加载时的对象重建ObjectLoader可以反序列化toJSON的结果但要注意异步问题import { ObjectLoader } from three; async load(data) { const loader new ObjectLoader(); const scene await loader.parseAsync(data.scene); // 清空当前场景 this.scene.clear(); this.scene.add(...scene.children); // 恢复相机 this.camera.position.fromArray(data.editorState.cameraPosition); this.orbitControls.target.fromArray(data.editorState.cameraTarget); }parseAsync是必须的因为贴图加载是异步的。如果用同步的parse贴图会丢失。6.3 外部资源的路径处理如果场景里引用了外部模型或贴图序列化时保存的是相对路径。加载时如果路径变了资源就找不到。解决方案是在保存时把资源转成 base64 内嵌或者维护一个资源映射表。对于简单编辑器我建议直接内嵌 base64。虽然文件会变大但胜在可靠。一个 1MB 的贴图转成 base64 后大约 1.3MB对于场景文件来说可以接受。7. 撤销重做编辑器的灵魂功能没有撤销重做的编辑器是不完整的。用户拖错一个物体发现回不去体验直接崩盘。7.1 命令模式的设计撤销重做的经典实现是命令模式。每个操作封装成一个命令对象包含execute和undo两个方法class TransformCommand { constructor(object, oldPosition, newPosition) { this.object object; this.oldPosition oldPosition.clone(); this.newPosition newPosition.clone(); } execute() { this.object.position.copy(this.newPosition); } undo() { this.object.position.copy(this.oldPosition); } }维护两个栈undoStack和redoStack。执行新命令时压入undoStack并清空redoStack撤销时从undoStack弹出执行undo压入redoStack。7.2 拖拽操作的命令合并拖拽物体时TransformControls会连续触发objectChange事件。如果每次变化都生成一个命令撤销一次只回退一帧用户要按几百次才能回到起点。正确做法是只在拖拽开始时记录初始状态拖拽结束时生成一个命令this.transformControls.addEventListener(mouseDown, () { this.dragStartPosition this.selected.position.clone(); }); this.transformControls.addEventListener(mouseUp, () { const command new TransformCommand( this.selected, this.dragStartPosition, this.selected.position ); this.history.push(command); });这样一次拖拽对应一个撤销步骤符合用户直觉。7.3 历史栈的容量控制历史栈不能无限增长否则内存会爆。设一个上限比如 50 步超过时丢弃最早的命令push(command) { this.undoStack.push(command); if (this.undoStack.length 50) { this.undoStack.shift(); } this.redoStack []; }50 步对于大多数编辑场景够用了。用户如果连续操作超过 50 次还没保存那说明工作流本身有问题。8. 性能优化让编辑器跑满 60 帧编辑器卡顿是最劝退的体验。我总结了几条实战中验证有效的优化策略。8.1 按需渲染默认情况下three.js 每帧都在渲染哪怕场景没有任何变化。编辑器的场景通常是静态的只有用户操作时才需要重绘。改成按需渲染let needsRender true; function animate() { requestAnimationFrame(animate); orbitControls.update(); if (needsRender) { renderer.render(scene, camera); needsRender false; } } // 任何会改变画面的操作后 function invalidate() { needsRender true; }在OrbitControls的change事件、TransformControls的objectChange事件、属性面板修改后调用invalidate()。这样静止时 GPU 占用几乎为零笔记本风扇不会狂转。8.2 几何体合并如果场景里有大量重复的小物体比如一百个相同的立方体每个物体单独渲染会产生一百次 draw call。用InstancedMesh合并const geometry new THREE.BoxGeometry(1, 1, 1); const material new THREE.MeshStandardMaterial({ color: 0x44aa88 }); const instancedMesh new THREE.InstancedMesh(geometry, material, 100); const matrix new THREE.Matrix4(); for (let i 0; i 100; i) { matrix.setPosition(Math.random() * 10, 0, Math.random() * 10); instancedMesh.setMatrixAt(i, matrix); } scene.add(instancedMesh);但要注意InstancedMesh的每个实例不能单独选中和变换所以只适合做背景装饰不适合做可编辑物体。8.3 材质复用每次new THREE.MeshStandardMaterial()都会创建一个新的着色器程序。如果一百个物体用一百个材质GPU 要编译一百次。正确做法是复用材质const sharedMaterial new THREE.MeshStandardMaterial({ color: 0xffffff }); for (let i 0; i 100; i) { const mesh new THREE.Mesh(geometry, sharedMaterial); scene.add(mesh); }但复用的代价是修改一个物体的颜色会影响到所有共用该材质的物体。所以编辑器里通常是默认共享修改时克隆function onColorChange(object, color) { if (object.material.userData.shared) { object.material object.material.clone(); object.material.userData.shared false; } object.material.color.set(color); }8.4 阴影的代价阴影是性能杀手。每盏投射阴影的光源都会触发一次额外的渲染 pass。如果场景里有多盏方向光帧率会断崖式下跌。我的建议是编辑器里只保留一盏投射阴影的方向光其他光源不投射阴影。视觉上几乎看不出差别但性能提升明显。9. 那些文档里不会写的踩坑记录最后分享几个我在实际开发中踩过的坑每一个都花了不少时间排查。坑一OrbitControls 的 target 被意外修改。当选中物体后如果把orbitControls.target设为物体位置相机会自动看向物体。但用户旋转视角后target 会停留在物体上导致后续操作视角很别扭。解决方案是只在双击物体时设置 target单击选中不改变视角。坑二TransformControls 与射线拾取的冲突。拖拽手柄时鼠标事件会被 TransformControls 捕获但射线拾取依然在运行导致拖拽过程中选中状态频繁切换。解决方案是在dragging-changed事件里禁用射线拾取。坑三窗口 resize 后相机宽高比没更新。这是最常见的问题但很多人只更新了 renderer 的 size忘了更新相机的 aspectwindow.addEventListener(resize, () { const { clientWidth, clientHeight } container; camera.aspect clientWidth / clientHeight; camera.updateProjectionMatrix(); // 这行不能忘 renderer.setSize(clientWidth, clientHeight); });updateProjectionMatrix()不调用的话相机的投影矩阵还是旧的画面会被拉伸。坑四dispose 的遗漏。切换选中物体时旧的 BoxHelper 要 dispose删除物体时几何体和材质要 dispose关闭编辑器时renderer 要 dispose。不 dispose 的话显存会持续增长最终导致页面崩溃。我写了一个disposeObject工具函数递归清理所有资源function disposeObject(object) { if (object.geometry) object.geometry.dispose(); if (object.material) { const materials Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(m { Object.values(m).forEach(v { if (v v.isTexture) v.dispose(); }); m.dispose(); }); } object.children.forEach(disposeObject); }这个函数在删除物体和清空场景时都要调用。坑五键盘快捷键与输入框冲突。用户正在属性面板的输入框里打字按下 W 键想输入字母结果触发了切换到平移模式。解决方案是判断当前焦点元素window.addEventListener(keydown, (e) { if (e.target.tagName INPUT || e.target.tagName TEXTAREA) return; // 处理快捷键 });这个判断要放在最前面否则所有快捷键都会在输入时误触发。坑六模型加载的坐标系差异。不同来源的模型可能使用不同的坐标系Y轴向上或Z轴向上。加载后如果发现模型躺倒了不要急着旋转先检查模型的up属性。有些格式如某些CAD导出默认Z轴向上需要手动调整。10. 后续可以扩展的方向这个编辑器骨架跑通之后有几个方向可以继续深挖。一是多选与成组支持框选多个物体并打组这需要扩展 SelectionManager 支持集合操作。二是材质节点编辑用可视化节点图替代简单的颜色选择器这需要引入节点编辑器库。三是协同编辑多人同时编辑一个场景这涉及操作变换OT或冲突无关复制数据类型CRDT的算法。四是模型导入导出支持 glTF、OBJ、FBX 等格式的互转。每个方向都可以独立展开成一个大话题但前提是先把基础骨架搭稳。我个人的经验是不要一开始就追求功能全面先把选中-变换-保存这条主链路跑通剩下的功能都是在这条链路上做加法。主链路不稳加再多功能也是空中楼阁。