【Three.js】模型沿着曲线运动的方法

257 阅读8分钟

在使用three.js进行开发的过程中,遇到了需要将模型沿着既定的路线行走的问题,经过一段时间的研究,总结出了一套自己的方法,在博客里面进行整理,提供给有需要的人。

在博客里,我将以行人沿着道路行走为例子,进行讲解。

建模

首先我们在blender中捏一段道路。

road.png

然后添加一段路径曲线,画出你需要的移动路径:

line.png

我这里画了两段,左边是line1右边是line2,模拟直线和曲线。

导出格式

导出的时候选择.glb格式,注意选择几何节点实例应用修改器松散边,如果有添加的自定义属性,也要注意勾选。

PS:如果导出的模型里面曲线的属性不是“line”而是“object3D”,可能是没有勾选松散边导致的。

export.png

导出时选择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个点画出来的线,大体沿着原有曲线的方向,但较为粗糙

points30.png

  • 50个点:基本符合

points50.png

  • 80个点:大大的符合

points80.png

解决了道路和行走路线的问题后,我们需要往里面添加行人模型了。你可以使用建模软件自己捏一个有骨骼、动画的行人模型,然后以glb的格式导出。如果你已经有了一个带骨骼的模型,可以选择在mixamo中为它添加动画。

[mixamo​]  www.mixamo.com/#/ 

mixamo.png

我比较懒,我选择直接从里面导出一个带动画的模型。

添加行人模型

在项目中可能会需要添加很多行人模型,为了便于管理,将相关逻辑集中在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()
})

让我们运行一下看看结果:

result.gif

OK!已经实现了在Three.js中模型沿着曲线运动了,在实际的使用中可以根据这个方法实现车辆沿路径移动等功能,希望能帮到有需要的人^ ^