头图

2D矩阵:这都是什么妖魔鬼怪啊!

这是上帝的杰作
English

头“秃”来自“the book of shader

今天我的文章可能会有点抽象。我尽量“有图有真相”,不让大家的大脑内存泄漏!

本文会讲到:

  • transform 2D变换背后的数学原理
  • 如何直观理解一个矩阵
  • 齐次变换是什么?
  • 可能会涉及一些:逆矩阵(),正交(),向量的知识,用到的时候假装自己知道就可以了!
  • 但基本不会涉及3D,四元数,各类引擎中MVP矩阵变换 。话不多说我们开始吧

scale+skew+rotate我全都要

作为一个传统前端,一个资深csser,一定知道transform属性上,是可以配置rotate用于旋转,scale用于缩放,skew用于斜切。
image.png

更进一步,transform还提供matrix,matrix3d这样的操作让我们可以更自由地变换图形:
image.png

所以大概能够想到2D的rotate,scale,skew本质其实就是某种特殊的矩阵。

那么接下来就是如何把这三个操作写成矩阵形式。

不过,在此之前我们先想想,矩阵操作的基本单元是什么?

是“点”

点组成了线,线组成了面!

如果你有写过shader,你会知道gl也是操作一堆的点来生成画面的。

所以“女神放大了都是马赛克

当然!不放大也可能有马赛克(误)

既然知道矩阵操作的单元是“点”,那么任意的点p(x,y)经过一个矩阵变化后的结果是什么?
2D:

3D:

上图公式表示矩阵的乘法:

矩阵*矩阵,则可以把右矩阵看成两个点/向量拼接而成的。乘法规则不变
现在让我们来看看scale/skew/rotate所对应的矩阵:

对于最简单的scale矩阵,(x,y)经过缩放矩阵变换我们期望它变成(scaleXx, scaleYy),带入上面的公式1验证:

而对于旋转矩阵,这个 表示逆时针旋转后向量,与原向量之间的夹角。

让我们验证一下,假设对于p0(1,1)那么它经过 =45度旋转后,将变为p1(0,√2):


斜切矩阵也是类似的套路,角度从一个变成了两个。这个后文我们还会提到。读者也可以自行验证下。

目前我们虽然把scale,skew,rotate三种变换写成了矩阵,但他们三者依旧是独立的。人类总是贪婪的,能不能只用一种方式去理解它们?换言之,我们更想要知道,对于任意的变换(这里说任意其实不太严谨,这个我们后面会提到),其对应的矩阵是什么?

那就要用引入一个新的概念:

“基”向量(必须用个紫色)

我们知道矩阵其实是在改变“点”,在空间中我们如何表示一个点?

在2D笛卡尔坐标体系下,两个相互垂直的方向构成x轴和y轴。任意点P则用一对数(a,b)表示。比如(2,3)就表示2D空间中的一个确定的点,这里的2,3的意义又是什么?

这里2的意思就是p在x方向占据2个单位长度,3就表示在y方向占据3个单位长度。这两个单位长度用向量表示为[1,0]^T与[0,1]^T 。

这种观点下,点可以写成:2 [1,0]^T + 3 [0,1]^T :

T就是转置,可以理解把原先横着写的行向量,竖着写成列向量。看!就是下图那种。

推广到任意坐标(a,b):
|1 0||a|    
|0 1||b| 

而其中[1,0]^T与[0,1]^T 就被称为一组基向量。他们组成的这个矩阵其实有个名字叫做单位矩阵。

单位矩阵在更复杂的交互中其实有很多重要的性质,但是和今天的话题没啥关系,就当听个冷知识吧!

high-level地理解矩阵

我们现在把“基向量”和用“基向量”表示的点都带入线性变换中,看看经过一次变换之后基向量会如何改变 ?

这里我们发现了一个有趣的现象:

原先的基向量x0,y0,经过变换后变为 1, 1(就是上图中红色的部分)。原先的点P0经过变换后变为P1,此时点p1是可以用 1, 1描述。而 1, 1前面的系数还是原来p0(a,b)的系数。(虽然形状发生了变化,但图中红色的虚线网格与基向量的比例不变)

整理成数学语言就是:

举个“栗”子:

对于一个缩放操作,x轴放大1.5倍,y轴放大2倍。

[x] = [1.5,0]^T

[y] = [0,2]^T

那么该变换对应的矩阵就是:

|x y| = |1.5  0 |   =  |scaleX   0    |
         | 0    2 |     |   0   scaleY |

同理,旋转和斜切变换也可以使用基向量的思路,易得对应矩阵:


这样包括但不限于rotate/scale/skew的矩阵变换,我们都可以从基向量的角度去理解了。

说到这,关于2D矩阵的知识点,大致就结束了!吗?


是不是有哪里怪怪的?

老子动不了了!

对!我们完全没有解释translate。到目前为止我们说的2*2矩阵只能进行如下操作

只能把点[x,y]变换成[ax+cy,bx+dy]^T的形式。
这种变换被叫做线性变换,线性变换有2个比较明显的特点:

  • 线性:对于矩阵变换去观察空间中任意的点,它们所受到的影响是一致的。或者不严谨地说,网格不会在变换后变成曲线(如下图)也不会不均匀:局部放大,另一局部缩小。
  • 关于原点对称:

    图中不论基向量如何变化,原点都没有发生改变。
