Normal Mapping in Unity URP: Lighting in Tangent Space

How normal mapping fakes surface detail without extra geometry, and a complete URP shader that builds the tangent-space basis in the vertex shader.

A brick wall rendered with normal mapping, showing per-pixel lighting across the mortar grooves

Raising the quality of a 3D asset eventually leads to wanting to represent the bumps and dents of the object as well.

Representing that with geometry costs you. Fine detail like the wrinkles in skin, or the pattern pressed into a small prop, means raising the polygon count, and the rendering cost rises with it.

It also buys less than it appears to. An object built from millions of polygons covers the same area of the screen as any other, and once it is drawn, most of those hard-won polygons come out smaller than a single pixel.

So the idea appears that fine detail only has to look right — and from that fake comes normal mapping, or bump mapping, built on a normal map.

The geometry is never modified, so the surface really is still flat. It simply catches light as though it were uneven, and the light and shade that produces is what makes it read as uneven.

What a normal map stores

A normal map encodes a normal direction in each texel, packed into the RGB channels. Where a regular texture answers “what color is this point”, a normal map answers “which way is this point facing”.

A brick wall base color texture: red-brown bricks in a running bond with gray mortar joints

Base map

The matching normal map: flat lavender across each brick face, with cyan and pink fringes where the faces drop away into the mortar joints

Normal map

The normal map is the image on the right: the normal direction at each UV coordinate, written out as a color value. The base map is on the left.

These maps are usually generated from a height map, which is a much more intuitive thing to author: white is high, black is low. To convert one into the other, you take the difference in height between neighboring texels in the horizontal and vertical directions, and compute the vector orthogonal to both slopes. That vector is the normal, and writing it out per texel gives you the normal map.

Lighting then uses three vectors: the normal we just sampled, the direction to the viewer, and the direction to the light.

The three vectors that drive the shading. Normal mapping replaces the flat geometric normal with one sampled per pixel.

The difference it makes

Here is the same wall rendered with only the base color texture, and then with a normal map driving the lighting.

The same wall with the normal map driving the lighting: each brick catches light on one side and falls into shadow on the other
The brick wall lit with only its base color texture: evenly lit and visibly flat
Base color onlyNormal mapped
Drag to compare. The geometry is identical — a flat quad in both cases. Every bit of relief comes from the lighting response.

The surface reads as uneven, but the more striking part is how much more present the wall looks once lighting actually varies across it. A flat texture receives flat light; a normal-mapped one does not.

Implementation

The full shader lives in my shader repository, alongside the more advanced techniques that build on it:

Full shader

NormalMapping.shader
Shader "Custom/NormalMapping"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
[Normal] _NormalMap("NormalMap", 2D) = "bump"
_Shininess("Shininess", Float) = 0.07
}
SubShader
{
Tags { "Queue"="Geometry" "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
LOD 100
Pass
{
Name "Normal"
Tags { "LightMode"="UniversalForward"}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 viewDirTS : TEXCOORD1;
float3 lightDir : TEXCOORD2;
float3 lightColor : COLOR;
};
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
TEXTURE2D(_NormalMap);
SAMPLER(sampler_NormalMap);
float _Shininess;
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float4 _NormalMap_ST;
CBUFFER_END
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
Light light = GetMainLight();
// Transform the light and view vectors into tangent space here,
// so the pixel shader can work in the same space as the normal map.
o.lightDir = mul(rotation, light.direction);
o.viewDirTS = mul(rotation, GetObjectSpaceNormalizeViewDir(v.vertex));
o.lightColor = light.color;
return o;
}
float4 frag (v2f i) : SV_Target
{
i.lightDir = normalize(i.lightDir);
i.viewDirTS = normalize(i.viewDirTS);
float3 halfVec = normalize(i.lightDir + i.viewDirTS);
float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));
normal = normalize(normal);
float4 color;
float3 diffuse = max(0, dot(normal, i.lightDir)) * i.lightColor;
float3 specular = pow(max(0, dot(normal, halfVec)), _Shininess * 128) * i.lightColor;
color.rgb = tex * diffuse + specular;
return color;
}
ENDHLSL
}
}
}

Walkthrough

Vertex shader: building the tangent-space basis

A normal map stores its normals in tangent space — a per-vertex coordinate frame aligned to the surface and its UV layout. The lighting math only works if every vector involved is expressed in the same space, so something has to be converted.

Converting the sampled normal into world or object space in the pixel shader would run that transform for every pixel on screen, which is expensive. So the light and view vectors are converted into tangent space in the vertex shader instead — once per vertex — and the results are handed to the pixel shader:

float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
Light light = GetMainLight();
o.lightDir = mul(rotation, light.direction);
o.viewDirTS = mul(rotation, GetObjectSpaceNormalizeViewDir(v.vertex));
o.lightColor = light.color;

The tangent and normal come straight from the mesh. The binormal (bitangent) is their cross product, with tangent.w carrying the handedness sign — that sign is what keeps mirrored UV islands from lighting inside out.

Pixel shader: unpacking the normal

float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv));
normal = normalize(normal);

UnpackNormal handles the decoding, which is not as simple as a range remap: depending on the platform and compression format, Unity may store the normal as DXT5nm with the X channel moved into alpha, or as a plain RGB texture. UnpackNormal picks the right path for the current target.

The explicit normalize afterwards is deliberate. Some formats come back effectively normalized already and some do not, and interpolation across a triangle denormalizes vectors regardless. Normalizing unconditionally keeps the result consistent across platforms rather than subtly different on each.

Lighting

float3 diffuse = max(0, dot(normal, i.lightDir)) * i.lightColor;
float3 specular = pow(max(0, dot(normal, halfVec)), _Shininess * 128) * i.lightColor;

Standard Lambert diffuse plus a Blinn–Phong specular term, both driven by the sampled normal rather than the geometric one. That substitution is the entire technique.

Where this breaks down

Normal mapping only changes shading. It does not change where the surface is, which means it cannot represent one bump occluding another. At grazing angles, or when the camera gets close, the illusion falls apart — the surface reads as a flat plane with a pattern painted on it, because that is exactly what it is.

Fixing that requires shifting the sampling position based on view direction, which is where parallax mapping comes in — the subject of the next article in this series.

References