Three.js 骨骼动画 SkinnedMesh 详解

所谓骨骼动画,以人体为例简单地说,人体的骨骼运动,骨骼运动会带动肌肉和人体皮肤的空间移动和表面变化。

Threejs骨骼动画需要通过骨骼网格模型类SkinnedMesh来实现,一般来说骨骼动画模型都是3D美术创建,然后程序员通过threejs引擎加载解析。

threejs文档可以切换中文,方便大家阅读

基础数据讲解

要让模型动起来必须具备以下条件:

1、带骨骼绑定的模型

模型加载完成之后我们可以通过遍历所有节点来查看模型是否有骨骼绑定

js 复制代码
model.traverse((o) => {
  console.log(o);
});

只要有SkinnedMesh和Bone类型的数据就说明模型有骨骼绑定

SkinnedMesh:具有Skeleton(骨架)和bones(骨骼)的网格,可用于给几何体上的顶点添加动画

Bone:骨骼是Skeleton(骨架)的一部分。骨架是由SkinnedMesh(蒙皮网格)依次来使用的。 骨骼几乎和空白Object3D相同。

2、骨骼动画的动画数据

动画数据可以是单独动画文件提供,也可以直接附带在模型文件中,我这里的实例是附带在glb模型文件中的(不同的模型文件数据结构有一点点差异),打印模型数据的数据结构如下:

js 复制代码
loader.load(
  "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb",
  (gltf) => {
    console.log(gltf);
  }
);

可以看到动画数据animations和模型数据scene都包含在文件中。

