Skip to content

Keep float values when saving screenshots to .exr or .hdr - #25657

Open
stuartparmenter wants to merge 3 commits into
bevyengine:mainfrom
stuartparmenter:hdr-wave3-float-screenshots
Open

Keep float values when saving screenshots to .exr or .hdr#25657
stuartparmenter wants to merge 3 commits into
bevyengine:mainfrom
stuartparmenter:hdr-wave3-float-screenshots

Conversation

@stuartparmenter

Copy link
Copy Markdown
Contributor

Objective

A screenshot of a float render target, for example an Hdr camera rendering to an Rgba16Float image, cannot be saved. Image::try_into_dynamic has no arm for Rgba16Float or Rgba32Float, so save_to_disk logs "screen format cannot be understood" and writes nothing.

Solution

try_into_dynamic gets the two float arms. Both produce DynamicImage::ImageRgba32F.

For a float source, save_to_disk keeps the float range in .exr and .hdr. For any other format it clips to 0..=1, sRGB encodes, and warns.

Non-float screenshots save as before. Image::convert, which TextureAtlasBuilder uses, filters out float results, so a float source still returns None.

Testing

Unit tests cover both conversions. An Hdr camera rendering to an Rgba16Float image was captured and saved. .exr and .hdr keep the range, and .png clips to 8-bit sRGB with the warning.

Showcase

let target = images.add(Image::new_target_texture(512, 512, TextureFormat::Rgba16Float, None));
commands.spawn((
    Camera3d::default(),
    Hdr,
    RenderTarget::Image(target.clone().into()),
));

// Later, when a frame is wanted.
commands
    .spawn(Screenshot::image(target))
    .observe(save_to_disk("frame.exr"));

What comes next

Later PRs in the HDR series add decoding of HDR transfer encodings and HDR PNG output.


This PR was built by me with the assistance of Claude Code w/ Fable 5.1

@stuartparmenter stuartparmenter added C-Feature A new feature, making something new possible A-Rendering Drawing game state to the screen D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 2, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Sep 2, 2026
Comment thread crates/bevy_image/src/image.rs Outdated
self.clone()
.try_into_dynamic()
.ok()
// `Rgba16Float` and `Rgba32Float` are unsupported. `image` would

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.

I don't understand what the issue is here.

@stuartparmenter stuartparmenter Sep 3, 2026

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 is here to prevent a behavior change in TextureAtlasBuilder, which also relies on try_into_dynamic through convert. Before this PR, Rgba16Float and Rgba32Float images failed in try_into_dynamic, so convert returned None and the atlas builder skipped them. If we let them flow through now, convert would turn them into 8-bit textures by clamping and scaling, with no sRGB encode, and call it a Rgba8UnormSrgb which would be broken. The filter keeps convert returning None for those two formats, as before.

Possibly longer term there is a more correct fix here for TextureAtlasBuilder but it would need to tonemap and encode HDR content, which is generally out of scope for me just trying to get HDR screenshots working and I don't think convert is the right place for that to happen


/// Saves the captured screenshot to disk at the provided path.
///
/// 8-bit screenshots save to any format `image` supports. Float screenshots

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.

"Float screenshots" -> I think we should be more specific about what this means

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.

i'll update the comment to be more clear that we're talking about a screenshot of an Rgba16Float or Rgba32Float render target (i.e. what you get from a Hdr camera rendering to an Image)

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);

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.

Comment thread crates/bevy_image/src/image.rs Outdated
Comment on lines +1552 to +1561
// `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,
image::DynamicImage::ImageRgb32F(_) | image::DynamicImage::ImageRgba32F(_)
)
})

@beicause beicause Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If it's an issue with TextureAtlasBuilder it should be fixed in that, rather than having this function skip the formats.

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.

As I noted above, convert already returns None for float images, and TextureAtlasBuilder logs an error and leaves that rect blank. This PR keeps that behaviour. The filter is there so convert doesn't start clamping linear float data to 8 bits with no sRGB encode.

Handling HDR sources in TextureAtlasBuilder properly, whether by clamping or tonemapping, is unrelated to screenshots and belongs in its own PR (if someone wants to do it in the future)

@Zeophlite Zeophlite added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen C-Feature A new feature, making something new possible D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants