Add resize_in_place to Image (#19410)

# Objective

Ultimately, I'd like to modify our font atlas creation systems so that
they are able to resize the font atlases as more glyphs are added. At
the moment, they create a new 512x512 atlas every time one fills up.
With large font sizes and many glyphs, your glyphs may end up spread out
across several atlases.

The goal would be to render text more efficiently, because glyphs spread
across fewer textures could benefit more from batching.

`AtlasAllocator` already has support for growing atlases, but we don't
currently have a way of growing a texture while keeping the pixel data
intact.

## Solution

Add a new method to `Image`: `resize_in_place` and a test for it.

## Testing

Ran the new test, and also a little demo comparing this side-by-side
with `resize`.

<details>
<summary>Expand Code</summary>

```rust
//! Testing ground for #19410

use bevy::prelude::*;
use bevy_render::render_resource::Extent3d;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, test)
        .init_resource::<Size>()
        .insert_resource(FillColor(Hsla::hsl(0.0, 1.0, 0.7)))
        .run();
}

#[derive(Resource, Default)]
struct Size(Option<UVec2>);
#[derive(Resource)]
struct FillColor(Hsla);
#[derive(Component)]
struct InPlace;

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);

    commands.spawn((
        Transform::from_xyz(220.0, 0.0, 0.0),
        Sprite::from_image(asset_server.load("branding/bevy_bird_dark.png")),
    ));

    commands.spawn((
        InPlace,
        Transform::from_xyz(-220.0, 0.0, 0.0),
        Sprite::from_image(asset_server.load("branding/icon.png")),
    ));
}

fn test(
    sprites: Query<(&Sprite, Has<InPlace>)>,
    mut images: ResMut<Assets<Image>>,
    mut new_size: ResMut<Size>,
    mut dir: Local<IVec2>,
    mut color: ResMut<FillColor>,
) -> Result {
    for (sprite, in_place) in &sprites {
        let image = images.get_mut(&sprite.image).ok_or("Image not found")?;
        let size = new_size.0.get_or_insert(image.size());

        if *dir == IVec2::ZERO {
            *dir = IVec2::splat(1);
        }

        *size = size.saturating_add_signed(*dir);

        if size.x > 400 || size.x < 150 {
            *dir = *dir * -1;
        }

        color.0 = color.0.rotate_hue(1.0);

        if in_place {
            image.resize_in_place_2d(
                Extent3d {
                    width: size.x,
                    height: size.y,
                    ..default()
                },
                &Srgba::from(color.0).to_u8_array(),
            )?;
        } else {
            image.resize(Extent3d {
                width: size.x,
                height: size.y,
                ..default()
            });
        }
    }

    Ok(())
}
```
</details>


https://github.com/user-attachments/assets/6b2d0ec3-6a6e-4da1-98aa-29e7162f16fa

## Alternatives

I think that this might be useful functionality outside of the font
atlas scenario, but we *could* just increase the initial font atlas
size, make it configurable, and/or size font atlases according to device
limits. It's not totally clear to me how to accomplish that last idea.
This commit is contained in:
Rob Parrett 2025-05-31 14:55:11 -07:00 committed by GitHub
parent b993202d79
commit dc4737923c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -851,6 +851,8 @@ impl Image {
/// Resizes the image to the new size, by removing information or appending 0 to the `data`.
/// Does not properly scale the contents of the image.
///
/// If you need to keep pixel data intact, use [`Image::resize_in_place`].
pub fn resize(&mut self, size: Extent3d) {
self.texture_descriptor.size = size;
if let Some(ref mut data) = self.data {
@ -878,6 +880,52 @@ impl Image {
self.texture_descriptor.size = new_size;
}
/// Resizes the image to the new size, keeping the pixel data intact, anchored at the top-left.
/// 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> {
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();
let Some(ref mut data) = self.data else {
return Err(ResizeError::ImageWithoutData);
};
let mut new: Vec<u8> = vec![0; byte_len];
let copy_width = old_size.width.min(new_size.width) as usize;
let copy_height = old_size.height.min(new_size.height) as usize;
let copy_depth = old_size
.depth_or_array_layers
.min(new_size.depth_or_array_layers) as usize;
let old_row_stride = old_size.width as usize * pixel_size;
let old_layer_stride = old_size.height as usize * old_row_stride;
let new_row_stride = new_size.width as usize * pixel_size;
let new_layer_stride = new_size.height as usize * new_row_stride;
for z in 0..copy_depth {
for y in 0..copy_height {
let old_offset = z * old_layer_stride + y * old_row_stride;
let new_offset = z * new_layer_stride + y * new_row_stride;
let old_range = (old_offset)..(old_offset + copy_width * pixel_size);
let new_range = (new_offset)..(new_offset + copy_width * pixel_size);
new[new_range].copy_from_slice(&data[old_range]);
}
}
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
/// it as a 2D array texture, where each of the stacked images becomes one layer of the
/// array. This is primarily for use with the `texture2DArray` shader uniform type.
@ -1540,6 +1588,14 @@ 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> {
@ -1730,4 +1786,173 @@ mod test {
image.set_color_at_3d(4, 9, 2, Color::WHITE).unwrap();
assert!(matches!(image.get_color_at_3d(4, 9, 2), Ok(Color::WHITE)));
}
#[test]
fn resize_in_place_2d_grow_and_shrink() {
use bevy_color::ColorToPacked;
const INITIAL_FILL: LinearRgba = LinearRgba::BLACK;
const GROW_FILL: LinearRgba = LinearRgba::NONE;
let mut image = Image::new_fill(
Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&INITIAL_FILL.to_u8_array(),
TextureFormat::Rgba8Unorm,
RenderAssetUsages::MAIN_WORLD,
);
// Create a test pattern
const TEST_PIXELS: [(u32, u32, LinearRgba); 3] = [
(0, 1, LinearRgba::RED),
(1, 1, LinearRgba::GREEN),
(1, 0, LinearRgba::BLUE),
];
for (x, y, color) in &TEST_PIXELS {
image.set_color_at(*x, *y, Color::from(*color)).unwrap();
}
// Grow image
image
.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
})
.unwrap();
// After growing, the test pattern should be the same.
assert!(matches!(
image.get_color_at(0, 0),
Ok(Color::LinearRgba(INITIAL_FILL))
));
for (x, y, color) in &TEST_PIXELS {
assert_eq!(
image.get_color_at(*x, *y).unwrap(),
Color::LinearRgba(*color)
);
}
// Pixels in the newly added area should get filled with zeroes.
assert!(matches!(
image.get_color_at(3, 3),
Ok(Color::LinearRgba(GROW_FILL))
));
// Shrink
image
.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
})
.unwrap();
// Images outside of the new dimensions should be clipped
assert!(image.get_color_at(1, 1).is_err());
}
#[test]
fn resize_in_place_array_grow_and_shrink() {
use bevy_color::ColorToPacked;
const INITIAL_FILL: LinearRgba = LinearRgba::BLACK;
const GROW_FILL: LinearRgba = LinearRgba::NONE;
const LAYERS: u32 = 4;
let mut image = Image::new_fill(
Extent3d {
width: 2,
height: 2,
depth_or_array_layers: LAYERS,
},
TextureDimension::D2,
&INITIAL_FILL.to_u8_array(),
TextureFormat::Rgba8Unorm,
RenderAssetUsages::MAIN_WORLD,
);
// Create a test pattern
const TEST_PIXELS: [(u32, u32, LinearRgba); 3] = [
(0, 1, LinearRgba::RED),
(1, 1, LinearRgba::GREEN),
(1, 0, LinearRgba::BLUE),
];
for z in 0..LAYERS {
for (x, y, color) in &TEST_PIXELS {
image
.set_color_at_3d(*x, *y, z, Color::from(*color))
.unwrap();
}
}
// Grow image
image
.resize_in_place(Extent3d {
width: 4,
height: 4,
depth_or_array_layers: LAYERS + 1,
})
.unwrap();
// After growing, the test pattern should be the same.
assert!(matches!(
image.get_color_at(0, 0),
Ok(Color::LinearRgba(INITIAL_FILL))
));
for z in 0..LAYERS {
for (x, y, color) in &TEST_PIXELS {
assert_eq!(
image.get_color_at_3d(*x, *y, z).unwrap(),
Color::LinearRgba(*color)
);
}
}
// Pixels in the newly added area should get filled with zeroes.
for z in 0..(LAYERS + 1) {
assert!(matches!(
image.get_color_at_3d(3, 3, z),
Ok(Color::LinearRgba(GROW_FILL))
));
}
// Shrink
image
.resize_in_place(Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
})
.unwrap();
// Images outside of the new dimensions should be clipped
assert!(image.get_color_at_3d(1, 1, 0).is_err());
// Higher layers should no longer be present
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();
// Pixels in the newly added layer should be zeroes.
assert!(matches!(
image.get_color_at_3d(0, 0, 1),
Ok(Color::LinearRgba(GROW_FILL))
));
}
}