Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ ruzstd = { version = "0.8" }

paste = "1.0.15"
bytemuck = { version = "1.14", features = ["derive"] }
half = { version = "2.4", features = ["bytemuck"] }
texpresso = { version = "2.0", features = ["rayon"] }

serde = { version = "1.0.204", features = ["derive"] }
indexmap = "2.7.0"
Expand Down
21 changes: 7 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Or use individual crates for a smaller dependency footprint:
```toml
[dependencies]
ltk_wad = "0.2"
ltk_texture = "0.4"
ltk_texture = "0.5"
ltk_mesh = "0.3"
```

Expand Down Expand Up @@ -76,14 +76,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {

```rust
use ltk_texture::Tex;
use std::io::Cursor;
use std::fs::File;

let tex = Tex::from_reader(&mut cursor)?;
let tex = Tex::from_reader(&mut File::open("texture.tex")?)?;
let surface = tex.decode_mipmap(0)?;
let image = surface.into_rgba_image()?;
image.save("output.png")?;
surface.into_rgba_image()?.save("output.png")?;
```

See the [`ltk_texture` README](crates/ltk_texture/README.md) for supported formats, raw pixel data access, and encoding.

### Parsing a Skinned Mesh

```rust
Expand Down Expand Up @@ -164,15 +165,7 @@ For a minimal build, disable defaults and opt-in selectively:
league-toolkit = { version = "0.2", default-features = false, features = ["wad"] }
```

### Texture Encoding with `intel-tex`

BC1/BC3 texture encoding requires the optional `intel-tex` feature on `ltk_texture`:

```toml
[dependencies]
league-toolkit = { version = "0.2", features = ["texture"] }
ltk_texture = { version = "0.4", features = ["intel-tex"] }
```
Some crates expose their own feature flags — e.g. texture *encoding* requires `intel-tex` on `ltk_texture` (see the [`ltk_texture` README](crates/ltk_texture/README.md)).

---

Expand Down
3 changes: 3 additions & 0 deletions crates/league-toolkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ ltk_texture = { version = "0.5.1", path = "../ltk_texture", optional = true }
ltk_wad = { version = "0.3.0", path = "../ltk_wad", optional = true }
ltk_hash = { version = "0.3.0", path = "../ltk_hash", optional = true }
ltk_rst = { version = "0.2.0", path = "../ltk_rst", optional = true }

[dev-dependencies]
image = { version = "0.25.2", default-features = false }
5 changes: 4 additions & 1 deletion crates/ltk_texture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ version = "0.5.1"
edition = "2021"
description = "Texture decoding/encoding utilities for League Toolkit"
license = "MIT OR Apache-2.0"
readme = "../../README.md"
readme = "README.md"

[lints]
workspace = true

[dependencies]
bitflags = { workspace = true }
bytemuck = { workspace = true }
half = { workspace = true }
byteorder = { workspace = true }
thiserror = { workspace = true }
num_enum = { workspace = true }
Expand All @@ -24,6 +26,7 @@ image_dds = { version = "0.6.0", default-features = false, features = [
] }

texture2ddecoder = "0.1.2"
texpresso = { workspace = true }
intel_tex_2 = { version = "0.5.0", optional = true }


Expand Down
126 changes: 126 additions & 0 deletions crates/ltk_texture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# ltk_texture

Decoding and encoding for League of Legends textures: the proprietary **`.tex`** format and **DDS**.

`.tex` is a thin container around block-compressed (or raw BGRA8) pixel data, with mipmaps stored smallest-first. This crate parses and writes the container, decodes every format the game ships, and can encode new textures from any `image::RgbaImage`.

Both 2D textures and volume (3D) textures are supported - a handful of map WADs ship `ResourceType::VolumeTexture` files whose `depth` z-slices are stored sequentially per mip. `decode_mipmap` decodes slice 0; `decode_mipmap_slice(level, slice)` decodes the rest.

## Feature flags

- `intel-tex`: enables BC7 encoding via [`intel_tex_2`](https://crates.io/crates/intel_tex_2)'s ISPC kernels (x86/x86_64 only). BC1/BC3 encoding is always available through [`texpresso`](https://crates.io/crates/texpresso) (pure Rust, any target). Decoding never requires any feature.

## Supported `.tex` formats

| ID | Format | Decode | Encode |
|-----|-------------------|--------|-----------------------|
| 1 | ETC1 | ✅ | ❌ |
| 2, 3 | ETC2/EAC | ✅ | ❌ |
| 10, 11 | BC1 | ✅ | ✅ |
| 12 | BC3 | ✅ | ✅ |
| 13 | BC7 (`BC7_UNORM_SRGB`) | ✅ | ✅ (`intel-tex`) |
| 14 | BC5 (`BC5_SNORM`) | ✅ | ❌ |
| 20 | Uncompressed BGRA8 | ✅ | ✅ |
| 21 | Uncompressed RGBA16 half-float | ✅ | ✅ |
| 22 | Uncompressed RGBA32 float | ✅ | ✅ |

BC5_SNORM is decoded by this crate's own implementation, verified against DirectXTex and the D3D11 functional spec - general-purpose decoders (`image_dds`, `texture2ddecoder`) only implement the unsigned variant, which silently corrupts signed data.

## Decoding

```rust
use ltk_texture::Tex;
use std::fs::File;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let tex = Tex::from_reader(&mut File::open("texture.tex")?)?;
println!("{}x{} {:?}, {} mips", tex.width, tex.height, tex.format, tex.mip_count);

// Decode the full-resolution mip and save it as a PNG
let surface = tex.decode_mipmap(0)?;
surface.into_rgba_image()?.save("output.png")?;

Ok(())
}
```

If you don't know whether a file is `.tex` or `.dds` (e.g. when pulling assets out of a WAD), use the format-agnostic `Texture`:

```rust
use ltk_texture::Texture;
use std::fs::File;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let texture = Texture::from_reader(&mut File::open("some_texture")?)?;
let image = texture.decode_mipmap(0)?.into_rgba_image()?;

Ok(())
}
```

## Decoded surfaces

`decode_mipmap` returns a `TexSurface`: tightly-packed, row-major pixel data tagged with the `PixelFormat` it naturally decodes to.

- Color formats (BC1/BC3/BC7) decode to `Rgba8Unorm`; ETC and raw data decode to `Bgra8Unorm`.
- BC5_SNORM decodes to `Rg8Snorm` with the signed data **intact** - nothing is lost to an RGBA remap.
- RGBA16F decodes to `Rgba16Float` (little-endian `half::f16` bit patterns; `half` is re-exported). The shipped files are shader textures holding values far outside `[0, 1]`, so use `as_pixels::<[half::f16; 4]>()` when the actual values matter - `into_rgba_image()` clamps.

`into_rgba_image()` is a *presentation* conversion: signed-normalized channels are remapped from `[-1, 1]` to `[0, 255]`, missing channels are filled with 0 (alpha with 255). When you need the real values - normal maps being the typical case - read them directly:

```rust
use ltk_texture::Tex;
use ltk_texture::tex::PixelFormat;
use std::fs::File;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let tex = Tex::from_reader(&mut File::open("normal_map.tex")?)?;

let surface = tex.decode_mipmap(0)?;
if surface.format == PixelFormat::Rg8Snorm {
// typed access to the signed normal-map channels
let pixels: &[[i8; 2]] = surface.as_pixels().unwrap();
let [x, y] = pixels[0];
}

Ok(())
}
```

## Encoding

BC1/BC3 encoding always works (texpresso cluster fit, parallelized with rayon); BC7 additionally requires the `intel-tex` feature (ISPC texture compressor bindings):

```rust
use ltk_texture::Tex;
use ltk_texture::tex::{EncodeFormat, EncodeOptions, MipmapFilter};
use std::fs::File;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = image::open("input.png")?;
let tex = Tex::encode_dynamic_image(
img,
EncodeOptions::new(EncodeFormat::Bc3 { weigh_colour_by_alpha: false })
.with_mipmaps()
.with_mipmap_filter(MipmapFilter::Lanczos3),
)?;
tex.write(&mut File::create("output.tex")?)?;

Ok(())
}
```

For alpha-blended textures, `EncodeFormat::Bc3 { weigh_colour_by_alpha: true }` weighs each pixel's contribution to the BC1/BC3 endpoint fit by its alpha, which can significantly improve perceived quality.

Input dimensions don't need to be multiples of 4; partial edge blocks are handled correctly and the true dimensions go in the header.

For textures headed back into the game, keep the base dimensions a multiple of 4: D3D11 forbids block-compressed textures with a non-aligned top mip. Uncompressed formats (BGRA8, RGBA16F) have no such restriction.

## Related crates

- [`ltk_wad`](../ltk_wad): WAD archives, where game textures actually live.
- [`league-toolkit`](../../): umbrella crate that re-exports everything behind feature flags.

## License

Licensed under either of MIT or Apache-2.0 at your option.
15 changes: 6 additions & 9 deletions crates/ltk_texture/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pub use error::*;
pub use tex::Tex;
use tex::TexSurface;

/// Re-exported for typed access to [`tex::PixelFormat::Rgba16Float`] surfaces,
/// e.g. `surface.as_pixels::<[half::f16; 4]>()`.
pub use half;

/// Represents a texture file
#[derive(Debug)]
pub enum Texture {
Expand Down Expand Up @@ -54,19 +58,12 @@ impl<'a> From<TexSurface<'a>> for Surface<'a> {
impl From<image::RgbaImage> for Surface<'static> {
fn from(img: image::RgbaImage) -> Self {
let (width, height) = img.dimensions();
let data = img.into_raw();

Surface::Tex(TexSurface {
width,
height,
data: tex::TexSurfaceData::Bgra8Owned(
data.chunks_exact(4)
.map(|pixel| {
let [r, g, b, a] = pixel else { unreachable!() };
u32::from_le_bytes([*b, *g, *r, *a])
})
.collect(),
),
format: tex::PixelFormat::Rgba8Unorm,
data: img.into_raw().into(),
})
}
}
Expand Down
151 changes: 151 additions & 0 deletions crates/ltk_texture/src/tex/bc5_snorm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! Signed-normalized BC5 (`BC5_SNORM`) decoding.
//!
//! Neither `image_dds` (via `bcdec_rs`) nor `texture2ddecoder` implement the *signed* variant
//! of BC4/BC5 — both decode the endpoints as unsigned bytes, which produces garbage for SNORM
//! data (wrong palette values *and* wrong 6-vs-8 entry mode selection, since that comparison is
//! done in the signed domain). This module implements the D3D11 spec (§19.5.2) directly.

/// Decode a BC5_SNORM surface to interleaved RG8 SNORM data (2 bytes per pixel, each byte
/// an `i8` bit pattern in `[-127, 127]`).
pub(crate) fn decode_bc5_snorm(data: &[u8], width: usize, height: usize) -> Vec<u8> {
let blocks_x = width.div_ceil(4);
let blocks_y = height.div_ceil(4);
debug_assert_eq!(data.len(), blocks_x * blocks_y * 16);

let mut rg = vec![0u8; width * height * 2];

for (block_i, block) in data.chunks_exact(16).enumerate() {
let bx = (block_i % blocks_x) * 4;
let by = (block_i / blocks_x) * 4;

let red = decode_bc4_snorm_block(block[..8].try_into().unwrap());
let green = decode_bc4_snorm_block(block[8..].try_into().unwrap());

for ty in 0..4 {
for tx in 0..4 {
let (x, y) = (bx + tx, by + ty);
if x >= width || y >= height {
continue;
}
let i = (y * width + x) * 2;
rg[i] = red[ty * 4 + tx] as u8;
rg[i + 1] = green[ty * 4 + tx] as u8;
}
}
}

rg
}

/// Decode a single 8-byte BC4_SNORM block into 16 texels in `[-127, 127]`
fn decode_bc4_snorm_block(block: [u8; 8]) -> [i8; 16] {
let e0 = block[0] as i8;
let e1 = block[1] as i8;

// Endpoint *values* clamp -128 to -127 so the range is symmetric around 0,
// but mode selection below compares the raw (pre-clamp) endpoints - this matches
// DirectXTex's BC4_SNORM::DecodeFromIndex
let r0 = e0.max(-127) as f32;
let r1 = e1.max(-127) as f32;

// Mode selection compares the raw endpoints in the *signed* domain
let palette: [f32; 8] = if e0 > e1 {
std::array::from_fn(|i| match i {
0 => r0,
1 => r1,
i => (r0 * (8 - i) as f32 + r1 * (i - 1) as f32) / 7.0,
})
} else {
std::array::from_fn(|i| match i {
0 => r0,
1 => r1,
6 => -127.0,
7 => 127.0,
i => (r0 * (6 - i) as f32 + r1 * (i - 1) as f32) / 5.0,
})
};

// 16 3-bit palette indices packed LSB-first into the remaining 6 bytes
let indices = u64::from_le_bytes(block) >> 16;

std::array::from_fn(|texel| palette[(indices >> (texel * 3)) as usize & 0b111].round() as i8)
}

#[cfg(test)]
mod tests {
use super::*;

fn bc4_block(e0: u8, e1: u8, indices: [u8; 16]) -> [u8; 8] {
let mut packed = 0u64;
for (i, &index) in indices.iter().enumerate() {
assert!(index < 8);
packed |= (index as u64) << (i * 3);
}
let bits = packed.to_le_bytes();
[e0, e1, bits[0], bits[1], bits[2], bits[3], bits[4], bits[5]]
}

#[test]
fn endpoints_map_to_full_range() {
// e0 = 127 (1.0), e1 = -127 (-1.0); e0 > e1 → 8-entry mode
let block = bc4_block(127, 0x81, [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]);
let texels = decode_bc4_snorm_block(block);
for pair in texels.chunks_exact(2) {
assert_eq!(pair, [127, -127]);
}
}

#[test]
fn neg_128_is_clamped_to_neg_127() {
let block = bc4_block(0x80, 0x80, [0; 16]);
assert_eq!(decode_bc4_snorm_block(block), [-127; 16]);
}

#[test]
fn mode_selection_uses_raw_endpoints_not_clamped() {
// e0 = -127, e1 = -128: raw comparison -127 > -128 selects the 8-entry mode,
// where every palette entry interpolates between -1.0 and -1.0.
// Comparing *clamped* endpoints (-127 !> -127) would wrongly select the 6-entry
// mode, whose index 7 is the constant +1.0. (Matches DirectXTex behavior.)
let block = bc4_block(0x81, 0x80, [7; 16]);
assert_eq!(decode_bc4_snorm_block(block), [-127; 16]);
}

#[test]
fn zero_stays_zero() {
let block = bc4_block(0, 0x81, [0; 16]);
assert_eq!(decode_bc4_snorm_block(block), [0; 16]);
}

#[test]
fn six_entry_mode_has_explicit_min_max() {
// e0 = -127 <= e1 = 127 → 6-entry mode; indices 6 and 7 are the constants -1.0 / 1.0
let block = bc4_block(0x81, 127, [6, 7, 6, 7, 6, 7, 6, 7, 6, 7, 6, 7, 6, 7, 6, 7]);
let texels = decode_bc4_snorm_block(block);
for pair in texels.chunks_exact(2) {
assert_eq!(pair, [-127, 127]);
}
}

#[test]
fn eight_entry_mode_interpolates() {
// e0 = 127, e1 = -127 → 8-entry mode.
// Index 2 = (6 * 127 + 1 * -127) / 7 = 635/7 ≈ 90.7 → 91
let block = bc4_block(127, 0x81, [2; 16]);
assert_eq!(decode_bc4_snorm_block(block), [91; 16]);
}

#[test]
fn decodes_rg_channels_and_partial_blocks() {
// One block, but a 2x2 surface — edge texels outside the surface must be skipped
let mut block = [0u8; 16];
block[..8].copy_from_slice(&bc4_block(127, 0x81, [0; 16])); // red = 1.0
block[8..].copy_from_slice(&bc4_block(0x81, 127, [0; 16])); // green = -1.0

let rg = decode_bc5_snorm(&block, 2, 2);
assert_eq!(rg.len(), 2 * 2 * 2);
for pixel in rg.chunks_exact(2) {
assert_eq!(pixel, [127i8 as u8, -127i8 as u8]);
}
}
}
Loading
Loading