Skip to content

[bevy_pbr] StandardMaterial fails to reconstruct Z for RA-packed KTX2/UASTC normal maps #25348

Description

@Farori

Bevy version and features

  • Version: 0.19
  • Features: Default features (specifically relies on bevy_pbr for StandardMaterial and bevy_image for KTX2/Basis Universal texture loading).

[Optional] Relevant system information

  • Rust version: Latest stable
  • OS: Windows 11 / Linux (Rendering-related issue, reproducible on any OS that supports BC7/ASTC)
  • Adapter Info:
    `AdapterInfo { name: "NVIDIA GeForce RTX 3090", vendor: 4318, device: 8708, device_type: DiscreteGpu, device_pci_bus_id: "0000:03:00.0", driver: "NVIDIA", driver_info: "610.47", backend: Vulkan, subgroup_min_size: 32, subgroup_max_size: 32, transient_saves_memory: false }`
    (Note: Reproduces on any GPU that supports BC7/ASTC as primary transcode targets for UASTC).

What you did

I tried to load a UASTC-encoded 2-channel normal map (e.g., generated via the official Khronos tool toktx --normal_mode) using a standard-compliant KTX2/Basis Universal loader like basisu_c_sys (or the raw basis-universal transcoder), and applied it to a StandardMaterial as a normal_map.

Standard tools like toktx pack 2-channel normal maps as RA (X in Red, Y in Alpha) prior to encoding. This is done to maximize compression quality because UASTC/ASTC algorithms handle RA layouts much better in 4-channel blocks.

According to the official Khronos KTX Developer Guide, ASTC and BC7 are the Primary Transcode Targets for UASTC. Transcoding to 2-channel formats like BC5 is considered an Additional (fallback) Target because it requires fully decoding the UASTC data to raw pixels and re-encoding it, which is slower and degrades quality.

Therefore, a standards-compliant loader transcodes this RA-packed UASTC texture into a 4-channel BC7 (or ASTC) texture, where the normal data safely resides in the Red and Alpha (.ra) channels.

What went wrong

What were you expecting?
The normal map to be sampled correctly with Z-reconstruction in the PBR shader, resulting in correct lighting.

What actually happened?
The lighting is completely broken.

Currently, StandardMaterial determines whether to apply Z-reconstruction in the PBR shader (via the TWO_COMPONENT_NORMAL_MAP flag) solely by inspecting the GPU TextureFormat:

  • If the format is 2-channel (Bc5RgUnorm, Rg8Unorm, EacRg11Unorm), Bevy assumes it's an RG normal map, enables Z-reconstruction, and samples .rg.
  • If the format is 4-channel (Bc7RgbaUnorm, Astc4x4RgbaUnorm), Bevy assumes it's a standard 3D RGB normal map (Z in Blue) and skips Z-reconstruction.

Because the standard-compliant loader correctly outputs a 4-channel BC7/ASTC texture (with data in R and A), Bevy's shader tries to read Z from the Blue channel. However, the Blue channel contains garbage or a constant value from the UASTC RA packing. This results in completely broken normals and lighting.

Additional information

The current workaround in bevy_image

Bevy's built-in KTX2 loader (bevy_image) is aware of this limitation. To make normal maps work, it intentionally violates the Khronos guidelines: it detects 2-channel layouts and forces a slow, quality-degrading downgrade to BC5/EAC_RG11, just to trick StandardMaterial into triggering the .rg shader path. This works around the engine limitation but is non-standard and slower.

Proposed Solutions

To make Bevy compatible with the broader KTX2 ecosystem and standard-compliant loaders, StandardMaterial needs a way to know the semantic layout of the normal map, independent of the GPU texture format.

  1. Add a NormalMapLayout hint: Allow users, loaders, or the GLTF/KTX2 parsers to explicitly specify the layout (e.g., Rgb, Rg, Ra). The PBR shader would then use this layout to correctly swizzle the sampled texture data (e.g., sampling .ra instead of .rg for BC7/ASTC) and trigger Z-reconstruction.
  2. Read KTX2 DFD Metadata: Enhance the KTX2 loader to read the Data Format Descriptor (DFD) channel IDs (e.g., KHR_DF_CHANNEL_UASTC_RG) and pass this semantic information to the material.

Related

This issue was discovered when investigating why the third-party basisu_c_sys crate produces broken normal maps while Bevy's native loader does not (see basisu_c_sys issue #70). The maintainer of basisu_c_sys correctly closed the issue as by design because their transcoder strictly follows the Khronos Primary Target guidelines, exposing this architectural limitation in Bevy's StandardMaterial.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-AssetsLoad files from disk to use for things like images, models, and soundsA-RenderingDrawing game state to the screenC-BugAn unexpected or incorrect behaviorS-Ready-For-ImplementationThis issue is ready for an implementation PR. Go for it!

    Type

    No type

    Projects

    Status
    Needs SME Triage
    Status
    Needs SME Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions