Linear Algebra for GLSL Shaders
12 / Transform the space

Stretch, squeeze,
and lean.

Change the lengths of two axes, or lean one while keeping the other still. Predict which properties of the drawing survive.

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

A wider card. A leaning grid.

Deform the drawing ↓

Let's make a card wider and shorter, then try a different change: slide its top sideways while its bottom slides the other way. A circle drawn in the same coordinates will reveal what each transformation does to distances.

A centered circle and rectangle are scaled by (1.5,0.75). The circle becomes an ellipse; the card becomes 1.8 units wide and 0.6 units high. Point (0.4,0.3) becomes (0.6,0.225).
Change the columns · predict the shapeScale (1.5, 0.75): wider in X, shorter in Y.

In chapter 11, the matrix columns kept unit length and stayed perpendicular. We will now relax those conditions. Keep reading each column as the destination of one local axis step, as we did in chapter 10.

Give each axis a length

To make one X step become two world units, choose (2,0) for the first column. To keep Y unchanged, choose (0,1) for the second. Generalize those two choices:

mat2 S = mat2(scaleX, 0.0,
              0.0, scaleY);
vec2 world = S * local;

The columns are (scaleX,0) and (0,scaleY). Expanding the product gives world.x=scaleX*local.x and world.y=scaleY*local.y. The factors are dimensionless ratios between output and input lengths along these axes.

Our card starts with half-width 0.6 and half-height 0.4, so its full size is 1.2 by 0.8. Choose scale=(1.5,0.75). The new size is 1.8 by 0.6. The test point P=(0.4,0.3) becomes (0.6,0.225).

The lab restricts both factors to [0.2,2]. A factor below one squeezes that coordinate; above one stretches it. One leaves it unchanged. Negative factors and zero introduce reflection and collapse, which we will examine later.

Watch a circle change

Equal factors produce uniform scale. Every displacement gets the same length multiplier, and angles stay the same. With scale=(1.5,1.5), a circle of radius 0.5 becomes a circle of radius 0.75.

Unequal factors produce nonuniform scale. The circle becomes an ellipse. Its horizontal and vertical semiaxes are 0.5×scaleX and 0.5×scaleY. For the wide card preset, they are 0.75 and 0.375. A boundary point satisfies:

(world.x / (0.5 * scaleX))²
  + (world.y / (0.5 * scaleY))² = 1

This is a mathematical equation, not a GLSL assignment. It says that dividing out the two stretches returns us to the original circle.

The coordinate axes remain perpendicular under positive diagonal scale, but that does not mean every right angle survives. Consider a=(1,1) and b=(1,-1). Their dot product is zero. Scale by (2,1), and they become (2,1) and (2,-1), whose dot product is 3. Their angle changed.

Different directions now have different length multipliers. This is why a stretched circle is a useful test: it shows a whole set of directions at once.

Lean the Y axis

Now leave the X step at (1,0), but send the Y step to (shear,1). Every row keeps its height while shifting horizontally by an amount proportional to that height:

mat2 H = mat2(1.0, 0.0,
              shear, 1.0);
vec2 world = H * local;
// world.x = local.x + shear * local.y
// world.y = local.y

This is a horizontal shear. At shear=0.75, a point one unit above the origin shifts 0.75 units right. A point one unit below shifts 0.75 units left. Points on y=0 do not move. The card becomes a parallelogram, with horizontal top and bottom edges.

P=(0.4,0.3) becomes (0.625,0.3). The X column still has length 1, while the Y column (0.75,1) has length 1.25. Their dot product is 0.75, so they are no longer perpendicular.

A rotation would turn both axes together and keep both lengths. A shear leaves one axis fixed while changing the other. It also turns the circle into an ellipse, generally tilted. The images of the original circle's X and Y endpoints are easy to predict, but they are not generally the principal axes of that tilted ellipse.

What happens to area?

Watch the outlined unit cell, originally a centered square with side length one. Scaling gives it width scaleX and height scaleY. Its area becomes their product. For (1.5,0.75), the area multiplier is 1.125; for (1.5,1.5), it is 2.25.

Horizontal shear keeps the cell's horizontal base at length one and the perpendicular height at one. Its area stays one even though a slanted edge gets longer. Use base times perpendicular height, not the product of the two edge lengths.

The card similarly keeps area 1.2×0.8=0.96 under shear. At shear=0.75, its bounding box is 1.8 wide and 0.8 high, but that surrounding rectangle includes empty corners. Its area is not the area of the parallelogram.

Area alone cannot identify the transformation. Scale=(2,0.5) also has area multiplier one, but it keeps the coordinate axes perpendicular and changes both lengths. Read the columns and the picture together.

