大家好,我是前端西瓜哥。
在二维中,对于图形(模型),它会有一个模型矩阵 matrix 来表达图形的形变。
比如图形先做了缩放,然后再位移,则模型矩阵为缩放矩阵左乘位移矩阵得到的复合矩阵。
矩阵的优点是计算方便,比如父节点和子节点都有 matrix,那子节点最终在画布的 matrix 就是它们的矩阵直接相乘。
缺点也明显,就是它的值是几个多种矩阵变换得到的数字,语义糟糕,看不出图形做了什么形变。
这不利于我们对图形的表达。
那么,有没有办法对矩阵做分解,得到多个形变的表达呢?
我们不妨看看 pixijs 怎么做的。
pixijs 里面有两个类:Matrix 和 Transform。
Matrix
Matrix 是平面矩阵类,提供矩阵相关的各种方法。
Matrix 使用 6 个数字表达,代表一个 3x3 的矩阵,用于平面矩阵变换。值有 a、b、c、d、tx、ty。
| a | c | tx|
| b | d | ty|
| 0 | 0 | 1 |
支持缩放、旋转、位移、左乘、右乘、逆矩阵、计算点应用矩阵后的结果等方法。支持链式写法。
缩放、旋转、位移:
import { Matrix } from 'pixi.js';
const matrix = new Matrix();
// 返回一个默认额单位矩阵
// [pixi.js:Matrix a=1 b=0 c=0 d=1 tx=0 ty=0]
// 1, 0, 0,
// 0, 1, 0,
// 0, 0, 1,
matrix.scale(3, 3);
// 放大为原来的 3 倍
// [pixi.js:Matrix a=3 b=0 c=0 d=3 tx=0 ty=0]
// 3, 0, 0,
// 0, 3, 0,
// 0, 0, 1,
// 支持链式写法(等价连续多个变换矩阵左乘)
matrix.rotate(Math.PI / 2).translate(10, 10);
// [pixi.js:Matrix a=1.8369701987210297e-16 b=3 c=-3 d=1.8369701987210297e-16 tx=10 ty=10]
// 上面这个 a 应该为 0,但因为浮点数误差导致一个非常小的小数。
左乘、右乘、逆矩阵:
const leftMatrix = new Matrix();
const rightMatrix = new Matrix();
// 右乘
const newMatrix = leftMatrix.append(rightMatrix);
// 左乘
const newMatrix2 = rightMatrix.prepend(leftMatrix);
// 逆矩阵
const inverseMatrix = leftMatrix.invert();
计算点应用矩阵后的结果、应用逆矩阵的结果:
const matrix = new Matrix();
// 点应用矩阵后的结果
const point = matrix.apply({ x: 100, y: 100 });
// 应用逆矩阵的结果
const inversePoint = matrix.applyInverse({ x: 100, y: 100 });
Transform
Transform 是 Matrix 的等价表达,但是对用户友好。
The Transform class facilitates the manipulation of a 2D transformation matrix through user-friendly properties: position, scale, rotation, skew, and pivot.
Transform 是一些属性的组合,可以表达一个图形的任意形变效果。
属性有:
- postion:位置,类型为 point { x: number, y: number }。
- scale:缩放,类型为 point。
- pivot:基准位置,类型为 point。它作为旋转、缩放的中心点,默认为原点。
- skew:斜切,类型为 point。弧度值,表示基向量方向和另一方向形成的角。
- rotation:旋转角,弧度单位。
用 typescript 类型表达为:
interface TransformableObject {
position: PointData;
scale: PointData;
pivot: PointData;
skew: PointData;
rotation: number;
}
interface PointData {
x: number;
y: number;
}
pixijs 的图形使用了 Transform 的这一套表达,让用户能够很简单直观地表达一些简单的形变。
Transform 下有一个 _matrix 属性,维护等价的 matrix 对象,当 transform 的属性更新时,matrix 会标记为 dirty,之后读取的时候会重新生成。
transform 这个名字其实有点迷惑,因为有时候我们也会把用在形变的矩阵 matrix,也叫做 transform。只是 pixijs 这里的命名比较特别,里面也有点乱。
下面看看 Matrix 和 Transform 之间的转换算法。
Transform 转 Matrix
pixijs 中 Transform 转 Matrix 的实现如下。
class Transform {
get matrix(): Matrix
{
const lt = this._matrix;
if (!this.dirty) return lt;
lt.a = this._cx * this.scale.x;
lt.b = this._sx * this.scale.x;
lt.c = this._cy * this.scale.y;
lt.d = this._sy * this.scale.y;
lt.tx = this.position.x - ((this.pivot.x * lt.a) + (this.pivot.y * lt.c));
lt.ty = this.position.y - ((this.pivot.x * lt.b) + (this.pivot.y * lt.d));
this.dirty = false;
return lt;
}
protected updateSkew(): void
{
this._cx = Math.cos(this._rotation + this.skew.y);
this._sx = Math.sin(this._rotation + this.skew.y);
this._cy = -Math.sin(this._rotation - this.skew.x); // cos, added PI/2
this._sy = Math.cos(this._rotation - this.skew.x); // sin, added PI/2
this.dirty = true;
}
}
_cx、_sx、_cy、sy 会在更新 skew 或 rotataion 时进行更新,是缓存数据。
我们抽出算法。
上面为了提高计算效率,没有用矩阵类的方法,这里给矩阵相乘表达。
import { Matrix } from 'pixi.js';
const transformToMatrix = (tf: TransformableObject) => {
const cosX = Math.cos(tf.rotation + tf.skew.y);
const sinX = Math.sin(tf.rotation + tf.skew.y);
const cosY = -Math.sin(tf.rotation - tf.skew.x);
const sinY = Math.cos(tf.rotation - tf.skew.x);
const skewMatrix = new Matrix(cosX, sinX, cosY, sinY, 0, 0);
return new Matrix()
.translate(-tf.pivot.x, -tf.pivot.y)
.prepend(skewMatrix)
.scale(tf.scale.x, tf.scale.y)
.translate(tf.position.x, tf.position.y);
};
斜切和旋转二者需要合并为一个斜切矩阵。因为旋转本质是一种斜切,只是刚好两个斜切角的和为 360 度的倍数。
所以这里要把 skew 和 rotation 加起来,计算一个斜切矩阵。
结果矩阵为下面几个矩阵连续左乘:
- pivot 负方向的位移矩阵。表示图形上的某个点,移动到坐标原点。pivot 可以理解为前置版位移。
- skew 和 rotation 得到的斜切矩阵。
- scale 对应的缩放矩阵。
- position 对应的位移矩阵。
Matrix 转 Transform
pixi.js 的实现为:
class Matrix {
public decompose(transform: TransformableObject): TransformableObject
{
// sort out rotation / skew..
const a = this.a;
const b = this.b;
const c = this.c;
const d = this.d;
const pivot = transform.pivot;
const skewX = -Math.atan2(-c, d);
const skewY = Math.atan2(b, a);
const delta = Math.abs(skewX + skewY);
if (delta < 0.00001 || Math.abs(PI_2 - delta) < 0.00001)
{
transform.rotation = skewY;
transform.skew.x = transform.skew.y = 0;
}
else
{
transform.rotation = 0;
transform.skew.x = skewX;
transform.skew.y = skewY;
}
// next set scale
transform.scale.x = Math.sqrt((a * a) + (b * b));
transform.scale.y = Math.sqrt((c * c) + (d * d));
// next set position
transform.position.x = this.tx + ((pivot.x * a) + (pivot.y * c));
transform.position.y = this.ty + ((pivot.x * b) + (pivot.y * d));
return transform;
}
}
上面这个是 matrix 对象的方法,接收一个 transform 对象,修改它的值,并返回它自身。
pivot 这个就直接取传入的 transform 的 pivot。
计算斜切值 skew。即求图形两条相邻边各自的余弦值对应的角。
如果刚好两个斜切角之和为 0 或 360 度,说明是特殊的斜切——旋转,那就给 rotation 设置为 skewY。skew 设置为 0。
如果不是,rotation 设置为 0,skew 设置为斜切角。
scale 分别为 a 和 b、c 和 d 的平方和开方。
最后是 position,理论上直接取 tx 和 ty 即可,不过有个 pivot。pivot 是图形斜切缩放前的前置位移,所以给它应用去掉 tx 和 ty 的矩阵做一个运算,然后再加上 tx 和 ty 即可。
结尾
矩阵 matrix 体现了数学的简洁之美,只用几个数字,就能表达图形的各种变换的组合。
但问题是可读性差,无法直接看出图形的特性,比如旋转了多少,缩放了多少。
为了提高易用性,pixijs 引入了一套和 matrix 等价的 transform,让开发者使用图形时,能够快速上手,很好地解决了 Matrix 的弊端。