Linear Algebra for GLSL Shaders
22 / Put it together

Build an interactive
shader card.

A local pattern, a moving surface, a camera, and a light. Put the pieces together, then use their coordinate spaces to find and fix three deliberate mistakes.

40–55 min with experimentsGLSL ES 3.00 / WebGL 2After chapters 01–21
What we want to make

One card. Every space has a job.

Build the card ↓

Tilt the surface without sliding its pattern. Change the camera without moving the light. Use the same small effect to explain where each coordinate belongs.

An interactive shader card connected to its local pattern, model transform, camera projection, and world-space lighting. Three debug views help isolate three deliberate mistakes.
The final projectLocal material. World lighting. Camera projection.

Move the pointer across the card. Its surface tilts, the pattern travels with it, and the light changes. This is a small interface effect, but it brings together almost every space we have used in the book.

Take the finished effect apart

Start at Finished, then choose the four build stages in order. Shape shows the local pattern on a centered, front-facing card. Transform adds scale, rotation, translation, and pointer tilt. Camera lets you change the viewpoint and vertical field of view. Light adds world-space diffuse lighting.

At each step, predict which part of the image should change. Moving the camera should not slide the pattern across the surface. Changing the light should not move the card. That separation is the new skill: when something looks wrong, identify the space where it first became wrong.

Give every value an address

We keep one mesh, one camera, and one point light. Before adding color, write down the meaning of the values that cross the shader stages:

ValueSpace and purpose
aPosition, aNormalLocal mesh position and surface normal.
aUV, vUVDimensionless material coordinates, from 0 to 1 across the card.
vWorldPositionPosition after the model transform, before the camera.
vWorldNormalNormal transformed into world space, still awaiting normalization.
uLightPositionPoint light position in the same world space.
gl_PositionHomogeneous clip position, before the GPU's perspective divide.

The card uses the tilted local plane from chapter 21: T=(1,0,1)/sqrt(2), B=(0,1,0), and N=(−1,0,1)/sqrt(2). Its points are u*T+v*B, with u from −1 to 1 and v from −0.64 to 0.64. Four corners and two triangles are enough.

This initial tilt has a useful purpose. Non-uniform X/Z scale visibly separates the correct normal from the ordinary direction transform. On an axis-aligned XY card scaled along XYZ, those two normalized results could agree and hide our prepared mistake.

Keep the ink on the card

The pattern reads vUV, carried from the mesh through perspective-correct interpolation. It never reconstructs its coordinates from gl_FragCoord. Rotating the card therefore carries its material with it, including when the two triangles recede by different amounts.

Let local=vUV−0.5. These centered UV coordinates run from −0.5 to +0.5 along each material axis. To place an accent a quarter of the card's width to the right, sample its shape at local−vec2(0.25,0.0). The accent's center occurs where that sample is zero: UV=(0.75,0.5).

vec2 local = vUV - 0.5;
vec2 accent = local - vec2(0.25, 0.0);

This is the backward mapping from chapter 14. Subtracting the desired offset moves the visible shape right. Adding it places the center at UV=(0.25,0.5), on the left. UV units describe fractions of the material; they are not equal physical distances on this rectangular card. A circular UV motif can consequently appear elliptical on the surface.

Move the object in the intended order

The finished model is T*Ry*Rx*S. With column vectors, the rightmost operation acts first: scale the local mesh, pitch around X, yaw around Y, then translate its center to (0.7,0,0). GLSL matrices use columns, and every forward position transform multiplies the vector on the right.

mat4 model = uTranslation * uRotation * uScale;
vec4 world = model * vec4(aPosition, 1.0);
gl_Position = uProjection * uView * world;

The default scales are sx=1.3, sy=1, sz=0.7, followed by pitch −10° and yaw 30°. The center remains (0.7,0,0) as those angles change. Swapping translation and rotation gives R*T*S, rotating the translation as well. At the default angles, the center becomes approximately (0.60622,0,−0.35). The card starts orbiting instead of turning around its own translated center.

