所谓骨骼动画,以人体为例简单地说,人体的骨骼运动,骨骼运动会带动肌肉和人体皮肤的空间移动和表面变化,下面将会提到的蒙皮概念你可以类比人体的皮肤。
Threejs骨骼动画需要通过骨骼网格模型类SkinnedMesh来实现,一般来说骨骼动画模型都是3D美术创建,然后程序员通过threejs引擎加载解析,为了让大家更深入理解骨骼动画,下面就通过threejs程序编写一个简易的骨骼动画。
直接使用Threejs编写一个骨骼动画还是比较复杂的,你首先应该了解骨头关节Bone、骨骼网格模型SkinnedMesh、骨架对象Skeleton这三个骨骼相关的类,除此之外还需要了解几何体Geometry和骨骼动画相关的顶点数据。
通过Bone类可以实例化一个骨关节对象,然后通过多个骨关节对象可以构成一个骨骼层级系统,Bone基类是Object3D,可以通过add方法给一个骨关节对象Bone添加一个子骨关节Bone。
var Bone1 = new THREE.Bone(); //关节1,用来作为根关节
var Bone2 = new THREE.Bone(); //关节2
var Bone3 = new THREE.Bone(); //关节3
// 设置关节父子关系 多个骨头关节构成一个树结构
Bone1.add(Bone2);
Bone2.add(Bone3);
// 设置关节之间的相对位置
//根关节Bone1默认位置是(0,0,0)
Bone2.position.y = 60; //Bone2相对父对象Bone1位置
Bone3.position.y = 40; //Bone3相对父对象Bone2位置
Threejs通过Skeleton类可以把所有骨关节对象Bone包含进来。
// 所有Bone对象插入到Skeleton中,全部设置为.bones属性的元素
var skeleton = new THREE.Skeleton([Bone1, Bone2, Bone3]); //创建骨骼系统
// 查看.bones属性中所有骨关节Bone
console.log(skeleton.bones);
// 返回所有关节的世界坐标
skeleton.bones.forEach(elem => {
console.log(elem.getWorldPosition(new THREE.Vector3()));
});
几何体Geometry的属性.skinWeights和.skinIndices主要作用是用来设置几何体的顶点位置是如何受骨关节运动影响的。比如几何体Geometry的顶点位置数据是你皮肤上的一个个点位,如果你的骨关节运动了,你的皮肤外形会跟着变化,就相当于Geometry的顶点坐标需要跟着骨关节变化,这时候需要注意,关节外面包裹的一层皮肤,不同区域变形程度不同,那也就是说如果骨关节Bone变化了,几何体Geometry顶点要像皮肤一样不同区域的顶点变化程度不同。这也正是.skinWeights和.skinIndices属性出现的原因,.skinWeights的字面意思就是设置骨骼蒙皮的权重。
SkinnedMesh类的字面意思就是骨骼网格模型,骨骼网格模型SkinnedMesh的基类是普通网格模型Mesh,SkinnedMesh和Mesh一样都是网格模型,只是一个有骨骼动画功能,一个没有骨骼动画功能。
骨骼网格模型SkinnedMesh绑定骨骼系统。
//骨骼关联网格模型
SkinnedMesh.add(Bone1); //根骨头关节添加到网格模型
SkinnedMesh.bind(skeleton); //网格模型绑定到骨骼系统
下面的的代码通过SkinnedMesh构造函数创建一个骨骼动画,如果你想深入理解骨骼动画可以研究一下下面的代码,下面代码还是比较复杂的,涉及到的知识点比较多,如果不想深入研究,可以大致看下有个印象,直接学习下一节,实际开发的时候,只需要会加载解析骨骼动画就可以。
/**
* 创建骨骼网格模型SkinnedMesh
*/
// 创建一个圆柱几何体,高度120,顶点坐标y分量范围[-60,60]
var geometry = new THREE.CylinderGeometry(5, 10, 120, 50, 300);
geometry.translate(0, 60, 0); //平移后,y分量范围[0,120]
console.log("name", geometry.vertices); //控制台查看顶点坐标
//
/**
* 设置几何体对象Geometry的蒙皮索引skinIndices、权重skinWeights属性
* 实现一个模拟腿部骨骼运动的效果
*/
//遍历几何体顶点,为每一个顶点设置蒙皮索引、权重属性
//根据y来分段,0~60一段、60~100一段、100~120一段
for (var i = 0; i < geometry.vertices.length; i++) {
var vertex = geometry.vertices[i]; //第i个顶点
if (vertex.y <= 60) {
// 设置每个顶点蒙皮索引属性 受根关节Bone1影响
geometry.skinIndices.push(new THREE.Vector4(0, 0, 0, 0));
// 设置每个顶点蒙皮权重属性
// 影响该顶点关节Bone1对应权重是1-vertex.y/60
geometry.skinWeights.push(new THREE.Vector4(1 - vertex.y / 60, 0, 0, 0));
} else if (60 < vertex.y && vertex.y <= 60 + 40) {
// Vector4(1, 0, 0, 0)表示对应顶点受关节Bone2影响
geometry.skinIndices.push(new THREE.Vector4(1, 0, 0, 0));
// 影响该顶点关节Bone2对应权重是1-(vertex.y-60)/40
geometry.skinWeights.push(new THREE.Vector4(1 - (vertex.y - 60) / 40, 0, 0, 0));
} else if (60 + 40 < vertex.y && vertex.y <= 60 + 40 + 20) {
// Vector4(2, 0, 0, 0)表示对应顶点受关节Bone3影响
geometry.skinIndices.push(new THREE.Vector4(2, 0, 0, 0));
// 影响该顶点关节Bone3对应权重是1-(vertex.y-100)/20
geometry.skinWeights.push(new THREE.Vector4(1 - (vertex.y - 100) / 20, 0, 0, 0));
}
}
// 材质对象
var material = new THREE.MeshPhongMaterial({
skinning: true, //允许蒙皮动画
// wireframe:true,
});
// 创建骨骼网格模型
var SkinnedMesh = new THREE.SkinnedMesh(geometry, material);
SkinnedMesh.position.set(50, 120, 50); //设置网格模型位置
SkinnedMesh.rotateX(Math.PI); //旋转网格模型
scene.add(SkinnedMesh); //网格模型添加到场景中
/**
* 骨骼系统
*/
var Bone1 = new THREE.Bone(); //关节1,用来作为根关节
var Bone2 = new THREE.Bone(); //关节2
var Bone3 = new THREE.Bone(); //关节3
// 设置关节父子关系 多个骨头关节构成一个树结构
Bone1.add(Bone2);
Bone2.add(Bone3);
// 设置关节之间的相对位置
//根关节Bone1默认位置是(0,0,0)
Bone2.position.y = 60; //Bone2相对父对象Bone1位置
Bone3.position.y = 40; //Bone3相对父对象Bone2位置
// 所有Bone对象插入到Skeleton中,全部设置为.bones属性的元素
var skeleton = new THREE.Skeleton([Bone1, Bone2, Bone3]); //创建骨骼系统
// console.log(skeleton.bones);
// 返回所有关节的世界坐标
// skeleton.bones.forEach(elem => {
// console.log(elem.getWorldPosition(new THREE.Vector3()));
// });
//骨骼关联网格模型
SkinnedMesh.add(Bone1); //根骨头关节添加到网格模型
SkinnedMesh.bind(skeleton); //网格模型绑定到骨骼系统
console.log(SkinnedMesh);
/**
* 骨骼辅助显示
*/
var skeletonHelper = new THREE.SkeletonHelper(SkinnedMesh);
scene.add(skeletonHelper);
// 转动关节带动骨骼网格模型出现弯曲效果 好像腿弯曲一样
skeleton.bones[1].rotation.x = 0.5;
skeleton.bones[2].rotation.x = 0.5;
通过骨骼骨骼系统代码实现骨骼动画效果。
var n = 0;
var T = 50;
var step = 0.01;
// 渲染函数
function render() {
renderer.render(scene, camera);
requestAnimationFrame(render);
n += 1;
if (n < T) {
// 改变骨关节角度
skeleton.bones[0].rotation.x = skeleton.bones[0].rotation.x - step;
skeleton.bones[1].rotation.x = skeleton.bones[1].rotation.x + step;
skeleton.bones[2].rotation.x = skeleton.bones[2].rotation.x + 2 * step;
}
if (n < 2 * T && n > T) {
skeleton.bones[0].rotation.x = skeleton.bones[0].rotation.x + step;
skeleton.bones[1].rotation.x = skeleton.bones[1].rotation.x - step;
skeleton.bones[2].rotation.x = skeleton.bones[2].rotation.x - 2 * step;
}
if (n === 2 * T) {
n = 0;
}
}
render();
var mixer = null; //声明一个混合器变量
loader.load("./marine_anims_core.json", function(obj) {
console.log(obj)
scene.add(obj); //添加到场景中
//从返回对象获得骨骼网格模型
var SkinnedMesh = obj.children[0];
//骨骼网格模型作为参数创建一个混合器
mixer = new THREE.AnimationMixer(SkinnedMesh);
// 查看骨骼网格模型的帧动画数据
// console.log(SkinnedMesh.geometry.animations)
// 解析跑步状态对应剪辑对象clip中的关键帧数据
var AnimationAction = mixer.clipAction(SkinnedMesh.geometry.animations[1]);
// 解析步行状态对应剪辑对象clip中的关键帧数据
// var AnimationAction = mixer.clipAction(SkinnedMesh.geometry.animations[3]);
AnimationAction.play();
// 骨骼辅助显示
// var skeletonHelper = new THREE.SkeletonHelper(SkinnedMesh);
// scene.add(skeletonHelper);
})
// 创建一个时钟对象Clock
var clock = new THREE.Clock();
// 渲染函数
function render() {
renderer.render(scene, camera);
requestAnimationFrame(render);
if (mixer !== null) {
//clock.getDelta()方法获得两帧的时间间隔
// 更新混合器相关的时间
mixer.update(clock.getDelta());
}
}
render();
.skinWeights表示的是几何体顶点权重数据,当使用骨骼动画网格模型SkinnedMesh的时候, 每个顶点最多可以有4个骨关节Bone影响它. skinWeights属性是一个权重值数组,对应于几何体中顶点的顺序。 例如,第一个skinWeight将对应于几何体中的第一个顶点. 由于每个顶点可以被4个骨关节Bone修改,因此使用四维向量对象Vector4表示一个顶点的权重.
四维向量Vector4每个分量的值通常应在0和1之间。当设置为0时,骨关节Bone变换将不起作用;设置为0.5时,将产生50%的影响;设置为100%时,会产生100%的影响。 如果只有一个骨关节Bone与顶点关联,那么你只需要考虑设置四维向量Vector4的第一个分量,其余分量的可以忽略并设置为0.
顶点索引属性.skinIndices
顶点索引属性.skinIndices就像skinWeights属性一样,skinIndices的值对应几何体的顶点. 每个顶点最多可以有4个与之关联的骨关节Bone。
骨骼动画的相关的一些顶点数据,主要是描述几何体表面的顶点数据是如何受骨骼系统关节运动影响的。加载外部模型你可以访问骨骼动画网格模型的几何体对象查看骨骼动画相关顶点数据。网格模型的几何体类型可能是Geometry,也可能是BufferGeometry,一般是缓冲类型的几何体BufferGeometry比较常见。
Geometry的骨骼动画顶点数据,直接查看.skinWeights和.skinIndices属性
console.log('骨骼动画皮肤顶点权重数据',SkinnedMesh.geometry.skinWeights);
console.log('骨骼动画皮肤顶点索引数据',SkinnedMesh.geometry.skinIndices);
BufferGeometry的骨骼动画顶点数据,如果你熟悉BufferGeometry的结构,应该都知道该几何体通过.attributes属性存储各种顶点数据。
console.log('骨骼动画皮肤顶点权重数据',SkinnedMesh.geometry.attributes.skinWeights);
console.log('骨骼动画皮肤顶点索引数据',SkinnedMesh.geometry.attributes.skinIndices);
// 通过加载器ObjectLoader加载./marine_anims_core.json模型文件
var loader = new THREE.ObjectLoader();
loader.load("./marine_anims_core.json", function(obj) {
console.log(obj);//浏览器控制台查看加载返回的结果
scene.add(obj); //添加到场景中
})
你可以在上面回调函数中分别插入下面代码进行测试。
//从返回对象获得骨骼网格模型
SkinnedMesh = obj.children[0];
// 查看骨头关节Bone
console.log(SkinnedMesh.skeleton.bones);
// 骨骼辅助显示
var skeletonHelper = new THREE.SkeletonHelper(SkinnedMesh);
scene.add(skeletonHelper);
// 遍历骨骼模型中的骨关节Bone,并获得世界坐标
SkinnedMesh.traverse(function(elem) {
if (elem.type === 'Bone') {
console.log(elem.getWorldPosition(new THREE.Vector3()));
}
});
var loader = new THREE.ObjectLoader(); //创建一个加载器
var mixer = null; //声明一个混合器变量
loader.load("./marine_anims_core.json", function(obj) {
scene.add(obj); //添加到场景中
//从返回对象获得骨骼网格模型
var SkinnedMesh = obj.children[0];
//骨骼网格模型作为参数创建一个混合器
mixer = new THREE.AnimationMixer(SkinnedMesh);
// 查看骨骼网格模型的帧动画数据
// console.log(SkinnedMesh.geometry.animations)
// 解析跑步状态对应剪辑对象clip中的关键帧数据
var AnimationAction = mixer.clipAction(SkinnedMesh.geometry.animations[1]);
// 解析步行状态对应剪辑对象clip中的关键帧数据
// var AnimationAction = mixer.clipAction(SkinnedMesh.geometry.animations[3]);
AnimationAction.play();
// 骨骼辅助显示
// var skeletonHelper = new THREE.SkeletonHelper(SkinnedMesh);
// scene.add(skeletonHelper);
})
// 创建一个时钟对象Clock
var clock = new THREE.Clock();
// 渲染函数
function render() {
renderer.render(scene, camera); //执行渲染操作
requestAnimationFrame(render); //请求再次执行渲染函数render,渲染下一帧
if (mixer !== null) {
//clock.getDelta()方法获得两帧的时间间隔
// 更新混合器相关的时间
mixer.update(clock.getDelta());
}
}
render();
关于变形动画,你可以理解为多组顶点数据,从一个状态变化到另一个状态,比如人的面部表情,哭的表情用一系列的顶点表示,笑的表情用一系列的顶点表示,从哭的表情过渡到笑的表情,就是表情对应的两组顶点之间的过渡,几何体的顶点的位置坐标发生变化,从一个状态过渡到另一个状态自然就产生了变形动画。
一般项目开发,变形动画和骨骼动画一样,由3D美术编辑好变形动画的模型数据,然后程序员通过Threejs相关的API解析渲染变形动画。
先获取两个变形目标box1和box2的顶点数据,然后通过几何体Geometry的变形目标属性.morphTargets设置好变形动画。
/**
* 创建网格模型,并给模型的几何体设置多个变形目标
*/
// 创建一个几何体具有8个顶点
var geometry = new THREE.BoxGeometry(50, 50, 50); //立方体几何对象
console.log(geometry.vertices);
// 为geometry提供变形目标的数据
var box1 = new THREE.BoxGeometry(100, 5, 100); //为变形目标1提供数据
var box2 = new THREE.BoxGeometry(5, 200, 5); //为变形目标2提供数据
// 设置变形目标的数据
geometry.morphTargets[0] = {name: 'target1',vertices: box1.vertices};
geometry.morphTargets[1] = {name: 'target2',vertices: box2.vertices};
var material = new THREE.MeshLambertMaterial({
morphTargets: true, //允许变形
color: 0x0000ff
}); //材质对象
var mesh = new THREE.Mesh(geometry, material); //网格模型对象
scene.add(mesh); //网格模型添加到场景中
通过上面代码把变形动画顶点数据设置好以后,如果想生成变形动画,首先要了解网格模型Mesh的.morphTargetInfluences属性。
你可以在上面代码中分别或同时插入下面代码进行测试,查看长方体网格模型mesh的外形变化。
//启用变形目标并设置变形目标影响权重,范围一般0~1
// 设置第一组顶点对几何体形状影响的变形系数
mesh.morphTargetInfluences[0] = 0.5;
// 设置第二组顶点对几何体形状影响的变形系数
mesh.morphTargetInfluences[1] = 1;
通过上面的测试,你可以发现改变morphTargetInfluences属性的值可以改变网格模型的形状,那么回忆一下第11章讲解的帧动画,你只需要控制.morphTargetInfluences的属性值生成关键帧数据,就可以实现关键帧动画,然后播放关键帧动画即可实现变形动画。
第一步先利用关键帧KeyframeTrack、剪辑AnimationClip两个类创建好帧动画。
/**
* 设置关键帧数据
*/
// 设置变形目标1对应权重随着时间的变化
var Track1 = new THREE.KeyframeTrack('.morphTargetInfluences[0]', [0,10,20], [0,1, 0]);
// 设置变形目标2对应权重随着时间的变化
var Track2 = new THREE.KeyframeTrack('.morphTargetInfluences[1]', [20,30, 40], [0, 1,0]);
// 创建一个剪辑clip对象,命名"default",持续时间40
var clip = new THREE.AnimationClip("default", 40, [Track1,Track2]);
第二步是使用混合器AnimationMixer播放设置好的关键帧动画。
/**
* 播放编辑好的关键帧数据
*/
var mixer = new THREE.AnimationMixer(mesh); //创建混合器
var AnimationAction = mixer.clipAction(clip); //返回动画操作对象
AnimationAction.timeScale = 5; //默认1,可以调节播放速度
// AnimationAction.loop = THREE.LoopOnce; //不循环播放
// AnimationAction.clampWhenFinished=true;//暂停在最后一帧播放的状态
AnimationAction.play(); //开始播放
...
...
// 创建一个时钟对象Clock
var clock = new THREE.Clock();
// 渲染函数
function render() {
renderer.render(scene, camera); //执行渲染操作
requestAnimationFrame(render); //请求再次执行渲染函数render,渲染下一帧
//clock.getDelta()方法获得两帧的时间间隔
// 更新混合器相关的时间
mixer.update(clock.getDelta());
}
render();
加载完三维模型后,通过console.log(geometry.morphTargets)可以在浏览器控制台查看模型的变形数据。
// 通过加载器JSONLoader加载变形动画模型文件./人/umich_ucs.json
var loader = new THREE.JSONLoader();
loader.load("./人/umich_ucs.json", function(geometry, materials) {
// console.log(geometry);
// console.log(materials);
// 通过平均面法线来计算顶点法线,效果更光滑
geometry.computeVertexNormals();
var mesh = new THREE.Mesh(geometry, materials[0]);
// 材质对象开启渲染目标
mesh.material.morphTargets = true
mesh.rotateX(-Math.PI / 2);
mesh.position.y = -50;
scene.add(mesh); //插入到场景中
// 查看变形目标数据
console.log(geometry.morphTargets);
})
你可以在代码中给网格模型的.morphTargetInfluences属性赋予不同的值,测试人的胖瘦变化。
// 变胖
mesh.morphTargetInfluences[0] = 1;
// 变瘦
mesh.morphTargetInfluences[4] = 1;
下面代码通过vuejs把网格模型Mesh的.morphTargetInfluences属性和滚动条进行绑定,只要滚动条改变就可以同步改变mesh.morphTargetInfluences属性值,mesh.morphTargetInfluences属性值改变,人的胖瘦自然跟着改变。
var loader = new THREE.JSONLoader(); //创建加载器
loader.load("./人/umich_ucs.json", function(geometry, materials) {
// 通过平均面法线来计算顶点法线,效果更光滑
geometry.computeVertexNormals();
var mesh = new THREE.Mesh(geometry, materials[0]);
// 材质对象开启渲染目标
mesh.material.morphTargets = true
mesh.rotateX(-Math.PI / 2);
mesh.position.y = -50;
scene.add(mesh); //插入到场景中
//实例化vue
vm = new Vue({
el: "#app",
data: {
time: 0,
},
watch: {
time: function(value) {
mesh.morphTargetInfluences[0] = value; // 变胖
// mesh.morphTargetInfluences[4] = value; // 变瘦
}
},
})
})
通过混合器AnimationMixer获取模型中的变形动画关键帧数据然后进行播放。
var loader = new THREE.JSONLoader(); //创建加载器
var mixer = null; //声明一个混合器变量
loader.load("./鸟/flamingo.json", function(geometry) {
// console.log(geometry);
var material = new THREE.MeshPhongMaterial({
morphTargets: true,
vertexColors: THREE.FaceColors,
});
// 通过平均面法线来计算顶点法线,效果更光滑
geometry.computeVertexNormals();
var mesh = new THREE.Mesh(geometry, material);
scene.add(mesh); //插入到场景中
// 创建一个混合器,播放网格模型模型的变形动画
mixer = new THREE.AnimationMixer(mesh);
// geometry.animations[0]:获得剪辑对象clip
var AnimationAction=mixer.clipAction(geometry.animations[0]);
// AnimationAction.timeScale = 0.5; //默认1,可以调节播放速度
// AnimationAction.loop = THREE.LoopOnce; //不循环播放
// AnimationAction.clampWhenFinished=true;//暂停在最后一帧播放的状态
AnimationAction.play();//播放动画
})
在渲染函数中,通过时钟类Clock获得两次渲染的时间间隔,然后执行mixer.update();传递给混合器AnimationMixer。
// 创建一个时钟对象Clock
var clock = new THREE.Clock();
// 渲染函数
function render() {
renderer.render(scene, camera); //执行渲染操作
requestAnimationFrame(render); //请求再次执行渲染函数render,渲染下一帧
//clock.getDelta()方法获得两帧的时间间隔
// 更新混合器相关的时间
mixer.update(clock.getDelta());
}
render();