简介这是一套基于 Vue3 构建的轻量级 Three.js 3D 场景编辑器面向前端开发者与 Web 3D 入门学习者解决传统 three.js 项目中场景配置繁琐、模型导入导出不便、调试可视化弱等实际问题。资源包共230个文件包含102个核心 JS 模块涵盖场景管理、相机控制、材质编辑等逻辑、95张 JPG 纹理与预览图、20个 CSS 样式文件如 index-.css、baseModel-.css 等体现模块化样式组织、5个 WASM 加速组件及少量 HTML、SVG、MD 文档整体压缩后仅12.1MB开箱即用。已有2974人学习下载适合快速搭建本地 3D 编辑环境、理解 scene-view 渲染器集成方案或作为 Vue3 Three.js 工程化实践参考。包内已预置可直接运行的构建产物支持 ZIP 场景导入/导出并兼容 scene-view 解析器渲染附带完整样式体系与组件化结构便于二次开发与教学演示。1. Three.js 3D 场景编辑器Vue3 版本不是玩具是能导出可部署场景的生产级前端工具链你有没有试过在 Vue 项目里嵌一个可拖拽、可实时修改材质、能保存为 JSON 的 3D 编辑界面不是 demo不是 tutorial而是真正在某高校数字孪生教学平台里跑着、被学生每天用来搭建虚拟实验室模型的那个编辑器——它就长这样。Three.js 本身不提供编辑能力官方示例全是“写死场景手动改代码”而这个 Vue3 版本把「添加物体→调整位置/旋转/缩放→切换材质→设置光照→导出为独立 HTML 或 JSON」全链路收进一个响应式 UI 里底层用 Composition API reactive 管理整个场景图Scene Graph状态变更直接驱动 Three 渲染循环没有 patch、没有 diff、没有中间层失真。它不是给初学者练手的“Three.js 入门小项目”而是面向工业仿真前端、数字展厅开发、教育类 3D 课件制作等真实场景的最小可用编辑器原型。如果你正卡在“怎么让非程序员也能搭 3D 场景”这一步或者需要把设计师产出的 glTF 模型快速集成进 Vue 系统并支持运行时微调那这份资源就是你该立刻拉下来跑通的第一块拼图。2. 架构选型与核心模块拆解为什么是 Vue3 Three.js 而不是 Babylon.js 或 React-Three-Fiber2.1 为什么放弃 React-Three-Fiber 和 react-three/dreiReact-Three-FiberR3F生态确实成熟但它的设计哲学是“声明式描述场景”所有变更都走 reconcile 流程。我们在某跨平台系统中实测过当用户高频拖拽物体15fps、同时切换 3 种 PBR 材质、并开启环境光遮蔽AO时R3F 的 fiber 树 diff 开销会吃掉 30% 主线程时间UI 响应延迟明显。而 Vue3 的响应式系统Proxy effect scheduler天然适配 Three.js 的 imperative 渲染范式——我们只需要监听transform.position这样的 ref 变更触发mesh.position.copy(positionRef.value)即可无虚拟 DOM 开销。更重要的是Vue3 的script setup语法能让组件逻辑高度内聚一个useTransformControls()Hook 就能同时管理 gizmo 渲染、鼠标射线拾取、键盘快捷键如 CtrlZ 撤销、以及 undo stack 的 snapshot 序列化不用跨多个 context 或 reducer。2.2 场景图Scene Graph如何用 reactive 实现双向绑定Three.js 原生 Scene 是一个对象树但它的属性如position,rotation是 THREE.Vector3/Quaternion 实例无法被 Vue 响应式系统自动追踪。常见错误做法是ref(mesh.position)—— 这只响应position引用变更不响应.x/.y/.z内部值变更。正确解法是封装一层 proxy// composables/useSceneObject.js export function useSceneObject(mesh) { const position reactive({ x: mesh.position.x, y: mesh.position.y, z: mesh.position.z }); // 同步到 Three.js 实例 watch(() [position.x, position.y, position.z], () { mesh.position.set(position.x, position.y, position.z); }, { immediate: true }); // 同理处理 rotation需注意 Euler 与 Quaternion 转换 const rotation reactive({ x: mesh.rotation.x, y: mesh.rotation.y, z: mesh.rotation.z }); watch(() [rotation.x, rotation.y, rotation.z], () { mesh.rotation.set(rotation.x, rotation.y, rotation.z); }, { immediate: true }); return { position, rotation }; }提示这里watch的immediate: true至关重要——它确保组件初始化时就将 Vue 数据同步到 Three 实例避免首次渲染出现位置偏移。若漏掉你会看到物体“闪一下”才归位这是新手最常踩的玄学翻车点。2.3 材质系统为何采用“预设模板 运行时 patch”而非动态创建编辑器支持 5 类材质StandardLambert、PhysicalPBR、Transparency透明玻璃、Emissive自发光、Toon卡通。如果每次切换都new THREE.MeshStandardMaterial({...})会导致 GPU 内存泄漏Three.js 不自动销毁旧材质。我们的做法是启动时预创建 5 个材质实例挂载到scene.userData.materialPresets切换时仅 patch 属性// stores/materialStore.js const materialPresets { standard: new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 }), physical: new THREE.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0.8, roughness: 0.2, clearcoat: 1.0 }), // ...其他预设 }; export function applyMaterialPreset(mesh, presetKey) { const preset materialPresets[presetKey]; // 关键复用材质实例只更新参数 mesh.material preset; // 若需个性化参数如单独改某个物体的颜色再 patch if (mesh.userData.customColor) { preset.color.set(mesh.userData.customColor); } }这种模式下无论用户切换多少次材质GPU 中活跃的材质实例始终只有 5 个内存占用稳定在 2MB 以内实测 Chrome Memory Profiler 数据。3. 核心功能实现从物体添加、Gizmo 控制到 JSON 导出的完整闭环3.1 添加基础几何体支持参数化生成与 glTF 导入双路径编辑器提供两种添加方式参数化创建立方体、球体、圆柱、平面、环面全部通过THREE.BufferGeometry构建避免THREE.Geometry已废弃兼容问题glTF 导入使用three-ts/gltf-loader轻量版无 DRACO 依赖支持拖拽.glb文件或粘贴 URL。关键代码在components/ObjectPanel.vue!-- components/ObjectPanel.vue -- template div classobject-actions button clickaddPrimitive(box)添加立方体/button button clickaddPrimitive(sphere)添加球体/button label classgltf-upload input typefile accept.glb,.gltf changehandleGLTFUpload hidden / span导入 glTF/span /label /div /template script setup import { useSceneStore } from /stores/sceneStore; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader; const sceneStore useSceneStore(); function addPrimitive(type) { let geometry, material; switch (type) { case box: geometry new THREE.BoxGeometry(1, 1, 1); break; case sphere: geometry new THREE.SphereGeometry(0.5, 32, 32); break; // ...其他类型 } material sceneStore.materialPresets.standard; const mesh new THREE.Mesh(geometry, material); mesh.name ${type}-${Date.now()}; sceneStore.addMesh(mesh); // 触发 reactive 更新 } async function handleGLTFUpload(e) { const file e.target.files[0]; if (!file) return; const loader new GLTFLoader(); const url URL.createObjectURL(file); try { const gltf await loader.loadAsync(url); // 只取 scene 中的第一个 mesh简化逻辑实际项目可加多选 const mesh gltf.scene.children.find(c c.isMesh); if (mesh) { mesh.name imported-${Date.now()}; sceneStore.addMesh(mesh); } } catch (err) { console.error(GLTF 加载失败:, err); } finally { URL.revokeObjectURL(url); } } /script注意URL.createObjectURL(file)必须配对URL.revokeObjectURL(url)否则内存持续增长。我们在线上环境见过因漏掉这句导致连续导入 20 个 glTF 后页面 OOM 的案例。3.2 Transform Controls变换控制器如何让 Gizmo 真正“粘”在物体上Three.js 官方TransformControls是独立于场景图的 UI 控件它默认以 world space 操作但用户直觉是“绕物体自身轴旋转”。我们做了三处关键改造坐标系绑定监听选中物体调用controls.attach(selectedMesh)并设置controls.mode translate局部空间旋转重写controls.update()中的旋转逻辑将rotation应用到selectedMesh.rotation而非selectedMesh.quaternion避免万向节死锁Gizmo 尺寸自适应根据物体包围盒bounding box动态缩放 Gizmo 大小防止小物体上 Gizmo 过大遮挡视野// composables/useTransformControls.js export function useTransformControls(camera, domElement) { const controls new TransformControls(camera, domElement); function updateGizmoScale(mesh) { const box new THREE.Box3().setFromObject(mesh); const size box.getSize(new THREE.Vector3()); const maxDim Math.max(size.x, size.y, size.z); controls.setSize(maxDim * 1.5); // 1.5 倍放大保证可点击 } return { controls, updateGizmoScale, // ...其他方法 }; }3.3 场景导出JSON 描述 独立 HTML 打包的双模输出导出不是简单JSON.stringify(scene)—— Three.js 对象含大量循环引用和函数直接序列化会报错。我们定义了一套精简的序列化 schema字段类型说明idstring物体唯一 IDUUID v4typestringbox | sphere | gltfposition{x,y,z}世界坐标rotation{x,y,z}Euler 角弧度scale{x,y,z}materialstringstandard | physical | ...userDataobject自定义字段如 glTF 的 URL导出逻辑在composables/useExporter.js// composables/useExporter.js export function exportSceneAsJSON(scene) { const meshes scene.children.filter(child child.isMesh); const data meshes.map(mesh { const box new THREE.Box3().setFromObject(mesh); const center box.getCenter(new THREE.Vector3()); return { id: mesh.uuid, type: mesh.userData.type || custom, 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: getMaterialPresetKey(mesh.material), userData: { ...mesh.userData } // 浅拷贝避免循环引用 }; }); return JSON.stringify({ version: 1.0.0, objects: data }, null, 2); } // 导出为独立 HTML含内联 Three.js 场景数据 export function exportAsStandaloneHTML(scene) { const jsonStr exportSceneAsJSON(scene); const htmlTemplate !DOCTYPE html html headtitle3D Scene/title/head body stylemargin:0;overflow:hidden; div idcontainer/div script typemodule import * as THREE from https://cdn.jsdelivr.net/npm/three0.152.2/examples/jsm/exports/index.js; // ...省略完整 Three.js 初始化代码 const sceneData ${jsonStr}; // 解析 sceneData 并构建场景 /script /body /html ; return htmlTemplate; }提示exportAsStandaloneHTML生成的 HTML 可直接双击打开无需本地服务适合发给客户做快速预览。但注意 CDN 版本号如0.152.2需与开发环境一致否则可能因 API 变更导致白屏。4. 避坑指南五个真实线上环境踩过的坑与血泪修复方案4.1 现象物体拖拽时卡顿严重帧率跌至 5fps原因未禁用TransformControls的raycaster.firstHitOnly false默认值导致每帧对场景中所有物体做射线检测O(n) 复杂度。当场景有 200 物体时单次raycast耗时超 16ms。解决在controls初始化后强制设为true并确保controls.object始终指向当前选中物体而非 nullcontrols.raycaster.firstHitOnly true; // 并在选中物体时 controls.attach(selectedMesh);4.2 现象导入 glTF 后材质丢失显示为粉红色Three.js 默认 missing texture 色原因GLTFLoader加载的材质默认使用MeshStandardMaterial但编辑器材质系统预设的是MeshPhysicalMaterial类型不匹配导致material.clone()失败。解决加载后遍历所有材质统一转换为预设材质模板的副本gltf.scene.traverse(child { if (child.isMesh child.material) { const preset sceneStore.materialPresets.physical; child.material preset.clone(); // 克隆而非赋值避免污染预设 } });4.3 现象旋转物体后再次拖拽时位置突变原因Three.js 中position是世界坐标rotation改变后若未重新计算position的局部偏移TransformControls的 translate 模式会基于旧的世界矩阵计算增量导致跳变。解决在controls.change事件中强制将position转换为局部坐标再存储controls.addEventListener(change, () { if (selectedMesh) { // 将世界 position 转为父节点下的局部 position const localPos selectedMesh.position.clone(); if (selectedMesh.parent) { localPos.applyMatrix4(selectedMesh.parent.matrixWorldInverse); } // 存入 reactive state selectedMeshState.position { x: localPos.x, y: localPos.y, z: localPos.z }; } });4.4 现象导出 JSON 后用相同代码加载时物体缩放异常过大或过小原因BoxGeometry(1,1,1)与SphereGeometry(0.5)的单位不一致且 glTF 模型自带 scale如 Blender 导出常带scale: 0.01。JSON 中只存了scale: {x:1,y:1,z:1}丢失原始建模单位信息。解决在导出前对每个 mesh 执行mesh.updateMatrixWorld()然后提取其matrixWorld的 scale 分量存入 JSONfunction extractScaleFromMatrix(matrix) { const scale new THREE.Vector3(); matrix.decompose(new THREE.Vector3(), new THREE.Quaternion(), scale); return { x: scale.x, y: scale.y, z: scale.z }; } // 导出时 scale: extractScaleFromMatrix(mesh.matrixWorld)4.5 现象Vue Devtools 中看到scene响应式对象爆炸式增长1000 个 effect原因对scene.children数组做了watch而 Three.js 每次add()都触发数组 length 变更导致每个子元素的 getter 都被收集为依赖。解决改用watch的deep: false 手动监听scene.children.length再用scene.children.forEach(...)遍历避免递归追踪watch( () scene.children.length, (newLen, oldLen) { if (newLen oldLen) { // 新增物体只处理最后一个 const newMesh scene.children[newLen - 1]; setupReactiveForMesh(newMesh); } } );5. 进阶技巧用 WebGLRenderer 的 setRenderTarget 实现“所见即所得”的截图与视频录制5.1 为什么不用 canvas.toDataURL()canvas.toDataURL()只能截取当前 canvas 像素但编辑器 UI如右侧属性面板、顶部菜单栏会覆盖在 Three.js 渲染画布之上直接截图会包含这些 DOM 元素不符合“纯 3D 场景”需求。真正要的是仅截取 Three.js 渲染器输出的帧且分辨率可自由指定如 4K。5.2 方案离屏 RenderTarget toBlob()Three.js 的WebGLRenderTarget可创建离屏缓冲区我们将场景渲染到该缓冲区再用renderer.readRenderTargetPixels()读取像素数据转为 ImageBitmap → Blob// composables/useScreenshot.js export function useScreenshot(renderer, scene, camera) { let renderTarget null; function initRenderTarget(width, height) { if (renderTarget) renderTarget.dispose(); renderTarget new THREE.WebGLRenderTarget(width, height, { generateMipmaps: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }); } async function capture(width 1920, height 1080) { initRenderTarget(width, height); // 临时修改相机 aspect保持画面不变形 const originalAspect camera.aspect; camera.aspect width / height; camera.updateProjectionMatrix(); // 渲染到离屏目标 renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); // 读取像素 const pixels new Uint8Array(width * height * 4); renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, pixels); // 转为 ImageBitmap注意 Y 轴翻转 const canvas document.createElement(canvas); canvas.width width; canvas.height height; const ctx canvas.getContext(2d); const imageData new ImageData(pixels, width, height); // Three.js 像素是 bottom-upCanvas 是 top-down需翻转 for (let y 0; y height / 2; y) { const row1 y * width * 4; const row2 (height - 1 - y) * width * 4; for (let i 0; i width * 4; i) { [pixels[row1 i], pixels[row2 i]] [pixels[row2 i], pixels[row1 i]]; } } ctx.putImageData(imageData, 0, 0); // 转 Blob 下载 return new Promise(resolve { canvas.toBlob(blob resolve(blob), image/png); }); } return { capture }; }5.3 录制视频用 MediaRecorder CanvasCaptureMediaStream若需录 5 秒视频可结合captureStream()function startRecording(durationMs 5000) { const stream renderer.domElement.captureStream(30); // 30fps const mediaRecorder new MediaRecorder(stream); const chunks []; mediaRecorder.ondataavailable e chunks.push(e.data); mediaRecorder.onstop () { const blob new Blob(chunks, { type: video/webm }); const url URL.createObjectURL(blob); // 触发下载 const a document.createElement(a); a.href url; a.download scene-${Date.now()}.webm; a.click(); }; mediaRecorder.start(); setTimeout(() mediaRecorder.stop(), durationMs); }注意captureStream()在部分安卓 WebView 中不支持生产环境需降级为 GIF 录制用gif.js库逐帧合成但帧率会降至 10fps 以下。我们在线上环境统一检测navigator.mediaDevices?.getDisplayMedia是否可用再决定启用哪套方案。从那以后我每次交付 3D 编辑器需求都会先跑一遍npm run build后的 standalone HTML在无网络环境下双击打开验证 glTF 加载、材质切换、导出功能是否 100% 离线可用——这步成了我的后悔药检查清单。希望帮到你。本文还有配套的精品资源点击获取