Linear Algebra for GLSL Shaders
16 / Transform the space

Give translation a place
in the matrix.

Move, turn, and resize a card around a chosen pivot. Then ask whether the pointer is inside it. One matrix can describe the whole trip.

30–40 min with experimentsGLSL ES 3.00 / WebGL 2After chapters 13–15
What we want to make

A card you can transform and test.

Try the transform editor ↓

The card turns green when the pointer is inside its local rectangle. A gold point follows the whole transformation. Switch that same input to a direction, and translation stops affecting its result.

A 3 by 3 affine matrix transforms a point with w=1, leaves translation out of a direction with w=0, and maps a world pointer back into a card's local coordinates.
One affine matrix · two kinds of inputTransform the drawing forward. Test the pointer backward.

We can now compose transforms, reverse them, and recognize collapse. This chapter puts those skills into a small transform editor. The new piece is a matrix that includes translation instead of keeping it beside the matrix.

Give translation a column

A linear mat2 cannot move the origin. Multiplying any matrix by the zero vector still gives zero. Our previous rule therefore needed an extra term:

vec2 world = A * local + offset;

A linear transformation followed by a translation is an affine transformation. For the invertible transforms used here, straight lines remain straight and parallel lines remain parallel. A singular affine map can collapse a line to a point. When offset is nonzero, it is not a linear map of the original 2D plane, because it no longer fixes the origin.

Add one component to the input. For a point, set this extra component to 1. It gives a third matrix column something to multiply:

mat3 M = mat3(vec3(A[0], 0.0),
              vec3(A[1], 0.0),
              vec3(offset, 1.0));
vec3 world = M * vec3(local, 1.0);

The constructor still takes columns. The first two contain the linear axes, extended with zero. The third contains the offset, extended with one. Displayed in rows, the same matrix is:

[ A[0].x   A[1].x   offset.x ]
[ A[0].y   A[1].y   offset.y ]
[    0        0        1     ]

Multiplication adds local.x times the first column, local.y times the second, and 1 times the third. The first two output components are exactly A*local+offset. The last component remains 1.

These are homogeneous coordinates. We call the extra component w, although it occupies .z in this GLSL vec3. It is not a new depth coordinate in this chapter. Our affine matrices keep the bottom row (0,0,1), so points keep w=1 and taking .xy gives their ordinary 2D coordinates. Perspective will need a separate step later.

Choose point or direction

A point has a position. A direction is a displacement between positions. Moving the entire drawing should move its points without adding that movement to each displacement.

vec2 worldPoint = (M * vec3(localPoint, 1.0)).xy;
vec2 worldDirection = (M * vec3(localDirection, 0.0)).xy;

With w=0, the third column contributes zero. Rotation and scale still act on the direction; translation does not. A direction is not automatically a unit vector, and non-uniform scale can change both its length and its angle.

For translation alone by (0.6,−0.3), the input (0.4,0.2) becomes point (1,−0.1), but direction (0.4,0.2). The lab draws the direction arrow from the fixed world origin so you can see that its components stay unchanged as translation moves the card.

This convention also follows from subtracting points. If two points become A*p+offset and A*q+offset, their difference is A*(p−q): the offsets cancel. Their homogeneous components subtract to 1−1=0. That is why a displacement belongs in the w=0 form.

Build the whole chain

Make a translation helper by using identity axes and placing the offset in the third column:

mat3 translation(vec2 t) {
    return mat3(vec3(1.0, 0.0, 0.0),
                vec3(0.0, 1.0, 0.0),
                vec3(t, 1.0));
}

Extend rotation and scale in the same way, but give them a third column (0,0,1). The complete shader below includes both constructors. Now their composition is ordinary matrix multiplication:

mat3 M = translation(offset) * rotation(angle) * scaling(scale);

Read from the input outward: scale first, rotate second, translate last. The resulting matrix stores the complete mapping. Reordering the factors changes the result, just as in chapter 13.

For two affine maps A₂*(A₁*p+t₁)+t₂, the combined linear part is A₂*A₁ and the combined offset is A₂*t₁+t₂. Multiplying their mat3 matrices carries out this bookkeeping for us. Earlier translations may be rotated or scaled by later factors.

Put the pivot in the product

Let c be the pivot in the card's original coordinate system. It may be inside or outside the card. To scale and rotate around it, first subtract c, apply scale and rotation, then add c back:

mat3 aroundPivot = translation(c) * rotation(angle)
                 * scaling(scale) * translation(-c);
mat3 M = translation(offset) * aroundPivot;

Before the final offset, the pivot stays fixed: c becomes zero, the linear operations keep it zero, and adding c restores it. After the offset, its world position is c+offset. Changing the pivot does not mean moving the world origin.

Writing A=R*S, the complete rule expands to A*(local−c)+c+offset. The third column therefore contains the effective translation b=c+offset−A*c, not simply the offset control. The first two columns still contain A.

At Combined, angle=90°, scale=(1.5,0.75), c=(0.2,−0.1), and offset=(0.5,0.25). The local sample V=(0.4,0.2) passes through (0.2,0.3), (0.3,0.225), (−0.225,0.3), (−0.025,0.2), then world=(0.475,0.45).

// Combined, ideal values shown in row order:
[ 0    -0.75    0.625 ]
[ 1.5   0      -0.15  ]
[ 0     0       1     ]

As a direction, the same input becomes (−0.15,0.6). Every translation factor multiplies w=0, including the pivot translations. Changing offset or pivot leaves that direction unchanged while the card and point may move.

Test the pointer locally

Our card is a rectangle with local half-size (0.6,0.4). A rotated rectangle is awkward to test in world coordinates. Its local test stays short:

vec2 localPointer = (inverse(M) * vec3(pointerWorld, 1.0)).xy;
bool inside = all(lessThanEqual(abs(localPointer), vec2(0.6, 0.4)));

The pointer is a world point, so it needs w=1. The inverse removes the entire forward chain, including its effective translation. At Combined, world pointer (0.475,0.45) recovers local (0.4,0.2), which is inside the card.

Use the same inverse for rendering each pixel and for testing the pointer. The card's mask and interaction then share one coordinate system. The host readouts mirror the built-in formulas with float32 reference arithmetic; the shader performs its own lookup and highlight. Editing the shader does not update those references.

The hit test includes the mathematical rectangle boundary. Float rounding near that boundary can change the result, and the antialiased visual edge can extend outside it. Test clearly inside or outside when checking the transformation; do not treat a faint edge pixel as an exact hit boundary.

Scale stays in [0.5,2] on both axes, so this family has an inverse throughout. Its linear determinant lies in [0.25,4]. We avoid collapse here to keep pointer interaction available; chapter 15 explains what a rejected inverse needs instead.

Try it

Transform the card. Find the pointer.

LIVE GLSL / WEBGL 2

Drag the white pointer or edit its world coordinates. Green fill means the shader's local rectangle test is inside. Point/direction mode changes how the fixed local input V=(0.4,0.2) is transformed; it does not change the card's matrix.

The square view has fixed half-height 4: one world unit is one eighth of the canvas height. Pointer number fields allow [−6,6]. Extreme settings can move geometry outside the view; the clipping note and full numerical readouts remain available.

Static Combined setup: angle 90 degrees, scale (1.5,0.75), offset (0.5,0.25), local pivot (0.2,-0.1). Point (0.4,0.2,1) maps to (0.475,0.45,1), while direction (0.4,0.2,0) maps to (-0.15,0.6,0).
Gold: point mode resultCyan: direction from world originWhite cross: world pointerPurple ring: world pivotBlue dot: transformed card originOrange: full first columnGreen: full second column

Fixed view half-height 4: y runs from −4 to 4, with equal X/Y units. One unit is one eighth of the canvas height. Orange and green guides show full columns from the transformed card origin; the cyan direction arrow starts at the fixed world origin. No direction is normalized.

M · displayed in rows, constructed from columns
M[0]M[1]M[2]
———
———
———

One affine matrix

Rotation and scale
World offset
Pivot · original card coordinates
World pointer
Preset and coordinate details

Combined restores the illustrated parameters and pointer. Identity uses no rotation or offset, scale=(1,1), pivot=(0,0), and pointer=(0.4,0.2). Translate only uses identity rotation/scale, offset=(0.6,−0.3), pivot=(0,0), and pointer=(1,−0.1). Pivot only uses Combined with offset zero and pointer=(−0.025,0.2).

Those four presets select Point mode and preserve overlays. Probe sample changes only the pointer to M*(0.4,0.2,1); Probe outside uses local (0.8,0.6). Ordinary edits preserve point/direction mode. Switching that mode does not change the card or its hit test.

The pivot is measured in the original card coordinates and may lie outside the card. Its world position is pivot+offset. The matrix's effective translation is generally different from the offset slider, because rotation and scale occur around that pivot.

Pointer fields allow [−6,6] so presets remain exact at extreme transforms; points outside the visible view are reported below. Reset lab restores Combined, Point mode, and both overlays. Reset code restores the shader separately.

Preparing the live shader.

Selected result · (x, y, w)
(0.47500, 0.45000, 1.00000)
Point reference · M*(0.4,0.2,1)
(0.47500, 0.45000, 1.00000)
Direction reference · M*(0.4,0.2,0)
(-0.15000, 0.60000, 0.00000)
World pivot · pivot + offset
(0.70000, 0.15000)
World pointer
(0.47500, 0.45000)
Inverse lookup · local pointer
(0.40000, 0.20000)
Hard card hit · host reference
Inside
Pointer round trip · M*inverse lookup
(0.47500, 0.45000)
Pointer round-trip error
0.00000
First column · M[0]
(0.00000, 1.50000, 0.00000)
Second column · M[1]
(-0.75000, 0.00000, 0.00000)
Third column · M[2]
(0.62500, -0.15000, 1.00000)

Point mode includes the matrix's third column because input w=1. The selected output keeps w=1.

The default card and guides fit. At extreme settings some geometry may leave the fixed view.

These are host float32 references for the built-in formulas, not GPU measurements. Shader edits do not change the table or readouts. Trigonometric functions, inverse arithmetic, and rounding can differ slightly on the GPU. The point and direction references always remain visible numerically, while the selected mode chooses the gold point or cyan arrow in the diagram.

The card's local half-size is (0.6,0.4). Its green highlight uses the hard inverse-space test abs(local.x)≤0.6 and abs(local.y)≤0.4, independently of edge antialiasing. Exact boundary points can fall on either side after floating-point rounding. Positive scales guarantee an inverse for every allowed transform.

Drag with a mouse to move the world pointer, or focus the canvas and use arrows: 0.05 units per press, or 0.25 with Shift. Touch taps place the pointer; swipes scroll normally. Canvas input follows the visible coordinates; number fields and keyboard movement can reach [−6,6]. There is no animation.

Open the complete shader edit · run · reset

M=T(offset)*T(pivot)*R*S*T(−pivot) combines all affine operations. The host supplies radians, positive scales, offset, original-space pivot, world pointer, and the mode/overlay flags. This example keeps homogeneous w at 0 or 1 and uses no perspective division. Drawing helpers are supplied; fwidth antialiases local fields without making them exact world distances.

⌘ / Ctrl + Enter to run

The host code converts degrees to radians, supplies bounded parameters, and handles pointer input. Matrix constructors use columns; the table displays their components in row order with a separate color for each column.

A complete affine card shader

Paste this smaller shader into the editor. It keeps the card, local hit highlight, white pointer, purple world pivot, and selected point or direction. The default shader adds grid and matrix-column guides. Drawing helpers are supplied; the new work is in the mat3 constructors and their product.

#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uAngle; // Radians, in [-pi, pi].
uniform vec2 uScale; // Each component in [0.5, 2].
uniform vec2 uOffset; // Each component in [-1, 1].
uniform vec2 uPivot; // Original card coordinates; each in [-0.5, 0.5].
uniform vec2 uPointer; // World coordinates; each in [-6, 6].
uniform bool uDirectionMode;
out vec4 fragColor;

mat3 translation(vec2 t) {
    return mat3(vec3(1.0, 0.0, 0.0),
                vec3(0.0, 1.0, 0.0), vec3(t, 1.0));
}
mat3 rotation(float angle) {
    float c = cos(angle), s = sin(angle);
    return mat3(vec3(c, s, 0.0), vec3(-s, c, 0.0), vec3(0.0, 0.0, 1.0));
}
mat3 scaling(vec2 s) {
    return mat3(vec3(s.x, 0.0, 0.0), vec3(0.0, s.y, 0.0), vec3(0.0, 0.0, 1.0));
}
float boxField(vec2 p, vec2 halfSize) {
    vec2 q = abs(p) - halfSize;
    return max(q.x, q.y);
}
float segmentDistance(vec2 p, vec2 a, vec2 b) {
    vec2 v = b - a;
    float vv = dot(v, v);
    float t = vv > 0.0 ? clamp(dot(p - a, v) / vv, 0.0, 1.0) : 0.0;
    return length(p - a - t * v);
}
float arrowDistance(vec2 p, vec2 tip) {
    float len = length(tip);
    if (len == 0.0) return length(p);
    vec2 d = tip / len;
    vec2 n = vec2(-d.y, d.x);
    float h = min(0.12, 0.3 * len);
    return min(segmentDistance(p, vec2(0.0), tip),
        min(segmentDistance(p, tip, tip - h*d + 0.5*h*n),
            segmentDistance(p, tip, tip - h*d - 0.5*h*n)));
}
void main() {
    vec2 p = 4.0 * (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
    float pixel = 8.0 / uResolution.y;
    mat3 M = translation(uOffset) * translation(uPivot)
           * rotation(uAngle) * scaling(uScale) * translation(-uPivot);
    mat3 back = inverse(M); // Positive bounded scale keeps this family invertible.
    vec2 local = (back * vec3(p, 1.0)).xy;
    vec2 localPointer = (back * vec3(uPointer, 1.0)).xy;
    bool inside = all(lessThanEqual(abs(localPointer), vec2(0.6, 0.4)));

    float field = boxField(local, vec2(0.6, 0.4));
    float lField = min(boxField(local - vec2(-0.25, 0.0), vec2(0.05, 0.30)),
                       boxField(local - vec2(0.0, -0.25), vec2(0.30, 0.05)));
    float edge = max(fwidth(field), 0.00001);
    float lEdge = max(fwidth(lField), 0.00001);
    float mask = 1.0 - smoothstep(-edge, edge, field);
    float letter = 1.0 - smoothstep(-lEdge, lEdge, lField);
    vec3 fill = inside ? vec3(0.13, 0.45, 0.28) : vec3(0.10, 0.25, 0.34);
    vec3 color = mix(vec3(0.035, 0.055, 0.08), fill, mask);
    color = mix(color, vec3(0.92), letter * mask);

    float w = uDirectionMode ? 0.0 : 1.0;
    vec2 result = (M * vec3(0.4, 0.2, w)).xy;
    float resultField = uDirectionMode ? arrowDistance(p, result)
        : abs(length(p - result) - 0.075);
    vec3 resultColor = uDirectionMode ? vec3(0.24, 0.88, 0.94) : vec3(1.0, 0.81, 0.31);
    color = mix(color, resultColor, 1.0 - smoothstep(pixel, 2.0*pixel, resultField));
    float pivotRing = abs(length(p - (uPivot + uOffset)) - 0.055);
    color = mix(color, vec3(0.76, 0.55, 1.0), 1.0 - smoothstep(pixel, 2.0*pixel, pivotRing));
    vec2 q = abs(p - uPointer);
    float crossField = min(max(q.x - 0.06, q.y - 0.007), max(q.x - 0.007, q.y - 0.06));
    color = mix(color, vec3(0.97), 1.0 - smoothstep(0.0, pixel, crossField));
    fragColor = vec4(color, 1.0);
}

Your turn

Use Reset lab and Reset code first.

  1. Choose Translate only. Switch between Point and Direction, then change the offset. Explain why one result changes and the other does not.
  2. Return to Combined. Predict the third matrix column, transformed point, direction, and world pivot. Use Probe sample to recover local V=(0.4,0.2). Use Probe outside to recover (0.8,0.6), which lies outside the card.
  3. Change rotation, both scales, offset, and pivot. Use both probe buttons again. The local coordinates and hit results should stay the same even though their world positions change.
  4. Paste the smaller shader. In only the localPointer calculation, replace vec3(uPointer, 1.0) with vec3(uPointer, 0.0). At Translate only, test pointer (1,−0.1). Explain why the reference still says inside while the shader stops highlighting the card. Restore w=1.
A hint

The final affine map has the form A*p+b. A point receives b; a direction does not. The inverse pointer lookup must subtract that effective translation. The probe buttons choose a known local point and send it forward before you recover it.

Check your result

Translate only gives point (1,−0.1) and direction (0.4,0.2). Combined has third column (0.625,−0.15,1), point (0.475,0.45), direction (−0.15,0.6), and world pivot (0.7,0.15). Probe outside is world (0.175,1.05), recovering local (0.8,0.6).

Both probe results survive all allowed transforms, within floating-point error: sample is inside and outside is outside. With the deliberate w=0 mistake at Translate only, localPointer becomes (1,−0.1) rather than (0.4,0.2), so the shader's box test fails. The built-in host reference does not follow this edit.

What you can do now

Represent a complete 2D affine transform in one mat3 and use its inverse for interaction. You can explain the third column, distinguish points from directions, and test a pointer after pivoted rotation and scale. Next, we will add a real third spatial coordinate.