用Vue3组合式API重写Three.js场景编辑器实战

发布时间:2026/10/16 4:41:18
用Vue3组合式API重写Three.js场景编辑器实战 简介面向需要搭建三维场景编辑工具的前端开发者与三维可视化爱好者这套基于 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.js2.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: [], // 当前选中的物体 uuidnull 表示未选中 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 undefinedreading isMesh。原因Vue3 的响应式系统会深度代理对象Three.js 的 Math 类Vector3、Euler、Color都明确标记了自己的私有字段_x/_y/_zgetter 访问时已经放到了代理层后面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 three0.160.0如果安装时提示peerDependencies冲突看看是不是本地 Node 版本太高比如 20导致 Vite 的 peer 依赖校验失败用pnpm install --legacy-peer-deps绕过即可。5.2 开发调试流程启动开发服务器pnpm devVite 默认端口是 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 存数据快照、拖拽事件手动同步」这套流程少踩一大半的坑。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询