☰
Vue3+Three.js场景编辑器:数据驱动架构与工程实战
2026/10/16 4:42:52 网站建设 项目流程

简介:这是一份基于Vue3、Three.js与Vite打造的3D场景编辑器工程资源,主要面向需要快速搭建三维可视化页面或自研Web端3D编辑工具的前端工程师。编辑器目前为纯前端单机版,支持场景模型的导入、导出与自定义配置,导出的场景文件可交由scene-view解析器在网页端渲染展示,因而既能独立完成三维场景的搭建,也能与现有项目灵活对接。压缩包约12.1MB,共230个文件,其中102个JS文件承担编辑器逻辑与依赖库,95张JPG为场景贴图及纹理素材,20个CSS用于各功能模块样式,另有WASM、PNG、HTML等文件辅助模型解析与环境启动,文件分类明确,便于按需查阅。目前已有2974人学习下载。通过这套资源,读者可以启动本地服务直接体验完整编辑流程,同时参考其目录结构与代码组织,深入理解Three.js场景管理、序列化与模型加载等关键实现,适合作为二次开发基座或个人作品的技术蓝本。

1. Three.js 3D场景编辑器:Vue3版本到底解决了什么问题

做前端三年以上的人基本都碰过这个场景:项目里要嵌入一个3D模块,需求从“能转圈看”升级到“能拖模型、改参数、保存场景”,此时直接拿 Three.js 原生代码堆功能会非常痛苦——渲染逻辑、交互逻辑、UI 状态全缠在一起,改一个功能要连带动三处代码。这份 Vue3 版本的 Three.js 场景编辑器把完整的可视化编辑能力收拢成响应式数据驱动架构:左侧场景树、中间画布、右侧属性面板三联动,物体增删、位移旋转缩放、材质颜色、灯光强度实时可改,并且支持场景 JSON 导出与回载。适合两类人:一类是写过 Three.js demo 但没做过编辑器的前端工程师,另一类是需要在管理后台里内嵌 3D 配置界面的业务开发者,拿这份资源可以直接改、直接跑。

2. 场景树与渲染循环的架构解耦:为什么不该把 Object3D 交给 Vue

先回答一个刚上手的人都会纠结的问题:Three.js 的 Scene 本身是个对象图,Object3D 上有 position、rotation、scale 这些属性,看起来天然和 Vue 的响应式系统契合,那能不能直接把 scene 塞进 reactive 里?

我早期在一个模拟项目里这么干过,结果踩了硬坑。Three.js 内部大量遍历自身属性做矩阵更新,Vue 的 Proxy 代理会把每次属性访问都拦一道,性能损耗是一方面;更麻烦的是 Three.js 内部一些对 this 的隐式引用在代理层会不稳定,偶尔出现旋转值被读成 NaN 甚至报 “Cannot read properties of undefined” 这种看起来和场景完全无关的错误。这个编辑器的做法是把“场景数据”和“Three 对象”彻底分开,中间只靠一个同步函数通信,这是整套代码里最值得先看懂的地方。

2.1 影子数据先行:一个普通对象描述一个场景物体

所谓影子数据,就是不掺任何 Three.js 类型、可以安全 JSON 序列化的纯 JavaScript 对象。每个场景物体在 store 里对应这样一条记录:

// store/modules/scene.js import { defineStore } from 'pinia' export const useSceneStore = defineStore('scene', { state: () => ({ items: [], // 场景物体影子数据列表 selectedId: null, // 当前选中物体 id lights: [] // 灯光也是同样的思路,单独存 }), actions: { addItem(payload = {}) { const item = { id: payload.id || crypto.randomUUID(), name: payload.name || `物体_${this.items.length + 1}`, type: payload.type || 'mesh', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 }, material: { color: '#cccccc', opacity: 1, metalness: 0 }, visible: true } this.items.push(item) return item.id } } })

这里的核心设计是 store 里永远不出现Mesh、Scene这些 Three.js 实例。id 用crypto.randomUUID()生成,保证每条影子数据有稳定唯一标识;材质字段初始值给到color、opacity、metalness三项,覆盖了 90% 的编辑场景,后面要扩展 emissive、roughness 也只是加字段的事。这么做的直接收益是:导出场景就是JSON.stringify(this.items),撤销重做就是存几张快照。而 Three.js 那边的实际 Object3D 对象,只活在渲染器模块内部维护的Map里,用 item.id 做键。

2.2 渲染器独立成模块:Three.js 代码不带 Vue 语法

