bevyengine / bevyengine/bevy

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

Open
#25,348 0 comments 0 reactions 0 assignees View on GitHub
A-Assets A-Rendering C-Bug S-Ready-For-Implementation
Dominant language
Rust
Stars
48.2k
Forks
4.8k
Avg merge
3d 22h
Merged PRs (30d)
161

Description

## 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:**
```ignore
`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](https://github.com/KhronosGroup/3D-Formats-Guidelines/blob/main/KTXDeveloperGuide.md), **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](https://github.com/beicause/basisu_c_sys/issues/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`.

Contributor guide

Open the contributing guide

Research direction

Start by tracing StandardMaterial's TWO_COMPONENT_NORMAL_MAP decision in bevy_pbr and the built-in KTX2 handling in bevy_image. Compare how semantic normal-map layout could reach the PBR shader; done means RA-packed BC7/ASTC normal maps sample the red and alpha channels and reconstruct Z correctly.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
computer-graphics, game-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.