Skip to content

2D Normal Map Generator — From Height or Sprite Luminance

Turn a height or grey image into a tangent-space normal map. The same strength means the same slope in every kernel, a flat input gives exactly (128, 128, 255), and the convention is named on screen.

A normal map generator turns a height or grey image into a tangent-space normal map: for every pixel it measures how steeply the height rises left–right and up–down and writes that tilt as RGB, with flat areas at exactly (128, 128, 255). This page opens the Texture Lab: drop a height map, a grey texture or a sprite, pick brightness or alpha as the height, a Sobel, Scharr or Sobel 5×5 kernel, a strength and OpenGL (Godot, Unity) or DirectX green, and download the PNG. It runs in the browser; nothing is uploaded.

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

From height to normal: gradient, kernel and strength

The Lab reads one height value per pixel: either the brightness of the image (Rec. 709 luma, 0.2126 R + 0.7152 G + 0.0722 B) or its alpha channel, from 0 to 255, used as 0 to 1. White is high, black is low.

A derivative kernel estimates the slope in x and in y from the neighbouring pixels. Sobel 3×3 is the default; Scharr 3×3 weighs the centre row more, which keeps diagonal slopes more consistent; Sobel 5×5 averages over a wider area, which calms noise and loses fine detail. Every kernel is normalised so that a ramp of one full height unit per pixel reads as slope 1, so the same strength gives the same tilt whichever kernel you pick.

The normal is (−strength·∂h/∂x, strength·∂h/∂y, 1) normalised to length 1 and encoded as (n + 1) × 127.5; DirectX output negates green, which is the only difference between the two files. With Wrap on, the kernel samples across the opposite edge, which a repeating texture needs; with Wrap off the border pixels are clamped and a tiled copy shows a seam.

Height map
A grey image where white is high and black is low.
Kernel
The small grid of weights that estimates the slope from neighbouring pixels.
Strength
The slope multiplier: 0–10 on the slider, 2 by default.
Wrap
Sampling across the opposite edge, so a tileable map has no seam.

Example: a brightness ramp of 32 levels per pixel

A grey image that gets 32 levels brighter per pixel towards the right, Sobel 3×3, OpenGL:

slope = 32 / 255 = 0.1255 per pixel
strength 1:  n ∝ (−0.1255, 0, 1) → (−0.124, 0, 0.992) → RGB (112, 128, 254)   tilt  7.2°
strength 2:  n ∝ (−0.2510, 0, 1) → (−0.243, 0, 0.970) → RGB ( 96, 128, 251)   tilt 14.1°
flat area:   n = (0, 0, 1)                            → RGB (128, 128, 255)

Red drops below 128 because a surface rising to the right faces left. Doubling the strength does not double the tilt, because the vector is normalised again: tilt = atan(strength × slope).

What it does

  • Height from luminance or from the alpha channel; kernels Sobel 3×3, Scharr and Sobel 5×5, normalised.
  • OpenGL (+Y, default) or DirectX (−Y), invert X or Y.
  • Wrap sampling for tileable textures: the edge texel equals the same texel inside a real 2× repeat.
  • Combine a detail normal with Reoriented Normal Mapping (no flattening RGB blend).

How to do it

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

Check the result

  • Flat parts of the height image must come out exactly (128, 128, 255); the Lab tests this for all three kernels and both conventions.
  • Read the validation under the result: mean vector length close to 1.000 and blue never below 128, since a normal cannot point into the surface.
  • For a tileable texture, check the joins with Wrap on, for example with the tiling seam check.
  • Light it before you ship: in your engine, or in the Lab's material preview, which is labelled an approximation (one light, GGX).

Troubleshooting

SymptomLikely causeHow to checkFix
Bumps look like dentsIn this picture dark is raised, but the Lab reads dark as lowA known bump: its lit side faces away from the lightTick both Invert X (red) and Invert Y (green) (the same as inverting the height); if only up–down is wrong, switch the convention instead
A line appears where the texture repeatsWrap was off, so the border pixels were clampedTile the result 2 × 2 and look at the joinsTurn Wrap on and export again
Grainy, noisy reliefJPEG artefacts or painted texture read as heightZoom into areas that should be smoothUse a PNG source, Sobel 5×5 or a lower strength
A sprite's outline stays flat while its inside is bumpyBrightness has no bevel: the silhouette is height only when alpha is the sourceSwitch the source to Alpha and compareUse alpha as height, or the bevel in the sprite normal map generator

Other ways to do it

OptionWhen it is the better choice
Studio Texture workspace (sprite normal maps)Sprites: a bevel from the silhouette, lights computed like Godot, per-frame sheets and verified Godot 4 and Unity 6 exports.
Bake from a high-poly model in a 3D programThe surface exists in 3D: a bake gives true normals instead of a guess from brightness.
OpenGL ↔ DirectX converterYou already have a normal map and only need the other green convention.

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

  • Brightness is not shape: painted shadows become dents and painted highlights become bumps.
  • No bevel, no 2D light preview and no engine bundle here; these maps were checked numerically, not rendered in an engine.
  • Output is an 8-bit PNG; a 16-bit height input keeps only its high byte.
  • 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

Texture Lab, measured on downloaded files: generated and converted maps decode to unit vectors (mean length 1.0001, worst deviation 0.0002); a flat height gives (128, 128, 255, 255) with all three kernels and both conventions; a wrapped edge equals the same pixel inside a real 2 × repeat; OpenGL and DirectX outputs differ in green only (255 − g). The Y+ convention of Unity and Godot is taken from their documentation.

Official documentation used

Questions

Is this the same as Laigter or SpriteIlluminator?

No. It computes the gradient of height or luminance; it does not paint or bevel shapes. For pixel art, the height comes from your own grey values.

Which convention should I use?

Unity, Godot and Blender expect OpenGL (+Y); Unreal expects DirectX (−Y), which the Lab labels as inferred from Epic's glTF exporter documentation.

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).