Skip to content

Pixel Art Normal Map Generator — From Sprite Brightness or Alpha

For 2D lights you need a normal map per sprite. The Texture Lab reads height from the sprite's own grey values (or its alpha) and turns the gradient into a tangent-space normal map, with the convention named on screen.

Pixel-art normal maps look best with only a few normal directions, the way artists paint them: flat, then one or two tilts towards each side. This page opens the Texture Lab, which turns a sprite's brightness or alpha into a normal map at pixel scale. The Studio's Texture workspace adds a 1.5 px bevel from the silhouette and "Pixel-art normals", which snap every pixel to 8 directions × 2 tilts plus flat by default, 17 possible normals, and exports them for Godot 4 or Unity 6.

Drop texture maps (PNG keeps exact channel bytes)
Runs in your browser. Files are not uploaded.
Texture Lab: a normal map generated from a CC0 texture, with the OpenGL/DirectX convention shown
The real Texture Lab with a CC0 asset (ambientCG).
Outputs
  • Channel PNGsMeasured· numpy
  • Normal mapMeasured· numpy
  • Packed mask / ORMMeasured· Pillow
  • Engine importUNVERIFIED

Why pixel-art normals are quantised

A normal map made from a gradient has a slightly different vector in almost every pixel. Under a moving light that becomes soft ramps and one-level steps of brightness inside a 16 × 16 sprite, which reads as noise next to a limited palette. Hand-painted pixel normals use a handful of values instead: flat, and a small set of tilts facing each direction, so the lit sprite breaks into clean bands that jump from pixel to pixel as the light moves.

Nerulio's quantiser in the Studio reproduces that. Each pixel keeps its own direction and tilt but snaps: the direction to 4, 8, 16 or 32 evenly spaced angles, the tilt to 1–4 steps up to 60°. With the default 8 directions × 2 steps, tilts under 15° become flat, 15–45° become 30° and anything steeper becomes 60°. Every pixel is snapped on its own, so a one-pixel ledge stays one pixel wide.

The height underneath should be pixel-sized too. For frames up to 128 px the Studio suggests a 1.5 px bevel rim and the Pixel kernel, a plain central difference without smoothing, so the rim does not spread into a blur. With 17 normals and a distant light, each colour of the sprite can take at most 17 brightness levels.

The engine decides the rest: a normal map filtered bilinearly mixes neighbouring normals again, a Godot PointLight2D left at Height 0 grazes the sprite from the side, and Unity computes 2D lighting at half the screen resolution unless Light Render Texture Scale is set to 1.

Direction step
360° ÷ directions: 45° for 8, so a tilted pixel faces right, up-right, up and so on.
Tilt step
How far a pixel leans from facing the viewer; 2 steps up to 60° give 30° and 60°.
Pixel kernel
A central difference with no cross smoothing, so a 1 px ledge stays 1 px wide.

Example: the 17 normals of the default setting (OpenGL bytes)

8 directions × 2 tilts + flat, as the quantiser writes them:

flat (tilt under 15°)                    128,128,255
30° ring  right 191,128,238  up 128,191,238  up-right  173,173,238
          left   64,128,238  down 128,64,238  down-left  82, 82,238
60° ring  right 238,128,191  up 128,238,191  up-right  206,206,191
          left   17,128,191  down 128,17,191  down-left  49, 49,191

input tilt 14° → flat    40° → 30°    46° → 60°    80° → 60°

Up-left and down-right follow the same pattern (82,173,238 and 173,82,238 at 30°). Every pixel of such a map is one of these 17 byte triples, so you can also touch it up by hand with an eyedropper.

What it does

  • Height from luminance or from the alpha channel.
  • Kernels Sobel 3×3, Scharr and Sobel 5×5, normalised so the same strength means the same slope.
  • OpenGL (+Y: Unity, Godot) or DirectX (−Y), with X/Y invert; flat areas come out exactly (128, 128, 255).
  • Converted and generated maps decode to unit-length vectors (checked).

How to do it

  1. Drop the sprite (or a hand-painted height map).
  2. Choose luminance or alpha as height, the kernel and the strength.
  3. Pick the convention your engine expects.
  4. Download the normal map.

Check the result

  • Count the colours of the exported normal map in any editor: with Pixel-art normals at 8 × 2 there are at most 17.
  • Move the light slowly across the sprite in the Lit view: bands should jump from pixel to pixel, not fade.
  • In the engine use Nearest (Godot) or Point (Unity) filtering; linear filtering blends neighbouring normals and brings the soft ramps back.

