Pixel-art normal-map lighting
Flat pixel art, lit like it has volume.
Every sprite pixel gets a normal: which way that pixel faces. A torch carried past the crates then lights each pixel from the right side.
The problem
Pixel art bakes its light into the colours. Move a torch past a hand-shaded crate and the shading stays put, so dynamic lights in a 2D scene end up as coloured glows laid over the sprites.
How it works
Give each sprite a second image of the same size: a normal map. Each pixel stores a direction as RGB (x right, y up, z out of the screen). The lighting then works per pixel, like on a 3D surface: brightness follows the angle between that pixel's normal and the direction to the light.
I generate the normal maps instead of drawing them:
- A height map per sprite. Bricks get a 2-pixel bevel and recessed mortar, crates a raised frame and grooved planks. The character and barrels get a rounded "pillow" from the distance to the sprite's edge.
- A 3×3 Sobel filter turns height into slope, and slope into a normal.
- Each normal snaps to the nearest of 17 directions: straight out, plus 8 directions at 35° and 8 at 60°.
That snap is what keeps it looking like pixel art. A flat face stays exactly flat, so light falls across it in clean steps instead of smooth gradients.
What I hit
I first set the lights in units that suit a 3D scene, and the whole wall burned orange. Here one world unit is one screen pixel. With linear falloff (decay 1) the torch ended up at intensity 250 and the coloured lamps at 150.
Pixels at a sprite's outer edge read their empty neighbours as height 0. That tilts the silhouette outward like a rim. It suits the round barrels and the character, so I kept it.
A data texture's first row is the bottom of the image, while my sprites start at the top. Without flipping the rows, every normal map was upside down and the light came from the wrong side.
My build
three.js (dev branch, r187): orthographic camera, one plane per sprite, MeshStandardMaterial with the sprite as map and the snapped normals as normalMap, both with nearest filtering. The frame renders at 320 × 180 and is scaled up 4× with no smoothing, so one texel is one screen pixel. All sprites are generated in code.
The left half uses the same sprites as plain textures with no lighting.
Trade-offs
- One extra texture per sprite, generated from a height map
- Any number of moving or coloured lights reuse the same normals
- Snapping keeps the pixel look under smooth light
- Lighting a whole 2D scene per pixel costs more than unlit sprites
- Generated heights are a guess: hand-tuned normals look better on characters
- No shadows: a lit pixel doesn't know a crate is between it and the torch
When to use it
Torches, lanterns, muzzle flashes and day-night cycles in 2D games where the art should react to light without redrawing it.
Source and credits
Repo: github.com/mrdoob/three.js (MIT license), branch dev, commit fa01dd6
Code: three.js renders the scene (OrthographicCamera, MeshStandardMaterial with map + normalMap, NearestFilter, PointLight). The sprites, height maps, the Sobel normal-map generator and the 17-direction normal quantiser are my own code. No tools are recommended or shown, and no art from the original post or any game is used.
Topic credit: @SandroMaglione's post on normal maps for pixel art, https://x.com/SandroMaglione/status/2117196018769178990