SkinnedMesh避坑:在bind()之前,更新Bone.matrixWorld
在官方文档中,SkinnedMesh提供了一个例子SkinnedMesh – three.js docs (threejs.org)
可以通过控制台把源码拷贝学习。
这次我想说的代码是这一部分:
const rootBone = bones[ 0 ];
mesh.add( rootBone );
// bind the skeleton to the mesh
const skeleton = new Skeleton( bones );
mesh.bind( skeleton );
可以看到,在mesh.bind()之前,把根Bone添加到了mesh.children。
如果我们现在换一下位置:
// bind the skeleton to the mesh
const skeleton = new Skeleton( bones );
mesh.bind( skeleton );
const rootBone = bones[ 0 ];
mesh.add( rootBone );
会发现,渲染的结果出问题了:
一开始遇到这个问题我也很懵,但是可以从SkeletonHelper找到突破口。
先说一下SkeletonHelper的原理:把所有的Bone对象按照父子关系连接起来即可。
因为只是连线,所以SkeletonHelper的图元是线段:class SkeletonHelper extends LineSegments
// SkeletonHelper.js
updateMatrixWorld(force) {
const bones = this.bones; // 骨骼节点的集合
const geometry = this.geometry;
const position = geometry.getAttribute('position'); // 所有线段的顶点的位置
_matrixWorldInv.copy(this.root.matrixWorld).invert();
// root对象是创建SkeletonHelper是,传入的对象,一般为SkinnedMesh对象
for (let i = 0, j = 0; i < bones.length; i++) {
const bone = bones[i];
// 以Bone和它的父Bone为端点 画线段
if (bone.parent && bone.parent.isBone) {
// 把Bone在世界坐标中的位置 变换到root对象的局部空间
_boneMatrix.multiplyMatrices(_matrixWorldInv, bone.matrixWorld);
_vector.setFromMatrixPosition(_boneMatrix);
// 将bone所在的位置设置为线段的一个端点
position.setXYZ(j, _vector.x, _vector.y, _vector.z);
// 与bone同样的逻辑 获取bone.parent的位置
_boneMatrix.multiplyMatrices(_matrixWorldInv, bone.parent.matrixWorld);
_vector.setFromMatrixPosition(_boneMatrix);
// 另一个端点
position.setXYZ(j + 1, _vector.x, _vector.y, _vector.z);
j += 2;
}
}
geometry.getAttribute('position').needsUpdate = true;
super.updateMatrixWorld(force);
}
可以看到,SkeletonHelper通过把父Bone和子Bone用线段连起来,从而画出Skeleton。当调用,Skeleton.pose()方法之后,没有线段被画出来,造成这样的结果,是因为:所有的Bone的位置重合了。
在我的前一篇博客中,介绍了Skeleton.pose()的源码,读者可以移步阅读骨骼原理及源码 - 掘金 (juejin.cn)
既然所有的Bone重合,就说明Bone.matrixWorld被设置成值一样的矩阵,而Skeleton.pose()方法设置这个矩阵时,依赖Skeleton.boneInverses属性。
这里节选出这部分的代码:
// Skeleton.js
pose() {
// recover the bind-time world matrices
for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
const bone = this.bones[ i ];
if ( bone ) {
bone.matrixWorld.copy( this.boneInverses[ i ] ).invert();
}
}
//...
}
也就是说,导致所有Bone.matrixWorld一样的原因,是Skeleton.boneInverses中所有的矩阵是一样的。
-
初始化:可以看到这样的调用链
Skeleton.constructor -> init -> calculateInverses -
SkinnedMesh.bind -> skeleton.calculateInverses
来看一下calculateInverses的代码,
calculateInverses() {
this.boneInverses.length = 0;
for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
const inverse = new Matrix4();
if ( this.bones[ i ] ) {
inverse.copy( this.bones[ i ].matrixWorld ).invert();
}
this.boneInverses.push( inverse );
}
}
很显然,就是把每个Bone.matrixWorld求了一个逆矩阵,结果这些Bone.matrixWorld的逆矩阵是一样的。
但为什么他们的matrixWorld会一样呢?他们不应该一样!!!
答案是:虽然我们在创建Bone时,调用Bone.add()方法,把所有Bone组织成了树形结构,但是,每个Bone.matrixWorld还没有更新。
也就是说,在Skeleton.calculateInverses()之前,要更新Bone.matrixWorld,有以下两种方法:
-
像文档中写的,把
Bone放到SkinnedMesh.children中,在SkinnedMesh.bind()中,会更新自己以及所有children的matrixWorld -
在
SkinnedMesh.bind()之前,显式地调用Bone.updateWorldMatrix,这种方法适用于Bone不作为SkinnedMesh的子对象时(例如,很多我们导入的带骨骼动画的模型中,Bone和SkinnedMesh会作为兄弟节点放在一个Group下)