#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorSpace {
#[default]
Srgb,
ExtendedSrgb,
DisplayP3,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
space: ColorSpace,
}
impl Color {
pub const TRANSPARENT: Self = Self::srgb(0.0, 0.0, 0.0, 0.0);
pub const BLACK: Self = Self::srgb(0.0, 0.0, 0.0, 1.0);
pub const WHITE: Self = Self::srgb(1.0, 1.0, 1.0, 1.0);
pub fn linear(r: f32, g: f32, b: f32, a: f32) -> Self {
Self {
r: linear_to_srgb(r),
g: linear_to_srgb(g),
b: linear_to_srgb(b),
a,
space: ColorSpace::Srgb,
}
}
pub const fn space(self) -> ColorSpace {
self.space
}
pub fn with_space(self, space: ColorSpace) -> Self {
let [r, g, b] = convert(self.space, space, [self.r, self.g, self.b]);
Self {
r,
g,
b,
a: self.a,
space,
}
}
pub const fn srgb(r: f32, g: f32, b: f32, a: f32) -> Self {
Self {
r,
g,
b,
a,
space: ColorSpace::Srgb,
}
}
pub const fn display_p3(r: f32, g: f32, b: f32, a: f32) -> Self {
Self {
r,
g,
b,
a,
space: ColorSpace::DisplayP3,
}
}
pub fn rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
Self::srgb(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
a as f32 / 255.0,
)
}
pub fn to_srgb(self) -> [f32; 4] {
self.to_array()
}
pub fn with_alpha(mut self, alpha: f32) -> Self {
self.a = alpha;
self
}
pub fn is_opaque(self) -> bool {
self.a >= 1.0
}
pub fn is_invisible(self) -> bool {
self.a <= 0.0
}
pub fn to_array(self) -> [f32; 4] {
let [r, g, b] = convert(
self.space,
ColorSpace::ExtendedSrgb,
[self.r, self.g, self.b],
);
[r, g, b, self.a]
}
}
const P3_TO_SRGB: [[f32; 3]; 3] = [
[1.224_940_2, -0.224_940_18, 0.0],
[-0.042_056_95, 1.042_056_9, 0.0],
[-0.019_637_55, -0.078_636_05, 1.098_273_6],
];
const SRGB_TO_P3: [[f32; 3]; 3] = [
[0.822_461_96, 0.177_538_04, 0.0],
[0.033_194_2, 0.966_805_8, 0.0],
[0.017_082_63, 0.072_397_44, 0.910_519_93],
];
fn apply(matrix: &[[f32; 3]; 3], c: [f32; 3]) -> [f32; 3] {
let row = |i: usize| matrix[i][0] * c[0] + matrix[i][1] * c[1] + matrix[i][2] * c[2];
[row(0), row(1), row(2)]
}
fn convert(from: ColorSpace, to: ColorSpace, c: [f32; 3]) -> [f32; 3] {
let through_light = |m: &[[f32; 3]; 3], c: [f32; 3]| {
let linear = [
srgb_to_linear(c[0]),
srgb_to_linear(c[1]),
srgb_to_linear(c[2]),
];
let rotated = apply(m, linear);
[
linear_to_srgb(rotated[0]),
linear_to_srgb(rotated[1]),
linear_to_srgb(rotated[2]),
]
};
match (from, to) {
(ColorSpace::DisplayP3, ColorSpace::DisplayP3) => c,
(ColorSpace::DisplayP3, _) => through_light(&P3_TO_SRGB, c),
(_, ColorSpace::DisplayP3) => through_light(&SRGB_TO_P3, c),
_ => c,
}
}
fn srgb_to_linear(value: f32) -> f32 {
let magnitude = value.abs();
let decoded = if magnitude <= 0.040_45 {
magnitude / 12.92
} else {
((magnitude + 0.055) / 1.055).powf(2.4)
};
decoded.copysign(value)
}
fn linear_to_srgb(value: f32) -> f32 {
let magnitude = value.abs();
let encoded = if magnitude <= 0.003_130_8 {
magnitude * 12.92
} else {
1.055 * magnitude.powf(1.0 / 2.4) - 0.055
};
encoded.copysign(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn the_endpoints_are_fixed_points_of_the_transfer_function() {
for value in [0.0, 1.0] {
assert!(close(srgb_to_linear(value), value));
assert!(close(linear_to_srgb(value), value));
}
}
#[test]
fn converting_to_srgb_and_back_returns_the_original() {
for value in [0.0, 0.001, 0.05, 0.25, 0.5, 0.75, 0.99, 1.0] {
let round_tripped = linear_to_srgb(srgb_to_linear(value));
assert!(
close(round_tripped, value),
"{value} became {round_tripped}"
);
}
}
#[test]
fn mid_gray_is_not_a_fixed_point() {
let linear = srgb_to_linear(0.5);
assert!(
(0.21..0.22).contains(&linear),
"sRGB 0.5 should be about 0.214 linear, got {linear}"
);
}
#[test]
fn the_linear_segment_covers_near_black() {
assert!(close(srgb_to_linear(0.04), 0.04 / 12.92));
assert!(srgb_to_linear(0.0001) > 0.0);
}
#[test]
fn alpha_is_not_transformed() {
let color = Color::srgb(0.5, 0.5, 0.5, 0.5);
assert_eq!(color.a, 0.5);
assert_eq!(color.to_srgb()[3], 0.5);
}
#[test]
fn eight_bit_values_land_where_expected() {
let white = Color::rgba8(255, 255, 255, 255);
assert!(close(white.r, 1.0) && close(white.a, 1.0));
let black = Color::rgba8(0, 0, 0, 255);
assert!(close(black.r, 0.0));
let gray = Color::rgba8(128, 128, 128, 255);
assert!(close(gray.r, 128.0 / 255.0), "sRGB 128 became {}", gray.r);
}
#[test]
fn visibility_is_decided_by_alpha_alone() {
assert!(Color::TRANSPARENT.is_invisible());
assert!(!Color::BLACK.is_invisible());
assert!(Color::BLACK.is_opaque());
assert!(!Color::WHITE.with_alpha(0.5).is_opaque());
}
#[test]
fn the_transfer_function_is_odd_about_zero() {
for value in [0.001, 0.0031308, 0.01, 0.04045, 0.1, 0.5, 1.0, 1.5] {
assert!(
close(linear_to_srgb(-value), -linear_to_srgb(value)),
"encoding {value}: {} against {}",
linear_to_srgb(-value),
-linear_to_srgb(value)
);
assert!(
close(srgb_to_linear(-value), -srgb_to_linear(value)),
"decoding {value}: {} against {}",
srgb_to_linear(-value),
-srgb_to_linear(value)
);
}
}
#[test]
fn converting_to_srgb_and_back_returns_the_original_outside_the_unit_range() {
for value in [
-1.5, -0.5, -0.226_742, -0.042_057, -0.001, 1.098, 1.225, 1.5,
] {
let round_tripped = linear_to_srgb(srgb_to_linear(value));
assert!(
close(round_tripped, value),
"{value} became {round_tripped}"
);
}
}
#[test]
fn a_component_below_zero_encodes_onto_the_curve_rather_than_its_linear_segment() {
assert!(close(linear_to_srgb(-0.042_056_955), -0.226_742));
assert!(!close(linear_to_srgb(-0.042_056_955), -0.543_376));
}
#[test]
fn zero_is_still_a_fixed_point() {
assert_eq!(linear_to_srgb(0.0), 0.0);
assert_eq!(srgb_to_linear(0.0), 0.0);
}
mod derivation {
fn to_xyz(primaries: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] {
let column = |p: [f64; 2]| [p[0] / p[1], 1.0, (1.0 - p[0] - p[1]) / p[1]];
let m = primaries.map(column);
let w = column(white);
let det = |a: [[f64; 3]; 3]| {
a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[1][0] * (a[0][1] * a[2][2] - a[0][2] * a[2][1])
+ a[2][0] * (a[0][1] * a[1][2] - a[0][2] * a[1][1])
};
let base = det(m);
let scale = |i: usize| {
let mut swapped = m;
swapped[i] = w;
det(swapped) / base
};
let columns = [
m[0].map(|v| v * scale(0)),
m[1].map(|v| v * scale(1)),
m[2].map(|v| v * scale(2)),
];
let mut rows = [[0.0; 3]; 3];
for (r, row) in rows.iter_mut().enumerate() {
for (c, cell) in row.iter_mut().enumerate() {
*cell = columns[c][r];
}
}
rows
}
fn inverse(a: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
let c = |i: usize, j: usize| {
let (i0, i1) = ((i + 1) % 3, (i + 2) % 3);
let (j0, j1) = ((j + 1) % 3, (j + 2) % 3);
a[i0][j0] * a[i1][j1] - a[i0][j1] * a[i1][j0]
};
let det = a[0][0] * c(0, 0) + a[1][0] * c(1, 0) + a[2][0] * c(2, 0);
let mut out = [[0.0; 3]; 3];
for (i, row) in out.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = c(j, i) / det;
}
}
out
}
pub fn p3_to_srgb() -> [[f64; 3]; 3] {
let srgb = to_xyz(
[[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]],
[0.3127, 0.3290],
);
let p3 = to_xyz(
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
[0.3127, 0.3290],
);
let inv = inverse(srgb);
let mut out = [[0.0; 3]; 3];
for (row, out_row) in out.iter_mut().enumerate() {
for (col, cell) in out_row.iter_mut().enumerate() {
*cell = (0..3).map(|k| inv[row][k] * p3[k][col]).sum::<f64>();
}
}
out
}
}
#[test]
fn the_change_of_primaries_is_what_the_chromaticities_give() {
let derived = derivation::p3_to_srgb();
for row in 0..3 {
for col in 0..3 {
let stated = P3_TO_SRGB[row][col] as f64;
assert!(
(stated - derived[row][col]).abs() < 1e-6,
"row {row} column {col}: source has {stated}, the primaries give {}",
derived[row][col]
);
}
}
}
#[test]
fn white_is_the_same_color_in_both_spaces() {
let white = Color::display_p3(1.0, 1.0, 1.0, 1.0).to_array();
for (component, value) in white.iter().enumerate() {
assert!(
close(*value, 1.0),
"component {component} of P3 white came back {value}"
);
}
}
#[test]
fn the_shared_blue_primary_stays_on_its_own_axis() {
let [r, g, b, _] = Color::display_p3(0.0, 0.0, 1.0, 1.0).to_array();
assert_eq!((r, g), (0.0, 0.0), "blue picked up another axis");
assert!(
b > 1.0,
"P3 blue is the same chromaticity at more light: {b}"
);
}
#[test]
fn grays_agree_between_the_spaces() {
for value in [0.0, 0.25, 0.5, 1.0] {
let through_srgb = Color::srgb(value, value, value, 1.0).to_array();
let through_p3 = Color::display_p3(value, value, value, 1.0).to_array();
for i in 0..4 {
assert!(
close(through_srgb[i], through_p3[i]),
"gray {value}, component {i}: {} against {}",
through_srgb[i],
through_p3[i]
);
}
}
}
#[test]
fn a_display_p3_red_leaves_the_srgb_primaries() {
let [r, g, b, a] = Color::display_p3(1.0, 0.0, 0.0, 1.0).to_array();
assert!(close(r, 1.093_066), "{r}");
assert!(close(g, -0.226_742), "{g}");
assert!(close(b, -0.150_137), "{b}");
assert_eq!(a, 1.0);
let [er, eg, eb, _] = Color::display_p3(1.0, 0.0, 0.0, 1.0).to_srgb();
assert!(close(er, 1.093_066), "{er}");
assert!(close(eg, -0.226_742), "{eg}");
assert!(close(eb, -0.150_137), "{eb}");
}
#[test]
fn restating_a_color_in_another_space_and_back_returns_it() {
let original = Color::srgb(0.2, 0.7, 0.4, 0.5);
let round_tripped = original
.with_space(ColorSpace::DisplayP3)
.with_space(ColorSpace::Srgb);
assert_eq!(round_tripped.space(), ColorSpace::Srgb);
for (a, b) in original.to_array().iter().zip(round_tripped.to_array()) {
assert!(close(*a, b), "{a} became {b}");
}
}
#[test]
fn the_two_srgb_spaces_describe_the_same_primaries() {
let wide = Color::srgb(1.5, -0.2, 0.3, 1.0).with_space(ColorSpace::ExtendedSrgb);
assert_eq!(wide.to_array(), [1.5, -0.2, 0.3, 1.0]);
}
}