简介面向 Vue3 前端开发者的一套 Three.js 三维场景编辑器打包成果基于 vue3、three.js、vite 构建可本地启动服务直接查看。编辑器支持场景导入、导出与自定义配置导出的场景文件可交给 scene-view 解析器渲染整体为纯前端单机版适合快速集成三维展示或学习 Three.js 工程化实践。资源包共 230 个文件压缩后 12.1MB以 JS 逻辑代码、CSS 样式、JPG 纹理贴图为主并有 WASM、PNG/SVG 图标、MD 文档及 HTML 入口覆盖编辑器运行所需静态资源与构建产物便于本地部署和二次分析。已有 2974 人学习下载。通过打包产物可了解 vue3 组件化封装 Three.js 的方式、场景序列化与解析流程以及 Vite 构建配置思路附带模型与贴图素材可直接用于测试与扩展对前端 3D 入门和工具链调研有参考价值。1. Three.js3D场景编辑器vue3版本我为什么放弃纯 Three.js 去写一个响应式编辑器第一次用纯 Three.js 写 3D 页面时我把模型、灯光、相机全部挂在全局变量下三天之后文件超过 2000 行同事改一个材质颜色要 CtrlF 搜半天。后来我用 Vue 3 重构了这套 Three.js 3D 场景编辑器vue3 版本核心思路很直接Three.js 只负责渲染Vue 3 的响应式数据只负责描述“场景里有什么”。左侧对象树、中间视口、右侧属性面板一边拖拽一边改数值场景实时回显。适合给团队搭内部 3D 工具、做三维展示后台、或者用来学 JavaScript 3D Vue.js 的数据流设计。这篇把架构、跑通步骤和二次开发节奏讲透读完能直接运行并动手改。2. 技术栈与架构设计Vue 3 的响应式如何管住 Three.js 的 Object3D2.1 技术栈选型为什么是 vue3 three vite 而不是纯 Three纯 Three.js 开发时渲染循环、对象增删、控制器、属性面板各自为政组件之间没有办法做到“改一个值自动更新到物体上”。Vue 3 的响应式系统可以成为整个编辑器的“数据中心”物体层级数据是响应式对象Three.js 的 Object3D 与它建立映射关系UI 组件只认响应式数据不直接碰 Three.js 场景图。JavaScript 3D Vue.js 这个组合的工程优势在于渲染循环仍然由 Three.js 的 requestAnimationFrame 控制没有虚拟 DOM 的额外负担而右侧属性面板的每一次输入都由 Vue 完成响应式更新。再用 Vite 做构建器对 three 这种大依赖的预构建和热更新体验都很好——改一个材质颜色保存后页面秒级刷新。换作 Webpack 的老配置每次冷启动都要等好几秒。我一般会让新项目直接用 Vite体感差异太明显了。2.2 项目目录结构engine 层与 components 层如何划边界整套代码的核心分层是engine目录不 import 任何 Vue 的东西只负责 Three.js 场景、相机、渲染器和变换控制器components目录里的组件只接受sceneState数据和engine暴露出来的方法不直接操作场景图。边界一旦建立调试时定位问题会非常快如果渲染结果不对去engine找如果 UI 数据和场景不一致去store找。典型的项目结构长这样src/ ├─ main.js # 创建 app初始化 engine ├─ App.vue # 布局容器 ├─ components/ │ ├─ SceneViewport.vue # 中间 canvas 视口 │ ├─ ObjectTree.vue # 左侧对象树 │ ├─ PropertyPanel.vue # 右侧属性面板 │ ├─ Toolbar.vue # 顶部工具栏 │ └─ ImportModelDialog.vue ├─ engine/ │ ├─ SceneManager.js # 场景、相机、渲染器生命周期 │ ├─ ObjectFactory.js # 创建 Box/Sphere/Light 等 │ ├─ Serializer.js # 场景保存与加载 │ └─ TextureLoader.js # 异步纹理加载封装 ├─ store/ │ └─ scene.js # reactive 全局状态 └─ utils/ └─ transform.js # 坐标轴输入格式化store/scene.js是组件层的唯一数据来源SceneManager.js是 engine 层对外的门面。比如左侧对象树删除一个节点组件调用的是sceneState.engine.removeObject(id)绝不是自己去scene.remove(filter(...))。这样增删逻辑可以被菜单栏、右键菜单、快捷键多个入口复用后面接键盘删除时不用重写一套逻辑。2.3 响应式状态设计用 reactive 包装 Object3D 还是用 ref pinia我最早也尝试过直接reactive(scene)想用 Vue 的响应式去代理整个 Three 场景结果一选中物体就触发一堆 setterCPU 占用立刻飙到 100%画面还掉帧。原因在 Three.js 的Object3D内部属性比如matrixWorld、quaternion等本身就是自动更新频率很高的字段reactive 再套一层拦截等于给本来高频的写操作叠加了响应式开销形成反馈循环。这套编辑器的做法是sceneState只存“可序列化的描述数据”Three 对象由SceneManager维护在一个普通 Map 里。选中对象时用Object3D.uuid作为关联 id组件之间只传字符串 id不传对象引用。核心状态模块如下import { reactive } from vue // store/scene.js export const sceneState reactive({ objects: [], // [{ uuid, name, type, position, rotation, scale, material }] selectedId: , // 当前选中对象的 uuid transformMode: translate, engine: null // 启动后由 main.js 注入 SceneManager 实例 }) export function selectObject(uuid) { sceneState.selectedId uuid }objects数组里每一项是普通对象不是 Three.js 的 Object3D。position、rotation、scale我特意平铺成{ x, y, z }因为 Three 的 Vector3 上有applyQuaternion、normalize等大量内部方法一旦被 Vue 代理某些缓存会失效。组件层通过一个 computed 拿到当前选中的描述对象import { computed } from vue import { sceneState } from ../store/scene export function useSelectedObject() { const selected computed(() sceneState.objects.find(obj obj.uuid sceneState.selectedId) || null ) return { selected } }为什么用reactive不用ref因为sceneState是个全局单例页面里几乎所有组件都要读它用ref意味着到处写.value阅读成本高。Pinia 在这个规模下也不是必需品只有当撤销重做、多标签页这类复杂状态出现时才值得引入。撤销重做在这个项目里可以直接用objects的历史快照实现后面会看到具体理由。3. 核心模块拆解一个方块从拖入场景到属性生效的完整链路3.1 场景初始化SceneManager 怎么把 renderer、camera、灯光装配起来SceneManager构造函数是整个 editor 的起点。它负责 canvas 挂载、渲染器创建、相机创建、轨道控制器和变换控制器的初始化。代码长这样import * as THREE from three import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js import { TransformControls } from three/examples/jsm/controls/TransformControls.js export class SceneManager { constructor(container) { this.renderer new THREE.WebGLRenderer({ antialias: true, alpha: false }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) container.appendChild(this.renderer.domElement) this.scene new THREE.Scene() this.scene.background new THREE.Color(0x1e1e1e) this.camera new THREE.PerspectiveCamera(45, 1, 0.1, 1000) this.camera.position.set(5, 5, 5) this.camera.lookAt(0, 0, 0) this.controls new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping true this._initLights() this._resize() this._renderLoop() } }antialias: true打开抗锯齿alpha: false保证画布背景不透明避免透明画布在部分浏览器里的混合问题。setPixelRatio(Math.min(window.devicePixelRatio, 2))限制像素比上限retina 屏不至于渲染 3 倍分辨率。PerspectiveCamera的第二个参数先传 1因为构造函数里还不知道容器尺寸等_resize拿到实际宽高后修正。OrbitControls负责鼠标旋转、平移、缩放视口。_resize里要同时更新 renderer 尺寸和相机宽高比_resize() { const { clientWidth, clientHeight } this.container const w clientWidth || 300 const h clientHeight || 300 this.renderer.setSize(w, h) this.camera.aspect w / h this.camera.updateProjectionMatrix() }给宽高都加了兜底值防止容器高度为 0 时渲染出一个 0 尺寸 canvas。这在后面避坑章节会重点再说一次因为这是“页面黑屏”最高频的元凶。3.2 对象管理addObject / removeObject 与对象树的自动同步工具栏点击“添加方块”组件调engine.addObject(box, { name: Box-01 })。ObjectFactory维护一个类型白名单避免传入未定义类型时拿到 undefined// engine/ObjectFactory.js import * as THREE from three export function createObject(type, options {}) { const creators { box: () new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x8888ff }) ), sphere: () new THREE.Mesh( new THREE.SphereGeometry(0.5, 32, 16), new THREE.MeshStandardMaterial({ color: 0xff8866 }) ), light: () new THREE.DirectionalLight(0xffffff, 1) } const creator creators[type] if (!creator) return null const obj creator() if (obj) { obj.name options.name || Object-${Date.now()} obj.position.set( options.position?.x || 0, options.position?.y || 0, options.position?.z || 0 ) } return obj }写到这里要说明一个细节creators[type]必须先取出来判断不能直接creators[type]()否则传一个不存在的 key 会报 “not a function”。options.position?.x用了可选链调用方即使只传了 name 也不会把坐标重置成 NaN。接下来 SceneManager 的addObject方法同时维护 Three 场景和 Vue 状态addObject(type, options {}) { const mesh ObjectFactory.createObject(type, options) if (!mesh) return null const plain { uuid: mesh.uuid, name: mesh.name, type, visible: true, position: { x: mesh.position.x, y: mesh.position.y, z: mesh.position.z }, rotation: { x: mesh.rotation.x, y: mesh.rotation.y, z: mesh.rotation.z }, scale: { x: mesh.scale.x, y: mesh.scale.y, z: mesh.scale.z }, material: mesh.material?.color ? #${mesh.material.color.getHexString()} : } this.objectsById.set(mesh.uuid, mesh) this.scene.add(mesh) sceneState.objects.push(plain) return mesh.uuid }objectsById是一个普通 Map存真实 Object3D 引用sceneState.objects存 UI 要展示的 plain object。一个 uuid 对应两份记录一份给 Three 渲染用一份给 Vue 模板用。左侧对象树不感知三.js 场景它只是在sceneState.objects.push(plain)后自动多了一行。删除操作要处理得比添加更小心尤其是内存释放。完整实现放在第五章的避坑条目里那里能讲清楚为什么removeObject的顺序会直接影响 WebGL 是否报错。3.3 属性面板直连v-model 到 position / rotation / scale 的转换细节属性面板如果直接写v-modelselected.position.x会在用户清空输入框时拿到空字符串parseFloat()变成 NaN坐标被赋成 NaN 后整个场景渲染就会异常。这里的处理是监听 input 事件做一次安全转换input typenumber step0.1 :valueselected.position.x inputonAxisInput(position, x, $event.target.value) /对应的方法function onAxisInput(key, axis, rawValue) { if (!selected.value) return const num parseFloat(rawValue) const safe Number.isNaN(num) ? 0 : num sceneState.objects sceneState.objects.map(obj { if (obj.uuid ! sceneState.selectedId) return obj return { ...obj, [key]: { ...obj[key], [axis]: safe } } }) }用 map 替换而不是直接改原对象是为了给后续的撤销重做留空间。如果直接obj.position.x safe历史栈里存下来的旧对象引用会被改动污染真正做 undo 时无法回退。map 之后生成一个新数组旧对象保持不变历史快照才可靠。改完描述数据后还要把数值同步到 Three 场景里的真实 Object3D 上。这里的同步路径是通过 SceneManager 暴露的方法完成的它要更新对象矩阵否则 TransformControls 的 gizmo 位置不会跟着变syncTransform(uuid, key, axis, value) { const obj this.objectsById.get(uuid) if (!obj) return obj[key][axis] value if (key position) { obj.updateMatrixWorld(true) } }updateMatrixWorld(true)强制更新世界矩阵。TransformControls 的 gizmo 是根据对象矩阵渲染的只改 position 不更新矩阵拖拽手柄会停在旧位置。3.4 序列化与场景存档toJSON / fromJSON 实现场景保存是编辑器区别于普通 demo 的关键能力。Three.js 的Scene.toJSON()已经能输出完整场景顶层包含 metadata、object、geometries、materials、images 五个字段对象之间靠 uuid 串联。第一次看到这个 JSON 结构时它对新手的观感确实是黑匣子但只要不手动改字段序列化本身是稳定的。// engine/Serializer.js import * as THREE from three export function serializeScene(scene) { return JSON.stringify(scene.toJSON()) } export function deserializeScene(jsonString) { const loader new THREE.ObjectLoader() return loader.parse(JSON.parse(jsonString)) }ObjectLoader.parse会递归重建场景里的所有 Object3D材质和几何体由内部缓存去重。调用侧的保存与加载流程saveToFile() { const json serializeScene(this.scene) const blob new Blob([json], { type: application/json }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download scene.json a.click() URL.revokeObjectURL(url) }scene.toJSON()会把贴图、几何体、材质全都嵌进去文件偏大但这是全量保存加载简单可靠。验证序列化是否成功很简单导出 JSON刷新页面再导入场景里的对象、位置、材质颜色应该完全一致。如果材质对不上多半是ObjectFactory里创建材质时漏了某些参数。4. 本地运行与调试clone 源码到 FPS 面板显示的三个步骤4.1 环境准备Node 版本、包管理器、依赖安装命令运行前提是 Node 的 LTS 版本Vite 对 Node 版本有最低要求建议直接用 LTS。包管理器用 npm 或 pnpm 都可以我一般用 pnpm因为 Three.js 的依赖树不小pnpm 的硬链接机制能省不少磁盘空间。安装命令npm install如果想用 pnpmpnpm install装完依赖后启动开发服务器npm run devVite 默认在http://localhost:5173启动页面加载后应看到默认场景中间一个方块顶部是工具栏。基础操作是鼠标左键旋转视口、右键平移、滚轮缩放。如果这步就报Cannot find module three优先检查 package.json 和 lock 文件是否匹配把node_modules删掉重新 install 往往能解决。这种问题玄学概率很高本质是锁文件与当前源码版本不一致。4.2 启动 dev server 与 vite 配置别名/utils项目里大量组件用/engine/...这种路径Vite 需要配置别名否则启动时全是模块找不到的报错。vite.config.js是这样配置的import { defineConfig } from vite import vue from vitejs/plugin-vue import { fileURLToPath } from node:url export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { port: 5173, open: false } })alias里的指向src目录这样组件里写import { SceneManager } from /engine/SceneManager时Vite 能找到文件。open: false表示启动时不自动打开浏览器我习惯不自动弹标签页。改完vite.config.js后如果没生效多半是 dev server 没重启Vite 对配置文件的感知偶尔会慢半拍手动 CtrlC 再npm run dev是最稳的后悔药。4.3 调试技巧用 stats.js 和 OrbitControls 看自己的场景场景编辑器最容易出现的问题是“场景里有 30 个模型帧率却很低”。这不一定是 GPU 瓶颈也可能是渲染循环里的 JS 逻辑过重或者 Vue 响应式更新干扰了主线程。我给这个项目加了 stats.js 面板可以同时看到 FPS 和渲染耗时npm install -D stats.js注册到容器右上角import Stats from stats.js this.stats new Stats() this.stats.dom.style.position absolute this.stats.dom.style.top 0px this.stats.dom.style.right 0px container.appendChild(this.stats.dom)渲染循环里begin和end必须紧包住真实渲染调用_renderLoop() { requestAnimationFrame(() this._renderLoop()) this.stats?.begin() this.renderer.render(this.scene, this.camera) this.stats?.end() }如果begin包住了整个 requestAnimationFrame 回调用时统计的是事件循环总耗时不是渲染耗时那样得到的数据没有参考价值。看面板时还有一个判断技巧FPS 高但 render 耗时高那是 draw call 太多FPS 低但 render 耗时正常多半是 JS 逻辑或者 Vue 更新占用了主线程。这个区分在排查性能问题时很省时间。如果想进一步验证对象树和场景里对象数量是否一致可以在 devtools 里把 SceneManager 挂到 window 上window.__sceneManager sceneManager然后执行__sceneManager.objectsById.size和左侧树的数量对比。不一致说明有删除操作没有走removeObject而是直接用scene.remove(obj)这是“场景里还有看不见的对象”这类问题的最快排查手段。4.4 场景文件导入导出的闭环验证调试序列化最直接的方法是把编辑器当成一个“场景查看器”先手动搭一个包含方块、球体、平行光的场景保存成scene.json然后刷新页面后通过导入按钮把它拖回场景。如果一切正常刚才的对象应该原样出现在左侧树位置和材质都一致。如果在导入后某个对象消失打开 console 查看 ObjectLoader 抛的错最常见的问题是type字段引用了不存在的构造函数比如序列化时材质已经是自定义类但导入时没有注册这个类。对于一个把 MeshStandardMaterial 作为主材质的编辑器来说自定义材质越少导入越稳。5. 避坑指南vue3 three 编辑器里四个高频翻车点5.1 canvas 尺寸为 0 导致的场景黑屏或只占一角现象页面加载后中间区域一片空白或者 canvas 只显示在左上角一小块。原因container 的clientHeight为 0。典型情况是容器高度写成100%但父元素没有具体高度另一个常见情况是 flex 布局里中间视口被左右两栏挤压高度没有撑开。renderer.setSize如果只调用一次浏览器窗口变化后 canvas 也会拉伸变形。解决在 SceneManager 构造时读取容器尺寸并用 ResizeObserver 监听容器变化而不是监听 window resize。同时在 CSS 里给视口容器设置flex: 1; min-height: 0min-height: 0是 flex 子项高度塌陷的关键修复。this.resizeObserver new ResizeObserver(() this._resize()) this.resizeObserver.observe(container)提示在 Flex 布局里中间视口高度塌陷是黑屏最高频原因min-height: 0几乎每次都要写。5.2 reactive 包装 Object3D 导致无限循环现象页面没有任何操作CPU 占用直接 100%风扇狂转。原因早期版本把THREE.Scene直接传给了reactive。Three.js 的 Object3D 每帧都在更新matrixWorld、quaternion等属性reactive 拦截这些 setter 后触发响应式队列不断被填充渲染循环和响应式更新互相反馈形成死循环。解决不要把 Three 对象直接交给响应式系统。用sceneState存 plain object用一个普通 Map 存真实 Object3D。如果组件里确实需要拿 Three 对象用shallowRef避开深层代理因为对象引用本身不需要响应式。5.3 异步加载 GLTF 模型导致先白模后材质的闪变现象导入一个大模型后先出现白色模型过一会儿贴图才显示期间页面像是卡住了。原因GLTFLoader.load是异步的贴图解码也是异步过程。加载完成前MeshStandardMaterial 的 map 为空渲染出来就是灰白的。如果 glb 文件解析很重主线程会被 JSON 解析卡住。解决加载前在sceneState里记录 loading 状态UI 上显示遮罩真正加载完成后再把模型加进场景。这样比“先加进去再等贴图”体验好得多。遮罩方案在这个项目里是性价比最高的做法渲染器编译 shader 的优化反而会拖慢首次加载。5.4 删除选中对象后 TransformControls 仍然拖着旧物体现象选中物体按 Delete 键对象从树里消失了但场景中的控制柄还停在原地拖动时报错Cannot read properties of null。原因TransformControls.attach(object)保存了内部对象引用。删除时只调了scene.remove(object)没有把 gizmo 从对象上分离也没有清空选中状态。解决移除前先判断当前选中对象是不是transformControls.object如果是先detach再移除对象最后清空selectedId。顺序反了会触发 WebGL 警告。同时遍历对象子树释放几何体和材质removeObject(uuid) { const obj this.objectsById.get(uuid) if (!obj) return if (this.transformControls.object obj) { this.transformControls.detach() } this.scene.remove(obj) obj.traverse(child { if (child.geometry) child.geometry.dispose() if (child.material) child.material.dispose() }) this.objectsById.delete(uuid) sceneState.objects sceneState.objects.filter(item item.uuid ! uuid) if (sceneState.selectedId uuid) sceneState.selectedId }traverse和dispose放最后一步是血泪经验换来的在detach之前释放材质会让渲染器引用到已经销毁的 GPU 资源控制台直接刷红色警告。长时间编辑后内存上涨多半就是这里只scene.remove没dispose。6. 二次开发实战给编辑器加一个 Transform 模式切换工具栏以添加“平移 / 旋转 / 缩放”三模式切换按钮为例看看这套架构下改功能有多快。这个功能要动两个模块SceneManager加一个设置方法Toolbar.vue加三个按钮。其他模块零改动。SceneManager 里已经初始化了 TransformControls封装一个入口setTransformMode(mode) { this.transformControls.setMode(mode) sceneState.transformMode mode }script setup import { sceneState } from ../store/scene const modes [ { value: translate, label: 平移 }, { value: rotate, label: 旋转 }, { value: scale, label: 缩放 } ] function switchMode(mode) { sceneState.engine?.setTransformMode(mode) } /script template div classtoolbar button v-formode in modes :keymode.value :class{ active: mode.value sceneState.transformMode } clickswitchMode(mode.value) {{ mode.label }} /button /div /template按钮高亮不维护组件内 state直接读sceneState.transformMode保证了工具栏和高亮状态永远同步。验证方法是选中立方体切换三种模式观察 gizmo 从坐标轴箭变为旋转环再变为缩放盒拖动时属性面板对应数值实时变化且 console 无报错。从那以后我每次加功能都会先想一遍数据流再动代码先确认 UI 入口会调 SceneManager 的哪个方法、改的是哪个响应式字段再落键盘。这个习惯让这个 Three.js 编辑器少翻了很多车希望帮到你。本文还有配套的精品资源点击获取