☰
用Vue3组合式API重写Three.js场景编辑器实战
2026/10/16 4:41:08 网站建设 项目流程

简介:面向需要搭建三维场景编辑工具的前端开发者与三维可视化爱好者,这套基于 Vue3、Three.js、Vite 构建的 3D 场景编辑器打包文件,帮助解决在浏览器中快速搭建、编辑并复用 3D 场景的问题。编辑器支持场景导入导出,可自定义灯光、模型、脚本等配置;导出的场景文件能通过 scene-view 解析器渲染,方便嵌入其他项目复用。资源共 230 个文件,包含 102 个 JS 脚本、95 张 JPG 图片、20 个 CSS 样式、5 个 WASM 模块,以及少量 PNG、SVG、GIF 等辅助资源,压缩包约 12.1MB,目录结构完整清晰,便于二次开发与本地部署。目前已有 2974 人学习下载。解压后自行启动服务即可体验纯前端单机版完整流程,代码基于 Vite 构建、打包产物可直接集成到现有工程,适合希望理解 Three.js 场景编辑器实现思路、掌握 scene-view 解析机制或基于此扩展功能的开发者参考学习。

1. 项目定位:一个给非开发人员用的 Three.js 场景编辑器

接到一个需求:给在线3D方案预览系统配一个场景编辑器。运营每次要调整产品展示图里的物体位置、材质颜色,都得提工单找开发改代码,改一次要等半天。最直接的解决办法是做一个网页端的3D场景编辑器——拖拽物体、改颜色、保存场景配置,全程不让开发介入。这个项目就是干这个的:用 Vue3 组合式 API 重写 Three.js 场景编辑器,底层渲染交给 Three.js,上层交互交给 Vue3。适合两类人看:一类是刚接触 Three.js 的 Vue 开发者,想知道怎么把 3D 场景塞进组件里;另一类是已经在写 Three.js 但项目结构越来越乱的人,想看看场景逻辑怎么和 UI 状态分离,而不是把所有代码堆在 App.vue 里。

2. 整体架构:为什么用 Vue3 重写而不是纯 Three.js

2.1 技术选型:Vue3 只是壳,Three.js 才是核心

这个项目本质上是一个复杂的 DOM 操作程序,但操作的不是 div,而是 canvas 里的 3D 场景。如果用原生 Three.js 写,代码量一大就容易变成几千行的单体文件;但用了 Vue3 之后,面板组件、属性表单、资源列表全部由 Vue 管理,Three.js 只需要专心管 canvas 里的渲染逻辑,两者分得很干净。

技术栈选型上,核心是这几样:

  • Three.js:负责场景渲染、几何体、材质、灯光和相机
  • Vue3 Composition API:把 Three.js 初始化逻辑封装成可复用的组合函数
  • Vite:开发服务器和构建工具。Vite 的热更新对 Three.js 项目很友好,修改材质颜色或者几何体参数时组件能即时刷新
  • Pinia:管理场景状态,包括当前选中物体、物体列表、编辑器模式(平移/旋转/缩放)

这里有一个取舍:为什么不直接用 Vue3 的响应式系统把 Three.js 场景包进去?因为 Three.js 的场景对象很大,而且大部分属性不需要响应式。常见的做法是,只有「选中了哪个物体」「物体的位置参数」「材质颜色」这类 UI 相关的数据放进响应式系统,Three.js 场景本身用普通对象引用。

2.2 目录结构:场景逻辑与 UI 组件完全分离

项目结构不是传统 Vue 项目的「页面 + 组件」划分方式,而是按「编辑器核心」和「面板 UI」两条线拆开的:

src/ ├── components/ │ ├── Viewport.vue // 3D 视口组件,承载 canvas │ ├── HierarchyPanel.vue // 左侧场景物体层级列表 │ ├── InspectorPanel.vue // 右侧属性检查面板 │ └── Toolbar.vue // 顶部工具栏:平移/旋转/缩放切换 ├── core/ │ ├── engine.js // 渲染器、场景、相机的初始化与销毁 │ ├── objects.js // 添加/删除/选中场景物体的逻辑 │ ├── transform.js // 位移、旋转、缩放的控制器封装 │ └── serialize.js // 场景 JSON 序列化与解析 ├── composables/ │ ├── useEngine.js // 提供渲染器生命周期 │ └── useSelection.js // 提供物体选中状态 ├── stores/ │ └── editor.js // Pinia 全局状态 └── App.vue // 只做布局,不放业务逻辑

Viewport.vue 是一个 canvas 容器组件,Three.js 渲染器挂载在它里面;HierarchyPanel.vue 展示场景里所有物体,点击列表项可以选中物体;InspectorPanel.vue 显示当前选中物体的位置、旋转、缩放和材质参数,修改表单上的数字,场景里的物体会立刻同步变换。App.vue 只负责排版,把三个面板放好位置,核心逻辑全在 core 和 composables 里。

2.3 数据流设计:Pinia 管状态,Three 管渲染

编辑器里最频繁的操作是「选中物体 → 改参数 → 场景刷新」,这里的数据流设计很关键。

在 stores/editor.js 里,定义了选中物体、物体列表和编辑器模式三个核心状态:

// stores/editor.js import { defineStore } from 'pinia' export const useEditorStore = defineStore('editor', { state: () => ({ // 物体列表只存 uuid 和 name,不存 Three 对象本身 objects: [], // 当前选中的物体 uuid,null 表示未选中 selectedObjectUuid: null, // 编辑器模式:translate / rotate / scale mode: 'translate' }), actions: { // 选中物体时,除了更新 uuid,还要通知 Three 场景更新 selectObject(uuid) { this.selectedObjectUuid = uuid // 通过事件总线或 pinia 外抛,让核心层监听到选中变化后触发拾取高亮 }, // 同步物体位置到列表 updateObjectTransform(uuid, { position, rotation, scale }) { const obj = this.objects.find(o => o.uuid === uuid) if (obj) { obj.position = position obj.rotation = rotation obj.scale = scale } } } })

这里有一个设计上的关键点:Pinia 的 objects 数组里不直接存 Three.js 的 Mesh 对象,只存 uuid、name、position 这些可序列化的数据。为什么?

因为 Three.js 的 Mesh 对象内部有太多循环引用和大型缓冲区属性,放进去会被 Vue 的响应式系统深度代理,带来两个后果:

  • 修改 Mesh.position.x 时会触发无意义的响应式更新
  • 序列化 JSON 导出时会碰到循环引用报错

后面避坑章节会详细说这个问题。数据流的整体走向是:Pinia 存「状态」,core 层通过 watch 或事件机制监听状态变化后操作 Three 场景,场景内的变化再同步回 Pinia。这样就保证了整个编辑器不会出现「UI 改了但场景没动」或者「场景动了但面板没刷新」的脱节问题。

3. 核心模块拆解:一个请求从进入到返回的完整链路

3.1 渲染器与场景骨架:从 canvas 初始化到自适应

Viewport.vue 是编辑器的主战场,它做的事情有两件:创建渲染器和销毁渲染器。这个生命周期必须跟 Vue 组件的挂载和卸载完全对齐,尤其是销毁这一步,写漏了会留下一个大坑——切换到别的页面之后 canvas 还在后台跑渲染循环,CPU 占用直接拉满。

// core/engine.js import * as THREE from 'three' export function createEngine(canvas) { // 创建渲染器,开启抗锯齿 const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) renderer.setPixelRatio(window.devicePixelRatio) // 场景和相机:使用透视相机,视野 45 度,近远裁剪面设为 0.1 和 1000 const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 1000) camera.position.set(5, 5, 10) camera.lookAt(0, 0, 0) // 基础灯光:环境光负责整体照明,方向光制造阴影感 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8) directionalLight.position.set(10, 20, 10) scene.add(ambientLight, directionalLight) // 地面网格,方便用户感知空间位置 const gridHelper = new THREE.GridHelper(20, 20) scene.add(gridHelper) return { renderer, scene, camera } }

Three.js r160 版本里,WebGLRenderer 构造参数里的 canvas 可以直接传组件里的 DOM 节点,这样就不需要再把渲染器挂到别的容器上。antialias 抗锯齿对编辑器场景很重要,不开的话模型边缘会有明显的锯齿。透视相机的近远裁剪面是 0.1 到 1000,如果做的是大场景漫游(比如整个园区),远裁剪面需要增大到 5000 以上,否则远景会被裁掉。

3.2 物体拾取与选中:Raycaster 射线检测

把物体加进场景很容易,难点是怎么让用户「点击选择物体」。Three.js 提供的 Raycaster 可以在鼠标点击位置从相机发出一条射线,计算出射线与场景中 Mesh 的交集。这一步有点玄学的地方在于:如果忽略了物件的包围盒或者父级变换,点击就选不中。常见做法是用递归查找,把所有可见的物体收集进一个数组再统一检测。

// core/objects.js import * as THREE from 'three' export function initRaycaster(canvas, camera, { onPick }) { const raycaster = new THREE.Raycaster() const pointer = new THREE.Vector2() canvas.addEventListener('click', (event) => { // 将鼠标坐标转换为归一化设备坐标,范围从 -1 到 1 const rect = canvas.getBoundingClientRect() pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 raycaster.setFromCamera(pointer, camera) // 需要传入一个可选的物体数组,这里由参数传入可拾取物体组 const intersects = raycaster.intersectObjects(pickables, false) if (intersects.length > 0) { onPick(intersects[0].object) } }) }

picker 中有一个特别容易踩的坑:intersectObjects 的第二个参数是 recursive,很多新手会设成 false,导致场景里有嵌套关系的物体(比如「机械臂底下挂夹爪」)点击夹爪永远选不中。在这个编辑器里,需要支持层级嵌套,所以这里应该传 true,让射线穿透所有子孙节点进行检测。

选中之后还要做视觉反馈。常见的做法是对被选中的物体套一层高亮线框,Three.js 的 BoxHelper 可以直接包裹任意物体生成长方体线框,不需要手工创建几何体,这是编辑器里很实用的技巧。

3.3 TransformControls 拖拽与变换:平移、旋转、缩放

场景编辑器最重要的交互就是拖拽。Three.js 官方有一个 TransformControls 辅助类,能像 3ds Max 一样在物体上显示三个轴方向的控制手柄,拖动手柄就能变换物体位置、角度和缩放。这个类在 addons 包里,引入路径是 three/examples/jsm/controls/TransformControls.js。

// core/transform.js import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js' export function createTransformControls(camera, domElement, scene) { const controls = new TransformControls(camera, domElement) scene.add(controls) // 拖拽过程中实时同步旋转角度到 Pinia,让属性面板跟着刷新 controls.addEventListener('dragging-changed', (event) => { // 拖拽时禁止 OrbitControls 同步旋转相机,否则会互相抢操作 this.controls.enabled = !event.value }) // 物体变化时回调,把 position 同步出来 controls.addEventListener('change', () => { const obj = controls.object if (obj) { updateTransformToStore(obj) } }) return controls }

dragging-changed 事件是编辑器里必须处理的:当用户按住控制器手柄拖拽时,相机轨迹球 OrbitControls 也要禁用,否则鼠标一动场景就跟着旋转,操作物体会「飘」。这是一个用过一次就忘不掉的坑。

TransformControls 还支持 mode 切换:translate(平移)、rotate(旋转)、scale(缩放),对应工具栏上的三个按钮。切换模式只需调用 controls.setMode('rotate'),底层会自动换一套控制柄 UI。编辑器工具栏就是绑定这种方式实现的。

3.4 场景序列化:JSON 导入导出

这个编辑器最终要解决「改完配置保存下来」的问题。Three.js 的 Mesh 对象不能直接 JSON.stringify,因为它内部有循环引用,直接序列化会报错。项目的 serialize.js 模块做了一层映射:

// core/serialize.js export function serializeScene(objects) { // 把 Three.js 物体精简为可序列化的普通对象 return objects.map(mesh => ({ uuid: mesh.uuid, name: mesh.name, type: mesh.geometry.type, // 位置、旋转、缩放,three 的 Euler 需要拆成 {x, y, z} 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: { color: mesh.material.color.getHex(), opacity: mesh.material.opacity, transparent: mesh.material.transparent } })) } export function loadSceneFromJSON(json) { // 反序列化时重新构建 THREE.Mesh,还原位置和材质 return json.map(item => { const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshStandardMaterial({ color: item.material.color }) const mesh = new THREE.Mesh(geometry, material) mesh.position.set(item.position.x, item.position.y, item.position.z) mesh.name = item.name return mesh }) }

这种「精简序列化」把几何体参数、材质参数都固化成简单的 JSON,不依赖 Three.js 的 toJSON 方法。优点很明显:导出文件体积小、结构直观、后端也能直接解析;缺点是需要自己维护 tab 映射。如果后续要支持导入自定义几何体(比如从外部 GLTF 文件加载),再扩展一个 geometry 字段存资源路径即可。

