写在开头
- 文章仅介绍整个实现逻辑,不包含一些细节的优化,demo代码中仍有许多值得优化的点,会在文章结果总结部分。
- 文章不涉及人物碰撞检测等其他人物运动相关内容
- 模型动画仅两种,站立和走动。没有去找后退,向左向右的动画,但动画状态机有简单实现,可以将对应动画进行替换。PS:adobe的动作库里可以去找找mixamo
正文
效果展示
人物运动
本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轴上分别需要移动多少距离
第三人称相机情况和使用控制器
第三人称主要有两个相机交互情况:
- 相机围绕人物旋转观察
- 人物运动时相机跟着运动
所以可以直接使用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)的聚焦点设置到了人物的位置
人物转向
初始化的时候,人物其实是正对着摄像头的,而在运动的时候,是背对着摄像头,有一个前进的效果的。这里对人物做了一个旋转的操作
// 选择人物方向
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
接口带有动画信息
虽然看起来是个数组,但是缺少了逗号
,
和开头结尾的[]
。需要处理一下。然后要转变成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)
}
值得注意的是,你必须要选择你要用的模型然后去切换动画获取动画的数据流,不然的话骨骼信息是对不上的。
写在最后
- git地址gitee
- 整体实现比较简单,没有考虑很多交互,比如跳跃过程中不能移动,移动的切换不是特别协调,动画切换也没有过度等等。
- 代码上也有很多性能点可以优化,比如说人物转向的方法的触发次数,键盘按键的触发次数问题。
- 这些问题在这个demo中不会去解决,仅作为一个实现逻辑的参考。
- git项目中还含有其他一些three的demo,可能在后续会更新对应文章。