From e1ef637538963ebc1c264fd7f6c7f38d56512365 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Tue, 1 Sep 2026 22:08:52 -0700 Subject: [PATCH 1/3] Keep float values when saving screenshots to .exr or .hdr --- crates/bevy_image/src/image.rs | 8 +++ .../src/image_texture_conversion.rs | 67 +++++++++++++++++++ .../bevy_render/src/view/window/screenshot.rs | 55 +++++++++++++-- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index 7062da1fbde0f..a58f9f6580022 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -1549,6 +1549,14 @@ impl Image { self.clone() .try_into_dynamic() .ok() + // `Rgba16Float` and `Rgba32Float` are unsupported. `image` would + // scale them to 8 bits with no sRGB encode. + .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)) diff --git a/crates/bevy_image/src/image_texture_conversion.rs b/crates/bevy_image/src/image_texture_conversion.rs index c17b03784e8c7..752fb43faa0cd 100644 --- a/crates/bevy_image/src/image_texture_conversion.rs +++ b/crates/bevy_image/src/image_texture_conversion.rs @@ -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 { @@ -183,6 +185,25 @@ impl Image { }) .map(DynamicImage::ImageRgba8) } + // `DynamicImage` has no f16 pixel type, so convert to f32. + TextureFormat::Rgba16Float => { + let pixels: Vec = 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 = 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)), } @@ -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 = 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 = 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); + } } diff --git a/crates/bevy_render/src/view/window/screenshot.rs b/crates/bevy_render/src/view/window/screenshot.rs index 72781f75daee5..925cf1714e133 100644 --- a/crates/bevy_render/src/view/window/screenshot.rs +++ b/crates/bevy_render/src/view/window/screenshot.rs @@ -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, @@ -134,16 +135,62 @@ struct RenderScreenshotsPrepared(EntityHashMap); struct RenderScreenshotsSender(Sender<(Entity, Image)>); /// Saves the captured screenshot to disk at the provided path. +/// +/// 8-bit screenshots save to any format `image` supports. Float screenshots +/// keep their range in `.exr` and `.hdr`. In any other format they are +/// clipped to `0..=1` and sRGB encoded. `.exr` needs the `exr` cargo feature. pub fn save_to_disk(path: impl AsRef) -> impl FnMut(On) { 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(); + // Radiance has an 8-bit exponent and `image`'s encoder does + // not range-check. + let max = bevy_math::ops::exp2(126.0); + 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()), From 9b93f64540eef86e51d0bfc70ad281c842c53181 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Wed, 2 Sep 2026 19:34:01 -0700 Subject: [PATCH 2/3] cleanup docs --- crates/bevy_image/src/image.rs | 6 ++++-- .../bevy_render/src/view/window/screenshot.rs | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index a58f9f6580022..2da884393c4a9 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -1549,8 +1549,10 @@ impl Image { self.clone() .try_into_dynamic() .ok() - // `Rgba16Float` and `Rgba32Float` are unsupported. `image` would - // scale them to 8 bits with no sRGB encode. + // `Rgba16Float` and `Rgba32Float` inputs are unsupported. `TextureAtlasBuilder` + // relies on `None` here to skip them. Letting them through would clamp and + // scale to 8 bits without converting linear to sRGB, and store that as + // `Rgba8UnormSrgb`, a dark, clipped texture. .filter(|img| { !matches!( img, diff --git a/crates/bevy_render/src/view/window/screenshot.rs b/crates/bevy_render/src/view/window/screenshot.rs index 925cf1714e133..81714238661ea 100644 --- a/crates/bevy_render/src/view/window/screenshot.rs +++ b/crates/bevy_render/src/view/window/screenshot.rs @@ -136,9 +136,12 @@ struct RenderScreenshotsSender(Sender<(Entity, Image)>); /// Saves the captured screenshot to disk at the provided path. /// -/// 8-bit screenshots save to any format `image` supports. Float screenshots -/// keep their range in `.exr` and `.hdr`. In any other format they are -/// clipped to `0..=1` and sRGB encoded. `.exr` needs the `exr` cargo feature. +/// 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) -> impl FnMut(On) { let path = path.as_ref().to_owned(); move |screenshot_captured| { @@ -157,8 +160,13 @@ pub fn save_to_disk(path: impl AsRef) -> impl FnMut(On ImageFormat::OpenExr => dyn_img, ImageFormat::Hdr => { let mut rgb = dyn_img.into_rgb32f(); - // Radiance has an 8-bit exponent and `image`'s encoder does - // not range-check. + // `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); for value in rgb.iter_mut() { *value = value.clamp(0.0, max); From 6c3660e8f6dd35aba0e0e9229a6df9b9bb0c4eab Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Fri, 4 Sep 2026 09:24:39 -0700 Subject: [PATCH 3/3] adjust wording to be more clear --- crates/bevy_image/src/image.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/bevy_image/src/image.rs b/crates/bevy_image/src/image.rs index 2da884393c4a9..40328e9bfccba 100644 --- a/crates/bevy_image/src/image.rs +++ b/crates/bevy_image/src/image.rs @@ -1549,10 +1549,9 @@ impl Image { self.clone() .try_into_dynamic() .ok() - // `Rgba16Float` and `Rgba32Float` inputs are unsupported. `TextureAtlasBuilder` - // relies on `None` here to skip them. Letting them through would clamp and - // scale to 8 bits without converting linear to sRGB, and store that as - // `Rgba8UnormSrgb`, a dark, clipped texture. + // `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,