Shape uses a separate neutral setup: unit scale, no translation, and Ry(45°). That rotation maps our tilted T to +X and N to +Z, so the card faces the camera. Later stages activate the model controls; disabled controls do not affect the current stage.

Use the pointer as a small rotation control

Convert the pointer's CSS-pixel position within the canvas to fractions of its displayed width and height. Map those fractions to centered values from −1 to +1, with Y pointing upward. They supply at most 15° of extra yaw and pitch around the manual angles.

This is a control mapping, not a point picked on the 3D surface. It needs neither a ray nor a plane intersection. Keyboard angle controls provide the same kind of adjustment. Pause pointer freezes pointer response; the manual controls remain available for inspection. Rendering happens when something changes, with no idle animation loop.

The camera uses the look-at construction from chapter 19, with eye=(cameraX,0,6), target=(0,0,0), and world up +Y. Camera X changes both the eye and the viewing basis. Merely subtracting a different X offset would move a camera that still looked parallel to its old direction.

The perspective matrix from chapter 20 uses the actual drawing-buffer aspect ratio, vertical FOV, near=0.5, and far=12. Resize updates that aspect; it does not stretch the material to fill the canvas. A real model scale may intentionally change the card's proportions. A viewport resize should add no extra distortion.

Compare directions in one space

Let A be the model's linear 3×3 part. Transform tangents with A and normals with transpose(inverse(A)), then normalize the interpolated normal in the fragment shader. The host caches this normal matrix while the model stays unchanged. Translation does not affect it.

// Normalize only after the squared-length guard shown below.
vec3 N = normalize(vWorldNormal);
vec3 L = normalize(uLightPosition - vWorldPosition);
float diffuse = max(dot(N, L), 0.0);

Both N and L are world-space directions. The light defaults to (2,2,4); its X control moves it independently of the camera. Positive scales keep the model invertible. A squared-length guard of 1e−8 returns zero before normalizing an undefined light direction, as in chapter 21.

At the default model, the correct unit normal is approximately (0.02296,0.15289,0.98798). Its dot with the unit transformed tangent is zero apart from rounding. The ordinary A*N transform gives approximately (−0.52906,0.08233,0.84458), and its tangent dot is −60/109≈−0.55046. Unit length has not repaired perpendicularity.

The finished material combines fixed ambient brightness with diffuse light. Its border, color bands, and accent are artistic decoration, not another lighting model. There are no shadows, distance attenuation, environment reflections, or physically based specular highlights.

Find the first broken space

Use Local to inspect UV attachment, World to inspect positions after the model, and Normal to display 0.5+0.5*N. World view uses clamp(0.5+0.2*worldPosition,0,1), with each channel corresponding to X, Y, or Z. The normal view maps signed components into RGB; a flat card has one normal color. These are diagnostic colors, not light intensity.

The three bug presets deliberately change one expression each. For the order bug, inspect the center in World view. For the sampling bug, return to Material and compare the accent against Local coordinates. For the normal bug, use Normal and the tangent-dot reference. Repair the marked line in the shader editor, run it, and compare the picture again.

Numerical readouts describe the built-in reference model. They do not read your edited GPU program. After a code repair, the reference can still describe the injected error while your shader shows the corrected result. Use it as a prediction, then clear the injection to compare against the correct built-in image.

Try it

Build, inspect, and repair.

Live mesh / WebGL 2

Choose a build stage, then explore one control group at a time. Manual yaw runs from −30° to 75°, pitch from −25° to 25°, and X/Z scales from 0.5 to 1.6. Camera X runs from −2 to 2 and vertical FOV from 35° to 65°. Light X runs from −4 to 4, with Y=2 and Z=4.

The mesh uses your accepted shader pair. Host reference numbers follow the selected built-in pipeline, including the deliberately injected bug. Reset lab restores controls; Reset code restores the shader pair.

ONE CARD · FOUR SPACES
Static finished card: its UV pattern stays attached while model, view, projection, and normal transformations carry it into a lit perspective image.

Light combines the model, camera, and one world-space point light.

The drawing buffer follows the fixed 4/3 stage aspect.

Pointer, pause, and spaces

Mouse movement maps the canvas to X/Y in [−1,1]. The offsets are yaw=15·X° and pitch=−15·Y°, added to the manual angles. Touch supports a tap or horizontal drag; vertical swipes and pinch zoom keep their browser behavior. Leaving the canvas keeps the last offset; Center tilt clears it. Editing an angle clears both offsets.

Pause pointer freezes only these offsets; manual controls still work. Offscreen, hidden, or interrupted previews pause pointer input. Resume it explicitly after returning. Updates are direct and coalesced once per frame, including with reduced motion; there is no continuous animation.

The local plane uses T=(1,0,1)/√2, B=(0,1,0), N=cross(T,B). Vertices are uT+vB for u=±1 and v=±0.64. UV is dimensionless: centered UV is UV−(0.5,0.5), so a UV circle can be an ellipse on the physical card. Rounded corners and the pattern are sampled from UV.

Correct order is translation (0.7,0,0) after Y rotation, X rotation, and scale: T·Ry·Rx·S. Shape uses a centered Ry(45°) card with unit scale. The camera looks from (Eye X,0,6) toward the world origin. Perspective uses near 0.5, far 12, and the actual buffer aspect. One light is at (Light X,2,4).

Build the finished card

Transform
Camera
Light

All stages are active. Disabled controls retain their values for later stages.

Three deliberate bugs

No deliberate bug is active. Bug presets restore the default parameters, choose Light and the material view, and pause pointer input for a reproducible comparison.

Repair the marked line in the shader while its bug preset stays active. The host reference still describes that built-in bug; it does not inspect your edited GLSL. Finished clears the bug. Reset lab restores parameters and controls; Reset code restores the shader pair.

Preparing the finished card.

Follow the spaces

The material view combines UV styling and world-space lighting.

Active build
—
Effective yaw / pitch °
—
Pointer yaw / pitch °
—
Effective scale
—
World eye · target (0,0,0)
—
Effective vertical FOV
—
Selected world center
—
Intended world center
—
Unit world tangent
—
Selected unit world normal
—
Correct unit world normal
—
dot(selected N, unit tangent)
—
dot(correct N, unit tangent)
—
Center light direction
—
Center diffuse · selected / correct
—
Accent center · centered UV
—
Accent center · UV
—
Lookup at centered UV (0,0)
—
Actual buffer aspect
—
Band direction · unit / position
—

Read the center to diagnose matrix order, centered UV to diagnose sampling sign, and tangent/normal dot products to diagnose the normal transform.

These are host float32 references to the built-in formulas, not GPU measurements. They do not follow shader edits. Cofactor inversion is evaluated from the uploaded model columns and rounded for the cached normal matrix; matrix products, camera coordinates, and normal/light diagnostics have small rounding differences across devices. Diffuse values refer only to the card center.

Local debug = (UV.x,UV.y,0.25). World debug = clamp(0.5+0.2·worldPosition,0,1). Normal debug = 0.5+0.5·unitWorldNormal. Debug colors bypass material lighting. The band is a decorative dot-product effect, not a physical specular reflection; lighting uses only 0.15 ambient + 0.85 diffuse.

Open the complete shader pair edit · run · diagnose

One indexed mesh uses aPosition at location 0, aNormal at 1, and aUV at 2. The host supplies the model factors, camera matrices, cached normal matrix, and selected stage/bug. Read the complete mesh, camera, pointer, and uniform setup. The three marked bug branches remain editable.

⌘ / Ctrl + Enter runs both shaders

A complete starting pair

Paste both shaders into their matching editors and run them together. This pair includes the card, diagnostic modes, and three injected mistakes. It leaves out the directional band so you can add it below. The rounded-box, antialiasing, and material helpers are supplied visual polish; the coordinate transformations are the parts to explain.

The complete host source contains the mesh data, column-major matrix construction, camera basis, inverse transpose, pointer mapping, uniforms, resizing, and context recovery. The live editors above contain the finished version, including the band.

Vertex shader

#version 300 es
precision highp float;
precision highp int;
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aUV;
uniform mat4 uTranslation;
uniform mat4 uRotation;
uniform mat4 uScale;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat3 uNormalMatrix;
uniform int uBug;
smooth out vec3 vWorldPosition;
smooth out vec3 vWorldNormal;
smooth out vec2 vUV;

void main() {
    mat4 model = uTranslation * uRotation * uScale;
    // Bug 1: rotate the translation, so the card center orbits the world origin.
    if (uBug == 1) model = uRotation * uTranslation * uScale;
    vec4 world = model * vec4(aPosition, 1.0);
    vec3 worldNormal = uNormalMatrix * aNormal;
    // Bug 3: ordinary directions and surface normals transform differently.
    if (uBug == 3) worldNormal = mat3(model) * aNormal;
    vWorldPosition = world.xyz;
    vWorldNormal = worldNormal;
    vUV = aUV;
    gl_Position = uProjection * uView * world;
}

Fragment shader

#version 300 es
precision highp float;
precision highp int;
smooth in vec3 vWorldPosition;
smooth in vec3 vWorldNormal;
smooth in vec2 vUV;
uniform vec3 uLightPosition;
uniform int uStage;
uniform int uDebugView;
uniform int uBug;
uniform bool uReveal;
uniform float uRevealPosition;
out vec4 fragColor;
const float EPS2 = 0.00000001;

// Supplied shape and material helpers. UV coordinates are dimensionless.
float roundedBox(vec2 p, vec2 halfSize, float radius) {
    vec2 q = abs(p) - halfSize + radius;
    return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}
float mask(float field, float aa) {
    return 1.0 - smoothstep(-aa, aa, field);
}
void main() {
    vec2 local = vUV - 0.5;
    vec2 physical = local * vec2(2.0, 1.28);
    float cardField = roundedBox(physical, vec2(0.98, 0.62), 0.11);
    vec2 accent = local - vec2(0.25, 0.0);
    // Bug 2: adding the offset moves the sampled accent to the opposite side.
    if (uBug == 2) accent = local + vec2(0.25, 0.0);
    float accentRadius = length(accent);
    float ringField = abs(accentRadius - 0.13) - 0.004;
    float dotField = accentRadius - 0.095;
    float barA = roundedBox(local - vec2(-0.22, 0.22), vec2(0.14, 0.018), 0.009);
    float barB = roundedBox(local - vec2(-0.25, 0.15), vec2(0.11, 0.009), 0.004);
    float barC = roundedBox(local - vec2(-0.20, -0.25), vec2(0.16, 0.012), 0.006);
    // Derivatives are evaluated before discard, outside fragment-varying branches.
    float cardAA = max(fwidth(cardField), 0.00001);
    float ringAA = max(fwidth(ringField), 0.00001);
    float dotAA = max(fwidth(dotField), 0.00001);
    float barAA = max(length(fwidth(local)), 0.00001);
    if (cardField > 0.0) discard;

    vec3 N = vec3(0.0);
    float normalLength2 = dot(vWorldNormal, vWorldNormal);
    if (normalLength2 > EPS2) N = vWorldNormal / sqrt(normalLength2);
    vec3 delta = uLightPosition - vWorldPosition;
    vec3 L = vec3(0.0);
    float lightLength2 = dot(delta, delta);
    if (lightLength2 > EPS2) L = delta / sqrt(lightLength2);
    float diffuse = max(dot(N, L), 0.0);

    vec3 color = mix(vec3(0.025, 0.055, 0.12), vec3(0.035, 0.34, 0.37),
                     smoothstep(-0.9, 1.0, physical.x + 0.7 * physical.y));
    vec2 cell = floor(vUV * vec2(10.0, 6.0));
    color += 0.018 * mod(cell.x + cell.y, 2.0);
    color = mix(color, vec3(0.26, 0.17, 0.48), 0.25 * smoothstep(0.05, 0.5, local.y));
    color = mix(color, vec3(0.91, 0.97, 0.96), 0.8 * mask(barA, barAA));
    color = mix(color, vec3(0.42, 0.73, 0.76), 0.8 * max(mask(barB, barAA), mask(barC, barAA)));
    color = mix(color, vec3(1.0, 0.56, 0.26), mask(dotField, dotAA));
    color = mix(color, vec3(0.96, 0.83, 0.56), mask(ringField, ringAA));

    // Exercise: insert a directional band here, using uReveal and uRevealPosition.
    if (uStage == 3) color *= 0.15 + 0.85 * diffuse;
    float border = 1.0 - smoothstep(-0.016 - cardAA, -0.016 + cardAA, cardField);
    color = mix(vec3(0.65, 0.86, 0.88), color, border);

    if (uDebugView == 1) color = vec3(vUV, 0.25);
    if (uDebugView == 2) color = clamp(0.5 + 0.2 * vWorldPosition, 0.0, 1.0);
    if (uDebugView == 3) color = 0.5 + 0.5 * N;
    fragColor = vec4(color, 1.0);
}

Your turn

Reset the lab and code before starting. Pause pointer response so the numbers stay still while you compare them.

  1. Choose each bug preset. Predict its visible symptom, select a useful debug view, and explain the faulty expression. Fix that expression in the editor while leaving the injection selected. Then clear the injection and compare against the correct built-in picture.
  2. Paste the starting shader pair. At its exercise comment, add a soft band using dot(local, vec2(0.6,0.8)). Move its center with uRevealPosition and enable it with uReveal. Choose the Directional band preset to supply those uniforms.
  3. Set the band center to zero. Predict whether local=(0.4,−0.3) belongs to its center line. Compare with local=(0.4,0.3). Rotate the card and move the camera: the band should stay attached to the material.
  4. Make your own reproducible preset in the host source: save a stage, scale, angle, light, and band position that exposes one mathematical idea. Explain which space changes and which stays fixed. Use the existing preset handler as the template.
A hint

The direction (0.6,0.8) already has length one. Dot measures signed distance along that direction in centered UV units. A band centered at k needs abs(along−k), followed by a soft transition at its edges. Put its color contribution before diffuse lighting if you want it to share the material's illumination.

Check your result

The order repair uses uTranslation*uRotation*uScale in the bug branch. The sample repair uses local−vec2(0.25,0.0). The normal repair uses uNormalMatrix*aNormal. The center then stays at (0.7,0,0), the accent returns to UV=(0.75,0.5), and the transformed normal is perpendicular to the surface.

float along = dot(local, vec2(0.6, 0.8));
float band = 1.0 - smoothstep(0.04, 0.07,
                             abs(along - uRevealPosition));
if (uReveal) {
    color = mix(color, vec3(0.52, 0.95, 0.9), 0.65 * band);
}

The two test points give along=0 and 0.48. At band center zero, the first is fully inside and the second is outside. The bright strip is a graphic highlight in UV space, not a physical specular reflection. One useful preset uses sx=1.6, sz=0.5, default angles, and the correct normal to demonstrate that the band remains attached while the lighting still follows the stretched surface.

A shader you can explain

Build an effect across several spaces, then find the first incorrect transformation. You can name the space of every important value, predict the order of a matrix chain, distinguish forward geometry from backward sampling, preserve a surface normal, and use a debug color to check the result.

The final check is practical: the pattern stays on the card, viewport changes add no distortion, non-uniform scale keeps correct lighting, and you can repair all three injected errors without guessing. These are the same tools you will use when the next effect has a different shape.

Keep the GLSL reference, AGSL port, debugging checklist, and next projects beside your editor.

For Android, transfer the coordinate reasoning and fragment patterns separately. AGSL RuntimeShader does not provide this WebGL vertex stage, mesh buffers, or gl_Position pipeline. Its host must supply whichever position and normal fields the effect needs.