Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/crates/bevy_image/src/saver.rs
30635 views
1
use std::io::Cursor;
2
3
use bevy_asset::{saver::AssetSaver, AssetPath, AsyncWriteExt};
4
use bevy_reflect::TypePath;
5
use image::{write_buffer_with_format, ExtendedColorType};
6
use serde::{Deserialize, Serialize};
7
use thiserror::Error;
8
use wgpu_types::TextureFormat;
9
10
use 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)]
21
pub struct ImageSaver;
22
23
impl 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)]
111
pub 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)]
118
pub 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)]
128
pub 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)]
155
mod 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
}
348
349