跳转至

6.5 实现 2D 变换

Implementing 2D Transforms

这一章使用WebGL进行2D绘图。当然,使用WebGL的真正动机是在Web上拥有高性能的3D图形。我们将在下一章中讨论这个问题。在WebGL中,实现变换是程序员的责任,与OpenGL 1.1相比,这增加了一层复杂性。但在我们尝试在三维空间中处理这种复杂性之前,这一小节展示了如何在2D环境中实现变换和分层建模。

This chapter uses WebGL for 2D drawing. Of course, the real motivation for using WebGL is to have high-performance 3D graphics on the web. We will turn to that in the next chapter. With WebGL, implementing transformations is the responsibility of the programmer, which adds a level of complexity compared to OpenGL 1.1. But before we attempt to deal with that complexity in three dimensions, this short section shows how to implement transforms and hierarchical modeling in a 2D context.

6.5.1 GLSL 中的转换

Transforms in GLSL

第2.3节中讨论了2D变换。回顾一下:基本变换是缩放、旋转和平移。这样的一系列变换可以组合成一个单一的仿射变换。2D仿射变换将点(x1,y1)映射到由以下形式的公式给出的点(x2,y2)

x2 = a*x1 + c*y1 + e
y2 = b*x1 + d*y1 + f

其中a, b, c, d, ef是常数。如2.3.8小节中所解释的,这个变换可以表示为3x3矩阵:

123

通过这种表示,点(x,y)变为三维向量(x,y,1),变换可以通过将向量与矩阵相乘来实现。

要将变换应用于原语,必须将变换矩阵与原语的每个顶点相乘。在GLSL中,执行此操作的自然位置是顶点着色器。从技术上讲,可以在JavaScript端进行乘法运算,但GLSL可以更高效地执行此操作,因为它可以同时处理多个顶点,并且GPU可能具有高效的矩阵数学硬件支持。(顺便说一下,仿射变换的一个特性是,只需在原语的顶点上应用它们即可。对变换后的顶点坐标进行插值到原语内部像素将得到正确的结果;也就是说,它给出的答案与在片段着色器中先插值原始顶点坐标然后应用变换相同。)

在GLSL中,类型mat3表示3x3矩阵,vec3表示三维向量。当应用于mat3vec3时,乘法运算符*计算乘积。因此,可以使用简单的GLSL赋值语句应用变换,如下所示:

transformedCoords = transformMatrix * originalCoords;

对于2D绘图,原始坐标可能作为类型为vec2的属性进入顶点着色器。我们需要通过添加1.0作为z坐标,将属性值变为vec3。变换矩阵可能是一个统一变量,以便JavaScript端可以指定变换。这导致以下最小的GLSL ES 1.00顶点着色器用于处理2D变换。(对于GLSL ES 3.0版本,将“attribute”限定符替换为“in”,并在第一行添加“#version 300 es”。)

attribute vec2 a_coords;
uniform mat3 u_transform;
void main() {
    vec3 transformedCoords = u_transform * vec3(a_coords,1.0);
    gl_Position = vec4(transformedCoords.xy, 0.0, 1.0);
}

输入坐标以vec2形式给出,(x,y),但我们需要一个vec3(x,y,1),以乘以矩阵,所以main()的第一行添加1.0作为z坐标。在下一行中,gl_Position的值必须是vec4。对于2D点,z坐标应该是0.0而不是1.0,所以我们只使用transformedCoords的x和y坐标。

在JavaScript端,函数gl.uniformMatrix3fv用于为类型为mat3的统一变量指定值(见6.3.3小节)。要使用它,矩阵的九个元素应该以列主序存储在数组中。将仿射变换矩阵加载到mat3中的命令可能是这样的:

gl.uniformMatrix3fv(u_transform_location, false, [ a, b, 0, c, d, 0, e, f, 1 ]);

Transforms in 2D were covered in Section 2.3. To review: The basic transforms are scaling, rotation, and translation. A sequence of such transformations can be combined into a single affine transform. A 2D affine transform maps a point (x1,y1) to the point (x2,y2) given by formulas of the form

x2 = a*x1 + c*y1 + e
y2 = b*x1 + d*y1 + f

where a, b, c, d, e, and f are constants. As explained in Subsection 2.3.8, this transform can be represented as the 3-by-3 matrix

123

With this representation, a point (x,y) becomes the three-dimensional vector (x,y,1), and the transformation can be implemented as multiplication of the vector by the matrix.

To apply a transformation to a primitive, each vertex of the primitive has to be multiplied by the transformation matrix. In GLSL, the natural place to do that is in the vertex shader. Technically, it would be possible to do the multiplication on the JavaScript side, but GLSL can do it more efficiently, since it can work on multiple vertices in parallel, and it is likely that the GPU has efficient hardware support for matrix math. (It is, by the way, a property of affine transformations that it suffices to apply them at the vertices of a primitive. Interpolation of the transformed vertex coordinates to the interior pixels of the primitive will give the correct result; that is, it gives the same answer as interpolating the original vertex coordinates and then applying the transformation in the fragment shader.)

In GLSL, the type mat3 represents 3-by-3 matrices, and vec3 represents three-dimensional vectors. When applied to a mat3 and a vec3, the multiplication operator * computes the product. So, a transform can applied using a simple GLSL assignment statement such as

transformedCoords = transformMatrix * originalCoords;

For 2D drawing, the original coordinates are likely to come into the vertex shader as an attribute of type vec2. We need to make the attribute value into a vec3 by adding 1.0 as the z-coordinate. The transformation matrix is likely to be a uniform variable, to allow the JavaScript side to specify the transformation. This leads to the following minimal GLSL ES 1.00 vertex shader for working with 2D transforms. (For a GLSL ES 3.00 version, the "attribute" qualifier is replaced by "in", and a first line "#version 300 es" is added.)

attribute vec2 a_coords;
uniform mat3 u_transform;
void main() {
vec3 transformedCoords = u_transform * vec3(a_coords,1.0);
gl_Position = vec4(transformedCoords.xy, 0.0, 1.0);
}

The input coordinates are given as a vec2, (x,y), but we need a vec3, (x,y,1), to multiply by the matrix, so the first line of main() adds 1.0 as the z-coordinate. In the next line, the value for gl_Position must be a vec4. For a 2D point, the z-coordinate should be 0.0, not 1.0, so we use only the x- and y-coordinates from transformedCoords.

On the JavaScript side, the function gl.uniformMatrix3fv is used to specify a value for a uniform of type mat3 (Subsection 6.3.3). To use it, the nine elements of the matrix should be stored in an array in column-major order. For loading an affine transformation matrix into a mat3, the command would be something like this:

gl.uniformMatrix3fv(u_transform_location, false, [ a, b, 0, c, d, 0, e, f, 1 ]);

6.5.2 JavaScript 中的转换

Transforms in JavaScript

在JavaScript端处理变换,我们需要一种方法来表示这些变换。我们还需要跟踪一个“当前变换”,它是所有有效建模变换的乘积。每当应用旋转或平移等变换时,当前变换就会改变。我们需要在绘制复杂对象之前保存当前变换的副本,并在绘制后恢复它。通常,为此目的使用变换堆栈。你应该已经从2D和3D图形中熟悉了这种模式。这里的不同之处在于,我们需要的数据结构和操作没有内置在标准API中,因此我们需要一些额外的JavaScript代码来实现它们。

作为一个例子,我写了一个名为AffineTransform2D的JavaScript类来表示2D仿射变换。这是一个非常基础的实现。AffineTransform2D类型的对象数据由变换矩阵中的数字a, b, c, d, ef组成。类中包含用于将变换与缩放、旋转和平移变换相乘的方法。这些方法通过在右侧乘以适当的矩阵来修改它们应用的变换。以下是API的完整描述,其中transformAffineTransform2D类型的对象:

  • transform = new AffineTransform2D(a,b,c,d,e,f) — 创建一个具有本节开头所示矩阵的AffineTransform2D
  • transform = new AffineTransform2D() — 创建一个表示恒等变换的AffineTransform2D
  • transform = new AffineTransform2D(original) — 其中originalAffineTransform2D,创建original的副本。
  • transform.rotate(r) — 通过将其与旋转矩阵相乘,修改transform,以进行r弧度的旋转。
  • transform.translate(dx,dy) — 通过将其与平移矩阵相乘,修改transform,以进行(dx,dy)的平移。
  • transform.scale(sx,sy) — 通过将其与缩放矩阵相乘,修改transform,水平缩放因子为sx,垂直缩放因子为sy
  • transform.scale(s) — 执行均匀缩放,与transform.scale(s,s)相同。
  • array = transform.getMat3() — 返回一个包含九个数字的数组,按列主序包含transform的矩阵。

实际上,AffineTransform2D对象不是将仿射变换表示为矩阵。相反,它将系数a, b, c, d, ef作为对象的属性存储。有了这种表示,AffineTransform2D类的scale方法可以定义如下:

scale(sx, sy = sx) { // sy的默认值为sx的值。
    this.a *= sx;
    this.b *= sx;
    this.c *= sy;
    this.d *= sy;
    return this;
}

这段代码将“this”对象表示的变换与缩放矩阵相乘,乘法在右侧进行。其他方法有类似的定义,但你不需要理解代码就能使用API。


在绘制原语之前,必须将当前变换作为mat3发送到顶点着色器中,在那里mat3将用于变换原语的顶点。方法transform.getMat3()返回变换作为一个数组,可以将其传递给gl.uniformMatrix3fv,该函数将其发送到着色器程序。

要实现变换堆栈,我们可以使用AffineTransform2D类型对象的数组。在JavaScript中,数组没有固定长度,并且它带有push()pop()方法,使其可以作为堆栈使用。为了方便,我们可以定义pushTransform()popTransform()函数来操作堆栈。在这里,当前变换存储在名为transform的全局变量中:

let transform = new AffineTransform2D();  // 最初是恒等变换。

const transformStack = [];  // 一个数组,用作变换堆栈。

/**
 *  将当前变换的副本推入变换堆栈。
 */
function pushTransform() {
    transformStack.push( new AffineTransform2D(transform) );
}

/**
 *  从变换堆栈中移除顶部项,并将其设置为当前
 *  变换。如果堆栈为空,则不执行任何操作(也不会出错)。
 */
function popTransform() {
    if (transformStack.length > 0) {
        transform = transformStack.pop();
    }
}

这段代码来自示例程序webgl/simple-hierarchy2D.html,该程序演示了使用AffineTransform2D和变换堆栈来实现分层建模。这是该程序绘制的一个对象的截图:

123

下面是绘制它的代码:

function square() { 
    gl.uniformMatrix3fv(u_transform_loc, false, transform.getMat3());
    gl.bindBuffer(gl.ARRAY_BUFFER, squareCoordsVBO);
    gl.vertexAttribPointer(a_coords_loc, 2, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.LINE_LOOP, 0, 4);
}

function nestedSquares() {
    gl.uniform3f( u_color_loc, 0, 0, 1); // 设置颜色为蓝色。
    square();
    for (let i = 1; i < 16; i++) {
        gl.uniform3f( u_color_loc, i/16, 0, 1 - i/16); // 红/蓝混合。
        transform.scale(0.8);
        transform.rotate(framenumber / 200);
        square();
    }
}

函数square()绘制一个大小为1且以(0,0)为中心的正方形。正方形的坐标已经存储在缓冲区squareCoordsVBO中,a_coords_loc是着色器程序中属性变量的位置。变量transform保存当前建模变换,该变换必须应用于正方形。通过调用

gl.uniformMatrix3fv(u_transform_loc, false, transform.getMat3());

将变换发送到着色器程序。

第二个函数nestedSquares()绘制16个正方形。在绘制正方形时,它使用以下代码修改建模变换:

transform.scale(0.8);
transform.rotate(framenumber / 200);

这些命令的效果是累积的,因此每个正方形都比前一个小一点,并且比前一个旋转得更多。旋转量取决于动画中的帧号。

嵌套正方形是程序绘制的三个复合对象之一。函数绘制以(0,0)为中心的嵌套正方形。在主draw()例程中,我想移动它们并使它们变小一点。所以,它们是使用以下代码绘制的:

pushTransform();

transform.translate(-0.5,0.5);  // 将正方形中心移动到(-0.5, 0.5)。
transform.scale(0.85);          // 将大小从1减小到0.85。
nestedSquares();

popTransform();

pushTransform()popTransform()确保在绘制正方形时对建模变换所做的所有更改不会影响稍后绘制的其他对象。变换始终以与代码中出现的顺序相反的顺序应用于对象。

我强烈建议您阅读源代码并看看它绘制的内容。处理变换的基本思想都在那里。在我们继续学习3D之前,理解它们是很好的。

To work with transforms on the JavaScript side, we need a way to represent the transforms. We also need to keep track of a "current transform" that is the product all the individual modeling transformations that are in effect. The current transformation changes whenever a transformation such as rotation or translation is applied. We need a way to save a copy of the current transform before drawing a complex object and to restore it after drawing. Typically, a stack of transforms is used for that purpose. You should be well familiar with this pattern from both 2D and 3D graphics. The difference here is that the data structures and operations that we need are not built into the standard API, so we need some extra JavaScript code to implement them.

As an example, I have written a JavaScript class, AffineTransform2D, to represent affine transforms in 2D. This is a very minimal implementation. The data for an object of type AffineTransform2D consists of the numbers a, b, c, d, e, and f in the transform matrix. There are methods in the class for multiplying the transform by scaling, rotation, and translation transforms. These methods modify the transform to which they are applied, by multiplying it on the right by the appropriate matrix. Here is a full description of the API, where transform is an object of type AffineTransform2D:

  • transform = new AffineTransform2D(a,b,c,d,e,f) — creates a AffineTransform2D with the matrix shown at the beginning of this section.
  • transform = new AffineTransform2D() — creates an AffineTransform2D representing the identity transform.
  • transform = new AffineTransform2D(original) — where original is an AffineTransform2D, creates a copy of original.
  • transform.rotate(r) — modifies transform by composing it with the rotation matrix for a rotation by r radians.
  • transform.translate(dx,dy) — modifies transform by composing it with the translation matrix for a translation by (dx,dy).
  • transform.scale(sx,sy) — modifies transform by composing it with the scaling matrix for scaling by a factor of sx horizontally and sy vertically.
  • transform.scale(s) — does a uniform scaling, the same as transform.scale(s,s).
  • array = transform.getMat3() — returns an array of nine numbers containing the matrix for transform in column-major order.

In fact, an AffineTransform2D object does not represent an affine transformation as a matrix. Instead, it stores the coefficients a, b, c, d, e, and f as properties of the object. With this representation, the scale method in the AffineTransform2D class can defined as follows:

scale(sx, sy = sx) { // Default value for sy is the value of sx.
    this.a *= sx;
    this.b *= sx;
    this.c *= sy;
    this.d *= sy;
    return this;
}

This code multiplies the transform represented by "this" object by a scaling matrix, on the right. Other methods have similar definitions, but you don't need to understand the code in order to use the API.


Before a primitive is drawn, the current transform must be sent as a mat3 into the vertex shader, where the mat3 will be used to transform the vertices of the primitive. The method transform.getMat3() returns the transform as an array that can be passed to gl.uniformMatrix3fv, which sends it to the shader program.

To implement the stack of transformations, we can use an array of objects of type AffineTransform2D. In JavaScript, an array does not have a fixed length, and it comes with push() and pop() methods that make it possible to use the array as a stack. For convenience, we can define functions pushTransform() and popTransform() to manipulate the stack. Here, the current transform is stored in a global variable named transform:

let transform = new AffineTransform2D();  // Initially the identity.

const transformStack = [];  // An array to serve as the transform stack.

/**
 *  Push a copy of the current transform onto the transform stack.
 */
function pushTransform() {
    transformStack.push( new AffineTransform2D(transform) );
}

/**
 *  Remove the top item from the transform stack, and set it to be the current
 *  transform.  If the stack is empty, nothing is done (and there is no error).
 */
function popTransform() {
    if (transformStack.length > 0) {
        transform = transformStack.pop();
    }
}

This code is from the sample program webgl/simple-hierarchy2D.html, which demonstrates using AffineTransform2D and a stack of transforms to implement hierarchical modeling. Here is a screenshot of one of the objects drawn by that program:

123

and here's the code that draws it:

function square() { 
    gl.uniformMatrix3fv(u_transform_loc, false, transform.getMat3());
    gl.bindBuffer(gl.ARRAY_BUFFER, squareCoordsVBO);
    gl.vertexAttribPointer(a_coords_loc, 2, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.LINE_LOOP, 0, 4);
}

function nestedSquares() {
    gl.uniform3f( u_color_loc, 0, 0, 1); // Set color to blue.
    square();
    for (let i = 1; i < 16; i++) {
        gl.uniform3f( u_color_loc, i/16, 0, 1 - i/16); // Red/Blue mixture.
        transform.scale(0.8);
        transform.rotate(framenumber / 200);
        square();
    }
}

The function square() draws a square that has size 1 and is centered at (0,0) in its own object coordinate system. The coordinates for the square have been stored in a buffer, squareCoordsVBO, and a_coords_loc is the location of an attribute variable in the shader program. The variable transform holds the current modeling transform that must be applied to the square. It is sent to the shader program by calling

gl.uniformMatrix3fv(u_transform_loc, false, transform.getMat3());

The second function, nestedSquares(), draws 16 squares. Between the squares, it modifies the modeling transform with

transform.scale(0.8);
transform.rotate(framenumber / 200);

The effect of these commands is cumulative, so that each square is a little smaller than the previous one, and is rotated a bit more than the previous one. The amount of rotation depends on the frame number in an animation.

The nested squares are one of three compound objects drawn by the program. The function draws the nested squares centered at (0,0). In the main draw() routine, I wanted to move them and make them a little smaller. So, they are drawn using the code:

pushTransform();

transform.translate(-0.5,0.5);  // Move center of squares to (-0.5, 0.5).
transform.scale(0.85);          // Reduce size from 1 to 0.85.
nestedSquares();

popTransform();

The pushTransform() and popTransform() ensure that all of the changes made to the modeling transform while drawing the squares will have no effect on other objects that are drawn later. Transforms are, as always, applied to objects in the opposite of the order in which they appear in the code.

I urge you to read the source code and take a look at what it draws. The essential ideas for working with transforms are all there. It would be good to understand them before we move on to 3D.