Geometric transformations
In the previous article, we learned the fundamentals of WebGPU by drawing a colored triangle. However, to create more complex scenes, we need to be able to manipulate these shapes to move, resize, and rotate them. This is where geometric transformations come into play.
Let’s take a 5-pointed star as an example. This figure can be decomposed into 10 triangles, 5 inner ones forming a pentagon plus 5 outer ones, each with its base on a side of the pentagon:
The vertex coordinates are defined in a local space, independent of the final scene to be rendered. This reference system is called object space. A typical choice, which makes applying transformations more intuitive, is to organize the vertices so that the entire figure is centered at the origin and contained within a unit square. This way, without transformations in the vertex shader, if we wanted to draw multiple stars (or other shapes), they would all appear overlapping at the center of the scene.
A minimal shader that draws the star is the following:
const star = array<vec2f, 30>( // Outer star points5 collapsed lines
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 points5 collapsed lines
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 { return vec4f(star[index], 0.0, 1.0);}
@fragmentfn fs() -> @location(0) vec4f { return vec4(1, 1, 1, 1);}The star array contains the vertex coordinates of the star in object space. The geometry is defined statically
in the shader code, but we will see later how to pass data dynamically.
Since the star is composed of 10 triangles, the render pass invokes the vertex shader 30 times (3 vertices per triangle):
renderPass.draw(30);#Transformation functions
In mathematics, a planar figure is represented by the infinite set of its points. We can define the star in a generic and abstract way with the following set:
In the plane, a geometric transformation is a function:
that maps each point to another point , called the image of . Translation, rotation, and scaling are the most common transformations for changing the position, orientation, and size of planar figures.
Applying a transformation to a planar figure produces another planar figure , defined as:
that is, the set of all points of transformed by . The figure is called the image of through .
#Translation
To move the star to a different position on the plane, we can add an offset vector to the coordinates of its vertices:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let dX = 0.3; let dY = 0.2; return vec4f( star[index] + vec2f(dX, dY), 0.0, 1.0 );}In formula:
Below is an interactive widget showing the effect of translation based on the offset:
A positive offset moves the star to the right (X axis) or upward (Y axis), while a negative offset moves it to the left or downward.
If the center of the figure in object space coincides with the origin, then the translation vector directly indicates where the center of the transformed figure will be positioned. In other words, translating the figure is equivalent to deciding where we want its center to be.
#Scaling
To change the size of the star, we can multiply the coordinates of its vertices by a scale factor :
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let sX = 0.5; let sY = 0.5; return vec4f( star[index] * vec2f(sX, sY), 0.0, 1.0 );}In formula:
Below is an interactive widget showing how the star changes shape and size as the scale factor varies:
A scale factor less than 1 reduces the star’s size, while a factor greater than 1 enlarges it. If the
scale factor is negative, the star is reflected along the corresponding axis. It is also possible to apply a
non-uniform scale using different values for sX and sY, thus changing the star’s size independently on both axes.
If the figure in object space is contained within a unit square, then applying a scale factor means that the scaled figure will be contained in a rectangle of dimensions and .
#Rotation
The following code implements rotation:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let theta = radians(45.0); let cosT = cos(theta); let sinT = sin(theta); let x = star[index].x; let y = star[index].y; let rotated = vec2f( x * cosT - y * sinT, x * sinT + y * cosT );
return vec4f(rotated, 0.0, 1.0);}The rotation is counterclockwise, pivots around the origin, and uses an angle expressed in radians. The corresponding formula is:
A positive angle rotates counterclockwise, while a negative angle rotates in the opposite direction. Below is an interactive widget showing how the star rotates based on the specified angle:
To derive the rotation formula, let’s start by considering a point and its rotation around the origin:
The coordinates and are relative to the axes , . We can make this dependency explicit by writing:
Let’s now consider the axes and obtained by applying the rotation to the axes :
We can observe that the coordinates of with respect to the rotated axes and are equal to the coordinates of with respect to the original axes and , that is:
At this point, it suffices to compute by rotating the axes .
Recalling that and are, by definition, the x and y coordinates of the point rotated by radians counterclockwise around the origin, we immediately have:
To compute , we exploit the fact that it must be perpendicular to . In 2D, to find a vector orthogonal to a given one, it suffices to swap its components and negate one. Applying this rule, we obtain:
Having the coordinates of the rotated axes, we can finally compute the coordinates of the rotated point:
which is the formula implemented by the shader.
#Shearing
Shearing tilts the star along one of the axes, altering the internal angles of the figure without modifying its area. The effect is achieved by adding a term proportional to the coordinates of one axis to the coordinates of the other:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let shX = 0.5; let shY = 0.2; let x = star[index].x; let y = star[index].y;
return vec4f( x + shX * y, y + shY * x, 0.0, 1.0 );}In formula:
Below is an interactive widget showing the shearing effect:
A positive factor tilts the figure in the corresponding direction, while a negative factor tilts it in the opposite direction. Shearing can be applied on only one axis or both. An interesting aspect is that geometric rotation can be obtained by composing a horizontal shear with a vertical one.
#Composing transformations
In practice, a single transformation is rarely sufficient to position or modify an object as desired. It is often necessary to apply a series of transformations in sequence. For example, we might want to scale an object to resize it and then rotate it around its center to orient it correctly. The combination of geometric transformations is achieved by applying them one after another. To illustrate this process, let’s look at a shader that first applies scaling, then rotation, and finally translation:
@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);}You can find the complete source code for this example on GitHub.
Below is an interactive widget that allows varying the translation, scale, and rotation parameters:
The order in which transformations are applied is fundamental, as it affects the final result. For example, rotating and then translating a figure can give a different result than translating and then rotating.
#Conclusions and next steps
In this article, we defined the most common geometric transformations. In the next one, we will see how to use matrices and homogeneous coordinates to handle all the examined transformations in a flexible way.