Linear Algebra for GLSL Shaders
11 / Transform the space

Rotate
the space.

Turn two axes by the same angle. Use them to rotate a shape, choose its pivot, and find the original coordinates behind each pixel.

25–35 min with experimentsGLSL ES 3.00 / WebGL 2After chapter 10
What we want to make

A turn with a chosen center.

Turn the icon ↓

Let's rotate an L-shaped icon. Its unequal directions make the turn visible. First we will turn it around its original origin. Then we will move the pivot outside the icon and watch its position and orientation change together.

An asymmetric L icon rotated thirty degrees counterclockwise around the origin. The orange and green axes remain perpendicular. Point P starts at (0.6,0) and moves to approximately (0.51962,0.3).
Same lengths · same right angle · a new orientationAt +30°, (0.6, 0) becomes approximately (0.51962, 0.3).

In chapter 10, each matrix column told us where one axis step went. A rotation makes a particular choice: both steps keep length one, and their right angle stays intact. We only change their orientation.

One point on a circle

Start at (1,0), one unit to the right of the origin. Move this point around a circle of radius one. Measure the angle from the positive X axis, with positive angles going counterclockwise in our Y-up coordinates.

The point's horizontal coordinate is called the cosine of the angle. Its vertical coordinate is the sine. Those names give us the first rotated axis:

vec2 axisX = vec2(cos(angle), sin(angle));

At 0° the point is (1,0). At +90° it is (0,1); at 180° it is (-1,0). A negative quarter-turn gives (0,-1). Because the point stays on the unit circle, cos(angle)² + sin(angle)² = 1.

GLSL's trigonometric functions take radians. One radian is the angle that cuts off an arc as long as the circle's radius. A full turn is 2π radians, a half-turn is π, and a quarter-turn is π/2. Convert degrees using degrees * π / 180; GLSL also has radians(degrees).

Our controls show degrees, but the host converts them once before sending uAngle. The shader receives radians already. Applying radians(uAngle) again would make a quarter-turn much too small.

Turn the second axis

The Y axis starts a quarter-turn ahead of X. It must keep that relationship. For a vector (c,s), the perpendicular vector on its counterclockwise side is (-s,c). Put these two vectors into the columns:

mat2 rotation(float angle) {
    float c = cos(angle);
    float s = sin(angle);
    return mat2(c, s, -s, c);
}

The arguments still run down columns: (c,s), then (-s,c). At +90°, these become (0,1) and (-1,0). The original X step points up, and the original Y step points left. Both turned counterclockwise.

vec2 local = vec2(0.6, 0.0);
vec2 world = rotation(angle) * local;
// +90 degrees: world is approximately (0.0, 0.6).
// -90 degrees: world is approximately (0.0, -0.6).

A useful first test is a point on the positive X axis. If it goes down for a positive quarter-turn, inspect the signs and constructor order before changing the rest of the shader.

Why the shape stays rigid

Both columns have squared length c*c + s*s = 1. Their dot product is c*(-s) + s*c = 0, so they stay perpendicular. The matrix turns our coordinate frame without stretching or shearing it.

For any vector (x,y), the output is (cx−sy, sx+cy). Square both components and add: the cross terms cancel, leaving (c*c+s*s)*(x*x+y*y). This is the original squared length. The same cancellation preserves dot products between two transformed vectors, so angles between nonzero vectors stay the same.

For a shape, apply that reasoning to the displacement between any two of its points. Distances remain unchanged, including when we later choose an external pivot. Floating-point sine, cosine, and arithmetic introduce small errors; the laboratory readouts are approximate checks, not exact proofs.

Choose the pivot

Multiplication by R alone keeps the origin fixed. To rotate around another point, first measure a displacement from that point, rotate the displacement, then restore the point's position:

mat2 R = rotation(angle);
vec2 world = pivot + R * (local - pivot);

local is a point in the original, unrotated drawing. pivot uses that same coordinate frame and the same world-unit scale. Here these frames coincide before the transformation. The subtraction makes the pivot our temporary origin; the addition returns to the shared frame.

Substitute local=pivot: the displacement is zero, so the result is the pivot again. At angle zero, every point returns to itself. These two tests catch many mistakes in the order of the operations.

For P=(0.6,0), choose pivot=(0.8,0.4). The displacement is (-0.2,-0.4). A positive quarter-turn changes it to (0.4,-0.2); adding the pivot gives (1.2,0.2). The distance to the pivot stays √0.2, approximately 0.44721.

The icon's original origin O=(0,0) moves too: at that angle it reaches (1.2,-0.4). Its center travels around the pivot while its axes turn. Moving the center along a circle while keeping the icon upright would be a different motion. Watch both position and orientation to distinguish them.

Our L's origin is the center of its bounding square, not its area centroid. The extra subtraction and addition handle the pivot explicitly; they do not give mat2 a hidden translation component.

Ask where a pixel came from

The forward formula tells us where an original point goes. A fragment shader faces the opposite question. Its output pixel is already fixed at world position p. Which original point should supply its color?

Undo the turn. Subtract the same pivot, rotate by the negative angle, and add the pivot back:

vec2 local = uPivot + rotation(-uAngle) * (p - uPivot);
float d = iconDistance(local);

Turning by an angle and then by its opposite restores the original vector: rotation(-angle) * rotation(angle) is identity. This is the inverse for this particular transformation. We will develop general inverse matrices later.

At +90° around the origin, P=(0.6,0) moves to world (0,0.6). The pixel there applies −90° and recovers (0.6,0). It can now evaluate the original drawing. If we use +90° for this lookup instead, the rendered icon turns the other way.

Keep the two jobs separate in your reasoning: forward mapping moves a point; backward sampling finds the original coordinate for an output pixel. The opposite signs describe the same visible turn. The lab's axes and cyan marker use forward mapping, while the filled icon uses backward sampling.

Try it

Move the pivot. Keep the turn.

LIVE GLSL / WEBGL 2

Change the angle, then move the gold pivot. Compare the filled icon with its original outline. The orange X and green Y guides follow the rotated origin; the cyan point starts at P=(0.6,0).

The fixed view spans −2 to 2 vertically. Both axes use the same scale: one world unit occupies a quarter of the canvas height. Large orbits may leave the view; moving the pivot does not zoom it.

Static default: an asymmetric L icon rotates counterclockwise by 30 degrees around the origin. The local sample (0.6,0) moves to about (0.51962,0.3). Orange and green arrows show the rotated axes; the gold cross is the pivot.
Paused. Play makes one counterclockwise turn from the current angle.
Blue: rotated L iconGray outline: original poseCyan: rotated sample PGold cross: pivotOrange: rotated X axisGreen: rotated Y axis

Fixed view half-height: 2. Equal units; y runs from −2 to 2. One unit is one quarter of the canvas height.

Local P=(0.6,0) is fixed. The white point O′ is the transformed object origin, originally the center of the L's bounding box. The axis arrows are drawn at length 0.6; the columns of R below have length 1. P coincides with the drawn X-axis tip. The faint circle traces P's orbit around the pivot.

Angle and pivot

Positive angles rotate points counterclockwise. The host converts degrees to radians once; the shader receives uAngle. Angle presets keep the pivot.

Pivot · fixed point in the shared coordinate plane

External pivot sets (0.8,0.4); Origin pivot sets (0,0). Pivot presets keep the angle. Each pivot component stays between −1 and 1.

Play adds one full turn in eight seconds, then stops at the exact starting orientation. Pressing Pause stops at the current angle; Play starts a new full turn there. Editing any control or the shader pauses playback. Reduced motion, a hidden or offscreen preview, and a lost graphics context also stop it; playback never resumes automatically.

Reset lab restores 30°, pivot (0,0), all overlays, and paused playback. Reset code restores the shader separately.

Preparing the live shader.

Angle · degrees
30.00000°
Shader angle · radians
0.52359879
cos(angle), sin(angle)
0.86603, 0.50000
First column · R[0] = (c,s)
(0.86603, 0.50000)
Second column · R[1] = (−s,c)
(−0.50000, 0.86603)
Column lengths · |R[0]|, |R[1]|
1.00000, 1.00000
Perpendicular columns · dot(R[0],R[1])
0.00000
Pivot · unchanged by rotation
(0.00000, 0.00000)
Rotated sample · P=(0.6,0) → P′
(0.51962, 0.30000)
Object origin · O=(0,0) → O′
(0.00000, 0.00000)
Radius to pivot · before, after
0.60000, 0.60000
Radius difference · absolute
0.00e+0

In the default setup, the pivot is the object origin. O stays fixed while the icon and P turn through 30° around it.

The rotated icon and sample are inside the fixed view.

Readouts are host float32 reference calculations for the built-in rotation formulas, not GPU measurements. The host rounds the angle uniform, trigonometric results, products, and sums; the GPU's sin and cos may round differently. Small residuals are expected. Shader edits change the live drawing but do not change these reference numbers. The local sample and icon stay fixed; changing the pivot changes the forward and backward transformations.

Click or drag to place the gold pivot. Focus the diagram and use arrow keys for 0.05 world units, or Shift for 0.25. On a touch screen, tap to place it or use the controls; swiping scrolls the page. Pointer edits pause playback and respect the −1 to 1 pivot limits.

Open the complete shader edit · run · reset

rotation(angle) constructs its two columns explicitly. Forward mapping moves P and O. The pixel uses rotation(−angle) to find the local point where the L's mask is sampled. The signed box, line, and arrow helpers below are supplied drawing code.

⌘ / Ctrl + Enter to run

The host code converts degrees to radians and supplies the pivot. Play makes one full turn; manual edits, leaving the scene, or hiding the page stop playback. Reduced-motion preferences keep the experiment manual.

A complete rotation shader

Paste this smaller version into the editor. Two boxes make the asymmetric icon; their drawing helper is separate from the rotation. The full default shader adds the grid, orbit guide, and display toggles.

#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uAngle; // Radians, converted once by the host.
uniform vec2 uPivot;
out vec4 fragColor;

mat2 rotation(float angle) {
    float c = cos(angle), s = sin(angle);
    return mat2(c, s, -s, c);
}

float boxDistance(vec2 p, vec2 halfSize) {
    vec2 q = abs(p) - halfSize;
    return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}

float iconDistance(vec2 p) {
    float upright = boxDistance(p - vec2(-0.3, 0.0), vec2(0.1, 0.4));
    float foot = boxDistance(p - vec2(0.0, -0.3), vec2(0.4, 0.1));
    return min(upright, foot);
}

float segmentDistance(vec2 p, vec2 a, vec2 b) {
    vec2 ab = b - a;
    float lenSq = dot(ab, ab);
    float t = lenSq > 0.00000001
        ? clamp(dot(p - a, ab) / lenSq, 0.0, 1.0) : 0.0;
    return length(p - (a + t * ab));
}

float stroke(float d, float edge) {
    return 1.0 - smoothstep(0.008, 0.008 + edge, d);
}

void main() {
    vec2 p = 2.0 * (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
    float edge = 4.0 / uResolution.y;
    mat2 R = rotation(uAngle);
    vec2 local = uPivot + rotation(-uAngle) * (p - uPivot);
    vec3 color = vec3(0.04, 0.06, 0.09);
    color = mix(color, vec3(0.28, 0.35, 0.41),
                stroke(abs(iconDistance(p)), edge));
    float icon = 1.0 - smoothstep(-edge, edge, iconDistance(local));
    color = mix(color, vec3(0.24, 0.65, 0.85), icon);
    vec2 origin = uPivot + R * (-uPivot);
    color = mix(color, vec3(1.0, 0.57, 0.28),
        stroke(segmentDistance(p, origin, origin + 0.6 * R[0]), edge));
    color = mix(color, vec3(0.40, 0.87, 0.56),
        stroke(segmentDistance(p, origin, origin + 0.6 * R[1]), edge));
    vec2 point = uPivot + R * (vec2(0.6, 0.0) - uPivot);
    color = mix(color, vec3(0.24, 0.88, 0.94),
        1.0 - smoothstep(0.035, 0.035 + edge, length(p - point)));
    color = mix(color, vec3(1.0, 0.81, 0.31),
        stroke(abs(length(p - uPivot) - 0.06), edge));
    fragColor = vec4(color, 1.0);
}

The soft edge is measured in the original drawing's units. Rotation preserves distances, so that width stays consistent after the turn. Scaling will require more care in the next chapter.

Your turn

Use Reset lab and Reset code first.

  1. With the pivot at the origin, predict P's position for +90° and −90°. Check the two columns' lengths and dot product. Does the L keep its shape?
  2. Choose External pivot, (0.8,0.4). Predict P and the transformed origin O at +90° and −90°. Compare the distance from P to the pivot before and after each turn.
  3. Use Play and watch the icon's axes as its origin moves. Explain how this differs from sliding an upright icon around a circle. Pause, then put the pivot at P=(0.6,0): which point stays fixed?
  4. Paste the smaller shader above. At +90° around the origin, change only rotation(-uAngle) in the sampling line to rotation(uAngle). Compare the icon with its forward-mapped axes. Restore the minus sign and explain why they agree again.
A hint

A positive quarter-turn maps (x,y) to (-y,x). Apply that rule to the displacement from the pivot, then add the pivot. For the sampling experiment, ask which original coordinate the pixel should recover.

Check your result

At the origin, P reaches (0,0.6) and (0,-0.6). With the external pivot, +90° gives P=(1.2,0.2), O=(1.2,-0.4); −90° gives P=(0.4,0.6), O=(0.4,1.2). P stays about 0.44721 units from that pivot. The transformed axes have unit lengths and dot product zero, within rounding; their drawn guides are shortened to 0.6 units.

At pivot=P, P stays fixed while the rest turns. An upright icon sliding along a circle changes position without changing orientation. The wrong sampling sign rotates the filled L clockwise while the guides turn counterclockwise. Negative-angle sampling restores their agreement.

What you can do now

Build a rotation from two turned axes, predict a quarter-turn around any pivot, and explain the opposite sign used for sampling. Check that lengths and right angles survive. Next, we will deliberately change those properties with scale and shear.