Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions node-graph/libraries/no-std-types/src/blending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,125 @@ mod tests {
assert!((half.a() - 0.5).abs() < 1e-5, "alpha was {}", half.a());
assert!((half.r() - 0.3).abs() < 1e-5, "red was {}", half.r());
}

// A transcription of the specification's pseudocode for the component blend modes, on gamma-encoded channels:
// https://www.w3.org/TR/compositing-1/#blendingnonseparable
mod specification {
pub fn lum(c: [f32; 3]) -> f32 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}

fn clip_color(c: [f32; 3]) -> [f32; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);

let mut c = c;
if n < 0. {
c = c.map(|channel| l + (channel - l) * l / (l - n));
}
if x > 1. {
c = c.map(|channel| l + (channel - l) * (1. - l) / (x - l));
}
c
}

pub fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
let d = l - lum(c);
clip_color(c.map(|channel| channel + d))
}

pub fn sat(c: [f32; 3]) -> f32 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}

pub fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
let mut order = [0, 1, 2];
order.sort_by(|&a, &b| c[a].total_cmp(&c[b]));
let [min, mid, max] = order;

let mut result = [0.; 3];
if c[max] > c[min] {
result[mid] = (c[mid] - c[min]) * s / (c[max] - c[min]);
result[max] = s;
}
result
}
}

#[test]
fn component_modes_match_the_specification() {
use specification::{lum, sat, set_lum, set_sat};

let colors = [
[0.8, 0.3, 0.6],
[0.2, 0.7, 0.4],
[1., 0., 0.],
[0.05, 0.1, 0.95],
[0.5, 0.5, 0.5],
[0.95, 0.9, 0.1],
[0., 0., 0.],
[1., 1., 1.],
];

for backdrop in colors {
for source in colors {
let expectations = [
(BlendMode::Hue, set_lum(set_sat(source, sat(backdrop)), lum(backdrop))),
(BlendMode::Saturation, set_lum(set_sat(backdrop, sat(source)), lum(backdrop))),
(BlendMode::Color, set_lum(source, lum(backdrop))),
(BlendMode::Luminosity, set_lum(backdrop, lum(source))),
];

for (mode, expected) in expectations {
let foreground = Color::from_gamma_srgb_channels(source[0], source[1], source[2], 1.);
let background = Color::from_gamma_srgb_channels(backdrop[0], backdrop[1], backdrop[2], 1.);
let [r, g, b, _] = apply_blend_mode(foreground, background, mode).to_gamma_srgb_channels();

for (ours, expected) in [r, g, b].into_iter().zip(expected) {
assert!((ours - expected).abs() < 1e-5, "{mode} of {source:?} over {backdrop:?} gave {:?}, not {expected:?}", [r, g, b]);
}
}
}
}
}

#[test]
fn color_mode_pulls_an_overshooting_channel_back_without_shifting_its_luma() {
let red = Color::from_gamma_srgb_channels(1., 0., 0., 1.);
let gray = Color::from_gamma_srgb_channels(0.5, 0.5, 0.5, 1.);

// Raising red's 0.3 luma to the gray's 0.5 puts red at 1.2, so every channel is pulled 5/7 of the way back toward 0.5
let [r, g, b, _] = apply_blend_mode(red, gray, BlendMode::Color).to_gamma_srgb_channels();
assert!((r - 1.).abs() < 1e-5 && (g - 2. / 7.).abs() < 1e-5 && (b - 2. / 7.).abs() < 1e-5, "got {r}, {g}, {b}");

// A gray backdrop has only luma to keep, so it takes on the red's
let [r, g, b, _] = apply_blend_mode(red, gray, BlendMode::Luminosity).to_gamma_srgb_channels();
assert!((r - 0.3).abs() < 1e-5 && (g - 0.3).abs() < 1e-5 && (b - 0.3).abs() < 1e-5, "got {r}, {g}, {b}");
}

#[test]
fn component_modes_clip_light_brighter_than_white() {
// An exposure raised above white reaches these modes as a gray, which they take as the white it clips to
let bright_gray = Color::from_rgbaf32_unchecked(4., 4., 4., 1.);
let white = Color::from_gamma_srgb_channels(1., 1., 1., 1.);
let ordinary = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.);

for mode in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] {
for (bright, clipped) in [
((bright_gray, ordinary), (white, ordinary)),
((ordinary, bright_gray), (ordinary, white)),
((bright_gray, bright_gray), (white, white)),
] {
let blended = apply_blend_mode(bright.0, bright.1, mode);
let expected = apply_blend_mode(clipped.0, clipped.1, mode);

let close = |ours: f32, theirs: f32| (ours - theirs).abs() < 1e-5;
assert!(
close(blended.r(), expected.r()) && close(blended.g(), expected.g()) && close(blended.b(), expected.b()),
"{mode} gave {blended:?}, not {expected:?}"
);
}
}
}
}
80 changes: 33 additions & 47 deletions node-graph/libraries/no-std-types/src/color/color_types.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::color_traits::{Alpha, AlphaMut, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB};
use super::component_blend::{channel_range, luma_rec_601_rounded, set_luminosity, set_saturation};
use super::discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float};
use bytemuck::{Pod, Zeroable};
use core::fmt::Debug;
Expand Down Expand Up @@ -382,7 +383,7 @@ impl Luminance for Color {
type LuminanceChannel = f32;
#[inline(always)]
fn luminance(&self) -> f32 {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
self.luminance_rec_709()
}
}