animations是一个动画数组,其中包含着多个AnimationClip,每个AnimationClip都是一个完整的动画数据,可以用来播放动画(通过AnimationAction来调度)。

  • blendMode 混合模式(暂未确定干什么用的,可能是这个,要翻墙 动画混合模式
  • duration 动画时长,单位为秒(上面的数据就是4.76秒)
  • name 动画名称
  • tracks 一个由关键帧轨道(KeyframeTracks)组成的数组,这个是动画动起来的关键,我们可以简单理解为里面的数据告诉模型的是每个时间点每个顶点要在什么位置,按时间轴去改变顶点位置来形成动画。
  • uuid 实例的UUID,自动分配且不可编辑

加载模型并处理数据

将模型加载并显示,这样模型就可以正常显示在页面中

js 复制代码
 const loader = new GLTFLoader();

  // 加载纹理贴图
  const texture = new THREE.TextureLoader().load(
    'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy.jpg'
  );
  texture.flipY = false;

  /** 材质对象 */
  const material = new THREE.MeshPhongMaterial({
    map: texture,
    color: 0xffffff,
    skinning: true
  });

  loader.load(
    'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb',
    (gltf) => {
      const model = gltf.scene;
      model.traverse((o) => {
        if (o.isMesh) {
          // 启用投射和接收阴影的能力
          o.castShadow = true;
          o.receiveShadow = true;
          o.material = material;
        }
      });

      // 放大 7 倍
      model.scale.set(7, 7, 7);
      model.position.y = -11;

      this.scene.add(model);
    }
  );

接下来处理动画数据

js 复制代码
// 拿到模型中自带的动画数据
const fileAnimations = gltf.animations;

// 创建模型动作混合器
const mixer = new THREE.AnimationMixer(model);

// 处理其他动画,过滤掉空闲动画
const clips = fileAnimations.filter((val) => val.name !== 'idle');

// 遍历动画列表并生成操作对象存储起来
const possibleAnims = clips.map((val) => {
  let clip = THREE.AnimationClip.findByName(clips, val.name);
  clip = mixer.clipAction(clip);
  return clip;
});

// 从动画列表中找到名字为'空闲'的动画
const idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle');
// 得到动画操作对象
const idle = mixer.clipAction(idleAnim);
this.idle = idle;
idle.play(); // 播放空闲动画

this.possibleAnims = possibleAnims;
this.mixer = mixer;

AnimationMixer:动画混合器是用于场景中特定对象的动画的播放器。当场景中的多个对象独立动画时,每个对象都可以使用同一个动画混合器。

简单的理解就是AnimationMixer对于传入的模型是一个全局的动画控制器,可以对动画进行一个全局的管理。

mixer.clipAction:AnimationMixer的一个方法,返回所传入的剪辑参数的AnimationAction, 根对象参数可选,默认值为混合器的默认根对象。第一个参数可以是动画剪辑(AnimationClip)对象或者动画剪辑的名称。

只有AnimationAction对象才能播放暂停动画,所有的剪辑剪辑对象AnimationClip必须通过AnimationAction来调度。

所以AnimationMixer、AnimationClip、AnimationAction三者的关系和含义及其作用一定要理解清楚。

播放动画

播放动画只需要AnimationAction.play()就可以,但是我们可以设置一些播放逻辑让动画播放效果更加友好

js 复制代码
// 播放动画
this.playModifierAnimation( this.idle, 0.25, this.possibleAnims[index], 0.25);

//...

/**
 * 切换动画
 * @params form 当前执行的动画
 * @params fSpeed 当前动画过度到下一个动画的时间
 * @params to 下一个要执行的动画
 * @params tSpeed 下一个动画执行完成后回到当前动画的时间
 */
playModifierAnimation (from, fSpeed, to, tSpeed) {
  to.setLoop(THREE.LoopOnce); // 设置只执行一次
  to.reset(); // 重置动画,保证动画从第一针开始播放
  from.crossFadeTo(to, fSpeed, true); // 进行动画过度
  to.play(); // 执行动画

  // 动画执行完成之后回到当前动画(原动画form)
  setTimeout(() => {
    to.crossFadeTo(from, tSpeed, true); // 进行动画过度
    from.enabled = true; // 当enabled被重新置为true, 动画将从当前时间(time)继续
    from.play(); // 播放原动画
    this.currentlyAnimating = false;
  }, to._clip.duration * 1000 - (tSpeed + fSpeed) * 1000);
}

完整代码奉上,完整代码多了人物视角跟随鼠标点击模型触发动作的功能,有兴趣的可以自己运行试一下

html 复制代码
<template>
  <div class="wrapper">
    <div class="buttons">
      <div
        v-for="(btn, index) in possibleAnims"
        class="button"
        :key="index"
        @click="palyAnimation(index)"
      >
        <span>{{ btn._clip.name }}</span>
      </div>
    </div>
    <canvas id="c"></canvas>
  </div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export default {
  data () {
    return {
      scene: null, // 场景
      camera: null, // 相机
      material: null, // 材质
      mesh: null, // 网格模型对象Mesh
      renderer: null, // 渲染器对象
      controls: null, //

      idle: null,
      neck: null,
      waist: null,
      possibleAnims: null,
      mixer: null,
      clock: null,

      currentlyAnimating: false, // Used to check whether characters neck is being used in another anim
      raycaster: new THREE.Raycaster() // Used to detect the click on our character
    };
  },
  mounted () {
    this.init();
    document.addEventListener('mousemove', (e) => {
      const mousecoords = this.getMousePos(e);
      if (this.neck && this.waist) {
        this.moveJoint(mousecoords, this.neck, 50);
        this.moveJoint(mousecoords, this.waist, 30);
      }
    });
    window.addEventListener('click', (e) => this.raycast(e));
    window.addEventListener('touchend', (e) => this.raycast(e, true));
  },
  methods: {
    /** 创建场景 */
    initScene () {
      const backgroundColor = 0xf1f1f1;
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(backgroundColor);
      scene.fog = new THREE.Fog(backgroundColor, 60, 100); // 雾化效果
      this.scene = scene;
    },

    /** 创建相机 */
    initCamera () {
      const camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      );
      camera.position.z = 30;
      camera.position.x = 0;
      camera.position.y = -3;

      this.camera = camera;
    },

    /** 创建渲染器 */
    initRenderer () {
      const canvas = document.querySelector('#c');
      const renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: true // 抗齿距
      });
      renderer.shadowMap.enabled = true; // 投射阴影
      renderer.setPixelRatio(window.devicePixelRatio);
      document.body.appendChild(renderer.domElement);
      this.renderer = renderer;
      const controls = new OrbitControls(this.camera, renderer.domElement);
      controls.maxPolarAngle = Math.PI / 2;
      controls.minPolarAngle = Math.PI / 3;
      controls.enableDamping = true;
      controls.enablePan = false;
      controls.dampingFactor = 0.1;
      controls.autoRotate = false; // Toggle this if you'd like the chair to automatically rotate
      controls.autoRotateSpeed = 0.2; // 30
    },

    /** 创建材质对象 */
    initTexture () {
      // 加载纹理贴图
      const texture = new THREE.TextureLoader().load(
        'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy.jpg'
      );
      texture.flipY = false;

      /** 材质对象 */
      const material = new THREE.MeshPhongMaterial({
        map: texture,
        color: 0xffffff,
        skinning: true
      });

      this.material = material;
    },

    /** 创建网格模型对象 */
    initMesh () {
      const loader = new GLTFLoader();
      let neck = null;
      let waist = null;

      // 加载纹理贴图
      const texture = new THREE.TextureLoader().load(
        'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy.jpg'
      );
      texture.flipY = false;

      /** 材质对象 */
      const material = new THREE.MeshPhongMaterial({
        map: texture,
        color: 0xffffff,
        skinning: true
      });

      loader.load(
        'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb',
        (gltf) => {
          const model = gltf.scene;

          model.traverse((o) => {
            if (o.isMesh) {
              // 启用投射和接收阴影的能力
              o.castShadow = true;
              o.receiveShadow = true;
              o.material = material;
            }
            if (o.isBone && o.name === 'mixamorigNeck') {
              neck = o;
            }
            if (o.isBone && o.name === 'mixamorigSpine') {
              waist = o;
            }
          });

          // 放大 7 倍
          model.scale.set(7, 7, 7);
          model.position.y = -11;

          this.scene.add(model);

          // 拿到模型中自带的动画数据
          const fileAnimations = gltf.animations;

          // 创建模型动作混合器
          const mixer = new THREE.AnimationMixer(model);

          // 处理其他动画
          const clips = fileAnimations.filter((val) => val.name !== 'idle');

          // 遍历动画列表并生成操作对象存储起来
          const possibleAnims = clips.map((val) => {
            let clip = THREE.AnimationClip.findByName(clips, val.name);

            clip.tracks.splice(3, 3);
            clip.tracks.splice(9, 3);

            clip = mixer.clipAction(clip);
            return clip;
          });

          // 从动画列表中找到名字为'空闲'的动画
          const idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle');

          idleAnim.tracks.splice(3, 3);
          idleAnim.tracks.splice(9, 3);

          // 得到动画操作对象
          const idle = mixer.clipAction(idleAnim);
          this.idle = idle;
          idle.play(); // 播放空闲动画

          this.neck = neck;
          this.waist = waist;
          this.possibleAnims = possibleAnims;
          this.mixer = mixer;

          console.log(this.possibleAnims);
        },
        undefined, // We don't need this function
        (error) => {
          console.error(error);
        }
      );
    },

    /** 创建光源 */
    initLight () {
      const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.61);
      hemiLight.position.set(0, 50, 0);
      // Add hemisphere light to scene
      this.scene.add(hemiLight);

      const d = 8.25;
      const dirLight = new THREE.DirectionalLight(0xffffff, 0.54);
      dirLight.position.set(-8, 12, 8);
      dirLight.castShadow = true;
      dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
      dirLight.shadow.camera.near = 0.1;
      dirLight.shadow.camera.far = 1500;
      dirLight.shadow.camera.left = d * -1;
      dirLight.shadow.camera.right = d;
      dirLight.shadow.camera.top = d;
      dirLight.shadow.camera.bottom = d * -1;
      // Add directional Light to scene
      this.scene.add(dirLight);
    },

    /** 创建地面 */
    initFloor () {
      const floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
      const floorMaterial = new THREE.MeshPhongMaterial({
        color: 0x0000ff,
        shininess: 0
      });

      const floor = new THREE.Mesh(floorGeometry, floorMaterial);
      floor.rotation.x = -0.5 * Math.PI;
      floor.receiveShadow = true;
      floor.position.y = -11;
      this.scene.add(floor);
    },

    /** 初始化 */
    init () {
      this.initScene();
      this.initCamera();
      this.initRenderer();

      this.clock = new THREE.Clock();
      this.initMesh();
      this.initLight();
      this.initFloor();

      this.update();
    },

    update () {
      if (this.mixer) {
        // 更新动画 根据clock确保不会因为帧率导致动画变快或变慢
        this.mixer.update(this.clock.getDelta());
      }

      if (this.resizeRendererToDisplaySize(this.renderer)) {
        const canvas = this.renderer.domElement;
        this.camera.aspect = canvas.clientWidth / canvas.clientHeight;
        this.camera.updateProjectionMatrix(); // 重新计算相机对象的投影矩阵值
      }

      this.renderer.render(this.scene, this.camera);
      requestAnimationFrame(this.update);
    },

    resizeRendererToDisplaySize (renderer) {
      const canvas = renderer.domElement;
      const width = window.innerWidth;
      const height = window.innerHeight;
      const canvasPixelWidth = canvas.width / window.devicePixelRatio;
      const canvasPixelHeight = canvas.height / window.devicePixelRatio;

      const needResize =
        canvasPixelWidth !== width || canvasPixelHeight !== height;
      if (needResize) {
        renderer.setSize(width, height, false);
      }
      return needResize;
    },

    getMousePos (e) {
      return { x: e.clientX, y: e.clientY };
    },

    /**
     * 旋转关节
     * @params mouse 当前鼠标的位置
     * @params joint 需要移动的关节
     * @params degreeLimit 允许关节旋转的角度范围
     */
    moveJoint (mouse, joint, degreeLimit) {
      const degrees = this.getMouseDegrees(mouse.x, mouse.y, degreeLimit);
      joint.rotation.y = THREE.MathUtils.degToRad(degrees.x);
      joint.rotation.x = THREE.MathUtils.degToRad(degrees.y);
      // console.log(joint);
    },

    /**
     * 判断鼠标位于视口上半部、下半部、左半部和右半部的具体位置
     * @params x 鼠标x值
     * @params y 鼠标y值
     * @return 关节旋转角度
     */
    getMouseDegrees (x, y, degreeLimit) {
      let dx = 0;
      let dy = 0;
      let xdiff;
      let xPercentage;
      let ydiff;
      let yPercentage;

      const w = { x: window.innerWidth, y: window.innerHeight };

      // Left (Rotates neck left between 0 and -degreeLimit)

      // 1. If cursor is in the left half of screen
      if (x <= w.x / 2) {
        // 2. Get the difference between middle of screen and cursor position
        xdiff = w.x / 2 - x;
        // 3. Find the percentage of that difference (percentage toward edge of screen)
        xPercentage = (xdiff / (w.x / 2)) * 100;
        // 4. Convert that to a percentage of the maximum rotation we allow for the neck
        dx = ((degreeLimit * xPercentage) / 100) * -1;
      }
      // Right (Rotates neck right between 0 and degreeLimit)
      if (x >= w.x / 2) {
        xdiff = x - w.x / 2;
        xPercentage = (xdiff / (w.x / 2)) * 100;
        dx = (degreeLimit * xPercentage) / 100;
      }
      // Up (Rotates neck up between 0 and -degreeLimit)
      if (y <= w.y / 2) {
        ydiff = w.y / 2 - y;
        yPercentage = (ydiff / (w.y / 2)) * 100;
        // Note that I cut degreeLimit in half when she looks up
        dy = ((degreeLimit * 0.5 * yPercentage) / 100) * -1;
      }

      // Down (Rotates neck down between 0 and degreeLimit)
      if (y >= w.y / 2) {
        ydiff = y - w.y / 2;
        yPercentage = (ydiff / (w.y / 2)) * 100;
        dy = (degreeLimit * yPercentage) / 100;
      }
      return { x: dx, y: dy };
    },

    raycast (e, touch = false) {
      const mouse = {};
      if (touch) {
        mouse.x = 2 * (e.changedTouches[0].clientX / window.innerWidth) - 1;
        mouse.y = 1 - 2 * (e.changedTouches[0].clientY / window.innerHeight);
      } else {
        mouse.x = 2 * (e.clientX / window.innerWidth) - 1;
        mouse.y = 1 - 2 * (e.clientY / window.innerHeight);
      }
      // update the picking ray with the camera and mouse position
      this.raycaster.setFromCamera(mouse, this.camera);

      // calculate objects intersecting the picking ray
      const intersects = this.raycaster.intersectObjects(
        this.scene.children,
        true
      );
      if (intersects[0]) {
        const object = intersects[0].object;

        if (object.name === 'stacy') {
          if (!this.currentlyAnimating) {
            this.currentlyAnimating = true;
            this.playOnClick();
          }
        }
      }
    },

    playOnClick () {
      const anim = Math.floor(Math.random() * this.possibleAnims.length) + 0;
      this.playModifierAnimation(
        this.idle,
        0.25,
        this.possibleAnims[anim],
        0.25
      );
    },

    /**
     * 切换动画
     * @params form 当前执行的动画
     * @params fSpeed 当前动画过度到下一个动画的时间
     * @params to 下一个要执行的动画
     * @params tSpeed 下一个动画执行完成后回到当前动画的时间
     */
    playModifierAnimation (from, fSpeed, to, tSpeed) {
      to.setLoop(THREE.LoopOnce); // 设置只执行一次
      to.reset(); // 重置动画,保证动画从第一针开始播放
      from.crossFadeTo(to, fSpeed, true); // 进行动画过度
      to.play(); // 执行动画

      // 动画执行完成之后回到当前动画(原动画form)
      setTimeout(() => {
        to.crossFadeTo(from, tSpeed, true); // 进行动画过度
        from.enabled = true; // 当enabled被重新置为true, 动画将从当前时间(time)继续
        from.play(); // 播放原动画
        this.currentlyAnimating = false;
      }, to._clip.duration * 1000 - (tSpeed + fSpeed) * 1000);
    },

    palyAnimation (index) {
      this.playModifierAnimation(
        this.idle,
        0.25,
        this.possibleAnims[index],
        0.25
      );
    }

  }
};
</script>
<style scoped>
.wrapper {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
#c {
  position: absolute;
  top: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.buttons {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 999;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100px;
  height: 34px;
  margin-bottom: 10px;
  border-radius: 8px;
  border: 1px solid #eee;
  font-size: 16px;
  background-color: #fff;
  cursor: pointer;
}
</style>
相关推荐
花生侠19 分钟前
记录:前端项目使用pnpm+husky(v9)+commitlint,提交代码格式化校验
前端
一涯26 分钟前
Cursor操作面板改为垂直
前端
我要让全世界知道我很低调33 分钟前
记一次 Vite 下的白屏优化
前端·css
1undefined235 分钟前
element中的Table改造成虚拟列表,并封装成hooks
前端·javascript·vue.js
蓝倾1 小时前
淘宝批量获取商品SKU实战案例
前端·后端·api
comelong1 小时前
Docker容器启动postgres端口映射失败问题
前端
花海如潮淹1 小时前
硬件产品研发管理工具实战指南
前端·python
用户3802258598241 小时前
vue3源码解析:依赖收集
前端·vue.js
WaiterL1 小时前
一文读懂 MCP 与 Agent
前端·人工智能·cursor