diff --git a/.gitignore b/.gitignore index ca337b93971e6..f3283b24e662f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ Cargo.lock # Bevy Assets assets/**/*.meta +!assets/textures/GroundSand005/*.meta crates/bevy_asset/imported_assets imported_assets .web-asset-cache diff --git a/Cargo.toml b/Cargo.toml index 0c160b3aea850..ed92e40a4ed4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -436,7 +436,12 @@ 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 +# 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"] # Enables system-level clipboard support. @@ -2083,6 +2088,18 @@ description = "Demonstrates loading a compressed asset" category = "Assets" wasm = false +[[example]] +name = "compressed_image_saver" +path = "examples/asset/compressed_image_saver.rs" +doc-scrape-examples = true +required-features = ["compressed_image_saver", "asset_processor", "jpeg"] + +[package.metadata.example.compressed_image_saver] +name = "Compressed Image Saver" +description = "Demonstrates compressing textures and generating mipmaps using CompressedImageSaver" +category = "Assets" +wasm = false + [[example]] name = "custom_asset" path = "examples/asset/custom_asset.rs" 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..1d39a165e249a --- /dev/null +++ b/_release-content/migration-guides/compressed_image_saver.md @@ -0,0 +1,20 @@ +--- +title: "`CompressedImageSaver` improvements" +pull_requests: [23567] +--- + +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. + +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`: + +```toml +# Before +bevy = { version = "0.18", features = ["compressed_image_saver"] } + +# 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 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. 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..cd1904b755167 --- /dev/null +++ b/_release-content/release-notes/compressed_image_saver.md @@ -0,0 +1,23 @@ +--- +title: CompressedImageSaver Improvements +authors: ["@JMS55", "@cwfitzgerald"] +pull_requests: [23567] +--- + +Bevy's `CompressedImageSaver` asset processor has been significantly upgraded with a new compression backend powered by the [`ctt`](https://github.com/cwfitzgerald/ctt) library. + +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. + +Try out the new `compressed_image_saver` example to see it in action. + +## Automatic Mipmap Generation + +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. + +## ASTC for Mobile + +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/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg b/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg new file mode 100644 index 0000000000000..f56ac4450fc76 Binary files /dev/null and b/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg differ diff --git a/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg.meta b/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg.meta new file mode 100644 index 0000000000000..6c18f282899ba --- /dev/null +++ b/assets/textures/GroundSand005/GroundSand005_COL_2K.jpg.meta @@ -0,0 +1,16 @@ +( + meta_format_version: "1.0", + asset: Process( + processor: "LoadTransformAndSave, CompressedImageSaver>", + settings: ( + loader_settings: ( + format: FromExtension, + is_srgb: true, + sampler: Default, + asset_usage: ("RENDER_WORLD"), + ), + transformer_settings: (), + saver_settings: (), + ), + ), +) diff --git a/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg b/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg new file mode 100644 index 0000000000000..7f5db4b312d84 Binary files /dev/null and b/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg differ diff --git a/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg.meta b/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg.meta new file mode 100644 index 0000000000000..2942e836d8f93 --- /dev/null +++ b/assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg.meta @@ -0,0 +1,17 @@ +( + meta_format_version: "1.0", + asset: Process( + processor: "LoadTransformAndSave, CompressedImageSaver>", + settings: ( + loader_settings: ( + format: FromExtension, + is_srgb: false, + sampler: Default, + asset_usage: ("RENDER_WORLD"), + texture_format: Some("r8unorm"), + ), + transformer_settings: (), + saver_settings: (), + ), + ), +) diff --git a/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg b/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg new file mode 100644 index 0000000000000..8d3c96dabe393 Binary files /dev/null and b/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg differ diff --git a/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg.meta b/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg.meta new file mode 100644 index 0000000000000..bfe367b7ff45e --- /dev/null +++ b/assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg.meta @@ -0,0 +1,16 @@ +( + meta_format_version: "1.0", + asset: Process( + processor: "LoadTransformAndSave, CompressedImageSaver>", + settings: ( + loader_settings: ( + format: FromExtension, + is_srgb: false, + sampler: Default, + asset_usage: ("RENDER_WORLD"), + ), + transformer_settings: (), + saver_settings: (), + ), + ), +) diff --git a/assets/textures/GroundSand005/GroundSand005_ORM_2K.png b/assets/textures/GroundSand005/GroundSand005_ORM_2K.png new file mode 100644 index 0000000000000..b9d3022db229a Binary files /dev/null and b/assets/textures/GroundSand005/GroundSand005_ORM_2K.png differ diff --git a/assets/textures/GroundSand005/GroundSand005_ORM_2K.png.meta b/assets/textures/GroundSand005/GroundSand005_ORM_2K.png.meta new file mode 100644 index 0000000000000..bfe367b7ff45e --- /dev/null +++ b/assets/textures/GroundSand005/GroundSand005_ORM_2K.png.meta @@ -0,0 +1,16 @@ +( + meta_format_version: "1.0", + asset: Process( + processor: "LoadTransformAndSave, CompressedImageSaver>", + settings: ( + loader_settings: ( + format: FromExtension, + is_srgb: false, + sampler: Default, + asset_usage: ("RENDER_WORLD"), + ), + transformer_settings: (), + saver_settings: (), + ), + ), +) diff --git a/assets/textures/GroundSand005/source.txt b/assets/textures/GroundSand005/source.txt new file mode 100644 index 0000000000000..560c66ef9fbce --- /dev/null +++ b/assets/textures/GroundSand005/source.txt @@ -0,0 +1 @@ +https://www.poliigon.com/texture/rippled-wet-sand-texture/6997 \ No newline at end of file diff --git a/crates/bevy_ecs/src/entity/mod.rs b/crates/bevy_ecs/src/entity/mod.rs index f4a06af5d4084..c44cd99c9ec00 100644 --- a/crates/bevy_ecs/src/entity/mod.rs +++ b/crates/bevy_ecs/src/entity/mod.rs @@ -121,7 +121,7 @@ use crate::{ storage::{SparseSetIndex, TableId, TableRow}, }; use alloc::vec::Vec; -use core::{fmt, hash::Hash, mem, num::NonZero, panic::Location}; +use core::{fmt, hash::Hash, mem, num::NonZero, ops::Range, panic::Location}; use derive_more::derive::Display; use log::warn; use nonmax::NonMaxU32; @@ -708,9 +708,17 @@ pub struct EntityAllocator { } impl EntityAllocator { + /// Creates a new `EntityAllocator` with a given range + pub fn new(range: Range) -> Self { + Self { + inner: remote_allocator::Allocator::new(range), + } + } + /// Restarts the allocator. pub(crate) fn restart(&mut self) { - self.inner = remote_allocator::Allocator::new(); + let range = self.inner.range().clone(); + self.inner = remote_allocator::Allocator::new(range); } /// Builds a new remote allocator that hooks into this [`EntityAllocator`]. @@ -779,7 +787,13 @@ impl EntityAllocator { /// More generally, manually spawning and [`despawn_no_free`](crate::world::World::despawn_no_free)ing entities allows you to skip Bevy's default entity allocator. /// This is useful if you want to enforce properties about the [`EntityIndex`]s of a group of entities, make a custom allocator, etc. pub fn alloc(&self) -> Entity { - self.inner.alloc() + self.inner.try_alloc().expect("out of entities") + } + + /// Allocates some [`Entity`]. + /// Returns `None` if no entities are available. This is a non-`panic`ing version of `alloc`. + pub fn try_alloc(&self) -> Option { + self.inner.try_alloc() } /// A more efficient way of calling [`alloc`](Self::alloc) repeatedly `count` times. diff --git a/crates/bevy_ecs/src/entity/remote_allocator.rs b/crates/bevy_ecs/src/entity/remote_allocator.rs index 15a3239b30b03..dcc802e541b02 100644 --- a/crates/bevy_ecs/src/entity/remote_allocator.rs +++ b/crates/bevy_ecs/src/entity/remote_allocator.rs @@ -39,7 +39,7 @@ use bevy_platform::{ Arc, }, }; -use core::mem::ManuallyDrop; +use core::{mem::ManuallyDrop, ops::Range}; use log::warn; use nonmax::NonMaxU32; @@ -296,7 +296,7 @@ impl FreeBuffer { /// making safety for other operations afterward need careful justification. /// Otherwise, the compiler will make unsound optimizations. #[inline] - unsafe fn iter(&self, indices: core::ops::Range) -> FreeBufferIterator<'_> { + unsafe fn iter(&self, indices: Range) -> FreeBufferIterator<'_> { FreeBufferIterator { buffer: self, future_buffer_indices: indices, @@ -325,7 +325,7 @@ struct FreeBufferIterator<'a> { /// The part of the buffer we are iterating at the moment. current_chunk_slice: core::slice::Iter<'a, Slot>, /// The indices in the buffer that are not yet in `current_chunk_slice`. - future_buffer_indices: core::ops::Range, + future_buffer_indices: Range, } impl<'a> Iterator for FreeBufferIterator<'a> { @@ -737,6 +737,7 @@ impl FreeList { struct FreshAllocator { /// The next value of [`Entity::index`] to give out if needed. next_entity_index: AtomicU32, + max_index: u32, } impl FreshAllocator { @@ -744,6 +745,13 @@ impl FreshAllocator { /// Ex: We may want this to be smaller on 32 bit platforms at some point. const MAX_ENTITIES: u32 = u32::MAX; + pub(crate) fn new(range: Range) -> Self { + Self { + next_entity_index: AtomicU32::new(range.start), + max_index: range.end, + } + } + /// The total number of indices given out. #[inline] fn total_entity_indices(&self) -> u32 { @@ -759,15 +767,23 @@ impl FreshAllocator { } /// Allocates a fresh [`EntityIndex`]. - /// This row has never been given out before. + /// This index has never been given out before. + /// If no index is available (out of range), than it returns None #[inline] - fn alloc(&self) -> Entity { + fn alloc(&self) -> Option { let index = self.next_entity_index.fetch_add(1, Ordering::Relaxed); - if index == Self::MAX_ENTITIES { + if index >= self.max_index { + self.next_entity_index + .store(self.max_index, Ordering::Relaxed); + return None; + } else if index == Self::MAX_ENTITIES { Self::on_overflow(); } + // SAFETY: We just checked that this was not max and we only added 1, so we can't have missed it. - Entity::from_index(unsafe { EntityIndex::new(NonMaxU32::new_unchecked(index)) }) + Some(Entity::from_index(unsafe { + EntityIndex::new(NonMaxU32::new_unchecked(index)) + })) } /// Allocates `count` [`EntityIndex`]s. @@ -777,7 +793,7 @@ impl FreshAllocator { let start_new = self.next_entity_index.fetch_add(count, Ordering::Relaxed); let new = match start_new .checked_add(count) - .filter(|new| *new < Self::MAX_ENTITIES) + .filter(|new| *new < self.max_index) { Some(new_next_entity_index) => start_new..new_next_entity_index, None => Self::on_overflow(), @@ -790,7 +806,7 @@ impl FreshAllocator { /// These rows have never been given out before. /// /// **NOTE:** Dropping will leak the remaining entity rows! -pub(super) struct AllocUniqueEntityIndexIterator(core::ops::Range); +pub(super) struct AllocUniqueEntityIndexIterator(Range); impl Iterator for AllocUniqueEntityIndexIterator { type Item = Entity; @@ -824,25 +840,24 @@ struct SharedAllocator { impl SharedAllocator { /// Constructs a [`SharedAllocator`] - fn new() -> Self { + fn new(range: Range) -> Self { Self { free: FreeList::new(), - fresh: FreshAllocator { - next_entity_index: AtomicU32::new(0), - }, + fresh: FreshAllocator::new(range), is_closed: AtomicBool::new(false), } } /// Allocates a new [`Entity`], reusing a freed index if one exists. + /// If no more entities can be allocated, this returns None /// /// # Safety /// /// This must not conflict with [`FreeList::free`] calls. #[inline] - unsafe fn alloc(&self) -> Entity { + unsafe fn try_alloc(&self) -> Option { // SAFETY: assured by caller - unsafe { self.free.alloc() }.unwrap_or_else(|| self.fresh.alloc()) + unsafe { self.free.alloc() }.or_else(|| self.fresh.alloc()) } /// Allocates a `count` [`Entity`]s, reusing freed indices if they exist. @@ -862,10 +877,8 @@ impl SharedAllocator { /// Allocates a new [`Entity`]. /// This will only try to reuse a freed index if it is safe to do so. #[inline] - fn remote_alloc(&self) -> Entity { - self.free - .remote_alloc() - .unwrap_or_else(|| self.fresh.alloc()) + fn try_remote_alloc(&self) -> Option { + self.free.remote_alloc().or_else(|| self.fresh.alloc()) } /// Marks the allocator as closed, but it will still function normally. @@ -891,28 +904,43 @@ pub(crate) struct Allocator { /// The local free list. /// We use this to amortize the cost of freeing to the shared allocator since that is expensive. local_free: Box>, + /// The index range this allocator operates on + range: Range, } impl Default for Allocator { fn default() -> Self { - Self::new() + Self::new(0..u32::MAX) } } impl Allocator { /// Constructs a new [`Allocator`] - pub(super) fn new() -> Self { + pub(super) fn new(range: Range) -> Self { Self { - shared: Arc::new(SharedAllocator::new()), + shared: Arc::new(SharedAllocator::new(range.clone())), local_free: Box::new(ArrayVec::new()), + range, } } + /// Returns the range this allocator operates on + pub fn range(&self) -> &Range { + &self.range + } + + /// Allocates a new [`Entity`], reusing a freed index if one exists. + #[cfg(test)] + fn alloc(&self) -> Entity { + self.try_alloc().expect("out of entities") + } + /// Allocates a new [`Entity`], reusing a freed index if one exists. + /// Returns None if no entities are available within the range #[inline] - pub(super) fn alloc(&self) -> Entity { + pub(super) fn try_alloc(&self) -> Option { // SAFETY: violating safety requires a `&mut self` to exist, but rust does not allow that. - unsafe { self.shared.alloc() } + unsafe { self.shared.try_alloc() } } /// The total number of indices given out. @@ -1057,8 +1085,8 @@ impl RemoteAllocator { /// They will not be unique in the world anymore and you should not spawn them! /// Before using the returned values in the world, first check that it is ok with [`EntityAllocator::has_remote_allocator`](super::EntityAllocator::has_remote_allocator). #[inline] - pub fn alloc(&self) -> Entity { - self.shared.remote_alloc() + pub fn alloc(&self) -> Option { + self.shared.try_remote_alloc() } /// Returns whether or not this [`RemoteAllocator`] is still connected to its source [`EntityAllocator`](super::EntityAllocator). @@ -1133,7 +1161,7 @@ mod tests { #[test] fn uniqueness() { let mut entities = Vec::with_capacity(2000); - let mut allocator = Allocator::new(); + let mut allocator = Allocator::default(); entities.extend(allocator.alloc_many(1000)); let pre_len = entities.len(); @@ -1159,7 +1187,7 @@ mod tests { /// This test just exists to make sure allocations don't step on each other's toes. #[test] fn allocation_order_correctness() { - let mut allocator = Allocator::new(); + let mut allocator = Allocator::default(); let e0 = allocator.alloc(); let e1 = allocator.alloc(); let e2 = allocator.alloc(); diff --git a/crates/bevy_image/Cargo.toml b/crates/bevy_image/Cargo.toml index b6cc45f9e7653..103cd7fc767b8 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 (cross-platform, transcodes to any GPU format at load time) +compressed_image_saver_universal = ["basis-universal"] + +# Texture compression asset processor (BCn for desktop, ASTC for mobile via env var) +compressed_image_saver = ["dep:ctt", "ktx2", "zstd"] [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.3", optional = true } [dev-dependencies] bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } diff --git a/crates/bevy_image/src/compressed_image_saver/ctt.rs b/crates/bevy_image/src/compressed_image_saver/ctt.rs new file mode 100644 index 0000000000000..18c14796dfdfe --- /dev/null +++ b/crates/bevy_image/src/compressed_image_saver/ctt.rs @@ -0,0 +1,107 @@ +use bevy_asset::{io::Writer, saver::SavedAsset, AssetPath, AsyncWriteExt}; + +use super::{ + ctt_helpers::{ + bevy_to_ctt_alpha_mode, choose_ctt_compressed_format, wgpu_to_ctt_texture_format, + }, + CompressedImageSaverError, CompressedImageSaverSettings, +}; +use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoaderSettings}; + +#[derive(Default)] +pub struct CompressedImageSaverCtt; + +impl CompressedImageSaverCtt { + pub async fn save( + &self, + writer: &mut Writer, + image: SavedAsset<'_, '_, Image>, + settings: &CompressedImageSaverSettings, + _asset_path: AssetPath<'_>, + ) -> Result { + let Some(ref data) = image.data else { + 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 input_format = wgpu_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::ColorSpace::Srgb + } else { + ctt::ColorSpace::Linear + }; + + let is_cubemap = matches!( + image.texture_view_descriptor, + Some(wgpu_types::TextureViewDescriptor { + dimension: Some(wgpu_types::TextureViewDimension::Cube), + .. + }) + ); + + 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: image.width() * bytes_per_pixel, + format: input_format, + color_space, + alpha: bevy_to_ctt_alpha_mode(settings.input_alpha_mode), + }] + }) + .collect(); + let ctt_image = ctt::Image { + surfaces, + is_cubemap, + }; + + let settings = ctt::ConvertSettings { + format: Some(output_format), + container: ctt::Container::ktx2_zstd(0), + quality: ctt::Quality::default(), + output_color_space: None, + output_alpha: Some(bevy_to_ctt_alpha_mode(settings.output_alpha_mode)), + swizzle: None, + mipmap: true, + mipmap_count: None, + mipmap_filter: ctt::MipmapFilter::default(), + encoder_settings: None, + registry: None, + }; + + let output = ctt::convert(ctt_image, settings) + .map_err(|e| CompressedImageSaverError::CompressionFailed(Box::new(e)))?; + let ctt::PipelineOutput::Encoded(compressed_bytes) = &output else { + return Err(CompressedImageSaverError::CompressionFailed( + "Expected encoded output from ctt".into(), + )); + }; + + 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, + }) + } +} diff --git a/crates/bevy_image/src/compressed_image_saver/ctt_helpers.rs b/crates/bevy_image/src/compressed_image_saver/ctt_helpers.rs new file mode 100644 index 0000000000000..5b1c13051a3a0 --- /dev/null +++ b/crates/bevy_image/src/compressed_image_saver/ctt_helpers.rs @@ -0,0 +1,347 @@ +use std::env; + +use ctt::{AlphaMode, TargetFormat}; +use ktx2::Format; +use wgpu_types::{AstcBlock, AstcChannel, TextureFormat}; + +use super::{CompressedImageSaverError, ImageCompressorAlphaMode}; + +/// Returns `Some((unorm, hdr))` ASTC format pair if the env var is set, `None` otherwise. +pub fn parse_astc_env_var() -> Result, CompressedImageSaverError> { + let Ok(val) = env::var("BEVY_COMPRESSED_IMAGE_SAVER_ASTC") else { + return Ok(None); + }; + + let val = val.trim(); + let (unorm, hdr) = match val { + "" | "1" | "4x4" => (Format::ASTC_4x4_UNORM_BLOCK, Format::ASTC_4x4_SFLOAT_BLOCK), + "5x4" => (Format::ASTC_5x4_UNORM_BLOCK, Format::ASTC_5x4_SFLOAT_BLOCK), + "5x5" => (Format::ASTC_5x5_UNORM_BLOCK, Format::ASTC_5x5_SFLOAT_BLOCK), + "6x5" => (Format::ASTC_6x5_UNORM_BLOCK, Format::ASTC_6x5_SFLOAT_BLOCK), + "6x6" => (Format::ASTC_6x6_UNORM_BLOCK, Format::ASTC_6x6_SFLOAT_BLOCK), + "8x5" => (Format::ASTC_8x5_UNORM_BLOCK, Format::ASTC_8x5_SFLOAT_BLOCK), + "8x6" => (Format::ASTC_8x6_UNORM_BLOCK, Format::ASTC_8x6_SFLOAT_BLOCK), + "8x8" => (Format::ASTC_8x8_UNORM_BLOCK, Format::ASTC_8x8_SFLOAT_BLOCK), + "10x5" => ( + Format::ASTC_10x5_UNORM_BLOCK, + Format::ASTC_10x5_SFLOAT_BLOCK, + ), + "10x6" => ( + Format::ASTC_10x6_UNORM_BLOCK, + Format::ASTC_10x6_SFLOAT_BLOCK, + ), + "10x8" => ( + Format::ASTC_10x8_UNORM_BLOCK, + Format::ASTC_10x8_SFLOAT_BLOCK, + ), + "10x10" => ( + Format::ASTC_10x10_UNORM_BLOCK, + Format::ASTC_10x10_SFLOAT_BLOCK, + ), + "12x10" => ( + Format::ASTC_12x10_UNORM_BLOCK, + Format::ASTC_12x10_SFLOAT_BLOCK, + ), + "12x12" => ( + Format::ASTC_12x12_UNORM_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, hdr))) +} + +pub fn choose_ctt_compressed_format( + input: TextureFormat, +) -> Result { + let astc_block = parse_astc_env_var()?; + + let format = match input { + // 1-channel snorm (ASTC has no snorm variant, pass through uncompressed if ASTC is preferred) + TextureFormat::R8Snorm => { + if astc_block.is_some() { + return Ok(TargetFormat::Uncompressed(wgpu_to_ctt_texture_format( + input, + )?)); + } + Format::BC4_SNORM_BLOCK + } + + // 1-channel + TextureFormat::R8Unorm => { + if let Some((astc_unorm, _)) = astc_block { + astc_unorm + } else { + Format::BC4_UNORM_BLOCK + } + } + + // 2-channel snorm (ASTC has no snorm variant, pass through uncompressed if ASTC is preferred) + TextureFormat::Rg8Snorm => { + if astc_block.is_some() { + return Ok(TargetFormat::Uncompressed(wgpu_to_ctt_texture_format( + input, + )?)); + } + Format::BC5_SNORM_BLOCK + } + + // 2-channel + TextureFormat::Rg8Unorm => { + if let Some((astc_unorm, _)) = astc_block { + astc_unorm + } else { + Format::BC5_UNORM_BLOCK + } + } + + // HDR / float formats + TextureFormat::Rgb9e5Ufloat + | TextureFormat::Rg11b10Ufloat + | TextureFormat::R16Float + | TextureFormat::Rg16Float + | TextureFormat::Rgba16Float => { + if let Some((_, astc_hdr)) = astc_block { + astc_hdr + } else { + Format::BC6H_UFLOAT_BLOCK + } + } + + // 4-channel LDR + TextureFormat::Rgba8Unorm + | TextureFormat::Rgba8UnormSrgb + | TextureFormat::Bgra8Unorm + | TextureFormat::Bgra8UnormSrgb + | TextureFormat::Rgb10a2Unorm => { + if let Some((astc_unorm, _)) = astc_block { + astc_unorm + } else { + Format::BC7_UNORM_BLOCK + } + } + + // Already compressed -> pass through as compressed + 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 { .. } => wgpu_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::R32Uint + | TextureFormat::R32Sint + | TextureFormat::R32Float + | TextureFormat::R64Uint + | TextureFormat::Rg8Uint + | TextureFormat::Rg8Sint + | TextureFormat::Rg16Uint + | TextureFormat::Rg16Sint + | TextureFormat::Rg16Unorm + | TextureFormat::Rg16Snorm + | TextureFormat::Rg32Uint + | TextureFormat::Rg32Sint + | TextureFormat::Rg32Float + | TextureFormat::Rgba8Uint + | TextureFormat::Rgba8Sint + | TextureFormat::Rgba8Snorm + | TextureFormat::Rgba16Uint + | TextureFormat::Rgba16Sint + | TextureFormat::Rgba16Unorm + | TextureFormat::Rgba16Snorm + | TextureFormat::Rgba32Uint + | TextureFormat::Rgba32Sint + | TextureFormat::Rgba32Float + | TextureFormat::Rgb10a2Uint => { + return Ok(TargetFormat::Uncompressed(wgpu_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(TargetFormat::Compressed { + encoder_name: None, + format, + }) +} + +pub fn wgpu_to_ctt_texture_format( + input: TextureFormat, +) -> Result { + 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, + }, + }) +} + +pub fn bevy_to_ctt_alpha_mode(alpha_mode: ImageCompressorAlphaMode) -> AlphaMode { + match alpha_mode { + ImageCompressorAlphaMode::Straight => AlphaMode::Straight, + ImageCompressorAlphaMode::Premultiplied => AlphaMode::Premultiplied, + ImageCompressorAlphaMode::Opaque => AlphaMode::Opaque, + } +} diff --git a/crates/bevy_image/src/compressed_image_saver/mod.rs b/crates/bevy_image/src/compressed_image_saver/mod.rs new file mode 100644 index 0000000000000..084762b5df6d4 --- /dev/null +++ b/crates/bevy_image/src/compressed_image_saver/mod.rs @@ -0,0 +1,194 @@ +#[cfg(feature = "compressed_image_saver")] +mod ctt; +#[cfg(feature = "compressed_image_saver")] +mod ctt_helpers; +#[cfg(all( + feature = "compressed_image_saver_universal", + not(feature = "compressed_image_saver") +))] +mod universal; + +#[cfg(feature = "compressed_image_saver")] +use crate::compressed_image_saver::ctt::CompressedImageSaverCtt; +#[cfg(all( + feature = "compressed_image_saver_universal", + not(feature = "compressed_image_saver") +))] +use crate::compressed_image_saver::universal::CompressedImageSaverUniversal; +use crate::{Image, ImageLoader, ImageLoaderSettings}; + +use bevy_asset::{ + io::Writer, + saver::{AssetSaver, SavedAsset}, + AssetPath, +}; +use bevy_reflect::TypePath; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use wgpu_types::TextureFormat; + +/// An [`AssetSaver`] for [`Image`] that compresses texture files. +/// +/// Compressed textures use less GPU VRAM and are faster to sample. +/// +/// # Platform support +/// +/// Two mutually exclusive feature flags control which compression backend is used: +/// +/// - **`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_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 in a single file. +/// +/// # Runtime feature flags +/// +/// The compressed output must also be loadable at runtime. Enable the corresponding feature: +/// +/// - **`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 +/// +/// The output format is chosen automatically based on the input texture's channel count and type: +/// +/// | Input channels | Output format | +/// |---|---| +/// | 1-channel (`R8Unorm`) | BC4 | +/// | 1-channel snorm (`R8Snorm`) | BC4 snorm | +/// | 2-channel (`Rg8Unorm`) | BC5 | +/// | 2-channel snorm (`Rg8Snorm`) | BC5 snorm | +/// | 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) | +/// | 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`]. +/// +/// # 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 (`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. +/// +/// The `compressed_image_saver_universal` backend does not generate mipmaps. +#[derive(TypePath, Default)] +#[expect(clippy::doc_markdown, reason = "clippy does not like unquoted BCn")] +pub struct CompressedImageSaver { + #[cfg(feature = "compressed_image_saver")] + inner: CompressedImageSaverCtt, + #[cfg(all( + feature = "compressed_image_saver_universal", + not(feature = "compressed_image_saver") + ))] + inner: CompressedImageSaverUniversal, +} + +impl AssetSaver for CompressedImageSaver { + type Asset = Image; + + type Settings = CompressedImageSaverSettings; + type OutputLoader = ImageLoader; + type Error = CompressedImageSaverError; + + async fn save( + &self, + writer: &mut Writer, + asset: SavedAsset<'_, '_, Self::Asset>, + settings: &Self::Settings, + asset_path: AssetPath<'_>, + ) -> Result { + self.inner.save(writer, asset, settings, asset_path).await + } +} + +/// Settings for [`CompressedImageSaver`]. +#[derive(Serialize, Deserialize)] +#[serde(default)] +pub struct CompressedImageSaverSettings { + /// The alpha mode the source image is authored in. + /// + /// Set this to match how the input texture stores its alpha channel. If the input does not + /// match `output_alpha_mode`, the saver converts between the two before compression. + /// + /// Defaults to [`ImageCompressorAlphaMode::Straight`], which is how most image editors and asset pipelines + /// produce textures. + pub input_alpha_mode: ImageCompressorAlphaMode, + /// The alpha mode the compressed output should use. + /// + /// With straight alpha, the RGB values of fully-transparent texels still consume endpoint + /// precision in block-compressed formats and can bleed into neighboring opaque texels under + /// texture filtering, producing colored fringes at transparent edges. Premultiplying forces + /// transparent texels to zero RGB, which avoids both problems. + /// + /// Defaults to [`ImageCompressorAlphaMode::Premultiplied`]. Materials sampling this texture must be + /// configured with `bevy_material::AlphaMode::Premultiplied` (or another premultiplied-blend + /// mode) so the blend state matches. + pub output_alpha_mode: ImageCompressorAlphaMode, +} + +impl Default for CompressedImageSaverSettings { + fn default() -> Self { + Self { + input_alpha_mode: ImageCompressorAlphaMode::Straight, + output_alpha_mode: ImageCompressorAlphaMode::Premultiplied, + } + } +} + +/// Alpha mode of an [`Image`] for use with [`CompressedImageSaver`]. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ImageCompressorAlphaMode { + /// The image has an alpha channel that is stored independently of the RGB channels. + Straight, + /// The image has an alpha channel, and the RGB channels have been premultiplied by the alpha value. + Premultiplied, + /// The image has no alpha channel. + Opaque, +} + +/// 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, + /// The texture format is not supported for compression. + #[error("Unsupported texture format for compression: {0:?}")] + UnsupportedFormat(TextureFormat), +} diff --git a/crates/bevy_image/src/compressed_image_saver.rs b/crates/bevy_image/src/compressed_image_saver/universal.rs similarity index 50% rename from crates/bevy_image/src/compressed_image_saver.rs rename to crates/bevy_image/src/compressed_image_saver/universal.rs index 6b6348a1a3c30..be98e92a25da0 100644 --- a/crates/bevy_image/src/compressed_image_saver.rs +++ b/crates/bevy_image/src/compressed_image_saver/universal.rs @@ -1,56 +1,37 @@ -use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoader, ImageLoaderSettings}; +use bevy_asset::{io::Writer, saver::SavedAsset, AssetPath, AsyncWriteExt}; -use bevy_asset::{ - saver::{AssetSaver, SavedAsset}, - AssetPath, -}; -use bevy_reflect::TypePath; -use futures_lite::AsyncWriteExt; -use thiserror::Error; - -/// An [`AssetSaver`] that writes compressed basis universal (.ktx2) files. -#[derive(TypePath)] -pub struct CompressedImageSaver; - -/// Errors encountered when writing compressed images. -#[non_exhaustive] -#[derive(Debug, Error, TypePath)] -pub enum CompressedImageSaverError { - /// I/O error. - #[error(transparent)] - Io(#[from] std::io::Error), - /// Attempted to save an image with uninitialized data. - #[error("Cannot compress an uninitialized image")] - UninitializedImage, -} +use super::{CompressedImageSaverError, CompressedImageSaverSettings}; +use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoaderSettings}; -impl AssetSaver for CompressedImageSaver { - type Asset = Image; +use basis_universal::{ + BasisTextureFormat, ColorSpace, Compressor, CompressorParams, UASTC_QUALITY_DEFAULT, +}; - type Settings = (); - type OutputLoader = ImageLoader; - type Error = CompressedImageSaverError; +#[derive(Default)] +pub struct CompressedImageSaverUniversal; - async fn save( +impl CompressedImageSaverUniversal { + pub async fn save( &self, - writer: &mut bevy_asset::io::Writer, - image: SavedAsset<'_, '_, Self::Asset>, - _settings: &Self::Settings, + writer: &mut Writer, + image: SavedAsset<'_, '_, Image>, + _settings: &CompressedImageSaverSettings, _asset_path: AssetPath<'_>, - ) -> Result { + ) -> Result { let is_srgb = image.texture_descriptor.format.is_srgb(); let compressed_basis_data = { - let mut compressor_params = basis_universal::CompressorParams::new(); - compressor_params.set_basis_format(basis_universal::BasisTextureFormat::UASTC4x4); + let mut compressor_params = CompressorParams::new(); + compressor_params.set_basis_format(BasisTextureFormat::UASTC4x4); compressor_params.set_generate_mipmaps(true); let color_space = if is_srgb { - basis_universal::ColorSpace::Srgb + ColorSpace::Srgb } else { - basis_universal::ColorSpace::Linear + compressor_params.set_no_selector_rdo(true); + ColorSpace::Linear }; compressor_params.set_color_space(color_space); - compressor_params.set_uastc_quality_level(basis_universal::UASTC_QUALITY_DEFAULT); + compressor_params.set_uastc_quality_level(UASTC_QUALITY_DEFAULT); let mut source_image = compressor_params.source_image_mut(0); let size = image.size(); @@ -59,7 +40,7 @@ impl AssetSaver for CompressedImageSaver { }; source_image.init(data, size.x, size.y, 4); - let mut compressor = basis_universal::Compressor::new(4); + let mut compressor = Compressor::new(4); #[expect( unsafe_code, reason = "The basis-universal compressor cannot be interacted with except through unsafe functions" @@ -68,12 +49,15 @@ 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| { + CompressedImageSaverError::CompressionFailed(format!("{e:?}").into()) + })?; } compressor.basis_file().to_vec() }; writer.write_all(&compressed_basis_data).await?; + Ok(ImageLoaderSettings { format: ImageFormatSetting::Format(ImageFormat::Basis), is_srgb, diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index 8f383d7759f1e..97a86b2b5e2b0 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -225,7 +225,10 @@ impl Plugin for ImagePlugin { .insert(&TRANSPARENT_IMAGE_HANDLE, Image::transparent()) .unwrap(); - #[cfg(feature = "compressed_image_saver")] + #[cfg(any( + feature = "compressed_image_saver", + feature = "compressed_image_saver_universal" + ))] if let Some(processor) = app .world() .get_resource::() @@ -234,12 +237,15 @@ impl Plugin for ImagePlugin { ImageLoader, bevy_asset::transformer::IdentityAssetTransformer, crate::CompressedImageSaver, - >>(crate::CompressedImageSaver.into()); - processor.set_default_processor::, - crate::CompressedImageSaver, - >>("png"); + >>(crate::CompressedImageSaver::default().into()); + + 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/image_texture_conversion.rs b/crates/bevy_image/src/image_texture_conversion.rs index f9af22a21327c..688cf1d631a2e 100644 --- a/crates/bevy_image/src/image_texture_conversion.rs +++ b/crates/bevy_image/src/image_texture_conversion.rs @@ -20,16 +20,11 @@ impl Image { match dyn_img { DynamicImage::ImageLuma8(image) => { - let i = DynamicImage::ImageLuma8(image).into_rgba8(); - width = i.width(); - height = i.height(); - format = if is_srgb { - TextureFormat::Rgba8UnormSrgb - } else { - TextureFormat::Rgba8Unorm - }; + width = image.width(); + height = image.height(); + format = TextureFormat::R8Unorm; - data = i.into_raw(); + data = image.into_raw(); } DynamicImage::ImageLumaA8(image) => { let i = DynamicImage::ImageLumaA8(image).into_rgba8(); diff --git a/crates/bevy_image/src/lib.rs b/crates/bevy_image/src/lib.rs index 8979ddb991367..1f87a10dd91c8 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", + feature = "compressed_image_saver_universal" +))] 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", + feature = "compressed_image_saver_universal" +))] 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 eeca37f7c484d..0bef966882ffa 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -29,7 +29,12 @@ 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 +# 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 (BCn for desktop, ASTC for mobile via env var) compressed_image_saver = ["bevy_image/compressed_image_saver"] # For ktx2 supercompression diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 8dcf77c0ba59c..956068a4b471e 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -114,7 +114,8 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio |bluenoise_texture|Include spatio-temporal blue noise KTX2 file used by generated environment maps, Solari and atmosphere| |bmp|BMP image format support| |clipboard_image|Enables image copy/paste via the system clipboard. Not supported on WASM.| -|compressed_image_saver|Enables compressed KTX2 UASTC texture output on the asset processor| +|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| diff --git a/examples/README.md b/examples/README.md index 0871132574246..1006dc427909a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -262,6 +262,7 @@ Example | Description [Asset Saving](../examples/asset/asset_saving.rs) | Demonstrates how to save an asset [Asset Saving with Subassets](../examples/asset/asset_saving_with_subassets.rs) | Demonstrates how to save an asset with subassets [Asset Settings](../examples/asset/asset_settings.rs) | Demonstrates various methods of applying settings when loading an asset +[Compressed Image Saver](../examples/asset/compressed_image_saver.rs) | Demonstrates compressing textures and generating mipmaps using CompressedImageSaver [Custom Asset](../examples/asset/custom_asset.rs) | Implements a custom asset loader [Custom Asset IO](../examples/asset/custom_asset_reader.rs) | Implements a custom AssetReader [Embedded Asset](../examples/asset/embedded_asset.rs) | Embed an asset in the application binary and load it diff --git a/examples/asset/compressed_image_saver.rs b/examples/asset/compressed_image_saver.rs new file mode 100644 index 0000000000000..6959584b9878b --- /dev/null +++ b/examples/asset/compressed_image_saver.rs @@ -0,0 +1,101 @@ +//! Demonstrates compressing textures and generating mipmaps using `CompressedImageSaver`. + +use bevy::{ + camera::Hdr, + light::{CascadeShadowConfigBuilder, DirectionalLightShadowMap}, + mesh::SphereKind, + prelude::*, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(AssetPlugin { + mode: AssetMode::Processed, + ..default() + })) + .insert_resource(DirectionalLightShadowMap { size: 4096 }) + .add_systems(Startup, spawn_scene) + .add_systems(Update, rotate) + .run(); +} + +fn spawn_scene( + asset_server: Res, + mut meshes: ResMut>, + mut commands: Commands, +) { + let orm = asset_server.load("textures/GroundSand005/GroundSand005_ORM_2K.png"); + let sphere_material = StandardMaterial { + base_color_texture: Some( + asset_server.load("textures/GroundSand005/GroundSand005_COL_2K.jpg"), + ), + perceptual_roughness: 1.0, + metallic_roughness_texture: Some(orm.clone()), + normal_map_texture: Some( + asset_server.load("textures/GroundSand005/GroundSand005_NRM_2K.jpg"), + ), + occlusion_texture: Some(orm), + parallax_mapping_method: ParallaxMappingMethod::Relief { max_steps: 4 }, + depth_map: Some(asset_server.load("textures/GroundSand005/GroundSand005_DISP_2K.jpg")), + ..Default::default() + }; + + let diffuse_env_map = asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"); + let specular_env_map = asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"); + + let floor_mesh = meshes.add(Circle::new(4.0).mesh().resolution(128).build()); + + let sphere_mesh = meshes.add( + Sphere::new(1.0) + .mesh() + .kind(SphereKind::Ico { subdivisions: 50 }) + .build() + .with_generated_tangents() + .unwrap(), + ); + + commands.spawn_scene_list(bsn_list! [ + ( + Mesh3d(floor_mesh) + MeshMaterial3d::(asset_value(Color::WHITE)) + Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)) + ), + ( + Mesh3d(sphere_mesh) + MeshMaterial3d::(asset_value(sphere_material)) + Transform::from_xyz(0.0, 1.0, 0.0) + Rotating + ), + ( + DirectionalLight { + illuminance: 7300.0, + shadow_maps_enabled: true, + } + template_value(Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y)) + template_value(CascadeShadowConfigBuilder { + num_cascades: 1, + maximum_distance: 20.0, + ..default() + }.build()) + ), + ( + Camera3d + Hdr + template_value(Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y)) + EnvironmentMapLight { + diffuse_map: diffuse_env_map, + specular_map: specular_env_map, + intensity: 1200.0, + } + ) + ]); +} + +#[derive(Component, Default, Clone)] +struct Rotating; + +fn rotate(mut query: Query<&mut Transform, With>, time: Res