When writing shaders, you’ve probably come across the function in shader code ‘UnpackNormal’, which can be seen in use here:
Float3 normal = UnpackNormal (tex2D(_Bump, _IN.uv_BumpMap));
Or in shadergraph, if you are using a normal texture without setting the texture import type as normal, you may have come across this node:
Or, in the case of setting the normal map texture to be imported as such, the Sample Texture 2D performs this function itself:
But why is it needed? Let’s start by looking at the space in these normal maps are made for, Tangent Space.
You’ve probably heard of spaces before with names like WorldSpace, ObjectSpace, ViewSpace, ScreenSpace, ClipSpace, etc. Tangent Space is another one of these spaces. We can think of tangent space as a relative axis conforming to the normal of a surface, where the Z component matches the surface normal’s direction. The X and Y components can be thought of as describing how much the surface normal deviates from its original direction. The X and Y component are also referred to as the Tangent and BiNormal (sometimes also called BiTangent).
The normal of the vertex is colored purple, whilst the tangent space aligned axis has the Z component aligned with the normal.
You may have seen the coordinate (0,0,1) used as a default tangent space normal, and the Z component being 1 makes it align with the normal exactly.
Our Tangent Space normal maps can only store positive values, whether it be from the range 0 - 1 or 0 – 255 (we will stick with the 0-1 range). Which means, without performing any type of conversion, the minimum vector we could store would be (0,0,1) and the maximum would be (1,1,1) (ignoring the Z component in this example), which would look like it can only deviate a max of one quadrant area from the surface normal.
We don’t just want the normal to deviate in this single quadrant though, we want a full range of coordinates around the normal. Since our textures that store the tangent space normal vectors can only store positive values, but we want to also store negative values too, we need a way of remapping a negative to positive range into the positive range.
Instead of performing a Remap() function, where we might pass in the negative to positive normal vector and get it remapped into a positive range, the math we use is a lot simpler than a Remap() function, since that uses a divide.
To convert our Tangent Normal Vector into a range that a texture can hold, we take the X and Y components and multiply them by 0.5, then add 0.5. See it expressed below:
Normal.xy = normal.xy * 0.5 + 0.5;
Let’s look at an example with a negative value:
· Tangent Normal Vector (-0.2, 0.3, 1):
· Normal.xy = (-0.2, 0.3) * 0.5 + 0.5
· Normal.xy = (-0.2 * 0.5 + 0.5, 0.3 * 0.5 + 0.5)
· Normal.xy = (-0.1 + 0.5, 0.15 + 0.5)
· Normal.xy = (0.4, 0.65)
· Final Texture Color for normal vector = (0.4, 0.65, 1);
So, that’s how we store our negative to positive ranged tangent normal vector into a texture. It also explains why the default normal color, describing no change in normal direction, is the bluish-purple color of (0.5,0.5,1), as it describes the converted range of (0,0,1) into the texture range.
How then, when we sample this normal from the texture, do we get back into the negative to positive range? The Unpack Normal function contains this conversion, that takes the X and Y components and multiplies them by 2, then subtracts 1 from it. See it expressed below:
Normal.xy = normal.xy * 2 – 1;
Let’s look at an example with the same normal vector we used before:
· Texture Normal Vector = (0.4, 0.65, 1)
· Normal.xy = (0.4, 0.65) * 2 – 1
· Normal.xy = (0.4 * 2 – 1, 0.65 * 2 - 1)
· Normal.xy = (0.8 – 1, 1.3 - 1)
· Normal.xy = (-0.2, 0.3)
· Final Unpacked Tangent Normal Vector = (-0.2, 0.3, 1)
· That’s what our original vector was!
As you may have noticed, the X and Y components contribute the most to the deviation of the normal. When performing calculations with normals, for example lighting, we often use normalized vectors. You may have also seen certain textures and compression formats only requiring the X and Y components of the tangent space normal vector, this is because we can derive the Z component from these two. Just like how in Pythagorean theorem, where A2 + B2 = C2, we can reword to this fit our space, X2 + Y2 = Z2. To get the final value of Z, we square root this result, so Z = √ (X2 + Y2). But since we are working with normalized vectors (also called unit vectors), the final magnitude will add up to a value of 1. To put that in our original formula, it would first look like: 1 = X2 + Y2 + Z2, but we can reword it to: Z2 = 1 – X2 + Y2. To get the final value of Z, we just square root this equation, so the formula becomes Z = √(1 – (X2 + Y2)).
This formula is what the unity Shader Graph Node Normal Reconstruct Z performs, as seen here:
Hopefully this has provided some insight into how tangent space is used with normal maps, and a brief explanation of why some of the formulas are used!