渲染器不写在 Vue 组件里,而是抽成一个独立的工厂函数,组件只在挂载时调用它:

// three/renderer.js import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' export function createEditorRenderer(canvas, store) { const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000) camera.position.set(8, 6, 10) camera.lookAt(0, 0, 0) const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.shadowMap.enabled = true const controls = new OrbitControls(camera, canvas) controls.enableDamping = true controls.dampingFactor = 0.08 // 影子数据 id -> Object3D 的映射表,编辑器内部专用 const objectMap = new Map() function render() { renderer.render(scene, camera) } function tick() { controls.update() render() requestAnimationFrame(tick) } tick() return { scene, camera, renderer, controls, objectMap, render } }

几个参数值得记一下:PerspectiveCamera(45, 1, 0.1, 1000)里第二个参数传 1 是占位,真正的宽高比由后续resize()方法按容器尺寸计算后写入,避免初始化时容器还没布局导致画面拉伸。setPixelRatio(Math.min(window.devicePixelRatio, 2))是限制高分屏下的渲染压力,3D 编辑器里场景复杂度不可控,retina 三倍像素比会直接把帧率拖垮。shadowMap.enabled = true一次性开好阴影,避免后面加灯光发现阴影怎么都不显示,回头才想起来没开全局开关,白排查半天。

requestAnimationFrame(tick)在这里是无条件持续跑的,这是编辑器初版的保守做法:保证任何时候操作都即时反馈。后面第 6 章会讲怎么改成按需渲染来省性能。渲染器整个生命周期只有两个对外的接触点:objectMap由同步函数读写,render由外部按需调用,组件卸载时再调用专门暴露的dispose()函数收尾。这种架构换来的是 Vue 组件里基本只写模板和事件,不出现任何new THREE.Mesh()代码。

3. 从零跑起编辑器:依赖搭建、画布初始化和场景序列化

拿到这套资源包之后,第一步不是看代码,而是先让工程跑起来。按下面这个顺序操作,二十分钟内能看到一个能拖拽物体的编辑画布。

3.1 初始化工程与选型注意事项

项目是 Vite 架构的 Vue3 工程,推荐用官方脚手架重新初始化后把src目录替换成资源包里的源码,这样能保证依赖版本和自己本机环境对齐:

npm create vite@latest three-editor -- --template vue cd three-editor npm install three pinia npm install npm run dev

三个依赖各有讲究:three是核心 3D 引擎,注意装完后在package.json里锁住版本号,Three.js 从 R150 之后对 import 路径和部分 API 有调整,升级不留神就会踩到 “OrbitControls 找不到” 这类玄学报错;pinia是 Vue3 官方推荐的轻量状态库,编辑器里所有场景数据都走它,方便调试和做时间旅行式快照;@vueuse/core在资源包里用于处理onKeyStroke快捷键删除物体这类副作用逻辑,属于加分项不是必须项。启动之后如果页面白屏,先看浏览器控制台有没有 “Failed to fetch dynamically imported module”,有的话八成是 Node 版本太低,Vite 5 需要 Node 18 以上,这是最常见的第一个翻车点。

3.2 三栏布局与画布挂载

编辑器是标准的三栏布局,模板结构可以直接抄:

<template> <div class="editor-layout"> <aside class="scene-panel"> <ul> <li v-for="item in store.items" :key="item.id" :class="{ active: item.id === store.selectedId }" @click="store.selectedId = item.id" > {{ item.name }} </li> </ul> </aside> <main class="viewport"> <canvas ref="canvasRef"></canvas> </main> <aside class="prop-panel"> <!-- 属性表单:位置、旋转、缩放、材质颜色 --> </aside> </div> </template>

挂载的关键在组件生命周期与 canvas 尺寸的关系。编辑器最常见的白屏原因不是代码写错,而是 canvas 渲染区域的宽高在初始化时是 0。正确做法是在onMounted里用nextTick等 DOM 布局完成后再创建渲染器,并注册一个窗口尺寸变化的监听:

import { ref, onMounted, nextTick } from 'vue' const canvasRef = ref(null) function resizeRenderer(renderer, camera, container) { const width = container.clientWidth const height = container.clientHeight if (width === 0 || height === 0) return renderer.setSize(width, height) camera.aspect = width / height camera.updateProjectionMatrix() } onMounted(async () => { await nextTick() const container = viewportRef.value const rendererKit = createEditorRenderer(canvasRef.value, store) resizeRenderer(rendererKit.renderer, rendererKit.camera, container) window.addEventListener('resize', () => { resizeRenderer(rendererKit.renderer, rendererKit.camera, container) }) })

