Linear Algebra for GLSL Shaders
21 / Add depth and light

Keep the light
attached to the surface.

Stretch the mesh without bending its lighting in the wrong direction. Transform the normal by preserving what makes it a normal: its perpendicularity to the surface.

35–45 min with experimentsGLSL ES 3.00 / WebGL 2After chapters 06, 14–15, and 18–20
What we want to make

The surface changes. The normal stays perpendicular.

Compare the normals ↓

Apply the same non-uniform scale to one tilted plane. Compare the obvious normal transform with inverse transpose, then use a tangent dot product to explain why their lighting differs.

A tilted surface scaled by two along X and one half along Z. The corrected normal stays perpendicular to the transformed tangent; the wrong normal leans toward it and produces incorrect lighting.
Same mesh · different normalLength one is not enough. A normal must stay perpendicular to the surface.

Stretch a lit card sideways. Its outline changes as expected, but the light suddenly seems to point somewhere else. Rotating the normal with the model worked before. Why does adding scale break it?

Keep the geometry. Change the normal.

Open the lab at Stretch and switch between Correct and Wrong. The mesh stays still. Only the normal used for lighting changes. Correct keeps the surface bright; Wrong leaves much of it dark.

Both methods produce unit vectors. That rules out a length problem. We need to check whether each vector still points perpendicular to the transformed surface. The X/Z cross-section draws those directions with equal scale on both axes, making the right angle visible. A perspective view does not generally preserve that angle on the screen.

Give the surface a tangent

A tangent points along a surface. Our local card is already tilted: its horizontal direction T climbs equally in X and Z, while its other direction B follows Y. From chapter 18, their cross product supplies a perpendicular normal:

vec3 T = normalize(vec3(1.0, 0.0, 1.0));
vec3 B = vec3(0.0, 1.0, 0.0);
vec3 localNormal = cross(T, B); // (-1, 0, 1) / sqrt(2)
vec3 localPosition = u * T + v * B;

The card spans u and v from −0.8 to +0.8. Its four corners form two indexed triangles, as in chapter 20. Each vertex carries a position, this same local normal, and UV coordinates. No hidden curved geometry is needed to reveal the error.

A direction can lose its angle

Let A be the linear 3×3 part of the model matrix. A tangent transforms as A*T, because it describes the difference between nearby positions. Translation cancels from that difference.

At Stretch, A scales X by 2, Y by 1, and Z by 0.5. The transformed tangent points along (4,0,1). Applying A to the local normal instead gives direction (−4,0,1). Their unit-vector dot product is −15/17, about −0.88235. They are clearly not perpendicular.

Normalizing the wrong result only fixes its length. It cannot repair its direction. Treating a normal as a vec4 with w=0 excludes translation, but still applies the same incorrect linear scale.

Preserve the zero dot product

Call the original normal n and any local tangent t. We know dot(n,t)=0. After transforming the surface, we want a new normal n′ for which dot(n′,A*t)=0.

A transpose lets us move A to the other side of the dot product. Choose the transformed normal so that the expression becomes our original zero:

dot(n′, A*t) = dot(transpose(A)*n′, t)
transpose(A)*n′ = n
n′ = inverse(transpose(A))*n
   = transpose(inverse(A))*n

This is the inverse transpose, usually called the normal matrix. It preserves the normal's relationship with every transformed tangent. It does not promise a unit result, so we still normalize before lighting.

For a diagonal scale, inverse transpose means reciprocal scale. Our normal changes from (−1,0,1)/sqrt(2) to (−0.5,0,2)/sqrt(2). After normalization, it is (−1,0,4)/sqrt(17). Its dot with the unit tangent (4,0,1)/sqrt(17) is exactly zero before rounding.

Rotate after the reciprocal scale

The lab uses A=R*S: scale first, then rotate around Y. Its normal matrix is R*inverse(S). The same rotation follows reciprocal scale, keeping the corrected normal attached as the card turns.

