Skip to content

Add source color primaries metadata to image assets - #25472

Open
stuartparmenter wants to merge 4 commits into
bevyengine:mainfrom
stuartparmenter:hdr-wave1.5-image-primaries
Open

Add source color primaries metadata to image assets#25472
stuartparmenter wants to merge 4 commits into
bevyengine:mainfrom
stuartparmenter:hdr-wave1.5-image-primaries

Conversation

@stuartparmenter

@stuartparmenter stuartparmenter commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Objective

An image asset does not record the gamut its colors came from. Once the working color space widens, every existing texture shifts saturation, and nothing can tell which textures were already wide-gamut.

Solution

  • Adds the Image::source_primaries field and SourceColorPrimaries, a closed set of Bt709, Bt2020 and DisplayP3.
  • Reads the gamut from file metadata: the KTX2 data format descriptor's colorPrimaries, the PNG cICP chunk, Radiance PRIMARIES= header lines, and the OpenEXR chromaticities attribute.
  • Stamps glTF textures Bt709. The glTF 2.0 spec mandates it, and KHR_texture_basisu requires the same from KTX2 files.
  • Adds a source_primaries loader setting to override it per asset, in code or in a .meta file. Order is setting, then file metadata, then Bt709. The setting also flows through Image::from_buffer, so an override skips the file read and its warnings in every format.
  • Warns once when file metadata disagrees with the load, meaning unsupported primaries, a PQ or HLG transfer function, or a KTX2 transfer that contradicts is_srgb. Each warning names the fix, either a loader setting or re-encoding the file.
  • Fills in the field at the exhaustive Image { .. } literals in the repo.

This is metadata only. Nothing reads the field yet, so decoding and rendering are unchanged. The future working color space PR (from the HDR series) carries the value into GpuImage, and a follow-up will use it to convert wide-gamut textures instead of assuming every texture is Rec. 709.

Design notes

SourceColorPrimaries is a closed set of named gamuts, not a second copy of bevy_color::RgbPrimaries. The renderer needs a discrete label it can hash, compare, and match on, for example in pipeline keys, and the loader settings need stable names in .meta files. RgbPrimaries cannot provide either, since it is a struct of chromaticity floats. It stays the colorimetric data you convert through, and to_rgb_primaries bridges the two.

There is no Unspecified variant. A file that declares nothing is treated as sRGB. That is what most tools assume, and it matches how is_srgb already works on Image.

The metadata reads never repeat the pixel decode. The EXR read parses only the header, and the PNG read stops at the first image-data chunk and skips ICC profiles, so the extra cost stays proportional to the header, not the file.

Testing

  • Tests pass across bevy_image and bevy_gltf, including new ones for PNG cICP parsing and the loader-setting override.
  • cargo check -p bevy with png, ktx2, hdr, exr, gltf and serialize, and the mipmap_generator workspace member compiles.
  • cargo check -p bevy_image with the ctt and basis-universal saver features, combined and universal-only.
  • cargo clippy and cargo fmt --check.

Migration

Image gains a field and has a manual Default, so exhaustive Image { .. } literals need it. Image::from_buffer and ktx2_buffer_to_image gain a trailing source_primaries: Option<SourceColorPrimaries> parameter. It only sets the stamped metadata, and with None the file's own metadata wins. See _release-content/migration-guides/image_source_color_primaries.md.


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

@stuartparmenter stuartparmenter added A-Rendering Drawing game state to the screen S-Needs-Review Needs reviewer attention (from anyone!) to move forward A-Color Color spaces and color math labels Aug 19, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Aug 19, 2026
@stuartparmenter
stuartparmenter force-pushed the hdr-wave1.5-image-primaries branch from 38755bd to f761e25 Compare August 19, 2026 18:41
@stuartparmenter
stuartparmenter requested a review from kfc35 August 19, 2026 18:52
@stuartparmenter stuartparmenter added the C-Feature A new feature, making something new possible label Aug 19, 2026
@stuartparmenter
stuartparmenter force-pushed the hdr-wave1.5-image-primaries branch from b39f6e3 to 2d37554 Compare August 19, 2026 19:23
@stuartparmenter
stuartparmenter force-pushed the hdr-wave1.5-image-primaries branch from 2d37554 to 671f841 Compare August 19, 2026 19:24
stuartparmenter added a commit to stuartparmenter/bevy that referenced this pull request Aug 19, 2026
The LinearRec2020 section describes a type this branch no longer adds,
the primaries module and HDR-safe clamps shipped upstream with their own
release content, and the image source-primaries section is superseded by
the note that PR bevyengine#25472 carries. What remains is the opt-in
Rec.2020 working color space, so the note is renamed to match.
stuartparmenter added a commit to stuartparmenter/bevy that referenced this pull request Aug 19, 2026
Adopt the PR branch's refined implementation: PNG cICP support, the
reworked KTX2 data-format-descriptor read, Image::from_buffer and
ktx2_buffer_to_image taking a source_primaries override,
from_chromaticities taking RgbPrimaries, and the PR's release content.
Branch-only HDR work in the same files stays: the SMAA HDR pipeline
key, the float screenshot conversion, and the tonemapping LUT caller
just gains the new argument.
@alice-i-cecile alice-i-cecile added X-Contentious There are nontrivial implications that should be thought through D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes labels Aug 20, 2026

