use crate::alpha::AlphaMode;
use crate::encoders::Quality;
use crate::error::{Error, Result};
use crate::format::TargetFormat;
use crate::processing::{
self, Buffer, PipelineOutput, Swizzle, Variant, encode, load, mipmap, passthrough, store,
swizzle,
};
use crate::surface::{ColorSpace, Image};
use crate::vk_format::FormatExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Container {
Dds,
Ktx2(Option<Ktx2Supercompression>),
Raw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ktx2Supercompression {
Zstd { level: i32 },
Zlib { level: u8 },
}
impl Container {
pub fn ktx2() -> Self {
Container::Ktx2(None)
}
pub fn ktx2_zstd(level: i32) -> Self {
Container::Ktx2(Some(Ktx2Supercompression::Zstd { level }))
}
pub fn ktx2_zlib(level: u8) -> Self {
Container::Ktx2(Some(Ktx2Supercompression::Zlib { level }))
}
}
#[derive(Default)]
pub struct ConvertSettings {
pub format: Option<TargetFormat>,
pub container: Container,
pub quality: Quality,
pub output_color_space: Option<ColorSpace>,
pub output_alpha: Option<AlphaMode>,
pub allow_discarding_alpha: bool,
pub swizzle: Option<Swizzle>,
pub mipmap: bool,
pub mipmap_count: Option<usize>,
pub mipmap_filter: mipmap::MipmapFilter,
}
impl Default for Container {
fn default() -> Self {
Container::Ktx2(None)
}
}
pub fn convert(image: Image, mut settings: ConvertSettings) -> Result<PipelineOutput> {
profiling::scope!("convert");
image.validate()?;
let input_base = &image.surfaces[0][0];
let input_fmt = input_base.format;
let input_cs = input_base.color_space;
let input_alpha = input_base.alpha;
let (target_fmt, encoder_step) = resolve_target(input_fmt, &mut settings)?;
let target_cs = settings.output_color_space.unwrap_or(input_cs);
let target_alpha = settings.output_alpha.unwrap_or(input_alpha);
let final_target_fmt = encoder_step
.as_ref()
.map(|s| s.target_format)
.unwrap_or(target_fmt);
let final_has_alpha = final_target_fmt.has_alpha_channel();
if warn_discarding_alpha(
input_fmt.has_alpha_channel(),
final_has_alpha,
target_alpha,
settings.allow_discarding_alpha,
) {
log::warn!(
"{final_target_fmt:?} has no alpha channel; discarding the source's straight \
alpha and keeping color. Set output alpha to premultiplied to bake it in, opaque \
to mark the texture opaque, or allow_discarding_alpha to silence."
);
}
log::debug!(
"convert: {input_fmt:?} ({input_cs:?}, {input_alpha:?}) → \
{final_target_fmt:?} ({target_cs:?}, {target_alpha:?}) \
container={:?} swizzle={} mipmap={}",
settings.container,
settings.swizzle.is_some(),
settings.mipmap,
);
let (input_base_fmt, _) = input_fmt.normalize();
let (target_base_fmt, _) = final_target_fmt.normalize();
let formats_match = input_base_fmt == target_base_fmt;
let no_pixel_work = settings.swizzle.is_none() && !settings.mipmap;
if formats_match && input_cs == target_cs && input_alpha == target_alpha && no_pixel_work {
log::debug!("convert: taking passthrough path (format and processing match)");
return passthrough::run(image, final_target_fmt, settings.container);
}
if input_fmt.is_compressed() {
if settings.swizzle.is_some() || settings.mipmap {
return Err(Error::UnsupportedConversion(
"cannot swizzle or mipmap a compressed input; decode it to an \
uncompressed format first"
.into(),
));
}
if input_cs != target_cs || input_alpha != target_alpha {
return Err(Error::UnsupportedConversion(
"cannot change color space or alpha mode for a compressed input; decode it to an \
uncompressed format first"
.into(),
));
}
log::debug!(
"convert: compressed input did not qualify for passthrough; \
falling back to passthrough format check"
);
return passthrough::run(image, final_target_fmt, settings.container);
}
if matches!(image.kind, crate::TextureKind::Texture3D) {
return Err(Error::UnsupportedConversion(
"3D textures are only supported in passthrough mode (no format change, swizzle, or mipmap generation)".into(),
));
}
let variant = processing::pick_variant(input_fmt, target_fmt).ok_or_else(|| {
Error::UnsupportedConversion(format!(
"cannot derive pipeline variant from {input_fmt:?} → {target_fmt:?}"
))
})?;
if !processing::families_compatible(input_fmt, target_fmt) {
return Err(Error::UnsupportedConversion(format!(
"integer/float family mismatch: {input_fmt:?} → {target_fmt:?}"
)));
}
log::debug!("convert: routing through {variant:?} pipeline");
match variant {
Variant::F32 => convert_f32(image, settings, target_fmt, encoder_step, final_has_alpha),
Variant::F64 => convert_f64(image, settings, target_fmt, encoder_step, final_has_alpha),
Variant::U32 => convert_u32(image, settings, target_fmt, encoder_step),
Variant::U64 => convert_u64(image, settings, target_fmt, encoder_step),
}
}
fn warn_discarding_alpha(
source_has_alpha: bool,
final_has_alpha: bool,
effective_out: AlphaMode,
allow: bool,
) -> bool {
source_has_alpha && !final_has_alpha && effective_out == AlphaMode::Straight && !allow
}
fn alpha_less_load_store(target_alpha: AlphaMode, src_alpha: AlphaMode) -> (AlphaMode, AlphaMode) {
use AlphaMode::*;
match (target_alpha, src_alpha) {
(Premultiplied, Straight) => (Straight, Opaque), (Premultiplied, _) => (Opaque, Opaque), (_, Premultiplied) => (Opaque, Straight), (_, _) => (Opaque, Opaque), }
}
fn resolve_target(
input_fmt: ktx2::Format,
settings: &mut ConvertSettings,
) -> Result<(ktx2::Format, Option<encode::EncoderStep>)> {
match settings.format.take() {
Some(TargetFormat::Compressed { format, encoder }) => {
let step = encode::EncoderStep {
target_format: format,
quality: settings.quality,
encoder,
};
let required_input = step.required_input()?;
Ok((required_input, Some(step)))
}
Some(TargetFormat::Uncompressed(fmt)) => Ok((fmt, None)),
None => Ok((input_fmt, None)),
}
}
fn convert_f32(
image: Image,
settings: ConvertSettings,
target_fmt: ktx2::Format,
encoder_step: Option<encode::EncoderStep>,
final_has_alpha: bool,
) -> Result<PipelineOutput> {
let input_base = &image.surfaces[0][0];
let target_color_space = settings
.output_color_space
.unwrap_or(input_base.color_space);
let target_alpha = settings.output_alpha.unwrap_or(input_base.alpha);
let mut out_layers = Vec::with_capacity(image.surfaces.len());
for layer in image.surfaces {
profiling::scope!("convert_f32_layer");
let mips = if settings.mipmap {
let target_count = settings
.mipmap_count
.unwrap_or_else(|| mipmap::full_mip_count(layer[0].width, layer[0].height));
let mut bufs = Vec::with_capacity(layer.len().min(target_count));
let mut store_alphas = Vec::with_capacity(bufs.capacity());
for mut surface in layer.into_iter().take(target_count) {
let (load_alpha, store_alpha) = if final_has_alpha {
(surface.alpha, target_alpha)
} else {
alpha_less_load_store(target_alpha, surface.alpha)
};
surface.alpha = load_alpha;
let mut buf: Buffer<f32> = load::load_f32(&surface)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_f32(&mut buf, sw);
}
bufs.push(buf);
store_alphas.push(store_alpha);
}
let generated_store_alpha = *store_alphas
.last()
.ok_or_else(|| Error::UnsupportedFormat("mipmap count must be >= 1".into()))?;
let bufs = mipmap::complete(bufs, settings.mipmap_filter, settings.mipmap_count)?;
store_alphas.resize(bufs.len(), generated_store_alpha);
let mut mips = Vec::with_capacity(bufs.len());
for (b, store_alpha) in bufs.into_iter().zip(store_alphas) {
let mut surface = store::store_f32(b, target_fmt, target_color_space, store_alpha)?;
surface.alpha = target_alpha;
mips.push(surface);
}
mips
} else {
let mut mips = Vec::with_capacity(layer.len());
for mut base in layer {
let (load_alpha, store_alpha) = if final_has_alpha {
(base.alpha, target_alpha)
} else {
alpha_less_load_store(target_alpha, base.alpha)
};
base.alpha = load_alpha;
let mut buf: Buffer<f32> = load::load_f32(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_f32(&mut buf, sw);
}
let mut surface =
store::store_f32(buf, target_fmt, target_color_space, store_alpha)?;
surface.alpha = target_alpha;
mips.push(surface);
}
mips
};
out_layers.push(mips);
}
let processed = Image {
surfaces: out_layers,
kind: image.kind,
};
let final_image = match encoder_step {
Some(step) => encode::encode_all(processed, &step)?,
None => processed,
};
passthrough::emit(final_image, settings.container)
}
fn convert_f64(
image: Image,
settings: ConvertSettings,
target_fmt: ktx2::Format,
encoder_step: Option<encode::EncoderStep>,
final_has_alpha: bool,
) -> Result<PipelineOutput> {
if settings.mipmap {
return Err(Error::UnsupportedFormat(
"f64 pipeline does not yet support mipmap generation".into(),
));
}
let input_base = &image.surfaces[0][0];
let target_color_space = settings
.output_color_space
.unwrap_or(input_base.color_space);
let target_alpha = settings.output_alpha.unwrap_or(input_base.alpha);
let mut out_layers = Vec::with_capacity(image.surfaces.len());
for layer in image.surfaces {
profiling::scope!("convert_f64_layer");
let mut mips = Vec::with_capacity(layer.len());
for mut base in layer {
let (load_alpha, store_alpha) = if final_has_alpha {
(base.alpha, target_alpha)
} else {
alpha_less_load_store(target_alpha, base.alpha)
};
base.alpha = load_alpha;
let mut buf = load::load_f64(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_f64(&mut buf, sw);
}
let mut surface = store::store_f64(buf, target_fmt, target_color_space, store_alpha)?;
surface.alpha = target_alpha;
mips.push(surface);
}
out_layers.push(mips);
}
let processed = Image {
surfaces: out_layers,
kind: image.kind,
};
let final_image = match encoder_step {
Some(step) => encode::encode_all(processed, &step)?,
None => processed,
};
passthrough::emit(final_image, settings.container)
}
fn convert_u32(
image: Image,
settings: ConvertSettings,
target_fmt: ktx2::Format,
encoder_step: Option<encode::EncoderStep>,
) -> Result<PipelineOutput> {
let input_alpha = image.surfaces[0][0].alpha;
check_uint_unsupported(&settings, input_alpha)?;
let target_alpha = settings.output_alpha.unwrap_or(input_alpha);
let mut out_layers = Vec::with_capacity(image.surfaces.len());
for layer in image.surfaces {
profiling::scope!("convert_u32_layer");
let mut mips = Vec::with_capacity(layer.len());
for base in layer {
let mut buf = load::load_u32(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_u32(&mut buf, sw);
}
mips.push(store::store_u32(buf, target_fmt, target_alpha)?);
}
out_layers.push(mips);
}
let processed = Image {
surfaces: out_layers,
kind: image.kind,
};
if encoder_step.is_some() {
return Err(Error::UnsupportedConversion(
"integer (uint/sint) formats cannot be block-compressed".into(),
));
}
passthrough::emit(processed, settings.container)
}
fn convert_u64(
image: Image,
settings: ConvertSettings,
target_fmt: ktx2::Format,
encoder_step: Option<encode::EncoderStep>,
) -> Result<PipelineOutput> {
let input_alpha = image.surfaces[0][0].alpha;
check_uint_unsupported(&settings, input_alpha)?;
let target_alpha = settings.output_alpha.unwrap_or(input_alpha);
let mut out_layers = Vec::with_capacity(image.surfaces.len());
for layer in image.surfaces {
profiling::scope!("convert_u64_layer");
let mut mips = Vec::with_capacity(layer.len());
for base in layer {
let mut buf = load::load_u64(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_u64(&mut buf, sw);
}
mips.push(store::store_u64(buf, target_fmt, target_alpha)?);
}
out_layers.push(mips);
}
let processed = Image {
surfaces: out_layers,
kind: image.kind,
};
if encoder_step.is_some() {
return Err(Error::UnsupportedConversion(
"integer (uint/sint) formats cannot be block-compressed".into(),
));
}
passthrough::emit(processed, settings.container)
}
fn check_uint_unsupported(settings: &ConvertSettings, input_alpha: AlphaMode) -> Result<()> {
if settings.mipmap {
return Err(Error::UnsupportedFormat(
"integer pipeline does not support mipmap generation".into(),
));
}
if settings.output_color_space.is_some() {
return Err(Error::UnsupportedFormat(
"integer pipeline does not support output_color_space change".into(),
));
}
if let Some(out_alpha) = settings.output_alpha
&& out_alpha != input_alpha
{
return Err(Error::UnsupportedFormat(
"integer pipeline does not support output_alpha change".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::surface::Surface;
fn make_image(
data: Vec<u8>,
width: u32,
height: u32,
format: ktx2::Format,
cs: ColorSpace,
alpha: AlphaMode,
) -> Image {
let bpp = format.bytes_per_pixel().unwrap() as u32;
Image {
surfaces: vec![vec![Surface {
data,
width,
height,
depth: 1,
stride: width * bpp,
slice_stride: 0,
format,
color_space: cs,
alpha,
}]],
kind: crate::TextureKind::Texture2D,
}
}
#[test]
fn raw_roundtrip_rgba8_linear() {
let image = make_image(
vec![10, 20, 30, 40, 50, 60, 70, 80],
2,
1,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(
img.surfaces[0][0].data,
vec![10, 20, 30, 40, 50, 60, 70, 80]
);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_rgba8_to_a2b10g10r10_unorm_end_to_end() {
let image = make_image(
vec![255, 0, 128, 255],
1,
1,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(
ktx2::Format::A2B10G10R10_UNORM_PACK32,
)),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
let s = &img.surfaces[0][0];
assert_eq!(s.format, ktx2::Format::A2B10G10R10_UNORM_PACK32);
assert_eq!(s.data.len(), 4);
let word = u32::from_le_bytes(s.data[..4].try_into().unwrap());
let r = word & 0x3ff;
let g = (word >> 10) & 0x3ff;
let b = (word >> 20) & 0x3ff;
let a = word >> 30;
assert_eq!((r, g, b, a), (1023, 0, 514, 3));
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_rgba8_to_e5b9g9r9_end_to_end() {
let image = make_image(
vec![255, 128, 0, 255],
1,
1,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(
ktx2::Format::E5B9G9R9_UFLOAT_PACK32,
)),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
let s = &img.surfaces[0][0];
assert_eq!(s.format, ktx2::Format::E5B9G9R9_UFLOAT_PACK32);
assert_eq!(s.data, 0x8001_0300u32.to_le_bytes());
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_uint_to_a2b10g10r10_uint_end_to_end() {
let image = make_image(
vec![255, 12, 200, 2],
1,
1,
ktx2::Format::R8G8B8A8_UINT,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(
ktx2::Format::A2B10G10R10_UINT_PACK32,
)),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
let s = &img.surfaces[0][0];
assert_eq!(s.format, ktx2::Format::A2B10G10R10_UINT_PACK32);
let word = u32::from_le_bytes(s.data[..4].try_into().unwrap());
assert_eq!(word & 0x3ff, 255);
assert_eq!((word >> 10) & 0x3ff, 12);
assert_eq!((word >> 20) & 0x3ff, 200);
assert_eq!(word >> 30, 2);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_rgba8_to_r8_channel_drop() {
let image = make_image(
vec![100, 150, 200, 255],
1,
1,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8_UNORM)),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0][0].format, ktx2::Format::R8_UNORM);
assert_eq!(img.surfaces[0][0].data, vec![100]);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_with_mipmap() {
let data = vec![128u8; 8 * 8 * 4];
let image = make_image(
data,
8,
8,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8G8B8A8_UNORM)),
container: Container::Raw,
mipmap: true,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0].len(), 4); }
_ => panic!("expected Raw output"),
}
}
fn three_mip_rgba8() -> Image {
let mip = |w: u32, h: u32, fill: u8| Surface {
data: vec![fill; (w * h * 4) as usize],
width: w,
height: h,
depth: 1,
stride: w * 4,
slice_stride: 0,
format: ktx2::Format::R8G8B8A8_UNORM,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Opaque,
};
Image {
surfaces: vec![vec![mip(8, 8, 10), mip(4, 4, 20), mip(2, 2, 30)]],
kind: crate::TextureKind::Texture2D,
}
}
#[test]
fn convert_f32_preserves_existing_mips_when_no_mipmap() {
let out = convert(
three_mip_rgba8(),
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8_UNORM)),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0].len(), 3, "existing 3-mip chain preserved");
assert_eq!(
(img.surfaces[0][2].width, img.surfaces[0][2].height),
(2, 2),
);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_f32_completes_existing_chain_with_mipmap() {
let out = convert(
three_mip_rgba8(),
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8_UNORM)),
container: Container::Raw,
mipmap: true,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0].len(), 4, "full chain completed");
assert_eq!(img.surfaces[0][0].data[0], 10);
assert_eq!(img.surfaces[0][1].data[0], 20);
assert_eq!(img.surfaces[0][2].data[0], 30);
assert_eq!(img.surfaces[0][3].data[0], 30);
assert_eq!(
(img.surfaces[0][3].width, img.surfaces[0][3].height),
(1, 1),
);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn convert_f32_mipmap_count_truncates_existing_chain() {
let out = convert(
three_mip_rgba8(),
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8_UNORM)),
container: Container::Raw,
mipmap: true,
mipmap_count: Some(2),
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0].len(), 2);
assert_eq!(img.surfaces[0][0].data[0], 10);
assert_eq!(img.surfaces[0][1].data[0], 20);
}
_ => panic!("expected Raw output"),
}
}
fn bc7_1block_image() -> Image {
Image {
surfaces: vec![vec![Surface {
data: vec![0xFFu8; 16],
width: 4,
height: 4,
depth: 1,
stride: 16,
slice_stride: 0,
format: ktx2::Format::BC7_UNORM_BLOCK,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Opaque,
}]],
kind: crate::TextureKind::Texture2D,
}
}
#[test]
fn convert_compressed_input_swizzle_errors() {
let err = convert(
bc7_1block_image(),
ConvertSettings {
container: Container::Raw,
swizzle: Some(Swizzle([
processing::SwizzleChannel::B,
processing::SwizzleChannel::G,
processing::SwizzleChannel::R,
processing::SwizzleChannel::A,
])),
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, Error::UnsupportedConversion(_)),
"expected UnsupportedConversion, got {err:?}",
);
}
#[test]
fn convert_compressed_input_mipmap_errors() {
let err = convert(
bc7_1block_image(),
ConvertSettings {
container: Container::Raw,
mipmap: true,
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, Error::UnsupportedConversion(_)),
"expected UnsupportedConversion, got {err:?}",
);
}
#[test]
fn convert_compressed_input_color_space_change_errors() {
let err = convert(
bc7_1block_image(),
ConvertSettings {
container: Container::Raw,
output_color_space: Some(ColorSpace::Srgb),
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, Error::UnsupportedConversion(_)),
"expected UnsupportedConversion, got {err:?}",
);
}
#[test]
fn convert_compressed_input_alpha_change_errors() {
let err = convert(
bc7_1block_image(),
ConvertSettings {
container: Container::Raw,
output_alpha: Some(AlphaMode::Premultiplied),
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, Error::UnsupportedConversion(_)),
"expected UnsupportedConversion, got {err:?}",
);
}
#[test]
fn convert_integer_family_mismatch_errors() {
let image = make_image(
vec![100, 150, 200, 255],
1,
1,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let err = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R8G8B8A8_UINT)),
container: Container::Raw,
..Default::default()
},
)
.unwrap_err();
match err {
Error::UnsupportedConversion(_) => {}
_ => panic!("expected UnsupportedConversion, got {err:?}"),
}
}
#[test]
fn convert_rgba8_to_bc7_ktx2() {
let image = make_image(
vec![128u8; 4 * 4 * 4],
4,
4,
ktx2::Format::R8G8B8A8_UNORM,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Compressed {
format: ktx2::Format::BC7_UNORM_BLOCK,
encoder: crate::encoders::Encoder::Auto,
}),
container: Container::ktx2(),
quality: Quality::UltraFast,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Encoded(bytes) => {
assert!(bytes.len() > 12);
assert_eq!(&bytes[0..12], b"\xabKTX 20\xbb\r\n\x1a\n");
}
_ => panic!("expected Encoded output"),
}
}
#[test]
fn convert_passthrough_compressed() {
let bc7_bytes = vec![0xFFu8; 16]; let image = Image {
surfaces: vec![vec![Surface {
data: bc7_bytes.clone(),
width: 4,
height: 4,
depth: 1,
stride: 16,
slice_stride: 0,
format: ktx2::Format::BC7_UNORM_BLOCK,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Opaque,
}]],
kind: crate::TextureKind::Texture2D,
};
let out = convert(
image,
ConvertSettings {
container: Container::Raw,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
assert_eq!(img.surfaces[0][0].data, bc7_bytes);
}
_ => panic!("expected Raw output"),
}
}
fn padded_rgba8_4x2() -> Image {
let pad = 0xCCu8;
let mut data = Vec::new();
for x in 0..4u8 {
data.extend_from_slice(&[10 + x, 20 + x, 30 + x, 255]);
}
data.extend_from_slice(&[pad; 8]);
for x in 0..4u8 {
data.extend_from_slice(&[100 + x, 110 + x, 120 + x, 255]);
}
data.extend_from_slice(&[pad; 8]);
Image {
surfaces: vec![vec![Surface {
data,
width: 4,
height: 2,
depth: 1,
stride: 4 * 4 + 8,
slice_stride: 0,
format: ktx2::Format::R8G8B8A8_UNORM,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Straight,
}]],
kind: crate::TextureKind::Texture2D,
}
}
#[test]
fn convert_padded_stride_swizzle_to_raw_is_tight() {
let image = padded_rgba8_4x2();
let out = convert(
image,
ConvertSettings {
container: Container::Raw,
swizzle: Some(Swizzle([
processing::SwizzleChannel::B,
processing::SwizzleChannel::G,
processing::SwizzleChannel::R,
processing::SwizzleChannel::A,
])),
..Default::default()
},
)
.unwrap();
let img = match out {
PipelineOutput::Raw(img) => img,
_ => panic!("expected Raw output"),
};
let s = &img.surfaces[0][0];
assert_eq!(s.stride, 4 * 4);
assert_eq!(s.data.len(), 4 * 4 * 2);
assert!(
!s.data.contains(&0xCC),
"padding leaked into output: {:?}",
s.data,
);
assert_eq!(&s.data[0..4], &[30, 20, 10, 255]);
let row1 = (4 * 4) as usize;
assert_eq!(&s.data[row1..row1 + 4], &[120, 110, 100, 255]);
}
#[test]
fn convert_padded_stride_to_bc7_succeeds() {
let image = padded_rgba8_4x2();
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Compressed {
format: ktx2::Format::BC7_UNORM_BLOCK,
encoder: crate::encoders::Encoder::Auto,
}),
container: Container::ktx2(),
quality: Quality::UltraFast,
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Encoded(bytes) => {
assert_eq!(&bytes[0..12], b"\xabKTX 20\xbb\r\n\x1a\n");
}
_ => panic!("expected Encoded output"),
}
}
#[test]
fn convert_padded_stride_uncompressed_passthrough_is_tight() {
let image = padded_rgba8_4x2();
let out = convert(
image,
ConvertSettings {
container: Container::ktx2(),
..Default::default()
},
)
.unwrap();
let bytes = match out {
PipelineOutput::Encoded(bytes) => bytes,
_ => panic!("expected Encoded output"),
};
let decoded = crate::input::ktx2::decode_ktx2_image(&bytes).unwrap();
let s = &decoded.surfaces[0][0];
assert_eq!(s.width, 4);
assert_eq!(s.height, 2);
assert_eq!(s.stride, 4 * 4); assert!(
!s.data.contains(&0xCC),
"padding leaked into KTX2 level data"
);
assert_eq!(&s.data[0..4], &[10, 20, 30, 255]);
assert_eq!(&s.data[16..20], &[100, 110, 120, 255]);
}
#[test]
fn convert_padded_stride_compressed_passthrough_is_tight() {
let pad = 0xCCu8;
let block0 = [0x11u8; 16];
let block1 = [0x22u8; 16];
let mut data = Vec::new();
data.extend_from_slice(&block0);
data.extend_from_slice(&block1);
data.extend_from_slice(&[pad; 16]);
let image = Image {
surfaces: vec![vec![Surface {
data,
width: 8,
height: 4,
depth: 1,
stride: 2 * 16 + 16, slice_stride: 0,
format: ktx2::Format::BC7_UNORM_BLOCK,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Opaque,
}]],
kind: crate::TextureKind::Texture2D,
};
let out = convert(
image,
ConvertSettings {
container: Container::ktx2(),
..Default::default()
},
)
.unwrap();
let bytes = match out {
PipelineOutput::Encoded(bytes) => bytes,
_ => panic!("expected Encoded output"),
};
let reader = ktx2::Reader::new(&bytes[..]).expect("valid KTX2");
let levels: Vec<_> = reader.levels().collect();
assert_eq!(levels.len(), 1);
assert_eq!(
levels[0].data.len(),
2 * 16,
"level payload must be tight (2 BC7 blocks), not include the padding block",
);
assert!(
!levels[0].data.contains(&pad),
"BC7 padding leaked into KTX2 level data",
);
let decoded = crate::input::ktx2::decode_ktx2_image(&bytes).unwrap();
let s = &decoded.surfaces[0][0];
assert_eq!(s.format, ktx2::Format::BC7_UNORM_BLOCK);
let mut expected = Vec::new();
expected.extend_from_slice(&block0);
expected.extend_from_slice(&block1);
assert_eq!(s.data, expected);
}
#[test]
fn convert_padded_slice_stride_3d_passthrough_is_tight() {
let pad = 0xCCu8;
let row_pad = 8;
let slice_pad = 8;
let stride = 4 * 4 + row_pad;
let slice_payload = stride * 2;
let slice_stride = slice_payload + slice_pad;
let depth = 3u32;
let mut data = Vec::with_capacity((slice_stride * depth) as usize);
for z in 0..depth as u8 {
for y in 0..2u8 {
for x in 0..4u8 {
data.extend_from_slice(&[z * 50 + x, y * 30, 0, 255]);
}
data.extend_from_slice(&[pad; 8]);
}
data.extend_from_slice(&[pad; 8]);
}
let image = Image {
surfaces: vec![vec![Surface {
data,
width: 4,
height: 2,
depth,
stride,
slice_stride,
format: ktx2::Format::R8G8B8A8_UNORM,
color_space: ColorSpace::Linear,
alpha: AlphaMode::Opaque,
}]],
kind: crate::TextureKind::Texture3D,
};
let out = convert(
image,
ConvertSettings {
container: Container::ktx2(),
..Default::default()
},
)
.unwrap();
let bytes = match out {
PipelineOutput::Encoded(bytes) => bytes,
_ => panic!("expected Encoded output"),
};
let decoded = crate::input::ktx2::decode_ktx2_image(&bytes).unwrap();
let s = &decoded.surfaces[0][0];
assert_eq!(s.depth, depth);
assert_eq!(s.stride, 4 * 4);
assert_eq!(s.slice_stride, 4 * 4 * 2);
assert!(
!s.data.contains(&pad),
"padding leaked into 3D KTX2 level data",
);
for z in 0..depth as usize {
let base = z * (4 * 4 * 2);
assert_eq!(
&s.data[base..base + 4],
&[(z as u8) * 50, 0, 0, 255],
"slice {z} pixel (0,0)",
);
}
}
#[test]
fn convert_uint_pipeline_with_swizzle() {
let mut data = Vec::new();
for v in &[10u32, 20, 30, 40] {
data.extend_from_slice(&v.to_le_bytes());
}
let image = make_image(
data,
1,
1,
ktx2::Format::R32G32B32A32_UINT,
ColorSpace::Linear,
AlphaMode::Opaque,
);
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R32G32B32A32_UINT)),
container: Container::Raw,
swizzle: Some(Swizzle([
processing::SwizzleChannel::A,
processing::SwizzleChannel::B,
processing::SwizzleChannel::G,
processing::SwizzleChannel::R,
])),
..Default::default()
},
)
.unwrap();
match out {
PipelineOutput::Raw(img) => {
let bytes = &img.surfaces[0][0].data;
assert_eq!(u32::from_le_bytes(bytes[0..4].try_into().unwrap()), 40);
assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 30);
assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()), 20);
assert_eq!(u32::from_le_bytes(bytes[12..16].try_into().unwrap()), 10);
}
_ => panic!("expected Raw output"),
}
}
#[test]
fn warn_discarding_alpha_truth_table() {
use AlphaMode::*;
for mode in [Straight, Premultiplied, Opaque] {
assert!(
!warn_discarding_alpha(true, true, mode, false),
"has-alpha {mode:?}"
);
}
assert!(!warn_discarding_alpha(false, false, Straight, false));
assert!(warn_discarding_alpha(true, false, Straight, false));
assert!(!warn_discarding_alpha(true, false, Straight, true));
assert!(!warn_discarding_alpha(true, false, Premultiplied, false));
assert!(!warn_discarding_alpha(true, false, Opaque, false));
}
#[test]
fn alpha_less_output_keeps_requested_metadata() {
for target_alpha in [AlphaMode::Premultiplied, AlphaMode::Opaque] {
let mut data = Vec::new();
for value in [0.8f32, 0.4, 0.2, 0.5] {
data.extend_from_slice(&value.to_le_bytes());
}
let image = make_image(
data,
1,
1,
ktx2::Format::R32G32B32A32_SFLOAT,
ColorSpace::Linear,
AlphaMode::Straight,
);
let output = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Uncompressed(ktx2::Format::R32G32B32_SFLOAT)),
output_alpha: Some(target_alpha),
container: Container::Raw,
..Default::default()
},
)
.unwrap();
let PipelineOutput::Raw(image) = output else {
panic!("expected raw output");
};
assert_eq!(image.surfaces[0][0].alpha, target_alpha);
}
}
}
#[cfg(all(test, feature = "encoder-amd"))]
mod bc6h_alpha_tests {
use super::*;
use crate::surface::Surface;
fn solid_rgba_f32(r: f32, g: f32, b: f32, a: f32, alpha: AlphaMode) -> Image {
let mut data = Vec::with_capacity(4 * 4 * 16);
for _ in 0..(4 * 4) {
for c in [r, g, b, a] {
data.extend_from_slice(&c.to_le_bytes());
}
}
Image {
surfaces: vec![vec![Surface {
data,
width: 4,
height: 4,
depth: 1,
stride: 4 * 16,
slice_stride: 0,
format: ktx2::Format::R32G32B32A32_SFLOAT,
color_space: ColorSpace::Linear,
alpha,
}]],
kind: crate::TextureKind::Texture2D,
}
}
fn encode_bc6h_first_texel(image: Image, output_alpha: Option<AlphaMode>) -> [f32; 3] {
let out = convert(
image,
ConvertSettings {
format: Some(TargetFormat::Compressed {
format: ktx2::Format::BC6H_UFLOAT_BLOCK,
encoder: crate::encoders::Encoder::Auto,
}),
container: Container::Raw,
quality: Quality::UltraFast,
output_alpha,
..Default::default()
},
)
.unwrap();
let img = match out {
PipelineOutput::Raw(img) => img,
_ => panic!("expected Raw output"),
};
let block: [u8; 16] = img.surfaces[0][0].data.as_slice().try_into().unwrap();
let texels = ctt_compressonator::bc6h::decompress_block(&block).unwrap();
[
half::f16::from_bits(texels[0]).to_f32(),
half::f16::from_bits(texels[1]).to_f32(),
half::f16::from_bits(texels[2]).to_f32(),
]
}
fn assert_rgb_close(actual: [f32; 3], expected: [f32; 3]) {
for i in 0..3 {
assert!(
(actual[i] - expected[i]).abs() <= 0.02,
"channel {i}: got {}, expected {} (full {actual:?} vs {expected:?})",
actual[i],
expected[i],
);
}
}
#[test]
fn zero_alpha_straight_keeps_color() {
let img = solid_rgba_f32(4.0, 2.0, 1.0, 0.0, AlphaMode::Straight);
assert_rgb_close(encode_bc6h_first_texel(img, None), [4.0, 2.0, 1.0]);
}
#[test]
fn partial_alpha_straight_keeps_color() {
let img = solid_rgba_f32(4.0, 2.0, 1.0, 0.5, AlphaMode::Straight);
assert_rgb_close(encode_bc6h_first_texel(img, None), [4.0, 2.0, 1.0]);
}
#[test]
fn zero_alpha_opaque_source_keeps_color() {
let img = solid_rgba_f32(4.0, 2.0, 1.0, 0.0, AlphaMode::Opaque);
assert_rgb_close(encode_bc6h_first_texel(img, None), [4.0, 2.0, 1.0]);
}
#[test]
fn premultiplied_output_bakes_partial_alpha() {
let img = solid_rgba_f32(4.0, 2.0, 1.0, 0.5, AlphaMode::Straight);
let out = encode_bc6h_first_texel(img, Some(AlphaMode::Premultiplied));
assert_rgb_close(out, [2.0, 1.0, 0.5]);
}
#[test]
fn premultiplied_output_bakes_zero_alpha_to_black() {
let img = solid_rgba_f32(4.0, 2.0, 1.0, 0.0, AlphaMode::Straight);
let out = encode_bc6h_first_texel(img, Some(AlphaMode::Premultiplied));
assert_rgb_close(out, [0.0, 0.0, 0.0]);
}
}