For a pure rotation, inverse transpose equals the rotation itself. Positive uniform scale also preserves the direction after normalization. Choose Uniform to see Correct and Wrong agree. For this particular plane they also agree whenever sx=sz, even if sy differs: the normal has no Y component.

All scales stay between 0.25 and 2. The model is invertible, and its positive determinant preserves the chosen orientation. Zero scale would remove a dimension; its inverse would not exist. GLSL does not define useful inverse results for singular or poorly conditioned matrices. The lab excludes these cases rather than inventing a normal.

// The mathematical operation, written in GLSL:
mat3 normalMatrix = transpose(inverse(mat3(model)));

The running example computes this constant matrix in JavaScript only when the model changes, then uploads uNormalMatrix. Moving the light does not require another inverse. The complete host source includes the cofactor calculation, determinant guard, mesh buffers, uniforms, and context recovery.

Give both directions the same space

The transformed normal is in world space. The light direction must be there too. Subtract each fragment's world position from the point light's world position, then normalize the displacement.

vec3 N = unitOrZero(vWorldNormal);
vec3 L = unitOrZero(uLightPosition - vWorldPosition);
float diffuse = max(dot(N, L), 0.0);

The dot product from chapter 06 becomes brightness: one when light points along the normal, zero at a right angle, and clamped to zero behind the surface. Mixing a world normal with a local light direction would compare different coordinate systems.

At the card's center, the default light (2,2,4) gives L=(1,1,2)/sqrt(6). Correct diffuse is 7/sqrt(102)≈0.69310. Wrong produces a negative dot, so its diffuse is zero. The center readout predicts this point only; a point light's direction varies across the card.

A light at the shaded point has no direction. The helper checks squared length against 1e−8, about 0.0001 units in length, before returning L=0 and diffuse=0. This boundary policy applies independently at each fragment. A light exactly on this flat plane gives zero correct diffuse across the plane: every remaining light displacement is tangent to it.

Normalize where you use the normal

The vertex shader passes the transformed normal and world position to the fragment shader. Smooth varyings use perspective-correct interpolation, but interpolation does not preserve vector length.

For example, equal weights between unit normals (1,0,0) and (0,1,0) produce (0.5,0.5,0), whose length is about 0.707. Normalize the interpolated result before taking the lighting dot product. Our flat card supplies identical normals at every vertex, so this particular interpolation does not shorten them; the fragment normalization also handles the length changed by the normal matrix.

The small unitOrZero helper below checks length before dividing. With the lab's permitted models, the surface normal is safely nonzero. The guard also gives a defined result if an edited shader supplies a zero normal.

Keep the material modest

The material multiplies its base color by 0.15 + 0.85*diffuse. The first term keeps unlit areas visible; it is a fixed artistic ambient term. The checker follows local UV, while an optional border makes the plane easier to read. Neither changes the normal mathematics.

This is an educational diffuse material, without distance attenuation, shadows, or specular reflections. The camera stays at (0,0,7), with vertical FOV 45°. Moving the light changes illumination, not projection. The light itself may be outside the view; its position still affects the visible card.

AGSL RuntimeShader uses a different host pipeline. To reuse the lighting idea, provide explicit position and normal fields in one chosen space; WebGL vertex attributes and gl_Position are not an AGSL mesh stage.

Try it

Stretch the card. Test the normal.

Live mesh / WebGL 2

Scale the tilted plane along X, Y, and Z from 0.25 to 2, then rotate it around Y from 0° to 90°. Compare Correct and Wrong without changing the geometry. The light moves in world space: X and Y from −3 to 3, Z from 0 to 5.

The mesh uses the selected shader pair. Host vectors, the equal-scale X/Z diagram, and numerical diagnostics use the built-in model. Check the tangent dot products alongside the image; center diffuse values describe the center only.

Static default: scale (2,1,0.5) transforms the tangent to direction (4,0,1). The correct normal points along (-1,0,4), perpendicular to the tangent; the wrong normal points along (-4,0,1).

