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 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);
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() {
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),
Variant::F64 => convert_f64(image, settings, target_fmt, encoder_step),
Variant::U32 => convert_u32(image, settings, target_fmt, encoder_step),
Variant::U64 => convert_u64(image, settings, target_fmt, encoder_step),
}
}
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>,
) -> 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 base = layer
.into_iter()
.next()
.ok_or_else(|| Error::UnsupportedFormat("empty layer".into()))?;
let mut buf: Buffer<f32> = load::load_f32(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_f32(&mut buf, sw);
}
let bufs = if settings.mipmap {
mipmap::generate(buf, settings.mipmap_filter, settings.mipmap_count)?
} else {
vec![buf]
};
let mut mips = Vec::with_capacity(bufs.len());
for b in bufs {
mips.push(store::store_f32(
b,
target_fmt,
target_color_space,
target_alpha,
)?);
}
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>,
) -> 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 base in layer {
let mut buf = load::load_f64(&base)?;
if let Some(sw) = &settings.swizzle {
swizzle::apply_f64(&mut buf, sw);
}
mips.push(store::store_f64(
buf,
target_fmt,
target_color_space,
target_alpha,
)?);
}
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_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"),
}
}
#[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"),
}
}
}