ThreeJs 矩阵和转动动画细节

Three 中矩阵的计算和应用过程

来源: Day4: Three.js 什麼!空間被扭曲了?我願稱你為最強——矩陣

https://codepen.io/umas-sunavan/pen/JjvNYwY

下面两段代码有什么区别?

// cube.matrix 为单位阵
const translationMatrix = new THREE.Matrix4().makeTranslation(5,0,0)
const scaleMatrix = new THREE.Matrix4().makeScale(2,1,1)
cube.applyMatrix4(translationMatrix.multiply(scaleMatrix))

// 结果:cube.matrix.elements = [2,0,0,0,0,1,0,0,0,0,1,0,5,0,0,1]
// 即数学上:|2|0|0|5|
//          |0|1|0|0|
//          |0|0|1|0|
//          |0|0|0|1|
// cube.matrix 为单位阵
const translationMatrix = new THREE.Matrix4().makeTranslation(5,0,0)
const scaleMatrix = new THREE.Matrix4().makeScale(2,1,1)
cube.applyMatrix4(translationMatrix)
cube.applyMatrix4(scaleMatrix)

// 结果:cube.matrix.elements = [2,0,0,0,0,1,0,0,0,0,1,0,10,0,0,1]
// 即数学上:|2|0|0|10|
//          |0|1|0| 0|
//          |0|0|1| 0|
//          |0|0|0| 1|

问题 1:效果为什么不同?applyMatrix4()做了什么?

因为运算顺序不同导致结果不同。Object3D.applyMatrix4()方法是这样实现的:

// github.com/mrdoob/three.js/blob/64c8d59ac9f57ac6e8737d32f01340ec304c5364/src/core/Object3D.js#L124
applyMatrix4( matrix ) {
        if ( this.matrixAutoUpdate ) this.updateMatrix();
        this.matrix.premultiply( matrix );
        this.matrix.decompose( this.position, this.quaternion, this.scale );
    }

注意:applyMatrix4() 是左乘矩阵

所以上述两个代码区别在于:

  • 第一个是 C = (T·S)·C = T·S·C
  • 第二个是 C = S·(T·C) = S·T·C

矩阵乘法不支持交换律,所以结果完全不同。

问题 2:为什么是左乘?(矩阵主序问题)

这个问题可以直接看这篇文章:WebGL 矩阵 vs 数学中的矩阵

数学上的 4 维矩阵通常使用行主序表示和参与运算,但 OpenGL 使用列主序。

数学上变换矩阵习惯写为:

为便于观察,我们表示为(A 角标矩阵)

|11|12|13|14|
|21|22|23|24|
|31|32|33|34|
|41|42|43|44|

Threejs 中是这样声明:

const m = new THREE.Matrix4();
m.set(11, 12, 13, 14,
      21, 22, 23, 24,
      31, 32, 33, 34,
      41, 42, 43, 44)
// 或通过行主序数组
m.fromArray([11, 12, 13, 14,
             21, 22, 23, 24,
             31, 32, 33, 34,
             41, 42, 43, 44],0)

// 存储结果是列主序的:
console.log(m.elements)
// [11,12,13,14,21,22,23,24,31,32,33,34,41,42,43,44]

所以根据矩阵转置乘法法则:(AB)T = BTAT

即数学上: C' = C(SRT)

OpenGL 上: C'T = (SRT)TCT = TTRTSTCT

即 C' = (TRS)·C 写成代码就是

// 缩放旋转平移
cube.applyMatrix4( new THREE.matrix4().multiply(translationM).multiply(rotaMZ).multiply(scaleM) )

Three 结合 Tween 的简单动画

以下所有代码中,cube 代表被运动的物体

平移有几种写法?

const gclock = new THREE.Clock();
function animate() {
  let t = gclock.getDelta() // v 是单位时间
  // case 1: 设置cube的position
  let pos = new THREE.Vector3(t,0,0)
  cube.position.set(...pos)

  // case 2: 沿特定轴平移,对Object3D本身操作
  cube.translateOnAxis(new THREE.Vector3(12,12,12).normalize(),t)

  // case 3: 先对空矩阵设定makeTranslation,连乘组成TRS,对cube应用TRS变换。
  //         每个t都对起始点变换,变化值不累加
  let Tm = new THREE.Matrix4().makeTranslation(v, 0, 0) // Tm 矩阵表示单次偏移矩阵变换,也就是每个t都会变换。
  let TRS = new THREE.Matrix4().multiply(Tm) // 单次变化值
  cube.applyMatrix4(TRS)

  // bad case 3: 每次修改了cube.matrix再做multiply,变化值累加,平移运动随时间越来越快。
  let Tm = new THREE.Matrix4().makeTranslation(v, 0, 0)
  let mat = new THREE.Matrix4().copy(cube.matrix).multiply(Tm) // 累计值
  cube.applyMatrix4(mat)
}

旋转有几种写法?

原点出发的轴线:

// 物体绕设定轴公转运动,设定轴是从原点出发指空间中某点。
const gclock = new THREE.Clock();
function animate() {
  let v = gclock.getDelta()

  let specAxes = new THREE.Vector3(12,12,12) // 旋转轴
  let Rm = new THREE.Matrix4().makeRotationAxis(specAxes.normalize(),v * Math.PI / 20)
  cube.applyMatrix4(Rm)
}

任意轴线:先平移再旋转再平移逆变换

// cube位置为 (3, 0, 0),以下函数让cube绕(1.5, 0, 0)公转。
// 做法是让旋转中心平移至(1.5, 0, 0),再进行旋转,再乘平移逆矩阵
cube.position.set(3, 0, 0)
const gclock = new THREE.Clock();
function animate() {
  let v = gclock.getDelta()

  let Tm = new THREE.Matrix4().makeTranslation(1.5, 0, 0) // 平移
  let Rm = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0,1,0).normalize(),v * Math.PI / 5)
  let TRS = new THREE.Matrix4().multiply(Tm).multiply(Rm).multiply(Tm.invert()) // 单次变化值
  cube.applyMatrix4(TRS)
}

和以上效果完全相同的实现方式:

// cube位置为 (3, 0, 0),以下函数让cube绕(1.5, 0, 0)公转。
// 做法是让cube中心平移至(1.5, 0, 0),再进行旋转,再乘平移逆矩阵
cube.position.set(3, 0, 0)
const gclock = new THREE.Clock();
function animate() {
  let v = gclock.getDelta()
  // 平移
  let Tm = new THREE.Matrix4().makeTranslation(-1.5, 0, 0) // 以cube为基准,轴心向-x方向平移1.5
  let Rm = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0,1,0).normalize(),v * Math.PI / 5)
  let TRS =  new THREE.Matrix4().premultiply(Tm).premultiply(Rm).premultiply(Tm.invert())
  cube.applyMatrix4(TRS)
}

目前看来第一种 mutiply 实现方式似乎更符合人的直觉,相当于对旋转轴进行变换

  1. (旋转轴 0,1,0) 平移变换 (至 1.5, 0, 0)
  2. 旋转变换
  3. 平移变换逆矩阵

TRS = T·R·T-1

这几个动画方式有什么区别?(单位量还是累积量)

【错误写法】

// 这个写法不能实现连续自转
const gclock = new THREE.Clock();
function animate() {
  let v = gclock.getDelta() // v是单位值,每次执行animate都是0.1s左右
  let matrix = new THREE.Matrix4().copy(cube.matrix)
  matrix.makeRotationY(Math.PI / 10 * v); // 注意:makeRotationY在累计值才有效果,每次只转一单位效果不累加。
  cube.setRotationFromMatrix(matrix)
}

function render() {
  if (!renderer) return;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
  animate()
}

【正确写法】

// 这个写法可以实现连续公转(无自转)。公转速度为 Math.PI / 10 * v
// 注意这个例子与上一个只有一行不同。
const gclock = new THREE.Clock();
function animate() {
  let v = gclock.getDelta() // v是单位值,每次执行animate都是0.1s左右
  let matrix = new THREE.Matrix4().copy(cube.matrix)
  matrix.makeRotationY(Math.PI / 10 * v); // 注意:makeRotationY在累计值才有效果,每次只转一单位效果不累加。
  cube.applyMatrix4(matrix)
}

function render() {
  if (!renderer) return;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
  animate()
}

【正确写法】

// 这个写法可以实现 1000ms 内自转 π/2 弧度。
let progress = { value: 0 }
new TWEEN.Tween(progress)
  .to({ value: 1 },1000)
  .easing(TWEEN.Easing.Linear.None)
  .onUpdate((progress) => {
    const v = progress.value  // 此处v是进度值,从0至1
    // matrix 实现 cube 自转
    let matrix = new THREE.Matrix4()
    matrix.makeRotationY(Math.PI / 2 * v);
    cube.setRotationFromMatrix(matrix)
  }).start()

function render() {
  if (!renderer) return;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
  TWEEN.update()
}

这个写法可以在绕(0,1,0)旋转同时自转面向原来的方向

function animate() {
  let v = gclock.getDelta()

  let matrix = new THREE.Matrix4().copy(cube.matrix)
  matrix.makeRotationY(Math.PI / 10 * v);
  cube.setRotationFromMatrix(matrix)
  cube.applyMatrix4(matrix) // 但如果去掉这行看不出任何效果
}

复合运动

// 两个平移叠加
function animate() {
  let v = gclock.getDelta()

  let T1 = new THREE.Matrix4().makeTranslation(v, 0, 0)
  let T2 = new THREE.Matrix4().makeTranslation(0, v, 0)
  let M = new THREE.Matrix4()
  M.multiplyMatrices(T2, T1)
  cube.applyMatrix4(M)
}

参考资料

Three.js 坐标系与变换矩阵 - 知乎

Threejs实现绕指定点/物体旋转 - CSDN 博客

// 世界平移-自身旋转-世界平移的逆(表现为原地旋转)
    public worldTranslation_SelfRotate() {
        let worldPosZero = this.getWorldPosition(this.cubeArr[0]);//本质上就是Object3D里的getWorldPosition() 获取世界坐标
        let worldPosOne = this.getWorldPosition(this.cubeArr[1]);
        let worldOffset = worldPosZero.sub(worldPosOne);
        let goZeroMatrix = new Matrix4().makeTranslation(worldOffset.x, worldOffset.y, worldOffset.z);
        this.cubeArr[1].matrix.premultiply(goZeroMatrix);

        let angle = 30 * Math.PI / 180;
        let absAngle = Math.abs(angle);
        this.cubeArr[1].matrix.multiply(new THREE.Matrix4().makeRotationZ(absAngle));

        let mat4I = new THREE.Matrix4();
        mat4I.copy(goZeroMatrix).invert();// mat4I.getInverse(goZeroMatrix);getInverse函数已弃用
        this.cubeArr[1].matrix.premultiply(mat4I);
    }

    // 自身平移-自身旋转-自身平移的逆(表现为绕轴旋转)
    public SelfTranslation_SelfRotate() {
        let worldPosZero = this.getWorldPosition(this.cubeArr[0]);
        let worldPosOne = this.getWorldPosition(this.cubeArr[1]);
        let worldOffset = worldPosZero.sub(worldPosOne);
        // 世界空间转局部空间
        let itemBaseVec = this.getBasisVec(this.cubeArr[1].matrix);//本质上就是extractBasis() 获取基坐标系
        let localOffset = this.vectorChangBasic(worldOffset,itemBaseVec);

        let goZeroMatrix = new Matrix4().makeTranslation(localOffset.x, localOffset.y, localOffset.z);
        this.cubeArr[1].matrix.multiply(goZeroMatrix);

        let angle = 30 * Math.PI / 180;
        let absAngle = Math.abs(angle);
        this.cubeArr[1].matrix.multiply(new THREE.Matrix4().makeRotationZ(absAngle));


        let mat4I = new THREE.Matrix4();
        mat4I.copy(goZeroMatrix).invert();// mat4I.getInverse(goZeroMatrix);getInverse函数已弃用
        this.cubeArr[1].matrix.multiply(mat4I);
    }

    private vectorChangBasic(needChangeVec: Vector3, baseVec: baseVectorObj) {
        // 世界空间的相对位置,转成基于baseVec坐标系下的位置
        let newVec: Vector3 = new Vector3();
        newVec.x = needChangeVec.dot(baseVec.x);
        newVec.y = needChangeVec.dot(baseVec.y);
        newVec.z = needChangeVec.dot(baseVec.z);
        return newVec;
    }