Expand Down Expand Up @@ -546,20 +547,6 @@ impl Color {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
}

/// Luma using Rec.601 SDTV coefficients.
#[inline(always)]
pub fn luminance_rec_601(&self) -> f32 {
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
}

/// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing.
#[inline(always)]
pub fn luminance_rec_601_rounded(&self) -> f32 {
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
}

/// Perceptual lightness (OkLab L) of the linear-light RGB, 0..1.
#[inline(always)]
pub fn lightness_oklab(&self) -> f32 {
Expand All @@ -583,13 +570,6 @@ impl Color {
}
}

/// Shift all RGB channels by the offset that moves Rec.601-rounded luma to `luminance`, clamping channels to 0..1. Approximate; channels above 1 are lost.
#[inline(always)]
pub fn with_luminance(&self, luminance: f32) -> Color {
let delta = luminance - self.luminance_rec_601_rounded();
self.map_rgb(|c| (c + delta).clamp(0., 1.))
}

/// The RGB chroma range, `max - min` across the three channels. Not the HSL/HSV saturation (use [`Self::to_hsla`] or [`Self::to_hsva`] for those).
#[inline(always)]
pub fn chroma_range(&self) -> f32 {
Expand All @@ -599,13 +579,6 @@ impl Color {
max - min
}

/// Replace HSL saturation with the given value, preserving hue, lightness, and alpha.
#[inline(always)]
pub fn with_saturation(&self, saturation: f32) -> Color {
let [hue, _, lightness, alpha] = self.to_hsla();
Color::from_hsla(hue, saturation, lightness, alpha)
}

/// Replace the alpha channel, leaving RGB unchanged.
pub fn with_alpha(&self, alpha: f32) -> Color {
Color {
Expand Down Expand Up @@ -798,34 +771,47 @@ impl Color {
if c_b == 0. { 1. } else { c_b / c_s }
}

/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma, with `c_s`'s alpha.
pub fn blend_hue(&self, c_s: Color) -> Color {
let sat_b = self.chroma_range();
let lum_b = self.luminance_rec_601();
/// Runs `blend` on this color's and `c_s`'s gamma-encoded channels, which the component blend modes are defined on, keeping `c_s`'s alpha.
#[inline(always)]
fn blend_gamma_rgb<F: Fn([f32; 3], [f32; 3]) -> [f32; 3]>(&self, c_s: Color, blend: F) -> Color {
// Light brighter than white is clipped, since the constructions are defined only across 0..1
let in_range = |color: &Color| {
let [red, green, blue, _] = color.to_gamma_srgb_channels();
[red.clamp(0., 1.), green.clamp(0., 1.), blue.clamp(0., 1.)]
};
let [r, g, b] = blend(in_range(self), in_range(&c_s));

c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha)
Color::from_gamma_srgb_channels(r, g, b, c_s.alpha)
}

/// Whole-color "Saturation" blend: this color's hue/luma with source saturation, with `c_s`'s alpha.
pub fn blend_saturation(&self, c_s: Color) -> Color {
let sat_s = c_s.chroma_range();
let lum_b = self.luminance_rec_601();
/// Whole-color "Hue" blend: source hue with this color's saturation and luma, with `c_s`'s alpha.
pub fn blend_hue(&self, c_s: Color) -> Color {
self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| {
let [r, g, b] = set_saturation(r_s, g_s, b_s, channel_range(r_b, g_b, b_b));
set_luminosity(r, g, b, luma_rec_601_rounded(r, g, b), luma_rec_601_rounded(r_b, g_b, b_b))
})
}

self.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha)
/// Whole-color "Saturation" blend: this color's hue and luma with source saturation, with `c_s`'s alpha.
pub fn blend_saturation(&self, c_s: Color) -> Color {
self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| {
let [r, g, b] = set_saturation(r_b, g_b, b_b, channel_range(r_s, g_s, b_s));
set_luminosity(r, g, b, luma_rec_601_rounded(r, g, b), luma_rec_601_rounded(r_b, g_b, b_b))
})
}

/// Whole-color "Color" blend: source hue/saturation with this color's luma, with `c_s`'s alpha.
/// Whole-color "Color" blend: source hue and saturation with this color's luma, with `c_s`'s alpha.
pub fn blend_color(&self, c_s: Color) -> Color {
let lum_b = self.luminance_rec_601();

c_s.with_luminance(lum_b).with_alpha(c_s.alpha)
self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| {
set_luminosity(r_s, g_s, b_s, luma_rec_601_rounded(r_s, g_s, b_s), luma_rec_601_rounded(r_b, g_b, b_b))
})
}

