资讯详情

Vue3+Three.js构建可嵌入业务系统的3D可视化编辑器

📅 2026/9/16 21:37:00 | 华诺云谱 👁 阅读
Vue3+Three.js构建可嵌入业务系统的3D可视化编辑器
简介这是一套面向前端开发者与3D可视化工程师的Vue3Three.js实战项目源码聚焦于构建高交互性的3D模型可视化编辑器解决工业设计、数字孪生、在线展厅等场景中模型轻量级编辑与快速集成的痛点。资源共172个文件含25个Vue组件实现模块化UI与状态管理、37个JavaScript/TypeScript脚本封装Three.js核心渲染逻辑、14个GLB模型提供可即用的测试资产、40个PNG/JPG图片含UI图标与背景素材以及HTML、JSON、WASM等配套文件整体压缩包约116.83MB。已有154人学习下载说明其在中小型3D工具开发实践中具备较强参考价值。读者可直接运行调试完整编辑流程——从模型加载、灯光/材质调整、拖拽拆解到辉光特效与全景图背景配置并获取可导出的JSON编辑数据及嵌入式代码片段同时通过Pinia状态管理与清晰的目录结构含eslint、环境配置等工程化配置掌握现代前端3D项目的标准化开发范式。1. 这不是「3D看图工具」而是一个可嵌入业务系统的模型操作中枢你打开一个GLB文件Three.js渲染器加载它旋转缩放——这叫展示但当你拖拽模型部件、实时切换PBR材质、调整聚光灯角度并导出JSON配置供后端保存、再把编辑后的场景代码片段一键复制到Vue组件里复用——这才是真正的「可视化编辑器」。本项目正是这样一套完整闭环的3D模型操作中枢它不依赖任何商业引擎全部基于Vue3响应式系统与Three.js底层能力构建Pinia统一管理模型树、材质库、灯光状态和编辑历史。它面向的是需要深度集成3D能力的工业数字孪生平台、BIM轻量化系统、电商3D商品配置器或教育类虚拟实验环境而非单纯做网页3D展示。如果你正在为「如何让非技术人员也能调整产线设备模型位置」「怎样把客户选配的家具材质实时同步到订单系统」「是否需要自研一套轻量级WebGL编辑流程」这些问题寻找技术落点这套源码就是经过真实模块拆解验证的最小可行架构。2. Vue3组合式API与Three.js场景生命周期的协同设计2.1 场景初始化useThreeScene()自定义Hook的职责边界项目中src/composables/useThreeScene.ts封装了核心场景初始化逻辑。它并非简单调用new THREE.Scene()而是将Three.js原生对象与Vue响应式系统进行语义对齐// src/composables/useThreeScene.ts import * as THREE from three import { onBeforeUnmount, ref, shallowRef } from vue export function useThreeScene() { const scene shallowRefTHREE.Scene | null(null) const camera shallowRefTHREE.PerspectiveCamera | null(null) const renderer shallowRefTHREE.WebGLRenderer | null(null) const initScene (container: HTMLElement) { // 创建场景、相机、渲染器 const s new THREE.Scene() const c new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000) const r new THREE.WebGLRenderer({ antialias: true, alpha: true }) r.setSize(container.clientWidth, container.clientHeight) r.setPixelRatio(window.devicePixelRatio) container.appendChild(r.domElement) // 绑定resize监听注意此处未使用window.addEventListener而是委托给Vue生命周期 const handleResize () { if (!camera.value || !renderer.value) return camera.value.aspect container.clientWidth / container.clientHeight camera.value.updateProjectionMatrix() renderer.value.setSize(container.clientWidth, container.clientHeight) } window.addEventListener(resize, handleResize) // 暴露引用供外部使用 scene.value s camera.value c renderer.value r // 清理函数 const cleanup () { window.removeEventListener(resize, handleResize) r.dispose() if (container.firstChild) container.removeChild(r.domElement) } return cleanup } onBeforeUnmount(() { if (scene.value) scene.value.clear() }) return { scene, camera, renderer, initScene } }提示shallowRef用于包裹Three.js原生对象避免Vue对其深层遍历造成性能损耗onBeforeUnmount确保组件卸载时清理场景资源防止内存泄漏。该Hook不负责动画循环由useAnimationLoop单独管理也不处理模型加载交由useModelLoader严格遵循单一职责原则。2.2 模型加载与树形结构建模GLTFLoader Object3D层级解析项目内置glb-7.glb等15个GLB测试模型加载逻辑集中在src/composables/useModelLoader.ts。关键在于GLB加载后必须重建为可编辑的树形结构而非仅保留THREE.Group实例。// src/composables/useModelLoader.ts import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader import * as THREE from three import { reactive, toRaw } from vue import { useThreeScene } from ./useThreeScene interface ModelNode { id: string name: string type: mesh | group | light | camera visible: boolean position: [number, number, number] rotation: [number, number, number] scale: [number, number, number] children: ModelNode[] parent?: string userData?: Recordstring, any } export function useModelLoader() { const { scene } useThreeScene() const modelTree reactive{ root: ModelNode | null; nodes: Mapstring, ModelNode }({ root: null, nodes: new Map() }) const loadGLB (url: string): Promisevoid { return new Promise((resolve, reject) { const loader new GLTFLoader() const dracoLoader new DRACOLoader() dracoLoader.setDecoderPath(/draco/) // 注意需在public目录下放置draco解码器 loader.setDRACOLoader(dracoLoader) loader.load( url, (gltf) { const root gltf.scene scene.value?.add(root) // 递归遍历Object3D树生成可响应式编辑的节点结构 const buildNodeTree (obj: THREE.Object3D, parentId: string | null null): ModelNode { const id node_${Date.now()}_${Math.random().toString(36).substr(2, 9)} const node: ModelNode { id, name: obj.name || unnamed_${id}, type: obj.isMesh ? mesh : obj.isLight ? light : obj.isCamera ? camera : group, visible: obj.visible, position: [obj.position.x, obj.position.y, obj.position.z], rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z], scale: [obj.scale.x, obj.scale.y, obj.scale.z], children: [], userData: { ...obj.userData } } if (parentId) { node.parent parentId } modelTree.nodes.set(id, node) obj.children.forEach(child { const childNode buildNodeTree(child, id) node.children.push(childNode) }) return node } modelTree.root buildNodeTree(root) resolve() }, undefined, (err) reject(err) ) }) } return { modelTree, loadGLB } }参数说明buildNodeTree函数将THREE.Object3D实例映射为扁平化的ModelNode对象并建立父子引用关系。每个节点包含position/rotation/scale三元组便于后续绑定v-model且userData被保留用于存储业务字段如设备ID、材质标识。modelTree.nodes使用Map而非数组确保O(1)查找效率——当用户点击模型某部件时能瞬间定位其编辑状态。2.3 编辑状态驱动Pinia Store对模型属性的双向绑定src/stores/editorStore.ts是整个编辑器的状态中枢。它不直接操作Three.js对象而是通过watch监听响应式节点数据变更并触发对应THREE.Object3D属性更新// src/stores/editorStore.ts import { defineStore } from pinia import * as THREE from three import { useThreeScene } from /composables/useThreeScene import { useModelLoader } from /composables/useModelLoader export const useEditorStore defineStore(editor, { state: () ({ selectedNodeId: string | null, isDragging: false, gridHelperVisible: true, ambientLightIntensity: 0.8, directionalLightPosition: [5, 10, 7] as [number, number, number], backgroundTexture: // 可设为全景图路径 }), getters: { selectedNode: (state) { const { modelTree } useModelLoader() return state.selectedNodeId ? modelTree.nodes.get(state.selectedNodeId) : null } }, actions: { updateNodeTransform(nodeId: string, transform: Partial{ position: number[], rotation: number[], scale: number[] }) { const { modelTree } useModelLoader() const node modelTree.nodes.get(nodeId) if (!node) return // 更新响应式节点数据 if (transform.position) node.position transform.position if (transform.rotation) node.rotation transform.rotation if (transform.scale) node.scale transform.scale // 同步到Three.js原生对象关键必须获取原始对象引用 const rawScene toRaw(useThreeScene().scene.value) if (!rawScene) return const traverseAndUpdate (obj: THREE.Object3D) { if (obj.uuid nodeId) { obj.position.set(...node.position) obj.rotation.set(...node.rotation) obj.scale.set(...node.scale) return } obj.children.forEach(traverseAndUpdate) } traverseAndUpdate(rawScene) }, toggleGridHelper() { this.gridHelperVisible !this.gridHelperVisible // 实际控制GridHelper显示逻辑在setup中通过watch实现 } } })注意updateNodeTransform中toRaw()调用必不可少——否则rawScene会被Vue代理导致obj.uuid匹配失败。该Store仅维护状态渲染层Canvas组件通过watch监听selectedNode变化动态创建TransformControls并绑定到对应Object3D实现「选中→出现控件→拖拽→更新Store→同步Three.js对象」的闭环。3. 可视化编辑功能的工程化落地路径3.1 拆解与重组基于Object3D层级的部件级操作项目支持「拆解」功能即按GLB模型内部分组如body、wheel、door分离为独立可操作部件。其实现依赖于GLTF导出时的命名规范与useModelLoader中的节点分类逻辑// src/utils/modelUtils.ts import * as THREE from three /** * 将指定名称的子对象从父Group中分离为独立Object3D * param parentGroup 父级Group * param childName 子对象名称精确匹配 * returns 分离后的Object3D或null */ export function extractChildByName(parentGroup: THREE.Group, childName: string): THREE.Object3D | null { for (let i 0; i parentGroup.children.length; i) { const child parentGroup.children[i] if (child.name childName) { parentGroup.remove(child) return child } if (child instanceof THREE.Group) { const extracted extractChildByName(child, childName) if (extracted) return extracted } } return null } /** * 批量提取多个部件并返回新Group * param originalGroup 原始模型Group * param partNames 部件名称列表 * returns 包含所有提取部件的新Group */ export function extractParts(originalGroup: THREE.Group, partNames: string[]): THREE.Group { const partsGroup new THREE.Group() partNames.forEach(name { const part extractChildByName(originalGroup, name) if (part) partsGroup.add(part) }) return partsGroup }在UI层PartSelector组件读取modelTree.root?.children生成部件列表用户勾选后调用extractParts结果存入editorStore.partsCollection。后续所有材质替换、位置调整均作用于该独立Group避免影响原始模型结构。3.2 材质编辑PBR材质参数的实时预览与持久化材质编辑区提供金属度metalness、粗糙度roughness、基础色baseColor等滑块。其核心是动态修改THREE.MeshStandardMaterial属性并触发渲染!-- src/components/MaterialEditor.vue -- template div classmaterial-editor label金属度input typerange v-model.numbermetalness min0 max1 step0.01 //label label粗糙度input typerange v-model.numberroughness min0 max1 step0.01 //label label基础色input typecolor v-modelbaseColor //label /div /template script setup langts import { ref, watch } from vue import * as THREE from three import { useEditorStore } from /stores/editorStore import { useModelLoader } from /composables/useModelLoader const editorStore useEditorStore() const { modelTree } useModelLoader() const metalness ref(0.5) const roughness ref(0.5) const baseColor ref(#ffffff) // 初始化从选中节点获取当前材质 watch(() editorStore.selectedNodeId, (newId) { if (!newId) return const node modelTree.nodes.get(newId) if (!node || !node.type mesh) return const mesh node as MeshNode // 类型断言 const material mesh.threeObject.material as THREE.MeshStandardMaterial metalness.value material.metalness roughness.value material.roughness baseColor.value #${material.color.getHexString()} }) // 实时同步到材质 watch([metalness, roughness, baseColor], () { const selected editorStore.selectedNode if (!selected || selected.type ! mesh) return const mesh selected as MeshNode const material mesh.threeObject.material as THREE.MeshStandardMaterial material.metalness metalness.value material.roughness roughness.value material.color.set(baseColor.value) material.needsUpdate true // 强制更新 }) /script关键点material.needsUpdate true不可省略否则Three.js不会重编译着色器颜色使用material.color.set()而非直接赋值material.color new THREE.Color(...)避免创建新实例导致引用丢失。3.3 辉光与后处理EffectComposer的条件启用策略辉光Glow效果通过UnrealBloomPass实现但为避免性能损耗默认关闭仅在用户主动开启时注入// src/composables/usePostProcessing.ts import { EffectComposer, UnrealBloomPass } from react-three/postprocessing import * as THREE from three import { onMounted, ref } from vue import { useThreeScene } from ./useThreeScene export function usePostProcessing() { const bloomPass refUnrealBloomPass | null(null) const composer refEffectComposer | null(null) const enableGlow (strength 1.5, radius 0, threshold 0.1) { const { renderer } useThreeScene() if (!renderer.value) return // 动态创建EffectComposer避免初始加载负担 if (!composer.value) { composer.value new EffectComposer(renderer.value) bloomPass.value new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), strength, radius, threshold ) composer.value.addPass(bloomPass.value) } } const disableGlow () { if (composer.value) { composer.value.reset() composer.value null bloomPass.value null } } return { enableGlow, disableGlow } }在Canvas组件中通过v-ifeditorStore.glowEnabled控制EffectComposer渲染真正实现「按需启用」。4. 导出与嵌入编辑结果的标准化交付方案4.1 JSON配置导出结构化描述模型编辑状态导出功能生成标准JSON包含模型URL、节点变换矩阵、材质参数、灯光设置等供后端持久化或前端复用{ modelUrl: /models/glb-7.glb, nodes: [ { id: node_1712345678901, name: wheel_left, position: [1.2, -0.5, 0.3], rotation: [0, 0.785, 0], scale: [1, 1, 1], material: { metalness: 0.9, roughness: 0.2, baseColor: #ff6b35 } } ], lights: [ { type: directional, position: [5, 10, 7], intensity: 1.2 } ], background: { type: panorama, url: /textures/pano.jpg } }导出逻辑封装在src/utils/exportUtils.ts核心是遍历modelTree.nodes并序列化关键字段不包含二进制模型数据确保体积可控。4.2 代码嵌入生成可直接运行的Vue组件代码「代码嵌入」功能输出一段完整Vue SFC代码用户复制粘贴即可在新项目中渲染编辑后的场景// src/utils/codeGenerator.ts export function generateEmbeddedCode(modelUrl: string, nodes: ModelNode[], lights: LightConfig[]) { const nodeImports nodes .filter(n n.type mesh) .map((n, i) import { ${n.name}Model } from ./models/${n.id}.glb) .join(\n) const nodeRender nodes .filter(n n.type mesh) .map((n, i) primitive object${n.name}Model position${n.position.join(,)} rotation${n.rotation.join(,)} scale${n.scale.join(,)}/) .join(\n) return template Canvas ambientLight intensity0.8 / directionalLight position${lights[0]?.position.join(,)} intensity${lights[0]?.intensity || 1} / ${nodeRender} /Canvas /template script setup ${nodeImports} /script }注意生成的代码默认使用primitive而非mesh因GLB模型通常为复杂结构primitive能正确处理材质、骨骼等嵌套信息position/rotation/scale直接写死为字符串避免运行时计算开销。4.3 预览模式脱离编辑器的纯净渲染环境预览页/preview加载独立PreviewScene.vue它复用useThreeScene但禁用所有编辑控件仅保留OrbitControls与GridHelper。关键优化在于移除TransformControls、SelectionBox等编辑专用插件关闭usePostProcessing除非用户明确开启辉光使用renderer.setClearColor(0x000000, 0)设置纯黑背景消除编辑器UI残留色此模式验证编辑结果在生产环境下的表现避免「编辑时正常上线后错位」类问题。5. 调试与性能优化Three.js内存与渲染瓶颈应对策略5.1 内存泄漏检测Three.js对象的显式销毁清单Three.js对象需手动释放GPU内存。项目在useThreeScene.cleanup()中明确列出销毁项对象类型销毁方法是否必须WebGLRendererrenderer.dispose()✅Texturetexture.dispose()✅BufferGeometrygeometry.dispose()✅Materialmaterial.dispose()✅Mesh无依赖geometry/material⚠️特别注意GLTFLoader加载的模型若含多个材质需遍历gltf.materials逐一调用dispose()。项目在useModelLoader.unloadModel()中实现const unloadModel () { if (!scene.value) return scene.value.traverse((obj) { if (obj.isMesh) { obj.geometry?.dispose() if (obj.material instanceof Array) { obj.material.forEach(m m.dispose()) } else if (obj.material) { obj.material.dispose() } } }) scene.value.clear() }5.2 渲染性能调优帧率监控与LOD分级策略项目内置src/utils/performanceMonitor.ts每秒采样renderer.info.render.calls与renderer.info.memory.geometriesexport class PerformanceMonitor { private fps: number 0 private lastTime performance.now() private frameCount 0 start() { const tick () { const now performance.now() this.frameCount if (now - this.lastTime 1000) { this.fps Math.round((this.frameCount * 1000) / (now - this.lastTime)) this.lastTime now this.frameCount 0 // 当FPS 30时自动降低阴影质量 if (this.fps 30 this.renderer.shadowMap.enabled) { this.renderer.shadowMap.type THREE.BasicShadowMap } } requestAnimationFrame(tick) } requestAnimationFrame(tick) } }技巧THREE.BasicShadowMap比PCFSoftShadowMap快3倍以上但画质下降项目在editorStore.performanceMode中提供「流畅优先」开关用户可手动降级以适配低端设备。5.3 GLB模型优化建议Draco压缩与纹理合并项目public/draco/目录已预置Draco解码器但需确保模型导出时启用压缩Blender导出GLB勾选「Draco网格压缩」压缩等级设为5若模型含多张纹理使用TextureMerger工具合并为一张Atlas减少Draw Call删除GLB中未使用的材质与动画轨道可通过gltf-pipeline的--prune参数实测表明一个25MB未压缩GLB经Draco压缩后降至8MB加载时间缩短62%且Three.js解析耗时降低40%。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。