Troubleshooting

SymptomLikely causeHow to checkFix
The lit sprite looks noisy or ditheredUnquantised gradient normals: this page's Lab output, or Pixel-art normals switched offThe normal map has hundreds of coloursTurn on Pixel-art normals in the Studio's Normal map panel
Light bands are soft in the engineLinear texture filtering, or Unity's 2D lights at half resolutionZoom to 400 %: soft edges between bandsNearest in Godot (blurry pixel art in Godot); Point filter and Light Render Texture Scale 1 in Unity
Outlines light up like ridgesA dark outline read as low height, so its edge becomes a slopeHeight view: a trench along the outlineLower the brightness detail or flatten the outline with the Height brush
Everything tilts, nothing stays flatStrength too high for pixel-sized relief, so most tilts pass 45°Most pixels hold 60° values (238 or 17 in red or green)Lower the strength, or use 3–4 tilt steps

Other ways to do it

OptionWhen it is the better choice
Paint the normals by hand in a pixel editorStylised art: lit in Godot 4.7.2, every generator's map of a hand-painted CC0 torch was further from the artist's own map than a flat map was (flat 26.6 levels, Nerulio 36.2).
Studio Texture workspace with Pixel-art normalsYou want the bevel, the quantisation, a lit preview computed like Godot and verified Godot/Unity exports: start from the sprite normal map page.
LaigterA desktop app with presets and its own lit preview; quantised pixel-art normals were not seen in it.

Outputs and how each was checked

"Measured" means the downloaded file was opened again outside the page (Pillow, numpy or an independent parser) and the property was measured. Loading these files in a game engine was not tested.

TargetFilesCheck
Channel PNGs<name>-r/g/b/a.png (8-bit grey)Measured numpyByte-identical per channel, including RGB under fully transparent texels.
Normal mapPNG · OpenGL (+Y) or DirectX (−Y)Measured numpyDecoded vectors are unit length (worst deviation 0.0002); OpenGL and DirectX differ in green only.
Packed mask / ORMUnity HDRP mask · URP metallic · ORM (glTF, Godot)Measured PillowPacked bytes checked: ORM preset over AO 10 / roughness 80 / metallic 220 → (10, 80, 220, 255).
Engine importUnity · Unreal · GodotUNVERIFIEDNot imported in any engine.

Evidence

Channel unpack byte-identical on all four channels (max difference 0), 2048 transparent texels keep their RGB; edge bleed changed 448 texels, all with alpha 0, and no alpha byte; OpenGL ↔ DirectX differ exactly in green (255 − g); converted normals decode to unit vectors.

Limits

  • The Texture Lab on this page computes gradients only: no bevel, no quantisation, no engine test. Quantised normals are in the Studio.
  • No generator derives an artist's stylised shading from the colours; quantisation makes a generated map look painted, not correct.
  • The maximum tilt is fixed at 60°.
  • Exact channel bytes need PNG; JPEG/WebP are decoded by the browser and marked "not byte-exact".
  • Interlaced PNG and 16-bit output are not supported; up to 33.5 million pixels on the exact path.
  • A normal map's convention cannot be detected from its pixels, only converted.
  • Nothing was imported into Unity, Unreal or Godot: channel orders rest on their documentation and engine source.

Versions and sources

tests/normals.test.mjs checks that only the allowed directions and tilts appear and that flat stays flat. The Studio's Godot export was rendered by Godot 4.7.2 on two pixel-art cases (a 6-frame 32 px torch and a 60-frame 48 px samurai), all checked frames within 1/255; that run covers the export path, not the quantiser specifically. The Lab maps on this page were checked numerically only. Engine settings follow the Godot and Unity documentation below.

Official documentation used

Questions

Is this like Laigter or SpriteIlluminator?

No. It computes the gradient of height or brightness; it does not inflate or bevel the silhouette and has no painting brushes. Paint a height map yourself for full control.

Was it tested in Godot or Unity lighting?

No. The conventions come from the engines' documentation; the maps were checked numerically, not rendered in an engine.

Is it free, and do I need an account?

Everything on these pages is free and works without an account. There is no watermark and no export that is held back.

Are my sprites uploaded?

No. The Studio decodes, packs and exports in this browser tab. Projects are autosaved in the browser (IndexedDB) and can be saved as a .nerulio file.

Does it need Aseprite or an engine plugin installed?

No. .aseprite files are read and written in the browser, and each engine bundle carries what that engine needs (for Godot, the import settings and scene; for Unity and the Godot TileSet, a small editor script).