在使用three.js进行开发的过程中,遇到了需要将模型沿着既定的路线行走的问题,经过一段时间的研究,总结出了一套自己的方法,在博客里面进行整理,提供给有需要的人。
在博客里,我将以行人沿着道路行走为例子,进行讲解。
建模
首先我们在blender中捏一段道路。
然后添加一段路径曲线,画出你需要的移动路径:
我这里画了两段,左边是line1右边是line2,模拟直线和曲线。
导出格式
导出的时候选择.glb格式,注意选择几何节点实例、应用修改器和松散边,如果有添加的自定义属性,也要注意勾选。
PS:如果导出的模型里面曲线的属性不是“line”而是“object3D”,可能是没有勾选松散边导致的。
导出时选择glb格式的理由
glb格式将模型中所有的数据(几何、材质、动画等)以二进制的形式存储在一个文件里,大大减小了文件体积,文件更紧凑、加载更快。- 可作为静态资源部署,便于在web项目中使用。
- 适配Three.js,其中提供的
GLTFLoader原生支持加载glb格式的文件。
在项目中读取
在roadCanvas组件中使用GLTFLoader加载整个道路模型,在大型项目中涉及较多的道路和模型,需要分离道路系统的操作逻辑,于是对模型中具体道路的操作封装在roadSystem.js中。
// 加载模型
const loader = new GLTFLoader()
loader.load('src/assets/models/road3.glb', (gltf) => {
scene.add(gltf.scene) //添加到场景中
roadSystem.initRoadSystem(gltf.scene);//初始化道路系统
//试试打印曲线中的点
gltf.scene.traverse((child) => {
console.log(child)
if (child.isLine || child.isLineSegments) {
console.log('曲线顶点:', child.geometry.attributes.position.array);
}
})
}
})
console.log('添加模型成功')
renderer.render(scene, camera)
}, undefined, (err) => {
console.error('模型加载失败:', err)
})
控制台输出结果如下, LineSegments是我们绘制的路径,曲线顶点是我们将模型导出的时候自动为我们细分的点。
初始化道路系统:在组件中将道路模型传入initRoadSystem,遍历模型中的所有子对象,找到类型为曲线的部分。如果有其他曲线,只需要截取一部分,可以在建模时分类命名,在这里根据名字来获取。
// 初始化道路系统,遍历模型中所有对象
initRoadSystem(roadModel) {
roadModel.traverse((obj) => {
// 如果对象是线(Line 或 LineSegments),认为是我们需要的道路路径
if (obj.isLine || obj.isLineSegments) {
this.processRoadObject(obj);
//将这条道路曲线传入函数中进行具体处理
}
});
}
processRoadObject(obj) {
const linePoints = []
// 获取几何体
const geometry = obj.geometry
// 如果是根据名字进行选择,那么在传入函数中可能出现没有几何体或不包含顶点数据的情况,在这里直接返回
if (!geometry || !geometry.attributes?.position) return
// 获取顶点位置数组
const positions = geometry.attributes.position.array
const matrixWorld = obj.matrixWorld // 获取对象的世界变换矩阵
// 遍历所有顶点
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i]
const y = positions[i + 1]
const z = positions[i + 2]
// 如果遍历到坐标非法(例如为 NaN),则跳过
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue
// 创建局部坐标点
const localPoint = new THREE.Vector3(x, y, z)
// 将局部坐标变换为世界坐标
const worldPoint = localPoint.applyMatrix4(matrixWorld)
// 为了更好的观察是否识别到我们的曲线,重置 y 值高度,抬高到道路之上
worldPoint.y = this.CAR_HEIGHT_ABOVE_ROAD || 0.05
// 添加到路径点列表中
linePoints.push(worldPoint)
}
// 如果路径点大于 1,说明可以构建曲线
if (linePoints.length > 1) {
// 构造 Catmull-Rom 曲线(平滑连接这些点)
const curve = new THREE.CatmullRomCurve3(linePoints, false)
// 采样出80个中间点用于可视化,取出的点越多,画出来的曲线就越平滑,越接近原有的曲线
const points = curve.getPoints(80)
// 存入roadPaths,使用 obj.name 作为 key
this.roadPaths.set(obj.name || `line_${this.roadPaths.size}`, {
curve,
points,
length: curve.getLength()
});
// 使用封装的函数可视化曲线
this.visualizePath(points)
}
}
// 将路径点可视化为一条三维线段
visualizePath(points) {
// 创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// 创建材质,随机颜色
const material = new THREE.LineBasicMaterial({
color: new THREE.Color().setHSL(Math.random(), 0.8, 0.5),
linewidth: 2
});
// 创建线对象并加入场景
const pathLine = new THREE.Line(geometry, material);
this.scene.add(pathLine);
}
关于采样曲线点数对于可视化的影响,分别使用了30、50
80个点:
- 30个点画出来的线,大体沿着原有曲线的方向,但较为粗糙
- 50个点:基本符合
- 80个点:大大的符合
解决了道路和行走路线的问题后,我们需要往里面添加行人模型了。你可以使用建模软件自己捏一个有骨骼、动画的行人模型,然后以glb的格式导出。如果你已经有了一个带骨骼的模型,可以选择在mixamo中为它添加动画。
[mixamo] www.mixamo.com/#/
我比较懒,我选择直接从里面导出一个带动画的模型。
添加行人模型
在项目中可能会需要添加很多行人模型,为了便于管理,将相关逻辑集中在humanManage.js中管理。让我们拆分管理逻辑,首先要加载这个模型,然后再将它加入场景中,加载和添加最好不要放在一个函数里,如果后续需要通过点击按钮添加人体模型的话不用反复加载,以免浪费资源。除此之外还要有更新模型的方法,调用模型动画的方法,以及清除模型与动画的方法(以免造成资源浪费)。
先在构造函数中声明会用到的数据:
constructor(scene, roadSystem) {
this.scene = scene; //Three.js 场景
this.roadSystem = roadSystem; //路径系统,提供路径数据
this.humans = []; //当前场景中所有人类模型
this.mixers = []; //所有人类动画混合器
this.clock = new THREE.Clock(); //控制动画时间
this.moveSpeed = 0.008; //控制动画播放的速度
this.HUMAN_SCALE = 0.5; //人物缩放比例
this.humanTemplate = null; //人物模型模板
this.animations = []; //存储人物模型携带的动画数据
}
加载人体模型并复制:整体思路是把模型加载下来,克隆到模板里面,实际添加到场景中的时候用的是克隆的模型。
async loadHumanModel() {
const loader = new GLTFLoader();
const url = new URL('@/assets/models/walk.glb', import.meta.url).href;
const gltf = await loader.loadAsync(url);
this.animations = gltf.animations; //模型的动画都存储在animations里面,把它们存储下来
this.humanTemplate = SkeletonUtils.clone(gltf.scene); //把模型使用骨骼工具克隆到模板里
this.humanTemplate.scale.set(this.HUMAN_SCALE, this.HUMAN_SCALE, this.HUMAN_SCALE); //根据人物缩放比例调整模板的模型
// 给人物模型添加阴影
this.humanTemplate.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
}
给每条道路生成count个人物模型并让它们在场景中移动的方法:
spawnHumansByRoads(count = 1) {
//首先获取到roadSystem中存储的道路数据,开始遍历
for (const [roadName, pathData] of this.roadSystem.getRoadPaths().entries()) {
if (!pathData || !pathData.curve) continue;
//给每一条道路模型添加count个人物模型
for (let i = 0; i < count; i++) {
const human = SkeletonUtils.clone(this.humanTemplate);
const mixer = new THREE.AnimationMixer(human);//获取动画系统
this.mixers.push(mixer);//加入到mixers数组中便于后续管理
// 随机起始进度 & 随机速度
human.userData = {
curve: pathData.curve,
progress: Math.random(),
speed: this.moveSpeed * (0.8 + Math.random() * 0.4)
};
this.setHumanAction(human, 'walk');//播放名为walk的动画(在后面讲解
//同时还要给模型设置位置和模型朝向
const pos = pathData.curve.getPointAt(human.userData.progress);
const tangent = pathData.curve.getTangentAt(human.userData.progress);
human.position.copy(pos);
human.lookAt(pos.clone().add(tangent));
this.scene.add(human);
this.humans.push(human);
}
}
}
更新人物动画和移动逻辑的方法:
updateHumans() {
const delta = this.clock.getDelta(); // 获取帧时间差
this.mixers.forEach(m => m.update(delta)); // 更新动画
this.humans.forEach(human => {
const { curve, speed } = human.userData;
human.userData.progress += speed * delta;
// 若超过路径终点,则重头再来,让这个模型在路径上循环行走
if (human.userData.progress > 1) {
human.userData.progress = 0;
}
// 更新人物位置与朝向
const pos = curve.getPointAt(human.userData.progress);
const tangent = curve.getTangentAt(human.userData.progress);
human.position.copy(pos); // 设置位置
human.lookAt(pos.clone().add(tangent)); // 设置朝向
});
}
其中const delta = this.clock.getDelta();这一句代码的作用是
- 用于获取 “上一帧到当前帧之间的时间差(秒)” ,用于驱动动画的时间更新。
clock.getDelta()会返回自上次调用getDelta()以来的秒数- 常用于动画系统中,确保动画播放速度在不同设备上保持一致。
设置人物动画:
setHumanAction(human, actionName) {
//查找是否有对应名称的动画
const clip = this.animations.find(a => a.name.toLowerCase() === actionName.toLowerCase());
if (!clip) return;
//获取该人物对应的 AnimationMixer(动画混合器)
const mixer = this.mixers.find(m => m.getRoot() === human);
if (!mixer) return;
//如果当前模型有动画正在播放,则将动画淡出
if (human.userData.currentAction) {
human.userData.currentAction.fadeOut(0.2);
}
//播放新的动画
const newAction = mixer.clipAction(clip);
newAction.timeScale = 4;//调整播放速度
newAction.reset().fadeIn(0.2).play();//从头播放,并在0.2秒淡入
human.userData.currentAction = newAction;//保存当前播放的动画,以便下次切换动画时淡出使用
}
清除人物模型以及动画:
clearAllHumans() {
this.humans.forEach(human => {
this.scene.remove(human);
const mixerIndex = this.mixers.findIndex(m => m.getRoot() === human);
if (mixerIndex !== -1) {
this.mixers[mixerIndex].stopAllAction();
this.mixers.splice(mixerIndex, 1);
}
});
this.humans = [];
}
执行时机
在编写完humanManage的逻辑后,我们需要在渲染模型的组件中调用相关的函数。
在onMounted中执行。
onMounted(async () => {
......
//添加行人,执行动画
async function addHuman() {
let count = 1;
// 加载人类模型
HumanManager.loadHumanModel().then(() => {
HumanManager.spawnHumansByRoads(count);
// 启动动画更新
setInterval(() => {
HumanManager.updateHumans();
}, 800); //可通过修改参数改变刷新频率
});
}
//动画循环函数,不断刷新Three.js场景
const animate = () => {
controls.update()
renderer.render(scene, camera)
animationId = requestAnimationFrame(animate)
}
//加载道路模型
const loadRoadModel = async () => {
try {
const loader = new GLTFLoader()
const roadModel = await new Promise((resolve, reject) => {
loader.load(
'src/assets/models/road.glb',
(gltf) => {
//初始化道路系统
roadSystem.initRoadSystem(gltf.scene);
resolve(gltf.scene);
},
undefined,
reject
);
})
scene.add(roadModel)
} catch (error) {
console.error('学校模型加载失败:', error)
}
}
await loadRoadModel()
addHuman();
animate();
......
})
记得及时清除资源,以免造成资源浪费:
onUnmounted(() => {
cancelAnimationFrame(animationId)
HumanManager.clearAllHumans();
window.removeEventListener('resize', onWindowResize)
cancelAnimationFrame(animationId)
renderer.dispose()
})
让我们运行一下看看结果:
OK!已经实现了在Three.js中模型沿着曲线运动了,在实际的使用中可以根据这个方法实现车辆沿路径移动等功能,希望能帮到有需要的人^ ^