From f28892cb16d2f12f84278bd8f829f20dc51219b2 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:15:00 -0400 Subject: [PATCH 01/21] WIP --- Cargo.toml | 9 +++- .../compressed_image_saver.md | 14 ++++++ .../release-notes/compressed_image_saver.md | 19 ++++++++ crates/bevy_image/Cargo.toml | 8 +++- .../bevy_image/src/compressed_image_saver.rs | 46 ++++++++++++++++++- crates/bevy_image/src/image.rs | 22 ++++----- crates/bevy_image/src/lib.rs | 10 +++- crates/bevy_internal/Cargo.toml | 7 ++- docs/cargo_features.md | 3 +- 9 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 _release-content/migration-guides/compressed_image_saver.md create mode 100644 _release-content/release-notes/compressed_image_saver.md diff --git a/Cargo.toml b/Cargo.toml index d3e301b23f43d..8b082adadeb07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -431,8 +431,13 @@ trace = ["bevy_internal/trace", "dep:tracing"] # Basis Universal compressed texture support basis-universal = ["bevy_internal/basis-universal"] -# Enables compressed KTX2 UASTC texture output on the asset processor -compressed_image_saver = ["bevy_internal/compressed_image_saver"] +# Texture compression asset processor (for web) +compressed_image_saver_web = ["bevy_internal/compressed_image_saver_web"] + +# Texture compression asset processor (for desktop) +compressed_image_saver_desktop = [ + "bevy_internal/compressed_image_saver_desktop", +] # BMP image format support bmp = ["bevy_internal/bmp"] diff --git a/_release-content/migration-guides/compressed_image_saver.md b/_release-content/migration-guides/compressed_image_saver.md new file mode 100644 index 0000000000000..c15a30710e1f7 --- /dev/null +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -0,0 +1,14 @@ +--- +title: Feature that broke +pull_requests: [14791, 15458, 15269] +--- + +Copy the contents of this file into a new file in `./migration-guides`, update the metadata, and add migration guide content here. + +Remember, your aim is to communicate: + +- What has changed since the last release? +- Why did we make this breaking change? +- How can users migrate their existing code? + +For more specifics about style and content, see the [instructions](./migration_guides.md). diff --git a/_release-content/release-notes/compressed_image_saver.md b/_release-content/release-notes/compressed_image_saver.md new file mode 100644 index 0000000000000..0a4110a5cb015 --- /dev/null +++ b/_release-content/release-notes/compressed_image_saver.md @@ -0,0 +1,19 @@ +--- +title: Feature name +authors: ["@FerrisTheCrab", "@BirdObsessed"] +pull_requests: [14791, 15458, 15269] +--- + +Copy the contents of this file into `./release-notes`, update the metadata, and add release note content here. + +## Goals + +Answer the following: + +- What has been changed or added? +- Why is this a big deal for users? +- How can they use it? + +## Style Guide + +You may use markdown headings levels two and three, and must not start with a heading. Prose is appreciated, but bullet points are acceptable. Copying the introduction from your PR is often a good place to start. diff --git a/crates/bevy_image/Cargo.toml b/crates/bevy_image/Cargo.toml index f45a92afee3db..0bf3ac5278932 100644 --- a/crates/bevy_image/Cargo.toml +++ b/crates/bevy_image/Cargo.toml @@ -45,8 +45,11 @@ zstd_rust = ["zstd", "dep:ruzstd"] # Binding to zstd C implementation (faster) zstd_c = ["zstd", "dep:zstd"] -# Enables compressed KTX2 UASTC texture output on the asset processor -compressed_image_saver = ["basis-universal"] +# Texture compression asset processor (for web) +compressed_image_saver_web = ["basis-universal"] + +# Texture compression asset processor (for desktop) +compressed_image_saver_desktop = ["dep:ctt", "ktx2"] [dependencies] # bevy @@ -88,6 +91,7 @@ ruzstd = { version = "0.8.0", optional = true } basis-universal = { version = "0.3.0", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } half = { version = "2.4.1" } +ctt = { version = "0.1", optional = true } [dev-dependencies] bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 6b6348a1a3c30..5b658585bdd30 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -8,11 +8,17 @@ use bevy_reflect::TypePath; use futures_lite::AsyncWriteExt; use thiserror::Error; -/// An [`AssetSaver`] that writes compressed basis universal (.ktx2) files. +/// An [`AssetSaver`] for [`Image`] that compresses texture files. +/// +/// Compressed textures both take up less space on disk, and use less VRAM. +/// +/// TODO: Document what platforms are supported, how feature flags work, +/// required native dependencies (https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prerequisites), +/// and what compression types exist #[derive(TypePath)] pub struct CompressedImageSaver; -/// Errors encountered when writing compressed images. +/// Errors encountered when writing compressed images via [`CompressedImageSaverError`]. #[non_exhaustive] #[derive(Debug, Error, TypePath)] pub enum CompressedImageSaverError { @@ -31,6 +37,42 @@ impl AssetSaver for CompressedImageSaver { type OutputLoader = ImageLoader; type Error = CompressedImageSaverError; + #[cfg(feature = "compressed_image_saver_desktop")] + async fn save( + &self, + writer: &mut bevy_asset::io::Writer, + image: SavedAsset<'_, '_, Self::Asset>, + _settings: &Self::Settings, + _asset_path: AssetPath<'_>, + ) -> Result { + let is_srgb = image.texture_descriptor.format.is_srgb(); + + let config = ctt::config::CompressConfig { + format: todo!(), + output_format: ctt::config::OutputFormat::Ktx2, + swizzle: None, + color_space: if is_srgb { + ctt::format::ColorSpace::Srgb + } else { + ctt::format::ColorSpace::Linear + }, + encode_settings: None, + }; + + let compressed_bytes = ctt::pipeline::run(&config, todo!())?; + writer.write_all(&compressed_bytes).await?; + + Ok(ImageLoaderSettings { + format: ImageFormatSetting::Format(ImageFormat::Ktx2), + is_srgb, + sampler: image.sampler.clone(), + asset_usage: image.asset_usage, + texture_format: None, + array_layout: None, + }) + } + + #[cfg(feature = "compressed_image_saver_web")] async fn save( &self, writer: &mut bevy_asset::io::Writer, diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index 456c61406f2b0..149b502a51dd1 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -219,21 +219,21 @@ impl Plugin for ImagePlugin { .insert(&TRANSPARENT_IMAGE_HANDLE, Image::transparent()) .unwrap(); - #[cfg(feature = "compressed_image_saver")] + #[cfg(any( + feature = "compressed_image_saver_desktop", + feature = "compressed_image_saver_web" + ))] if let Some(processor) = app .world() .get_resource::() { - processor.register_processor::, - crate::CompressedImageSaver, - >>(crate::CompressedImageSaver.into()); - processor.set_default_processor::, - crate::CompressedImageSaver, - >>("png"); + for file_extension in ["png", "jpeg", "jpg"] { + processor.set_default_processor::, + crate::CompressedImageSaver, + >>(file_extension); + } } app.preregister_asset_loader::(ImageLoader::SUPPORTED_FILE_EXTENSIONS); diff --git a/crates/bevy_image/src/lib.rs b/crates/bevy_image/src/lib.rs index 8f47a238a852b..32d13b1917825 100644 --- a/crates/bevy_image/src/lib.rs +++ b/crates/bevy_image/src/lib.rs @@ -24,7 +24,10 @@ mod serialized_image; pub use self::serialized_image::*; #[cfg(feature = "basis-universal")] mod basis; -#[cfg(feature = "compressed_image_saver")] +#[cfg(any( + feature = "compressed_image_saver_desktop", + feature = "compressed_image_saver_web" +))] mod compressed_image_saver; #[cfg(feature = "dds")] mod dds; @@ -40,7 +43,10 @@ mod saver; mod texture_atlas; mod texture_atlas_builder; -#[cfg(feature = "compressed_image_saver")] +#[cfg(any( + feature = "compressed_image_saver_desktop", + feature = "compressed_image_saver_web" +))] pub use compressed_image_saver::*; #[cfg(feature = "dds")] pub use dds::*; diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index a4f5c3d13d883..e11bbcb2bdcdd 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -29,8 +29,11 @@ detailed_trace = ["bevy_ecs/detailed_trace", "bevy_render?/detailed_trace"] sysinfo_plugin = ["bevy_diagnostic/sysinfo_plugin"] -# Enables compressed KTX2 UASTC texture output on the asset processor -compressed_image_saver = ["bevy_image/compressed_image_saver"] +# Texture compression asset processor (for web) +compressed_image_saver_web = ["bevy_image/compressed_image_saver_web"] + +# Texture compression asset processor (for desktop) +compressed_image_saver_desktop = ["bevy_image/compressed_image_saver_desktop"] # For ktx2 supercompression zlib = ["bevy_image/zlib"] diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 30b19f33a1ef2..1486b03c27f9e 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -110,7 +110,8 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio |bevy_winit|winit window and input backend| |bluenoise_texture|Include spatio-temporal blue noise KTX2 file used by generated environment maps, Solari and atmosphere| |bmp|BMP image format support| -|compressed_image_saver|Enables compressed KTX2 UASTC texture output on the asset processor| +|compressed_image_saver_desktop|Texture compression asset processor (for desktop)| +|compressed_image_saver_web|Texture compression asset processor (for web)| |critical-section|`critical-section` provides the building blocks for synchronization primitives on all platforms, including `no_std`.| |custom_cursor|Enable winit custom cursor support| |dds|DDS compressed texture support| From 18cfdbb7317c30fbb38903a70756b725bfc5bb9e Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:15:30 -0400 Subject: [PATCH 02/21] add todo --- crates/bevy_image/src/compressed_image_saver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 5b658585bdd30..3a5b22b1e63ab 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -14,7 +14,7 @@ use thiserror::Error; /// /// TODO: Document what platforms are supported, how feature flags work, /// required native dependencies (https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prerequisites), -/// and what compression types exist +/// what compression types exist, and mipmap generation? #[derive(TypePath)] pub struct CompressedImageSaver; From ef98abdeadae2351b0fdf4984c098b0a25449eea Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:57:05 -0400 Subject: [PATCH 03/21] WIP --- crates/bevy_image/src/compressed_image_saver.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 3a5b22b1e63ab..f52233ca275e5 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -18,13 +18,16 @@ use thiserror::Error; #[derive(TypePath)] pub struct CompressedImageSaver; -/// Errors encountered when writing compressed images via [`CompressedImageSaverError`]. +/// Errors encountered when writing compressed images via [`CompressedImageSaver`]. #[non_exhaustive] #[derive(Debug, Error, TypePath)] pub enum CompressedImageSaverError { /// I/O error. #[error(transparent)] Io(#[from] std::io::Error), + /// The underlying compression library returned an error. + #[error(transparent)] + CompressionFailed(Box), /// Attempted to save an image with uninitialized data. #[error("Cannot compress an uninitialized image")] UninitializedImage, @@ -47,6 +50,11 @@ impl AssetSaver for CompressedImageSaver { ) -> Result { let is_srgb = image.texture_descriptor.format.is_srgb(); + let layout = ctt::image::ImageLayout { + layers: todo!(), + is_cubemap: todo!(), + }; + let config = ctt::config::CompressConfig { format: todo!(), output_format: ctt::config::OutputFormat::Ktx2, @@ -59,7 +67,10 @@ impl AssetSaver for CompressedImageSaver { encode_settings: None, }; - let compressed_bytes = ctt::pipeline::run(&config, todo!())?; + let compressed_bytes = ctt::pipeline::run(&config, layout) + .await + .map_err(|e| CompressedImageSaver::CompressionFailed(Box::new(e)))?; + writer.write_all(&compressed_bytes).await?; Ok(ImageLoaderSettings { From ad31db5acf5b74d8a940fc4be2a42ff49b9b8355 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:00:32 -0400 Subject: [PATCH 04/21] Disable selector RDO for linear, based on tune_for_normal_maps --- crates/bevy_image/src/compressed_image_saver.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index f52233ca275e5..00ef912e35fb8 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -100,6 +100,7 @@ impl AssetSaver for CompressedImageSaver { let color_space = if is_srgb { basis_universal::ColorSpace::Srgb } else { + compressor_params.set_no_selector_rdo(true); basis_universal::ColorSpace::Linear }; compressor_params.set_color_space(color_space); From a0d83131e7b0d53d49ff193b9b8b5d8b0b4ef57a Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:35:29 -0400 Subject: [PATCH 05/21] WIP --- .../bevy_image/src/compressed_image_saver.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 00ef912e35fb8..0850c8e7b9e0e 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -48,13 +48,33 @@ impl AssetSaver for CompressedImageSaver { _settings: &Self::Settings, _asset_path: AssetPath<'_>, ) -> Result { - let is_srgb = image.texture_descriptor.format.is_srgb(); - - let layout = ctt::image::ImageLayout { - layers: todo!(), - is_cubemap: todo!(), + let Some(ref data) = image.data else { + return Err(CompressedImageSaverError::UninitializedImage); }; + let is_srgb = image.texture_descriptor.format.is_srgb(); + let is_cubemap = matches!( + image.texture_view_descriptor, + Some(wgpu_types::TextureViewDescriptor { + dimension: Some(wgpu_types::TextureViewDimension::Cube), + .. + }) + ); + + let layers = (0..image.texture_descriptor.array_layer_count()) + .into_iter() + .map(|layer| { + vec![ctt::image::RawImage { + data: todo!(), + width: image.width(), + height: image.height(), + stride: todo!(), + pixel_format: todo!(), + }]; + }) + .collect(); + let layout = ctt::image::ImageLayout { layers, is_cubemap }; + let config = ctt::config::CompressConfig { format: todo!(), output_format: ctt::config::OutputFormat::Ktx2, @@ -122,7 +142,9 @@ impl AssetSaver for CompressedImageSaver { // library bindings note that invalid params might produce undefined behavior. unsafe { compressor.init(&compressor_params); - compressor.process().unwrap(); + compressor + .process() + .map_err(|e| CompressedImageSaver::CompressionFailed(Box::new(e)))?; } compressor.basis_file().to_vec() }; From 23f25b2b8e0e7b3d97d34ae2f51b6c077cbe617f Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:37:03 -0400 Subject: [PATCH 06/21] Misc --- crates/bevy_image/src/compressed_image_saver.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 0850c8e7b9e0e..4cff61cb261ad 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -53,6 +53,12 @@ impl AssetSaver for CompressedImageSaver { }; let is_srgb = image.texture_descriptor.format.is_srgb(); + let color_space = if is_srgb { + ctt::format::ColorSpace::Srgb + } else { + ctt::format::ColorSpace::Linear + }; + let is_cubemap = matches!( image.texture_view_descriptor, Some(wgpu_types::TextureViewDescriptor { @@ -79,11 +85,7 @@ impl AssetSaver for CompressedImageSaver { format: todo!(), output_format: ctt::config::OutputFormat::Ktx2, swizzle: None, - color_space: if is_srgb { - ctt::format::ColorSpace::Srgb - } else { - ctt::format::ColorSpace::Linear - }, + color_space, encode_settings: None, }; From bd2ed87e7fccc6dda108fe511043409e6f2ceb1f Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:37:53 -0400 Subject: [PATCH 07/21] WIP --- crates/bevy_image/src/compressed_image_saver.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 4cff61cb261ad..b3a8cc7e62be1 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -75,7 +75,11 @@ impl AssetSaver for CompressedImageSaver { width: image.width(), height: image.height(), stride: todo!(), - pixel_format: todo!(), + pixel_format: ctt::format::PixelFormat { + components: todo!(), + channel_type: todo!(), + color_space, + }, }]; }) .collect(); From fa171ae6ef3b1b04054f1dfd23f744e8debd31f0 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:40:57 -0400 Subject: [PATCH 08/21] Assert --- crates/bevy_image/src/compressed_image_saver.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index b3a8cc7e62be1..ec853e5631669 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -27,7 +27,7 @@ pub enum CompressedImageSaverError { Io(#[from] std::io::Error), /// The underlying compression library returned an error. #[error(transparent)] - CompressionFailed(Box), + CompressionFailed(Box), /// Attempted to save an image with uninitialized data. #[error("Cannot compress an uninitialized image")] UninitializedImage, @@ -52,6 +52,12 @@ impl AssetSaver for CompressedImageSaver { return Err(CompressedImageSaverError::UninitializedImage); }; + if image.texture_descriptor.mip_level_count != 1 { + return Err(CompressedImageSaverError::CompressionFailed( + "Expected texture_descriptor.mip_level_count to be 1".into(), + )); + } + let is_srgb = image.texture_descriptor.format.is_srgb(); let color_space = if is_srgb { ctt::format::ColorSpace::Srgb From c2454e1ed272afcca6417a1eaa1b895498581a48 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:46:29 -0400 Subject: [PATCH 09/21] WIP --- crates/bevy_image/src/compressed_image_saver.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index ec853e5631669..980b13b2b96fa 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -82,7 +82,13 @@ impl AssetSaver for CompressedImageSaver { height: image.height(), stride: todo!(), pixel_format: ctt::format::PixelFormat { - components: todo!(), + components: match image.texture_descriptor.format.components() { + 1 => ctt::format::PixelComponents::R, + 2 => ctt::format::PixelComponents::Rg, + 3 => ctt::format::PixelComponents::Rgb, + 4 => ctt::format::PixelComponents::Rgba, + _ => unreachable!(), + }, channel_type: todo!(), color_space, }, From 542d427b902a20b4aaf55dad5a6f25f4ecae6e9e Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:56:15 -0400 Subject: [PATCH 10/21] WIP --- crates/bevy_image/Cargo.toml | 2 +- .../bevy_image/src/compressed_image_saver.rs | 323 ++++++++++++++++-- 2 files changed, 291 insertions(+), 34 deletions(-) diff --git a/crates/bevy_image/Cargo.toml b/crates/bevy_image/Cargo.toml index 0bf3ac5278932..21d8c190edc28 100644 --- a/crates/bevy_image/Cargo.toml +++ b/crates/bevy_image/Cargo.toml @@ -91,7 +91,7 @@ ruzstd = { version = "0.8.0", optional = true } basis-universal = { version = "0.3.0", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } half = { version = "2.4.1" } -ctt = { version = "0.1", optional = true } +ctt = { git = "https://github.com/cwfitzgerald/ctt", rev = "fd41d518a557803bd1eda1bcc6fbbcd4e07757c5", optional = true } [dev-dependencies] bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 980b13b2b96fa..c49462fe51cc7 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -1,12 +1,14 @@ use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoader, ImageLoaderSettings}; use bevy_asset::{ + io::Writer, saver::{AssetSaver, SavedAsset}, AssetPath, }; use bevy_reflect::TypePath; use futures_lite::AsyncWriteExt; use thiserror::Error; +use wgpu_types::TextureFormat; /// An [`AssetSaver`] for [`Image`] that compresses texture files. /// @@ -27,10 +29,13 @@ pub enum CompressedImageSaverError { Io(#[from] std::io::Error), /// The underlying compression library returned an error. #[error(transparent)] - CompressionFailed(Box), + CompressionFailed(Box), /// Attempted to save an image with uninitialized data. #[error("Cannot compress an uninitialized image")] UninitializedImage, + /// The texture format is not supported for compression. + #[error("Unsupported texture format for compression: {0:?}")] + UnsupportedFormat(TextureFormat), } impl AssetSaver for CompressedImageSaver { @@ -43,7 +48,7 @@ impl AssetSaver for CompressedImageSaver { #[cfg(feature = "compressed_image_saver_desktop")] async fn save( &self, - writer: &mut bevy_asset::io::Writer, + writer: &mut Writer, image: SavedAsset<'_, '_, Self::Asset>, _settings: &Self::Settings, _asset_path: AssetPath<'_>, @@ -58,11 +63,14 @@ impl AssetSaver for CompressedImageSaver { )); } + let input_format = map_to_ctt_texture_format(image.texture_descriptor.format)?; + let output_format = choose_ctt_compressed_format(image.texture_descriptor.format)?; + let is_srgb = image.texture_descriptor.format.is_srgb(); let color_space = if is_srgb { - ctt::format::ColorSpace::Srgb + ctt::ColorSpace::Srgb } else { - ctt::format::ColorSpace::Linear + ctt::ColorSpace::Linear }; let is_cubemap = matches!( @@ -73,43 +81,54 @@ impl AssetSaver for CompressedImageSaver { }) ); - let layers = (0..image.texture_descriptor.array_layer_count()) - .into_iter() - .map(|layer| { - vec![ctt::image::RawImage { - data: todo!(), + let bytes_per_pixel = + crate::TextureFormatPixelInfo::pixel_size(&image.texture_descriptor.format).map_err( + |_| CompressedImageSaverError::UnsupportedFormat(image.texture_descriptor.format), + )? as u32; + + let surfaces = data + .chunks_exact((image.width() * image.height() * bytes_per_pixel) as usize) + .map(|layer_data| { + vec![ctt::Surface { + data: layer_data.to_vec(), width: image.width(), height: image.height(), - stride: todo!(), - pixel_format: ctt::format::PixelFormat { - components: match image.texture_descriptor.format.components() { - 1 => ctt::format::PixelComponents::R, - 2 => ctt::format::PixelComponents::Rg, - 3 => ctt::format::PixelComponents::Rgb, - 4 => ctt::format::PixelComponents::Rgba, - _ => unreachable!(), - }, - channel_type: todo!(), - color_space, - }, - }]; + stride: image.width() * bytes_per_pixel, + format: input_format, + color_space, + alpha: ctt::AlphaMode::Straight, // TODO: User-configurable? + }] }) .collect(); - let layout = ctt::image::ImageLayout { layers, is_cubemap }; + let ctt_image = ctt::Image { + surfaces, + is_cubemap, + }; - let config = ctt::config::CompressConfig { - format: todo!(), - output_format: ctt::config::OutputFormat::Ktx2, + let settings = ctt::ConvertSettings { + format: Some(output_format), + container: ctt::Container::Ktx2, + quality: ctt::Quality::default(), + output_color_space: None, + output_alpha: None, swizzle: None, - color_space, - encode_settings: None, + mipmap: true, + mipmap_count: None, + mipmap_filter: ctt::MipmapFilter::default(), + allow_lossy: true, + encoder_settings: None, + registry: None, }; - let compressed_bytes = ctt::pipeline::run(&config, layout) - .await - .map_err(|e| CompressedImageSaver::CompressionFailed(Box::new(e)))?; + let output = ctt::convert(ctt_image, settings) + .map_err(|e| CompressedImageSaverError::CompressionFailed(Box::new(e)))?; + let ctt::ConvertOutput::Encoded(compressed_bytes) = &output else { + return Err(CompressedImageSaverError::CompressionFailed( + "Expected encoded output from ctt".into(), + )); + }; - writer.write_all(&compressed_bytes).await?; + writer.write_all(compressed_bytes).await?; Ok(ImageLoaderSettings { format: ImageFormatSetting::Format(ImageFormat::Ktx2), @@ -124,7 +143,7 @@ impl AssetSaver for CompressedImageSaver { #[cfg(feature = "compressed_image_saver_web")] async fn save( &self, - writer: &mut bevy_asset::io::Writer, + writer: &mut Writer, image: SavedAsset<'_, '_, Self::Asset>, _settings: &Self::Settings, _asset_path: AssetPath<'_>, @@ -178,3 +197,241 @@ impl AssetSaver for CompressedImageSaver { }) } } + +#[cfg(feature = "compressed_image_saver_desktop")] +fn choose_ctt_compressed_format( + input: TextureFormat, +) -> Result { + use ktx2::Format; + + let format = match input { + // 1-channel snorm -> BC4 snorm + TextureFormat::R8Snorm | TextureFormat::R16Snorm => Format::BC4_SNORM_BLOCK, + + // 1-channel -> BC4 + TextureFormat::R8Unorm + | TextureFormat::R8Uint + | TextureFormat::R8Sint + | TextureFormat::R16Uint + | TextureFormat::R16Sint + | TextureFormat::R16Unorm + | TextureFormat::R16Float + | TextureFormat::R32Uint + | TextureFormat::R32Sint + | TextureFormat::R32Float + | TextureFormat::R64Uint => Format::BC4_UNORM_BLOCK, + + // 2-channel snorm -> BC5 snorm + TextureFormat::Rg8Snorm | TextureFormat::Rg16Snorm => Format::BC5_SNORM_BLOCK, + + // 2-channel -> BC5 + TextureFormat::Rg8Unorm + | TextureFormat::Rg8Uint + | TextureFormat::Rg8Sint + | TextureFormat::Rg16Uint + | TextureFormat::Rg16Sint + | TextureFormat::Rg16Unorm + | TextureFormat::Rg16Float + | TextureFormat::Rg32Uint + | TextureFormat::Rg32Sint + | TextureFormat::Rg32Float => Format::BC5_UNORM_BLOCK, + + // HDR / float RGB formats -> BC6H + TextureFormat::Rgb9e5Ufloat + | TextureFormat::Rg11b10Ufloat + | TextureFormat::Rgba16Float + | TextureFormat::Rgba32Float => Format::BC6H_UFLOAT_BLOCK, + + // 4-channel LDR -> BC7 + TextureFormat::Rgba8Unorm + | TextureFormat::Rgba8Uint + | TextureFormat::Rgba8Sint + | TextureFormat::Rgba8Snorm + | TextureFormat::Rgba16Uint + | TextureFormat::Rgba16Sint + | TextureFormat::Rgba16Unorm + | TextureFormat::Rgba16Snorm + | TextureFormat::Rgba32Uint + | TextureFormat::Rgba32Sint + | TextureFormat::Bgra8Unorm + | TextureFormat::Rgb10a2Uint + | TextureFormat::Rgb10a2Unorm => Format::BC7_UNORM_BLOCK, + TextureFormat::Rgba8UnormSrgb | TextureFormat::Bgra8UnormSrgb => Format::BC7_SRGB_BLOCK, + + // Already compressed -> pass through + TextureFormat::Bc1RgbaUnorm + | TextureFormat::Bc1RgbaUnormSrgb + | TextureFormat::Bc2RgbaUnorm + | TextureFormat::Bc2RgbaUnormSrgb + | TextureFormat::Bc3RgbaUnorm + | TextureFormat::Bc3RgbaUnormSrgb + | TextureFormat::Bc4RUnorm + | TextureFormat::Bc4RSnorm + | TextureFormat::Bc5RgUnorm + | TextureFormat::Bc5RgSnorm + | TextureFormat::Bc6hRgbUfloat + | TextureFormat::Bc6hRgbFloat + | TextureFormat::Bc7RgbaUnorm + | TextureFormat::Bc7RgbaUnormSrgb + | TextureFormat::Etc2Rgb8Unorm + | TextureFormat::Etc2Rgb8UnormSrgb + | TextureFormat::Etc2Rgb8A1Unorm + | TextureFormat::Etc2Rgb8A1UnormSrgb + | TextureFormat::Etc2Rgba8Unorm + | TextureFormat::Etc2Rgba8UnormSrgb + | TextureFormat::EacR11Unorm + | TextureFormat::EacR11Snorm + | TextureFormat::EacRg11Unorm + | TextureFormat::EacRg11Snorm + | TextureFormat::Astc { .. } => map_to_ctt_texture_format(input)?, + + // Depth/stencil and video formats cannot be compressed + TextureFormat::Stencil8 + | TextureFormat::Depth16Unorm + | TextureFormat::Depth24Plus + | TextureFormat::Depth24PlusStencil8 + | TextureFormat::Depth32Float + | TextureFormat::Depth32FloatStencil8 + | TextureFormat::NV12 + | TextureFormat::P010 => { + return Err(CompressedImageSaverError::UnsupportedFormat(input)); + } + }; + + Ok(ctt::TargetFormat::Compressed { + encoder_name: None, + format, + }) +} + +#[cfg(feature = "compressed_image_saver_desktop")] +fn map_to_ctt_texture_format( + input: TextureFormat, +) -> Result { + use ctt::Format; + use wgpu_types::{AstcBlock, AstcChannel, TextureFormat}; + + Ok(match input { + TextureFormat::R8Unorm => Format::R8_UNORM, + TextureFormat::R8Snorm => Format::R8_SNORM, + TextureFormat::R8Uint => Format::R8_UINT, + TextureFormat::R8Sint => Format::R8_SINT, + TextureFormat::R16Uint => Format::R16_UINT, + TextureFormat::R16Sint => Format::R16_SINT, + TextureFormat::R16Unorm => Format::R16_UNORM, + TextureFormat::R16Snorm => Format::R16_SNORM, + TextureFormat::R16Float => Format::R16_SFLOAT, + TextureFormat::Rg8Unorm => Format::R8G8_UNORM, + TextureFormat::Rg8Snorm => Format::R8G8_SNORM, + TextureFormat::Rg8Uint => Format::R8G8_UINT, + TextureFormat::Rg8Sint => Format::R8G8_SINT, + TextureFormat::R32Uint => Format::R32_UINT, + TextureFormat::R32Sint => Format::R32_SINT, + TextureFormat::R32Float => Format::R32_SFLOAT, + TextureFormat::Rg16Uint => Format::R16G16_UINT, + TextureFormat::Rg16Sint => Format::R16G16_SINT, + TextureFormat::Rg16Unorm => Format::R16G16_UNORM, + TextureFormat::Rg16Snorm => Format::R16G16_SNORM, + TextureFormat::Rg16Float => Format::R16G16_SFLOAT, + TextureFormat::Rgba8Unorm => Format::R8G8B8A8_UNORM, + TextureFormat::Rgba8UnormSrgb => Format::R8G8B8A8_SRGB, + TextureFormat::Rgba8Snorm => Format::R8G8B8A8_SNORM, + TextureFormat::Rgba8Uint => Format::R8G8B8A8_UINT, + TextureFormat::Rgba8Sint => Format::R8G8B8A8_SINT, + TextureFormat::Bgra8Unorm => Format::B8G8R8A8_UNORM, + TextureFormat::Bgra8UnormSrgb => Format::B8G8R8A8_SRGB, + TextureFormat::Rgb9e5Ufloat => Format::E5B9G9R9_UFLOAT_PACK32, + TextureFormat::Rgb10a2Uint => Format::A2B10G10R10_UINT_PACK32, + TextureFormat::Rgb10a2Unorm => Format::A2B10G10R10_UNORM_PACK32, + TextureFormat::Rg11b10Ufloat => Format::B10G11R11_UFLOAT_PACK32, + TextureFormat::R64Uint => Format::R64_UINT, + TextureFormat::Rg32Uint => Format::R32G32_UINT, + TextureFormat::Rg32Sint => Format::R32G32_SINT, + TextureFormat::Rg32Float => Format::R32G32_SFLOAT, + TextureFormat::Rgba16Uint => Format::R16G16B16A16_UINT, + TextureFormat::Rgba16Sint => Format::R16G16B16A16_SINT, + TextureFormat::Rgba16Unorm => Format::R16G16B16A16_UNORM, + TextureFormat::Rgba16Snorm => Format::R16G16B16A16_SNORM, + TextureFormat::Rgba16Float => Format::R16G16B16A16_SFLOAT, + TextureFormat::Rgba32Uint => Format::R32G32B32A32_UINT, + TextureFormat::Rgba32Sint => Format::R32G32B32A32_SINT, + TextureFormat::Rgba32Float => Format::R32G32B32A32_SFLOAT, + TextureFormat::Stencil8 => Format::S8_UINT, + TextureFormat::Depth16Unorm => Format::D16_UNORM, + TextureFormat::Depth24Plus => Format::X8_D24_UNORM_PACK32, + TextureFormat::Depth24PlusStencil8 => Format::D24_UNORM_S8_UINT, + TextureFormat::Depth32Float => Format::D32_SFLOAT, + TextureFormat::Depth32FloatStencil8 => Format::D32_SFLOAT_S8_UINT, + TextureFormat::NV12 | TextureFormat::P010 => { + return Err(CompressedImageSaverError::UnsupportedFormat(input)); + } + TextureFormat::Bc1RgbaUnorm => Format::BC1_RGBA_UNORM_BLOCK, + TextureFormat::Bc1RgbaUnormSrgb => Format::BC1_RGBA_SRGB_BLOCK, + TextureFormat::Bc2RgbaUnorm => Format::BC2_UNORM_BLOCK, + TextureFormat::Bc2RgbaUnormSrgb => Format::BC2_SRGB_BLOCK, + TextureFormat::Bc3RgbaUnorm => Format::BC3_UNORM_BLOCK, + TextureFormat::Bc3RgbaUnormSrgb => Format::BC3_SRGB_BLOCK, + TextureFormat::Bc4RUnorm => Format::BC4_UNORM_BLOCK, + TextureFormat::Bc4RSnorm => Format::BC4_SNORM_BLOCK, + TextureFormat::Bc5RgUnorm => Format::BC5_UNORM_BLOCK, + TextureFormat::Bc5RgSnorm => Format::BC5_SNORM_BLOCK, + TextureFormat::Bc6hRgbUfloat => Format::BC6H_UFLOAT_BLOCK, + TextureFormat::Bc6hRgbFloat => Format::BC6H_SFLOAT_BLOCK, + TextureFormat::Bc7RgbaUnorm => Format::BC7_UNORM_BLOCK, + TextureFormat::Bc7RgbaUnormSrgb => Format::BC7_SRGB_BLOCK, + TextureFormat::Etc2Rgb8Unorm => Format::ETC2_R8G8B8_UNORM_BLOCK, + TextureFormat::Etc2Rgb8UnormSrgb => Format::ETC2_R8G8B8_SRGB_BLOCK, + TextureFormat::Etc2Rgb8A1Unorm => Format::ETC2_R8G8B8A1_UNORM_BLOCK, + TextureFormat::Etc2Rgb8A1UnormSrgb => Format::ETC2_R8G8B8A1_SRGB_BLOCK, + TextureFormat::Etc2Rgba8Unorm => Format::ETC2_R8G8B8A8_UNORM_BLOCK, + TextureFormat::Etc2Rgba8UnormSrgb => Format::ETC2_R8G8B8A8_SRGB_BLOCK, + TextureFormat::EacR11Unorm => Format::EAC_R11_UNORM_BLOCK, + TextureFormat::EacR11Snorm => Format::EAC_R11_SNORM_BLOCK, + TextureFormat::EacRg11Unorm => Format::EAC_R11G11_UNORM_BLOCK, + TextureFormat::EacRg11Snorm => Format::EAC_R11G11_SNORM_BLOCK, + TextureFormat::Astc { block, channel } => match (block, channel) { + (AstcBlock::B4x4, AstcChannel::Unorm) => Format::ASTC_4x4_UNORM_BLOCK, + (AstcBlock::B4x4, AstcChannel::UnormSrgb) => Format::ASTC_4x4_SRGB_BLOCK, + (AstcBlock::B4x4, AstcChannel::Hdr) => Format::ASTC_4x4_SFLOAT_BLOCK, + (AstcBlock::B5x4, AstcChannel::Unorm) => Format::ASTC_5x4_UNORM_BLOCK, + (AstcBlock::B5x4, AstcChannel::UnormSrgb) => Format::ASTC_5x4_SRGB_BLOCK, + (AstcBlock::B5x4, AstcChannel::Hdr) => Format::ASTC_5x4_SFLOAT_BLOCK, + (AstcBlock::B5x5, AstcChannel::Unorm) => Format::ASTC_5x5_UNORM_BLOCK, + (AstcBlock::B5x5, AstcChannel::UnormSrgb) => Format::ASTC_5x5_SRGB_BLOCK, + (AstcBlock::B5x5, AstcChannel::Hdr) => Format::ASTC_5x5_SFLOAT_BLOCK, + (AstcBlock::B6x5, AstcChannel::Unorm) => Format::ASTC_6x5_UNORM_BLOCK, + (AstcBlock::B6x5, AstcChannel::UnormSrgb) => Format::ASTC_6x5_SRGB_BLOCK, + (AstcBlock::B6x5, AstcChannel::Hdr) => Format::ASTC_6x5_SFLOAT_BLOCK, + (AstcBlock::B6x6, AstcChannel::Unorm) => Format::ASTC_6x6_UNORM_BLOCK, + (AstcBlock::B6x6, AstcChannel::UnormSrgb) => Format::ASTC_6x6_SRGB_BLOCK, + (AstcBlock::B6x6, AstcChannel::Hdr) => Format::ASTC_6x6_SFLOAT_BLOCK, + (AstcBlock::B8x5, AstcChannel::Unorm) => Format::ASTC_8x5_UNORM_BLOCK, + (AstcBlock::B8x5, AstcChannel::UnormSrgb) => Format::ASTC_8x5_SRGB_BLOCK, + (AstcBlock::B8x5, AstcChannel::Hdr) => Format::ASTC_8x5_SFLOAT_BLOCK, + (AstcBlock::B8x6, AstcChannel::Unorm) => Format::ASTC_8x6_UNORM_BLOCK, + (AstcBlock::B8x6, AstcChannel::UnormSrgb) => Format::ASTC_8x6_SRGB_BLOCK, + (AstcBlock::B8x6, AstcChannel::Hdr) => Format::ASTC_8x6_SFLOAT_BLOCK, + (AstcBlock::B8x8, AstcChannel::Unorm) => Format::ASTC_8x8_UNORM_BLOCK, + (AstcBlock::B8x8, AstcChannel::UnormSrgb) => Format::ASTC_8x8_SRGB_BLOCK, + (AstcBlock::B8x8, AstcChannel::Hdr) => Format::ASTC_8x8_SFLOAT_BLOCK, + (AstcBlock::B10x5, AstcChannel::Unorm) => Format::ASTC_10x5_UNORM_BLOCK, + (AstcBlock::B10x5, AstcChannel::UnormSrgb) => Format::ASTC_10x5_SRGB_BLOCK, + (AstcBlock::B10x5, AstcChannel::Hdr) => Format::ASTC_10x5_SFLOAT_BLOCK, + (AstcBlock::B10x6, AstcChannel::Unorm) => Format::ASTC_10x6_UNORM_BLOCK, + (AstcBlock::B10x6, AstcChannel::UnormSrgb) => Format::ASTC_10x6_SRGB_BLOCK, + (AstcBlock::B10x6, AstcChannel::Hdr) => Format::ASTC_10x6_SFLOAT_BLOCK, + (AstcBlock::B10x8, AstcChannel::Unorm) => Format::ASTC_10x8_UNORM_BLOCK, + (AstcBlock::B10x8, AstcChannel::UnormSrgb) => Format::ASTC_10x8_SRGB_BLOCK, + (AstcBlock::B10x8, AstcChannel::Hdr) => Format::ASTC_10x8_SFLOAT_BLOCK, + (AstcBlock::B10x10, AstcChannel::Unorm) => Format::ASTC_10x10_UNORM_BLOCK, + (AstcBlock::B10x10, AstcChannel::UnormSrgb) => Format::ASTC_10x10_SRGB_BLOCK, + (AstcBlock::B10x10, AstcChannel::Hdr) => Format::ASTC_10x10_SFLOAT_BLOCK, + (AstcBlock::B12x10, AstcChannel::Unorm) => Format::ASTC_12x10_UNORM_BLOCK, + (AstcBlock::B12x10, AstcChannel::UnormSrgb) => Format::ASTC_12x10_SRGB_BLOCK, + (AstcBlock::B12x10, AstcChannel::Hdr) => Format::ASTC_12x10_SFLOAT_BLOCK, + (AstcBlock::B12x12, AstcChannel::Unorm) => Format::ASTC_12x12_UNORM_BLOCK, + (AstcBlock::B12x12, AstcChannel::UnormSrgb) => Format::ASTC_12x12_SRGB_BLOCK, + (AstcBlock::B12x12, AstcChannel::Hdr) => Format::ASTC_12x12_SFLOAT_BLOCK, + }, + }) +} From a027777a2dbf1a8ec07aa44ab9f71502dc209c5c Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:58:53 -0400 Subject: [PATCH 11/21] Add TODO --- crates/bevy_image/src/compressed_image_saver.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index c49462fe51cc7..02fbf9a578a1f 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -204,6 +204,7 @@ fn choose_ctt_compressed_format( ) -> Result { use ktx2::Format; + // TODO: ASTC support let format = match input { // 1-channel snorm -> BC4 snorm TextureFormat::R8Snorm | TextureFormat::R16Snorm => Format::BC4_SNORM_BLOCK, From f1346ba4dc215c33468341bdde6ae394414e06b3 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:59:07 -0400 Subject: [PATCH 12/21] Misc --- crates/bevy_image/src/compressed_image_saver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 02fbf9a578a1f..99634e6cdc744 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -310,7 +310,7 @@ fn map_to_ctt_texture_format( input: TextureFormat, ) -> Result { use ctt::Format; - use wgpu_types::{AstcBlock, AstcChannel, TextureFormat}; + use wgpu_types::{AstcBlock, AstcChannel}; Ok(match input { TextureFormat::R8Unorm => Format::R8_UNORM, From 48f9c44dfab4f76d6c3c365d0ec6cdd41ca9b1b0 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:14:53 -0400 Subject: [PATCH 13/21] Docs --- .../bevy_image/src/compressed_image_saver.rs | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 99634e6cdc744..b54b971bdc923 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -12,11 +12,55 @@ use wgpu_types::TextureFormat; /// An [`AssetSaver`] for [`Image`] that compresses texture files. /// -/// Compressed textures both take up less space on disk, and use less VRAM. +/// Compressed textures both take up less space on disk, and use less GPU VRAM. /// -/// TODO: Document what platforms are supported, how feature flags work, -/// required native dependencies (https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prerequisites), -/// what compression types exist, and mipmap generation? +/// Mipmaps are also generated, which prevents aliasing when textures are viewed at a distance, +/// and increases GPU cache hits, improving rendering performance. +/// +/// # Platform support +/// +/// Two mutually exclusive feature flags control which compression backend is used: +/// +/// - **`compressed_image_saver_desktop`** — Uses the [`ctt`](https://github.com/cwfitzgerald/ctt) +/// library to compress textures into BCn formats, output as KTX2. Requires a C++ compiler; +/// see the [ctt readme](https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prebuilt-binaries). +/// Best for desktop (Windows, macOS, Linux) where BCn hardware support is universal. +/// +/// - **`compressed_image_saver_web`** — Uses [`basis-universal`] to compress textures into UASTC +/// (Basis Universal) format. This is a GPU-agnostic supercompressed format that can be +/// transcoded at load time to whatever format the target GPU supports, making it suitable for +/// WebGPU and cross-platform distribution. +/// +/// # Runtime feature flags +/// +/// The compressed output must also be loadable at runtime. Enable the corresponding feature: +/// +/// - **`ktx2`** — Required to load KTX2 files produced by the desktop backend. +/// - **`basis-universal`** — Required to load Basis Universal files produced by the web backend. +/// +/// # Compression format selection (desktop) +/// +/// The output format is chosen automatically based on the input texture's channel count and type: +/// +/// | Input channels | Output format | +/// |---|---| +/// | 1-channel (R) | BC4 | +/// | 1-channel snorm | BC4 snorm | +/// | 2-channel (RG) | BC5 | +/// | 2-channel snorm | BC5 snorm | +/// | HDR / float (e.g. `Rgba16Float`) | BC6H | +/// | 4-channel LDR (e.g. `Rgba8Unorm`) | BC7 | +/// | 4-channel sRGB (e.g. `Rgba8UnormSrgb`) | BC7 sRGB | +/// | Already compressed (BCn, ETC2, EAC, ASTC) | Re-encoded to the same format | +/// +/// Depth, stencil, and video formats (`NV12`, `P010`) are not supported and will return +/// [`CompressedImageSaverError::UnsupportedFormat`]. +/// +/// # Mipmap generation +/// +/// Mipmaps are generated automatically during compression. The desktop backend requires +/// input images to have a `mip_level_count` of 1 (i.e., no pre-existing mip chain); +/// the compressor will produce a full mip chain in the output. #[derive(TypePath)] pub struct CompressedImageSaver; @@ -181,7 +225,7 @@ impl AssetSaver for CompressedImageSaver { compressor.init(&compressor_params); compressor .process() - .map_err(|e| CompressedImageSaver::CompressionFailed(Box::new(e)))?; + .map_err(|e| CompressedImageSaverError::CompressionFailed(Box::new(e)))?; } compressor.basis_file().to_vec() }; From 5b90771310aed8d216ef3d8c2ff02ed844ee1f68 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:03:45 -0400 Subject: [PATCH 14/21] Fix docs --- crates/bevy_image/src/compressed_image_saver.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index b54b971bdc923..4ce036d09eccd 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -12,7 +12,7 @@ use wgpu_types::TextureFormat; /// An [`AssetSaver`] for [`Image`] that compresses texture files. /// -/// Compressed textures both take up less space on disk, and use less GPU VRAM. +/// Compressed textures use less GPU VRAM. /// /// Mipmaps are also generated, which prevents aliasing when textures are viewed at a distance, /// and increases GPU cache hits, improving rendering performance. @@ -26,7 +26,7 @@ use wgpu_types::TextureFormat; /// see the [ctt readme](https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prebuilt-binaries). /// Best for desktop (Windows, macOS, Linux) where BCn hardware support is universal. /// -/// - **`compressed_image_saver_web`** — Uses [`basis-universal`] to compress textures into UASTC +/// - **`compressed_image_saver_web`** — Uses `basis-universal` to compress textures into UASTC /// (Basis Universal) format. This is a GPU-agnostic supercompressed format that can be /// transcoded at load time to whatever format the target GPU supports, making it suitable for /// WebGPU and cross-platform distribution. @@ -51,7 +51,7 @@ use wgpu_types::TextureFormat; /// | HDR / float (e.g. `Rgba16Float`) | BC6H | /// | 4-channel LDR (e.g. `Rgba8Unorm`) | BC7 | /// | 4-channel sRGB (e.g. `Rgba8UnormSrgb`) | BC7 sRGB | -/// | Already compressed (BCn, ETC2, EAC, ASTC) | Re-encoded to the same format | +/// | Already compressed (BCn, ASTC, ETC2, EAC) | Re-encoded to the same format | /// /// Depth, stencil, and video formats (`NV12`, `P010`) are not supported and will return /// [`CompressedImageSaverError::UnsupportedFormat`]. From f92fb42256687304aaf87c9fbca6e98c3ee08008 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:23:49 -0400 Subject: [PATCH 15/21] ASTC support --- Cargo.toml | 12 +- crates/bevy_image/Cargo.toml | 8 +- .../bevy_image/src/compressed_image_saver.rs | 213 +++++++++++++++--- crates/bevy_image/src/image.rs | 4 +- crates/bevy_image/src/lib.rs | 8 +- crates/bevy_internal/Cargo.toml | 10 +- docs/cargo_features.md | 4 +- 7 files changed, 210 insertions(+), 49 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 31f26c4c682b0..c7f90aa4d4dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -431,14 +431,14 @@ trace = ["bevy_internal/trace", "dep:tracing"] # Basis Universal compressed texture support basis-universal = ["bevy_internal/basis-universal"] -# Texture compression asset processor (for web) -compressed_image_saver_web = ["bevy_internal/compressed_image_saver_web"] - -# Texture compression asset processor (for desktop) -compressed_image_saver_desktop = [ - "bevy_internal/compressed_image_saver_desktop", +# Texture compression asset processor (cross-platform, transcodes to any GPU format at load time) +compressed_image_saver_universal = [ + "bevy_internal/compressed_image_saver_universal", ] +# Texture compression asset processor (BCn for desktop, ASTC for mobile via env var) +compressed_image_saver = ["bevy_internal/compressed_image_saver"] + # BMP image format support bmp = ["bevy_internal/bmp"] diff --git a/crates/bevy_image/Cargo.toml b/crates/bevy_image/Cargo.toml index 21d8c190edc28..3d3e3ea92dc8a 100644 --- a/crates/bevy_image/Cargo.toml +++ b/crates/bevy_image/Cargo.toml @@ -45,11 +45,11 @@ zstd_rust = ["zstd", "dep:ruzstd"] # Binding to zstd C implementation (faster) zstd_c = ["zstd", "dep:zstd"] -# Texture compression asset processor (for web) -compressed_image_saver_web = ["basis-universal"] +# Texture compression asset processor (cross-platform, transcodes to any GPU format at load time) +compressed_image_saver_universal = ["basis-universal"] -# Texture compression asset processor (for desktop) -compressed_image_saver_desktop = ["dep:ctt", "ktx2"] +# Texture compression asset processor (BCn for desktop, ASTC for mobile via env var) +compressed_image_saver = ["dep:ctt", "ktx2"] [dependencies] # bevy diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 4ce036d09eccd..18f36bebde717 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -21,12 +21,13 @@ use wgpu_types::TextureFormat; /// /// Two mutually exclusive feature flags control which compression backend is used: /// -/// - **`compressed_image_saver_desktop`** — Uses the [`ctt`](https://github.com/cwfitzgerald/ctt) -/// library to compress textures into BCn formats, output as KTX2. Requires a C++ compiler; -/// see the [ctt readme](https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prebuilt-binaries). -/// Best for desktop (Windows, macOS, Linux) where BCn hardware support is universal. +/// - **`compressed_image_saver`** — Uses the [`ctt`](https://github.com/cwfitzgerald/ctt) +/// library to compress textures into BCn or ASTC formats, output as KTX2. Requires a C++ +/// compiler; see the [ctt readme](https://github.com/cwfitzgerald/ctt?tab=readme-ov-file#prebuilt-binaries). +/// Outputs BCn by default (for desktop targets). Set +/// `BEVY_COMPRESSED_IMAGE_SAVER_ASTC` to output ASTC instead (for mobile targets). /// -/// - **`compressed_image_saver_web`** — Uses `basis-universal` to compress textures into UASTC +/// - **`compressed_image_saver_universal`** — Uses `basis-universal` to compress textures into UASTC /// (Basis Universal) format. This is a GPU-agnostic supercompressed format that can be /// transcoded at load time to whatever format the target GPU supports, making it suitable for /// WebGPU and cross-platform distribution. @@ -35,10 +36,10 @@ use wgpu_types::TextureFormat; /// /// The compressed output must also be loadable at runtime. Enable the corresponding feature: /// -/// - **`ktx2`** — Required to load KTX2 files produced by the desktop backend. -/// - **`basis-universal`** — Required to load Basis Universal files produced by the web backend. +/// - **`ktx2`** — Required to load KTX2 files produced by `compressed_image_saver`. +/// - **`basis-universal`** — Required to load Basis Universal files produced by `compressed_image_saver_universal`. /// -/// # Compression format selection (desktop) +/// # Compression format selection /// /// The output format is chosen automatically based on the input texture's channel count and type: /// @@ -56,9 +57,28 @@ use wgpu_types::TextureFormat; /// Depth, stencil, and video formats (`NV12`, `P010`) are not supported and will return /// [`CompressedImageSaverError::UnsupportedFormat`]. /// +/// # ASTC override +/// +/// Set the `BEVY_COMPRESSED_IMAGE_SAVER_ASTC` environment variable to compress into ASTC +/// instead of BCn. ASTC is natively supported on mobile GPUs (Android, iOS) and some +/// desktop GPUs, while BCn is typically only supported on desktop GPUs. +/// +/// The value specifies the block size. Larger blocks compress more aggressively (smaller +/// files, less VRAM) at the cost of quality. If set to an empty string or `1`, defaults +/// to `4x4`. +/// +/// | Block size | Bits per pixel | Notes | +/// |---|---|---| +/// | `4x4` | 8.00 | Highest quality, same bit rate as BC7 | +/// | `6x6` | 3.56 | Good balance of quality and size | +/// | `8x8` | 2.00 | Aggressive, suitable for base_color_texture | +/// +/// All 14 ASTC block sizes are supported: `4x4`, `5x4`, `5x5`, `6x5`, `6x6`, `8x5`, +/// `8x6`, `8x8`, `10x5`, `10x6`, `10x8`, `10x10`, `12x10`, `12x12`. +/// /// # Mipmap generation /// -/// Mipmaps are generated automatically during compression. The desktop backend requires +/// Mipmaps are generated automatically during compression. The `compressed_image_saver` feature requires /// input images to have a `mip_level_count` of 1 (i.e., no pre-existing mip chain); /// the compressor will produce a full mip chain in the output. #[derive(TypePath)] @@ -89,7 +109,7 @@ impl AssetSaver for CompressedImageSaver { type OutputLoader = ImageLoader; type Error = CompressedImageSaverError; - #[cfg(feature = "compressed_image_saver_desktop")] + #[cfg(feature = "compressed_image_saver")] async fn save( &self, writer: &mut Writer, @@ -184,7 +204,7 @@ impl AssetSaver for CompressedImageSaver { }) } - #[cfg(feature = "compressed_image_saver_web")] + #[cfg(feature = "compressed_image_saver_universal")] async fn save( &self, writer: &mut Writer, @@ -242,18 +262,121 @@ impl AssetSaver for CompressedImageSaver { } } -#[cfg(feature = "compressed_image_saver_desktop")] +/// Returns `Some((unorm, srgb, hdr))` ASTC format triple if the env var is set, `None` otherwise. +#[cfg(feature = "compressed_image_saver")] +fn parse_astc_env_var( +) -> Result, CompressedImageSaverError> { + use ktx2::Format; + + let val = match std::env::var("BEVY_COMPRESSED_IMAGE_SAVER_ASTC") { + Ok(v) => v, + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(std::env::VarError::NotUnicode(_)) => return Ok(None), + }; + + let val = val.trim(); + let (unorm, srgb, hdr) = match val { + "" | "1" | "4x4" => ( + Format::ASTC_4x4_UNORM_BLOCK, + Format::ASTC_4x4_SRGB_BLOCK, + Format::ASTC_4x4_SFLOAT_BLOCK, + ), + "5x4" => ( + Format::ASTC_5x4_UNORM_BLOCK, + Format::ASTC_5x4_SRGB_BLOCK, + Format::ASTC_5x4_SFLOAT_BLOCK, + ), + "5x5" => ( + Format::ASTC_5x5_UNORM_BLOCK, + Format::ASTC_5x5_SRGB_BLOCK, + Format::ASTC_5x5_SFLOAT_BLOCK, + ), + "6x5" => ( + Format::ASTC_6x5_UNORM_BLOCK, + Format::ASTC_6x5_SRGB_BLOCK, + Format::ASTC_6x5_SFLOAT_BLOCK, + ), + "6x6" => ( + Format::ASTC_6x6_UNORM_BLOCK, + Format::ASTC_6x6_SRGB_BLOCK, + Format::ASTC_6x6_SFLOAT_BLOCK, + ), + "8x5" => ( + Format::ASTC_8x5_UNORM_BLOCK, + Format::ASTC_8x5_SRGB_BLOCK, + Format::ASTC_8x5_SFLOAT_BLOCK, + ), + "8x6" => ( + Format::ASTC_8x6_UNORM_BLOCK, + Format::ASTC_8x6_SRGB_BLOCK, + Format::ASTC_8x6_SFLOAT_BLOCK, + ), + "8x8" => ( + Format::ASTC_8x8_UNORM_BLOCK, + Format::ASTC_8x8_SRGB_BLOCK, + Format::ASTC_8x8_SFLOAT_BLOCK, + ), + "10x5" => ( + Format::ASTC_10x5_UNORM_BLOCK, + Format::ASTC_10x5_SRGB_BLOCK, + Format::ASTC_10x5_SFLOAT_BLOCK, + ), + "10x6" => ( + Format::ASTC_10x6_UNORM_BLOCK, + Format::ASTC_10x6_SRGB_BLOCK, + Format::ASTC_10x6_SFLOAT_BLOCK, + ), + "10x8" => ( + Format::ASTC_10x8_UNORM_BLOCK, + Format::ASTC_10x8_SRGB_BLOCK, + Format::ASTC_10x8_SFLOAT_BLOCK, + ), + "10x10" => ( + Format::ASTC_10x10_UNORM_BLOCK, + Format::ASTC_10x10_SRGB_BLOCK, + Format::ASTC_10x10_SFLOAT_BLOCK, + ), + "12x10" => ( + Format::ASTC_12x10_UNORM_BLOCK, + Format::ASTC_12x10_SRGB_BLOCK, + Format::ASTC_12x10_SFLOAT_BLOCK, + ), + "12x12" => ( + Format::ASTC_12x12_UNORM_BLOCK, + Format::ASTC_12x12_SRGB_BLOCK, + Format::ASTC_12x12_SFLOAT_BLOCK, + ), + other => { + return Err(CompressedImageSaverError::CompressionFailed( + format!("Invalid BEVY_COMPRESSED_IMAGE_SAVER_ASTC block size: {other:?}. \ + Expected one of: 4x4, 5x4, 5x5, 6x5, 6x6, 8x5, 8x6, 8x8, 10x5, 10x6, 10x8, 10x10, 12x10, 12x12") + .into(), + )); + } + }; + + Ok(Some((unorm, srgb, hdr))) +} + +#[cfg(feature = "compressed_image_saver")] fn choose_ctt_compressed_format( input: TextureFormat, ) -> Result { use ktx2::Format; - // TODO: ASTC support + let astc_block = parse_astc_env_var()?; + let format = match input { - // 1-channel snorm -> BC4 snorm - TextureFormat::R8Snorm | TextureFormat::R16Snorm => Format::BC4_SNORM_BLOCK, + // 1-channel snorm + TextureFormat::R8Snorm | TextureFormat::R16Snorm => { + if let Some((unorm, _, _)) = astc_block { + unorm + } else { + Format::BC4_SNORM_BLOCK + } + } - // 1-channel -> BC4 + // 1-channel TextureFormat::R8Unorm | TextureFormat::R8Uint | TextureFormat::R8Sint @@ -264,12 +387,24 @@ fn choose_ctt_compressed_format( | TextureFormat::R32Uint | TextureFormat::R32Sint | TextureFormat::R32Float - | TextureFormat::R64Uint => Format::BC4_UNORM_BLOCK, + | TextureFormat::R64Uint => { + if let Some((unorm, _, _)) = astc_block { + unorm + } else { + Format::BC4_UNORM_BLOCK + } + } - // 2-channel snorm -> BC5 snorm - TextureFormat::Rg8Snorm | TextureFormat::Rg16Snorm => Format::BC5_SNORM_BLOCK, + // 2-channel snorm + TextureFormat::Rg8Snorm | TextureFormat::Rg16Snorm => { + if let Some((unorm, _, _)) = astc_block { + unorm + } else { + Format::BC5_SNORM_BLOCK + } + } - // 2-channel -> BC5 + // 2-channel TextureFormat::Rg8Unorm | TextureFormat::Rg8Uint | TextureFormat::Rg8Sint @@ -279,15 +414,27 @@ fn choose_ctt_compressed_format( | TextureFormat::Rg16Float | TextureFormat::Rg32Uint | TextureFormat::Rg32Sint - | TextureFormat::Rg32Float => Format::BC5_UNORM_BLOCK, + | TextureFormat::Rg32Float => { + if let Some((unorm, _, _)) = astc_block { + unorm + } else { + Format::BC5_UNORM_BLOCK + } + } - // HDR / float RGB formats -> BC6H + // HDR / float RGB formats TextureFormat::Rgb9e5Ufloat | TextureFormat::Rg11b10Ufloat | TextureFormat::Rgba16Float - | TextureFormat::Rgba32Float => Format::BC6H_UFLOAT_BLOCK, + | TextureFormat::Rgba32Float => { + if let Some((_, _, hdr)) = astc_block { + hdr + } else { + Format::BC6H_UFLOAT_BLOCK + } + } - // 4-channel LDR -> BC7 + // 4-channel LDR TextureFormat::Rgba8Unorm | TextureFormat::Rgba8Uint | TextureFormat::Rgba8Sint @@ -300,8 +447,20 @@ fn choose_ctt_compressed_format( | TextureFormat::Rgba32Sint | TextureFormat::Bgra8Unorm | TextureFormat::Rgb10a2Uint - | TextureFormat::Rgb10a2Unorm => Format::BC7_UNORM_BLOCK, - TextureFormat::Rgba8UnormSrgb | TextureFormat::Bgra8UnormSrgb => Format::BC7_SRGB_BLOCK, + | TextureFormat::Rgb10a2Unorm => { + if let Some((unorm, _, _)) = astc_block { + unorm + } else { + Format::BC7_UNORM_BLOCK + } + } + TextureFormat::Rgba8UnormSrgb | TextureFormat::Bgra8UnormSrgb => { + if let Some((_, srgb, _)) = astc_block { + srgb + } else { + Format::BC7_SRGB_BLOCK + } + } // Already compressed -> pass through TextureFormat::Bc1RgbaUnorm @@ -349,7 +508,7 @@ fn choose_ctt_compressed_format( }) } -#[cfg(feature = "compressed_image_saver_desktop")] +#[cfg(feature = "compressed_image_saver")] fn map_to_ctt_texture_format( input: TextureFormat, ) -> Result { diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index 149b502a51dd1..f6cc9464cb944 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -220,8 +220,8 @@ impl Plugin for ImagePlugin { .unwrap(); #[cfg(any( - feature = "compressed_image_saver_desktop", - feature = "compressed_image_saver_web" + feature = "compressed_image_saver", + feature = "compressed_image_saver_universal" ))] if let Some(processor) = app .world() diff --git a/crates/bevy_image/src/lib.rs b/crates/bevy_image/src/lib.rs index 32d13b1917825..20400a1b066fd 100644 --- a/crates/bevy_image/src/lib.rs +++ b/crates/bevy_image/src/lib.rs @@ -25,8 +25,8 @@ pub use self::serialized_image::*; #[cfg(feature = "basis-universal")] mod basis; #[cfg(any( - feature = "compressed_image_saver_desktop", - feature = "compressed_image_saver_web" + feature = "compressed_image_saver", + feature = "compressed_image_saver_universal" ))] mod compressed_image_saver; #[cfg(feature = "dds")] @@ -44,8 +44,8 @@ mod texture_atlas; mod texture_atlas_builder; #[cfg(any( - feature = "compressed_image_saver_desktop", - feature = "compressed_image_saver_web" + feature = "compressed_image_saver", + feature = "compressed_image_saver_universal" ))] pub use compressed_image_saver::*; #[cfg(feature = "dds")] diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 23607751347dc..3aa21b2e3068f 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -29,11 +29,13 @@ detailed_trace = ["bevy_ecs/detailed_trace", "bevy_render?/detailed_trace"] sysinfo_plugin = ["bevy_diagnostic/sysinfo_plugin"] -# Texture compression asset processor (for web) -compressed_image_saver_web = ["bevy_image/compressed_image_saver_web"] +# Texture compression asset processor (cross-platform, transcodes to any GPU format at load time) +compressed_image_saver_universal = [ + "bevy_image/compressed_image_saver_universal", +] -# Texture compression asset processor (for desktop) -compressed_image_saver_desktop = ["bevy_image/compressed_image_saver_desktop"] +# Texture compression asset processor (BCn for desktop, ASTC for mobile via env var) +compressed_image_saver = ["bevy_image/compressed_image_saver"] # For ktx2 supercompression zlib = ["bevy_image/zlib"] diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 360b757c2c347..142e7fad7d53e 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -110,8 +110,8 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio |bevy_world_serialization|Provides ECS serialization functionality| |bluenoise_texture|Include spatio-temporal blue noise KTX2 file used by generated environment maps, Solari and atmosphere| |bmp|BMP image format support| -|compressed_image_saver_desktop|Texture compression asset processor (for desktop)| -|compressed_image_saver_web|Texture compression asset processor (for web)| +|compressed_image_saver|Texture compression asset processor (BCn for desktop, ASTC for mobile via env var)| +|compressed_image_saver_universal|Texture compression asset processor (cross-platform, transcodes to any GPU format at load time)| |critical-section|`critical-section` provides the building blocks for synchronization primitives on all platforms, including `no_std`.| |custom_cursor|Enable winit custom cursor support| |dds|DDS compressed texture support| From 8b721defe783be80258e92c457618ea21d87af06 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:31:22 -0400 Subject: [PATCH 16/21] Docs --- .../compressed_image_saver.md | 22 ++++++++++------- .../release-notes/compressed_image_saver.md | 24 ++++++++++--------- .../bevy_image/src/compressed_image_saver.rs | 15 ++++++------ 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/_release-content/migration-guides/compressed_image_saver.md b/_release-content/migration-guides/compressed_image_saver.md index c15a30710e1f7..e33a5d8c949a7 100644 --- a/_release-content/migration-guides/compressed_image_saver.md +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -1,14 +1,20 @@ --- -title: Feature that broke -pull_requests: [14791, 15458, 15269] +title: `CompressedImageSaver` improvements +pull_requests: [23567] --- -Copy the contents of this file into a new file in `./migration-guides`, update the metadata, and add migration guide content here. +The `compressed_image_saver` Cargo feature has been reworked. The old behavior (Basis Universal UASTC compression) has been moved to a new feature called `compressed_image_saver_universal`, and the `compressed_image_saver` feature now uses the `ctt` library to compress textures into BCn (desktop) or ASTC (mobile) formats instead. -Remember, your aim is to communicate: +If you were using the `compressed_image_saver` feature and want to keep the previous Basis Universal behavior, rename the feature in your `Cargo.toml`: -- What has changed since the last release? -- Why did we make this breaking change? -- How can users migrate their existing code? +```toml +# Before +bevy = { version = "0.18", features = ["compressed_image_saver"] } -For more specifics about style and content, see the [instructions](./migration_guides.md). +# After (keeps old Basis Universal behavior) +bevy = { version = "0.19", features = ["compressed_image_saver_universal"] } +``` + +Alternatively, keep using `compressed_image_saver` to get the new BCn/ASTC compression backend. This produces higher-quality output and supports a wider range of input formats, but does not support web. + +`CompressedImageSaverError` has a new variant `CompressionFailed`. If you were matching exhaustively on this enum, add a branch for it. diff --git a/_release-content/release-notes/compressed_image_saver.md b/_release-content/release-notes/compressed_image_saver.md index 0a4110a5cb015..358317f05990e 100644 --- a/_release-content/release-notes/compressed_image_saver.md +++ b/_release-content/release-notes/compressed_image_saver.md @@ -1,19 +1,21 @@ --- -title: Feature name -authors: ["@FerrisTheCrab", "@BirdObsessed"] -pull_requests: [14791, 15458, 15269] +title: CompressedImageSaver Improvements +authors: ["@JMS55", "@cwfitzgerald"] +pull_requests: [23567] --- -Copy the contents of this file into `./release-notes`, update the metadata, and add release note content here. +Bevy's `CompressedImageSaver` asset processor has been significantly upgraded with a new compression backend powered by the [`ctt`](https://github.com/cwfitzgerald/ctt) library. -## Goals +The new `compressed_image_saver` feature compresses textures into BCn formats (for desktop GPUs) or ASTC formats (for mobile GPUs), producing higher-quality output than the previous Basis Universal approach. The compressor automatically selects the best output format based on the input texture's channel count and type — for example, single-channel textures get BC4, HDR textures get BC6H, and standard RGBA textures get BC7. -Answer the following: +## Automatic Mipmap Generation -- What has been changed or added? -- Why is this a big deal for users? -- How can they use it? +No more manually generating mipmaps! The new backend automatically produces a full mip chain during compression. This means less aliasing when textures are viewed at a distance and better GPU cache utilization — all for free, just by running your textures through the asset processor. -## Style Guide +## ASTC for Mobile -You may use markdown headings levels two and three, and must not start with a heading. Prose is appreciated, but bullet points are acceptable. Copying the introduction from your PR is often a good place to start. +To target mobile GPUs, set the `BEVY_COMPRESSED_IMAGE_SAVER_ASTC` environment variable with your desired block size (e.g. `4x4`, `6x6`, `8x8`). Larger blocks give smaller files at the cost of quality. All 14 ASTC block sizes are supported. + +## Basis Universal is Still Available + +The previous Basis Universal compression behavior has been moved to the `compressed_image_saver_universal` feature. This remains the best choice for cross-platform distribution (including WebGPU), since UASTC can be transcoded at load time to whatever format the target GPU supports. diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 18f36bebde717..043efced5c056 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -14,9 +14,6 @@ use wgpu_types::TextureFormat; /// /// Compressed textures use less GPU VRAM. /// -/// Mipmaps are also generated, which prevents aliasing when textures are viewed at a distance, -/// and increases GPU cache hits, improving rendering performance. -/// /// # Platform support /// /// Two mutually exclusive feature flags control which compression backend is used: @@ -76,11 +73,15 @@ use wgpu_types::TextureFormat; /// All 14 ASTC block sizes are supported: `4x4`, `5x4`, `5x5`, `6x5`, `6x6`, `8x5`, /// `8x6`, `8x8`, `10x5`, `10x6`, `10x8`, `10x10`, `12x10`, `12x12`. /// -/// # Mipmap generation +/// # Mipmap generation (`compressed_image_saver` only) +/// +/// When using the `compressed_image_saver` backend, mipmaps are generated automatically +/// during compression. This prevents aliasing when textures are viewed at a distance and +/// increases GPU cache hits, improving rendering performance. Input images must have a +/// `mip_level_count` of 1 (i.e., no pre-existing mip chain); the compressor will produce +/// a full mip chain in the output. /// -/// Mipmaps are generated automatically during compression. The `compressed_image_saver` feature requires -/// input images to have a `mip_level_count` of 1 (i.e., no pre-existing mip chain); -/// the compressor will produce a full mip chain in the output. +/// The `compressed_image_saver_universal` backend does not generate mipmaps. #[derive(TypePath)] pub struct CompressedImageSaver; From f71c5856680f170c4f4452d5b4fcc491382eb499 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:46:55 -0400 Subject: [PATCH 17/21] Feedback --- .../compressed_image_saver.md | 2 +- .../bevy_image/src/compressed_image_saver.rs | 104 +++++++++--------- 2 files changed, 56 insertions(+), 50 deletions(-) diff --git a/_release-content/migration-guides/compressed_image_saver.md b/_release-content/migration-guides/compressed_image_saver.md index e33a5d8c949a7..2098d312607f6 100644 --- a/_release-content/migration-guides/compressed_image_saver.md +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -1,5 +1,5 @@ --- -title: `CompressedImageSaver` improvements +title: "`CompressedImageSaver` improvements" pull_requests: [23567] --- diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 043efced5c056..540eb4ea478b8 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -42,13 +42,14 @@ use wgpu_types::TextureFormat; /// /// | Input channels | Output format | /// |---|---| -/// | 1-channel (R) | BC4 | -/// | 1-channel snorm | BC4 snorm | -/// | 2-channel (RG) | BC5 | -/// | 2-channel snorm | BC5 snorm | -/// | HDR / float (e.g. `Rgba16Float`) | BC6H | +/// | 1-channel (`R8Unorm`) | BC4 | +/// | 1-channel snorm (`R8Snorm`) | BC4 snorm | +/// | 2-channel (`Rg8Unorm`) | BC5 | +/// | 2-channel snorm (`Rg8Snorm`) | BC5 snorm | +/// | HDR / packed float (e.g. `Rgb9e5Ufloat`) | BC6H | /// | 4-channel LDR (e.g. `Rgba8Unorm`) | BC7 | /// | 4-channel sRGB (e.g. `Rgba8UnormSrgb`) | BC7 sRGB | +/// | Integer or high-precision (16-bit+) formats | Uncompressed KTX2 (passthrough) | /// | Already compressed (BCn, ASTC, ETC2, EAC) | Re-encoded to the same format | /// /// Depth, stencil, and video formats (`NV12`, `P010`) are not supported and will return @@ -161,7 +162,7 @@ impl AssetSaver for CompressedImageSaver { stride: image.width() * bytes_per_pixel, format: input_format, color_space, - alpha: ctt::AlphaMode::Straight, // TODO: User-configurable? + alpha: ctt::AlphaMode::Straight, // TODO: User-configurable }] }) .collect(); @@ -175,12 +176,12 @@ impl AssetSaver for CompressedImageSaver { container: ctt::Container::Ktx2, quality: ctt::Quality::default(), output_color_space: None, - output_alpha: None, + output_alpha: Some(ctt::AlphaMode::Premultiplied), // TODO: User-configurable swizzle: None, mipmap: true, mipmap_count: None, mipmap_filter: ctt::MipmapFilter::default(), - allow_lossy: true, + allow_lossy: false, encoder_settings: None, registry: None, }; @@ -369,7 +370,7 @@ fn choose_ctt_compressed_format( let format = match input { // 1-channel snorm - TextureFormat::R8Snorm | TextureFormat::R16Snorm => { + TextureFormat::R8Snorm => { if let Some((unorm, _, _)) = astc_block { unorm } else { @@ -378,17 +379,7 @@ fn choose_ctt_compressed_format( } // 1-channel - TextureFormat::R8Unorm - | TextureFormat::R8Uint - | TextureFormat::R8Sint - | TextureFormat::R16Uint - | TextureFormat::R16Sint - | TextureFormat::R16Unorm - | TextureFormat::R16Float - | TextureFormat::R32Uint - | TextureFormat::R32Sint - | TextureFormat::R32Float - | TextureFormat::R64Uint => { + TextureFormat::R8Unorm => { if let Some((unorm, _, _)) = astc_block { unorm } else { @@ -397,7 +388,7 @@ fn choose_ctt_compressed_format( } // 2-channel snorm - TextureFormat::Rg8Snorm | TextureFormat::Rg16Snorm => { + TextureFormat::Rg8Snorm => { if let Some((unorm, _, _)) = astc_block { unorm } else { @@ -406,16 +397,7 @@ fn choose_ctt_compressed_format( } // 2-channel - TextureFormat::Rg8Unorm - | TextureFormat::Rg8Uint - | TextureFormat::Rg8Sint - | TextureFormat::Rg16Uint - | TextureFormat::Rg16Sint - | TextureFormat::Rg16Unorm - | TextureFormat::Rg16Float - | TextureFormat::Rg32Uint - | TextureFormat::Rg32Sint - | TextureFormat::Rg32Float => { + TextureFormat::Rg8Unorm => { if let Some((unorm, _, _)) = astc_block { unorm } else { @@ -424,10 +406,7 @@ fn choose_ctt_compressed_format( } // HDR / float RGB formats - TextureFormat::Rgb9e5Ufloat - | TextureFormat::Rg11b10Ufloat - | TextureFormat::Rgba16Float - | TextureFormat::Rgba32Float => { + TextureFormat::Rgb9e5Ufloat | TextureFormat::Rg11b10Ufloat => { if let Some((_, _, hdr)) = astc_block { hdr } else { @@ -436,19 +415,7 @@ fn choose_ctt_compressed_format( } // 4-channel LDR - TextureFormat::Rgba8Unorm - | TextureFormat::Rgba8Uint - | TextureFormat::Rgba8Sint - | TextureFormat::Rgba8Snorm - | TextureFormat::Rgba16Uint - | TextureFormat::Rgba16Sint - | TextureFormat::Rgba16Unorm - | TextureFormat::Rgba16Snorm - | TextureFormat::Rgba32Uint - | TextureFormat::Rgba32Sint - | TextureFormat::Bgra8Unorm - | TextureFormat::Rgb10a2Uint - | TextureFormat::Rgb10a2Unorm => { + TextureFormat::Rgba8Unorm | TextureFormat::Bgra8Unorm | TextureFormat::Rgb10a2Unorm => { if let Some((unorm, _, _)) = astc_block { unorm } else { @@ -463,7 +430,7 @@ fn choose_ctt_compressed_format( } } - // Already compressed -> pass through + // Already compressed -> pass through as compressed TextureFormat::Bc1RgbaUnorm | TextureFormat::Bc1RgbaUnormSrgb | TextureFormat::Bc2RgbaUnorm @@ -490,6 +457,45 @@ fn choose_ctt_compressed_format( | TextureFormat::EacRg11Snorm | TextureFormat::Astc { .. } => map_to_ctt_texture_format(input)?, + // Integer, high-precision, and float formats -> pass through uncompressed + TextureFormat::R8Uint + | TextureFormat::R8Sint + | TextureFormat::R16Uint + | TextureFormat::R16Sint + | TextureFormat::R16Unorm + | TextureFormat::R16Snorm + | TextureFormat::R16Float + | TextureFormat::R32Uint + | TextureFormat::R32Sint + | TextureFormat::R32Float + | TextureFormat::R64Uint + | TextureFormat::Rg8Uint + | TextureFormat::Rg8Sint + | TextureFormat::Rg16Uint + | TextureFormat::Rg16Sint + | TextureFormat::Rg16Unorm + | TextureFormat::Rg16Snorm + | TextureFormat::Rg16Float + | TextureFormat::Rg32Uint + | TextureFormat::Rg32Sint + | TextureFormat::Rg32Float + | TextureFormat::Rgba8Uint + | TextureFormat::Rgba8Sint + | TextureFormat::Rgba8Snorm + | TextureFormat::Rgba16Uint + | TextureFormat::Rgba16Sint + | TextureFormat::Rgba16Unorm + | TextureFormat::Rgba16Snorm + | TextureFormat::Rgba16Float + | TextureFormat::Rgba32Uint + | TextureFormat::Rgba32Sint + | TextureFormat::Rgba32Float + | TextureFormat::Rgb10a2Uint => { + return Ok(ctt::TargetFormat::Uncompressed(map_to_ctt_texture_format( + input, + )?)); + } + // Depth/stencil and video formats cannot be compressed TextureFormat::Stencil8 | TextureFormat::Depth16Unorm From 595ebf4863a9337c435dfe1c70a82ee61a4ee146 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:30:24 -0400 Subject: [PATCH 18/21] Doc tweaks --- _release-content/migration-guides/compressed_image_saver.md | 2 +- crates/bevy_image/src/compressed_image_saver.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/_release-content/migration-guides/compressed_image_saver.md b/_release-content/migration-guides/compressed_image_saver.md index 2098d312607f6..40ede9f0d0050 100644 --- a/_release-content/migration-guides/compressed_image_saver.md +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -15,6 +15,6 @@ bevy = { version = "0.18", features = ["compressed_image_saver"] } bevy = { version = "0.19", features = ["compressed_image_saver_universal"] } ``` -Alternatively, keep using `compressed_image_saver` to get the new BCn/ASTC compression backend. This produces higher-quality output and supports a wider range of input formats, but does not support web. +Alternatively, keep using `compressed_image_saver` to get the new BCn/ASTC compression backend. This produces higher-quality output and supports a wider range of input formats, but does not support all platforms in a single file like UASTC does. We reccomend sticking to `compressed_image_saver_universal` when targeting the web. `CompressedImageSaverError` has a new variant `CompressionFailed`. If you were matching exhaustively on this enum, add a branch for it. diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 540eb4ea478b8..5e4c729d3d763 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -12,7 +12,7 @@ use wgpu_types::TextureFormat; /// An [`AssetSaver`] for [`Image`] that compresses texture files. /// -/// Compressed textures use less GPU VRAM. +/// Compressed textures use less GPU VRAM and are faster to sample. /// /// # Platform support /// @@ -27,7 +27,7 @@ use wgpu_types::TextureFormat; /// - **`compressed_image_saver_universal`** — Uses `basis-universal` to compress textures into UASTC /// (Basis Universal) format. This is a GPU-agnostic supercompressed format that can be /// transcoded at load time to whatever format the target GPU supports, making it suitable for -/// WebGPU and cross-platform distribution. +/// WebGPU and cross-platform distribution in a single file. /// /// # Runtime feature flags /// From 3978fbf19381c0bf0b644f04af8c98b5d88aea75 Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:32:50 -0400 Subject: [PATCH 19/21] Typo --- _release-content/migration-guides/compressed_image_saver.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_release-content/migration-guides/compressed_image_saver.md b/_release-content/migration-guides/compressed_image_saver.md index 40ede9f0d0050..1d39a165e249a 100644 --- a/_release-content/migration-guides/compressed_image_saver.md +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -15,6 +15,6 @@ bevy = { version = "0.18", features = ["compressed_image_saver"] } bevy = { version = "0.19", features = ["compressed_image_saver_universal"] } ``` -Alternatively, keep using `compressed_image_saver` to get the new BCn/ASTC compression backend. This produces higher-quality output and supports a wider range of input formats, but does not support all platforms in a single file like UASTC does. We reccomend sticking to `compressed_image_saver_universal` when targeting the web. +Alternatively, keep using `compressed_image_saver` to get the new BCn/ASTC compression backend. This produces higher-quality output and supports a wider range of input formats, but does not support all platforms in a single file like UASTC does. We recommend sticking to `compressed_image_saver_universal` when targeting the web. `CompressedImageSaverError` has a new variant `CompressionFailed`. If you were matching exhaustively on this enum, add a branch for it. From 5f0f2b227ed32d3e6c2071a2ce4cc3bcee6aa0ca Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:12:55 -0400 Subject: [PATCH 20/21] Use BC6H for rgba16float --- crates/bevy_image/src/compressed_image_saver.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 5e4c729d3d763..463d85f03a980 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -46,10 +46,10 @@ use wgpu_types::TextureFormat; /// | 1-channel snorm (`R8Snorm`) | BC4 snorm | /// | 2-channel (`Rg8Unorm`) | BC5 | /// | 2-channel snorm (`Rg8Snorm`) | BC5 snorm | -/// | HDR / packed float (e.g. `Rgb9e5Ufloat`) | BC6H | +/// | HDR / float (e.g. `Rgba16Float`) | BC6H | /// | 4-channel LDR (e.g. `Rgba8Unorm`) | BC7 | /// | 4-channel sRGB (e.g. `Rgba8UnormSrgb`) | BC7 sRGB | -/// | Integer or high-precision (16-bit+) formats | Uncompressed KTX2 (passthrough) | +/// | Integer or high-precision (>16-bit) formats | Uncompressed KTX2 (passthrough) | /// | Already compressed (BCn, ASTC, ETC2, EAC) | Re-encoded to the same format | /// /// Depth, stencil, and video formats (`NV12`, `P010`) are not supported and will return @@ -405,8 +405,12 @@ fn choose_ctt_compressed_format( } } - // HDR / float RGB formats - TextureFormat::Rgb9e5Ufloat | TextureFormat::Rg11b10Ufloat => { + // HDR / float formats + TextureFormat::Rgb9e5Ufloat + | TextureFormat::Rg11b10Ufloat + | TextureFormat::R16Float + | TextureFormat::Rg16Float + | TextureFormat::Rgba16Float => { if let Some((_, _, hdr)) = astc_block { hdr } else { @@ -464,7 +468,6 @@ fn choose_ctt_compressed_format( | TextureFormat::R16Sint | TextureFormat::R16Unorm | TextureFormat::R16Snorm - | TextureFormat::R16Float | TextureFormat::R32Uint | TextureFormat::R32Sint | TextureFormat::R32Float @@ -475,7 +478,6 @@ fn choose_ctt_compressed_format( | TextureFormat::Rg16Sint | TextureFormat::Rg16Unorm | TextureFormat::Rg16Snorm - | TextureFormat::Rg16Float | TextureFormat::Rg32Uint | TextureFormat::Rg32Sint | TextureFormat::Rg32Float @@ -486,7 +488,6 @@ fn choose_ctt_compressed_format( | TextureFormat::Rgba16Sint | TextureFormat::Rgba16Unorm | TextureFormat::Rgba16Snorm - | TextureFormat::Rgba16Float | TextureFormat::Rgba32Uint | TextureFormat::Rgba32Sint | TextureFormat::Rgba32Float From 6e1255cc37f2b64470b9d631e3a5c07250286fff Mon Sep 17 00:00:00 2001 From: JMS55 <47158642+JMS55@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:27:56 -0400 Subject: [PATCH 21/21] Zstd supercompression --- crates/bevy_image/Cargo.toml | 4 ++-- crates/bevy_image/src/compressed_image_saver.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/bevy_image/Cargo.toml b/crates/bevy_image/Cargo.toml index 3d3e3ea92dc8a..62a725eb1b16f 100644 --- a/crates/bevy_image/Cargo.toml +++ b/crates/bevy_image/Cargo.toml @@ -82,7 +82,7 @@ futures-lite = "2.0.1" guillotiere = "0.6.0" rectangle-pack = "0.4" ddsfile = { version = "0.5.2", optional = true } -ktx2 = { version = "0.4.0", optional = true } +ktx2 = { git = "https://github.com/BVE-Reborn/ktx2.git", rev = "49e6fed", optional = true } # For ktx2 supercompression flate2 = { version = "1.0.22", optional = true } zstd = { version = "0.13.3", optional = true } @@ -91,7 +91,7 @@ ruzstd = { version = "0.8.0", optional = true } basis-universal = { version = "0.3.0", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } half = { version = "2.4.1" } -ctt = { git = "https://github.com/cwfitzgerald/ctt", rev = "fd41d518a557803bd1eda1bcc6fbbcd4e07757c5", optional = true } +ctt = { git = "https://github.com/cwfitzgerald/ctt", rev = "0ac0748fc25fd0cdee2794a43d1617cc5b776e05", optional = true } [dev-dependencies] bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver.rs index 463d85f03a980..f797657939232 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver.rs @@ -33,7 +33,7 @@ use wgpu_types::TextureFormat; /// /// The compressed output must also be loadable at runtime. Enable the corresponding feature: /// -/// - **`ktx2`** — Required to load KTX2 files produced by `compressed_image_saver`. +/// - **`ktx2` and `zstd`** — Required to load KTX2 files produced by `compressed_image_saver`. /// - **`basis-universal`** — Required to load Basis Universal files produced by `compressed_image_saver_universal`. /// /// # Compression format selection @@ -173,7 +173,7 @@ impl AssetSaver for CompressedImageSaver { let settings = ctt::ConvertSettings { format: Some(output_format), - container: ctt::Container::Ktx2, + container: ctt::Container::ktx2_zstd(0), quality: ctt::Quality::default(), output_color_space: None, output_alpha: Some(ctt::AlphaMode::Premultiplied), // TODO: User-configurable @@ -188,7 +188,7 @@ impl AssetSaver for CompressedImageSaver { let output = ctt::convert(ctt_image, settings) .map_err(|e| CompressedImageSaverError::CompressionFailed(Box::new(e)))?; - let ctt::ConvertOutput::Encoded(compressed_bytes) = &output else { + let ctt::PipelineOutput::Encoded(compressed_bytes) = &output else { return Err(CompressedImageSaverError::CompressionFailed( "Expected encoded output from ctt".into(), ));