diff --git a/Cargo.toml b/Cargo.toml index 5922df24..0ebaf0cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 45d3a1f7..cdef5b1e 100644 --- a/README.md +++ b/README.md @@ -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" ``` @@ -76,14 +76,15 @@ fn main() -> Result<(), Box> { ```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 @@ -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)). --- diff --git a/crates/league-toolkit/Cargo.toml b/crates/league-toolkit/Cargo.toml index 564a9450..05846ed6 100644 --- a/crates/league-toolkit/Cargo.toml +++ b/crates/league-toolkit/Cargo.toml @@ -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 } diff --git a/crates/ltk_texture/Cargo.toml b/crates/ltk_texture/Cargo.toml index 1899268f..592ae079 100644 --- a/crates/ltk_texture/Cargo.toml +++ b/crates/ltk_texture/Cargo.toml @@ -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 } @@ -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 } diff --git a/crates/ltk_texture/README.md b/crates/ltk_texture/README.md new file mode 100644 index 00000000..eae64ba4 --- /dev/null +++ b/crates/ltk_texture/README.md @@ -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> { + 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> { + 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> { + 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> { + 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. diff --git a/crates/ltk_texture/src/lib.rs b/crates/ltk_texture/src/lib.rs index 5424e031..f5f8f6d8 100644 --- a/crates/ltk_texture/src/lib.rs +++ b/crates/ltk_texture/src/lib.rs @@ -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 { @@ -54,19 +58,12 @@ impl<'a> From> for Surface<'a> { impl From 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(), }) } } diff --git a/crates/ltk_texture/src/tex/bc5_snorm.rs b/crates/ltk_texture/src/tex/bc5_snorm.rs new file mode 100644 index 00000000..5b5f86f9 --- /dev/null +++ b/crates/ltk_texture/src/tex/bc5_snorm.rs @@ -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 { + 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]); + } + } +} diff --git a/crates/ltk_texture/src/tex/encode.rs b/crates/ltk_texture/src/tex/encode.rs index 84ce8d62..9226e10e 100644 --- a/crates/ltk_texture/src/tex/encode.rs +++ b/crates/ltk_texture/src/tex/encode.rs @@ -1,126 +1,91 @@ use super::Format; -#[cfg(feature = "intel-tex")] -use intel_tex_2::{bc1, bc3, RgbaSurface}; - #[cfg(any(feature = "intel-tex", test))] -#[inline] -fn clamp01(x: f32) -> f32 { - x.clamp(0.0, 1.0) -} +use std::borrow::Cow; -#[cfg(any(feature = "intel-tex", test))] -#[inline] -fn quantize_to_bits(x: f32, bits: u8) -> f32 { - debug_assert!((1..=8).contains(&bits)); - let levels = (1u32 << bits) - 1; - (x * levels as f32).round() / levels as f32 -} +#[cfg(feature = "intel-tex")] +use intel_tex_2::{bc7, RgbaSurface}; -/// Dither RGB toward 5/6/5 (BC1/BC3 color endpoint-ish). Alpha is untouched. -/// -/// Floyd–Steinberg error diffusion with serpentine scan to reduce directional artifacts. +/// Pad RGBA data out to the 4x4 block grid by replicating edge texels. #[cfg(any(feature = "intel-tex", test))] -fn floyd_steinberg_dither_rgb565_in_place(width: u32, height: u32, rgba: &mut [u8]) { - let w = width as usize; - let h = height as usize; - if w == 0 || h == 0 { - return; +fn pad_to_block_grid(width: u32, height: u32, rgba: &[u8]) -> (u32, u32, Cow<'_, [u8]>) { + let padded_w = width.next_multiple_of(4); + let padded_h = height.next_multiple_of(4); + if (padded_w == width && padded_h == height) || width == 0 || height == 0 { + return (width, height, Cow::Borrowed(rgba)); } - debug_assert_eq!(rgba.len(), w * h * 4); - // Error buffers for current and next row: per-x accumulated error for RGB. - let mut err_curr = vec![[0.0f32; 3]; w]; - let mut err_next = vec![[0.0f32; 3]; w]; - - for y in 0..h { - let left_to_right = (y & 1) == 0; + let (w, pw, ph) = (width as usize, padded_w as usize, padded_h as usize); + let mut padded = Vec::with_capacity(pw * ph * 4); + for y in 0..ph { + let row = &rgba[y.min(height as usize - 1) * w * 4..][..w * 4]; + padded.extend_from_slice(row); + for _ in w..pw { + padded.extend_from_slice(&row[(w - 1) * 4..]); + } + } + (padded_w, padded_h, Cow::Owned(padded)) +} - let (x_start, x_end, step): (isize, isize, isize) = if left_to_right { - (0, w as isize, 1) - } else { - (w as isize - 1, -1, -1) - }; +/// Texture format to encode to, along with any format-specific options +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EncodeFormat { + Bc1 { + /// Weigh colour by alpha during the cluster fit. Off by default; enabling + /// it can significantly improve perceived quality for textures rendered with + /// alpha blending, at the cost of color accuracy in transparent regions. + weigh_colour_by_alpha: bool, + }, + Bc3 { + /// Weigh colour by alpha during the cluster fit - see [`EncodeFormat::Bc1`] + weigh_colour_by_alpha: bool, + }, + Bc7, + Bgra8, + Rgba16Float, + Rgba32Float, +} - let mut x = x_start; - while x != x_end { - let xi = x as usize; - let idx = (y * w + xi) * 4; - - let r0 = rgba[idx] as f32 / 255.0 + err_curr[xi][0]; - let g0 = rgba[idx + 1] as f32 / 255.0 + err_curr[xi][1]; - let b0 = rgba[idx + 2] as f32 / 255.0 + err_curr[xi][2]; - - let r = clamp01(r0); - let g = clamp01(g0); - let b = clamp01(b0); - - // Quantize to 5/6/5 - let rq = quantize_to_bits(r, 5); - let gq = quantize_to_bits(g, 6); - let bq = quantize_to_bits(b, 5); - - // Write back (alpha untouched) - rgba[idx] = (rq * 255.0).round().clamp(0.0, 255.0) as u8; - rgba[idx + 1] = (gq * 255.0).round().clamp(0.0, 255.0) as u8; - rgba[idx + 2] = (bq * 255.0).round().clamp(0.0, 255.0) as u8; - - // Error - let er = r - rq; - let eg = g - gq; - let eb = b - bq; - - // Floyd–Steinberg weights: - // right 7/16, down-left 3/16, down 5/16, down-right 1/16 - let xr = x + step; // "right" in scan direction - let xl = x - step; // "left" in scan direction - - // right (same row) - if xr >= 0 && (xr as usize) < w { - let xri = xr as usize; - err_curr[xri][0] += er * (7.0 / 16.0); - err_curr[xri][1] += eg * (7.0 / 16.0); - err_curr[xri][2] += eb * (7.0 / 16.0); - } - - // next row - if y + 1 < h { - // down - err_next[xi][0] += er * (5.0 / 16.0); - err_next[xi][1] += eg * (5.0 / 16.0); - err_next[xi][2] += eb * (5.0 / 16.0); - - // down-left (relative to scan direction) - if xl >= 0 && (xl as usize) < w { - let xli = xl as usize; - err_next[xli][0] += er * (3.0 / 16.0); - err_next[xli][1] += eg * (3.0 / 16.0); - err_next[xli][2] += eb * (3.0 / 16.0); - } - - // down-right - if xr >= 0 && (xr as usize) < w { - let xri = xr as usize; - err_next[xri][0] += er * (1.0 / 16.0); - err_next[xri][1] += eg * (1.0 / 16.0); - err_next[xri][2] += eb * (1.0 / 16.0); - } - } - - x += step; +impl From for Format { + fn from(format: EncodeFormat) -> Self { + match format { + EncodeFormat::Bc1 { .. } => Self::Bc1, + EncodeFormat::Bc3 { .. } => Self::Bc3, + EncodeFormat::Bc7 => Self::Bc7, + EncodeFormat::Bgra8 => Self::Bgra8, + EncodeFormat::Rgba16Float => Self::Rgba16Float, + EncodeFormat::Rgba32Float => Self::Rgba32Float, } + } +} - // Rotate error buffers - std::mem::swap(&mut err_curr, &mut err_next); - err_next.fill([0.0; 3]); +impl TryFrom for EncodeFormat { + type Error = EncodeError; + + /// Convert a raw tex format into its encode counterpart with default options, + /// failing with [`EncodeError::UnsupportedFormat`] if the format cannot be encoded + fn try_from(format: Format) -> Result { + Ok(match format { + Format::Bc1 => Self::Bc1 { + weigh_colour_by_alpha: false, + }, + Format::Bc3 => Self::Bc3 { + weigh_colour_by_alpha: false, + }, + Format::Bc7 => Self::Bc7, + Format::Bgra8 => Self::Bgra8, + Format::Rgba16Float => Self::Rgba16Float, + Format::Rgba32Float => Self::Rgba32Float, + format => return Err(EncodeError::UnsupportedFormat(format)), + }) } } /// Options for encoding textures #[derive(Debug, Clone)] pub struct EncodeOptions { - /// Texture format to encode to - pub format: Format, + /// Texture format to encode to, with any format-specific options + pub format: EncodeFormat, /// Whether to generate mipmaps pub generate_mipmaps: bool, /// Filter type to use for mipmap generation @@ -154,7 +119,7 @@ impl MipmapFilter { impl EncodeOptions { /// Create new options with the specified format and no mipmaps - pub fn new(format: Format) -> Self { + pub fn new(format: EncodeFormat) -> Self { Self { format, generate_mipmaps: false, @@ -177,35 +142,91 @@ impl EncodeOptions { impl Default for EncodeOptions { fn default() -> Self { - Self::new(Format::Bc3) + Self::new(EncodeFormat::Bc3 { + weigh_colour_by_alpha: false, + }) } } -/// Encode an RGBA8 image into the specified format +/// Encode an RGBA8 image into the format specified by `options` +/// +/// Dimensions don't need to be multiples of 4 - the encoder will pad the image to a 4x4 block grid by replicating edge texels. /// /// # Example /// ```no_run -/// use ltk_texture::tex::{encode_rgba, Format}; +/// use ltk_texture::tex::{encode_rgba, EncodeFormat, EncodeOptions}; /// /// let width = 256; /// let height = 256; /// let rgba_data: Vec = vec![0; (width * height * 4) as usize]; /// /// // Encode to BC3 format -/// let compressed = encode_rgba(width, height, &rgba_data, Format::Bc3).unwrap(); +/// let format = EncodeFormat::Bc3 { weigh_colour_by_alpha: false }; +/// let compressed = +/// encode_rgba(width, height, &rgba_data, &EncodeOptions::new(format)).unwrap(); /// ``` pub fn encode_rgba( width: u32, height: u32, rgba_data: &[u8], - format: Format, + options: &EncodeOptions, +) -> Result, EncodeError> { + match options.format { + EncodeFormat::Bc1 { + weigh_colour_by_alpha, + } => encode_texpresso( + texpresso::Format::Bc1, + width, + height, + rgba_data, + weigh_colour_by_alpha, + ), + EncodeFormat::Bc3 { + weigh_colour_by_alpha, + } => encode_texpresso( + texpresso::Format::Bc3, + width, + height, + rgba_data, + weigh_colour_by_alpha, + ), + EncodeFormat::Bc7 => encode_bc7(width, height, rgba_data), + EncodeFormat::Bgra8 => encode_bgra8(rgba_data), + EncodeFormat::Rgba16Float => encode_rgba16_float(rgba_data), + EncodeFormat::Rgba32Float => encode_rgba32_float(rgba_data), + } +} + +/// Encode RGBA8 data to BC1/BC3 via texpresso's cluster fit +/// +/// texpresso handles non-multiple-of-4 dimensions natively by masking out-of-image texels +/// in partial edge blocks from the endpoint fit entirely. +fn encode_texpresso( + format: texpresso::Format, + width: u32, + height: u32, + rgba_data: &[u8], + weigh_colour_by_alpha: bool, ) -> Result, EncodeError> { - match format { - Format::Bc1 => encode_bc1(width, height, rgba_data), - Format::Bc3 => encode_bc3(width, height, rgba_data), - Format::Bgra8 => encode_bgra8(rgba_data), - _ => Err(EncodeError::UnsupportedFormat(format)), + let (w, h) = (width as usize, height as usize); + if rgba_data.len() != w * h * 4 { + return Err(EncodeError::InvalidPixelData); } + + let mut out = vec![0u8; format.compressed_size(w, h)]; + format.compress( + rgba_data, + w, + h, + texpresso::Params { + algorithm: texpresso::Algorithm::ClusterFit, + weigh_colour_by_alpha, + ..Default::default() + }, + &mut out, + ); + + Ok(out) } /// Encode an RGBA8 image with mipmaps into the specified format @@ -214,21 +235,24 @@ pub fn encode_rgba( /// /// # Example /// ```no_run -/// use ltk_texture::tex::{encode_rgba_with_mipmaps, Format, MipmapFilter}; +/// use ltk_texture::tex::{encode_rgba_with_mipmaps, EncodeFormat, EncodeOptions}; /// use image::RgbaImage; /// /// let img = RgbaImage::new(256, 256); -/// let (data, mip_count) = encode_rgba_with_mipmaps(&img, Format::Bc3, MipmapFilter::Triangle).unwrap(); +/// let format = EncodeFormat::Bc3 { weigh_colour_by_alpha: false }; +/// let (data, mip_count) = +/// encode_rgba_with_mipmaps(&img, &EncodeOptions::new(format)).unwrap(); /// ``` pub fn encode_rgba_with_mipmaps( img: &image::RgbaImage, - format: Format, - filter: MipmapFilter, + options: &EncodeOptions, ) -> Result<(Vec, u32), EncodeError> { let (width, height) = img.dimensions(); + if width == 0 || height == 0 { + return Err(EncodeError::ZeroSizedImage); + } - // Calculate mipmap count - let mip_count = ((height.max(width) as f32).log2().floor() + 1.0) as u32; + let mip_count = height.max(width).ilog2() + 1; // Generate all mip levels (from full size down to 1x1) let mut mip_levels = Vec::new(); @@ -244,7 +268,7 @@ pub fn encode_rgba_with_mipmaps( ¤t_img, mip_width, mip_height, - filter.to_image_filter(), + options.mipmap_filter.to_image_filter(), ); } @@ -258,111 +282,83 @@ pub fn encode_rgba_with_mipmaps( for img in mip_levels.iter().rev() { let (w, h) = img.dimensions(); let rgba_data = img.as_raw(); - let encoded = encode_rgba(w, h, rgba_data, format)?; + let encoded = encode_rgba(w, h, rgba_data, options)?; encoded_data.extend_from_slice(&encoded); } Ok((encoded_data, mip_count)) } -/// Encode RGBA8 data to BC1 format +/// Encode RGBA8 data to BC7 format #[cfg(feature = "intel-tex")] -fn encode_bc1(width: u32, height: u32, rgba_data: &[u8]) -> Result, EncodeError> { +fn encode_bc7(width: u32, height: u32, rgba_data: &[u8]) -> Result, EncodeError> { let expected_len = width as usize * height as usize * 4; if rgba_data.len() != expected_len { return Err(EncodeError::InvalidPixelData); } - // Pre-dither toward RGB565 to reduce visible block artifacts in BC1. - let mut rgba = rgba_data.to_vec(); - floyd_steinberg_dither_rgb565_in_place(width, height, &mut rgba); - + let (width, height, rgba) = pad_to_block_grid(width, height, rgba_data); let surface = RgbaSurface { data: &rgba, width, height, stride: 4 * width, }; - Ok(bc1::compress_blocks(&surface)) + Ok(bc7::compress_blocks(&bc7::alpha_basic_settings(), &surface)) } #[cfg(not(feature = "intel-tex"))] -fn encode_bc1(_width: u32, _height: u32, _rgba_data: &[u8]) -> Result, EncodeError> { - Err(EncodeError::UnsupportedFormat(Format::Bc1)) -} - -/// Encode RGBA8 data to BC3 format -#[cfg(feature = "intel-tex")] -fn encode_bc3(width: u32, height: u32, rgba_data: &[u8]) -> Result, EncodeError> { - let expected_len = width as usize * height as usize * 4; - if rgba_data.len() != expected_len { - return Err(EncodeError::InvalidPixelData); - } - - // Pre-dither toward RGB565 to reduce visible block artifacts in BC3 (color endpoints). - let mut rgba = rgba_data.to_vec(); - floyd_steinberg_dither_rgb565_in_place(width, height, &mut rgba); - - let surface = RgbaSurface { - data: &rgba, - width, - height, - stride: 4 * width, - }; - Ok(bc3::compress_blocks(&surface)) +fn encode_bc7(_width: u32, _height: u32, _rgba_data: &[u8]) -> Result, EncodeError> { + Err(EncodeError::UnsupportedFormat(Format::Bc7)) } #[cfg(test)] mod tests { use super::*; - fn allowed_levels(bits: u8) -> std::collections::HashSet { - let levels = (1u32 << bits) - 1; - (0..=levels) - .map(|n| { - ((n as f32 / levels as f32) * 255.0) - .round() - .clamp(0.0, 255.0) as u8 - }) - .collect() - } - #[test] - fn dither_preserves_alpha_and_quantizes_rgb_to_565_levels() { - let (w, h) = (8u32, 4u32); - let mut rgba = vec![0u8; (w * h * 4) as usize]; - - // Fill with a gradient-ish pattern and varying alpha - for y in 0..h as usize { - for x in 0..w as usize { - let idx = (y * w as usize + x) * 4; - rgba[idx] = (x as u8).wrapping_mul(31); - rgba[idx + 1] = (y as u8).wrapping_mul(47); - rgba[idx + 2] = ((x + y) as u8).wrapping_mul(19); - rgba[idx + 3] = (255u8).wrapping_sub((x as u8).wrapping_mul(17)); - } + fn pads_partial_blocks_with_replicated_edges() { + // 2x1 image [A, B] -> 4x4 where every row is [A, B, B, B] + let a = [1, 2, 3, 4]; + let b = [5, 6, 7, 8]; + let pixels = [a, b].concat(); + let (w, h, padded) = pad_to_block_grid(2, 1, &pixels); + assert_eq!((w, h), (4, 4)); + let expected_row = [a, b, b, b].concat(); + for row in padded.chunks_exact(4 * 4) { + assert_eq!(row, expected_row); } - let alpha_before: Vec = rgba.chunks_exact(4).map(|p| p[3]).collect(); - floyd_steinberg_dither_rgb565_in_place(w, h, &mut rgba); - let alpha_after: Vec = rgba.chunks_exact(4).map(|p| p[3]).collect(); - assert_eq!(alpha_before, alpha_after); - - let allowed_r = allowed_levels(5); - let allowed_g = allowed_levels(6); - let allowed_b = allowed_levels(5); + // block-aligned data is passed through without copying + let rgba = vec![0u8; 8 * 4 * 4]; + let (w, h, padded) = pad_to_block_grid(8, 4, &rgba); + assert_eq!((w, h), (8, 4)); + assert!(matches!(padded, Cow::Borrowed(_))); + } +} - for px in rgba.chunks_exact(4) { - assert!(allowed_r.contains(&px[0])); - assert!(allowed_g.contains(&px[1])); - assert!(allowed_b.contains(&px[2])); - } +/// Convert RGBA8 to RGBA16 half-float (uncompressed) +fn encode_rgba16_float(rgba_data: &[u8]) -> Result, EncodeError> { + if !rgba_data.len().is_multiple_of(4) { + return Err(EncodeError::InvalidPixelData); } + + Ok(rgba_data + .iter() + .flat_map(|&channel| half::f16::from_f32(channel as f32 / 255.0).to_le_bytes()) + .collect()) } -#[cfg(not(feature = "intel-tex"))] -fn encode_bc3(_width: u32, _height: u32, _rgba_data: &[u8]) -> Result, EncodeError> { - Err(EncodeError::UnsupportedFormat(Format::Bc3)) +/// Convert RGBA8 to RGBA32 float (uncompressed) +fn encode_rgba32_float(rgba_data: &[u8]) -> Result, EncodeError> { + if !rgba_data.len().is_multiple_of(4) { + return Err(EncodeError::InvalidPixelData); + } + + Ok(rgba_data + .iter() + .flat_map(|&channel| (channel as f32 / 255.0).to_le_bytes()) + .collect()) } /// Convert RGBA8 to BGRA8 (uncompressed) @@ -385,4 +381,6 @@ pub enum EncodeError { UnsupportedFormat(Format), #[error("Invalid pixel data")] InvalidPixelData, + #[error("Cannot encode a zero-sized image")] + ZeroSizedImage, } diff --git a/crates/ltk_texture/src/tex/error.rs b/crates/ltk_texture/src/tex/error.rs index 73769373..1300a4da 100644 --- a/crates/ltk_texture/src/tex/error.rs +++ b/crates/ltk_texture/src/tex/error.rs @@ -7,6 +7,8 @@ pub enum Error { UnknownTextureFormat(u8), #[error("Unsupported TEX format: {0:?}")] UnsupportedTextureFormat(Format), + #[error("Unknown TEX resource type: {0}")] + UnknownResourceType(u8), #[error("Invalid TEX flags: {0:#b}")] InvalidTextureFlags(u8), #[error("IO Error: {0}")] @@ -23,14 +25,19 @@ pub enum Error { #[derive(thiserror::Error, Debug)] pub enum DecodeErr { - #[error("Could not decode ETC1: {0}")] - Etc1(&'static str), - #[error("Could not decode ETC2/EAC: {0}")] - Etc2Eac(&'static str), - #[error("Could not decode BC3: {0}")] - Bc3(&'static str), - #[error("Could not decode BC1: {0}")] - Bc1(&'static str), + #[error("Could not decode {0:?}: {1}")] + Decode(Format, &'static str), #[error("Could not decode: {0}")] ImageDds(#[from] image_dds::error::SurfaceError), + #[error( + "Mip level {level} (bytes {start}..{end}) is out of bounds of the texture data ({len} bytes)" + )] + MipOutOfBounds { + level: u32, + start: usize, + end: usize, + len: usize, + }, + #[error("Slice {slice} is out of bounds of the texture depth ({depth} slices)")] + SliceOutOfBounds { slice: u32, depth: usize }, } diff --git a/crates/ltk_texture/src/tex/format.rs b/crates/ltk_texture/src/tex/format.rs index a735be31..9abdcefe 100644 --- a/crates/ltk_texture/src/tex/format.rs +++ b/crates/ltk_texture/src/tex/format.rs @@ -10,8 +10,16 @@ pub enum Format { #[num_enum(alternatives = [11])] Bc1 = 10, Bc3 = 12, + /// BC7 (`BC7_UNORM_SRGB`) + Bc7 = 13, + /// BC5 (`BC5_SNORM`) + Bc5Snorm = 14, /// Uncompressed BGRA8 Bgra8 = 20, + /// Uncompressed RGBA16 half-float (`R16G16B16A16_FLOAT`) + Rgba16Float = 21, + /// Uncompressed RGBA32 float (`R32G32B32A32_FLOAT`) + Rgba32Float = 22, } impl Format { @@ -26,7 +34,7 @@ impl Format { /// Get the block size of the format pub fn block_size(&self) -> (usize, usize) { match self { - Format::Bgra8 => (1, 1), + Format::Bgra8 | Format::Rgba16Float | Format::Rgba32Float => (1, 1), _ => (4, 4), } } @@ -38,7 +46,11 @@ impl Format { Format::Etc2Eac => 16, Format::Bc1 => 8, Format::Bc3 => 16, + Format::Bc7 => 16, + Format::Bc5Snorm => 16, Format::Bgra8 => 4, + Format::Rgba16Float => 8, + Format::Rgba32Float => 16, } } } diff --git a/crates/ltk_texture/src/tex/mod.rs b/crates/ltk_texture/src/tex/mod.rs index 22625305..f189ad75 100644 --- a/crates/ltk_texture/src/tex/mod.rs +++ b/crates/ltk_texture/src/tex/mod.rs @@ -1,7 +1,8 @@ use byteorder::{ReadBytesExt, LE}; use num_enum::{IntoPrimitive, TryFromPrimitive}; -use std::{hint::unreachable_unchecked, io}; +use std::{borrow::Cow, io}; +mod bc5_snorm; mod encode; mod error; mod format; @@ -15,13 +16,18 @@ pub use surface::*; use super::ReadError; +/// Signature of `texture2ddecoder`'s surface decode functions +type Texture2dDecodeFn = fn(&[u8], usize, usize, &mut [u32]) -> Result<(), &'static str>; + /// League extended texture file (.tex) #[derive(Debug)] pub struct Tex { pub width: u16, pub height: u16, + /// Number of z-slices; `1` for everything but volume textures. + pub depth: u8, pub format: Format, - pub resource_type: u8, + pub resource_type: ResourceType, pub flags: TextureFlags, pub mip_count: u32, data: Vec, @@ -40,24 +46,25 @@ impl Tex { /// # Example /// ```no_run /// use ltk_texture::Tex; - /// use ltk_texture::tex::{EncodeOptions, Format, MipmapFilter}; + /// use ltk_texture::tex::{EncodeFormat, EncodeOptions, MipmapFilter}; /// use image::RgbaImage; /// /// let img = RgbaImage::new(256, 256); + /// let format = EncodeFormat::Bc3 { weigh_colour_by_alpha: false }; /// /// // Without mipmaps - /// let tex = Tex::encode_rgba_image(&img, EncodeOptions::new(Format::Bc3)).unwrap(); + /// let tex = Tex::encode_rgba_image(&img, EncodeOptions::new(format)).unwrap(); /// /// // With mipmaps /// let tex_mips = Tex::encode_rgba_image( /// &img, - /// EncodeOptions::new(Format::Bc3).with_mipmaps() + /// EncodeOptions::new(format).with_mipmaps() /// ).unwrap(); /// /// // With mipmaps and custom filter /// let tex_lanczos = Tex::encode_rgba_image( /// &img, - /// EncodeOptions::new(Format::Bc3) + /// EncodeOptions::new(format) /// .with_mipmaps() /// .with_mipmap_filter(MipmapFilter::Lanczos3) /// ).unwrap(); @@ -67,22 +74,25 @@ impl Tex { options: EncodeOptions, ) -> Result { let (width, height) = img.dimensions(); + if width == 0 || height == 0 { + return Err(EncodeError::ZeroSizedImage); + } let (data, mip_count, flags) = if options.generate_mipmaps { - let (mip_data, mip_count) = - encode_rgba_with_mipmaps(img, options.format, options.mipmap_filter)?; + let (mip_data, mip_count) = encode_rgba_with_mipmaps(img, &options)?; (mip_data, mip_count, TextureFlags::HasMipMaps) } else { let rgba_data = img.as_raw(); - let encoded = encode_rgba(width, height, rgba_data, options.format)?; + let encoded = encode_rgba(width, height, rgba_data, &options)?; (encoded, 1, TextureFlags::empty()) }; Ok(Self { width: width as u16, height: height as u16, - format: options.format, - resource_type: 0, // texture + depth: 1, + format: options.format.into(), + resource_type: ResourceType::Texture, flags, mip_count, data, @@ -94,12 +104,13 @@ impl Tex { /// # Example /// ```no_run /// use ltk_texture::Tex; - /// use ltk_texture::tex::{EncodeOptions, Format}; + /// use ltk_texture::tex::{EncodeFormat, EncodeOptions}; /// /// let img = image::open("texture.png").unwrap(); + /// let format = EncodeFormat::Bc3 { weigh_colour_by_alpha: false }; /// let tex = Tex::encode_dynamic_image( /// img, - /// EncodeOptions::new(Format::Bc3).with_mipmaps() + /// EncodeOptions::new(format).with_mipmaps() /// ).unwrap(); /// ``` pub fn encode_dynamic_image( @@ -113,71 +124,120 @@ impl Tex { impl Tex { /// Try to decode a single mipmap, where 0 is full resolution, and [Self::mip_count] is the smallest /// mip (1x1). + /// + /// For volume textures this decodes z-slice 0; use [Self::decode_mipmap_slice] for the rest. pub fn decode_mipmap(&self, level: u32) -> Result, DecodeErr> { + self.decode_mipmap_slice(level, 0) + } + + /// Try to decode a single z-slice of a mipmap. For 2D textures the only valid slice is 0. + /// + /// - Mip dimensions halve per level with a floor of 1 + /// - Mip slices are stored sequentially, matching the D3D volume texture layout + /// - Mip levels are stored in reverse order, smallest to largest + pub fn decode_mipmap_slice(&self, level: u32, slice: u32) -> Result, DecodeErr> { let level = level.min(self.mip_count - 1); let width = self.width as usize; let height = self.height as usize; + let depth = (self.depth as usize).max(1); let (block_w, block_h) = self.format.block_size(); - let mip_dims = |level: u32| ((width >> level).max(1), (height >> level).max(1)); - let mip_bytes = |dims: (usize, usize)| { + let mip_dims = |level: u32| { + ( + (width >> level).max(1), + (height >> level).max(1), + (depth >> level).max(1), + ) + }; + // size of a single z-slice of a mip + let slice_bytes = |dims: (usize, usize, usize)| { (dims.0.div_ceil(block_w)) * (dims.1.div_ceil(block_h)) * self.format.bytes_per_block() }; + let mip_bytes = |dims: (usize, usize, usize)| slice_bytes(dims) * dims.2; + + // size of mip + let (w, h, d) = mip_dims(level); + if slice as usize >= d { + return Err(DecodeErr::SliceOutOfBounds { slice, depth: d }); + } // sum all mips before our one // (league sorts mips smallest -> largest so our iterator counts up) let off = (level + 1..self.mip_count) .map(|level| mip_bytes(mip_dims(level))) - .sum::(); + .sum::() + + slice as usize * slice_bytes((w, h, d)); - // size of mip - let (w, h) = mip_dims(level); + let mip_data = + self.data + .get(off..off + slice_bytes((w, h, d))) + .ok_or(DecodeErr::MipOutOfBounds { + level, + start: off, + end: off + slice_bytes((w, h, d)), + len: self.data.len(), + })?; - let data = match self.format { - Format::Bgra8 => TexSurfaceData::Bgra8Slice( - // TODO: test me (this is likely wrong) - &self.data[off..off + (w * h * self.format.bytes_per_block())], + // decodes a block-compressed mip to RGBA8 via image_dds + let decode_image_dds = |image_format| -> Result, DecodeErr> { + let surface = image_dds::Surface { + width: w as u32, + height: h as u32, + depth: 1, + layers: 1, + mipmaps: 1, + image_format, + data: mip_data, + }; + Ok(surface.decode_layers_mipmaps_rgba8(0..1, 0..1)?.data) + }; + + // decodes a mip to BGRA8 via texture2ddecoder + let decode_texture2d = |decode: Texture2dDecodeFn| -> Result, DecodeErr> { + let mut data = vec![0u32; w * h]; + decode(mip_data, w, h, &mut data) + .map_err(|reason| DecodeErr::Decode(self.format, reason))?; + // the u32 pixels are BGRA8 packed as little-endian + Ok(data.into_iter().flat_map(u32::to_le_bytes).collect()) + }; + + use image_dds::ImageFormat as IF; + let (format, data): (PixelFormat, Cow<'_, [u8]>) = match self.format { + // TODO: test me (this is likely wrong) + Format::Bgra8 => (PixelFormat::Bgra8Unorm, Cow::Borrowed(mip_data)), + Format::Bc1 => ( + PixelFormat::Rgba8Unorm, + decode_image_dds(IF::BC1RgbaUnorm)?.into(), + ), + Format::Bc3 => ( + PixelFormat::Rgba8Unorm, + decode_image_dds(IF::BC3RgbaUnorm)?.into(), + ), + Format::Bc7 => ( + PixelFormat::Rgba8Unorm, + decode_image_dds(IF::BC7RgbaUnormSrgb)?.into(), + ), + // image_dds/texture2ddecoder only decode *unsigned* BC5, so we do it ourselves + Format::Bc5Snorm => ( + PixelFormat::Rg8Snorm, + bc5_snorm::decode_bc5_snorm(mip_data, w, h).into(), + ), + Format::Rgba16Float => (PixelFormat::Rgba16Float, Cow::Borrowed(mip_data)), + Format::Rgba32Float => (PixelFormat::Rgba32Float, Cow::Borrowed(mip_data)), + Format::Etc1 => ( + PixelFormat::Bgra8Unorm, + decode_texture2d(texture2ddecoder::decode_etc1)?.into(), + ), + Format::Etc2Eac => ( + PixelFormat::Bgra8Unorm, + decode_texture2d(texture2ddecoder::decode_etc2_rgba8)?.into(), ), - Format::Bc1 | Format::Bc3 => { - let image_format = match self.format { - Format::Bc1 => image_dds::ImageFormat::BC1RgbaUnorm, - Format::Bc3 => image_dds::ImageFormat::BC3RgbaUnorm, - // Safety: outer match guarantees only Bc1/Bc3 reach here - _ => unsafe { unreachable_unchecked() }, - }; - let surface = image_dds::Surface { - width: w as u32, - height: h as u32, - depth: 1, - layers: 1, - mipmaps: 1, - image_format, - data: &self.data[off..off + mip_bytes((w, h))], - }; - let rgba8 = surface.decode_layers_mipmaps_rgba8(0..1, 0..1)?; - TexSurfaceData::Rgba8Owned(rgba8.data) - } - _ => { - let mut data = vec![0u32; w * h]; - let i = &self.data[off..off + mip_bytes((w, h))]; - let o = &mut data; - match self.format { - Format::Etc1 => { - texture2ddecoder::decode_etc1(i, w, h, o).map_err(DecodeErr::Etc1) - } - Format::Etc2Eac => { - texture2ddecoder::decode_etc2_rgba8(i, w, h, o).map_err(DecodeErr::Etc2Eac) - } - // Safety: Bgra8/Bc1/Bc3 are handled above - Format::Bgra8 | Format::Bc1 | Format::Bc3 => unsafe { unreachable_unchecked() }, - }?; - TexSurfaceData::Bgra8Owned(data) - } }; Ok(TexSurface { width: w as _, height: h as _, + format, data, }) } @@ -196,11 +256,9 @@ impl Tex { pub fn from_reader_no_magic(reader: &mut R) -> Result { let (width, height) = (reader.read_u16::()?, reader.read_u16::()?); - let _is_extended_format = reader.read_u8(); // maybe.. + let depth = reader.read_u8()?; let format = Format::from_u8(reader.read_u8()?)?; - - // (0: texture, 1: cubemap, 2: surface, 3: volumetexture) - let resource_type = reader.read_u8()?; // maybe.. + let resource_type = ResourceType::from_u8(reader.read_u8()?)?; let flags = reader.read_u8()?; let flags = TextureFlags::from_bits(flags).ok_or(Error::InvalidTextureFlags(flags))?; @@ -211,18 +269,44 @@ impl Tex { Ok(Self { width, height, + depth, format, flags, resource_type, data, mip_count: match flags.contains(TextureFlags::HasMipMaps) { - true => ((height.max(width) as f32).log2().floor() + 1.0) as u32, + true => (height.max(width).max(depth as u16).max(1) as u32).ilog2() + 1, false => 1, }, }) } } +/// GPU resource type, as stored in the TEX header. +/// Only regular and volume textures can be found in live builds of the game. +#[derive(TryFromPrimitive, IntoPrimitive, Clone, Copy, Debug, Hash, PartialEq, Eq)] +#[repr(u8)] +pub enum ResourceType { + /// A regular 2D texture (`depth == 1`) + Texture = 0, + /// Six 2D faces (This resource type is assumed by reverse engineering) + Cubemap = 1, + /// A bare 2D image (This resource type is assumed by reverse engineering) + Surface = 2, + /// A 3D texture: [`Tex::depth`] z-slices per mip level, stored sequentially + VolumeTexture = 3, +} + +impl ResourceType { + pub fn from_u8(resource_type: u8) -> Result { + Self::try_from(resource_type).map_err(|_| Error::UnknownResourceType(resource_type)) + } + + pub fn to_u8(&self) -> u8 { + (*self).into() + } +} + bitflags::bitflags! { #[derive(Debug, Clone, Copy)] pub struct TextureFlags: u8 { @@ -246,8 +330,235 @@ pub enum TextureAddress { Clamp, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_new_format_ids() { + assert_eq!(Format::from_u8(13).unwrap(), Format::Bc7); + assert_eq!(Format::from_u8(14).unwrap(), Format::Bc5Snorm); + assert_eq!(Format::from_u8(22).unwrap(), Format::Rgba32Float); + assert_eq!(Format::Bc7.to_u8(), 13); + assert_eq!(Format::Bc5Snorm.to_u8(), 14); + assert_eq!(Format::Rgba32Float.to_u8(), 22); + } + + fn tex_file(format: u8, data: &[u8]) -> Vec { + let mut file = Vec::new(); + file.extend_from_slice(b"TEX\0"); + file.extend_from_slice(&4u16.to_le_bytes()); // width + file.extend_from_slice(&4u16.to_le_bytes()); // height + file.push(1); // depth + file.push(format); + file.push(0); // resource type: texture + file.push(0); // flags: no mipmaps + file.extend_from_slice(data); + file + } + + #[test] + fn write_roundtrips_header_bytes() { + let file = tex_file(20, &[0u8; 64]); + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert_eq!(tex.depth, 1); + assert_eq!(tex.resource_type, ResourceType::Texture); + + let mut out = Vec::new(); + tex.write(&mut out).unwrap(); + assert_eq!(out, file); + } + + #[test] + fn decodes_volume_texture_slices() { + // 2x2x2 BGRA8 volume texture, no mips: two sequential 16-byte z-slices + let mut file = Vec::new(); + file.extend_from_slice(b"TEX\0"); + file.extend_from_slice(&2u16.to_le_bytes()); // width + file.extend_from_slice(&2u16.to_le_bytes()); // height + file.push(2); // depth + file.push(20); // Bgra8 + file.push(3); // resource type: volume texture + file.push(0); // flags: no mipmaps + file.extend_from_slice(&[0x11; 16]); // slice 0 + file.extend_from_slice(&[0x22; 16]); // slice 1 + + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert_eq!(tex.depth, 2); + assert_eq!(tex.resource_type, ResourceType::VolumeTexture); + + assert_eq!(&*tex.decode_mipmap_slice(0, 0).unwrap().data, &[0x11; 16]); + assert_eq!(&*tex.decode_mipmap_slice(0, 1).unwrap().data, &[0x22; 16]); + // decode_mipmap is slice 0 + assert_eq!(&*tex.decode_mipmap(0).unwrap().data, &[0x11; 16]); + assert!(matches!( + tex.decode_mipmap_slice(0, 2), + Err(DecodeErr::SliceOutOfBounds { slice: 2, depth: 2 }) + )); + } + + #[test] + fn reads_and_decodes_bc5_snorm_tex() { + // one BC5 block: red = constant 1.0 (endpoints 127/-127, all indices 0), + // green = constant -1.0 (endpoints -127/-127) + let mut block = Vec::new(); + block.extend_from_slice(&[127, 0x81, 0, 0, 0, 0, 0, 0]); + block.extend_from_slice(&[0x81, 0x81, 0, 0, 0, 0, 0, 0]); + let file = tex_file(14, &block); + + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert_eq!(tex.format, Format::Bc5Snorm); + assert_eq!(tex.mip_count, 1); + + // the decoded surface preserves the signed data + let surface = tex.decode_mipmap(0).unwrap(); + assert_eq!(surface.format, PixelFormat::Rg8Snorm); + assert_eq!(surface.as_pixels::<[i8; 2]>().unwrap(), [[127, -127]; 16]); + + // ...while the image conversion remaps to [0, 255] + let img = surface.into_rgba_image().unwrap(); + assert_eq!(img.dimensions(), (4, 4)); + for pixel in img.pixels() { + assert_eq!(pixel.0, [255, 0, 0, 255]); + } + } + + #[test] + fn reads_and_decodes_rgba16_float_tex() { + // 4x4 RGBA16F: every pixel (0.5, -1.0, 2.0, 1.0) — out-of-[0,1] values must + // survive decoding intact and only clamp in the image conversion + let pixel = [ + half::f16::from_f32(0.5), + half::f16::from_f32(-1.0), + half::f16::from_f32(2.0), + half::f16::from_f32(1.0), + ]; + let data: Vec = std::iter::repeat_n(pixel, 16) + .flatten() + .flat_map(half::f16::to_le_bytes) + .collect(); + let file = tex_file(21, &data); + + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert_eq!(tex.format, Format::Rgba16Float); + + let surface = tex.decode_mipmap(0).unwrap(); + assert_eq!(surface.format, PixelFormat::Rgba16Float); + assert_eq!(surface.as_pixels::<[half::f16; 4]>().unwrap(), [pixel; 16]); + + let img = surface.into_rgba_image().unwrap(); + for p in img.pixels() { + assert_eq!(p.0, [128, 0, 255, 255]); + } + } + + #[test] + fn rgba16_float_encode_roundtrips() { + let mut img = image::RgbaImage::new(4, 4); + img.pixels_mut().for_each(|p| p.0 = [0, 51, 204, 255]); + + let tex = + Tex::encode_rgba_image(&img, EncodeOptions::new(EncodeFormat::Rgba16Float)).unwrap(); + assert_eq!(tex.format, Format::Rgba16Float); + + let decoded = tex.decode_mipmap(0).unwrap().into_rgba_image().unwrap(); + assert_eq!(decoded.pixels().next().unwrap().0, [0, 51, 204, 255]); + } + + #[test] + fn reads_and_decodes_rgba32_float_tex() { + // 4x4 RGBA32F: every pixel (0.5, -1.0, 2.0, 1.0) — out-of-[0,1] values must + // survive decoding intact and only clamp in the image conversion + let pixel = [0.5f32, -1.0, 2.0, 1.0]; + let data: Vec = std::iter::repeat_n(pixel, 16) + .flatten() + .flat_map(f32::to_le_bytes) + .collect(); + let file = tex_file(22, &data); + + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert_eq!(tex.format, Format::Rgba32Float); + + let surface = tex.decode_mipmap(0).unwrap(); + assert_eq!(surface.format, PixelFormat::Rgba32Float); + assert_eq!(surface.as_pixels::<[f32; 4]>().unwrap(), [pixel; 16]); + + let img = surface.into_rgba_image().unwrap(); + for p in img.pixels() { + assert_eq!(p.0, [128, 0, 255, 255]); + } + } + + #[test] + fn rgba32_float_encode_roundtrips() { + let mut img = image::RgbaImage::new(4, 4); + img.pixels_mut().for_each(|p| p.0 = [0, 51, 204, 255]); + + let tex = + Tex::encode_rgba_image(&img, EncodeOptions::new(EncodeFormat::Rgba32Float)).unwrap(); + assert_eq!(tex.format, Format::Rgba32Float); + + let decoded = tex.decode_mipmap(0).unwrap().into_rgba_image().unwrap(); + assert_eq!(decoded.pixels().next().unwrap().0, [0, 51, 204, 255]); + } + + /// The texpresso BC1/BC3 backend: a solid color that's exact in RGB565 must + /// round-trip unchanged, including through the masked partial blocks and + /// non-block-aligned mip levels of a 10x6 NPOT chain. + #[test] + fn texpresso_bc_encode_roundtrips() { + let mut img = image::RgbaImage::new(10, 6); + img.pixels_mut().for_each(|p| p.0 = [255, 0, 0, 255]); + + for format in [ + EncodeFormat::Bc1 { + weigh_colour_by_alpha: false, + }, + EncodeFormat::Bc3 { + weigh_colour_by_alpha: false, + }, + ] { + let tex = + Tex::encode_rgba_image(&img, EncodeOptions::new(format).with_mipmaps()).unwrap(); + assert_eq!(tex.mip_count, 4); // 10x6 -> 5x3 -> 2x1 -> 1x1 + + for level in 0..tex.mip_count { + let surface = tex.decode_mipmap(level).unwrap(); + assert_eq!(surface.width, (10 >> level).max(1)); + assert_eq!(surface.height, (6 >> level).max(1)); + for p in surface.into_rgba_image().unwrap().pixels() { + assert_eq!(p.0, [255, 0, 0, 255], "{format:?} level {level}"); + } + } + } + } + + #[test] + fn zero_sized_image_errors() { + let img = image::RgbaImage::new(0, 0); + assert!(matches!( + Tex::encode_rgba_image(&img, EncodeOptions::new(EncodeFormat::Bgra8)), + Err(EncodeError::ZeroSizedImage) + )); + assert!(matches!( + Tex::encode_rgba_image(&img, EncodeOptions::new(EncodeFormat::Bgra8).with_mipmaps()), + Err(EncodeError::ZeroSizedImage) + )); + } + + #[test] + fn truncated_data_errors_instead_of_panicking() { + let file = tex_file(13, &[0; 4]); // BC7 4x4 needs a full 16-byte block + let tex = Tex::from_reader(&mut file.as_slice()).unwrap(); + assert!(matches!( + tex.decode_mipmap(0), + Err(DecodeErr::MipOutOfBounds { .. }) + )); + } +} + //#[cfg(test)] -//mod tests { +//mod old_tests { // use super::*; // use image::codecs::png::PngEncoder; // use io::BufWriter; diff --git a/crates/ltk_texture/src/tex/surface.rs b/crates/ltk_texture/src/tex/surface.rs index c5cdcb37..97741012 100644 --- a/crates/ltk_texture/src/tex/surface.rs +++ b/crates/ltk_texture/src/tex/surface.rs @@ -1,46 +1,122 @@ use super::super::ToImageError; +use std::borrow::Cow; + +/// The uncompressed pixel layout of a decoded surface +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub enum PixelFormat { + /// 8-bit BGRA, unsigned normalized + Bgra8Unorm, + /// 8-bit RGBA, unsigned normalized + Rgba8Unorm, + /// 8-bit RG, signed normalized (bytes are `i8` bit patterns) + Rg8Snorm, + /// 16-bit RGBA, half-float (channels are little-endian [`half::f16`] bit patterns) + Rgba16Float, + /// 32-bit RGBA, float (channels are little-endian [`f32`] bit patterns) + Rgba32Float, +} + +impl PixelFormat { + pub const fn channel_count(self) -> usize { + match self { + PixelFormat::Bgra8Unorm + | PixelFormat::Rgba8Unorm + | PixelFormat::Rgba16Float + | PixelFormat::Rgba32Float => 4, + PixelFormat::Rg8Snorm => 2, + } + } + + pub const fn bytes_per_pixel(self) -> usize { + match self { + PixelFormat::Bgra8Unorm | PixelFormat::Rgba8Unorm => 4, + PixelFormat::Rg8Snorm => 2, + PixelFormat::Rgba16Float => 8, + PixelFormat::Rgba32Float => 16, + } + } +} /// A decoded tex mipmap +/// +/// `data` holds tightly-packed, row-major pixels laid out as described by `format`. +/// Decoders emit their *natural* format (e.g. BC5_SNORM decodes to [`PixelFormat::Rg8Snorm`] +/// with the signed data intact) - use [`Self::into_rgba_image`] when you just want something +/// presentable. pub struct TexSurface<'a> { pub width: u32, pub height: u32, - pub data: TexSurfaceData<'a>, -} - -/// The data of a tex surface -pub enum TexSurfaceData<'a> { - Bgra8Slice(&'a [u8]), - Bgra8Owned(Vec), - Rgba8Owned(Vec), + pub format: PixelFormat, + pub data: Cow<'a, [u8]>, } impl TexSurface<'_> { + /// Reinterpret the raw data as a slice of typed pixels, e.g. `[i8; 2]` for + /// [`PixelFormat::Rg8Snorm`]. + /// + /// Returns `None` if `P`'s size/alignment doesn't evenly fit the data. + pub fn as_pixels(&self) -> Option<&[P]> { + bytemuck::try_cast_slice(&self.data).ok() + } + /// Convert the surface to an [image::RgbaImage] + /// + /// This is a *presentation* conversion: signed-normalized channels are remapped from + /// `[-1, 1]` to `[0, 255]`, float channels are clamped to `[0, 1]` before quantizing + /// (out-of-range data is lost - use [`Self::as_pixels`] for the real values), and + /// channels missing from the source format are filled with 0 (alpha with 255). pub fn into_rgba_image(self) -> Result { - image::RgbaImage::from_raw( - self.width, - self.height, - match self.data { - TexSurfaceData::Bgra8Slice(data) => data - .chunks_exact(4) - .flat_map(|pixel| { - let [b, g, r, a] = pixel else { - unreachable!(); - }; - [r, g, b, a] - }) - .copied() - .collect(), - TexSurfaceData::Bgra8Owned(vec) => vec - .into_iter() - .flat_map(|pixel| { - let [b, g, r, a] = pixel.to_le_bytes(); - [r, g, b, a] - }) - .collect(), - TexSurfaceData::Rgba8Owned(data) => data, - }, - ) - .ok_or(ToImageError::InvalidContainerSize) + let rgba: Vec = match self.format { + PixelFormat::Rgba8Unorm => self.data.into_owned(), + PixelFormat::Bgra8Unorm => self + .data + .chunks_exact(4) + .flat_map(|pixel| { + let [b, g, r, a] = pixel else { unreachable!() }; + [*r, *g, *b, *a] + }) + .collect(), + PixelFormat::Rg8Snorm => self + .data + .chunks_exact(2) + .flat_map(|pixel| { + let [r, g] = pixel else { unreachable!() }; + [ + snorm8_to_unorm8(*r as i8), + snorm8_to_unorm8(*g as i8), + 0, + 255, + ] + }) + .collect(), + PixelFormat::Rgba16Float => self + .data + .chunks_exact(2) + .map(|channel| { + let value = half::f16::from_le_bytes([channel[0], channel[1]]).to_f32(); + (value.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect(), + PixelFormat::Rgba32Float => self + .data + .chunks_exact(4) + .map(|channel| { + let value = + f32::from_le_bytes([channel[0], channel[1], channel[2], channel[3]]); + (value.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect(), + }; + + image::RgbaImage::from_raw(self.width, self.height, rgba) + .ok_or(ToImageError::InvalidContainerSize) } } + +/// Remap a signed-normalized value from `[-1, 1]` to `[0, 255]` +fn snorm8_to_unorm8(v: i8) -> u8 { + // -128 is clamped to -127 so the range is symmetric around 0 + let v = v.max(-127) as f32 / 127.0; + ((v * 0.5 + 0.5) * 255.0).round() as u8 +} diff --git a/crates/ltk_texture/src/tex/write.rs b/crates/ltk_texture/src/tex/write.rs index 47339114..20b5af03 100644 --- a/crates/ltk_texture/src/tex/write.rs +++ b/crates/ltk_texture/src/tex/write.rs @@ -8,12 +8,13 @@ impl Tex { /// # Example /// ```no_run /// use ltk_texture::Tex; - /// use ltk_texture::tex::{EncodeOptions, Format}; + /// use ltk_texture::tex::{EncodeFormat, EncodeOptions}; /// use image::RgbaImage; /// use std::fs::File; /// /// let img = RgbaImage::new(256, 256); - /// let tex = Tex::encode_rgba_image(&img, EncodeOptions::new(Format::Bc3)).unwrap(); + /// let format = EncodeFormat::Bc3 { weigh_colour_by_alpha: false }; + /// let tex = Tex::encode_rgba_image(&img, EncodeOptions::new(format)).unwrap(); /// /// // Write to file /// let mut file = File::create("texture.tex").unwrap(); @@ -26,9 +27,9 @@ impl Tex { // Write header writer.write_u16::(self.width)?; writer.write_u16::(self.height)?; - writer.write_u8(0)?; // is_extended_format (maybe) + writer.write_u8(self.depth)?; writer.write_u8(self.format.into())?; - writer.write_u8(self.resource_type)?; + writer.write_u8(self.resource_type.to_u8())?; writer.write_u8(self.flags.bits())?; // Write data