resizeRenderer里camera.updateProjectionMatrix()这行很容易被漏掉,漏掉的结果是窗口缩放后画面比例不变形但视角区域错乱,物体看起来被裁切。容器尺寸为 0 时直接 return 不做处理,这个防御性判断能避免渲染器在错误尺寸下建立内部缓冲,后面怎么调都调不回来。

3.3 场景导出与导入:只序列化影子数据

编辑器的核心卖点是“编辑完能把场景存下来”。直接JSON.stringify(scene)是不可能的,Three.js 对象内部有循环引用,会报 “Converting circular structure to JSON”。资源包里的序列化是纯数据层操作:

// utils/sceneSerializer.js import { useSceneStore } from '../store/modules/scene' export function serializeScene() { const store = useSceneStore() const payload = { version: 1, // 场景格式版本号,后续迭代兼容用 camera: { position: { x: 8, y: 6, z: 10 }, // 相机视角也要存,回载才完整 fov: 45, far: 1000 }, lights: store.lights, // 灯光参数列表 items: store.items // 物体影子数据 } return JSON.stringify(payload, null, 2) } export function deserializeScene(jsonText) { const store = useSceneStore() const data = JSON.parse(jsonText) if (data.version !== 1) { console.warn('未知的场景格式版本:', data.version) return false } store.items = data.items.map(item => ({ ...item })) store.lights = data.lights.map(light => ({ ...light })) store.selectedId = null return true }

两个设计细节值得说。一是version字段,场景格式一旦进入业务使用,后面加功能必然导致结构变化,没有版本号就只能靠猜;二是deserializeScene里做了一层浅拷贝{ ...item },防止外部 JSON 对象直接引用进 store,后续编辑时误改到原始数据源。灯光和物体的 silhouette 完全一致,也走影子数据、序列化、同步三层,这套一致性是这个资源包架构上最干净的地方。

4. 交互操作三件套:拾取、变换控制、材质联动的实现

编辑器好不好用,就看点击选中、拖拽变换、属性面板改参这三个交互是否跟手。这一章把三个核心模块的实现和参数陷阱讲透。

4.1 Raycaster 拾取并反查业务 id

点击画布选中物体的标准做法是 Raycaster 射线检测,重点是归一化坐标的计算和子物体的递归:

// three/picking.js import * as THREE from 'three' const raycaster = new THREE.Raycaster() const pointer = new THREE.Vector2() export function pickItem(event, renderer, camera, targetArray) { const rect = renderer.domElement.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) // targetArray 是 objectMap.values() 转成的数组 const hits = raycaster.intersectObjects(targetArray, true) if (hits.length === 0) return null // 向上查找带 userData.itemId 的祖先节点 let node = hits[0].object while (node && !node.userData.itemId) { node = node.parent } return node ? node.userData.itemId : null }

坐标转换那段公式是固定套路:clientX - rect.left得到 canvas 内的像素坐标,除以宽高归一化到 0~1,再映射到 WebGL 的 -1~1 区间,Y 轴要取反因为屏幕坐标从上往下而三维坐标从下往上。第二个参数true表示递归检测子物体,模型加载进来通常是多层嵌套结构的,不递归就只能点到外层壳。射线命中后不一定直接命中挂载了 itemId 的那个节点,所以要用while循环向上找祖先,这是建模工具导出的模型经常嵌套三四层之后的血泪经验。

4.2 TransformControls 与 OrbitControls 的冲突处理

TransformControls 和 OrbitControls 同时作用在一个 canvas 上,冲突是必然的:按着物体拖拽时视角也在转,体验极差。解法是监听 TransformControls 的 dragging 事件,在拖拽期间禁用轨道控制:

import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js' const transformControls = new TransformControls(camera, renderer.domElement) scene.add(transformControls.getHelper()) // 切换选中物体时重新 attach watch(() => store.selectedId, (id) => { transformControls.detach() const obj = objectMap.get(id) if (obj) transformControls.attach(obj) }) // 拖拽时禁用轨道控制,避免视角跟着乱跑 transformControls.addEventListener('dragging-changed', (event) => { controls.enabled = !event.value }) // 拖拽过程中实时回写影子数据 transformControls.addEventListener('objectChange', () => { const obj = transformControls.object const item = store.items.find(i => i.id === obj.userData.itemId) if (!item) return item.position = { x: obj.position.x, y: obj.position.y, z: obj.position.z } })

