Light that knows its shape: normal maps for pre-rendered sprites
In a 2D strategy game a tank is a picture with the sun baked in. When a shell lands beside it at night, the picture has no idea which side faces the blast. This lesson shows the one piece of data that fixes that, the normal map, and how we built it into Iron Sector.
- sprite sheets with normal maps
- 135sprite sheets with normal maps
- frames, each with its own normals
- 55,204frames, each with its own normals
- resolution of the normal pages
- ½resolution of the normal pages
- per frame with 50 lit sprites
- +0.5 msper frame with 50 lit sprites
A sprite is a picture
In a 2D strategy game, a tank is not a 3D model. It is a picture, rendered once from a fixed camera and stored in an atlas. Each frame is only a grid of colours, and the sunlight was baked into those colours when the sprite was made.
That is fast, and it looks right, until something else in the scene starts to glow: a muzzle flash, an explosion, a burning house at night. The picture does not know which way its surfaces face, so the most a game can do is make the whole sprite brighter. It looks like a sticker.


What a normal is
A normal is an arrow one unit long that stands straight out of a surface and points the way the surface faces. To light a surface, a renderer compares that arrow with a second one pointing toward the light.
In 1760 Johann Lambert described the rule games still use: a matte surface receives light in proportion to the cosine of the angle between the two arrows. That cosine is simply their dot product: multiply x by x, y by y, z by z, and add. A face turned straight to the light gets all of it; edge-on or turned away, it gets nothing.
brightness ∝ cos θ = n · l n · l = nx·lx + ny·ly + nz·lz
| Angle to the light | Light received |
|---|---|
| 0° | 1.00 |
| 45° | 0.71 |
| 60° | 0.50 |
| ≥ 90° | 0 |
Normal maps in games
In 1978 James Blinn showed that you can fake small bumps by bending a surface's normals, without adding geometry. By 2004, with Doom 3 and Half-Life 2, it was standard: a simple model wears a texture of normals baked from a far more detailed one, so rivets and bricks catch the light.
2D games borrowed the idea. With Sprite Lamp an artist paints a character lit from a few sides and the tool derives a normal map. Dead Cells renders its characters from 3D models and exports a normal map with every frame. Iron Sector is closest to that case: our sprites are rendered from 3D models, so the normals are not painted or guessed. They are measured.
How a normal is stored in a picture
A normal has three numbers and an image has three channels, so a normal map stores the arrow as a colour. Red is how much the surface points right, green how much it points up, blue how much it points toward the camera.
Each value runs from −1 to 1, so it is squeezed into the colour range: halve it, add a half. That is why normal maps look violet: a surface facing the camera is (128, 128, 255). To read a pixel back, divide by 255, double it and subtract one.
colour = n × 0.5 + 0.5 n = colour × 2 − 1 (218, 83, 205) → (0.71, −0.35, 0.61)


Where our normals come from
The models in Iron Sector are written as code and turned into voxels, cubes about two centimetres across. A voxel on the surface has at least one empty neighbour. To find its normal, the generator looks at all 26 neighbours: each filled one pushes the arrow away from itself, the diagonal ones a little less. Sum the pushes, scale to one unit, and the arrow points out of the material.
On a flat roof everything below is filled and everything above is empty, so the arrow points straight up; on a round turret the arrows fan out smoothly. The same pass that writes a pixel's colour writes its normal into the same pixel, so the normal map fits the sprite exactly. For the game that meant 135 sprite sheets and 55,204 frames, built next to the existing atlases.
n = normalize( −Σ filled(k) · dₖ / |dₖ| ) over 26 neighbours

The sun that is already there
Every sprite already carries a light: a sun from the upper left, baked in at render time, 30 % ambient and 70 % from the sun by the same dot product.
We tried the textbook route first: store the colours without light and relight everything at runtime. The relit tank was off by up to 32 steps out of 255 where our limit was 3; the soft ground shadow and sub-pixel detail cannot be recovered from the maps. So the approved daytime look stays exactly as it is, and new light is added on top. That is also what a real flash does.
intensity = 0.30 + 0.70 · max(0, n · L) L = (−0.55, 0.30, 0.78)
The equation of one light
The added colour is the sprite's own colour, times a strength k, times the light's colour, times a facing term, times a distance term. The facing term is the dot product with wrap lighting: adding 0.2 before dividing lets light reach a little around the edges, as on dusty painted steel. The distance term fades smoothly from one to zero at the light's radius.
Take a turret pixel at night with a flash halfway to its radius. From the right, n · l = 0.71, which wraps to 0.76; the falloff is 0.56; with k = 2.2 the pixel gains 93 %. From the left, n · l is negative and the pixel gains nothing. Without normals both sides gain the same 124 %, and the tank looks flat.
added = colour × k × light × wrap(n · l) × falloff(d / r) wrap(x) = max(0, (x + 0.2) / 1.2) falloff(q) = (1 − q²)² k: day 0.35 · dawn 1.2 · night 2.2




Two spaces, one camera
The normals are stored in screen space, as the camera sees them. The lights live in the world, as a point on the ground and a height above it. Our camera is fixed forever, 45° around and 30° down, so the conversion is a single rotation by 30°.
For every pixel the shader takes the vector to the light, tilts it by the camera angle, and only then takes the dot product. A fire 10 units up and 10 units in front of the tank becomes an arrow pointing mostly into the screen and a little from above: it lights the faces turned toward us, and the roof a bit.
v = ( dx, dz·cos 30° − dy·sin 30°, dy·cos 30° + dz·sin 30° )
In the game
At night a Tiger fires its main gun. The flash lasts a few frames, but its light reaches six times its visible radius, because in the dark light is what you notice. Without normals the flash paints a bright circle over the ground and the tank alike. With normals the front plate and the mantlet light up and the rear stays dark. By day the same flash is barely there, as it should be.
Explosions are where normals earn their keep. A shell on the left lights the left side; a shell on the right moves the light across. In a barrage each sprite takes the eight strongest lights and adds them up, so a tank can be lit from two or three sides at once, each in its own colour.







Light that stops at a wall
Deciding where to draw the light was half of the work. Drawn on top of all sprites, a tank parked behind a house shone straight through the wall. Drawn under the night filter, the dark blue capped red at a third and fire came out brown.
The answer was a light picture of its own. The renderer draws the scene a second time, in the same order, into a separate texture: lit sprites as pure light, every other sprite as a black silhouette, so a house in front blocks the tank behind it. Then that picture is added over the night grade and fire stays warm. One honest limit remains: the light has no shadows yet.
What it costs
A normal map is as big as the picture it belongs to, but shape changes more slowly than colour, so the normals are stored at half resolution. Scaled back up they differ by about one step in 255. The scene needs a tenth to a quarter more memory, and on a fast GPU fifty lit sprites add about half a millisecond per frame.
The whole system is optional. With lighting off not one pixel changes and no normal map is loaded. Medium lights 50 sprites with 3 lights each; High lights 150 sprites with 8 lights each.
Summary
Three numbers in a pixel, one dot product per light, and a careful choice of where to draw it. The sprites are still pictures rendered once from a fixed camera, but now every pixel knows which way it faces. And when something burns in the dark, the tank turns its armour to the light.