/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma, with `c_s`'s alpha.
/// Whole-color "Luminosity" blend: this color's hue and saturation with source luma, with `c_s`'s alpha.
pub fn blend_luminosity(&self, c_s: Color) -> Color {
let lum_s = c_s.luminance_rec_601();

self.with_luminance(lum_s).with_alpha(c_s.alpha)
self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| {
set_luminosity(r_b, g_b, b_b, luma_rec_601_rounded(r_b, g_b, b_b), luma_rec_601_rounded(r_s, g_s, b_s))
})
}

/// All four channels as `(red, green, blue, alpha)`.
Expand Down
94 changes: 94 additions & 0 deletions node-graph/libraries/no-std-types/src/color/component_blend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! The luma and saturation constructions that the component blend modes (hue, saturation, color, and luminosity)
//! and the adjustments derived from them are built on. All of them work on gamma-encoded channels across 0..1.
//!
//! <https://www.w3.org/TR/compositing-1/#blendingnonseparable>

/// The Rec. 601 luma with its weights rounded to two decimal places, as the specification defines it for these blend modes.
pub fn luma_rec_601_rounded(r: f32, g: f32, b: f32) -> f32 {
0.3 * r + 0.59 * g + 0.11 * b
}

/// The spread between the largest and smallest channels, which is what the component blend modes call saturation.
pub fn channel_range(r: f32, g: f32, b: f32) -> f32 {
r.max(g).max(b) - r.min(g).min(b)
}

fn pull_toward_luminosity(channels: [f32; 3], luminosity: f32, scale: f32) -> [f32; 3] {
[
luminosity + (channels[0] - luminosity) * scale,
luminosity + (channels[1] - luminosity) * scale,
luminosity + (channels[2] - luminosity) * scale,
]
}

/// The Luminosity blend mode's construction: shifts gamma-encoded channels from `luma` to `luminosity`,
/// then pulls them toward it just enough to bring every channel back into 0..1.
pub fn set_luminosity(r: f32, g: f32, b: f32, luma: f32, luminosity: f32) -> [f32; 3] {
let shift = luminosity - luma;
let mut channels = [r + shift, g + shift, b + shift];

let low = channels[0].min(channels[1]).min(channels[2]);
if low < 0. {
channels = pull_toward_luminosity(channels, luminosity, luminosity / (luminosity - low));
}
let high = channels[0].max(channels[1]).max(channels[2]);
if high > 1. {
channels = pull_toward_luminosity(channels, luminosity, (1. - luminosity) / (high - luminosity));
}

[channels[0].clamp(0., 1.), channels[1].clamp(0., 1.), channels[2].clamp(0., 1.)]
}

/// The Saturation blend mode's construction: scales gamma-encoded channels about their smallest, which becomes 0, so the largest becomes `saturation`. A gray becomes black.
pub fn set_saturation(r: f32, g: f32, b: f32, saturation: f32) -> [f32; 3] {
let low = r.min(g).min(b);
let range = channel_range(r, g, b);
if range <= 0. {
return [0.; 3];
}

let scale = saturation / range;
[(r - low) * scale, (g - low) * scale, (b - low) * scale]
}

#[cfg(test)]
mod tests {
use super::*;

// The specification's own formulation, which sorts the channels and scales only the middle one
fn specification_set_saturation(channels: [f32; 3], saturation: f32) -> [f32; 3] {
let mut order = [0, 1, 2];
order.sort_by(|&a, &b| channels[a].total_cmp(&channels[b]));
let [low, middle, high] = order;

let mut result = [0.; 3];
if channels[high] > channels[low] {
result[middle] = (channels[middle] - channels[low]) * saturation / (channels[high] - channels[low]);
result[high] = saturation;
}
result
}

#[test]
fn set_saturation_matches_the_specification() {
for channels in [[0.2, 0.7, 0.4], [0.9, 0.1, 0.5], [0.3, 0.3, 0.8], [1., 0., 0.], [0.6, 0.6, 0.6]] {
for saturation in [0., 0.35, 1.] {
let ours = set_saturation(channels[0], channels[1], channels[2], saturation);
let expected = specification_set_saturation(channels, saturation);

for channel in 0..3 {
assert!((ours[channel] - expected[channel]).abs() < 1e-6, "{channels:?} at {saturation} gave {ours:?}, not {expected:?}");
}
}
}
}

#[test]
fn set_luminosity_lands_on_the_luminosity_and_stays_in_range() {
// Pure red raised to a mid luminosity overshoots in red, so it is pulled back toward the luminosity
let [r, g, b] = set_luminosity(1., 0., 0., luma_rec_601_rounded(1., 0., 0.), 0.5);

assert!((luma_rec_601_rounded(r, g, b) - 0.5).abs() < 1e-5);
assert!((r - 1.).abs() < 1e-5 && (g - b).abs() < 1e-6 && g > 0. && g < 0.5, "got {r}, {g}, {b}");
}
}
2 changes: 2 additions & 0 deletions node-graph/libraries/no-std-types/src/color/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
mod color_traits;
mod color_types;
mod component_blend;
mod discrete_srgb;
mod transfer;

pub use color_traits::*;
pub use color_types::*;
pub use component_blend::*;
pub use discrete_srgb::*;
pub use transfer::*;
Loading
Loading