three.js中常用的数学操作

2,842 阅读17分钟

要灵活运用threejs,除了要了解其oop特性,也需要熟悉其数学库。 正因为three有一套完整的数学库,所以,其实我们并不需要会很多数学,只需要会普通的四则运算和调用它的api即可。 本文就是教大家如何使用其数学库,不会涉及很多数学,因为鄙人也不会。

向量

所谓向量,就是除了大小,它还有具有方向,这这两个要素构成了一个向量。

image.png

向量这个类可以说是我们最常用的了。 细分的话有二维、三维、四维 。 为什么没有一维向量,因为普通数字即一维向量。 除了四维,向量都可以放到几何空间里去理解。

一维向量表示数轴上的点的坐标,二维向量可以表示平面中一个点的坐标, 三维向量可以表示立体空间中的一点的坐标。 以上所说,都是建立在直角坐标系的前提下, 至于四维向量,在我们这里是齐次坐标,不会扯上时间,有兴趣的可以去了解一下齐次坐标。

可以看到向量的默认参数几乎都是0 ,除了四维向量的w分量是1。

    const v2 = new Vector2()
    const v3 = new Vector3(1)
    const v4 = new Vector4(1,2,)
    console.log(v2,v3,...v4)
//Vector2 {x: 0, y: 0} Vector3 {x: 1, y: 0, z: 0} 1 2 0 1

虽然Vector是个对象,但是three实现了其迭代器接口,所以它是个可迭代对象,可以直接使用扩展运算符、r循环等操作。

数学库同样是oop风格,一般方法都会返回这个对象本身,clone方法除外。js对象的判等是判断内存地址,所以向量还提供了equals方法。

每个向量对象都有对应的分量属性,可以直接修改,也可以使用set方法。 copy方法是复制参数的值。

const  v42 = v4.clone() , v43 = v4.add(new Vector4()) ; ;
console.log(v42 === v4, v43 === v4 , v43.equals(v4)) ;
v42.x = 10  ;
v4.copy(v42) ;

image.png

前面说了向量是由大小和方向两个要素组成的,three也提供了两个对应的方法。

length方法就是求模(大小),normalize方法是归一化,个人更喜欢单位化这个名字。 归一化之后,向量的模(大小)就是1了,所以方向就显得突出了。 normalize方法会改变对象本身。

console.log(v4.length) ;
console.log(v4.normalize());

image.png

向量和数字之间的四则运算

这种运算叫做数乘,和普通的四则运算没有什么差别。 数乘操作的方法会有scalar这个命名成分。 scalar的意思就是标量,和矢量(向量)相对。

效果就是会对这个向量全部分量都进行相应的四则运算。

v4.addScalar(2) ;console.log(v4);
v4.divideScalar(1) ;console.log(v4);
v4.multiplyScalar(2) ;console.log(v4);
v4.multiplyScalar(2) ;console.log(v4);

image.png

向量与向量之间的运算

加法

如下图所示,向量是从起点指向终点的, 所以两个向量相加时只需要将其首尾相连,便可得到新的向量,新的向量从新的起点指向新的终点。

OA + AB = OB

减法就是其逆运算。反过来就是 OA - OB = - AB = BA

image.png

addsub会修改对象本身,参数不受影响,addVector方法会直接用计算的结果的值去覆盖对象本身。 所以,如果你不希望对象本身会被修改,应该使用clone方法。

v4.add( new Vector4(1,1,1,1)) ;console.log(v4);
v4.sub( new Vector4(1,1,1,1)) ;console.log(v4);
v4.addVectors( new Vector4(1) , new Vector4(0,1)) ;console.log(v4);
v4.subVectors( new Vector4(1) , new Vector4(0,1)) ;console.log(v4);

image.png

乘法

乘法分为点乘和叉乘, 点乘dot的结果是一个数值,叉乘cross的结果是一个向量。

点乘法则适用于所有维度的向量,叉乘法则严格意义上来说只有三维向量可以使用。这个数学库也是这么实现的。

点乘就像是物理中的做功。 设有向量 a b , a·b ,就是a在b上的投影乘以b。 点乘常用于判断两个向量的方向是否相近, 一般我们所说向量同向,指的就是他们的点积大于0 ,并非需要方向完全相同。

image.png

叉乘, 其作用本来是为了确定正交基底,符合右手螺旋定则。 三维向量ab 叉乘得到向量 c ,那么 c 必然和 a b都垂直。 叉乘常用于求正交的向量,或利用其特性来判断旋转的方向。

const v32 = new Vector3(0,1,0) ;
v3.cross(v32) ; console.log(v3);
v32.crossVectors(v3, v32) ;console.log(v32);

image.png

欧拉角

欧拉对象,是用欧拉旋转的方式来表示一个物体的旋转状态,它有四个主要属性,x y z order, 分别表示绕三轴的旋转的弧度数,order 则表示三轴的顺序,默认是'XYZ'。 为什么会有顺序这个属性存在,这就要说一说,欧拉旋转是怎么旋转的了。

姑且把一个欧拉旋转分三次,每次一个轴。 那么每次单轴旋转的时候,会使得另外两个轴跟着一起转,轮到下个轴的时候,旋转轴的位置就变了。

欧拉对象同样有set、 copy、 clone、equals 这些方法, 还有一些类似set的方法, 同样是可迭代对象,看代码就知道了。

至于和四元数,矩阵相关的方法,下面再细说。


const euler = new Euler(1,1,1, 'XYZ') , e2  = new Euler().copy(euler) ;
console.log(euler.equals(e2)) ; // true
euler.set( 1.5,Math.PI/2,1.5 ,'YZX' ) ;

euler.setFromVector3(new Vector3(1,1,1), 'XYZ') ; console.log(euler);
//Euler {_x: 1, _y: 1, _z: 1, _order: 'XYZ'}

euler.fromArray ([1,1,1] ) ; console.log(euler);
//usemath.js:41 Euler {_x: 1, _y: 1, _z: 1, _order: 'XYZ'}

euler.setFromRotationMatrix( new Matrix4(), 'XYZ', true )     ;console.log(euler)
//usemath.js:43 Euler {_x: -0, _y: 0, _z: -0, _order: 'XYZ'};

euler.setFromQuaternion( new Quaternion(),'XYZ', true);console.log(euler);
//usemath.js:42 Euler {_x: -0, _y: 0, _z: -0, _order: 'XYZ'}


下图这个动画,实际上只有y轴的欧拉角在变化,但是结果是不是有点意外。 这是因为,另外两个欧拉角不是0 。 如果要实现绕某个单轴旋转,尤其是在已经有一定旋转度数的情况下,建议不要使用欧拉角,用四元数。

cube.rotation.y+=.01 y轴欧拉旋转.gif 下面这个demo,如果可以运行的话,可以试试单轴转动立方体,然后观察欧拉角的变化。 不能运行的话,点进去应该是可以的。

应用场景

前面虽然说不要使用欧拉来做旋转,但是有两种情况是可以的。首先,旋转轴必定是xyz轴之一。

第一种情况就是xyz的初始欧拉角为0 ,这时任意选一种都可以实现期望的旋转效果。

第二种情况就是旋转轴为order的最后一个,此时也能实现期望效果。

所以针对第二种情况,貌似可以通过修改Order来实现xyz的任意一个,但是原本的欧拉角,在修改了Order之后,其实这个旋转量就变了。所以如果真的执意要用欧拉的话,可以调用reOrder方法,但是这个方法实际上就是从四元数上转。

欧拉角是用来表示物体的旋转状态的, 所以它另一个应用场景就是,已知欧拉角把这个状态给他还原出来。典型的场景就是手机的陀螺仪,陀螺仪事件会给出当前设备的旋转状态的欧拉角表示。

四元数

四元数看起来就比较抽象了,无法直观的从四元数的属性上想象出旋转量。 但是四元数用起来就很简单。 我们直接看它的api。

四元数对象也是可迭代对象,同样有set copy clone equals,但是直接赋值类的方法不常用,因为太抽象。通常是复制目标对象的四元数值,然后运用其api进行计算。

从一个四元数上能到什么?

直接能看见的也就是xyxw四个数,得不到什么可以直接利用的信息。 但是,四元数它是可以表示一个旋转后的状态,是可以转为欧拉或者矩阵的。 一般来说,会转为矩阵,因为最终是把矩阵传入gl的。

且看api。

new Matrix4().setRotationFromQuaternion(q1) ;
new Euler().setFromQuaternion(q1) ;

如何得到一个四元数

最常用的就是绕任意轴,旋转任意角度。 比如说,你现在就想让物体绕向量(1,1,1)旋转60度。 旋转轴需要归一化

q1.setFromAxisAngle(new Vector3(1,1,1).normalize(), Math.PI/3) ;

其次就是,已知向量a经过旋转之后得到向量b ,那么通过他们的单位向量可以计算出这个旋转量对应的四元数。

const n1 = new Vector3(1,1,1), n2 = new Vector3(-1,1,1) ;
q1.setFromUnitVectors( n1.normalize(), n2.normalize()) ;

乘法

四元数表示是一个旋转状态,或者说是旋转量,这个旋转量是可以叠加的,叠加的办法就是乘法。 这是欧拉旋转做不到的,欧拉旋转只有一定条件下才能叠加旋转量。

比如说现在要先绕向量(1,1,1)旋转60度,再绕轴(0,0,1)旋转30度。如果你用欧拉旋转,可以说无从下手,但是,用四元数的话,如下。

q1.setFromAxisAngle(new Vector3(1,1,1).normalize(), Math.PI/3) ;
q0.setFromAxisAngle((new Vector3(0,0,1), Math.PI/6))
q0.multiply(q1);

四元数还有一个api premultiply, 区别就是先后顺序,四元数的乘法不具备交换律。
先绕向量(1,1,1)旋转60,再绕轴(0,0,1)旋转30度, 和先绕向量(0,0,1)旋转30,再绕轴(1,1,1)旋转60度,这两者的结果显然是不一样的。

需要注意的是,按照上面的代码做的是本地轴的旋转,所谓本地轴,就是轴经过父级的position, 没错,并不是物体的中心。

three中的设计是,父级的世界位乘上物体的本地位得到物体的世界位。这里其实说的是矩阵,但是对于四元数同样适用。 所以,如果想要用四元数绕世界轴旋转,使用premultiply即可。

插值

我想把上面的旋转过程做成动画, 当然可以先每帧绕第一个轴旋转一定的度数,然后继续第二个轴。 四元数为我们提供了便捷的apiq1.slerp(q2, t), 就是q1 到q2之间插值, t是插值比例,0-1。 t = 1的时候插值就结束了。

    const q = new Quaternion(), q1 = new Quaternion(), q0 = new Quaternion();
    q1.setFromAxisAngle(new Vector3(1,1,1).normalize(), Math.PI/2) ;
    q0.setFromAxisAngle(new Vector3(0,0,1), Math.PI/2)
    let t1 = 0, t2 = 0;

    q0.premultiply(q1)

    function ani(stamp) {
        if (t1 < 1) {
            t1 += .006;
            q.slerp(q1, t1);

          cube3.quaternion.copy(q)  
        } else if (t2 < 1) {
            t2 += .006;
            q1.slerp(q0, t2)
            cube3.quaternion.copy(q1)  
        }

        renderer.render(scene, config.camera)
        requestAnimationFrame(ani)
    }
    ani()

矩阵

矩阵想必大家都不陌生,最终要输入到着色器里的就是矩阵,据说是显卡的设计对矩阵运算友好。 但是,对CPU来说矩阵的运算还是比较大的,一个四阶矩阵有16个元素,两个矩阵相乘,可想而知,虽然可以直接套公式可以稍微简化, 但是计算旋转的话,还是四元数比较快。

这里只有三阶方阵和四阶方阵,四阶方阵用得多一些,下面以四阶矩阵为例。

矩阵的set方法的参数 ,是按行主序来的,elements里面则是按列主序来的。 就当做是行主序即可。

    let m41 = new Matrix4().set(
        1, 0, 0, 1,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    )
    console.log(m41);
    // Matrix4
    // elements: (16) [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1]
          

矩阵方法同样有clone copy equals这些。

const matrix4 = new Matrix4(), matrix42 = matrix4.clone() ;
matrix42.equals(matrix4)

矩阵不是可迭代对象,它的elements属性本身就是数组。

矩阵变换

变换就是旋转、 缩放 、平移。都有公式,下面的方法就是封装了这些公式。唯一需要注意的是变换中心,这里没有类似css中 transform-origin这种东西,如果要改变变换中心,可以用先移到变换中心,进行变换,然后移回来的做法。 默认的变换中心就是当前坐标系的(0,0,0);

下面就是就是将变换中心移到(1,1,1),然后进行变换。


const pi = Math.PI;
const obj = new Object3D() ;

const matrix4 = new Matrix4(), matrix42 = matrix4.clone() ;
matrix42.equals(matrix4)
matrix4.makeTranslation(1,1,1) ; obj.applyMatrix4(matrix4);
matrix4.makeRotateZ(pi);obj.applyMatrix4(matrix4);
matrix4.makeScale(2,2,2) ;obj.applyMatrix4(matrix4);
matrix4.makeTranslation(1,1,1);obj.applyMatrix4(matrix4);

如果希望永久改变变换中心,其实可以直接去移动geometry,但是需要注意的是,这样会切实改变顶点的原始点位,最好不要在渲染的过程中去做此改动。

需要注意的是make方法族,是直接将矩阵设置为对应变换的结果,而不是在原本的矩阵的基础上进行累加变换。 如果希望在原本的基础上进行变换,应该乘上新的变换矩阵。 所以,上面的代码每次调用applyMatrix4的时候,实际上都是乘上了一个新的矩阵。

makeXXXfrom族方法,就是将其他形式的变换转为矩阵的形式。

m4.makeRotationFromEuler(new Euler()) ;
m4.makeRotationFromQuaternion(new Quaternion())

相关方法还有很多,尤其是旋转,会分xyz轴,但是都差不多,根据具体需要,用就行了。

矩阵的运算

矩阵的运算仍然可以分为数乘和其他。其他就是矩阵与向量,矩阵与矩阵。 这里主要说矩阵乘法,因为其他运算,一般情况下是用不到的,真的用到了,也是直接用。

matrix4.multiply(matrix42);
matrix4.premultiply(matrix42);
const matrix43 = matrix4.multiplyMatrices(matrix4,matrix42);

矩阵乘法同样不符合交换律。 这就导致了什么呢? 导致计算结果的顺序可能和惯性思维相反。

现在有两个四阶方矩阵m1、 m2,其效果分别是旋转一定角度和位移一定距离, 如果我们希望对物体obj先旋转再位移。 那么算式和代码如下:

//m2 · m1 · obj ;
obj.applyMatrix4(m1);
obj.applyMatrix4(m2);

上面两步完全可以先把m1 m2合成一个变换矩阵m3 ;那么就是

//m3 = m2 · m1 ;
const m3 = m2.multiply(m1)
obj.applyMatrix4(m3);

这样顺序就对了。前面的矩阵变换的代码直接是顺序applyMatrix4,顺序就是对的,这是因为applyMatrix4的实现就是用本身矩阵去premultiply变换矩阵。 矩阵乘法就是这么简单。 手写的话确实很麻烦,但是这里已经封装好了。

更新 之前一直都是用的四阶矩阵,最近在写canvas2d, 突然发现三阶矩阵上还有,scale ,translate ,rotate这样直接使用的api.只要按顺序调用它们,得到结果就是期望的结果, 因为内部是用premultiply封装的。

只是有一点需要注意, matrix3的rotate方法是反方向旋转的。

const mat3 = new Matrix3()
mat3.scale(.5,.5);
mat3.rotate(rotation)
mat3.translate(position.x,position.y) ;

其它运算

数乘、求值(行列式)、转置 一般都用不上。

求逆可能用的上。比如说,你对一个物体做了一定的变换,现在想还原回来,就可以乘上原本的矩阵的逆,就是它的逆矩阵,也是直接调用方法就行了。


 const matrix4Reverse = matrix4.clone().invert() //  求逆,同样是对矩阵对象本身修改,
 
 matrix4.multiplyScalar(4) ; //数乘 所有元素都乘上4
 matrix4.scale(1,2,3); // 分别对三列 进行数乘 ,和缩放毫无关系
 const value =matrix4.determinant() ;// 求值 行列式

工具类方法

Matrix4上面还有一些封装好的公式方法,有特定的作用。 比如,透视投影和正交投影,当然这两个我们不会直接用到。

m4.makePerspective( left, right, top, bottom, near, far )
m4.makeOrthographic( left, right, top, bottom, near, far ) 

lookAt这个方法我们就比较常用了, 其作用就是旋转物体,使得物体朝向目标, 物体的正面就是其z正半轴对应的那个面。常见的应用场景就是控制相机,看向目标物体,以保证目标物体在视线范围内。

现在有两个三维物体A和B , 想让A 看向B, 这个eye就是A的位置, target就是B的位置,up是上方向,上方向就暂且理解为是A的本地坐标系的y轴的方向,这个最好是单位向量。

m4.lookAt( eye, target, up );

decomposecompose刚好可以说互为逆操作。前面一直把旋转、缩放、平移分开来讲,实际上最后肯定是汇聚到一个矩阵里面的。decompose就是从一个矩阵里解构出位移position、旋转rotation、缩放scalecompose就是反过来,由这个三个变换组合成一个矩阵。 变换的顺序肯定是先缩放旋转,最后位移。

m4.decompose( position, quaternion, scale )// 计算结果会赋值到入参里,返回值还是矩阵本身,
m4.compose( position, quaternion, scale )//  矩阵本身被修改,返回矩阵本身

直接使用Object3D的方法

以上所说都是数学库的用法,而实际应用的时候,也许大家更喜欢直接调用Object3d上的方法。

const obj = new Object3D()

旋转

除了applyQuaternion applyMatrix4之外,都是用的四元数方法,并且内部是用premultiply实现的,已经考虑了顺序的问题,按照正常的顺序使用即可。 绕xyz的旋转就是调用了本地旋转方法,angle是弧度,axis是单位向量


	obj.rotateOnAxis( axis, angle )
	obj.rotateOnWorldAxis( axis, angle )

	rotateX( angle ) {

		return this.rotateOnAxis( _xAxis, angle );

	}

	rotateY( angle ) {

		return this.rotateOnAxis( _yAxis, angle );

	}

	rotateZ( angle ) {

		return this.rotateOnAxis( _zAxis, angle );

	}

以set开头的方法和上面的方法的区别就是,set相关方法是直接把物体的旋转状态设为某一个值, 而上面的方法都是在原本的基础上,进行一定的旋转,也就是说是一个旋转的增量。

	obj.setRotationFromAxisAngle( axis, angle )

	obj.setRotationFromEuler( new Euler() ) 

	obj.setRotationFromMatrix( new Matrix4() ) 

	obj.setRotationFromQuaternion( new Quaternion() )

还有一个方法也是结果导向,那就是lookAt,它不关心需要多少旋转才能达成看向目标这个结果,只关心最终效果。 透视相机是继承了Object3D的,所以也有这个方法。

这里的看向方法做了兼容,可以只传入一个三维向量,也可以传入三个数字。 和上面矩阵的方法对比,这里少了上方向, 它这里的上方向其实有的,为obj.up这个属性值,默认是Y轴方向,eye就是物体本身的世界位,所以这个传入的target也需要是世界位。

const camera = new PerspectiveCamera() ;
camera.lookAt(new Vector3(1,2,3)) ;
camera.lookAt(3,2,1) ;

缩放

过于简单,以至于没有封装对应的方法,直接修改scale属性就好了。

位移

都是本地坐标下的位移方法,Object3D没有提供世界位的方法,如果需要移动到世界位,可以先算出世界位移向量,然后调用worldToLocal方法转本地,最后调用translate方法即可。

translateOnAxis( axis, distance ) 

	translateX( distance ) {

		return this.translateOnAxis( _xAxis, distance );

	}
	translateY( distance ) {

		return this.translateOnAxis( _yAxis, distance );

	}
	translateZ( distance ) {

		return this.translateOnAxis( _zAxis, distance );

	}

下面就就是希望把obj从原本的位置,移动到目标位置(世界位),的一种演示。 也可以反过来,计算出目标位置的本地位,然后覆盖掉objposition即可。 如果希望在添加物体的时候保留其世界位不变,可以直接使用attach方法 。


const worldPos = new Vector3(3,4,5) ;// 目标位置 世界位
const p = new Group() ;         //  随便给obj找个父级
p.scale.set(2,2,2) ;
p.rotateOnAxis(new Vector3(1,2,1), Math.PI/ 3) ;
p.translateOnAxis(new Vector3(1,1,1) , 10) ;

p.add( obj) ;

const objWorldPos = obj.getWorldPosition() ;//  得到obj的世界坐标
const delta  = worldPos.clone().sub(objWorldPos) ; // 计算在世界坐标下的位移量
obj.worldToLocal(delta) ;   //  把位移量转为本地坐标
obj.position.add(delta) ;   //  直接把位移量加到本地坐标上  

结语

个人常用的数学操作差不多就这么多。

数学库还有一些几何类,他们只是单纯的几何表示,不构造geometry

Spherical类提供了球坐标相关的方法,sphere则提供构造一个球的相关方法。

Box3就是一个3d盒子,geomerty的计算包围盒就用到了它。

RayPlane一般是合起来用的,射线和平面的交点。

还有更多的用法,遇到需求时查一查文档便知。

虽然射线拾取物体,好像也用到数学,但其实只要你把鼠标位的坐标换算到裁剪空间而已,剩余的过程完全封装了。

最重要的一点,就是别忘了大部分方法都是修改对象本身,如果不希望对象本身被修改,那么就要改变方法的调用者,谁调用就指向谁。