photocraft/crates/color/src/blend.rs

469 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Photoshop blend modes (scalar reference implementation).
//!
//! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions,
//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color,
//! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference
//! that the GPU kernels must match.
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlendMode {
/// Groups only: children blend directly into the group's backdrop.
PassThrough,
Normal,
Dissolve,
Darken,
Multiply,
ColorBurn,
LinearBurn,
DarkerColor,
Lighten,
Screen,
ColorDodge,
LinearDodge,
LighterColor,
Overlay,
SoftLight,
HardLight,
VividLight,
LinearLight,
PinLight,
HardMix,
Difference,
Exclusion,
Subtract,
Divide,
Hue,
Saturation,
Color,
Luminosity,
}
impl BlendMode {
/// All modes except PassThrough, in Photoshop menu order.
pub const LAYER_MODES: [BlendMode; 27] = [
BlendMode::Normal,
BlendMode::Dissolve,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::LinearBurn,
BlendMode::DarkerColor,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::LinearDodge,
BlendMode::LighterColor,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::VividLight,
BlendMode::LinearLight,
BlendMode::PinLight,
BlendMode::HardMix,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Subtract,
BlendMode::Divide,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
];
pub fn label(self) -> &'static str {
match self {
BlendMode::PassThrough => "Pass Through",
BlendMode::Normal => "Normal",
BlendMode::Dissolve => "Dissolve",
BlendMode::Darken => "Darken",
BlendMode::Multiply => "Multiply",
BlendMode::ColorBurn => "Color Burn",
BlendMode::LinearBurn => "Linear Burn",
BlendMode::DarkerColor => "Darker Color",
BlendMode::Lighten => "Lighten",
BlendMode::Screen => "Screen",
BlendMode::ColorDodge => "Color Dodge",
BlendMode::LinearDodge => "Linear Dodge (Add)",
BlendMode::LighterColor => "Lighter Color",
BlendMode::Overlay => "Overlay",
BlendMode::SoftLight => "Soft Light",
BlendMode::HardLight => "Hard Light",
BlendMode::VividLight => "Vivid Light",
BlendMode::LinearLight => "Linear Light",
BlendMode::PinLight => "Pin Light",
BlendMode::HardMix => "Hard Mix",
BlendMode::Difference => "Difference",
BlendMode::Exclusion => "Exclusion",
BlendMode::Subtract => "Subtract",
BlendMode::Divide => "Divide",
BlendMode::Hue => "Hue",
BlendMode::Saturation => "Saturation",
BlendMode::Color => "Color",
BlendMode::Luminosity => "Luminosity",
}
}
/// PSD 4-character blend key.
pub fn psd_key(self) -> [u8; 4] {
*match self {
BlendMode::PassThrough => b"pass",
BlendMode::Normal => b"norm",
BlendMode::Dissolve => b"diss",
BlendMode::Darken => b"dark",
BlendMode::Multiply => b"mul ",
BlendMode::ColorBurn => b"idiv",
BlendMode::LinearBurn => b"lbrn",
BlendMode::DarkerColor => b"dkCl",
BlendMode::Lighten => b"lite",
BlendMode::Screen => b"scrn",
BlendMode::ColorDodge => b"div ",
BlendMode::LinearDodge => b"lddg",
BlendMode::LighterColor => b"lgCl",
BlendMode::Overlay => b"over",
BlendMode::SoftLight => b"sLit",
BlendMode::HardLight => b"hLit",
BlendMode::VividLight => b"vLit",
BlendMode::LinearLight => b"lLit",
BlendMode::PinLight => b"pLit",
BlendMode::HardMix => b"hMix",
BlendMode::Difference => b"diff",
BlendMode::Exclusion => b"smud",
BlendMode::Subtract => b"fsub",
BlendMode::Divide => b"fdiv",
BlendMode::Hue => b"hue ",
BlendMode::Saturation => b"sat ",
BlendMode::Color => b"colr",
BlendMode::Luminosity => b"lum ",
}
}
pub fn from_psd_key(key: [u8; 4]) -> Option<BlendMode> {
std::iter::once(BlendMode::PassThrough).chain(Self::LAYER_MODES).find(|m| m.psd_key() == key)
}
pub fn is_separable(self) -> bool {
!matches!(self, BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor)
}
}
/// Separable per-channel blend function `B(cb, cs)`.
#[inline]
pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
match mode {
BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough => cs,
BlendMode::Darken => cb.min(cs),
BlendMode::Multiply => cb * cs,
BlendMode::ColorBurn => color_burn(cb, cs),
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
BlendMode::Lighten => cb.max(cs),
BlendMode::Screen => cb + cs - cb * cs,
BlendMode::ColorDodge => color_dodge(cb, cs),
BlendMode::LinearDodge => (cb + cs).min(1.0),
BlendMode::Overlay => hard_light(cs, cb),
BlendMode::SoftLight => soft_light_ps(cb, cs),
BlendMode::HardLight => hard_light(cb, cs),
BlendMode::VividLight => {
if cs <= 0.5 {
color_burn(cb, 2.0 * cs)
} else {
color_dodge(cb, 2.0 * (cs - 0.5))
}
}
BlendMode::LinearLight => (cb + 2.0 * cs - 1.0).clamp(0.0, 1.0),
BlendMode::PinLight => {
if cs <= 0.5 {
cb.min(2.0 * cs)
} else {
cb.max(2.0 * cs - 1.0)
}
}
BlendMode::HardMix => {
if cb + cs >= 1.0 - 1e-6 {
1.0
} else {
0.0
}
}
BlendMode::Difference => (cb - cs).abs(),
BlendMode::Exclusion => cb + cs - 2.0 * cb * cs,
BlendMode::Subtract => (cb - cs).max(0.0),
BlendMode::Divide => {
if cs <= 0.0 {
if cb <= 0.0 { 0.0 } else { 1.0 }
} else {
(cb / cs).min(1.0)
}
}
// Non-separable modes are handled by `blend_rgb`; fall back to source.
BlendMode::DarkerColor | BlendMode::LighterColor | BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity => cs,
}
}
#[inline]
fn color_burn(cb: f32, cs: f32) -> f32 {
if cb >= 1.0 {
1.0
} else if cs <= 0.0 {
0.0
} else {
1.0 - ((1.0 - cb) / cs).min(1.0)
}
}
#[inline]
fn color_dodge(cb: f32, cs: f32) -> f32 {
if cb <= 0.0 {
0.0
} else if cs >= 1.0 {
1.0
} else {
(cb / (1.0 - cs)).min(1.0)
}
}
#[inline]
fn hard_light(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 {
cb * 2.0 * cs
} else {
let s = 2.0 * cs - 1.0;
cb + s - cb * s
}
}
/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half).
#[inline]
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) }
}
// ---- non-separable helpers (W3C Compositing Level 1) ----
#[inline]
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 out = c;
if n < 0.0 {
let d = l - n;
for v in &mut out {
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d };
}
}
if x > 1.0 {
let d = x - l;
for v in &mut out {
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d };
}
}
out
}
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
let d = l - lum(c);
clip_color([c[0] + d, c[1] + d, c[2] + d])
}
fn sat(c: [f32; 3]) -> f32 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
let mut idx = [0usize, 1, 2];
idx.sort_by(|&a, &b| c[a].partial_cmp(&c[b]).unwrap_or(std::cmp::Ordering::Equal));
let (imin, imid, imax) = (idx[0], idx[1], idx[2]);
let mut out = [0.0f32; 3];
if c[imax] > c[imin] {
out[imid] = (c[imid] - c[imin]) * s / (c[imax] - c[imin]);
out[imax] = s;
}
out[imin] = 0.0;
out
}
/// Blend a whole RGB triple: `B(Cb, Cs)` including non-separable modes.
pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
match mode {
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
BlendMode::Color => set_lum(cs, lum(cb)),
BlendMode::Luminosity => set_lum(cb, lum(cs)),
BlendMode::DarkerColor => {
if lum(cs) < lum(cb) {
cs
} else {
cb
}
}
BlendMode::LighterColor => {
if lum(cs) > lum(cb) {
cs
} else {
cb
}
}
m => [blend_channel(m, cb[0], cs[0]), blend_channel(m, cb[1], cs[1]), blend_channel(m, cb[2], cs[2])],
}
}
/// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity.
///
/// Uses the W3C/PDF general formula:
/// `αo = αs + αb(1−αs)`,
/// `αo·Co = (1−αs)·αb·Cb + (1−αb)·αs·Cs + αs·αb·B(Cb,Cs)`.
/// Dissolve must be resolved by the caller (per-pixel coverage decision) before calling.
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
let ab = backdrop[3];
let as_ = source[3] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let cb = [backdrop[0], backdrop[1], backdrop[2]];
let cs = [source[0], source[1], source[2]];
let b = blend_rgb(mode, cb, cs);
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 4];
}
let mut out = [0.0f32; 4];
for i in 0..3 {
let premul = (1.0 - as_) * ab * cb[i] + (1.0 - ab) * as_ * cs[i] + as_ * ab * b[i];
out[i] = premul / ao;
}
out[3] = ao;
out
}
#[cfg(test)]
mod tests {
use super::*;
const E: f32 = 1e-5;
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < E
}
#[test]
fn psd_keys_roundtrip_and_unique() {
let mut seen = std::collections::HashSet::new();
for m in std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES) {
assert!(seen.insert(m.psd_key()), "duplicate key {m:?}");
assert_eq!(BlendMode::from_psd_key(m.psd_key()), Some(m));
}
assert_eq!(BlendMode::from_psd_key(*b"zzzz"), None);
assert_eq!(seen.len(), 28);
}
#[test]
fn normal_is_source() {
for &(cb, cs) in &[(0.2, 0.7), (1.0, 0.0), (0.0, 1.0)] {
assert!(close(blend_channel(BlendMode::Normal, cb, cs), cs));
}
}
#[test]
fn identities_with_white_and_black() {
// Multiply by white / screen with black / add black / darken with white leave backdrop unchanged.
for cb in [0.0f32, 0.1, 0.5, 0.9, 1.0] {
assert!(close(blend_channel(BlendMode::Multiply, cb, 1.0), cb));
assert!(close(blend_channel(BlendMode::Screen, cb, 0.0), cb));
assert!(close(blend_channel(BlendMode::LinearDodge, cb, 0.0), cb));
assert!(close(blend_channel(BlendMode::Darken, cb, 1.0), cb));
assert!(close(blend_channel(BlendMode::Lighten, cb, 0.0), cb));
assert!(close(blend_channel(BlendMode::Difference, cb, 0.0), cb));
assert!(close(blend_channel(BlendMode::Subtract, cb, 0.0), cb));
assert!(close(blend_channel(BlendMode::LinearBurn, cb, 1.0), cb));
assert!(close(blend_channel(BlendMode::Exclusion, cb, 0.0), cb));
// 50% gray is neutral for the "light" family
assert!(close(blend_channel(BlendMode::HardLight, cb, 0.5), cb));
assert!(close(blend_channel(BlendMode::SoftLight, cb, 0.5), cb));
assert!(close(blend_channel(BlendMode::LinearLight, cb, 0.5), cb));
}
}
#[test]
fn known_values() {
assert!(close(blend_channel(BlendMode::Multiply, 0.5, 0.5), 0.25));
assert!(close(blend_channel(BlendMode::Screen, 0.5, 0.5), 0.75));
assert!(close(blend_channel(BlendMode::Overlay, 0.25, 0.5), 0.25));
assert!(close(blend_channel(BlendMode::Overlay, 0.75, 1.0), 1.0));
assert!(close(blend_channel(BlendMode::ColorDodge, 0.5, 0.5), 1.0));
assert!(close(blend_channel(BlendMode::ColorBurn, 0.5, 0.5), 0.0));
assert!(close(blend_channel(BlendMode::ColorBurn, 1.0, 0.0), 1.0));
assert!(close(blend_channel(BlendMode::ColorDodge, 0.0, 1.0), 0.0));
assert!(close(blend_channel(BlendMode::Divide, 0.25, 0.5), 0.5));
assert!(close(blend_channel(BlendMode::Divide, 0.0, 0.0), 0.0));
assert!(close(blend_channel(BlendMode::Divide, 0.3, 0.0), 1.0));
assert!(close(blend_channel(BlendMode::Exclusion, 0.5, 0.5), 0.5));
assert!(close(blend_channel(BlendMode::PinLight, 0.8, 0.2), 0.4));
assert!(close(blend_channel(BlendMode::PinLight, 0.1, 0.9), 0.8));
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.4), 1.0));
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0));
}
#[test]
fn separable_outputs_stay_in_unit_range() {
let steps: Vec<f32> = (0..=20).map(|i| i as f32 / 20.0).collect();
for m in BlendMode::LAYER_MODES {
for &cb in &steps {
for &cs in &steps {
let v = blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]);
for c in v {
assert!((-E..=1.0 + E).contains(&c), "{m:?} cb={cb} cs={cs} -> {c}");
}
}
}
}
}
#[test]
fn luminosity_and_color_swap_components() {
let cb = [0.8, 0.2, 0.2];
let cs = [0.1, 0.1, 0.9];
let l = blend_rgb(BlendMode::Luminosity, cb, cs);
assert!(close(lum(l), lum(cs)));
let c = blend_rgb(BlendMode::Color, cb, cs);
assert!(close(lum(c), lum(cb)));
let h = blend_rgb(BlendMode::Hue, cb, cs);
assert!(close(lum(h), lum(cb)));
let s = blend_rgb(BlendMode::Saturation, cb, [0.5, 0.5, 0.5]);
// zero-saturation source desaturates the backdrop
assert!(close(s[0], s[1]) && close(s[1], s[2]));
}
#[test]
fn darker_lighter_color_pick_whole_pixel() {
let a = [0.9, 0.1, 0.1];
let b = [0.2, 0.6, 0.2];
assert_eq!(blend_rgb(BlendMode::DarkerColor, a, b), if lum(b) < lum(a) { b } else { a });
assert_eq!(blend_rgb(BlendMode::LighterColor, a, b), if lum(b) > lum(a) { b } else { a });
}
#[test]
fn composite_alpha_rules() {
let bg = [0.2, 0.4, 0.6, 1.0];
// Transparent source leaves backdrop.
assert_eq!(composite(BlendMode::Multiply, bg, [1.0, 0.0, 0.0, 0.0], 1.0), bg);
// Opaque normal source replaces.
let r = composite(BlendMode::Normal, bg, [1.0, 0.0, 0.0, 1.0], 1.0);
assert!(close(r[0], 1.0) && close(r[3], 1.0));
// Half opacity normal = lerp.
let r = composite(BlendMode::Normal, bg, [1.0, 1.0, 1.0, 1.0], 0.5);
assert!(close(r[0], 0.6) && close(r[1], 0.7) && close(r[2], 0.8));
// Blend over transparent backdrop yields source regardless of mode.
let r = composite(BlendMode::Multiply, [0.0; 4], [0.3, 0.5, 0.7, 1.0], 1.0);
assert!(close(r[0], 0.3) && close(r[1], 0.5) && close(r[2], 0.7) && close(r[3], 1.0));
// Alpha accumulates.
let r = composite(BlendMode::Screen, [0.0, 0.0, 0.0, 0.5], [0.0, 0.0, 0.0, 0.5], 1.0);
assert!(close(r[3], 0.75));
}
}