Home

Awesome

The details of transforming normals

Have you ever seen transpose(inverse(M)) * normal in code before when transforming normals?

This is the defacto solution to dealing with non-uniform scale or skewed models when transforming normals and it's such an accepted practice that nearly every single graphics programming resource mentions and encourages it. The problem is it's wrong.

How did we get here?

A geometric normal is fully defined by its orientation with respect to a surface and the fact that it's orthogonal / perpendicular to the tangent plane at the surface point.

When transforming a normal we want something that preserves both of those constraints. The inverse transpose matrix used to transform the normal is derived from satisfying just the latter. That is, the dot product should equal zero. What we should be using instead is the cross product as it enforces both.

Not easily persuaded? Take this very simple triangle

Now reflect it around the zy axis. You'll note that the normal does not reflect properly if transpose(inverse(M)) is used to transform it. In fact, the normal points in the culled direction. Now all your lighting calculations end up as

max(0, dot(n, l)) = 0

Lets take a look at the derivation with the dot product to see what is actually happening here

We arrive to that by using this identity

From the above we see that we need transpose(inverse(M)) In front of N in order to preserve orthogonality.

What is not so obvious here is that

Has more than one solution and orientation isn't considered. This is easy to show.

When the normal is not zero (|N| != 0) and the triangle isn't degenerate (|V| != 0) you get

Which means when you have M such that

Then you will get a normal that points in the completely opposite direction.

Looking at it a bit differently

So what happens if we derive the normal from the cross product of three vertices instead?

Then we apply M to the vertices to get something more interesting

Where cof here is the cofactor

This tells us something rather interesting

The transpose(inverse(M)) is missing the sign from det(M) and that's why the normal is oriented the wrong way when det(M) < 0.

What we're actually interested in using is the cofactor instead of transpose(inverse(M)), which has the added benefit of being more efficent to compute and more accurate.

Insight to be had

We should not be deriving the normal from the dot product because it leads to precisely this problem. Derive it from the cross product and teach the derivation of it using the cross product.

Sample code

Included here is some sample C code for calculating the cofactor of a 4x4 matrix which can be used instead of transpose(inverse(M))

float minor(const float m[16], int r0, int r1, int r2, int c0, int c1, int c2) {
  return m[4*r0+c0] * (m[4*r1+c1] * m[4*r2+c2] - m[4*r2+c1] * m[4*r1+c2]) -
         m[4*r0+c1] * (m[4*r1+c0] * m[4*r2+c2] - m[4*r2+c0] * m[4*r1+c2]) +
         m[4*r0+c2] * (m[4*r1+c0] * m[4*r2+c1] - m[4*r2+c0] * m[4*r1+c1]);
}

void cofactor(const float src[16], float dst[16]) {
  dst[ 0] =  minor(src, 1, 2, 3, 1, 2, 3);
  dst[ 1] = -minor(src, 1, 2, 3, 0, 2, 3);
  dst[ 2] =  minor(src, 1, 2, 3, 0, 1, 3);
  dst[ 3] = -minor(src, 1, 2, 3, 0, 1, 2);
  dst[ 4] = -minor(src, 0, 2, 3, 1, 2, 3);
  dst[ 5] =  minor(src, 0, 2, 3, 0, 2, 3);
  dst[ 6] = -minor(src, 0, 2, 3, 0, 1, 3);
  dst[ 7] =  minor(src, 0, 2, 3, 0, 1, 2);
  dst[ 8] =  minor(src, 0, 1, 3, 1, 2, 3);
  dst[ 9] = -minor(src, 0, 1, 3, 0, 2, 3);
  dst[10] =  minor(src, 0, 1, 3, 0, 1, 3);
  dst[11] = -minor(src, 0, 1, 3, 0, 1, 2);
  dst[12] = -minor(src, 0, 1, 2, 1, 2, 3);
  dst[13] =  minor(src, 0, 1, 2, 0, 2, 3);
  dst[14] = -minor(src, 0, 1, 2, 0, 1, 3);
  dst[15] =  minor(src, 0, 1, 2, 0, 1, 2);
}