这个性质其实也很好理解,[0,0]这个点不管用什么矩阵处理后都是[0,0] ,这意味着实际上矩阵变换没有能力进行平移操作。

因为线性变换的代数本质是:

然鹅!平移操作的代数本质是:

这时候仅仅使用一个2D矩阵,想要平移。就真的是“臣妾做不到了”,所以这时我们引入一个新的变换“齐次变换”!

齐次变换


说好是2D怎么又给我整成3D?虽然我们把原来的2维点[x,y]拓展为三维的点[x,y,1]。

但我们不必操心第三个行,我们仅看矩阵的前两行即可:

实际上,它就是css中的matrix(a,b,c,d,tx,tx)。而这个矩阵变换的结果就真的实现了2D线性变换与平移:

至此,2D矩阵的变换的原理部分就基本说完了。下面我们结合下PIXI.js的源代码进行一些简单的源码分析:

秃头环节

PIXI.js采用transform+matrix的组合实现图形操作。
让我们先来看看Matrix.ts.
export class Matrix
{
// ......
  constructor(a = 1, b = 0, c = 0, d = 1, tx = 0, ty = 0)
    {
        /**
         * @member {number}
         * @default 1
         */
        this.a = a;

        /**
         * @member {number}
         * @default 0
         */
        this.b = b;

        /**
         * @member {number}
         * @default 0
         */
        this.c = c;

        /**
         * @member {number}
         * @default 1
         */
        this.d = d;

        /**
         * @member {number}
         * @default 0
         */
        this.tx = tx;

        /**
         * @member {number}
         * @default 0
         */
        this.ty = ty;
    }
// .....
}

可以看到这个构造函数的参数,和我们上文所说的齐次变换矩阵的前两行是一毛一样的,也与css中的matrix参数一致。

但是它的注释还是容易引起一些误会的:

/**
     * @param {number} [a=1] - x scale
     * @param {number} [b=0] - y skew
     * @param {number} [c=0] - x skew
     * @param {number} [d=1] - y scale
     * @param {number} [tx=0] - x translation
     * @param {number} [ty=0] - y translation
*/
具体每个参数的用途,大可不必拘泥,我们现在可以从基向量的角度去准确理解了。

matrix的大部分方法都比较容易理解,这里我们挑选几个来说说吧!

rotate(angle: number): this
    {
        const cos = Math.cos(angle);
        const sin = Math.sin(angle);

        const a1 = this.a;
        const c1 = this.c;
        const tx1 = this.tx;

        this.a = (a1 * cos) - (this.b * sin);
        this.b = (a1 * sin) + (this.b * cos);
        this.c = (c1 * cos) - (this.d * sin);
        this.d = (c1 * sin) + (this.d * cos);
        this.tx = (tx1 * cos) - (this.ty * sin);
        this.ty = (tx1 * sin) + (this.ty * cos);

        return this;
    }

rotate的作用就是旋转,matrix本身也记录了一套变换。那么在此基础上,再进行操作就需要使用矩阵的乘法。矩阵的乘法是有顺序的。例如我们需要对点进行如图平移,缩放,平移操作,就必须把矩阵依次序相乘:

而rotate操作是在原有矩阵之后执行的,所以新矩阵就是[Result] = Rotate:

是不是豁然开朗?

是的!除非是一些特殊操作,大部分正常人类是不会愿意面对矩阵计算的,因此PIXI提供了另一个类Transform。把矩阵变换用一些比较容易理解的属性(position,scale,rotate...)代替(CSS重的transform也有类似功效)除外,Transform代码也比较业务,它上面的数据都是ObservablePoint,还实现了父子级的关系。而实际运行时,Transform通过decompose方法实现自身数据与Matrix数据的转换。

decompose(transform: Transform): Transform
    {
        // 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;
    }

其中Math.atan2(-c, d)和Math.atan2(b, a)。其实在计算2个基向量变换前后的夹角:

当ø与 相等时,我们就认为这是一个旋转操作。但是由于数值精度的问题,浮点数很难会相等,所以 PIXI采用差值法判断,即

var delta = Math.abs(ø -  );
if(delta < Number.EPSILON){
   ...
}else{
   ....
}

这里需要注意x基向量确定的是Y方向的斜切值,而y基向量确定的是X方向的斜切值:

此处出现了我觉得是这段代码中最神来之笔的地方。

由于我们算出来的skewX其实是负方向的。而delta = Math.abs(ø - )又需要取两值之差。PIXI就直接在Math.atan2(-c, d)加上了符号。后续判断直接使用delta = Math.abs(skewX + skewY);而skewX方向也被校正了。这种代码上的整合和巧思其实遍布PIXI.js的每个角落,实在佩服作者的逻辑能力。

最后说说scale和position

position就是正常的齐次变换。

而对于scale,我们判断是否有缩放的标准就是基向量是否缩放,所以scale.x就是旋转后x基向量的模长(aa+bb)^0.5

scale.y同理。

以上就是个人总结的一些关于2D矩阵的知识点,希望各位大佬不吝赐教。

突然发现我们好久没聊shader了,下期我们就来聊聊水雾和毛玻璃的shader要如何实现吧。

参考资料:

Fundamentals of Computer Graphics, Fourth Edition

《3blue1Brown线性代数本质》

thebookofshaders

阅读 922

Know little more JS and Technology
一本离散的学习笔记

//loading...

2.1k 声望
154 粉丝
0 条评论
你知道吗?

//loading...

2.1k 声望
154 粉丝
宣传栏