最隐蔽的坑出现在 store 到 Object3D 的反向同步:如果响应式 watch 同时也在监听 store.items 的深度变化并写回obj.position,拖拽时就会变成“用户拖一下、同步函数又拉回来”的死循环,表现为物体抖动或松手后回弹。资源包的处理是在同步函数里加isDragging全局标志,拖拽事件将 flag 置 true,同步函数看到 flag 直接跳过物体变换相关的字段。从数据结构设计角度看,位置信息的流向必须单一:用户操作 → 影子数据 → 渲染器,反向要明确标注权限。

4.3 材质参数实时联动属性面板

属性面板里的颜色选择器一拖,画布马上要变。材质联动不能用 item 级深度 watch,那样一次 store 更新会触发整个场景的材质重设,浪费性能。资源包的做法是对必要字段做聚合监听:

// store 里用 computed 聚合材质变更信号 import { computed } from 'vue' export function useMaterialSignal(store) { return computed(() => { return store.items .map(item => `${item.id}:${item.material.color}:${item.material.opacity}`) .join('|') }) }
// 渲染器侧监听聚合信号 const materialSignal = useMaterialSignal(store) watch(materialSignal, () => { for (const item of store.items) { const obj = objectMap.get(item.id) if (!obj?.material) continue const material = Array.isArray(obj.material) ? obj.material[0] : obj.material material.color.set(item.material.color) material.opacity = item.material.opacity material.transparent = item.material.opacity < 1 } render() })

聚合信号串成一个字符串做浅比较,任意一个物体的颜色或透明度变化都会触发重建信号字符串,从而命中 watch。material.transparent这个属性是透明度生效的前提,很多新手只改opacity发现没有任何变化,就是这个值没跟着置 true。另外注意Array.isArray的判断:多材质物体(比如一个 Mesh 由多个材质面组成)返回的是材质数组,只取第一个会漏,资源包里取首个材质做统一设置,如果需要逐面设置要走独立逻辑,这属于已知边界,代码注释里写明了。

5. 避坑排查:Vue3 集成 Three.js 的六个典型问题

这一章的每一条都是真实调试过的坑,按“现象 → 原因 → 解决”记录,遇到同类问题可以直接对号入座。

5.1 把 Scene 对象写进 reactive 后帧率暴跌或报错

现象:编辑器页面能打开,但拖动物体时明显卡顿,控制台偶尔报出与 Three.js 矩阵计算相关的 TypeError。

原因:Vue 的响应式系统用 Proxy 包装对象,Three.js 内部每一帧都要高频读写 position、matrix 等属性,代理拦截 + 深层依赖收集的开销成倍放大;部分内部方法对 this 的绑定在代理层表现异常,触发非确定性报错。

解决:严格坚持第 2 章的架构,store 只存影子数据,Three.js 实例全部放在渲染器模块内部的Map中。用markRaw标记确实要放进 Vue 组件的 Three 实例,例如 transformControls 的 helper 对象,避免无意间被响应式包裹。

5.2 组件卸载后 requestAnimationFrame 还在跑

现象:从编辑器页面切换到其他路由,canvas 已经看不到了,但浏览器任务管理器中 GPU 占用率仍居高不下;重新进入编辑器页面时控制台报 “WebGL context lost”。

原因:tick里的requestAnimationFrame是全局性的,组件卸载只销毁了 DOM,没有取消动画循环。丢失的 WebGL 上下文永远不会自动恢复,必须重建渲染器。

解决:组件onUnmounted里调用cancelAnimationFrame(rafId),同时执行renderer.dispose()和controls.dispose()。渲染器的tick需要把 rafId 暴露出来,资源包的createEditorRenderer返回值里包含了dispose方法,内部统一做清理,组件侧只调一个函数即可。

5.3 画布初始化时拿到的宽高为 0,画面空白

现象:编辑器首次进入白屏,控制台没有报错,手动改一下浏览器窗口大小后画面出现。

原因:onMounted触发时布局还没有完全计算,clientWidth为 0,渲染器创建时的内部缓冲尺寸是 0×0;后续没有 resize 事件触发所以一直空白。

解决:用await nextTick()确保 DOM 布局完成后再初始化渲染器,并注册window.addEventListener('resize', ...)兜底。编辑器容器用了 flex 布局时,还要注意容器本身有没有拿到高度,常见问题是父级高度没设,子级height: 100%全部塌陷为 0。