@JMS55 JMS55 left a comment

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.

Technically sound, but docs need some work.

Also, I think we should talk to the image-rs people about trying to get this upstreamed, seems like code that would be better off living with them long term.

Comment thread _release-content/release-notes/image_source_color_primaries.md Outdated
Comment thread _release-content/release-notes/image_source_color_primaries.md Outdated
Comment thread _release-content/release-notes/image_source_color_primaries.md Outdated
Comment thread _release-content/migration-guides/image_source_color_primaries.md Outdated
Comment thread _release-content/migration-guides/image_source_color_primaries.md Outdated
Comment thread crates/bevy_image/src/image.rs Outdated
Comment thread crates/bevy_image/src/png.rs Outdated
/// Returns `None` when the chunk is absent, the header cannot be parsed, or the
/// primaries are not supported. Unsupported primaries warn once, and so does a PQ or
/// HLG transfer function, since the data is loaded as if it were sRGB-encoded.
pub(crate) fn png_source_primaries(bytes: &[u8]) -> Option<SourceColorPrimaries> {

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.

Really feels like we should try to upstream this stuff into the image crate...

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 agree, I can file an upstream issue and look at it, but don't want to block

reflect(Default, Debug, Clone, PartialEq, Hash)
)]
#[cfg_attr(not(feature = "bevy_reflect"), derive(TypePath))]
pub enum SourceColorPrimaries {

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.

Can you write some documentation explaining in simple terms what color primaries are, and then link to the wikipedia page or another resource explaining it in more detail? We shouldn't assume people looking at this type have any idea what a color primary or white point is is :)

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.

would it be OK to link to the recently added

impl RgbPrimaries {
which covers most of it, rather than duplicate?

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.

Oh hmm. Yeah it would be, but why can't we use that directly?

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.

The renderer needs a discrete label it can hash/compare/match on, for example in pipeline keys, and the loader settings need stable names in .meta files, which is what SourceColorPrimaries is -- RgbPrimaries is a struct of chromaticity floats focused on colormetric data. Not sure it makes sense to force one in to the other

Comment thread crates/bevy_image/src/source_color_primaries.rs
/// set's value. Files write primaries with three or four decimal places, so `2e-3`
/// absorbs that rounding. The supported sets all differ by at least `0.09` in some
/// coordinate, so a file can never match two sets.
const CHROMATICITY_MATCH_TOLERANCE: f32 = 2e-3;

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.

Why do we need a tolerance?

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 rewrote the comment to explain it more or less in these terms:

Basically its a helper for EXR and Radiance HDR files -- other files have basically enums.

A Radiance HDR file can carry a header line like this:
PRIMARIES= 0.640 0.330 0.300 0.600 0.150 0.060 0.3127 0.3290

Those eight numbers pin down which exact red, green, blue, and white the file's RGB values are mixing between -- two numbers per color, locating it on a standard color chart. EXR files can carry the same eight numbers in a chromaticities attribute. Each of the three sets we support (BT.709, BT.2020, Display P3) is defined by its own well known eight numbers, so identifying the file's set is just asking: whose numbers does this line match?

The catch is that these are decimals written by whatever program made the file, and programs round differently. The standard white point is 0.3127, but plenty of writers emit 0.313. Same intent, but a straight == would fail. Allowing each number to be off by up to 0.002 absorbs the rounding.

And 0.002 can't pick the wrong set, because the three sets aren't remotely close to each other: any two of them differ by at least 0.09 in one of the eight numbers, about 45 times the tolerance. A file's numbers can only ever be near one of them.

@beicause

Copy link
Copy Markdown
Member

Question: How do you plan to utilize the image color primaries information? In shaders, it is impossible to attach primaries information to every texture for performance reasons.

@stuartparmenter

Copy link
Copy Markdown
Contributor Author

Question: How do you plan to utilize the image color primaries information? In shaders, it is impossible to attach primaries information to every texture for performance reasons.

I have planned, but haven't actually built, tested, and profiled the answer to your question yet -- was wanting to avoid making the already large HDR branch even bigger. Today the full branch carries the the metadata through to GpuImage, but then nothing reads it. Under the (upcoming) wide working space, every color texture is assumed to be Rec.709-authored, and the composed color converts once at the end of composition.

The future plan (that I will build out once more of the PRs have made progress and the dust has settled) is roughly:

  1. single-texture pipelines specialize on the texture's primaries metadata. The skybox and environment map pipelines come first for things like wide-gamut HDRIs. The metadata selects a shader def at pipeline build, and the def folds a constant conversion matrix into the shader. No primaries data reaches the shader at runtime. This is also why the type is a small enum that can be hashed and compared -- it's built to sit in a pipeline key.

  2. materials that bind many textures need something else. Thinking that we probably convert the pixel data at decode or upload time, but want to explore some more options. I don't like doing a per-texture flag at shared sampling sites since they can't know whether an earlier step already converted the value, so it can be double converted.

I can open a issue and add it to my HDR tracking issue if you want to discuss approaches there on how to best do it, would love any input!

@stuartparmenter
stuartparmenter requested a review from JMS55 August 22, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Color Color spaces and color math A-Rendering Drawing game state to the screen C-Feature A new feature, making something new possible D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes S-Needs-Review Needs reviewer attention (from anyone!) to move forward X-Contentious There are nontrivial implications that should be thought through

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants