Three实现第三视角控制移动人物

1,713 阅读2分钟

写在开头

  1. 文章仅介绍整个实现逻辑,不包含一些细节的优化,demo代码中仍有许多值得优化的点,会在文章结果总结部分。
  2. 文章不涉及人物碰撞检测等其他人物运动相关内容
  3. 模型动画仅两种,站立和走动。没有去找后退,向左向右的动画,但动画状态机有简单实现,可以将对应动画进行替换。PS:adobe的动作库里可以去找找mixamo

正文

效果展示

Jul-10-2022 17-08-34.gif

人物运动

本demo中仅支持了wasd的运动方式,所以人物运动就是对键盘按键的监听

document.addEventListener('keydown', keyMove)
document.addEventListener('keyup', keyUp)
function keyUp(ev) {
  if (moveState.getStatus() !== 'jump') {
    moveState.changeStatus('stand')
  }
}
  // 键盘监听
  function keyMove(ev) {
    if (moveState.getStatus() === 'jump') return
    switch(ev.keyCode) {
      case 65:
        moveState.changeStatus('left')
        moveBox()
        break
      case 87:
        moveState.changeStatus('forward')
        moveBox()
        break
      case 68:
        moveState.changeStatus('right')
        moveBox()
        break
      case 83:
        moveState.changeStatus('back')
        moveBox()
        break
      case 32:
        moveState.changeStatus('jump')
        jump()
      default:
        return;
    }
  }

其中moveBox和jump的实现逻辑如下,moveState是个什么东西会在后续中讲到

// 跳跃
function jump() {
  // 跳跃的最大高度
  const max = 100
  // 初始高度
  const initY = model.position.y
  // 是否在下坠
  let down = false
  // 递增和递减系数
  let t = 1
  const x = 0.6
  // 跳跃
  let interval = setInterval(() => {
    const downNumber = down ? -1 : 1
    model.position.y += 3 * downNumber * t
    camera.position.y += 3 * downNumber * t
    t += downNumber * x
    // 到最高点开始下坠
    if (model.position.y >= max) {
      down = true
    }
    // 到最低点结束跳跃
    if (model.position.y <= initY && down) {
      model.position.y = initY
      moveState.changeStatus('stand')
      clearInterval(interval)
    }
    setControl(...model.position)
  }, 30)
}

// 移动物体
function moveBox() {
  // 获取人物中心点和相机中心点
  const p1 = model.position
  const p2 = camera.position
  // 计算两者连接形成的向量
  const v1 = p1.clone().sub(p2)
  // 去掉y轴,变成xz的两位向量
  v1.y = 0
  const length = v1.length()
  // 获取垂直向量
  const v2 = new THREE.Vector3(v1.z, 0, -v1.x)
  // 移动的方向
  let dir = 1
  let v = v1
  switch(moveState.getStatus()) {
    case 'forward': 
      dir = 1
      break
    case 'left':
      dir = 1
      v = v2
      break
    case 'right':
      dir = -1
      v = v2
      break
    case 'back': 
      dir = -1
      break
    default:
      return;
  }
  // 移动位置
  for (const key in v) {
    if (key === 'y') continue
    const temp =  dir * step * v[key] / length
    model.position[key] += temp
    camera.position[key] += temp
  }
  setControl(...model.position)
}

都是做了比较潦草的运动规则,jump为设置了一个定时器去修改model位置的y值。

moveBox为计算了相机和人物模型两点组成的向量,并且计算了与其垂直的一个向量(都去掉了y轴,因为不做y轴上的运动)用于左右移动 然后去计算在x和z轴上分别需要移动多少距离

第三人称相机情况和使用控制器

第三人称主要有两个相机交互情况:

  1. 相机围绕人物旋转观察
  2. 人物运动时相机跟着运动

所以可以直接使用OrbitControls控制器(可以使得相机围绕目标进行轨道运动)来完成第一种情况。 第二种情况可以让相机位置和控制器的聚焦点随着人物同步运动。

  ...  
    for (const key in v) {
      if (key === 'y') continue
      const temp =  dir * step * v[key] / length
      model.position[key] += temp
      camera.position[key] += temp
    }
    setControl(...model.position)
  }
  // 设置相机位置
  function setControl(x,y,z) {
    rotateModel()
    controls.target.set( x,y,z )
    controls.update()
  }

