color spaces and representation (#1572)

`Color` can now be from different color spaces or representation:
- sRGB
- linear RGB
- HSL

This fixes #1193 by allowing the creation of const colors of all types, and writing it to the linear RGB color space for rendering.

I went with an enum after trying with two different types (`Color` and `LinearColor`) to be able to use the different variants in all place where a `Color` is expected.

I also added the HLS representation because:
- I like it
- it's useful for some case, see example `contributors`: I can just change the saturation and lightness while keeping the hue of the color
- I think adding another variant not using `red`, `green`, `blue` makes it clearer there are differences

Co-authored-by: Carter Anderson <mcanders1@gmail.com>
This commit is contained in:
François 2021-03-17 23:59:51 +00:00
parent ab0165d20d
commit bcd5318247
7 changed files with 1144 additions and 430 deletions

File diff suppressed because it is too large Load Diff

View File

@ -29,19 +29,173 @@ impl SrgbColorSpace for f32 {
}
}
#[test]
fn test_srgb_full_roundtrip() {
let u8max: f32 = u8::max_value() as f32;
for color in 0..u8::max_value() {
let color01 = color as f32 / u8max;
let color_roundtrip = color01
.linear_to_nonlinear_srgb()
.nonlinear_to_linear_srgb();
// roundtrip is not perfect due to numeric precision, even with f64
// so ensure the error is at least ready for u8 (where sRGB is used)
assert_eq!(
(color01 * u8max).round() as u8,
(color_roundtrip * u8max).round() as u8
);
pub struct HslRepresentation;
impl HslRepresentation {
/// converts a color in HLS space to sRGB space
pub fn hsl_to_nonlinear_srgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
let hue_prime = hue / 60.0;
let largest_component = chroma * (1.0 - (hue_prime % 2.0 - 1.0).abs());
let (r_temp, g_temp, b_temp) = if hue_prime < 1.0 {
(chroma, largest_component, 0.0)
} else if hue_prime < 2.0 {
(largest_component, chroma, 0.0)
} else if hue_prime < 3.0 {
(0.0, chroma, largest_component)
} else if hue_prime < 4.0 {
(0.0, largest_component, chroma)
} else if hue_prime < 5.0 {
(largest_component, 0.0, chroma)
} else {
(chroma, 0.0, largest_component)
};
let lightness_match = lightness - chroma / 2.0;
[
r_temp + lightness_match,
g_temp + lightness_match,
b_temp + lightness_match,
]
}
/// converts a color in sRGB space to HLS space
pub fn nonlinear_srgb_to_hsl([red, green, blue]: [f32; 3]) -> (f32, f32, f32) {
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB
let x_max = red.max(green.max(blue));
let x_min = red.min(green.min(blue));
let chroma = x_max - x_min;
let lightness = (x_max + x_min) / 2.0;
let hue = if chroma == 0.0 {
0.0
} else if red > green && red > blue {
60.0 * (green - blue) / chroma
} else if green > red && green > blue {
60.0 * (2.0 + (blue - red) / chroma)
} else {
60.0 * (4.0 + (red - green) / chroma)
};
let hue = if hue < 0.0 { 360.0 + hue } else { hue };
let saturation = if lightness <= 0.0 || lightness >= 1.0 {
0.0
} else {
(x_max - lightness) / lightness.min(1.0 - lightness)
};
(hue, saturation, lightness)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn srgb_linear_full_roundtrip() {
let u8max: f32 = u8::max_value() as f32;
for color in 0..u8::max_value() {
let color01 = color as f32 / u8max;
let color_roundtrip = color01
.linear_to_nonlinear_srgb()
.nonlinear_to_linear_srgb();
// roundtrip is not perfect due to numeric precision, even with f64
// so ensure the error is at least ready for u8 (where sRGB is used)
assert_eq!(
(color01 * u8max).round() as u8,
(color_roundtrip * u8max).round() as u8
);
}
}
#[test]
fn hsl_to_srgb() {
// "truth" from https://en.wikipedia.org/wiki/HSL_and_HSV#Examples
// black
let (hue, saturation, lightness) = (0.0, 0.0, 0.0);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 0);
assert_eq!((g * 100.0).round() as u32, 0);
assert_eq!((b * 100.0).round() as u32, 0);
// white
let (hue, saturation, lightness) = (0.0, 0.0, 1.0);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 100);
assert_eq!((g * 100.0).round() as u32, 100);
assert_eq!((b * 100.0).round() as u32, 100);
let (hue, saturation, lightness) = (300.0, 0.5, 0.5);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 75);
assert_eq!((g * 100.0).round() as u32, 25);
assert_eq!((b * 100.0).round() as u32, 75);
// a red
let (hue, saturation, lightness) = (283.7, 0.775, 0.543);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 70);
assert_eq!((g * 100.0).round() as u32, 19);
assert_eq!((b * 100.0).round() as u32, 90);
// a green
let (hue, saturation, lightness) = (162.4, 0.779, 0.447);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 10);
assert_eq!((g * 100.0).round() as u32, 80);
assert_eq!((b * 100.0).round() as u32, 59);
// a blue
let (hue, saturation, lightness) = (251.1, 0.832, 0.511);
let [r, g, b] = HslRepresentation::hsl_to_nonlinear_srgb(hue, saturation, lightness);
assert_eq!((r * 100.0).round() as u32, 25);
assert_eq!((g * 100.0).round() as u32, 10);
assert_eq!((b * 100.0).round() as u32, 92);
}
#[test]
fn srgb_to_hsl() {
// "truth" from https://en.wikipedia.org/wiki/HSL_and_HSV#Examples
// black
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([0.0, 0.0, 0.0]);
assert_eq!(hue.round() as u32, 0);
assert_eq!((saturation * 100.0).round() as u32, 0);
assert_eq!((lightness * 100.0).round() as u32, 0);
// white
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([1.0, 1.0, 1.0]);
assert_eq!(hue.round() as u32, 0);
assert_eq!((saturation * 100.0).round() as u32, 0);
assert_eq!((lightness * 100.0).round() as u32, 100);
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([0.75, 0.25, 0.75]);
assert_eq!(hue.round() as u32, 300);
assert_eq!((saturation * 100.0).round() as u32, 50);
assert_eq!((lightness * 100.0).round() as u32, 50);
// a red
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([0.704, 0.187, 0.897]);
assert_eq!(hue.round() as u32, 284);
assert_eq!((saturation * 100.0).round() as u32, 78);
assert_eq!((lightness * 100.0).round() as u32, 54);
// a green
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([0.099, 0.795, 0.591]);
assert_eq!(hue.round() as u32, 162);
assert_eq!((saturation * 100.0).round() as u32, 78);
assert_eq!((lightness * 100.0).round() as u32, 45);
// a blue
let (hue, saturation, lightness) =
HslRepresentation::nonlinear_srgb_to_hsl([0.255, 0.104, 0.918]);
assert_eq!(hue.round() as u32, 251);
assert_eq!((saturation * 100.0).round() as u32, 83);
assert_eq!((lightness * 100.0).round() as u32, 51);
}
}

