1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-30 21:30:03 -04:00

Fix semi-transparent colors appearing too bright (#5824)

The bug was in `Color32::from_rgba_unmultiplied` and by extension
affects:

* `Color32::from_rgba_unmultiplied`
* `hex_color!`
* `HexColor`
* `ColorImage::from_rgba_unmultiplied`
* All images with transparency (png, webp, …)
* `Color32::from_white_alpha`

The bug caused translucent colors to appear too bright.

## More
Color is hard.

When I started out egui I thought "linear space is objectively better,
for everything!" and then I've been slowly walking that back for various
reasons:

* sRGB textures not available everywhere
* gamma-space is more _perceptually_ even, so it makes sense to use for
anti-aliasing
* other applications do everything in gamma space, so that's what people
expect (this PR)

Similarly, pre-multiplied alpha _makes sense_ for blending colors. It
also enables additive colors, which is nice. But it does complicate
things. Especially when mixed with sRGB/gamma (As @karhu [points
out](https://github.com/emilk/egui/pull/5824#issuecomment-2738099254)).

## Related
* Closes https://github.com/emilk/egui/issues/5751
* Closes https://github.com/emilk/egui/issues/5771 ? (probably; hard to
tell without a repro)
* But not https://github.com/emilk/egui/issues/5810

## TODO
* [x] I broke the RGBA u8 color picker. Fix it

---------

Co-authored-by: Andreas Reich <andreas@rerun.io>
This commit is contained in:
Emil Ernerfeldt
2025-03-21 10:45:25 +01:00
committed by GitHub
parent d54e29d375
commit 3f731ec794
19 changed files with 399 additions and 103 deletions

View File

@@ -5,10 +5,24 @@ use crate::{fast_round, linear_f32_from_linear_u8, Rgba};
/// Instead of manipulating this directly it is often better
/// to first convert it to either [`Rgba`] or [`crate::Hsva`].
///
/// Internally this uses 0-255 gamma space `sRGBA` color with premultiplied alpha.
/// Alpha channel is in linear space.
/// Internally this uses 0-255 gamma space `sRGBA` color with _premultiplied alpha_.
///
/// The special value of alpha=0 means the color is to be treated as an additive color.
/// It's the non-linear ("gamma") values that are multiplied with the alpha.
///
/// Premultiplied alpha means that the color values have been pre-multiplied with the alpha (opacity).
/// This is in contrast with "normal" RGBA, where the alpha is _separate_ (or "unmultiplied").
/// Using premultiplied alpha has some advantages:
/// * It allows encoding additive colors
/// * It is the better way to blend colors, e.g. when filtering texture colors
/// * Because the above, it is the better way to encode colors in a GPU texture
///
/// The color space is assumed to be [sRGB](https://en.wikipedia.org/wiki/SRGB).
///
/// All operations on `Color32` are done in "gamma space" (see <https://en.wikipedia.org/wiki/SRGB>).
/// This is not physically correct, but it is fast and sometimes more perceptually even than linear space.
/// If you instead want to perform these operations in linear-space color, use [`Rgba`].
///
/// An `alpha=0` means the color is to be treated as an additive color.
#[repr(C)]
#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -16,6 +30,7 @@ use crate::{fast_round, linear_f32_from_linear_u8, Rgba};
pub struct Color32(pub(crate) [u8; 4]);
impl std::fmt::Debug for Color32 {
/// Prints the contents with premultiplied alpha!
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [r, g, b, a] = self.0;
write!(f, "#{r:02X}_{g:02X}_{b:02X}_{a:02X}")
@@ -90,41 +105,49 @@ impl Color32 {
#[deprecated = "Renamed to PLACEHOLDER"]
pub const TEMPORARY_COLOR: Self = Self::PLACEHOLDER;
/// From RGB with alpha of 255 (opaque).
#[inline]
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 255])
}
/// From RGB into an additive color (will make everything it blend with brighter).
#[inline]
pub const fn from_rgb_additive(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 0])
}
/// From `sRGBA` with premultiplied alpha.
///
/// You likely want to use [`Self::from_rgba_unmultiplied`] instead.
#[inline]
pub const fn from_rgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
Self([r, g, b, a])
}
/// From `sRGBA` WITHOUT premultiplied alpha.
/// From `sRGBA` with separate alpha.
///
/// This is a "normal" RGBA value that you would find in a color picker or a table somewhere.
///
/// You can use [`Self::to_srgba_unmultiplied`] to get back these values,
/// but for transparent colors what you get back might be slightly different (rounding errors).
#[inline]
pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
use std::sync::OnceLock;
match a {
// common-case optimization
// common-case optimization:
0 => Self::TRANSPARENT,
// common-case optimization
// common-case optimization:
255 => Self::from_rgb(r, g, b),
a => {
static LOOKUP_TABLE: OnceLock<Box<[u8]>> = OnceLock::new();
let lut = LOOKUP_TABLE.get_or_init(|| {
use crate::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8};
(0..=u16::MAX)
.map(|i| {
let [value, alpha] = i.to_ne_bytes();
let value_lin = linear_f32_from_gamma_u8(value);
let alpha_lin = linear_f32_from_linear_u8(alpha);
gamma_u8_from_linear_f32(value_lin * alpha_lin)
fast_round(value as f32 * linear_f32_from_linear_u8(alpha))
})
.collect()
});
@@ -136,22 +159,26 @@ impl Color32 {
}
}
/// Opaque gray.
#[doc(alias = "from_grey")]
#[inline]
pub const fn from_gray(l: u8) -> Self {
Self([l, l, l, 255])
}
/// Black with the given opacity.
#[inline]
pub const fn from_black_alpha(a: u8) -> Self {
Self([0, 0, 0, a])
}
/// White with the given opacity.
#[inline]
pub fn from_white_alpha(a: u8) -> Self {
Rgba::from_white_alpha(linear_f32_from_linear_u8(a)).into()
Self([a, a, a, a])
}
/// Additive white.
#[inline]
pub const fn from_additive_luminance(l: u8) -> Self {
Self([l, l, l, 0])
@@ -162,21 +189,25 @@ impl Color32 {
self.a() == 255
}
/// Red component multiplied by alpha.
#[inline]
pub const fn r(&self) -> u8 {
self.0[0]
}
/// Green component multiplied by alpha.
#[inline]
pub const fn g(&self) -> u8 {
self.0[1]
}
/// Blue component multiplied by alpha.
#[inline]
pub const fn b(&self) -> u8 {
self.0[2]
}
/// Alpha (opacity).
#[inline]
pub const fn a(&self) -> u8 {
self.0[3]
@@ -213,9 +244,26 @@ impl Color32 {
(self.r(), self.g(), self.b(), self.a())
}
/// Convert to a normal "unmultiplied" RGBA color (i.e. with separate alpha).
///
/// This will unmultiply the alpha.
///
/// This is the inverse of [`Self::from_rgba_unmultiplied`],
/// but due to precision problems it may return slightly different values for transparent colors.
#[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
Rgba::from(*self).to_srgba_unmultiplied()
let [r, g, b, a] = self.to_array();
match a {
// Common-case optimization.
0 | 255 => self.to_array(),
a => {
let factor = 255.0 / a as f32;
let r = fast_round(factor * r as f32);
let g = fast_round(factor * g as f32);
let b = fast_round(factor * b as f32);
[r, g, b, a]
}
}
}
/// Multiply with 0.5 to make color half as opaque, perceptually.
@@ -291,7 +339,7 @@ impl Color32 {
)
}
/// Blend two colors, so that `self` is behind the argument.
/// Blend two colors in gamma space, so that `self` is behind the argument.
pub fn blend(self, on_top: Self) -> Self {
self.gamma_multiply_u8(255 - on_top.a()) + on_top
}
@@ -333,3 +381,131 @@ impl std::ops::Add for Color32 {
])
}
}
#[cfg(test)]
mod test {
use super::*;
fn test_rgba() -> impl Iterator<Item = [u8; 4]> {
[
[0, 0, 0, 0],
[0, 0, 0, 255],
[10, 0, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 0],
[10, 20, 0, 255],
[10, 20, 30, 255],
[10, 20, 30, 40],
[255, 255, 255, 0],
[255, 255, 255, 255],
]
.into_iter()
}
#[test]
fn test_color32_additive() {
let opaque = Color32::from_rgb(40, 50, 60);
let additive = Color32::from_rgb(255, 127, 10).additive();
assert_eq!(additive.blend(opaque), opaque, "opaque on top of additive");
assert_eq!(
opaque.blend(additive),
Color32::from_rgb(255, 177, 70),
"additive on top of opaque"
);
}
#[test]
fn test_color32_blend_vs_gamma_blend() {
let opaque = Color32::from_rgb(0x60, 0x60, 0x60);
let transparent = Color32::from_rgba_unmultiplied(168, 65, 65, 79);
assert_eq!(
transparent.blend(opaque),
opaque,
"Opaque on top of transparent"
);
// Blending in gamma-space is the de-facto standard almost everywhere.
// Browsers and most image editors do it, and so it is what users expect.
assert_eq!(
opaque.blend(transparent),
Color32::from_rgb(
blend(0x60, 168, 79),
blend(0x60, 65, 79),
blend(0x60, 65, 79)
),
"Transparent on top of opaque"
);
fn blend(dest: u8, src: u8, alpha: u8) -> u8 {
let src = src as f32 / 255.0;
let dest = dest as f32 / 255.0;
let alpha = alpha as f32 / 255.0;
fast_round((src * alpha + dest * (1.0 - alpha)) * 255.0)
}
}
#[test]
fn color32_unmultiplied_round_trip() {
for in_rgba in test_rgba() {
let [r, g, b, a] = in_rgba;
if a == 0 {
continue;
}
let c = Color32::from_rgba_unmultiplied(r, g, b, a);
let out_rgba = c.to_srgba_unmultiplied();
if a == 255 {
assert_eq!(in_rgba, out_rgba);
} else {
// There will be small rounding errors whenever the alpha is not 0 or 255,
// because we multiply and then unmultiply the alpha.
for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) {
assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}");
}
}
}
}
#[test]
fn from_black_white_alpha() {
for a in 0..=255 {
assert_eq!(
Color32::from_white_alpha(a),
Color32::from_rgba_unmultiplied(255, 255, 255, a)
);
assert_eq!(
Color32::from_white_alpha(a),
Color32::WHITE.gamma_multiply_u8(a)
);
assert_eq!(
Color32::from_black_alpha(a),
Color32::from_rgba_unmultiplied(0, 0, 0, a)
);
assert_eq!(
Color32::from_black_alpha(a),
Color32::BLACK.gamma_multiply_u8(a)
);
}
}
#[test]
fn to_from_rgba() {
for [r, g, b, a] in test_rgba() {
let original = Color32::from_rgba_unmultiplied(r, g, b, a);
let rgba = Rgba::from(original);
let back = Color32::from(rgba);
assert_eq!(back, original);
}
assert_eq!(
Color32::from(Rgba::from_rgba_unmultiplied(1.0, 0.0, 0.0, 0.5)),
Color32::from_rgba_unmultiplied(255, 0, 0, 128)
);
}
}

