Skip to content

Support for packed normal maps revisited #2602

@castano

Description

@castano

It was recently brought to my attention that the glTF specification dictates that normal map textures represent the XYZ coordinates of the normal vector in tangent space using the RGB components in linear space:

https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#_material_normaltexture

There appears to be no mention that the normals are packed in the [0, 1] range and are therefore need to be unpacked to the [-1, 1] range, but this is what all implementations do.

In real-time pipelines the overwhelming convention is to store only the X and Y components and to reconstruct the Z in the shader from the unit-lenght constraint. This allows the use of the following representations:

  • BC5 / EAC_RG are two-channel formats with no blue channel at all.
  • ASTC has no native two-channel XY mode; encoders (e.g. Arm astc-encoder normal) repurpose a Luminance+Alpha endpoint mode, yielding an effective (L, L, L, A) layout read as .ga.
  • BC3nm / DXT5nm (legacy) store X in alpha and Y in green to exploit the higher-precision alpha block.

In all of these the blue channel is either absent or carries unrelated data, so a conformant loader sampling B as Z produces incorrect normals. Today existing loaders either assume that normal maps are always three-channel, so the use of packed normal maps is prohibited (resulting in increased memory footprint), or guess the encoding from the channel count or texture format, behavior that is unspecified and inconsistent across engines.

For an overview of this common practice, see: https://www.ludicon.com/castano/blog/2026/02/normal-map-compression-revisited/

This issue has been brought up in the past:

but no action was taken. I worry that if we want to get glTF adoption in high-end real-time pipelines we need to solve this problem and provide clear guidance, otherwise implementations will end up using ad-hoc solutions that are not necessarily compatible with one another.

I've been thinking what would be the best way to do that.

One idea is to add an image extension specifying how the normal is encoded:

"images": [
{ 
  "uri": "normal_bc5.dds", 
  "mimeType": "image/dds",
  "extensions": {
    "LUDICON_packed_normal": { "swizzle": "rg" }
  }
}
],

This allows a loader to build the material shader with the corresponding reconstruction without having to load and inspect the contents of the texture. However, image extensions are unusual and it doesn't solve the problem of backward compatibility. Existing loaders would load that image, ignore the extension and render the material incorrectly.

Another idea is to tag the texture instead of the image:

"textures": [
{
  "source": 0,
  "extensions": {
    "LUDICON_packed_normal": { "swizzle": "rg" }
  }
}
],

This is more aligned with other extensions, but still suffers from the same problem. A third approach is to use the packed_normal extension not to tag the texture, but to gate the sources to which it will apply:

"textures": [
  {
    "source": 0,
    "extensions": {
      "LUDICON_packed_normal": {
        "source": 1,
        "swizzle": "rg",
        "extensions": {
          "EXT_texture_webp": { "source": 2 }
        }
      }
    }
  }
],

"images": [
  { "uri": "normal_xyz.png" },
  { "uri": "normal_xy.png", "mimeType": "image/png" },
  { "uri": "normal_xty.webp", "mimeType": "image/webp" }
],

I think this works better, but there's still a problem: You can only specify one reconstruction swizzle per texture. You can't have a texture that references an ASTC image using GA swizzle, and a BC5 image using the RG swizzle. I'm leaning toward thinking this is an OK limitation.

This meets the most common scenario of generating an optimized model for a specific viewer or platform, while still having a way to signal to other loaders that the model requires the extension, and optionally providing a fallback.

Thoughts? I'd be happy to writhe the extension spec, schema, and example implementation in Three.js.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions