Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/bevy_image/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,15 @@ impl Image {
self.clone()
.try_into_dynamic()
.ok()
// `Rgba16Float` and `Rgba32Float` inputs return `None`. `image`
// would clamp them to 8 bits with no sRGB encode, and the
// `Rgba8UnormSrgb` result would be dark and clipped.
.filter(|img| {
!matches!(
img,
image::DynamicImage::ImageRgb32F(_) | image::DynamicImage::ImageRgba32F(_)
)
})
.and_then(|img| match new_format {
TextureFormat::R8Unorm => {
Some((image::DynamicImage::ImageLuma8(img.into_luma8()), false))
Expand Down
67 changes: 67 additions & 0 deletions crates/bevy_image/src/image_texture_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ impl Image {
/// - `TextureFormat::Rg8Unorm`
/// - `TextureFormat::Rgba8UnormSrgb`
/// - `TextureFormat::Bgra8UnormSrgb`
/// - `TextureFormat::Rgba16Float`, converted to [`DynamicImage::ImageRgba32F`]
/// - `TextureFormat::Rgba32Float`
///
/// To convert [`Image`] to a different format see: [`Image::convert`].
pub fn try_into_dynamic(self) -> Result<DynamicImage, IntoDynamicImageError> {
Expand Down Expand Up @@ -183,6 +185,25 @@ impl Image {
})
.map(DynamicImage::ImageRgba8)
}
// `DynamicImage` has no f16 pixel type, so convert to f32.
TextureFormat::Rgba16Float => {
let pixels: Vec<f32> = data
.as_chunks()
.0
.iter()
.map(|&bytes| half::f16::from_le_bytes(bytes).to_f32())
.collect();
ImageBuffer::from_raw(width, height, pixels).map(DynamicImage::ImageRgba32F)
}
TextureFormat::Rgba32Float => {
let pixels: Vec<f32> = data
.as_chunks()
.0
.iter()
.map(|&bytes| f32::from_le_bytes(bytes))
.collect();
ImageBuffer::from_raw(width, height, pixels).map(DynamicImage::ImageRgba32F)
}
// Throw and error if conversion isn't supported
texture_format => return Err(IntoDynamicImageError::UnsupportedFormat(texture_format)),
}
Expand Down Expand Up @@ -247,4 +268,50 @@ mod test {
);
assert_eq!(luma_a16.texture_descriptor.format, TextureFormat::Rg16Unorm);
}

#[test]
fn rgba16float_to_dynamic_keeps_hdr_range() {
// Includes a value above 1.0 and a negative one.
let pixels: [f32; 8] = [2.5, 1.0, -0.25, 1.0, 0.5, 0.0, 1.0, 0.25];
let data: Vec<u8> = pixels
.iter()
.flat_map(|&v| half::f16::from_f32(v).to_le_bytes())
.collect();
let image = Image::new(
Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
data,
TextureFormat::Rgba16Float,
RenderAssetUsages::MAIN_WORLD,
);
let DynamicImage::ImageRgba32F(converted) = image.try_into_dynamic().unwrap() else {
panic!("expected DynamicImage::ImageRgba32F");
};
assert_eq!(converted.as_raw().as_slice(), &pixels);
}

#[test]
fn rgba32float_to_dynamic_is_lossless() {
let pixels: [f32; 4] = [3.75, 0.125, -1.5, 1.0];
let data: Vec<u8> = pixels.iter().flat_map(|v| v.to_le_bytes()).collect();
let image = Image::new(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
data,
TextureFormat::Rgba32Float,
RenderAssetUsages::MAIN_WORLD,
);
let DynamicImage::ImageRgba32F(converted) = image.try_into_dynamic().unwrap() else {
panic!("expected DynamicImage::ImageRgba32F");
};
assert_eq!(converted.as_raw().as_slice(), &pixels);
}
}
63 changes: 59 additions & 4 deletions crates/bevy_render/src/view/window/screenshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use alloc::{borrow::Cow, sync::Arc};
use bevy_app::{First, Plugin, Update};
use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Handle, RenderAssetUsages};
use bevy_camera::{ManualTextureViewHandle, NormalizedRenderTarget, RenderTarget};
use bevy_color::{ColorToPacked, LinearRgba, Srgba};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
entity::EntityHashMap, message::message_update_system, prelude::*, system::SystemState,
Expand Down Expand Up @@ -134,16 +135,70 @@ struct RenderScreenshotsPrepared(EntityHashMap<ScreenshotPreparedState>);
struct RenderScreenshotsSender(Sender<(Entity, Image)>);

/// Saves the captured screenshot to disk at the provided path.
///
/// A screenshot of an 8-bit render target saves to any format `image`
/// supports. A screenshot of an `Rgba16Float` or `Rgba32Float` render target,
/// for example an `Hdr` camera rendering to an `Image`, holds values outside
/// `0..=1`. Those are kept in `.exr` and `.hdr`. Any other format clips to
/// `0..=1` and converts to 8-bit sRGB, with a warning. `.exr` needs the `exr`
/// cargo feature.
pub fn save_to_disk(path: impl AsRef<Path>) -> impl FnMut(On<ScreenshotCaptured>) {
let path = path.as_ref().to_owned();
move |screenshot_captured| {
use image::{DynamicImage, ImageFormat};

let img = screenshot_captured.image.clone();
match img.try_into_dynamic() {
Ok(dyn_img) => match image::ImageFormat::from_path(&path) {
Ok(dyn_img) => match ImageFormat::from_path(&path) {
Ok(format) => {
// discard the alpha channel which stores brightness values when HDR is enabled to make sure
// the screenshot looks right
let img = dyn_img.to_rgb8();
let is_float = matches!(
dyn_img,
DynamicImage::ImageRgb32F(_) | DynamicImage::ImageRgba32F(_)
);
let img = if is_float {
match format {
ImageFormat::OpenExr => dyn_img,
ImageFormat::Hdr => {
let mut rgb = dyn_img.into_rgb32f();
// `image`'s Radiance encoder stores one shared exponent byte per
// pixel, computed as `floor(log2(max)) + 1` in `i32` and stored as
// `(exp + 128) as u8`, with no range checks. An infinite channel
// overflows the `i32` add, and a channel at or above 2^127 or below
// 2^-128 wraps the exponent byte. Clamp to `0.0..=2^126`, and
// replace NaN and values below `f32::MIN_POSITIVE` with zero, so
// the encoder accepts every value.
let max = bevy_math::ops::exp2(126.0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain to me what's going on here too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was found due to a panic during testing, and I would have spent a while trying to figure it out w/o Claude.

Basically The .hdr format stores each pixel as three 8-bit color values plus one shared 8-bit exponent, so the range it can hold is limited. image's encoder works out that exponent with floor(log2(max)) + 1 as an i32 and then stores it as (exp + 128) as u8, and it never checks that the value fits. If a channel is infinite, which is easy to get in a float render target, log2 is infinite, the cast to i32 saturates, and the + 1 overflows. If a channel is 2^127 or bigger, or smaller than 2^-128, the exponent byte wraps around and the pixel decodes to garbage. So before encoding we clamp every channel and set every NaN and subnormal value to zero.

I'll update the comment to be more clear.

I'll see if I can find an upstream issue and file on if not.

for value in rgb.iter_mut() {
*value = value.clamp(0.0, max);
if !value.is_normal() {
*value = 0.0;
}
}
DynamicImage::ImageRgb32F(rgb)
}
_ => {
warn!(
"Saving a floating point screenshot as 8-bit RGB. \
Values above 1.0 are clipped. \
Save to an .exr or .hdr path to keep them."
);
let rgb = dyn_img.into_rgb32f();
let encoded =
image::RgbImage::from_fn(rgb.width(), rgb.height(), |x, y| {
let [r, g, b] = rgb.get_pixel(x, y).0;
image::Rgb(
Srgba::from(LinearRgba::rgb(r, g, b))
.to_u8_array_no_alpha(),
)
});
DynamicImage::ImageRgb8(encoded)
}
}
} else {
// discard the alpha channel which stores brightness values when HDR is enabled to make sure
// the screenshot looks right
DynamicImage::ImageRgb8(dyn_img.to_rgb8())
};
#[cfg(not(target_arch = "wasm32"))]
match img.save_with_format(&path, format) {
Ok(_) => info!("Screenshot saved to {}", path.display()),
Expand Down