基于Three.js的人物模型沿房间路径自动行走动画系统⑯

本项目使用 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 动画。

  • 支持浏览器窗口变化时的实时适配。

模块用途
threeWebGL 渲染核心库
@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>

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值