简介面向Vue3前端开发者的Three.js三维场景编辑器基于vue3、three.js与vite构建用于在Web端实现3D场景的可视化搭建、编辑与导出。编辑器支持场景导入导出及自定义配置导出的文件可配合scene-view解析器渲染复用当前为纯前端单机版无需后端服务。压缩包共230个文件包含102个JS逻辑、95张JPG图片多为贴图与预览图、20个CSS样式文件及WASM模块等整体大小12.1MB打包后可直接启动本地服务查看效果。目前已有2974人学习下载适合具备一定three.js基础的开发者参考编辑器架构、场景数据序列化及vue3集成方式。通过这套文件可快速获得完整可运行的项目样例并依据示例将场景解析能力接入到自身业务中。1. 这不是又一套管理后台Three.js 3D 场景编辑器在 Vue3 里到底解决什么做可视化项目的人应该都有过这种体验客户要一个 3D 展厅模型有了灯光调了一下午相机角度改了八遍最后发现代码里全是硬编码的坐标和旋转值。每次改场景都要翻源码、找变量、重新构建改完还不一定记得当时为什么这么调。Three.js 3D 场景编辑器vue3 版本就是冲着这个问题来的——把场景里的模型、灯光、相机、材质这些资源从代码里抽出来变成界面上的可操作对象拖一拖、点一点场景就改好了再导出配置给运行时用。这个方向适合三类人一是做数字孪生和智慧园区可视化的前端团队场景频繁改需求二是做 Three.js 教学和工具链的开发者想给用户一个所见即所得的配置入口三是接外包项目、需要快速交付演示效果的独立开发者。它的本质不是“用 Vue3 重写一个 Three.js 编辑器”而是“把场景状态做成响应式数据把操作界面和 Three.js 运行时解耦”这个架构思路比具体代码更值钱。正文会以一个可运行的编辑器雏形为主线从场景数据模型、资源导入、属性面板、撤销重做、序列化这几个关键模块拆开讲最后落到性能和工程化的坑上。整个方案不需要魔改 Three.js也不依赖特定后端纯前端就能跑起来。2. 先想清楚再动手编辑器的场景数据模型与撤销重做设计2.1 为什么不能把 Three.js 对象直接塞进 Vue3 的 reactive很多从零开始做编辑器的人第一步就写const scene reactive(new THREE.Scene())然后发现视图不更新或者更新起来卡得没法用。原因在于 Three.js 的对象体系里position、rotation这些属性是带有内部标识的原生类实例Vue3 的 Proxy 代理对这类对象的深层响应式拦截开销很大而且Scene里维护了大量非枚举属性reactive代理后经常出现控制台警告甚至死循环。常见做法是做个双层结构第一层是“编辑器业务模型”用普通 JSON 对象描述节点树放进reactive或ref第二层是“Three.js 运行时对象”维护一个Map把节点 ID 和实际对象对应起来。界面操作只改第一层然后通过一个同步函数把变更应用到第二层。// 节点模型编辑器的“真相来源” const editorState reactive({ nodes: [], // 场景节点树每个节点是普通对象 selectedId: null, resources: new Map(), // 资源 ID - Three.js 对象纹理、几何体等 }) // Three.js 运行时对象注册表不放进响应式 const runtimeObjects new Map() function addNode(parentId, type, options {}) { const id generateId() const node { id, parentId, type, name: options.name || type, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 }, visible: true, userData: {} } editorState.nodes.push(node) syncNodeToRuntime(node.id) // 立即创建对应的 Three.js 对象 return id }这里的核心逻辑是editorState.nodes是纯数据不包含 Three.js 实例每次增删改都调用syncNodeToRuntime把变化推送到运行时。runtimeObjects不放进reactive避免 Proxy 代理三维向量和矩阵内部的大量运算。参数说明type可以是mesh、light、camera、group等options里带geometry、material的资源引用 ID。这样设计的好处是后续做撤销重做时只需要深拷贝editorState.nodes这一份纯 JSON不需要处理复杂的对象图。2.2 属性面板的响应式绑定不能直接绑 THREE.Vector3属性面板要显示选中节点的位置、旋转、缩放很多人第一反应是v-modelselectedNode.position.x。问题在于selectedNode是从editorState.nodes里 filter 出来的它本身已经是响应式代理对象嵌套属性可以触发更新但如果你直接绑到一个 Three.js 的Vector3实例上界面改了、运行时不会变因为Vector3不是响应式的。我一般会在节点模型里维护一份“镜像属性”界面只改镜像然后统一同步到 Three.js 对象template div v-ifselectedNode classproperty-panel label位置 X/label input typenumber v-model.numberselectedNode.position.x changesyncTransform(selectedNode.id) / label位置 Y/label input typenumber v-model.numberselectedNode.position.y changesyncTransform(selectedNode.id) / /div /template script setup function syncTransform(id) { const node editorState.nodes.find((n) n.id id) const obj runtimeObjects.get(id) if (!obj) return obj.position.set(node.position.x, node.position.y, node.position.z) obj.rotation.set(node.rotation.x, node.rotation.y, node.rotation.z) obj.scale.set(node.scale.x, node.scale.y, node.scale.z) } /script逻辑说明v-model.number保证输入框给到的是数字而不是字符串change在输入完成失焦时触发一次同步避免每敲一个数字就执行一次position.set。如果你用input滑块拖动时会频繁同步场景复杂时会有明显卡顿。参数说明这里的position、rotation、scale都是三元素数组形式的普通对象rotation单位是弧度。如果属性面板想显示角度需要在绑定处做一次rad * 180 / Math.PI转换同步回来再除回去。2.3 撤销重做怎么做才不卡快照细粒度与命令模式的分界线编辑器没有撤销重做是没法用的但实现方式有两种路线。一种是“快照式”每次操作前深拷贝editorState.nodes推进历史栈操作后对比还原。这个方案简单可靠适合节点规模在几百个以内的场景缺点是每次拷贝全量数据历史多了内存吃紧。另一种是“命令模式”把每次操作封装成{ do(), undo() }结构只记录增量变化。比如移动节点undo就是存下旧坐标并恢复。命令模式内存占用小但每个操作类型都要单独写撤销逻辑开发量翻倍。我的建议是编辑器第一版先做快照式等核心功能稳定了再把高频操作比如移动、删节点改成命令模式。快照式实现如下const history [] const historyIndex ref(-1) function commit() { // 深拷贝节点树保留历史 const snapshot JSON.parse(JSON.stringify(editorState.nodes)) history.splice(historyIndex.value 1) history.push(snapshot) historyIndex.value history.length - 1 } function undo() { if (historyIndex.value 0) return historyIndex.value-- restoreSnapshot(history[historyIndex.value]) } function restoreSnapshot(snapshot) { // 先销毁全部运行时对象再按快照重建 runtimeObjects.forEach((obj) obj.removeFromParent obj.removeFromParent()) runtimeObjects.clear() editorState.nodes reactive(snapshot) editorState.nodes.forEach((node) syncNodeToRuntime(node.id)) }逻辑说明JSON.parse(JSON.stringify(...))对纯 JSON 节点模型是安全的因为节点里不存函数、不存 Three.js 实例。commit可以绑定到addNode、removeNode、changeTransform等用户操作上不要在每次syncTransform里动不动就 commit否则撤销历史会被拖拽过程刷爆。参数说明historyIndex用ref而不是普通变量这样工具栏上的撤销按钮可以根据historyIndex 0来禁用/启用。restoreSnapshot里全量重建的代价是场景大时会卡但如果你的编辑器定位是“配置型场景”而不是“超高面数游戏场景”这个代价完全可以接受。3. 搭建编辑器主框架从资源管理到场景画布的事件同步3.1 左侧资源面板模型、材质、贴图怎么挂到场景节点上一个可用的编辑器左侧一定有个资源区。资源区不是直接把文件拖进 Three.js 场景就完事它需要维护一个“资源注册表”把二进制资源转成 Three.js 可用的对象并且给每个资源一个 ID节点通过 ID 引用资源。async function importGLB(file) { const url URL.createObjectURL(file) const loader new GLTFLoader() const gltf await loader.loadAsync(url) const model gltf.scene const resourceId res_ Date.now() // 资源注册表里存根引用不直接挂到场景 editorState.resources.set(resourceId, { type: gltf, name: file.name, object: model.clone(), }) return resourceId } function addModelNode(resourceId) { const res editorState.resources.get(resourceId) if (!res) return const nodeId addNode(scene, mesh, { name: res.name }) const runtimeObj runtimeObjects.get(nodeId) const modelClone res.object.clone() runtimeObj.add(modelClone) }这里有个关键点res.object.clone()而不是直接使用原始 object。因为一个模型可能被多次放进场景如果直接复用同一个 object 实例第二次添加时第一次的位置会被顶掉——Three.js 里同一个对象只有一个父节点。参数说明GLTFLoader来自three/examples/jsm/loaders/GLTFLoader.js需要配合importmap或vite的别名配置。如果你用的是 Vite 构建直接import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js在部分版本会出现路径问题常见做法是建一个loader.js统一导出后续换three/addons路径也方便。另外加载大模型时一定要给用户一个 loading 状态loadAsync之前先URL.createObjectURL加载完成后再revokeObjectURL释放内存。3.2 场景画布与节点树的双向联动点击选中、拖拽移动、聚焦相机编辑器右侧是 Three.js 渲染画布左侧是节点树。这两边的交互必须双向联动在画布里点击一个模型左侧节点树要同步高亮在节点树里点击某个节点画布里的对应对象要能被高亮或者包围盒闪烁。射线检测的代码本身很简单但要注意坐标转换。Three.js 的Raycaster需要传入归一化设备坐标范围是 -1 到 1直接拿鼠标像素值会导致永远点不中// 节选画布点击选中 canvas.addEventListener(click, (event) { 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 raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera) const hits raycaster.intersectObjects(scene.children, true) if (hits.length 0) { // 向上查找根节点对应的编辑节点 id const id findEditorNodeId(hits[0].object) editorState.selectedId id highlightNode(id) } })逻辑说明intersectObjects第二个参数true表示递归检测子节点。模型内部往往有几十个子 mesh直接拿hits[0].object拿到的可能是最里层的 mesh所以需要findEditorNodeId沿着父链找到编辑器节点对应的 ID——常见做法是在每个模型根节点上挂userData.editorId子 mesh 的父链上能查到这个字段。参数说明raycaster.far的默认值是无穷大场景里如果有超大地面会干扰拾取建议按场景尺寸设置成camera.far * 0.9。另外画布点击事件要区分“点击”和“拖拽”常见做法是记录mousedown和mouseup的坐标差小于 5 像素才算点击否则会出现在旋转相机时误选中模型的问题。实现拖拽移动时我推荐先做“选中的模型跟随一个透明平面上的射线交点移动”而不是直接改position。因为直接改屏幕上点的投影坐标会混入相机视角因素表现很别扭。拖拽时把射线打到y 0的平面上拿到世界坐标再赋值给模型function dragMove(event) { const ndc getNDC(event) raycaster.setFromCamera(ndc, camera) const plane new THREE.Plane(new THREE.Vector3(0, 1, 0), 0) const target new THREE.Vector3() raycaster.ray.intersectPlane(plane, target) const node editorState.nodes.find(n n.id editorState.selectedId) node.position.x target.x node.position.z target.z syncTransform(node.id) }这个方案只适合“地面行走类物体”。如果是空中物体或者任意方向的拖拽需要把拖拽平面从y0改成“垂直于相机视线且经过物体当前位置的平面”实现上就是先用camera.getWorldDirection得到视线方向再用THREE.Plane.setFromNormalAndCoplanarPoint生成平面。3.3 高亮选中的三个方案线框、包围盒、发光描边怎么选编辑器里选中态的视觉反馈很重要。常见的方案有三种线框叠加、包围盒边线、后处理描边。线框叠加最简单——给选中对象加一个EdgesGeometry材质的辅助对象但大模型上会看到密密麻麻的三角面线框视觉很脏。包围盒边线是Box3Helper性能好但只能表达对象的轴对齐范围对不规则形状的指示性弱。后处理描边效果最好用OutlinePass不过需要额外引入 EffectComposer 和 RenderPass渲染管线会变重。我一般建议默认用包围盒然后在属性面板里提供“切换为线框模式”的高级选项。原因是编辑器场景要频繁操作后处理管线在多视图Perspective / Top / Side切换时容易出问题排查成本高。如果后续要做“材质预览”功能再上后处理优先级不高。// 选中高亮包围盒 半透明辅助体 function highlightNode(id) { clearHighlight() const obj runtimeObjects.get(id) if (!obj) return const box new THREE.Box3().setFromObject(obj) if (box.isEmpty()) return const helper new THREE.Box3Helper(box, 0x00ff88) scene.add(helper) highlightHelper helper }逻辑说明Box3.setFromObject会递归计算世界坐标下的包围盒所以即使模型内部有旋转子节点也能拿到正确范围。clearHighlight需要把上一个highlightHelper从场景里移除否则连续点击多个节点时画布上会积累一堆辅助框。参数说明Box3Helper的第二个参数是颜色第三个参数可以传LineBasicMaterial覆盖默认线宽。注意 WebGL 里LINE_WIDTH大部分情况下只能为 1不要指望通过linewidth调粗边框线浏览器层面基本不支持。4. 把资源导入做实GLB / OBJ / 贴图文件的处理边界与参数细节4.1 GLB 模型加载的参数Draco 压缩、动画、多材质分离GLB 是编辑器里最常见的模型格式但“能加载”和“能编辑”是两回事。如果模型用了 Draco 压缩你必须先引入DRACOLoader并配置 decoder 路径否则会直接报错或黑屏。很多新手加载失败八成是 decoder 的 wasm 文件路径没配对。import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader.js import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js const dracoLoader new DRACOLoader() dracoLoader.setDecoderPath(/draco/) // 需要把 wasm 文件放到 public 目录 const loader new GLTFLoader() loader.setDRACOLoader(dracoLoader)参数说明setDecoderPath的路径指向包含draco_wasm_wrapper.js和draco_decoder.wasm的目录。如果你用 Vite直接把node_modules/three/examples/jsm/libs/draco/拷到public/draco即可。注意版本匹配Three.js r125 之后和 r160 之前的 Draco 版本有差异升级 Three.js 后最好同步替换 decoder 文件否则偶尔会出现“解码成功但模型扭曲”的诡异现象。加载完成后如果想要“按材质拆分 Mesh”GLTF 的scene默认是一个嵌套的 Group。我的做法是遍历所有Mesh把每个材质不同的 Mesh 提升为单独的可编辑节点这样属性面板可以对每个部分单独改颜色、改粗糙度满足“模型色彩定制”的需求。实现上不复杂但要小心原模型里Mesh之间的父子变换关系——直接remove和add会丢变换矩阵正确做法是用getWorldPosition、getWorldQuaternion、getWorldScale把变换固化到Matrix4再重新挂到场景根节点。4.2 贴图重复导入与内存泄漏每次导入前先释放旧资源编辑器开发中内存泄漏问题比功能问题更难排查。每次导入一个 GLB 或者 JPEG 贴图Three.js 都会创建新的Texture和Material如果你直接替换场景里的材质而不做旧材质dispose()GPU 内存会持续上涨标签页最终崩掉。function disposeMaterial(material) { if (Array.isArray(material)) { material.forEach(m disposeMaterial(m)) return } const textures [] for (const key of Object.keys(material)) { if (material[key] instanceof THREE.Texture) { textures.push(material[key]) } } textures.forEach(t t.dispose()) material.dispose() } function replaceMaterial(nodeId, newMaterial) { const obj runtimeObjects.get(nodeId) const oldMats obj.material disposeMaterial(oldMats) obj.material newMaterial }逻辑说明disposeMaterial里对材质的所有属性做遍历找到Texture实例并释放这是因为模型材质可能同时有map、normalMap、roughnessMap等多张贴图只释放map的话其余贴图仍然占用 GPU 显存。如果材质贴图有texture.mipmaps还需要把generateMipmaps关掉或者手动释放不过这块属于高级优化只在频繁加载贴图时才需要关注。参数说明material.dispose()不等于清除 WebGL 程序实际显存释放发生在渲染器下一次render时。如果你在同一个事件循环里做了释放和重新渲染旧内存还没来得及回收所以不要在一个循环里反复创建销毁材质尽量用对象池来管理可复用材质。4.3 导出场景 JSON 的 schema 设计资源引用与运行时端怎么对接编辑器做完之后最大的问题就是“导出给运行时用”。导出的 JSON 不能直接塞 Three.js 的ObjectLoader因为ObjectLoader加载不了你自定义的“编辑器节点模型”。所以导出格式要按“编辑器契约”设计运行时端写一个对应的 loader 来解析。function exportScene() { const sceneData { version: 1, nodes: editorState.nodes.map(node ({ id: node.id, type: node.type, name: node.name, parentId: node.parentId, transform: { position: [...node.position], rotation: [...node.rotation], scale: [...node.scale], }, visible: node.visible, material: node.materialRef, // 资源 ID geometry: node.geometryRef, // 资源 ID userData: node.userData, })), resources: Array.from(editorState.resources.entries()).map(([id, res]) ({ id, type: res.type, name: res.name, // 这里不导出二进制内容只导出引用二进制单独上传 })), } return JSON.stringify(sceneData, null, 2) }这个导出的核心思路是“场景里只放引用资源单独管理”。二进制文件GLB、贴图不应该塞进 JSON运行时端通过资源 ID 去请求对应的静态资源路径。如果你的部署是纯静态就把资源文件放在同目录的assets/下运行时 loader 根据 ID 拼 URL。如果资源较多应该做资源清单文件manifest避免运行时遍历目录。参数说明version字段非常重要编辑器迭代后节点结构会变运行时 loader 可以根据 version 做兼容处理。rotation数组是弧度制导出时不要转成角度运行时直接用Quaternion.setFromEuler或Object3D.rotation.set更好。5. 编辑器性能与状态维护大场景卡顿、脏检查与自动保存避坑指南5.1 为什么拖动变换控件时整个场景会掉帧TransformControls 的坑在编辑器里加TransformControls是标配但很多人在拖拽时发现整个场景掉帧严重。原因有两层一是TransformControls默认会触发objectChange事件如果你在这个事件里做响应式状态同步等于每帧都执行一次JSON深拷贝或 DOM 更新二是拖拽时如果场景里有阴影贴图、抗锯齿等重开销特性每帧都在计算完整渲染。我建议拖拽期间不要实时写回编辑器状态只在dragging-changed事件结束时写回一次const transformControls new TransformControls(camera, renderer.domElement) scene.add(transformControls) transformControls.addEventListener(dragging-changed, (event) { if (!event.value) { // 拖拽结束同步一次 const node editorState.nodes.find(n n.id editorState.selectedId) const obj runtimeObjects.get(node.id) node.position [obj.position.x, obj.position.y, obj.position.z] node.rotation [obj.rotation.x, obj.rotation.y, obj.rotation.z] node.scale [obj.scale.x, obj.scale.y, obj.scale.z] commit() // 记一次撤销快照 } })逻辑说明dragging-changed的event.value为false时表示拖拽结束。此时obj.position已经被 TransformControls 改过所以直接从运行时对象读回坐标并同步到编辑器状态再做一次快照。如果你在objectChange事件里每帧更新editorState.nodes响应式系统会触发 Vue 的依赖更新在模板里绑定任何相关属性都会造成额外的 JS 执行。参数说明transformControls.setMode(translate | rotate | scale)要跟节点类型联动。例如灯光节点可能只需要 translate 和 rotate相机需要 translate 和 rotate模型 Mesh 三种都要。设置showX、showY、showZ可以控制轴向显示对 2D 平面项目的需求比较有用。5.2 场景自动保存防抖、版本号和恢复逻辑编辑器做到后期自动保存是刚需。实现不难但几个细节容易踩坑保存频率太高会频繁写 localStorage 导致卡顿场景大了之后 localStorage 有 5MB 上限要压缩或换 IndexedDB另外自动保存恢复时不要覆盖用户当前正在编辑的未保存内容。我一般用“防抖 自定义事件”来做任何涉及节点树变化的操作添加、删除、修改属性、导入资源都调用markDirty()内部防抖 5 秒后执行一次保存。let saveTimer null function markDirty() { if (saveTimer) clearTimeout(saveTimer) saveTimer setTimeout(saveSceneDraft, 5000) } function saveSceneDraft() { const data exportScene() localStorage.setItem(editor_draft_v1, data) } function recoverDraft() { const raw localStorage.getItem(editor_draft_v1) if (!raw) return null const draft JSON.parse(raw) if (draft.version ! 1) return null // 校验节点结构合法性后再恢复 return draft }逻辑说明markDirty放在addNode、removeNode、changeTransform、importGLB等所有编辑器操作的统一出口处。防抖的意义是避免一次拖拽过程中频繁写盘——虽然我们上述已经做了只在拖拽结束时 commit但自动保存仍然要防抖因为拖拽过程中如果有其他操作比如切工具、改参数也会触发。参数说明localStorage容量有限场景节点超过几千个后JSON 字符串可能超过 5MB。这种量级建议换indexedDB或者减小保存粒度只保存增量变更。另外恢复草稿时要校验version因为编辑器升级后旧草稿可能无法解析直接报错比静默丢数据好处理。5.3 编辑器里最常翻车的三个场景循环依赖、ID 冲突、多选删除第一个是循环依赖。场景节点树理论上只能是树但操作中如果允许“把父节点拖到子节点下”就会形成环导致遍历死循环。在实现拖拽重排节点时需要先判断“目标节点是否是当前节点的祖先”。function canReparent(nodeId, newParentId) { if (nodeId newParentId) return false let p editorState.nodes.find(n n.id newParentId) while (p) { if (p.id nodeId) return false p editorState.nodes.find(n n.id p.parentId) } return true }第二个是 ID 冲突。如果你的生成 ID 是Date.now()字符串在同一毫秒内快速创建多个节点会冲突。简单方案是加个自增计数器或者用crypto.randomUUID()后者更省事。第三个是删除包含大量子节点的组对象。如果直接删除父节点而不递归删除子节点运行时对象会挂在场景里变成“孤儿对象”既不显示在节点树里又消耗 GPU。删除节点时要递归收集所有后代节点的运行时对象逐个removeFromParent并 dispose 相关材质和几何体最后才从editorState.nodes里过滤掉。function collectDescendantIds(id) { const ids [id] for (const node of editorState.nodes) { if (node.parentId id) { ids.push(...collectDescendantIds(node.id)) } } return ids } function removeNode(id) { const ids collectDescendantIds(id) ids.forEach(rid { const obj runtimeObjects.get(rid) if (obj) { obj.removeFromParent() obj.traverse(child { if (child.geometry) child.geometry.dispose() if (child.material) disposeMaterial(child.material) }) } runtimeObjects.delete(rid) }) editorState.nodes editorState.nodes.filter(n !ids.includes(n.id)) commit() }这里要强调的是traverse里释放材质和几何体的顺序先释放几何体再释放材质没有严格先后但dispose()之后不要再对这个对象做任何渲染操作所以要在removeFromParent之后遍历——顺序反了的话有些浏览器的 WebGL 上下文会报 warning 甚至异常。6. 从编辑器到产品把场景配置能力开放给使用方编辑器做完真正要落地的是“让不懂 Three.js 的人也能改场景”。这里的进阶思路是把编辑器的产出从“静态 JSON”扩展成“可交互配置”也就是给运行时端加一个轻量解释器让配置逻辑与渲染逻辑分离。我常用的一种做法是编辑器里每个节点可以挂“行为脚本”比如“点击这个模型时相机移向它”。这个脚本不是完整的 JavaScript 代码而是一组规则对象运行时端执行规则解释器。这样编辑器导出的 JSON 就变成了一个“可视化逻辑文件”客户改交互不需要碰代码。// 运行时端解释器处理简单的点击互动规则 function processInteractions(event) { const hits raycaster.intersectObjects(interactiveMeshes, true) if (hits.length 0) return const targetId hits[0].object.userData.editorId const rule sceneConfig.interactions.find(r r.triggerId targetId) if (!rule) return if (rule.action focusCamera) { animateCameraTo(rule.targetPosition || targetPositionFromId(rule.targetId)) } if (rule.action toggleVisibility) { const targetObj runtimeObjects.get(rule.targetId) targetObj.visible !targetObj.visible } }这个解释器很轻只有几十行但对客户的体验提升是质的他们不需要理解 Three.js 的相机控制只需要在编辑器里选择“点击 AB 隐藏”这类配置即可。如果你的编辑器定位是面向内部设计团队这个能力可能不是最重要但如果要做成可交付给甲方的工具它是拉开差距的核心功能。验证编辑器的产出是否合格我有个习惯导出一份 JSON然后在一个完全空白的 Vite 项目里只加载运行时 loader 和解释器不加载编辑器代码跑一遍场景。如果这个纯净版能正确加载所有节点、响应所有交互编辑器才算真正完成。如果只在自己电脑上“看起来能跑”等到部署现场多半会因为资源路径、跨域、模型加载顺序等问题翻车。这个方案做下来最大的教训是“不要贪多”。一开始我总想把材质编辑器、粒子系统、地形工具全塞进来结果每个功能都半吊子。后来聚焦到“模型摆放 材质替换 属性调整 交互配置”这四个核心场景稳定性和可用性都上来了。编辑器这东西功能少一点没关系但每一步操作都不能出 bug——用户对编辑器的信任是建立在“我改的东西不会突然消失”之上的。希望这篇笔记能帮你少踩几个坑把 Three.js 场景编辑器稳稳当当地做出来。本文还有配套的精品资源点击获取