Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f28892c
WIP
JMS55 Mar 29, 2026
18cfdbb
add todo
JMS55 Mar 29, 2026
ef98abd
WIP
JMS55 Mar 29, 2026
ad31db5
Disable selector RDO for linear, based on tune_for_normal_maps
JMS55 Mar 29, 2026
a0d8313
WIP
JMS55 Mar 29, 2026
23f25b2
Misc
JMS55 Mar 29, 2026
bd2ed87
WIP
JMS55 Mar 29, 2026
fa171ae
Assert
JMS55 Mar 29, 2026
c2454e1
WIP
JMS55 Mar 29, 2026
73d0687
Merge commit '842fa5c4559303edbd1774013bc0f3d0dd529799' into compress…
JMS55 Apr 3, 2026
108518d
Merge commit '6dc30ca38789a1023df17649198b006e1df9cf5c' into compress…
JMS55 Apr 12, 2026
542d427
WIP
JMS55 Apr 12, 2026
a027777
Add TODO
JMS55 Apr 12, 2026
f1346ba
Misc
JMS55 Apr 12, 2026
48f9c44
Docs
JMS55 Apr 12, 2026
5b90771
Fix docs
JMS55 Apr 12, 2026
f92fb42
ASTC support
JMS55 Apr 12, 2026
8b721de
Docs
JMS55 Apr 12, 2026
f71c585
Feedback
JMS55 Apr 12, 2026
595ebf4
Doc tweaks
JMS55 Apr 12, 2026
3978fbf
Typo
JMS55 Apr 12, 2026
5f0f2b2
Use BC6H for rgba16float
JMS55 Apr 12, 2026
6e1255c
Zstd supercompression
JMS55 Apr 14, 2026
31f74f4
Use published versions
JMS55 Apr 20, 2026
41c550a
Migration
JMS55 Apr 20, 2026
bd7dcc2
Merge branch 'main' into compressed-image-saver2
JMS55 Apr 20, 2026
f036f14
Merge commit '489818930b7ec268455fe371b3c5b0fb1c0c46c3' into compress…
JMS55 May 2, 2026
cf1c4a7
Fix merge
JMS55 May 2, 2026
ff2024a
Fixes
JMS55 May 2, 2026
277c7bc
Fix grayscale conversion
JMS55 May 2, 2026
7ad83ba
Bugfix
JMS55 May 2, 2026
996d8a5
Add example
JMS55 May 2, 2026
e19d6ab
Merge branch 'main' into compressed-image-saver2
JMS55 May 2, 2026
975526e
Update release notes for CompressedImageSaver
JMS55 May 2, 2026
a8f0e0d
Build templated pages
JMS55 May 3, 2026
83470c1
Split processor up
JMS55 May 3, 2026
6fdfc8f
Clippy
JMS55 May 3, 2026
3b28d93
User-configurable alpha modes, clippy
JMS55 May 3, 2026
d7295c2
Clippy
JMS55 May 3, 2026
2a7c4bb
Clippy I hate you
JMS55 May 3, 2026
2a6d7d5
Require zstd
JMS55 May 4, 2026
ba11f5b
Alpha mode rename
JMS55 May 4, 2026
5e2b49a
Merge branch 'main' into compressed-image-saver2
JMS55 May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Cargo.lock

# Bevy Assets
assets/**/*.meta
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could remove this now that we don't auto generate meta files, but let's not do that in this PR.

!assets/textures/GroundSand005/*.meta
crates/bevy_asset/imported_assets
imported_assets
.web-asset-cache
Expand Down
19 changes: 18 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2080,6 +2085,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"
Expand Down
20 changes: 20 additions & 0 deletions _release-content/migration-guides/compressed_image_saver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: "`CompressedImageSaver` improvements"
pull_requests: [23567]
---
Comment thread
alice-i-cecile marked this conversation as resolved.

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.
23 changes: 23 additions & 0 deletions _release-content/release-notes/compressed_image_saver.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions assets/textures/GroundSand005/GroundSand005_COL_2K.jpg.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
(
meta_format_version: "1.0",
asset: Process(
processor: "LoadTransformAndSave<ImageLoader, IdentityAssetTransformer<Image>, CompressedImageSaver>",
settings: (
loader_settings: (
format: FromExtension,
is_srgb: true,
sampler: Default,
asset_usage: ("RENDER_WORLD"),
),
transformer_settings: (),
saver_settings: (),
),
),
)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions assets/textures/GroundSand005/GroundSand005_DISP_2K.jpg.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
(
meta_format_version: "1.0",
asset: Process(
processor: "LoadTransformAndSave<ImageLoader, IdentityAssetTransformer<Image>, CompressedImageSaver>",
settings: (
loader_settings: (
format: FromExtension,
is_srgb: false,
sampler: Default,
asset_usage: ("RENDER_WORLD"),
texture_format: Some("r8unorm"),
),
transformer_settings: (),
saver_settings: (),
),
),
)
Comment thread
JMS55 marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions assets/textures/GroundSand005/GroundSand005_NRM_2K.jpg.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
(
meta_format_version: "1.0",
asset: Process(
processor: "LoadTransformAndSave<ImageLoader, IdentityAssetTransformer<Image>, CompressedImageSaver>",
settings: (
loader_settings: (
format: FromExtension,
is_srgb: false,
sampler: Default,
asset_usage: ("RENDER_WORLD"),
),
transformer_settings: (),
saver_settings: (),
),
),
)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions assets/textures/GroundSand005/GroundSand005_ORM_2K.png.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
(
meta_format_version: "1.0",
asset: Process(
processor: "LoadTransformAndSave<ImageLoader, IdentityAssetTransformer<Image>, CompressedImageSaver>",
settings: (
loader_settings: (
format: FromExtension,
is_srgb: false,
sampler: Default,
asset_usage: ("RENDER_WORLD"),
),
transformer_settings: (),
saver_settings: (),
),
),
)
1 change: 1 addition & 0 deletions assets/textures/GroundSand005/source.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
https://www.poliigon.com/texture/rippled-wet-sand-texture/6997
8 changes: 6 additions & 2 deletions crates/bevy_image/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down
107 changes: 107 additions & 0 deletions crates/bevy_image/src/compressed_image_saver/ctt.rs
Original file line number Diff line number Diff line change
@@ -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<ImageLoaderSettings, CompressedImageSaverError> {
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,
})
}
}
Loading