Skip to content

Normal Map Converter — OpenGL ↔ DirectX (Flip Green)

The two conventions differ in one thing: the green channel. The conversion is 255 − g, its own inverse, so a converted map converts back to the same bytes.

OpenGL-style normal maps (Y+, green points up — what Unity, Godot and glTF expect) and DirectX-style maps (Y−, green points down) differ only in the green channel. Converting one into the other is g → 255 − g on every texel, with red, blue and alpha unchanged, and doing it twice returns the original bytes. Nerulio's converter applies exactly that to the PNG in your browser and shows a unit-length check; it does not guess which convention your file uses — the Studio's Texture workspace has a measured detector for that.

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

What the green channel means, and what flipping does

A tangent-space normal map stores one unit vector per texel: red is X (towards the image's right), green is Y, blue is Z (out of the surface), each encoded as (n + 1) / 2 × 255. A flat texel is (128, 128, 255). The two conventions agree on X and Z and disagree only on which way +Y points in the image: up for OpenGL, down for DirectX.

Feeding a map in the wrong convention inverts every vertical slope: under a light from above, the lower edge of a bump is lit instead of the upper one, so bumps read as dents or the light seems to come from below. Horizontal slopes stay right, which is why the error can look like a lighting bug rather than a texture bug.

The fix is arithmetic, not a re-bake: y → −y is g → 255 − g, which is its own inverse. The Unity manual says Unity uses Y+ (OpenGL) normal maps; Godot's manual requires X+, Y+, Z+ ("OpenGL style"); glTF 2.0 fixes +X right and +Y up. For Unreal the Lab shows DirectX (−Y) marked "inferred", because Epic states it only indirectly.

Which convention is a given file? Both are valid normal maps, so the Lab does not guess. The Studio's Texture workspace estimates it from the surface itself (whether the slopes are consistent with each other, plus the silhouette on sprites) and shows a confidence; its verdict is never applied until you confirm it. See OpenGL or DirectX? for that check.

Tangent space
Normals relative to the surface: X along the texture's U, Y along V, Z out of the surface.
OpenGL (Y+)
Green points up in the image. Unity, Godot, glTF.
DirectX (Y−)
Green points down in the image.
Unit length
A valid normal decodes to a vector of length 1; the Lab reports the mean and the worst deviation.

Example: one texel, and how well detection works

A texel tilted towards the top of the image, normal (0, 0.6, 0.8), and a flat texel:

OpenGL   (0, 0.6, 0.8)  → (128, 204, 230)      flat (128, 128, 255)
DirectX  (0, −0.6, 0.8) → (128,  51, 230)      flat (128, 127, 255)
green    255 − 204 = 51 → (51 − 127.5) / 127.5 = −0.6

Studio GL/DX detector on maps of known convention (2026-09-24):
  128 full 1K maps      verdict 100 %   accuracy 100 %    wrong "high" 0
  504 crops of 64²      verdict 99.6 %  accuracy 99.6 %   wrong "high" 0
  naive "mostly green-high = OpenGL" rule, 1562 samples: 46 %

A flat texel becomes (128, 127, 255) and returns to (128, 128, 255) when converted back: the one-level difference is the 8-bit encoding's centre, not a loss. Without transparent edges, a map whose red is flipped reads as DirectX in the detector, and the Studio says so.

What changes in each channel

ChannelOpenGL (Y+)ConverterDirectX (Y−)
R (X)Towards image rightUnchangedTowards image right
G (Y)Up the image255 − gDown the image
B (Z)Out of the surface, ≥ 128UnchangedOut of the surface, ≥ 128
AWhatever the file hadUnchangedUnchanged
ambientCG file name_NormalGL—_NormalDX

The Studio's Texture workspace reads _NormalGL / _NormalDX in a file name as a declared convention; the Lab classifies both simply as normal maps, and any other name says nothing about the convention.

What it does

  • OpenGL ↔ DirectX conversion that changes green and nothing else (checked per channel).
  • Which engine expects which convention, with the documentation it rests on (Unreal's −Y is labelled inferred).
  • Normal map validation: mean length, worst deviation, lowest blue.
  • Height → normal with Sobel, Scharr or Sobel 5×5 and wrap for tiling textures.

How to do it

  1. Drop the normal map.
  2. Under What to do, choose OpenGL ↔ DirectX.
  3. Check the validation numbers.
  4. Download the converted map.

Files you get

FileWhat it is for
rock_normal-directx.pngThe converted map. The name assumes the input was OpenGL; if you converted a DirectX map, the content is OpenGL — rename it to avoid confusion.
texture-report.jsonOptional, from the Inspect stage: normalCheck.meanLength, unitLength and blueNonNegative for each normal map in the set.

Use the converted map in the engine

Engine settings from their documentation; Nerulio did not run these imports.

  1. Unity 6: import the OpenGL map and set Texture Type to Normal map. If you only have a DirectX map, you can skip the conversion and tick Flip Green Channel in the same importer.
  2. Godot 4: use the OpenGL map on the material's Normal Map (enable it, then assign the texture). For a DirectX map, the alternative is Process › Normal Map Invert Y in the Import dock, then Reimport.
  3. glTF 2.0: the normal texture must use +X right and +Y up, so convert DirectX maps before exporting.
  4. DirectX-style targets: use the converted -directx.png and follow that engine's own import documentation.
  5. Light the material from above and check that raised details are lit on their upper edge.

Check the result

  • The Normal stage shows the mean vector length and the worst deviation: a valid map reads close to 1.
  • Compare the saved file with the source channel by channel (Channels stage): R, B and A identical, G = 255 − g.
  • Convert the result again: you get the original bytes back.

Troubleshooting

SymptomLikely causeHow to checkFix
Bumps look like dents, light seems to come from belowThe map is in the other conventionLight from above: which edge is bright?Convert here, or use Unity's Flip Green Channel / Godot's Normal Map Invert Y
After converting, the lighting is still wrong sidewaysRed is flipped, not green (NormalMap-Online's default output is such a map)The Studio's Check panel flags a flipped red on spritesFlip red (X−) in the Studio's Texture workspace; the Lab only converts green
It was right before and wrong afterThe map was already in the target conventionConvert once more to undo (the operation is its own inverse)Keep the original
Lighting looks flat or smeared in UnityThe map was imported as a Default colour textureTexture Type in the InspectorSet Texture Type to Normal map
The validator reports blue below 128 or vectors far from unit lengthNot a tangent-space normal map (a height map, an object-space map, or a resized colour copy)Mean length and lowest blue in the Normal stageFlipping green will not fix it: generate a map from height with the normal map generator

Other ways to do it

OptionWhen it is the better choice
Unity's Flip Green Channel / Godot's Normal Map Invert YOne engine only: the import flag flips it without a second file.
Download the other variantThe asset site ships both (ambientCG has _NormalGL and _NormalDX): take the one your engine expects.
The Studio's convention checkYou do not know which convention a map uses, or suspect a flipped red.

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

  • 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

Measured on the Texture Lab (docs/TEXTURE-LAB.md): two downloads compared per channel gave dx.g == 255 − gl.g exactly with R, B and A identical; converted maps decoded to mean length 1.0001 with worst deviation 0.0002. Detection figures are from the Studio's evaluation on real maps of known convention (docs/STUDIO-TEXTURE.md, 2026-09-24). Engine conventions are quoted from the documentation below; no engine import of the converted file was run.

Official documentation used

Questions

Can it tell which convention my map uses?

No. Both are valid normal maps; the Lab tells you which engine expects which and converts, but never claims to detect it.

Is the conversion lossless?

Yes. Green becomes 255 − g; converting twice gives the original bytes.

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