Skip to main content

bevy_color/
primaries.rs

1//! CIE 1931 chromaticity coordinates and RGB primary sets, used to convert linear RGB
2//! values from one color space to another.
3//!
4//! Matrices are derived from chromaticity coordinates with the
5//! [Lindbloom method](http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html).
6
7use crate::Xyza;
8use bevy_math::{DMat3, DVec3, Mat3};
9#[cfg(feature = "bevy_reflect")]
10use bevy_reflect::prelude::*;
11
12/// A position in the [CIE 1931 xy chromaticity diagram](https://en.wikipedia.org/wiki/CIE_1931_color_space),
13/// describing a color's hue and saturation independently of its luminance.
14#[derive(Debug, Clone, Copy, PartialEq)]
15#[cfg_attr(
16    feature = "bevy_reflect",
17    derive(Reflect),
18    reflect(Clone, PartialEq, Default)
19)]
20#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(
22    all(feature = "serialize", feature = "bevy_reflect"),
23    reflect(Serialize, Deserialize)
24)]
25pub struct Chromaticity {
26    /// The x chromaticity coordinate. Physical colors are in `[0.0, 0.8]`.
27    pub x: f32,
28    /// The y chromaticity coordinate. Physical colors are in `(0.0, 0.9]`.
29    pub y: f32,
30}
31
32impl Chromaticity {
33    /// Construct a new [`Chromaticity`] from CIE 1931 xy coordinates.
34    pub const fn new(x: f32, y: f32) -> Self {
35        Self { x, y }
36    }
37
38    /// The [CIE Standard Illuminant D65](https://registry.color.org/rgb-registry/srgb#:~:text=White%20point%20chromaticity)
39    /// white point, as specified by ITU-R BT.709 / BT.2020 and the sRGB standard.
40    pub const D65: Self = Self::new(0.3127, 0.3290);
41
42    /// The [ACES white point](https://docs.acescentral.com/white-point/), about
43    /// CIE Standard Illuminant D60.
44    pub const D60: Self = Self::new(0.32168, 0.33767);
45
46    /// Convert this chromaticity and a luminance to a CIE 1931 color space.
47    ///
48    /// A luminance of `1.0` is the reference white. Alpha is set to `1.0`.
49    pub const fn to_xyza(self, luminance: f32) -> Xyza {
50        let xyz = self.to_dxyz();
51        Xyza::new(
52            (xyz.x * luminance as f64) as f32,
53            luminance,
54            (xyz.z * luminance as f64) as f32,
55            1.0,
56        )
57    }
58
59    /// The CIE 1931 XYZ tristimulus value of this chromaticity at luminance `1.0`,
60    /// in `f64` precision for matrix derivation.
61    const fn to_dxyz(self) -> DVec3 {
62        let (x, y) = (self.x as f64, self.y as f64);
63        DVec3::new(x / y, 1.0, (1.0 - x - y) / y)
64    }
65}
66
67impl Default for Chromaticity {
68    fn default() -> Self {
69        Self::D65
70    }
71}
72
73/// A set of RGB primaries and a white point, given as [`Chromaticity`] coordinates.
74///
75/// Together they define what a linear RGB color looks like, aside from overall brightness.
76#[derive(Debug, Clone, Copy, PartialEq)]
77#[cfg_attr(
78    feature = "bevy_reflect",
79    derive(Reflect),
80    reflect(Clone, PartialEq, Default)
81)]
82#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(
84    all(feature = "serialize", feature = "bevy_reflect"),
85    reflect(Serialize, Deserialize)
86)]
87pub struct RgbPrimaries {
88    /// The chromaticity of the red primary.
89    pub red: Chromaticity,
90    /// The chromaticity of the green primary.
91    pub green: Chromaticity,
92    /// The chromaticity of the blue primary.
93    pub blue: Chromaticity,
94    /// The chromaticity of the white point (the color produced by `(1, 1, 1)`).
95    pub white: Chromaticity,
96}
97
98impl RgbPrimaries {
99    /// The [ITU-R BT.709](https://registry.color.org/rgb-registry/bt709) primaries with a D65
100    /// white point.
101    ///
102    /// sRGB and [`LinearRgba`](crate::LinearRgba) use these primaries.
103    pub const BT709: Self = Self {
104        red: Chromaticity::new(0.640, 0.330),
105        green: Chromaticity::new(0.300, 0.600),
106        blue: Chromaticity::new(0.150, 0.060),
107        white: Chromaticity::D65,
108    };
109
110    /// The [ITU-R BT.2020](https://registry.color.org/rgb-registry/bt2020) (Rec. 2020)
111    /// wide-gamut primaries with a D65 white point.
112    pub const BT2020: Self = Self {
113        red: Chromaticity::new(0.708, 0.292),
114        green: Chromaticity::new(0.170, 0.797),
115        blue: Chromaticity::new(0.131, 0.046),
116        white: Chromaticity::D65,
117    };
118
119    /// The [Display P3](https://registry.color.org/rgb-registry/displayp3) primaries:
120    /// the DCI-P3 primaries with a D65 white point.
121    pub const DISPLAY_P3: Self = Self {
122        red: Chromaticity::new(0.680, 0.320),
123        green: Chromaticity::new(0.265, 0.690),
124        blue: Chromaticity::new(0.150, 0.060),
125        white: Chromaticity::D65,
126    };
127
128    /// The [ACEScg](https://docs.acescentral.com/encodings/acescg) (AP1) primaries
129    /// with the ACES white point, used for scene-linear rendering.
130    pub const ACES_CG: Self = Self {
131        red: Chromaticity::new(0.713, 0.293),
132        green: Chromaticity::new(0.165, 0.830),
133        blue: Chromaticity::new(0.128, 0.044),
134        white: Chromaticity::D60,
135    };
136
137    /// Returns a matrix that converts linear RGB colors from these primaries to `dst`
138    /// primaries.
139    ///
140    /// White points are not adapted. Converting between sets with different whites, such as
141    /// [`RgbPrimaries::ACES_CG`] and a D65 set, leaves a small shift in white.
142    /// The matrix is computed in `f64` and rounded to `f32`.
143    pub fn matrix_to(self, dst: Self) -> Mat3 {
144        (dst.rgb_to_xyz_dmat3().inverse() * self.rgb_to_xyz_dmat3()).as_mat3()
145    }
146
147    /// Derive the RGB to XYZ matrix in `f64` precision. The matrix maps `(1, 1, 1)`
148    /// to the XYZ value of [`Self::white`] at luminance `1.0`.
149    fn rgb_to_xyz_dmat3(&self) -> DMat3 {
150        let primaries = DMat3::from_cols(
151            self.red.to_dxyz(),
152            self.green.to_dxyz(),
153            self.blue.to_dxyz(),
154        );
155        // Scale each column so that (1, 1, 1) maps to the white point at Y = 1.
156        let scale = primaries.inverse() * self.white.to_dxyz();
157        primaries * DMat3::from_diagonal(scale)
158    }
159}
160
161impl Default for RgbPrimaries {
162    fn default() -> Self {
163        Self::BT709
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::testing::{assert_approx_eq, assert_mat3_approx_eq};
171    use bevy_math::Vec3;
172
173    #[test]
174    fn bt709_to_bt2020_matches_bt2087() {
175        // Published ITU-R BT.2087-0 (table in section 2.2.1) Rec.709 to Rec.2020 matrix.
176        let expected = Mat3::from_cols_array_2d(&[
177            [0.6274, 0.0691, 0.0164],
178            [0.3293, 0.9195, 0.0880],
179            [0.0433, 0.0114, 0.8956],
180        ]);
181        let m = RgbPrimaries::BT709.matrix_to(RgbPrimaries::BT2020);
182        assert_mat3_approx_eq(m, expected, 1e-4);
183    }
184
185    #[test]
186    fn rgb_to_rgb_round_trip_is_identity() {
187        let sets = [
188            RgbPrimaries::BT709,
189            RgbPrimaries::BT2020,
190            RgbPrimaries::DISPLAY_P3,
191            RgbPrimaries::ACES_CG,
192        ];
193        for src in sets {
194            for dst in sets {
195                let round_trip = dst.matrix_to(src) * src.matrix_to(dst);
196                assert_mat3_approx_eq(round_trip, Mat3::IDENTITY, 1e-6);
197            }
198        }
199    }
200
201    #[test]
202    fn white_maps_to_white_point() {
203        for primaries in [
204            RgbPrimaries::BT709,
205            RgbPrimaries::BT2020,
206            RgbPrimaries::DISPLAY_P3,
207            RgbPrimaries::ACES_CG,
208        ] {
209            let white_xyz = primaries.rgb_to_xyz_dmat3().as_mat3() * Vec3::ONE;
210            let expected = primaries.white.to_xyza(1.0);
211            assert_approx_eq!(white_xyz.x, expected.x, 1e-6);
212            assert_approx_eq!(white_xyz.y, expected.y, 1e-6);
213            assert_approx_eq!(white_xyz.z, expected.z, 1e-6);
214        }
215    }
216
217    #[test]
218    fn bt709_matrix_matches_crate_srgb_matrix() {
219        // This crate's sRGB matrices use Lindbloom's D65, XYZ 0.95047, 1.0, 1.08883 from
220        // ASTM E308. White from the BT.709 chromaticity 0.3127, 0.3290 differs by about
221        // 2e-4, so this check is only approximate.
222        let expected = Mat3::from_cols_array_2d(&[
223            [0.4124564, 0.2126729, 0.0193339],
224            [0.3575761, 0.7151522, 0.119192],
225            [0.1804375, 0.072175, 0.9503041],
226        ]);
227        let m = RgbPrimaries::BT709.rgb_to_xyz_dmat3().as_mat3();
228        assert_mat3_approx_eq(m, expected, 5e-4);
229    }
230
231    #[test]
232    fn chromaticity_to_xyza() {
233        let xyz = Chromaticity::D65.to_xyza(1.0);
234        assert_approx_eq!(xyz.x, 0.9504559, 1e-6);
235        assert_approx_eq!(xyz.y, 1.0, 1e-6);
236        assert_approx_eq!(xyz.z, 1.0890578, 1e-6);
237        assert_approx_eq!(xyz.alpha, 1.0, 1e-6);
238
239        let xyz = Chromaticity::D65.to_xyza(2.0);
240        assert_approx_eq!(xyz.y, 2.0, 1e-6);
241        assert_approx_eq!(xyz.x, 2.0 * 0.9504559, 1e-5);
242    }
243}