Skip to main content

bevy_image/
saver.rs

1use std::io::Cursor;
2
3use bevy_asset::{saver::AssetSaver, AssetPath, AsyncWriteExt};
4use bevy_reflect::TypePath;
5use image::{write_buffer_with_format, ExtendedColorType};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use wgpu_types::TextureFormat;
9
10use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoader, ImageLoaderSettings};
11
12/// [`AssetSaver`] for images that can be saved by the `image` crate.
13///
14/// Unlike `CompressedImageSaver`, this does not attempt to do any "texture optimization", like
15/// compression (though some file formats intrinsically perform some compression, e.g., JPEG).
16///
17/// Some file formats do not support all texture formats (e.g., PNG does not support
18/// [`TextureFormat::Rg8Unorm`]). In some cases, [`ImageSaver`] will convert the image to allow
19/// writing as the requested file format.
20#[derive(Clone, TypePath)]
21pub struct ImageSaver;
22
23impl AssetSaver for ImageSaver {
24    type Asset = Image;
25    type Error = SaveImageError;
26    type OutputLoader = ImageLoader;
27    type Settings = ImageSaverSettings;
28
29    async fn save(
30        &self,
31        _writer: &mut bevy_asset::io::Writer,
32        asset: bevy_asset::saver::SavedAsset<'_, '_, Self::Asset>,
33        settings: &Self::Settings,
34        asset_path: AssetPath<'_>,
35    ) -> Result<ImageLoaderSettings, Self::Error> {
36        let format = match settings.format {
37            SaveImageFormatSetting::Format(format) => format,
38            SaveImageFormatSetting::FromExtension => match asset_path.get_extension() {
39                None => return Err(SaveImageError::MissingExtension(asset_path.into_owned())),
40                Some(extension) => ImageFormat::from_extension(extension)
41                    .ok_or_else(|| SaveImageError::UnknownExtension(extension.to_owned()))?,
42            },
43        };
44
45        let Some(_asset_data) = asset.data.as_ref() else {
46            return Err(SaveImageError::ImageMissingData);
47        };
48
49        // TODO: Consider supporting more formats here!
50        let (image_crate_format, color_type, is_srgb): (_, ExtendedColorType, _) = match format {
51            #[cfg(feature = "png")]
52            ImageFormat::Png => match asset.texture_descriptor.format {
53                TextureFormat::R8Unorm => (image::ImageFormat::Png, ExtendedColorType::L8, false),
54                TextureFormat::Rgba8Unorm => {
55                    (image::ImageFormat::Png, ExtendedColorType::Rgba8, false)
56                }
57                TextureFormat::Rgba8UnormSrgb => {
58                    (image::ImageFormat::Png, ExtendedColorType::Rgba8, true)
59                }
60                _ => {
61                    return Err(SaveImageError::UnsupportedSaveColorTypeForFormat(
62                        ImageFormat::Png,
63                        asset.texture_descriptor.format,
64                    ))
65                }
66            },
67            // FIXME: https://github.com/rust-lang/rust/issues/129031
68            #[expect(
69                clippy::allow_attributes,
70                reason = "`unreachable_patterns` may not always lint"
71            )]
72            #[allow(
73                unreachable_patterns,
74                reason = "The wildcard pattern will be unreachable if only save-able formats are enabled"
75            )]
76            _ => return Err(SaveImageError::UnsupportedFormat(format)),
77        };
78
79        #[expect(clippy::allow_attributes, reason = "this lint only sometimes lints")]
80        #[allow(
81            unreachable_code,
82            reason = "this code is unreachable if none of the supported save formats are enabled"
83        )]
84        let mut bytes = vec![];
85        write_buffer_with_format(
86            &mut Cursor::new(&mut bytes),
87            _asset_data,
88            asset.width(),
89            asset.height(),
90            color_type,
91            image_crate_format,
92        )?;
93
94        _writer.write_all(&bytes).await?;
95
96        Ok(ImageLoaderSettings {
97            format: ImageFormatSetting::Format(format),
98            // Passing in the original texture format breaks things. For example, PNG will save R8
99            // data as RGBA8 data: if we later try to load as R8, we get 4 times as many pixels!
100            texture_format: None,
101            is_srgb,
102            sampler: asset.sampler.clone(),
103            asset_usage: asset.asset_usage,
104            array_layout: None,
105        })
106    }
107}
108
109/// Settings for how to save an image.
110#[derive(Serialize, Deserialize, Default, Clone, Debug)]
111pub struct ImageSaverSettings {
112    /// Defines the file format that the image will be saved as.
113    pub format: SaveImageFormatSetting,
114}
115
116/// The setting for how to choose which file-format to use.
117#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug)]
118pub enum SaveImageFormatSetting {
119    /// The file format to write will be deduced from the file path being written to.
120    #[default]
121    FromExtension,
122    /// This is the explicit file format being written.
123    Format(ImageFormat),
124}
125
126/// An error while saving an image.
127#[derive(Error, Debug)]
128pub enum SaveImageError {
129    /// Cannot deduce file format from extension because there is no extension.
130    #[error("SaveImageFormatSetting::FromExtension was set, but the asset path \"{0}\" has no extension")]
131    MissingExtension(AssetPath<'static>),
132    /// Cannot deduce file format from extension since this extension is unknown. Holds the
133    /// extension that could not be matched.
134    #[error("could not determine asset format for extension \"{0}\"")]
135    UnknownExtension(String),
136    /// [`Image::data`] is [`None`], so there is no data to save. See
137    /// [`RenderAssetUsages`](bevy_asset::RenderAssetUsages) for more.
138    #[error("the provided image does not contain any pixel data. Its data may live on the GPU (which we can't save out) due to `RenderAssetUsages`")]
139    ImageMissingData,
140    /// The image saver doesn't support the file format being requested.
141    #[error("the requested file format {0:?} is not supported for saving")]
142    UnsupportedFormat(ImageFormat),
143    /// The image saver doesn't support the texture format of the image data for the image format.
144    #[error("the image uses a texture format \"{1:?}\" that is not supported for saving by the image format \"{0:?}\"")]
145    UnsupportedSaveColorTypeForFormat(ImageFormat, TextureFormat),
146    /// The [`image`] crate returned an error.
147    #[error(transparent)]
148    ImageError(#[from] image::ImageError),
149    /// Writing the bytes returned an error.
150    #[error(transparent)]
151    IoError(#[from] std::io::Error),
152}
153
154#[cfg(test)]
155mod tests {
156    use std::path::Path;
157
158    use bevy_app::{App, TaskPoolPlugin};
159    use bevy_asset::{
160        io::{
161            memory::{Dir, MemoryAssetReader, MemoryAssetWriter},
162            AssetSourceBuilder, AssetSourceId,
163        },
164        saver::{save_using_saver, SavedAsset},
165        AssetApp, AssetPath, AssetPlugin, AssetServer, Assets, RenderAssetUsages,
166    };
167    use bevy_color::Srgba;
168    use bevy_ecs::world::World;
169    use bevy_math::UVec2;
170    use bevy_platform::future::block_on;
171    use wgpu_types::TextureFormat;
172
173    use crate::{
174        CompressedImageFormats, Image, ImageLoader, ImageSaver, ImageSaverSettings,
175        TextureFormatPixelInfo,
176    };
177
178    fn create_app() -> (App, Dir) {
179        let mut app = App::new();
180        let dir = Dir::default();
181        let dir_clone_1 = dir.clone();
182        let dir_clone_2 = dir.clone();
183        app.register_asset_source(
184            AssetSourceId::Default,
185            AssetSourceBuilder::new(move || {
186                Box::new(MemoryAssetReader {
187                    root: dir_clone_1.clone(),
188                })
189            })
190            .with_writer(move |_| {
191                Some(Box::new(MemoryAssetWriter {
192                    root: dir_clone_2.clone(),
193                }))
194            }),
195        )
196        .add_plugins((
197            TaskPoolPlugin::default(),
198            AssetPlugin {
199                watch_for_changes_override: Some(false),
200                use_asset_processor_override: Some(false),
201                ..Default::default()
202            },
203        ))
204        .init_asset::<Image>()
205        .register_asset_loader(ImageLoader::new(CompressedImageFormats::empty()));
206
207        (app, dir)
208    }
209
210    fn run_app_until(app: &mut App, mut predicate: impl FnMut(&mut World) -> Option<()>) {
211        const LARGE_ITERATION_COUNT: usize = 10000;
212        for _ in 0..LARGE_ITERATION_COUNT {
213            app.update();
214            if predicate(app.world_mut()).is_some() {
215                return;
216            }
217        }
218
219        panic!("Ran out of loops to return `Some` from `predicate`");
220    }
221
222    #[expect(clippy::allow_attributes, reason = "only occasionally unused")]
223    #[allow(unused, reason = "only used for feature-flagged image formats")]
224    fn roundtrip_for_type(file_name: &str, color_type: TextureFormat) {
225        let (mut app, dir) = create_app();
226        let asset_server = app.world().resource::<AssetServer>().clone();
227
228        let asset_path = AssetPath::from_path(Path::new(file_name));
229
230        const WIDTH: u32 = 5;
231        let mut image = Image::new(
232            wgpu_types::Extent3d {
233                width: WIDTH,
234                height: WIDTH,
235                depth_or_array_layers: 1,
236            },
237            wgpu_types::TextureDimension::D2,
238            vec![0; color_type.pixel_size().unwrap() * WIDTH as usize * WIDTH as usize],
239            color_type,
240            RenderAssetUsages::all(),
241        );
242        for y in 0..WIDTH {
243            for x in 0..WIDTH {
244                image
245                    .set_color_at(
246                        x,
247                        y,
248                        Srgba::new(
249                            (x + 1) as f32 / WIDTH as f32,
250                            (y + 1) as f32 / WIDTH as f32,
251                            (x + y + 2) as f32 / (2 * WIDTH) as f32,
252                            1.0,
253                        )
254                        .into(),
255                    )
256                    .unwrap();
257            }
258        }
259
260        {
261            let asset_server = asset_server.clone();
262            let image = image.clone();
263            let asset_path = asset_path.clone_owned();
264            block_on(async move {
265                let saved_asset = SavedAsset::from_asset(&image);
266                save_using_saver(
267                    asset_server,
268                    &ImageSaver,
269                    &asset_path,
270                    saved_asset,
271                    &ImageSaverSettings::default(),
272                )
273                .await
274            })
275            .unwrap();
276        }
277
278        assert!(dir.get_asset(asset_path.path()).is_some());
279
280        let handle = asset_server.load::<Image>(asset_path);
281        run_app_until(&mut app, |_| asset_server.is_loaded(&handle).then_some(()));
282
283        let loaded_image = app
284            .world()
285            .resource::<Assets<Image>>()
286            .get(&handle)
287            .unwrap();
288
289        assert_eq!(loaded_image.size(), UVec2::new(WIDTH, WIDTH));
290        let compare_images = 'compare_images: {
291            for y in 0..WIDTH {
292                for x in 0..WIDTH {
293                    if image.get_color_at(x, y).unwrap() != loaded_image.get_color_at(x, y).unwrap()
294                    {
295                        break 'compare_images Err((x, y));
296                    }
297                }
298            }
299            Ok(())
300        };
301
302        if let Err((x, y)) = compare_images {
303            fn image_to_string(image: &Image) -> String {
304                (0..WIDTH)
305                    .map(|y| {
306                        (0..WIDTH)
307                            .map(|x| {
308                                let color = image.get_color_at(x, y).unwrap().to_srgba();
309                                format!(
310                                    "({},{},{})",
311                                    (color.red * 255.0) as u32,
312                                    (color.green * 255.0) as u32,
313                                    (color.blue * 255.0) as u32,
314                                )
315                            })
316                            .collect::<Vec<_>>()
317                            .join(" ")
318                    })
319                    .collect::<Vec<_>>()
320                    .join("\n")
321            }
322            panic!(
323                "Mismatch in color at ({x}, {y})\nleft:\n{}\nright:\n{}",
324                image_to_string(loaded_image),
325                image_to_string(&image)
326            );
327        }
328    }
329
330    #[cfg(feature = "png")]
331    mod png_tests {
332        use super::*;
333
334        #[test]
335        fn roundtrip_png_r8_unorm() {
336            roundtrip_for_type("image.png", TextureFormat::R8Unorm);
337        }
338        #[test]
339        fn roundtrip_png_rgba8_unorm_srgb() {
340            roundtrip_for_type("image.png", TextureFormat::Rgba8UnormSrgb);
341        }
342        #[test]
343        fn roundtrip_png_rgba8_unorm() {
344            roundtrip_for_type("image.png", TextureFormat::Rgba8Unorm);
345        }
346    }
347}