Sample the original drawing

As with rotation, a fragment shader starts at a fixed world pixel p and asks for the original local coordinate. Undo scale by division. Undo horizontal shear by subtracting its row-dependent shift:

// Scale mode: both factors are positive and at least 0.2.
vec2 local = p / uScale;

// Shear mode, used separately:
vec2 local = vec2(p.x - uShear * p.y, p.y);

Choose one declaration for the selected mode. The shear reversal works because y did not change: we know exactly which horizontal offset to subtract. Neither expression needs the general matrix inverse yet.

For scale=(1.5,0.75), world (0.6,0.225) maps back to P=(0.4,0.3). For shear=0.75, world (0.625,0.3) does the same. Multiplying p by the scale factors during lookup would make the visible object smaller when those factors grow.

Keep distance claims precise

The expression length(local)-0.5 still identifies the circle's boundary at zero, and its sign still separates inside from outside. After nonuniform scale or shear, its magnitude is not the world-space distance to the transformed boundary.

For scale=(2,1), world (1.2,0) gives local distance 0.1, while its nearest ellipse boundary is 0.2 world units away. World (0,0.6) also gives local distance 0.1, but its boundary is only 0.1 world units away. One global multiplier cannot repair both.

We can still use this local field to draw the correct shape. The supplied shader uses fwidth(field), an estimate of how much the field changes across nearby screen fragments, to soften its edge over roughly pixel-sized transitions. This helps rendering; it does not turn the field into an exact world-distance function.

Try it

Stretch one way. Lean another.

LIVE GLSL / WEBGL 2

Use Scale and Shear separately. Compare the transformed circle and card with Original overlay. The axes show the matrix columns, and the centered unit cell makes changes in area visible.

The view stays fixed at −2 to 2 vertically, with the same unit length in both directions. One unit occupies a quarter of the canvas height. Switching modes does not apply the two transformations in sequence.

Static default Scale example: scale=(1.5,0.75). The circle becomes a wide ellipse, the rectangle has width 1.8 and height 0.6, and the transformed unit cell has area 1.125. Sample (0.4,0.3) moves to (0.6,0.225).
Cyan: circle, local radius 0.5Purple: rectangle, local size 1.2 × 0.8Pale outline: local unit cellGold: P=(0.4,0.3) transformedOrange: first columnGreen: second column

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

The arrows show the full columns, with no shortening or normalization. The pale unit cell starts at [−0.5,0.5] on both axes. The mapped grid is a finite local patch [−1,1], with a line every 0.5; gray outlines show the original shapes.

Masks are sampled in local coordinates. fwidth supplies approximate screen antialiasing for those fields; it does not turn them into exact world-space distances. The purple outline uses forward-mapped segments for a constant stroke width.

Scale or shear

Scale · active
Shear · inactive, remembered

Scale uses only scale.x and scale.y. The stored shear value is inactive. Switching modes preserves all values.

mat2(1.5, 0.0, 0.0, 0.75)

Identity resets the active mode: scale=(1,1), or shear=0. Wide card sets Scale(1.5,0.75); Uniform sets Scale(1.5,1.5). Lean right and Lean left set Shear to +0.75 and −0.75. Unused parameters are preserved.

This chapter keeps scale positive, from 0.2 to 2. Shear ranges from −1.5 to 1.5. The two transformations are never combined here.

Reset lab restores the default Scale view and all overlays. Reset code restores the shader separately.

Preparing the live shader.

First column · M[0]
(1.50000, 0.00000)
Second column · M[1]
(0.00000, 0.75000)
Column lengths · |M[0]|, |M[1]|
1.50000, 0.75000
Column dot product
0.00000
Area of transformed local unit cell
1.12500
Sample · P=(0.4,0.3) → world
(0.60000, 0.22500)
Card bounds · width, height · axis-aligned
1.80000, 0.60000
Card's true area · original area 0.96
1.08000

Scale changes the two column lengths independently. Unequal scale stretches the circle into an ellipse; the columns remain perpendicular.

The shapes and sample fit in the fixed view. The finite grid and full column arrows may reach its edges.

These are host float32 reference calculations for the built-in formulas, not GPU measurements. Shader edits do not change the numbers. The card's axis-aligned bounds describe its enclosing box; their product is not the area of a sheared card. Unit-cell area is scale.x × scale.y in Scale mode and 1 in Shear mode. Card area is 0.96 times that value.

Focus the diagram to use the keyboard. In Scale mode, Left/Right changes scale.x and Down/Up changes scale.y by 0.05. In Shear mode, Left/Right changes shear by 0.05. Hold Shift for 0.25. Inputs stay within their stated limits. The preview has no drag handle; on touch screens, use the controls and swipe normally to scroll.

Open the complete shader edit · run · reset

The host supplies uScale, uShear, uMode (0 Scale, 1 Shear), and the three overlay flags. The shader selects one forward matrix and its matching backward coordinate formula. All drawing helpers are supplied; fwidth is evaluated before the drawing branches.

⌘ / Ctrl + Enter to run

The host code keeps the two modes separate and supplies the allowed parameters. The shape masks use backward sampling; the sample point, axes, and guide cell use forward mapping.

A complete scale and shear shader

Paste this smaller version into the editor. The cyan circle and purple card use the selected backward mapping. The gold point uses the matching forward matrix. The longer default shader adds the grid, original outlines, and guide cell.

#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform vec2 uScale; // Each component is in [0.2, 2].
uniform float uShear; // In [-1.5, 1.5].
uniform int uMode; // 0 = Scale, 1 = Shear. Used separately.
out vec4 fragColor;

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

void main() {
    vec2 p = 2.0 * (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
    mat2 S = mat2(uScale.x, 0.0, 0.0, uScale.y);
    mat2 H = mat2(1.0, 0.0, uShear, 1.0);
    mat2 M = uMode == 0 ? S : H;
    vec2 local = uMode == 0 ? p / uScale
                           : vec2(p.x - uShear * p.y, p.y);

    // These are local fields, not world-space distances after deformation.
    float circle = length(local) - 0.5;
    float card = boxField(local, vec2(0.6, 0.4));
    // Derivatives supply approximate screen-space edge widths.
    float circleAA = max(fwidth(circle), 0.00001);
    float cardAA = max(fwidth(card), 0.00001);
    vec3 color = vec3(0.04, 0.06, 0.09);
    float circleFill = 1.0 - smoothstep(-circleAA, circleAA, circle);
    float circleLine = 1.0 - smoothstep(circleAA, 2.0 * circleAA, abs(circle));
    color = mix(color, vec3(0.24, 0.88, 0.94), circleFill * 0.16);
    color = mix(color, vec3(0.24, 0.88, 0.94), circleLine);
    float cardLine = 1.0 - smoothstep(cardAA, 2.0 * cardAA, abs(card));
    color = mix(color, vec3(0.77, 0.62, 1.0), cardLine);
    vec2 sampleWorld = M * vec2(0.4, 0.3);
    float pixelSize = 4.0 / uResolution.y;
    float marker = 1.0 - smoothstep(0.035, 0.035 + pixelSize,
                                   length(p - sampleWorld));
    color = mix(color, vec3(1.0, 0.81, 0.31), marker);
    fragColor = vec4(color, 1.0);
}

The small positive lower bound keeps the smoothing interval defined where a derivative is tiny. It changes the rendering width, not the matrix or the input parameters.

Your turn

Use Reset lab and Reset code first.

  1. Make a card 1.8 units wide and 0.6 units high. Predict the two scale factors, the circle's semiaxes, the unit cell's area, and P's new coordinates. Compare with Wide card.
  2. Choose Uniform, then change the scale to (2,0.5). Compare the circle and the area readout. Which setting preserves every angle? Which preserves area?
  3. Choose Lean right. Predict both columns, their lengths and dot product, and P's position. Explain why the cell's area stays one even though its Y edge gets longer. Try Lean left and predict which values change sign.
  4. Paste the smaller shader. In Scale mode at (1.5,0.75), replace only p / uScale with p * uScale. Predict the visible card dimensions before running it. Restore the division, then explain why the local circle field is still not a world-distance measurement.
A hint

Divide desired card dimensions by the original 1.2 and 0.8. For shear, y stays unchanged, and each horizontal row shifts by shear×y. For the wrong sampling expression, solve scale×p=original boundary coordinate.

Check your result

The wide card needs scale=(1.5,0.75). Its circle semiaxes are (0.75,0.375), unit cell area is 1.125, and P becomes (0.6,0.225). Uniform=(1.5,1.5) preserves angles but multiplies area by 2.25. Scale=(2,0.5) preserves area but changes most angles.

Lean right uses columns (1,0), (0.75,1), with lengths 1 and 1.25, dot product 0.75, and P=(0.625,0.3). Lean left changes the second column's X component and dot product to -0.75; P becomes (0.175,0.3). Both keep unit-cell area one. The incorrect Scale lookup produces a card 0.8 wide and about 1.06667 high; the gold forward marker still uses the intended matrix.

What you can do now

Build scale or shear from its axis images and predict what happens to lengths, angles, and area. Explain why shear is not rotation and why a deformed local field is not automatically a world-space distance. Next, we will combine transformations and see why their order matters.