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            texture_format: None,
99            is_srgb,
100            sampler: asset.sampler.clone(),
101            asset_usage: asset.asset_usage,
102            array_layout: None,
103            expand_grayscale: false,
104        })
105    }
106}
107
108/// Settings for how to save an image.
109#[derive(Serialize, Deserialize, Default, Clone, Debug)]
110pub struct ImageSaverSettings {
111    /// Defines the file format that the image will be saved as.
112    pub format: SaveImageFormatSetting,
113}
114
115/// The setting for how to choose which file-format to use.
116#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug)]
117pub enum SaveImageFormatSetting {
118    /// The file format to write will be deduced from the file path being written to.
119    #[default]
120    FromExtension,
121    /// This is the explicit file format being written.
122    Format(ImageFormat),
123}
124
125/// An error while saving an image.
126#[derive(Error, Debug)]
127pub enum SaveImageError {
128    /// Cannot deduce file format from extension because there is no extension.
129    #[error("SaveImageFormatSetting::FromExtension was set, but the asset path \"{0}\" has no extension")]
130    MissingExtension(AssetPath<'static>),
131    /// Cannot deduce file format from extension since this extension is unknown. Holds the
132    /// extension that could not be matched.
133    #[error("could not determine asset format for extension \"{0}\"")]
134    UnknownExtension(String),
135    /// [`Image::data`] is [`None`], so there is no data to save. See
136    /// [`RenderAssetUsages`](bevy_asset::RenderAssetUsages) for more.
137    #[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`")]
138    ImageMissingData,
139    /// The image saver doesn't support the file format being requested.
140    #[error("the requested file format {0:?} is not supported for saving")]
141    UnsupportedFormat(ImageFormat),
142    /// The image saver doesn't support the texture format of the image data for the image format.
143    #[error("the image uses a texture format \"{1:?}\" that is not supported for saving by the image format \"{0:?}\"")]
144    UnsupportedSaveColorTypeForFormat(ImageFormat, TextureFormat),
145    /// The [`image`] crate returned an error.
146    #[error(transparent)]
147    ImageError(#[from] image::ImageError),
148    /// Writing the bytes returned an error.
149    #[error(transparent)]
150    IoError(#[from] std::io::Error),
151}
152
153#[cfg(test)]
154mod tests {
155    use std::path::Path;
156
157    use bevy_app::{App, TaskPoolPlugin};
158    use bevy_asset::{
159        io::{
160            memory::{Dir, MemoryAssetReader, MemoryAssetWriter},
161            AssetSourceBuilder, AssetSourceId,
162        },
163        saver::{save_using_saver, SavedAsset},
164        AssetApp, AssetPath, AssetPlugin, AssetServer, Assets, RenderAssetUsages,
165    };
166    use bevy_color::Srgba;
167    use bevy_ecs::world::World;
168    use bevy_math::UVec2;
169    use bevy_platform::future::block_on;
170    use wgpu_types::TextureFormat;
171
172    use crate::{
173        CompressedImageFormats, Image, ImageLoader, ImageSaver, ImageSaverSettings,
174        TextureFormatPixelInfo,
175    };
176
177    fn create_app() -> (App, Dir) {
178        let mut app = App::new();
179        let dir = Dir::default();
180        let dir_clone_1 = dir.clone();
181        let dir_clone_2 = dir.clone();
182        app.register_asset_source(
183            AssetSourceId::Default,
184            AssetSourceBuilder::new(move || {
185                Box::new(MemoryAssetReader {
186                    root: dir_clone_1.clone(),
187                })
188            })
189            .with_writer(move |_| {
190                Some(Box::new(MemoryAssetWriter {
191                    root: dir_clone_2.clone(),
192                }))
193            }),
194        )
195        .add_plugins((
196            TaskPoolPlugin::default(),
197            AssetPlugin {
198                watch_for_changes_override: Some(false),
199                use_asset_processor_override: Some(false),
200                ..Default::default()
201            },
202        ))
203        .init_asset::<Image>()
204        .register_asset_loader(ImageLoader::new(CompressedImageFormats::empty()));
205
206        (app, dir)
207    }
208
209    fn run_app_until(app: &mut App, mut predicate: impl FnMut(&mut World) -> Option<()>) {
210        const LARGE_ITERATION_COUNT: usize = 10000;
211        for _ in 0..LARGE_ITERATION_COUNT {
212            app.update();
213            if predicate(app.world_mut()).is_some() {
214                return;
215            }
216        }
217
218        panic!("Ran out of loops to return `Some` from `predicate`");
219    }
220
221    #[expect(clippy::allow_attributes, reason = "only occasionally unused")]
222    #[allow(unused, reason = "only used for feature-flagged image formats")]
223    fn roundtrip_for_type(file_name: &str, color_type: TextureFormat) {
224        let (mut app, dir) = create_app();
225        let asset_server = app.world().resource::<AssetServer>().clone();
226
227        let asset_path = AssetPath::from_path(Path::new(file_name));
228
229        const WIDTH: u32 = 5;
230        let mut image = Image::new(
231            wgpu_types::Extent3d {
232                width: WIDTH,
233                height: WIDTH,
234                depth_or_array_layers: 1,
235            },
236            wgpu_types::TextureDimension::D2,
237            vec![0; color_type.pixel_size().unwrap() * WIDTH as usize * WIDTH as usize],
238            color_type,
239            RenderAssetUsages::all(),
240        );
241        for y in 0..WIDTH {
242            for x in 0..WIDTH {
243                image
244                    .set_color_at(
245                        x,
246                        y,
247                        Srgba::new(
248                            (x + 1) as f32 / WIDTH as f32,
249                            (y + 1) as f32 / WIDTH as f32,
250                            (x + y + 2) as f32 / (2 * WIDTH) as f32,
251                            1.0,
252                        )
253                        .into(),
254                    )
255                    .unwrap();
256            }
257        }
258
259        {
260            let asset_server = asset_server.clone();
261            let image = image.clone();
262            let asset_path = asset_path.clone_owned();
263            block_on(async move {
264                let saved_asset = SavedAsset::from_asset(&image);
265                save_using_saver(
266                    asset_server,
267                    &ImageSaver,
268                    &asset_path,
269                    saved_asset,
270                    &ImageSaverSettings::default(),
271                )
272                .await
273            })
274            .unwrap();
275        }
276
277        assert!(dir.get_asset(asset_path.path()).is_some());
278
279        let handle = asset_server.load::<Image>(asset_path);
280        run_app_until(&mut app, |_| asset_server.is_loaded(&handle).then_some(()));
281
282        let loaded_image = app
283            .world()
284            .resource::<Assets<Image>>()
285            .get(&handle)
286            .unwrap();
287
288        assert_eq!(loaded_image.size(), UVec2::new(WIDTH, WIDTH));
289        assert_eq!(loaded_image.texture_descriptor.format, color_type);
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        #[test]
348        fn load_grayscale_png() {
349            let (mut app, dir) = create_app();
350            let asset_server = app.world().resource::<AssetServer>().clone();
351
352            let mut png = Vec::new();
353            image::DynamicImage::ImageLuma8(
354                image::GrayImage::from_raw(2, 2, vec![10, 20, 30, 40]).unwrap(),
355            )
356            .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
357            .unwrap();
358            dir.insert_asset(Path::new("gray.png"), png.clone());
359            dir.insert_asset(Path::new("gray_r8.png"), png);
360
361            let expanded = asset_server.load::<Image>("gray.png");
362            let r8 = asset_server
363                .load_builder()
364                .with_settings(|s: &mut crate::ImageLoaderSettings| {
365                    s.texture_format = Some(TextureFormat::R8Unorm);
366                })
367                .load::<Image>("gray_r8.png");
368            run_app_until(&mut app, |_| {
369                (asset_server.is_loaded(&expanded) && asset_server.is_loaded(&r8)).then_some(())
370            });
371
372            let images = app.world().resource::<Assets<Image>>();
373            let expanded = images.get(&expanded).unwrap();
374            assert_eq!(
375                expanded.texture_descriptor.format,
376                TextureFormat::Rgba8UnormSrgb
377            );
378            assert_eq!(expanded.data.as_ref().unwrap().len(), 16);
379            let r8 = images.get(&r8).unwrap();
380            assert_eq!(r8.texture_descriptor.format, TextureFormat::R8Unorm);
381            assert_eq!(r8.data.as_deref(), Some(&[10, 20, 30, 40][..]));
382        }
383    }
384}