本项目使用 Three.js + GLTFLoader 实现了以下功能:
一、项目概述
本项目是一个基于 Vue3 + Three.js 的 3D 可视化系统,核心功能为:
加载建筑模型并提取房间中心点作为路径
加载动画人物模型(带“行走”动画)
人物按路径匀速移动,自动朝向行进方向
支持轨迹可视化、动画控制、窗口自适应等功能
二、核心功能说明
加载环境背景(全景图)
loadEnvironment()
使用
360.jpg
环境贴图生成立方体贴图,设置为scene.environment
。实现逼真的光照反射环境。
加载GLTF房屋模型并提取路径点
model.traverse(...)
遍历 GLTF 模型结构,获取每个房间的 包围盒中心点
Box3.getCenter()
。将所有房间的中心点加入路径数组
pathPoints
。支持自动路径规划,无需手动输入坐标。
加载带动画的老人模型
loader.load('scene.gltf', ...)
加载
oldmodels/scene.gltf
老人模型。设置缩放比例、起始位置。
使用
THREE.AnimationMixer
播放内置动画Take 001
(通常为“行走”或“移动”)。沿路径移动模型 + 自动朝向
moveAlongPath(object, pathPoints)
动画控制器
mixer = new THREE.AnimationMixer(oldMan); mixer.clipAction(shuffleClip).play();
控制人物动画播放,确保在移动过程中持续播放步行动画。
利用
clock.getDelta()
保持帧间时间同步,适配不同帧率。渲染与自适应
renderer.render(scene, camera); window.addEventListener('resize', handleResize);
渲染循环中统一更新控制器、动画帧、Tween 动画。
支持浏览器窗口变化时的实时适配。
模块 用途 three
WebGL 渲染核心库 @tweenjs/tween.js
动画补间库,用于模型匀速移动 GLTFLoader
加载 .gltf
动画模型OrbitControls
鼠标控制器,实现缩放/旋转/拖拽 Box3
包围盒工具,用于计算中心点 AnimationMixer
控制 GLTF 动画播放
三、完整代码
<template> <div ref="threeCanvas" style="width: 100%; height: 100%" id="c"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue'; import * as THREE from 'three'; import { Tween, Easing, Group } from '@tweenjs/tween.js'; const tweenGroup = new Group(); // ✅ 新方式 //加载.obj文件加载器 // import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; //加载.mtl文件加载器 // import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js'; //鼠标控制器 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 加载 GLTF 模型加载器 import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const clock = new THREE.Clock(); let mixer = null; // 在组件作用域声明 let oldMan = null; // 定义 oldMan 变量 const dumpVec3 = (v3, precision = 3) => `${v3.x.toFixed(precision)}, ${v3.y.toFixed(precision)}, ${v3.z.toFixed(precision)}`; async function loadEnvironment() { const loader = new THREE.TextureLoader(); const texture = await loader.loadAsync('360.jpg'); const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(texture.image.height); //等距矩形纹理转换成一个立方体贴图(Cube Map)。立方体贴图就是用六张贴图拼成的盒子纹理。 cubeRenderTarget.fromEquirectangularTexture(renderer, texture); scene.background = cubeRenderTarget.texture; scene.environment = cubeRenderTarget.texture; } const threeCanvas = ref(null); let bladeMesh = null; let renderer, scene, camera, controls; //渲染器,场景,相机,控制器 // let cars; const cars = []; let curve; let curveObject; const groundY = -1; //创建两个3D向量用来临时存储点位置: //carPosition:汽车当前所在点的位置 //carTarget:汽车前方目标点的位置(汽车朝向) const carPosition = new THREE.Vector3(); const carTarget = new THREE.Vector3(); const targetOffset = 0.01; // 目标点相对位置偏移,用于计算车头朝向 //初始化场景、相机、灯光、控制器等 const initScene = () => { scene = new THREE.Scene();//创建场景 // scene.background = new THREE.Color(0x87ceeb); // 天空蓝色 //普通2d纹理背景 // const loader=new THREE.TextureLoader() // const bgtexture=loader.load('infinity-81183443.jpg') // bgtexture.colorSpace = THREE.SRGBColorSpace; // scene.background=bgtexture //天空盒子 // const loader1 = new THREE.CubeTextureLoader (); // loader1.setPath('geomatriy/'); // const cubeTexture = loader1.load([ // 'px.jpg', 'nx.jpg', // 'py.jpg', 'ny.jpg', // 'pz.jpg', 'nz.jpg' // ]); //scene.background = cubeTexture; // 加载360度全景背景纹理 // const loader = new THREE.TextureLoader(); // const texture = loader.load('360.jpg', () => { // texture.mapping = THREE.EquirectangularReflectionMapping; // scene.background = texture; // }); // 方式 3:加载 equirectangular 图,然后转 cube map(用于反射) loadEnvironment(); camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100000);//创建透视相机 camera.position.set(0, 1.5, 15);//设置相机位置前面上面 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });//创建渲染器//开启抗锯齿效果 renderer.setSize(window.innerWidth, window.innerHeight); // 将渲染器的 canvas 添加到 DOM 中 threeCanvas.value.appendChild(renderer.domElement); // 设置鼠标控制器 controls = new OrbitControls(camera, renderer.domElement); // controls.enableDamping = true; //启用阻尼(惯性) controls.minDistance = 1; controls.maxDistance = 500; // controls.maxDistance = 20; // 最大缩放距离 controls.maxPolarAngle = Math.PI;//限制垂直旋转角度(最大为 90°) const lights = [ new THREE.AmbientLight(0xffffff, 0.6),// 环境光 new THREE.DirectionalLight(0xffffff, 0.8),// 主方向光 new THREE.DirectionalLight(0xffffff, 0.4),// 辅助方向光 new THREE.PointLight(0xffffff, 0.5)// 点光源 ]; lights[1].position.set(5, 10, 7);// 设置主方向光位置 lights[2].position.set(-5, 5, -5);// 设置辅助方向光位置 lights[3].position.set(0, 3, 3);// 设置点光源位置 lights.forEach(light => scene.add(light)); // 统一添加到场景中 // const controlPoints = [ // [1.118281, groundY, -3.681386], // [3.948875, groundY, -3.641834], // [3.960072, groundY, -0.240352], // [3.985447, groundY, 4.585005], // [-3.793631, groundY, 4.585006], // [-3.826839, groundY, -14.736200], // [-14.542292, groundY, -14.765865], // [-14.520929, groundY, -3.627002], // [-5.452815, groundY, -3.634418], // [-5.467251, groundY, 4.549161], // [-13.266233, groundY, 4.567083], // [-13.250067, groundY, -13.499271], // [4.081842, groundY, -13.435463], // [4.125436, groundY, -5.334928], // [-14.521364, groundY, -5.239871], // [-14.510466, groundY, 5.486727], // [5.745666, groundY, 5.510492], // [5.787942, groundY, -14.728308], // [-5.423720, groundY, -14.761919], // [-5.373599, groundY, -3.704133], // [1.004861, groundY, -3.641834], // ]; // const p0 = new THREE.Vector3(); // const p1 = new THREE.Vector3(); // curve = new THREE.CatmullRomCurve3( // controlPoints.map((p, ndx) => { // p0.set(...p); // p1.set(...controlPoints[(ndx + 1) % controlPoints.length]); // return [ // (new THREE.Vector3()).copy(p0), // (new THREE.Vector3()).lerpVectors(p0, p1, 0.1), // (new THREE.Vector3()).lerpVectors(p0, p1, 0.9), // ]; // }).flat(), // true, // ); // const points = curve.getPoints(250); // const geometry = new THREE.BufferGeometry().setFromPoints(points); // const material = new THREE.LineBasicMaterial({color: 0xff0000}); // curveObject = new THREE.Line(geometry, material); // scene.add(curveObject); // curveObject.visible = false; // 隐藏路径线 // }; //加载模型文件和材质 // const loadModel = () => { // const mtlLoader = new MTLLoader();// 创建材质加载器 // mtlLoader.setPath('/models/');// 设置材质文件路径 // // 加载材质文件 // mtlLoader.load('1.mtl', (materials) => { // materials.preload();// 预加载材质 // // 创建 OBJ 模型加载器 // const objLoader = new OBJLoader(); // // 应用预加载好的材质 // objLoader.setMaterials(materials); // // 设置模型文件路径 // objLoader.setPath('/models/'); // // 加载模型文件 // objLoader.load('1.obj', (object) => { // object.traverse((child) => { // // 遍历模型所有子对象 // if (child.isMesh && child.name.toLowerCase().includes('blade')) { // // 如果是叶片网格对象,保存引用以便后续动画使用 // bladeMesh = child; // } // }); // object.scale.set(0.5, 0.5, 0.5);// 缩放模型 // scene.add(object);// 添加到场景中 // }); // }); }; const pathPoints = [ new THREE.Vector3(1.2, 0, 2.3), // 房间 A 的入口 new THREE.Vector3(3.5, 0, 2.3), // 房间 B 的门口 new THREE.Vector3(3.5, 0, 5.0), // 房间 B 内部 ]; const dumpObject = function (obj, lines = [], isLast = true, prefix = '') { const localPrefix = isLast ? '└─' : '├─'; lines.push(`${prefix}${prefix ? localPrefix : ''}${obj.name || '*no-name*'} [${obj.type}]`); const newPrefix = prefix + (isLast ? ' ' : '│ '); const lastNdx = obj.children.length - 1; obj.children.forEach((child, ndx) => { const isLastChild = ndx === lastNdx; dumpObject(child, lines, isLastChild, newPrefix); }); return lines; }; // ✅ 用来存放每个车的容器 Object3D const loadModel = () => { const loader = new GLTFLoader(); loader.setPath('/models1/'); // 设置你的 gltf 路径 loader.load('scene.gltf', (gltf) => { const model = gltf.scene; scene.add(model); model.updateMatrixWorld(true); const roomPoints = []; model.traverse((child) => { if (child.name && child.parent.children.length > 5) { console.log("child.name", child.name); // 计算该对象的包围盒 const box = new THREE.Box3().setFromObject(child); const center = new THREE.Vector3(); box.getCenter(center); // 保存中心点 roomPoints.push(center); console.log("room center:", center); } }); console.log("所有房间中心点坐标:", roomPoints); // pathPoints.length = 0; pathPoints.push(...roomPoints); // 获取 Cars 节点 // cars = model.getObjectByName('Cars'); // 获取名字为 'Cars' 的节点,它的 children 是一堆车。 // const loadedCars = model.getObjectByName('Cars'); // const fixes = [ // { prefix: 'Car_08', rot: [Math.PI * .5, 0, Math.PI * .5], }, // { prefix: 'CAR_03', rot: [0, Math.PI, 0], }, // { prefix: 'Car_04', rot: [0, Math.PI, 0], }, // ]; // model.updateMatrixWorld();//先更新一下全局坐标系统,保证 .getWorldPosition() 能获取正确位置。 // for (const car of loadedCars.children.slice()) { // car.scale.set(0.005, 0.005, 0.005); // 缩小单个车 // const fix = fixes.find(fix => car.name.startsWith(fix.prefix)); // const obj = new THREE.Object3D(); // car.getWorldPosition(obj.position); // car.position.set(0, 0, 0); // car.rotation.set(...fix.rot); // obj.add(car); // scene.add(obj); // cars.push(obj); // } // if (!cars) { // console.warn('未找到名为 "Cars" 的节点'); // } else { // console.log('找到 Cars 节点,包含子节点数量:', cars.children.length); // } }, undefined, (error) => { console.error('加载GLTF模型失败:', error); }); }; // 老人角色模型 const loadOldManModel = () => { const loader = new GLTFLoader(); loader.setPath('/oldmodels/'); loader.load('scene.gltf', (gltf) => { oldMan = gltf.scene;//根节点 scene oldMan.scale.set(0.01, 0.01, 0.01); console.log("动画", gltf.animations); oldMan.name = 'OldMan';//给这个模型命名为 'OldMan',后续查找/遍历方便定位该对象。 console.log('[结构] dump:', dumpObject(oldMan).join('\n')); oldMan.position.set(0, 0, 0); // 起始坐标 console.log('[OldMan] Loaded:', oldMan); scene.add(oldMan); // 创建动画混合器 if (oldMan) { mixer = new THREE.AnimationMixer(oldMan);//如果模型成功加载,就为其创建一个动画混合器 mixer,用来控制播放动画(如行走)。 } // 找到名字为 "Shuffle" 的动画 const shuffleClip = THREE.AnimationClip.findByName(gltf.animations, 'Take 001'); // 播放 Shuffle 动画 const action = mixer.clipAction(shuffleClip);//动画混合器中获取该动画片段的 action 实例,可用于播放、停止、淡入淡出等控制 console.log('[Shuffle] Action:', action); action.play(); setTimeout(() => { moveAlongPath(oldMan, pathPoints); }, 1000); // 可选:保存引用或添加到人物控制器中 // oldManModelRef = oldMan; }, undefined, (error) => { console.error('加载 oldman.gltf 失败:', error); }); }; const moveAlongPath = (object, points, index = 0) => { if (index >= points.length - 1) return;//已经到达或超过最后一个点,就不继续执行 const from = points[index]; const to = points[index + 1];//当前从 from 点出发,目标是 to 点。每次移动一段路径 object.lookAt(to);//对象面朝下一个目标点,使移动方向自然(人物朝着行走方向看) //创建一个 Tween 动画: new Tween(object.position, tweenGroup) .to({ x: to.x, y: to.y, z: to.z }, 5000) .easing(Easing.Linear.None)//设置动画缓动函数:线性匀速移动,不加速、不减速。 .onComplete(() => moveAlongPath(object, points, index + 1))//动画完成时,递归调用 moveAlongPath,移动到下一个路径点。 .start();//动画完成时,递归调用 moveAlongPath,移动到下一个路径点。 // 可视化路径点 const sphere = new THREE.Mesh( new THREE.SphereGeometry(0.05), new THREE.MeshBasicMaterial({ color: 0xff0000 }) ); //建一个红色小球,用于在场景中标记 to 点(可视化路径节点)。 sphere.position.copy(to);//将小球位置设置为 to 点 scene.add(sphere);//把红色小球添加到场景中,便于观察路径的每个点。 } // const animate = (time) => { // time *= 0.001; // 毫秒 -> 秒 // requestAnimationFrame(animate);// 每帧请求动画回调 // // 让汽车旋转 // if (cars) { // for (const car of cars) { // car.rotation.y = time; // 可加速度,例如 time * 2 // } // } // controls.update();// 更新控制器状态(处理惯性) // renderer.render(scene, camera); //执行渲染 // }; const animate = (time) => { time *= 0.001; // 毫秒 -> 秒 requestAnimationFrame(animate); // if (cars.length > 0) { // const pathTime = time * 0.01; // 控制速度,数值越大车跑得越快 // cars.forEach((car, ndx) => { // // 一个介于 0 和 1 之间的数字,用于均匀间隔汽车 // const u = (pathTime + ndx / cars.length) % 1; // // 获取路径上第一个点(汽车当前位置) // curve.getPointAt(u, carPosition); // carPosition.applyMatrix4(curveObject.matrixWorld); // // 获取路径上第二个点(汽车朝向目标点) // curve.getPointAt((u + targetOffset) % 1, carTarget); // carTarget.applyMatrix4(curveObject.matrixWorld); // // 汽车固定高度,覆盖Y轴 // carPosition.y = groundY; // car.position.copy(carPosition); // // 汽车面向目标点 // car.lookAt(carTarget); // // 也可以选择放到两个点的中间位置,更平滑 // // car.position.lerpVectors(carPosition, carTarget, 0.5); // }); // } const delta = clock.getDelta(); if (mixer) { // 确认mixer已赋值才调用update mixer.update(delta);// 获取当前帧与上一帧之间的时间差,单位为秒。用于动画步进,确保在不同帧率下动画速度一致。 } tweenGroup.update(time * 1000); // ✅ 替代 TWEEN.update controls.update();// delta 来推进动画帧。 renderer.render(scene, camera); }; // 窗口尺寸变化回调,保持自适应 const handleResize = () => { const width = window.innerWidth; const height = window.innerHeight; renderer.setSize(width, height);// 更新渲染器大小 camera.aspect = width / height;//更新相机大小 camera.updateProjectionMatrix();//更新投影矩阵 }; onMounted(() => { initScene(); loadModel(); animate(); loadOldManModel(); window.addEventListener('resize', handleResize);// 监听窗口大小变化 }); onBeforeUnmount(() => { window.removeEventListener('resize', handleResize);// 移除监听 renderer.dispose();// 销毁渲染器资源 controls.dispose(); // 销毁控制器 }); </script> <style scoped> #c { /* width: 100%; height: 100%; display: block; */ /* background: url(infinity-81183443.jpg) no-repeat center center; */ /* background-size: cover; */ } body { margin: 0; /* overflow: hidden; */ width: 100%; height: 100%; } </style>