12.9 HTML 画布几何变换

为画布中元素应用图形变换

到现在为止,你在画布中绘制的所有元素都是按照它应该出现的样子绘制的。例如,矩形是按照fillRect方法定义的位置和尺寸绘制的,并且它是用水平和垂直的线条绘制的,平淡无奇。但是,如果你想要画一些奇特的图形呢?如果想要旋转一个矩形呢?如果想要缩放图形呢?2D渲染上下文的变形功能能够帮助你实现所有这样的操作。它们支持的功能是非常强大的。

平移

最基本的操作就是平移,即将2D渲染上下文的原点从一个位置移动到另一个位置。在画布中进行平移使用的是translate方法时,实际上它移动的是2D渲染上下文的坐标原点,而不是所绘制的对象(参见图1)。

图1
translate方法移动2D渲染上下文的原点

Translate方法的调用方式如下:

context.translate(150, 150); 

两个参数是(x,y)坐标值,表示把2D渲染上下文的原点移动多远.一定要注意,将来你所指定(x,y)坐标值会加上原点的平移,原点最初的默认值是(0,0)。例如,如果执行两次与上面例子完全相同的平移,那么实际上是将原点在x轴方向移动300个单位(0+150+150),在y轴方向也移动300个单位(0+150+150)。

通过移动2D渲染上下文的原点,画布中的所有对象都将移动相应的距离:

context.fillRect(150, 150, 100, 100);
    context.translate(150, 150);
    context.fillStyle = "rgb(255, 0, 0)";
    context.fillRect(150, 150, 100, 100); 

一般情况下,第二次调用fillRect时,所绘制的正方形的原点坐标是(150,150),但是由于执行了一次平移,这个正方形的原点现在变成(300,300)(参见图2)。

平移会影响图形原点

一定要理解这其中的原理。红色正方形的原点仍然为(150,150),它只是看上去又平移了150像素,这是因为在黑色正方形绘制之后,2D渲染上下文的原点已经平移了150像素。如果你希望红色正方形仍然出现在点(150,150)原来的位置(即黑色正方形所在位置),那么可以直接将它的原点设为(0,0):

context.translate(150, 150);
    context.fillStyle = "rgb(255, 0, 0)";
    context.fillRect(0, 0, 100, 100); 

这是因为你已经将2D渲染上下文移动到位置(150,150),所以从现在开始,所有在点(0,0)绘制的图形实际上都显示在点(150,150)上。

注意:每一种变形方法,包括平移,都会影响方法执行后所绘制的所有元素。这是因为它们都是直接在2D渲染上下文上操作的,而不是只针对所绘制的图形,这与你修改了fillStyle等属性的效果一样,新的颜色会影响后来绘制的所有元素。

缩放

另一个变形方法就是缩放(scale),顾名思义,它是调整2D渲染上下文的尺寸。它与平移的区别在于(x,y)参数是缩放倍数,而不是像素值。

 context.scale(2, 2); 
    context.fillRect(0, 0, 100, 100); 

这个例子将2D渲染上下文的x和y方向都乘以2。通俗地说,2D渲染上下文及其绘制的所有对象现在都变成2倍尺寸(参见图3)。

缩放2D渲染上下文

单独使用scale将使所有绘图内容变大,而且它也会使一些对象被画在一些不恰当的位置上。例如,放大2倍实际上意味着现在1个像素变成2个像素.所以如果你绘制了一个x为150像素的图形,现在它看起来像是变成x为300像素了。如果这不符合你的要求,或者你只想要缩放一个图形,可以组合使用scale和translate方法。

 context.save();
    context.translate(150, 150);
    context.scale(2, 2);
    context.fillRect(0, 0, 100, 100); 

在这个例子中,首先保存画布的状态,再将原点平移到(150,150)。然后,将画布放大两倍,在位置(0,0)绘制一个正方形。因为已经将2D渲染上下文平移到(150,150),所以这个正方形会被绘制在正确的位置,并同时放大两倍(参见图4)。

保持原点同时进行缩放

问题是,从现在开始绘制的其他图形都将平移150像素并在两个方向同时放大两倍。幸好,你已经完成了前面一半的工作:在执行变形之前保存了绘图状态。剩下一半工作是恢复之前保存的绘图状态。

context.restore();
    context.fillRect(0, 0, 100, 100); 

在恢复绘图状态之后,后面绘制的所有图形都不会出现变形效果(参见图5)。

在执行平移后恢复绘图状态

旋转

如果要我选择一个最喜欢的变形功能,我肯定会选择rotate方法。到现在为止,我们介绍的变形方法的共同特点是它们都很容易调用。rotate方法也不例外,你只需要传入以弧度为单位的2D渲染上下文旋转角度值即可:

context.rotate(0.7854); // Rotate 45 degrees (Math.PI/4) 
    context.fillRect(0, 0, 100, 100); 

然而,这个旋转的结果可能并不是你所期望的。为什么正方形会旋转到浏览器边界以外呢?(参见图6)。

旋转画布可能导致图形出现在一些奇怪的位置上

出现这种结果,是因为rotate方法是把2D渲染上下文绕其原点(0,0)进行旋转的,在前面这个例子中,原点是屏幕的左上角。因此,你所绘制的正方形本身是不会旋转的,它现在实际上是以45度角绘制到画布中。图7可以帮助理解这一点。

图7
旋转后的2D绘图上下文

当然,如果你只想旋转所要绘制的图形,那么这样肯定不行。这时,仍然还需要使用translate方法。要实现所期望的效果,需要将2D渲染上下文的原点平移到正在绘制的图形的中心。然后,再对画布执行一次旋转,接着在当前位置绘制图形。这个过程描述起来有些复杂,所以让我们用示例代码来演示这个过程:

context.translate(200, 200); // Translate to centre of square 
    context.rotate(0.7854); // Rotate 45 degrees
    context.fillRect(-50, -50, 100, 100); // Draw a square with the centre at the point of rotation 

这样你会得到一个旋转45度角的有趣正方形,它正位于你想要的位置(参见图8)。

以原点为中心旋转一个图形

注意:执行变形的顺序是极为重要的。例如,如果在执行平移之前将画布旋转45度,那么你会在45度角上进行平移。WHATWG规范中有一个例子指出,如果一个缩放变形操作先将你给制的任何图形的宽度放大2倍,随后再旋转90度,那么当你绘制一个宽度是高度2倍的矩形时,这个缩放变形操作会把你所绘制的矩形变成一个正方形。仔细想想,你就能明白它的意思,它强调了考虑变形顺序的必要性。如果绘图时出现错误,那么请先检查顺序!

变换矩阵

现目前为止,你所使用的所有变形方法都会影响一个东西,那就是变换矩阵。我们不讨论一些非必要的细节(这些细节信息并不重要),变换矩阵就是一组数字,它们各自描述一个稍后将会介绍的特定变形类型。矩阵分成多个列和行。在画布中,你使用的是一个3x3矩阵——3列和3行(参见图9)。

图9
2D渲染上下文的变换矩阵

你可以忽略最后一行,因为你不需要也不能修改它的值.最重要的是第一行和第二行,其中包含的数字值对应画布中使用的a至f。你可以看到,图9中每一个数字值都对应一种特定的变形。例如,a表示在x轴的缩放倍数,f表示在y轴的平移。

现在,在学习如何手动处理变换矩阵之前,我先说明一下这个矩阵的默认值。一个新的2D渲染上下文将包含一个全新的变换矩阵,即单位矩阵(identity matrix)(参见图10)。

图10
单位矩阵

除了左上角至右下角的主对角线以外,这个特殊矩阵的每个值都设置为0。这样设置的唯一原因是它更适合进行计算,但是可以确定的是,单位矩阵表示完全未执行过变形。全面理解单位矩阵的含义并不是很重要,重要的是要知道变换矩阵中的默认值是什么。

操作变换矩阵

本节要介绍的最后两个方法是transform和setTransform。它们能够帮助我们操作2D渲染上下文的变换矩阵。 我们已经了解了足够多的基本概念,所以现在让我们使用transform执行一个平移和缩放,然后再绘制一个正方形,以此说明它的作用:

context.transform(2, 0, 0, 2, 150, 150);
    context.fillRect(0, 0, 100, 100); 

transform方法有6个参数,分别对应变换矩阵的每一个值,第一个表示a,最后一个表f。在这个例子中,你想将画布的尺寸放大2倍,所以将第1个和第4个参数设置为2,即a和d——分别对应x轴缩放和y轴缩放。 可以理解。而如果要平移画布原点呢?没错:你需要设置第5个和第6个参数,即e和f——分别对应x轴平移和y轴平移(参见图11)。

使用变换矩阵进行缩放平移

希望你现在已经理解了它的使用方法,手动操作变换矩阵其实并不复杂。只要理解每一个值的意义,就能够执行正确的操作。现在让我们用变换矩阵执行一些更高级的变形——旋转!

不使用rotate方法执行旋转变形似乎有些复杂,但是如果你听我讲下去,很快就能明白这样做的意义:

context.setTransform(1, 0, 0, 1, 0, 0); // Identity matrix 
    var xScale = Math.cos(0.7854); 
    var ySkew = -Math.sin(0.7854);
    var xSkew = Math.sin(0.7854);
    var yScale = Math.cos(0.7854);
    var xTrans = 200;
    var yTrans = 200;
    context.transform(xScale, ySkew, xSkew, yScale, xTrans, yTrans);
    context.fillRect(-50, -50, 100, 100); 

首先,你需要调用setTransform方法。这是第二个操作变换矩阵的方法,它的作用是将矩阵重置为单位矩阵,然后按照6个参数执行变形。在这个例子中,使用它来重置变换矩阵,从而保证你操作的是一个原始状态的变换矩阵。然后,为一些变量赋值,它们是调用transform方法所使用的参数。有了这些作为参数的变量,就能够使整个过程变得更加简洁和清晰,而且更容易理解。

需要指出的是,transform方法实际上是将现有的变换矩阵乘以你所指定的值,而不是直接设置变换矩阵的值.这意味着其中会有一个累积效应.如果你多次调用transform,那么每一次变形都是应用到前一个变形所得到的变换矩阵。我承认这有一些复杂,但是我希望它能够帮助你理解变形的工作方式。

你可能注意到了,我们又一次使用到Math对象。在这个例子中,使用它来返回一些必要值,缩放和倾斜变形将使用这些值来生成旋转效果。因为掌握这一点非常重要,所以在此重复一遍:使用变换矩阵进行旋转是倾斜和缩放的组合效果

注意:我们还会在本书中使用正弦和余弦等三角函数,如果你希望学习更多关于它们的使用方法和作用,我强烈建议你找些书来复习一下这些函数。 但是,我们无法在这里逐一解释每一个概念.变换矩阵的维基百科页面包含更丰富的信息://en.wikipedia.org/wiki/Transformation_matrix

最后,将所有代码编写出来,你会得到下面的结果:一个漂亮的旋转后的正方形(参见图12)。

使用变换矩阵进行旋转

这些旋转效果完全是你从零开始做出来的,根本没用rotate方法!一般而言,这三个核心的变形方法在大多数时间都能够满足要求,但是即便不满足要求,理解变换矩阵也能够帮助你解决问题。