5.4 GLTF 模型加载 404 或路径不对

现象:GLTFLoader.load请求models/xxx.glb返回 404,控制台报 “Failed to load resource”。代码写在本地时正常,打包部署后全部失效。

原因:Vite 工程里相对路径在开发服务器和打包后的基路径处理不同,models/xxx.glb按模块相对路径解析,部署到子目录时直接找不到。public 目录的文件引用方式也容易混淆。

解决:模型统一放public/models/,引'/models/xxx.glb'这样的绝对路径;生产环境用import.meta.env.BASE_URL + 'models/xxx.glb'拼接,适配任意部署子路径。加载异步回调里的gltf.scene要加到场景并设置userData.itemId,否则后面拾取射线检测不到它。

5.5 拖动变换控件时属性面板数值不动或来回跳

现象:用 TransformControls 拖动物体,属性面板的 X/Y/Z 数值不刷新;松手后数值跳回原值。

原因:双向同步冲突。store 深度监听写回 Object3D,与 TransformControls 的 objectChange 事件回写互相覆盖,最后一次写回决定了最终值。

解决:加isDragging标志位,objectChange 事件触发期间暂存 store 的深度同步逻辑。标志位放在渲染器模块的闭包里,两边都能访问,不需要引入全局变量。

5.6 场景里开了阴影但物体不投射阴影

现象:光源、地面、物体都有了,shadowMap.enabled也设了 true,物体在地面上没有影子。

原因:Three.js 的阴影是三段式开关:全局渲染器开关、光源的castShadow、物体的castShadow与地面receiveShadow。任何一段缺了都不显示。另外阴影相机参数不对时,影子可能被裁切掉。

解决:逐段检查。方向光的shadow.camera.left/right/top/bottom参数决定阴影贴图覆盖范围,默认值对稍大的场景就不够用。地面贴得太深时调shadow.bias = -0.0005减小 shadow acne 条纹。

6. 进阶用法:把编辑器接入业务系统前的三个关键改造

编辑器跑通后,下一步通常是接业务:设备点选弹详情、场景配置保存到数据库、多人协作时的版本回溯。有三个改造是绕不开的。

6.1 用快照栈实现撤销重做

编辑操作是离散动作,用操作栈式的 diff 很复杂,快照是性价比最高的方案。资源包里提供了基于结构克隆的快照实现:

let snapshots = [] let snapshotIndex = -1 export function pushSnapshot(store) { // 每次新操作先截掉当前索引之后的旧快照 snapshots = snapshots.slice(0, snapshotIndex + 1) snapshots.push(JSON.parse(JSON.stringify(store.$state))) snapshotIndex++ if (snapshots.length > 30) snapshots.shift() } export function undo(store) { if (snapshotIndex <= 0) return snapshotIndex-- store.$patch(snapshots[snapshotIndex]) } export function redo(store) { if (snapshotIndex >= snapshots.length - 1) return snapshotIndex++ store.$patch(snapshots[snapshotIndex]) }

store.$patch是 Pinia 提供的批量更新接口,传入整个快照对象会触发一次响应式更新,比逐字段赋值性能好得多。快照深度限制 30 步是经验值,3D 场景的数据量不算小,超过 30 步内存占用就很明显了。

6.2 把常驻动画循环改成按需渲染

编辑器场景里的模型通常是静态的,持续跑 RAF 纯属浪费。改成按需渲染的思路是:渲染条件只在交互发生时满足,源码里把第 2 章的tick循环删掉,用事件驱动渲染:

// 换成事件驱动的渲染 controls.addEventListener('change', render) transformControls.addEventListener('objectChange', render) window.addEventListener('resize', render) // 拾取选中高亮后也要 render 一次

注意:如果场景里加载了带动画的模型,比如循环旋转的风机或闪烁指示灯,按需渲染会导致动画卡在最后一帧,此时要保留 RAF 循环,并且让动画模型走独立的更新通道。

6.3 业务数据挂进 userData

编辑器最终要服务业务系统,把业务字段塞进userData是最省事的联动方式。设备模型加载后立即挂载设备编码和所属层级,射线拾取命中后直接一个字段取到全部业务上下文。从那以后我每接一个编辑器项目,都强制先画一张数据流向图,明确哪些字段归 store、哪些归 Three 内部状态,才允许写第一行渲染代码。希望帮到你。

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

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

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

立即咨询