序列化格式是编辑器的核心资产,一定要在开发早期定好版本号。后面加字段时(比如给材质加 wireframe 属性),没有版本号会导致旧配置文件解析出错。

4. 避坑记录:Three.js 与 Vue3 整合的高频问题

4.1 现象:物体拖拽后坐标「飘」

把 TransformControls 拖到一个位置,松开鼠标,物体又跳回了之前的位置。这个坑非常隐蔽:它的原因不是 Three.js 计算出错,而是 Vue3 的组合式函数在 setup 里取到的物体状态是旧引用。比如在拖拽事件的回调里通过 get 函数取 mesh.position 时,拿到的是一个已经被重新赋值过的position对象,直接用= mesh.position.x给 Pinia 赋值,回写的时候位置就覆盖回来了。

解法:同步位置时不要存引用,存数值快照。

controls.addEventListener('change', () => { const p = controls.object.position // 用浅拷贝保存数值,防止整个对象引用被 Pinia 响应式代理 editorStore.updateObjectTransform(mesh.uuid, { position: { x: p.x, y: p.y, z: p.z } }) })

从那以后我每次遇到「数据被跳回」的诡异现象,第一反应不是检查 Three 的数学库,而是看是不是 Vue3 的响应式代理把 Three 对象包了一层。

4.2 现象:材质颜色改了但画布不更新

InspectorPanel 上有一个颜色选择器,点击之后颜色值变了,但场景里的物体还是原来的颜色。原因:MeshStandardMaterial 的 color 属性不是基本类型,它内部是 Color 类的实例,直接给这个实例赋值普通字符串不会触发 Three.js 的内部更新逻辑。

解法:用 color 对象实例的方法设置。

import { Color } from 'three' // 正确的做法 mesh.material.color.set('#ff0000') // 或者接受 rgb 分量 mesh.material.color.setRGB(1, 0, 0)

还有一个连带问题:如果材质调用了material.needsUpdate = true,但就只更新一次,后续渲染循环里不会再触发重新编译着色器。这是 Three.js 的缓存机制,记得给属性的变更时刻做一次手动 needsUpdate 标记,完成后重置为 false。

4.3 现象:组件切走再切回,多个渲染循环叠加

Vue 的组件是动态挂载和销毁的,如果在 Viewport 组件里只创建渲染循环而不清理,组件还留在页面时销毁了 Canvas,再次进入页面又创建了一个新的渲染循环。旧的requestAnimationFrame循环还在跑,两个循环同时渲染同一个 canvas,表现为画面闪烁、帧率下降、浏览器 CPU 占用 100%。

解法:组件卸载时停止循环并释放 GPU 资源。

// Viewport.vue import { onMounted, onBeforeUnmount } from 'vue' let animationId function animate() { animationId = requestAnimationFrame(animate) renderer.render(scene, camera) } onMounted(() => { createEngine(canvasRef.value) animate() }) onBeforeUnmount(() => { cancelAnimationFrame(animationId) // 释放 GPU 内的几何体和材质,防止内存泄漏 scene.traverse(obj => { if (obj.isMesh) { obj.geometry.dispose() obj.material.dispose() } }) renderer.dispose() })

这个坑几乎人人都会踩。推荐在项目里建一个 useEngine 组合函数,把 create / destruction / animation 循环都封装起来,这样所有页面引用同一个生命周期管理逻辑,不会漏掉 cancelAnimationFrame。

4.4 现象:Three 对象放进 reactive 后被 Proxy 包装导致报错

在早期版本里,我尝试过直接把 Mesh 对象塞进 Pinia 的state,然后发现mesh.rotation.x赋值之后,整个对象直接被 Proxy 包装,Three.js 内部某个方法拿到 did 时发现不是自己的类型,直接抛出一个异常Cannot read properties of undefined(reading 'isMesh')。

原因:Vue3 的响应式系统会深度代理对象,Three.js 的 Math 类(Vector3、Euler、Color)都明确标记了自己的私有字段_x/_y/_z,getter 访问时已经放到了代理层后面,Three.js 的 setter 函数在写私有字段时走的是 Proxy 拦截的 set 操作,拦截之后访问内部缓存就失败了。

解法:凡是和 Three 对象直接相关的数据都裸存(用markRaw标记),或者干脆不进入 Pinia。

import { markRaw } from 'vue' // 场景中的 mesh 用 markRaw 包裹后才放进可变数组 sceneObjects.push(markRaw(mesh))

5. 运行与调试:从克隆到跑通完整流程

5.1 环境准备与依赖安装

项目依赖 Node.js 16.18 以上版本,推荐用 pnpm 作为包管理器。clone 到本地之后,执行:

pnpm install

装依赖时需要注意:Three.js 从 r150 之后走了新的发布通道,如果直接用pnpm add three,装到的可能是 pre-release 版本,API 不稳定。这个项目锁定的是 r160 左右的稳定版本,安装指令建议带上精确版本号:

pnpm add three@0.160.0

如果安装时提示peerDependencies冲突,看看是不是本地 Node 版本太高(比如 20+)导致 Vite 的 peer 依赖校验失败,用pnpm install --legacy-peer-deps绕过即可。

5.2 开发调试流程

启动开发服务器:

pnpm dev

Vite 默认端口是 5173,浏览器打开后就能看到编辑器界面。左侧是物体层级列表,右侧是属性面板,中间是 3D 视口。

调试时建议打开 Vue DevTools 的 Pinia 面板,重点看两个状态:selectedObjectUuid有没有更新,objects里有没有被塞进 Three 对象(如果看到 Mesh 出现,就说明某段代码忘记markRaw了)。选中一个物体,拖拽一下,再点击属性面板上的坐标输入框,验证两个方向的同步是否正常。

5.3 构建与部署

pnpm build

构建产物在 dist 目录。这个项目是纯前端应用,没有后端接口,直接部署到任意静态资源服务器就行。

需要注意一点:因为项目用到了three/examples/jsm/...下的模块,Vite 构建时会自动做 tree-shaking,但 TransformControls 这类 addons 依赖了 DOM 事件监听,如果代码里对 addons 做了动态 import,需要确保目标浏览器支持 ES2020。生产环境建议把构建后的产物拿到浏览器实测一次,用 Chrome 的 Performance 面板看渲染循环有没有抛异常——编辑器类项目最常见的生产环境 Bug 都是因为浏览器不支持某个 WebGL 扩展导致渲染器初始化失败。

6. 二次开发指南:扩展一个「自定义文字标签」组件

拿到这份源码之后,最快上手二次开发的方式是新增一种物体类型。这里以「在场景中添加一个文字标签」为例,把扩展思路完整走一遍。

第一步:在 core/objects.js 里新增工厂函数,用 Three.js 的 TextGeometry 生成 3D 文字。注意文字字体需要加载外部 JSON 字体文件,在 unpkg 上有现成的资源:

import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js' import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js' export function createTextLabel(text = '标签') { const loader = new FontLoader() const font = loader.load('/fonts/helvetiker_regular.typeface.json') const geometry = new TextGeometry(text, { font, size: 0.4, // 文字尺寸,决定标签在场景里的大小 height: 0.1, // 文字厚度,3D 效果 curveSegments: 6 // 曲面的分段数,越小性能越好,越大越平滑 }) const material = new THREE.MeshStandardMaterial({ color: 0xffffff }) const mesh = new THREE.Mesh(geometry, material) mesh.name = text return mesh }

第二步:在工具栏里加一个「新增文字」按钮,点击之后调用工厂函数,把生成的 mesh 加入场景,然后同步更新 Pinia 的 objects 数组。

第三步:扩展 InspectorPanel,在材质区域增加一个 text 输入框。注意文字几何体无法在创建后直接改内容,需要重新创建;所以监听输入变化时,先调用dispose()释放旧几何体,再用新文本重新生成网格。

扩展完可以在序列化格式里加一个text字段,让编辑器的导出文件支持保存带文字的标签。

// serialize.js 里增加 if (mesh.userData.text !== undefined) { item.text = mesh.userData.text }

这个扩展点看起来很普通,但它已经走完了编辑器的完整闭环:新建物体、UI 操作、数据持久化三个核心链路都打通了。后续不管是加「圆柱体」「灯光预设」还是「导入 GLB 模型」,都是在这个闭环里替换几何体和材质的部分。

整个编辑器项目踩坑最多的地方不是 Three.js 的 API,而是引擎逻辑和 Vue 响应式系统的边界。从那以后,我写类似项目都会强制走一遍「裸存引擎对象、Pinia 存数据快照、拖拽事件手动同步」这套流程,少踩一大半的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询