Transformation matrices
In the previous article, we saw how to compose multiple transformations by applying them in sequence:
const star = array<vec2f, 30>(13 collapsed lines
// Outer star points vec2f(0.000000, -0.197140), vec2f(0.309011, -0.425297), vec2f(0.187467, -0.060929), vec2f(0.187467, -0.060929), vec2f(0.500000, 0.162443), vec2f(0.115866, 0.159500), vec2f(0.115866, 0.159500), vec2f(0.000000, 0.525707), vec2f(-0.115866, 0.159500), vec2f(-0.115866, 0.159500), vec2f(-0.500000, 0.162443), vec2f(-0.187467, -0.060929), vec2f(-0.187467, -0.060929), vec2f(-0.309011, -0.425297), vec2f(0.000000, -0.197140),
// Inner star points vec2f(0.000000, -0.197140), vec2f(0.000000, 0.000000), vec2f(0.187467, -0.060929), vec2f(0.187467, -0.060929), vec2f(0.000000, 0.000000), vec2f(0.115866, 0.159500), vec2f(0.115866, 0.159500), vec2f(0.000000, 0.000000), vec2f(-0.115866, 0.159500), vec2f(-0.115866, 0.159500), vec2f(0.000000, 0.000000), vec2f(-0.187467, -0.060929), vec2f(-0.187467, -0.060929), vec2f(0.000000, 0.000000), vec2f(0.000000, -0.197140));
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { // Step 1: Scale let sX = 0.5; let sY = 0.5; var transformed = vec2f( star[index].x * sX, star[index].y * sY );
// Step 2: Rotation let theta = radians(45.0); let cosT = cos(theta); let sinT = sin(theta); transformed = vec2f( transformed.x * cosT - transformed.y * sinT, transformed.x * sinT + transformed.y * cosT );
// Step 3: Translation let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}The implementation, however, is not very flexible because it is limited to the composition of scale, rotation, and translation. If we wanted to apply different transformations, or change their order, we would be forced to modify the source code each time.
In this article, we will define geometric transformations using matrices, a tool from linear algebra. Being able to uniformly represent transformations and compositions, matrices make the code generic and reusable for any sequence of transformations.
The final code is available in the 04-matrix branch of the GitHub repository.
#Matrices
A rectangular matrix of dimensions is a “table” of real numbers:
where is the number of rows, the number of columns, and are the elements of the matrix.
A matrix of dimensions is called a row matrix:
While a matrix of dimensions is called a column matrix:
Finally, a matrix of dimensions is called a square matrix of order :
Below are shown, in order, a rectangular matrix, a square matrix of order , and a row matrix:
#Matrix product
Given a matrix of dimensions and a matrix of dimensions , we call the product of and a matrix of dimensions defined as:
In other words, the element is obtained by combining the -th row of matrix with the -th column of matrix . For example:
Product of two 2x2 matrices. Tap or click on elements to highlight the rows/columns involved in the calculation.
Where the individual elements are:
The product is defined only between “compatible” matrices, that is, when the number of columns of the first matrix equals the number of rows of the second. The result will have the number of rows of the first matrix and the number of columns of the second. A simpler way to remember this rule is the example:
#Transposition
We call the transpose matrix or simply transpose of the matrix of dimensions obtained by swapping rows with columns. For example:
The transpose of the product of two matrices equals transposing the individual matrices and swapping their order:
A matrix of dimensions is called symmetric if , that is, if it coincides with its transpose:
The elements are symmetric with respect to the main diagonal ().
#Identity matrix
The identity matrix is a square matrix in which all elements of the main diagonal equal , while the others are :
The identity matrix is the neutral element with respect to the product operation, that is:
where is a square matrix of order .
#Inverse matrix
A square matrix of order is said to be invertible if there exists a square matrix of order such that:
In that case, is called the inverse of . A non-invertible matrix is also called singular.
#Point representation
In the following sections, we will use matrices to define transformation functions. In order for these transformations, expressed in matrix form, to act on points, it is necessary to represent the points themselves in a mathematical format compatible with matrix operations.
A point is represented by its coordinates, which we can denote using a vector, a row matrix, or a column matrix:
All three notations define the exact same concept; the difference is only at the notation level. Therefore, we will consider the three forms as equivalent.
#Linear transformations
A linear transformation is a function of the form:
where are real coefficients. The name derives from the fact that the transformed coordinates are a linear combination of the original point’s coordinates. Linear transformations can be expressed as the product of a square matrix of order and a column matrix:
Alternatively, it can be defined as the product of a row matrix and a square matrix of order :
In the following sections, we will describe points using column matrices, so we will use the first form.
Linear transformations preserve parallelism between lines, but not angles and areas: this means that parallel lines before the transformation will remain parallel after, but angles formed by pairs of lines could change their amplitude and geometric figures could undergo a variation in their area.
In other words, if we take a parallelogram, after a linear transformation it will remain a parallelogram, but it might no longer be a rectangle or a square. The directions of lines are modified, and distances are rescaled in a non-uniform way, causing angle distortion and area variation.
#Scale
The scale transformation
can be written as:
Performing the matrix product yields:
#Rotation
The rotation
can be written as:
Performing the matrix product yields:
#Shear
The shear transformation
can be written as:
Performing the matrix product yields:
#Matrices in WGSL
The WGSL language supports matrices of dimensions between and with the primitive types matCxRf, where
is the number of columns and the number of rows. The notation is reversed compared to mathematical convention, which indicates
the number of rows first and then the number of columns. For example, the type mat2x4f represents a matrix (four rows and two columns):
The binary encoding of matCxRf types is column-major: this means that matCxRf is structured
in memory as an array of vectors of length . For example, mat2x4f is conceptually equivalent to array<vec4f, 2>:
Care must be taken not to confuse the mathematical notation of matrices, which is row-major, with the binary representation in WGSL, which is column-major. To better understand this concept, let’s examine a non-symmetric matrix, such as the shear matrix:
The mat2x2f constructor follows the column-major representation and therefore accepts elements by column, not by row:
fn shearMatrix(shx: f32, shy: f32) -> mat2x2f { return mat2x2f(1, shy, shx, 1);}
fn shearMatrixTransposed(shx: f32, shy: f32) -> mat2x2f { return mat2x2f( 1, shx, shy, 1 );}The indentation of the shearMatrixTransposed function visually mirrors the mathematical notation, which is row-major, but the result in memory will be the transposed matrix:
If we had considered a symmetric matrix, there would have been no difference, because, by definition, the transpose of a symmetric matrix coincides with the matrix itself.
#Product in WGSL
The product in WGSL is defined both between matrices and between matrices and vectors. We can update our example shader by replacing the rotation and scale transformations with matrices:
fn scaleMatrix(sX: f32, sY: f32) -> mat2x2f { return mat2x2f( sX, 0, 0, sY );}
fn rotationMatrix(theta: f32) -> mat2x2f { let cosT = cos(theta); let sinT = sin(theta); return mat2x2f( cosT, sinT, -sinT, cosT );}
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { // Step 1: Scale var transformed = scaleMatrix(0.5, 0.5) * star[index];
// Step 2: Rotation transformed = rotationMatrix(radians(45.0)) * transformed;
// Step 3: Translation let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}We can notice a first advantage of using matrices: the code for applying two different transformations, scale and rotation, is always a matrix product.
The scaleMatrix and rotationMatrix functions build the matrices to apply in the form , where is a column vector.
The shader code follows this convention and applies the products in the form M * p rather than p * M.
In general, in WGSL vectors are considered neither rows nor columns; however, both expressions M * p and p * M are valid.
In order for the product to always be well-defined, vectors are interpreted as rows or columns depending on the order
in which they appear in the product with a matrix. The two expressions are computed as follows:
The two results do not differ merely in being a row vector rather than a column vector (irrelevant in WGSL), but the elements themselves are different! For this reason, it is essential to establish a convention and always apply it consistently.
As with the code example shown, in the following sections we will always use matrices in the form M * p,
paying attention to the column-major layout in the matCxRf constructors.
#Composing transformations
The updated shader version implements the formula:
where is the rotation matrix, is the scale matrix, is the vertex to transform, and is the translation vector. The logical sequence of steps is:
- The point is scaled via matrix product with , obtaining a first intermediate point.
- The computed point is rotated via matrix product with , thus obtaining the second intermediate point.
- Finally, the second intermediate point is translated via vector addition with , thus obtaining the final result.
In particular, the transformations and are performed in two separate steps; let’s now see how to unify them. In general, the matrix product has the associative property, that is:
Leveraging this property in the shader formula ( is a column matrix), we can write:
Setting , we can simplify to:
The result is significant: the single matrix is equivalent to the transformations and in sequence, that is, it represents their composition. At this point, we can further simplify the shader code:
fn scaleMatrix(sX: f32, sY: f32) -> mat2x2f {4 collapsed lines
return mat2x2f( sX, 0, 0, sY );}
fn rotationMatrix(theta: f32) -> mat2x2f {6 collapsed lines
let cosT = cos(theta); let sinT = sin(theta); return mat2x2f( cosT, sinT, -sinT, cosT );}
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { var worldTransform = rotationMatrix(radians(45.0)) * scaleMatrix(0.5, 0.5);
// Step 1: Scale + Rotation var transformed = worldTransform * star[index];
// Step 2: Translation let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}The advantage of this approach may seem limited in our example, since the overall number of operations
does not change. However, in real cases, worldTransform is usually a shader parameter and, consequently,
is not recomputed for each vertex.
Generalizing, suppose we want to apply in order a series of linear transformations to a point :
Repeatedly leveraging the associative property, we can represent the composition through a single matrix and use it directly:
By doing so, the single matrix encodes the original sequence, abstracting the vertex shader from the type, number, and order of transformations to apply. 😮😱
Unlike multiplication of real numbers, multiplication of square matrices generally does not have the commutative property, that is, . The order in which transformations are performed is relevant, as we had seen intuitively in the previous article. It is important to note that the order in which matrices appear in the definition of is the reverse of the order in which the transformations are “applied.” The product is equivalent to transforming first with , then with , and so on.
#Conclusions
In this article, we introduced matrices and some of their properties. We saw that scale, rotation, and shear are linear transformations and therefore can be expressed in the form , where is a square matrix of order .
However, so far in the discussion, we have intentionally left translation aside. As we will see, it is not possible to represent a translation in the plane with order square matrices!
How to solve this problem? The answer in the next article, dedicated to projective spaces and homogeneous coordinates.