View File

@@ -208,17 +208,22 @@ mod tests {
#[test]
fn hex_string_round_trip() {
use Color32 as C;
let cases = [
C::from_rgba_unmultiplied(10, 20, 30, 0),
C::from_rgba_unmultiplied(10, 20, 30, 40),
C::from_rgba_unmultiplied(10, 20, 30, 255),
C::from_rgba_unmultiplied(0, 20, 30, 0),
C::from_rgba_unmultiplied(10, 0, 30, 40),
C::from_rgba_unmultiplied(10, 20, 0, 255),
[0, 20, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 255],
[10, 20, 30, 0],
[10, 20, 30, 255],
[10, 20, 30, 40],
];
for color in cases {
assert_eq!(C::from_hex(color.to_hex().as_str()), Ok(color));
for [r, g, b, a] in cases {
let color = Color32::from_rgba_unmultiplied(r, g, b, a);
assert_eq!(Color32::from_hex(color.to_hex().as_str()), Ok(color));
}
}
}

View File

@@ -1,6 +1,5 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32, Color32, Rgba,
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_u8_from_linear_f32, Color32, Rgba,
};
/// Hue, saturation, value, alpha. All in the range [0, 1].
@@ -29,30 +28,20 @@ impl Hsva {
/// From `sRGBA` with premultiplied alpha
#[inline]
pub fn from_srgba_premultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_premultiplied(
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
}
/// From `sRGBA` without premultiplied alpha
#[inline]
pub fn from_srgba_unmultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_unmultiplied(
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
}
/// From linear RGBA with premultiplied alpha
#[inline]
pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
#![allow(clippy::many_single_char_names)]
if a == 0.0 {
if a <= 0.0 {
if r == 0.0 && b == 0.0 && a == 0.0 {
Self::default()
} else {
@@ -152,13 +141,7 @@ impl Hsva {
#[inline]
pub fn to_srgba_premultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_premultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a),
]
Color32::from(*self).to_array()
}
/// To gamma-space 0-255.

View File

@@ -1,9 +1,20 @@
//! Color conversions and types.
//!
//! This crate is built for the wants and needs of [`egui`](https://github.com/emilk/egui/).
//!
//! If you want an actual _good_ color crate, use [`color`](https://crates.io/crates/color) instead.
//!
//! If you want a compact color representation, use [`Color32`].
//! If you want to manipulate RGBA colors use [`Rgba`].
//! If you want to manipulate RGBA colors in linear space use [`Rgba`].
//! If you want to manipulate colors in a way closer to how humans think about colors, use [`HsvaGamma`].
//!
//! ## Conventions
//! The word "gamma" or "srgb" is used to refer to values in the non-linear space defined by
//! [the sRGB transfer function](https://en.wikipedia.org/wiki/SRGB).
//! We use `u8` for anything in the "gamma" space.
//!
//! We use `f32` in 0-1 range for anything in the linear space.
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
@@ -39,23 +50,46 @@ pub use hex_color_runtime::*;
impl From<Color32> for Rgba {
fn from(srgba: Color32) -> Self {
Self([
linear_f32_from_gamma_u8(srgba.0[0]),
linear_f32_from_gamma_u8(srgba.0[1]),
linear_f32_from_gamma_u8(srgba.0[2]),
linear_f32_from_linear_u8(srgba.0[3]),
])
let [r, g, b, a] = srgba.to_array();
if a == 0 {
// Additive, or completely transparent
Self([
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
0.0,
])
} else {
let a = linear_f32_from_linear_u8(a);
Self([
linear_from_gamma(r as f32 / (255.0 * a)) * a,
linear_from_gamma(g as f32 / (255.0 * a)) * a,
linear_from_gamma(b as f32 / (255.0 * a)) * a,
a,
])
}
}
}
impl From<Rgba> for Color32 {
fn from(rgba: Rgba) -> Self {
Self([
gamma_u8_from_linear_f32(rgba.0[0]),
gamma_u8_from_linear_f32(rgba.0[1]),
gamma_u8_from_linear_f32(rgba.0[2]),
linear_u8_from_linear_f32(rgba.0[3]),
])
let [r, g, b, a] = rgba.to_array();
if a == 0.0 {
// Additive, or completely transparent
Self([
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
0,
])
} else {
Self([
fast_round(gamma_u8_from_linear_f32(r / a) as f32 * a),
fast_round(gamma_u8_from_linear_f32(g / a) as f32 * a),
fast_round(gamma_u8_from_linear_f32(b / a) as f32 * a),
linear_u8_from_linear_f32(a),
])
}
}
}

View File

@@ -1,9 +1,8 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32,
};
use crate::Color32;
/// 0-1 linear space `RGBA` color with premultiplied alpha.
///
/// See [`crate::Color32`] for explanation of what "premultiplied alpha" means.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -70,20 +69,12 @@ impl Rgba {
#[inline]
pub fn from_srgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r, g, b, a)
Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
}
#[inline]
pub fn from_srgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r * a, g * a, b * a, a)
Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
}
#[inline]
@@ -211,13 +202,12 @@ impl Rgba {
/// unmultiply the alpha
#[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a.abs()),
]
crate::Color32::from(*self).to_srgba_unmultiplied()
}
/// Blend two colors in linear space, so that `self` is behind the argument.
pub fn blend(self, on_top: Self) -> Self {
self.multiply(1.0 - on_top.a()) + on_top
}
}
@@ -276,3 +266,72 @@ impl std::ops::Mul<Rgba> for f32 {
])
}
}
#[cfg(test)]
mod test {
use super::*;
fn test_rgba() -> impl Iterator<Item = [u8; 4]> {
[
[0, 0, 0, 0],
[0, 0, 0, 255],
[10, 0, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 0],
[10, 20, 0, 255],
[10, 20, 30, 255],
[10, 20, 30, 40],
[255, 255, 255, 0],
[255, 255, 255, 255],
]
.into_iter()
}
#[test]
fn test_rgba_blend() {
let opaque = Rgba::from_rgb(0.4, 0.5, 0.6);
let transparent = Rgba::from_rgb(1.0, 0.5, 0.0).multiply(0.3);
assert_eq!(
transparent.blend(opaque),
opaque,
"Opaque on top of transparent"
);
assert_eq!(
opaque.blend(transparent),
Rgba::from_rgb(
0.7 * 0.4 + 0.3 * 1.0,
0.7 * 0.5 + 0.3 * 0.5,
0.7 * 0.6 + 0.3 * 0.0
),
"Transparent on top of opaque"
);
}
#[test]
fn test_rgba_roundtrip() {
for in_rgba in test_rgba() {
let [r, g, b, a] = in_rgba;
if a == 0 {
continue;
}
let rgba = Rgba::from_srgba_unmultiplied(r, g, b, a);
let out_rgba = rgba.to_srgba_unmultiplied();
if a == 255 {
assert_eq!(in_rgba, out_rgba);
} else {
// There will be small rounding errors whenever the alpha is not 0 or 255,
// because we multiply and then unmultiply the alpha.
for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) {
assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}");
}
}
}
}
}