只拿了一部分的代码,可以看到在设置完人物(model)的位置后对相机(camera)的位置也进行了设置,并且将控制器(control)的聚焦点设置到了人物的位置

人物转向

WeChat62a45df0aaed621f9567210bc2ebef6c.png

初始化的时候,人物其实是正对着摄像头的,而在运动的时候,是背对着摄像头,有一个前进的效果的。这里对人物做了一个旋转的操作

  // 选择人物方向
  function rotateModel() {
    // 获取人物中心点和相机中心点
    const p1 = model.position
    const p2 = camera.position
    // 计算两者连接形成的向量
    const v1 = p1.clone().sub(p2)
    v1.y = 0
    // 人物的初始面向
    const origin = new THREE.Vector3(0,0,1)
    // 点乘求夹角
    const radian = Math.acos(v1.dot( origin ) /(v1.length()*origin.length()))
    // 叉乘求方向
    v1.cross(origin)
    model.rotation.y = radian * (v1.z === 0 && 1 / v1.z < 0 ? -1 : 1)
  }

动画状态机

  class MoveState {
    animationsStatus = {
      stand: 'static',
      forward: 'move',
      back: 'move',
      left: 'move',
      right: 'move',
      jump: 'move',
    }
    statuses = Object.keys(this.animationsStatus)
    constructor(mixer, animations) {
      this.status = 'stand'
      this.mixer = mixer

      this.animations = animations
      this.playAnimation()
    }

    getStatus() {
      return this.status
    }

    playAnimation() {
      this.mixer.stopAllAction()
      this.animations[this.animationsStatus[this.status]].play()
    }

    changeStatus(status) {
      if (this.status === status || !this.statuses.includes(status)) return
      this.status = status
      this.playAnimation()
    }
  }

实现的比较简单,主要逻辑为由状态机去控制人物当前状态,当人物状态改变时,去触发对应需要执行的动画。(可以看到除了stand状态,其他状态都用了同一个移动动画,因为比较懒,没有去找对应的动画。

在mixamo爬取动画

Three的loader会把模型输出成一个对象,其中包含了很多属性,animations是指改模型中含有的动画,可能有一个或者多个.

在mixamo中导出的模型一般只包含一个动画.但我们没必要为了多个动画,导出多个模型,然后在three中导入多个模型去提取动画.

mixamo中api/v1/animations/stream接口带有动画信息

WeChat0033d05ceafbf951abc0d3fc9d3d848f.png 虽然看起来是个数组,但是缺少了逗号,和开头结尾的[]。需要处理一下。然后要转变成three可用的动画格式,我写了两个函数去处理,在js/animation.js中也可以找到.

function handleAnimationData(a) {
  const b = a.shift()
  const c = a.pop()
  const times = a.map(item => item.time)
  const values = a.reduce((acc, item) => {
    b.frame_descriptor.forEach(bone => {
      const data = item.data
      const index = bone.offset
      if (bone.ch === 'pos') {
        acc[bone.node + '-pos'] ||= []
        acc[bone.node + '-pos'].push(...data.slice(index, index + 3))
      } else {
        acc[bone.node] ||= []
        acc[bone.node].push(...data.slice(index, index + 4))
      }
    })
    return acc
  }, {})
  return { times, values }
}

function createAnimation({ times, values }, name = 'test') { // 生成动画
  const tracks = []
  for (const key in values) {
    if (key.includes('-pos')) {
      tracks.push(new THREE.KeyframeTrack(key.split(':').join('').slice(0, -4) + '.position', new Float32Array(times), new Float32Array(values[key])))
    } else {
      tracks.push(new THREE.KeyframeTrack(key.split(':').join('') + '.quaternion', new Float32Array(times), new Float32Array(values[key])))
    }
  }
  return new THREE.AnimationClip(name, times[times.length - 1], tracks)
}

值得注意的是,你必须要选择你要用的模型然后去切换动画获取动画的数据流,不然的话骨骼信息是对不上的。

写在最后

  1. git地址gitee
  2. 整体实现比较简单,没有考虑很多交互,比如跳跃过程中不能移动,移动的切换不是特别协调,动画切换也没有过度等等。
  3. 代码上也有很多性能点可以优化,比如说人物转向的方法的触发次数,键盘按键的触发次数问题。
  4. 这些问题在这个demo中不会去解决,仅作为一个实现逻辑的参考。
  5. git项目中还含有其他一些three的demo,可能在后续会更新对应文章。