vlambda博客
学习文章列表

骨骼动画—从基础建模到Threejs渲染

导语

骨骼动画作为一种常见的模型动画,在公司的很多 3d 场景都有涉及。本文会介绍用建模软件制作骨骼动画到 web 渲染整个流程,以及骨骼动画的原理。


01.

 基本介绍

骨骼动画是模型动画中的一种。在骨骼动画中,模型具有互相连接的“骨骼”组成的骨架结构,通过改变骨骼的朝向和位置来为模型生成动画。


简言之,就是用骨骼的变化带动相关顶点的变化。效果如下:

上面小人的走路动画就是身上细细的骨骼带动的

02.

用建模软件制作骨骼动画并在web渲染

1

blender建模

市面上的建模软件很多,本文采用免费的blender。


所谓建模,就是建立模型。这一块通常由专业的建模师完成。本文的模型比较简单,用 6 个立方体拼接成一个方块人。

骨骼动画—从基础建模到Threejs渲染

2

制作骨骼

建模软件中骨骼也是一个基本元素,和添加立方体一样,我们也可以手动添加骨骼。如下图就生成了四个骨骼。

骨骼动画—从基础建模到Threejs渲染

之前说到,骨骼动画是由骨骼产生的变化。所以骨骼的变换类型决定了动画的变换类型。而一般建模软件里的骨骼,都具有平移,缩放,旋转三种变换,正好和动画的基础变换对应上。

下图演示骨骼旋转:

骨骼动画—从基础建模到Threejs渲染

此时骨骼的运动并没有带动腿部顶点的运动,因为我们还没有将骨骼与顶点绑定。

3

绑定骨骼

通过选中骨骼和顶点,可以将骨骼和顶点绑定到一起。同时可以设置骨骼影响某个顶点的程度,即权重。下图红色部门表示被骨骼剧烈影响的部分,蓝色表示不会被骨骼影响的部分:

骨骼动画—从基础建模到Threejs渲染

绑骨之后的效果:

骨骼动画—从基础建模到Threejs渲染

4

制作骨骼动画

动画就是在物体的基础变换(平移,缩放和旋转)上引入时间,从而产生动态效果。


通常我们只需要在建模软件上插入几个关键帧,它就会帮我们自动线性补全其余的动画帧。


比如我在第 1 帧放入一个向前踢腿的关键帧,在第 60 帧放入一个后踢的关键帧,Blender 就会自动帮我补全其他的动画帧。


第1帧:

骨骼动画—从基础建模到Threejs渲染

第60帧:

骨骼动画—从基础建模到Threejs渲染

自动补帧之后的动画:

骨骼动画—从基础建模到Threejs渲染

5

在threejs中渲染

将模型导出为 gltf 文件,并用 threejs 自带的 GLTFLoader 加载渲染。

import { OrbitControls } from './three/orbitcontrols.js';

import './three/GLTFLoader.js';

 

let domElement = document.getElementById("avatarDom");

let canvasW = domElement.clientWidth;

let canvasH = domElement.clientHeight;

 

let renderer = new THREE.WebGLRenderer();

domElement.appendChild(renderer.domElement);

 

renderer.setSize(canvasW,canvasH);

 

let scene = new THREE.Scene();

 

var camera = new THREE.PerspectiveCamera(45, 500 / 500, 1, 1000);

camera.position.set(5,5, 10);

camera.lookAt(scene.position);

camera.aspect= canvasW / canvasH;

camera.updateProjectionMatrix();

scene.add(camera);

scene.add(new THREE.AmbientLight(0x333333));

 

const controls = new OrbitControls(camera, renderer.domElement);

controls.update();

 

const axisHelper = new THREE.AxisHelper(100);

scene.add(axisHelper);

 

let clock = new THREE.Clock();

let mixer,animationClip,clipAction = null;

var loader = new THREE.GLTFLoader();

loader.load('human.gltf',function (result) {

  scene.add(result.scene);

 

  mixer = new THREE.AnimationMixer(result.scene );

  animationClip = result.animations[0];

  clipAction = mixer.clipAction( animationClip).play();   

  animationClip = clipAction.getClip();

 

});

 

function render() {

  var delta = clock.getDelta();

  requestAnimationFrame(render);

  renderer.render(scene, camera)

 

  if (mixer && clipAction) {

    mixer.update( delta );

  }

}

render();

渲染效果:

骨骼动画—从基础建模到Threejs渲染

03纯用代码创建一个骨骼动画

为了了解 Threejs 是如何处理骨骼的,我们尝试用程序直接生成一个骨骼模型。


Threejs 中用 SkinnedMesh 来管理骨骼模型,其实就是多了骨骼数据的网格。SkinnedMesh 比 Mesh 多了两个数据:


  • geometry.skinWeights:表示几何体顶点所关联骨骼的权重。skinWeights 属性是一个权重值数组,对应于几何体中顶点的顺序。例如,第一个skinWeight 将对应于几何体的第一个顶点。由于每个顶点可以被 4 个骨骼 Bone 修改,因此用 Vector4 表示作用于该顶点的四个骨骼的权重 weights

  • geometry.skinIndices:对应于几何体顶点关联的骨骼索引。每个顶点最多可以有4个与之关联的骨骼。因此,如果查看第一个顶点和第一个骨骼索引skinIndex,它将告诉您与该顶点关联的骨骼。例如,第一个顶点坐标( 10.05,30.10, 12.12 ), 第一个骨骼索引 skin index值 是( 10, 2, 0, 0 ),第一个皮肤权重 skin weight 值是( 0.8, 0.2, 0, 0 ),表达的意思是骨骼skeleton.bones[10] 对第一个顶点坐标影响权重 80%,骨骼 skeleton.bones[2] 对第一个顶点的影响权重 20%。接下来的两个骨骼权重值的权重为 0,因此对顶点坐标没有任何影响.


以下用圆台 + 三个骨头模拟腿部运动:

import { OrbitControls } from './three/orbitcontrols.js';

 

let domElement = document.getElementById("avatarDom");

let canvasW = domElement.clientWidth;

let canvasH = domElement.clientHeight;

 

let renderer = new THREE.WebGLRenderer();

domElement.appendChild(renderer.domElement);

 

renderer.setSize(canvasW,canvasH);

 

let scene = new THREE.Scene();

 

var camera = new THREE.PerspectiveCamera(45, 500 / 500, 1, 2000);

camera.position.z= 400;

camera.lookAt(scene.position);

camera.aspect= canvasW / canvasH;

camera.updateProjectionMatrix();

scene.add(camera);

scene.background= new THREE.Color(0xa0a0a0);

 

const controls = new OrbitControls(camera, renderer.domElement);

controls.target.set(0,25, 0);

controls.update();

 

const axisHelper = new THREE.AxisHelper(100);

scene.add(axisHelper);

 

/**

 * 创建骨骼网格模型SkinnedMesh
 */
//创建一个圆台几何体,高度120,顶点坐标y分量范围[-60,60]

let 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(let i = 0; i < geometry.vertices.length; i++) {

  let vertex = geometry.vertices[i]; //第i个顶点

  if (vertex.y <= 60) {

    // 设置每个顶点蒙皮索引属性  受根关节Bone1(0)影响

    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('Bone1:', Bone1);

 

//骨骼关联网格模型

SkinnedMesh.add(Bone1);//根骨头关节添加到网格模型

SkinnedMesh.bind(skeleton);//网格模型绑定到骨骼系统

console.log('SkinnedMesh:', SkinnedMesh);

/**

 * 骨骼辅助显示

 */

var skeletonHelper = new THREE.SkeletonHelper(SkinnedMesh);

scene.add(skeletonHelper);

 

//转动关节带动骨骼网格模型出现弯曲效果  好像腿弯曲一样

skeleton.bones[1].rotation.x= 0.5;

skeleton.bones[2].rotation.x= 0.5;

 

//渲染函数

function render() {

  renderer.render(scene, camera);

  requestAnimationFrame(render);

}

render();

渲染效果:

骨骼动画—从基础建模到Threejs渲染

04.

骨骼动画的原理

在 Threejs 中每个顶点受 4 个骨头控制,且各自有权重。所以,一个顶点的变化 = weight[0]*bone[0] + weight[1]*bone[1] + weight[2]*bone[2] +weight[3]*bone[3]


而图形学中,刻画物体变化方式通常都是矩阵,所以我们猜测这个 Bone 是一个矩阵。


打印一下这个 Bone 对象,发现确实有一个 matrix 属性表示一个4 * 4的矩阵。

骨骼动画—从基础建模到Threejs渲染

当我们向 x 轴正方向平移 Bone[0] 时,会带动整个物体移动,同时 matrix 由单位矩阵变为平移矩阵。


ps: Threejs 其实是 webgl 的一层封装,而 webgl 存储矩阵采用的是列主序,所以此时的平移矩阵为

综上,骨骼其实就是一个4 * 4的齐次矩阵,该矩阵包含了平移,缩放和旋转变换。

附录

演示代码:https://github.com/Zack921/bone-animation

Blender教学:https://www.bilibili.com/video/BV1WW411v7Ji?t=571

Three.js骨骼动画:http://www.yanhuangxueyuan.com/doc/Three.js/SkinnedMesh.html