Allow images to be resized on the GPU without losing data (#19462)

# Objective

#19410 added support for resizing images "in place" meaning that their
data was copied into the new texture allocation on the CPU. However,
there are some scenarios where an image may be created and populated
entirely on the GPU. Using this method would cause data to disappear, as
it wouldn't be copied into the new texture.

## Solution

When an image is resized in place, if it has no data in it's asset,
we'll opt into a new flag `copy_on_resize` which will issue a
`copy_texture_to_texture` command on the old allocation.

To support this, we require passing the old asset to all `RenderAsset`
implementations. This will be generally useful in the future for
reducing things like buffer re-allocations.

## Testing

Tested using the example in the issue.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
charlotte 🌸 2025-06-23 23:22:50 -07:00 committed by GitHub
parent 8b6fe34570
commit 7b5e4e3be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 85 additions and 54 deletions

View File

@ -196,6 +196,7 @@ impl RenderAsset for GpuAutoExposureCompensationCurve {
source: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
(render_device, render_queue): &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, bevy_render::render_asset::PrepareAssetError<Self::SourceAsset>> {
let texture = render_device.create_texture_with_data(
render_queue,

View File

@ -464,5 +464,6 @@ pub fn lut_placeholder() -> Image {
sampler: ImageSampler::Default,
texture_view_descriptor: None,
asset_usage: RenderAssetUsages::RENDER_WORLD,
copy_on_resize: false,
}
}

View File

@ -554,6 +554,7 @@ impl RenderAsset for GpuLineGizmo {
gizmo: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
render_device: &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
let list_position_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
usage: BufferUsages::VERTEX,

View File

@ -356,6 +356,8 @@ pub struct Image {
pub sampler: ImageSampler,
pub texture_view_descriptor: Option<TextureViewDescriptor<Option<&'static str>>>,
pub asset_usage: RenderAssetUsages,
/// Whether this image should be copied on the GPU when resized.
pub copy_on_resize: bool,
}
/// Used in [`Image`], this determines what image sampler to use when rendering. The default setting,
@ -747,12 +749,15 @@ impl Image {
label: None,
mip_level_count: 1,
sample_count: 1,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
view_formats: &[],
},
sampler: ImageSampler::Default,
texture_view_descriptor: None,
asset_usage,
copy_on_resize: false,
}
}
@ -887,13 +892,15 @@ impl Image {
/// When growing, the new space is filled with 0. When shrinking, the image is clipped.
///
/// For faster resizing when keeping pixel data intact is not important, use [`Image::resize`].
pub fn resize_in_place(&mut self, new_size: Extent3d) -> Result<(), ResizeError> {
pub fn resize_in_place(&mut self, new_size: Extent3d) {
let old_size = self.texture_descriptor.size;
let pixel_size = self.texture_descriptor.format.pixel_size();
let byte_len = self.texture_descriptor.format.pixel_size() * new_size.volume();
self.texture_descriptor.size = new_size;
let Some(ref mut data) = self.data else {
return Err(ResizeError::ImageWithoutData);
self.copy_on_resize = true;
return;
};
let mut new: Vec<u8> = vec![0; byte_len];
@ -923,10 +930,6 @@ impl Image {
}
self.data = Some(new);
self.texture_descriptor.size = new_size;
Ok(())
}
/// Takes a 2D image containing vertically stacked images of the same size, and reinterprets
@ -1591,14 +1594,6 @@ pub enum TextureError {
IncompleteCubemap,
}
/// An error that occurs when an image cannot be resized.
#[derive(Error, Debug)]
pub enum ResizeError {
/// Failed to resize an Image because it has no data.
#[error("resize method requires cpu-side image data but none was present")]
ImageWithoutData,
}
/// The type of a raw image buffer.
#[derive(Debug)]
pub enum ImageType<'a> {
@ -1822,13 +1817,11 @@ mod test {
}
// Grow image
image
.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
})
.unwrap();
image.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
});
// After growing, the test pattern should be the same.
assert!(matches!(
@ -1849,13 +1842,11 @@ mod test {
));
// Shrink
image
.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
})
.unwrap();
image.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
});
// Images outside of the new dimensions should be clipped
assert!(image.get_color_at(1, 1).is_err());
@ -1898,13 +1889,11 @@ mod test {
}
// Grow image
image
.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: LAYERS + 1,
})
.unwrap();
image.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: LAYERS + 1,
});
// After growing, the test pattern should be the same.
assert!(matches!(
@ -1929,13 +1918,11 @@ mod test {
}
// Shrink
image
.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
})
.unwrap();
image.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
});
// Images outside of the new dimensions should be clipped
assert!(image.get_color_at_3d(1, 1, 0).is_err());
@ -1944,13 +1931,11 @@ mod test {
assert!(image.get_color_at_3d(0, 0, 1).is_err());
// Grow layers
image
.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
})
.unwrap();
image.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
});
// Pixels in the newly added layer should be zeroes.
assert!(matches!(

View File

@ -1410,6 +1410,7 @@ impl<M: Material> RenderAsset for PreparedMaterial<M> {
alpha_mask_deferred_draw_functions,
material_param,
): &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
let draw_opaque_pbr = opaque_draw_functions.read().id::<DrawMaterial<M>>();
let draw_alpha_mask_pbr = alpha_mask_draw_functions.read().id::<DrawMaterial<M>>();

View File

@ -474,6 +474,7 @@ impl RenderAsset for RenderWireframeMaterial {
source_asset: Self::SourceAsset,
_asset_id: AssetId<Self::SourceAsset>,
_param: &mut SystemParamItem<Self::Param>,
_previous_asset: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
Ok(RenderWireframeMaterial {
color: source_asset.color.to_linear().to_f32_array(),

View File

@ -209,6 +209,7 @@ impl RenderAsset for RenderMesh {
mesh: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
(images, mesh_vertex_buffer_layouts): &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
let morph_targets = match mesh.morph_targets() {
Some(mt) => {

View File

@ -73,6 +73,7 @@ pub trait RenderAsset: Send + Sync + 'static + Sized {
source_asset: Self::SourceAsset,
asset_id: AssetId<Self::SourceAsset>,
param: &mut SystemParamItem<Self::Param>,
previous_asset: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>>;
/// Called whenever the [`RenderAsset::SourceAsset`] has been removed.
@ -355,7 +356,8 @@ pub fn prepare_assets<A: RenderAsset>(
0
};
match A::prepare_asset(extracted_asset, id, &mut param) {
let previous_asset = render_assets.get(id);
match A::prepare_asset(extracted_asset, id, &mut param, previous_asset) {
Ok(prepared_asset) => {
render_assets.insert(id, prepared_asset);
bpf.write_bytes(write_bytes);
@ -382,7 +384,7 @@ pub fn prepare_assets<A: RenderAsset>(
// we remove previous here to ensure that if we are updating the asset then
// any users will not see the old asset after a new asset is extracted,
// even if the new asset is not yet ready or we are out of bytes to write.
render_assets.remove(id);
let previous_asset = render_assets.remove(id);
let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
if bpf.exhausted() {
@ -394,7 +396,7 @@ pub fn prepare_assets<A: RenderAsset>(
0
};
match A::prepare_asset(extracted_asset, id, &mut param) {
match A::prepare_asset(extracted_asset, id, &mut param, previous_asset.as_ref()) {
Ok(prepared_asset) => {
render_assets.insert(id, prepared_asset);
bpf.write_bytes(write_bytes);

View File

@ -116,6 +116,7 @@ impl RenderAsset for GpuShaderStorageBuffer {
source_asset: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
render_device: &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
match source_asset.data {
Some(data) => {

View File

@ -7,6 +7,7 @@ use bevy_asset::AssetId;
use bevy_ecs::system::{lifetimeless::SRes, SystemParamItem};
use bevy_image::{Image, ImageSampler};
use bevy_math::{AspectRatio, UVec2};
use tracing::warn;
use wgpu::{Extent3d, TextureFormat, TextureViewDescriptor};
/// The GPU-representation of an [`Image`].
@ -44,6 +45,7 @@ impl RenderAsset for GpuImage {
image: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
(render_device, render_queue, default_sampler): &mut SystemParamItem<Self::Param>,
previous_asset: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
let texture = if let Some(ref data) = image.data {
render_device.create_texture_with_data(
@ -54,7 +56,38 @@ impl RenderAsset for GpuImage {
data,
)
} else {
render_device.create_texture(&image.texture_descriptor)
let new_texture = render_device.create_texture(&image.texture_descriptor);
if image.copy_on_resize {
if let Some(previous) = previous_asset {
let mut command_encoder =
render_device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("copy_image_on_resize"),
});
let copy_size = Extent3d {
width: image.texture_descriptor.size.width.min(previous.size.width),
height: image
.texture_descriptor
.size
.height
.min(previous.size.height),
depth_or_array_layers: image
.texture_descriptor
.size
.depth_or_array_layers
.min(previous.size.depth_or_array_layers),
};
command_encoder.copy_texture_to_texture(
previous.texture.as_image_copy(),
new_texture.as_image_copy(),
copy_size,
);
render_queue.submit([command_encoder.finish()]);
} else {
warn!("No previous asset to copy from for image: {:?}", image);
}
}
new_texture
};
let texture_view = texture.create_view(

View File

@ -967,6 +967,7 @@ impl<M: Material2d> RenderAsset for PreparedMaterial2d<M> {
transparent_draw_functions,
material_param,
): &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
match material.as_bind_group(&pipeline.material2d_layout, render_device, material_param) {
Ok(prepared) => {

View File

@ -473,6 +473,7 @@ impl RenderAsset for RenderWireframeMaterial {
source_asset: Self::SourceAsset,
_asset_id: AssetId<Self::SourceAsset>,
_param: &mut SystemParamItem<Self::Param>,
_previous_asset: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
Ok(RenderWireframeMaterial {
color: source_asset.color.to_linear().to_f32_array(),

View File

@ -211,6 +211,7 @@ fn make_chunk_image(size: &UVec2, indices: &[Option<u16>]) -> Image {
sampler: ImageSampler::nearest(),
texture_view_descriptor: None,
asset_usage: RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD,
copy_on_resize: false,
}
}

View File

@ -581,6 +581,7 @@ impl<M: UiMaterial> RenderAsset for PreparedUiMaterial<M> {
material: Self::SourceAsset,
_: AssetId<Self::SourceAsset>,
(render_device, pipeline, material_param): &mut SystemParamItem<Self::Param>,
_: Option<&Self>,
) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
match material.as_bind_group(&pipeline.ui_layout, render_device, material_param) {
Ok(prepared) => Ok(PreparedUiMaterial {