SkinnedMesh避坑:在bind()之前,更新Bone.matrixWorld

681 阅读3分钟

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 );

会发现,渲染的结果出问题了:

example-1.PNG

一开始遇到这个问题我也很懵,但是可以从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中所有的矩阵是一样的。

  1. 初始化:可以看到这样的调用链Skeleton.constructor -> init -> calculateInverses

  2. 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,有以下两种方法:

  1. 像文档中写的,把Bone放到SkinnedMesh.children中,在SkinnedMesh.bind()中,会更新自己以及所有childrenmatrixWorld

  2. SkinnedMesh.bind()之前,显式地调用Bone.updateWorldMatrix,这种方法适用于Bone不作为SkinnedMesh的子对象时(例如,很多我们导入的带骨骼动画的模型中,BoneSkinnedMesh会作为兄弟节点放在一个Group下)