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.

- 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
| Channel | OpenGL (Y+) | Converter | DirectX (Y−) |
|---|---|---|---|
| R (X) | Towards image right | Unchanged | Towards image right |
| G (Y) | Up the image | 255 − g | Down the image |
| B (Z) | Out of the surface, ≥ 128 | Unchanged | Out of the surface, ≥ 128 |
| A | Whatever the file had | Unchanged | Unchanged |
| 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
- Drop the normal map.
- Under What to do, choose OpenGL ↔ DirectX.
- Check the validation numbers.
- Download the converted map.
Files you get
| File | What it is for |
|---|---|
rock_normal-directx.png | The 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.json | Optional, 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.
- 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 tickFlip Green Channelin the same importer. - 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.
- glTF 2.0: the normal texture must use +X right and +Y up, so convert DirectX maps before exporting.
- DirectX-style targets: use the converted
-directx.pngand follow that engine's own import documentation. - 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
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| Bumps look like dents, light seems to come from below | The map is in the other convention | Light 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 sideways | Red is flipped, not green (NormalMap-Online's default output is such a map) | The Studio's Check panel flags a flipped red on sprites | Flip red (X−) in the Studio's Texture workspace; the Lab only converts green |
| It was right before and wrong after | The map was already in the target convention | Convert once more to undo (the operation is its own inverse) | Keep the original |
| Lighting looks flat or smeared in Unity | The map was imported as a Default colour texture | Texture Type in the Inspector | Set Texture Type to Normal map |
| The validator reports blue below 128 or vectors far from unit length | Not 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 stage | Flipping green will not fix it: generate a map from height with the normal map generator |
Other ways to do it
| Option | When it is the better choice |
|---|---|
Unity's Flip Green Channel / Godot's Normal Map Invert Y | One engine only: the import flag flips it without a second file. |
| Download the other variant | The asset site ships both (ambientCG has _NormalGL and _NormalDX): take the one your engine expects. |
| The Studio's convention check | You 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.
| Target | Files | Check |
|---|---|---|
| Channel PNGs | <name>-r/g/b/a.png (8-bit grey) | Measured numpyByte-identical per channel, including RGB under fully transparent texels. |
| Normal map | PNG · OpenGL (+Y) or DirectX (−Y) | Measured numpyDecoded vectors are unit length (worst deviation 0.0002); OpenGL and DirectX differ in green only. |
| Packed mask / ORM | Unity 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 import | Unity · Unreal · Godot | UNVERIFIEDNot 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).