World X/Z slice · equal units

Host reference with X right and Z up. Both axes use 80 pixels per world unit. The transformed plane segment and unit tangent lie perpendicular to the correct normal. The wrong normal generally does not. XZ011−1−1 TN correctN wrong

Host reference, range −2.2…2.2 on both axes. Gray is the transformed plane segment ±0.8·A·T. Colored unit directions are drawn at length 1.2. B / +Y is perpendicular to this slice, pointing into the page.

Transformed unit tangent · cyanCorrect unit normal · greenWrong unit normal · red dashedLight position · gold

Same geometry, different normal. The local plane uses T=(1,0,1)/√2, B=(0,1,0), and N=cross(T,B)=(−1,0,1)/√2. Its four vertices are uT+vB for u,v=±0.8. The model applies positive scale, then Y rotation; the center stays at the world origin.

The mesh is rendered by WebGL. The SVG arrows are separate host references: each unit direction is drawn at length 1.2 from the center, then projected through the same fixed camera. Projected angles are not world angles; the equal-scale X/Z slice makes the right angle visible. Rings mark an end-on projection. Coincident normals share the label “N · both.” Custom shader edits do not alter these reference arrows.

The default light is outside the fixed viewport; it still lights the plane.

Camera: eye (0,0,7), vertical FOV 45°, near 0.5, far 12, stage aspect 4/3. The projection uses the actual buffer aspect after pixel rounding. This diffuse example includes ambient fill and a decorative border, with no attenuation, shadows, specular, or full PBR model.

Keep the normal perpendicular

Model · scale, then rotate Y
Light position · world space

Normal matrix available; cached until the model changes.

Presets, guards, and reference values

Stretch restores scale (2,1,0.5), yaw 0°, and light (2,2,4). Uniform uses scale (1.5,1.5,1.5). Rotated uses the Stretch scales and yaw 45°. Light at center uses the Stretch scales, yaw 0°, and light (0,0,0). Presets preserve Correct/Wrong mode and overlays. Reset lab restores all defaults.

Every scale is positive and bounded away from zero. The host extracts A from the uploaded float32 model, computes its general inverse transpose with cofactors, and caches that matrix. It checks abs(det(A))>1e−8 before division. Light, mode, resize, and repaint do not recompute the normal matrix.

The fragment shader normalizes its interpolated world normal again. When the squared normal length or squared light distance is at most 1e−8, the corresponding direction is zero and diffuse is zero. At a coincident light, the center therefore receives only the 0.15 ambient term.

All light readouts describe the plane center. The shader computes lightPosition−worldPosition separately at each fragment, so lighting can vary across the plane. Reset code restores both shader editors; Run shaders accepts a pair only after both compile and link. Sliders and numeric fields provide keyboard control; the canvas has no drag action or animation.

Preparing the live mesh shaders.

Transformed tangent · A*T
—
Unit transformed tangent
—
Unit correct normal
—
Unit wrong normal
—
dot(correct N, unit tangent)
—
dot(wrong N, unit tangent)
—
det(A) · uploaded columns
—
Center light direction · L
—
Light distance from center
—
Center diffuse · Correct
—
Center diffuse · Wrong
—
Selected center factor · 0.15 + 0.85*diffuse
—
A = mat3(model) · rows shown, columns labeled
A[0]A[1]A[2]
———
———
———
Normal matrix = transpose(inverse(A)) · rows shown, columns labeled
normal[0]normal[1]normal[2]
———
———
———

A normal must remain perpendicular to the transformed surface tangent. Normalizing A*N changes its length, not its direction.

These are host references, not GPU measurements; shader edits do not change them. Model entries are float32. Cofactor inversion is evaluated in host arithmetic from those entries and rounded to float32 for upload; reference vector products and normalization also use float32 rounding. Tiny residual dot products are expected.

Matrix entries are shown as rows, with each labeled column read vertically. Lighting uses world-space positions and normals. The center diffuse numbers describe max(dot(N,L),0) before the UV checker and decorative border.

Open the complete shader pair edit · run · reset

Inputs: aPosition at location 0, aNormal at 1, and aUV at 2. The host supplies uModel, uView, uProjection, cached uNormalMatrix, uWrongNormal, uLightPosition, uBaseColor, and uShowPattern. Read the complete mesh setup and cached cofactor normal-matrix computation.

⌘ / Ctrl + Enter runs both shaders

The smaller shader pair

Paste both shaders into the matching editors and run them together. The host supplies the same mesh, matrices, light, and color uniforms. This pair keeps the normal transform and lighting while leaving out the border. Diagnostics and vector overlays always show the built-in reference calculations, including after shader edits.

Vertex shader

#version 300 es
precision highp float;
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aUV;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat3 uNormalMatrix;
uniform bool uWrongNormal;
out vec3 vWorldPosition;
out vec3 vWorldNormal;
out vec2 vUV;
void main() {
    vec4 world = uModel * vec4(aPosition, 1.0);
    vWorldPosition = world.xyz;
    vWorldNormal = uWrongNormal
        ? mat3(uModel) * aNormal
        : uNormalMatrix * aNormal;
    vUV = aUV;
    gl_Position = uProjection * uView * world;
}

Fragment shader

#version 300 es
precision highp float;
in vec3 vWorldPosition;
in vec3 vWorldNormal;
in vec2 vUV;
uniform vec3 uLightPosition;
uniform vec3 uBaseColor;
uniform bool uShowPattern;
out vec4 fragColor;
vec3 unitOrZero(vec3 v) {
    float lengthSquared = dot(v, v);
    if (lengthSquared <= 0.00000001) return vec3(0.0);
    return v * inversesqrt(lengthSquared);
}
void main() {
    vec3 N = unitOrZero(vWorldNormal);
    vec3 L = unitOrZero(uLightPosition - vWorldPosition);
    float diffuse = max(dot(N, L), 0.0);
    vec3 color = uBaseColor;
    if (uShowPattern) {
        vec2 cell = floor(vUV * 6.0);
        float checker = mod(cell.x + cell.y, 2.0);
        color *= mix(0.45, 1.0, checker);
    }
    fragColor = vec4(color * (0.15 + 0.85 * diffuse), 1.0);
}

Your turn

Start with Reset lab and Reset code.

  1. At Stretch, predict both unit normals and their dot products with the unit transformed tangent. Switch to Wrong and explain why normalization did not prevent the lighting error.
  2. Choose Uniform. Predict whether the two methods agree. Then set sx=sz=1 and sy=2. Explain why this non-uniform scale also preserves this plane's normal direction.
  3. Return to Stretch and choose Rotated. Check that the correct dot remains near zero even though the normal and center diffuse change in world space.
  4. Paste the smaller shader pair. In Correct mode at Stretch, replace uNormalMatrix * aNormal with mat3(uModel) * aNormal in the vertex shader. Run it, observe the wrong lighting, then restore the original expression. The reference numbers do not inspect your edited shader.
A hint

For Stretch, compare (4,0,1), (−4,0,1), and (−1,0,4), each divided by sqrt(17). Rotation preserves their pairwise dot products. Scaling Y changes the card's height but cannot tilt a normal whose Y component is zero.

Check your result

Stretch gives correct N≈(−0.24254,0,0.97014), wrong N≈(−0.97014,0,0.24254), and unit tangent≈(0.97014,0,0.24254). Their tangent dots are approximately 0 and −0.88235. Center diffuse is about 0.69310 and 0.

Uniform and sx=sz give the same normalized direction for both methods. Rotated preserves the dot results. Restoring uNormalMatrix * aNormal makes the edited shader's light agree with the corrected surface again.

What you can do now

Transform a normal and verify that it remains perpendicular to the transformed surface. You can derive inverse transpose from a zero dot product, keep N and L in world space, and normalize the normal in the fragment stage. Next, we will combine these pieces into an interactive shader card.