View File

@ -1,9 +1,6 @@
use ab_glyph::{FontArc, FontVec, InvalidFont, OutlinedGlyph};
use bevy_reflect::TypeUuid;
use bevy_render::{
color::Color,
texture::{Extent3d, Texture, TextureDimension, TextureFormat},
};
use bevy_render::texture::{Extent3d, Texture, TextureDimension, TextureFormat};
#[derive(Debug, TypeUuid)]
#[uuid = "97059ac6-c9ba-4da9-95b6-bed82c3ce198"]
@ -28,25 +25,12 @@ impl Font {
});
// TODO: make this texture grayscale
let color = Color::WHITE;
let color_u8 = [
(color.r() * 255.0) as u8,
(color.g() * 255.0) as u8,
(color.b() * 255.0) as u8,
];
Texture::new(
Extent3d::new(width as u32, height as u32, 1),
TextureDimension::D2,
alpha
.iter()
.map(|a| {
vec![
color_u8[0],
color_u8[1],
color_u8[2],
(color.a() * a * 255.0) as u8,
]
})
.map(|a| vec![255, 255, 255, (*a * 255.0) as u8])
.flatten()
.collect::<Vec<u8>>(),
TextureFormat::Rgba8UnormSrgb,

View File

@ -127,11 +127,12 @@ impl<'a> From<&'a OwnedWgpuVertexBufferLayout> for wgpu::VertexBufferLayout<'a>
impl WgpuFrom<Color> for wgpu::Color {
fn from(color: Color) -> Self {
let linear = color.as_linear_rgba_f32();
wgpu::Color {
r: color.r_linear() as f64,
g: color.g_linear() as f64,
b: color.b_linear() as f64,
a: color.a() as f64,
r: linear[0] as f64,
g: linear[1] as f64,
b: linear[2] as f64,
a: linear[3] as f64,
}
}
}

View File

@ -29,7 +29,7 @@ struct SelectTimer;
struct ContributorDisplay;
struct Contributor {
color: [f32; 3],
hue: f32,
}
struct Velocity {
@ -40,8 +40,11 @@ struct Velocity {
const GRAVITY: f32 = -9.821 * 100.0;
const SPRITE_SIZE: f32 = 75.0;
const COL_DESELECTED: Color = Color::rgba_linear(0.03, 0.03, 0.03, 0.92);
const COL_SELECTED: Color = Color::WHITE;
const SATURATION_DESELECTED: f32 = 0.3;
const LIGHTNESS_DESELECTED: f32 = 0.2;
const SATURATION_SELECTED: f32 = 0.9;
const LIGHTNESS_SELECTED: f32 = 0.7;
const ALPHA: f32 = 0.92;
const SHOWCASE_TIMER_SECS: f32 = 3.0;
@ -69,7 +72,7 @@ fn setup(
let pos = (rnd.gen_range(-400.0..400.0), rnd.gen_range(0.0..400.0));
let dir = rnd.gen_range(-1.0..1.0);
let velocity = Vec3::new(dir * 500.0, 0.0, 0.0);
let col = gen_color(&mut rnd);
let hue = rnd.gen_range(0.0..=360.0);
// some sprites should be flipped
let flipped = rnd.gen_bool(0.5);
@ -77,7 +80,7 @@ fn setup(
let transform = Transform::from_xyz(pos.0, pos.1, 0.0);
commands
.spawn((Contributor { color: col },))
.spawn((Contributor { hue },))
.with(Velocity {
translation: velocity,
rotation: -dir * 5.0,
@ -90,12 +93,12 @@ fn setup(
..Default::default()
},
material: materials.add(ColorMaterial {
color: COL_DESELECTED * col,
color: Color::hsla(hue, SATURATION_DESELECTED, LIGHTNESS_DESELECTED, ALPHA),
texture: Some(texture_handle.clone()),
}),
transform,
..Default::default()
})
.with(transform);
});
let e = commands.current_entity().unwrap();
@ -180,15 +183,8 @@ fn select_system(
let (name, e) = &sel.order[sel.idx];
if let Ok((c, handle, mut tr)) = q.get_mut(*e) {
for mut text in dq.iter_mut() {
select(
&mut *materials,
handle.clone(),
c,
&mut *tr,
&mut *text,
name,
);
if let Some(mut text) = dq.iter_mut().next() {
select(&mut *materials, handle, c, &mut *tr, &mut *text, name);
}
}
}
@ -197,14 +193,14 @@ fn select_system(
/// bring the object to the front and display the name.
fn select(
materials: &mut Assets<ColorMaterial>,
mat_handle: Handle<ColorMaterial>,
mat_handle: &Handle<ColorMaterial>,
cont: &Contributor,
trans: &mut Transform,
text: &mut Text,
name: &str,
) -> Option<()> {
let mat = materials.get_mut(mat_handle)?;
mat.color = COL_SELECTED * cont.color;
mat.color = Color::hsla(cont.hue, SATURATION_SELECTED, LIGHTNESS_SELECTED, ALPHA);
trans.translation.z = 100.0;
@ -224,7 +220,7 @@ fn deselect(
trans: &mut Transform,
) -> Option<()> {
let mat = materials.get_mut(mat_handle)?;
mat.color = COL_DESELECTED * cont.color;
mat.color = Color::hsla(cont.hue, SATURATION_DESELECTED, LIGHTNESS_DESELECTED, ALPHA);
trans.translation.z = 0.0;
@ -321,20 +317,3 @@ fn contributors() -> Contributors {
.filter_map(|x| x.ok())
.collect()
}
/// Generate a color modulation
///
/// Because there is no `Mul<Color> for Color` instead `[f32; 3]` is
/// used.
fn gen_color(rng: &mut impl Rng) -> [f32; 3] {
loop {
let rgb = rng.gen();
if luminance(rgb) >= 0.6 {
break rgb;
}
}
}
fn luminance([r, g, b]: [f32; 3]) -> f32 {
0.299 * r + 0.587 * g + 0.114 * b
}

View File

@ -5,7 +5,7 @@ use bevy::{
use rand::Rng;
const BIRDS_PER_SECOND: u32 = 1000;
const BASE_COLOR: Color = Color::rgb_linear(5.0, 5.0, 5.0);
const BASE_COLOR: Color = Color::rgb(5.0, 5.0, 5.0);
const GRAVITY: f32 = -9.8 * 100.0;
const MAX_VELOCITY: f32 = 750.;
const BIRD_SCALE: f32 = 0.15;

View File

@ -106,11 +106,11 @@ fn text_color_system(time: Res<Time>, mut query: Query<&mut Text, With<ColorText
let seconds = time.seconds_since_startup() as f32;
// We used the `Text::with_section` helper method, but it is still just a `Text`,
// so to update it, we are still updating the one and only section
text.sections[0]
.style
.color
.set_r((1.25 * seconds).sin() / 2.0 + 0.5)
.set_g((0.75 * seconds).sin() / 2.0 + 0.5)
.set_b((0.50 * seconds).sin() / 2.0 + 0.5);
text.sections[0].style.color = Color::Rgba {
red: (1.25 * seconds).sin() / 2.0 + 0.5,
green: (0.75 * seconds).sin() / 2.0 + 0.5,
blue: (0.50 * seconds).sin() / 2.0 + 0.5,
alpha: 1.0,
};
}
}