First commit. Version 0.1.0
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! CPU-side gradient sampling and LUT baking.
|
||||
//!
|
||||
//! The GPU gradient path in `gles_render` shaders samples a 1D lookup
|
||||
//! texture baked on the CPU: for each gradient we precompute N equally
|
||||
//! spaced samples across an extended `t` domain (so stops outside
|
||||
//! `[0, 1]` are covered without extra shader logic), already colour-space
|
||||
//! converted, and upload those N × 4 bytes as an RGBA8 texture.
|
||||
//!
|
||||
//! # Extrapolation
|
||||
//!
|
||||
//! Stops whose `position` falls outside `[0, 1]` are supported by
|
||||
//! **linear extrapolation**: below the first stop we prolong the
|
||||
//! `(first, second)` slope, above the last stop we prolong the
|
||||
//! `(last-1, last)` slope. Values are not clamped. This is the
|
||||
//! physically correct behaviour for CSS `linear-gradient` stops defined
|
||||
//! with positions outside the visible range.
|
||||
//!
|
||||
//! # Colour spaces
|
||||
//!
|
||||
//! [`GradientSpace::Srgb`] interpolates raw sRGB channels (cheap, looks
|
||||
//! muddy on saturated gradients). [`GradientSpace::LinearRgb`] — the
|
||||
//! default — converts each stop to linear light, interpolates, and
|
||||
//! converts the result back to sRGB. [`GradientSpace::Oklab`] is not yet
|
||||
//! implemented and silently falls back to `LinearRgb`.
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::paint::{ ColorStop, GradientSpace };
|
||||
|
||||
/// How many samples the LUT stores along the `t` axis.
|
||||
pub const LUT_SAMPLES: usize = 512;
|
||||
|
||||
/// Extended `t` domain the LUT covers. Wide enough to comfortably contain
|
||||
/// the typical out-of-`[0, 1]` stops produced by design-tool exports.
|
||||
pub const LUT_DOMAIN: ( f32, f32 ) = ( -2.0, 3.0 );
|
||||
|
||||
// ─── sRGB ↔ linear ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert one sRGB gamma-encoded channel to linear light.
|
||||
#[ inline ]
|
||||
pub fn srgb_to_linear( x: f32 ) -> f32
|
||||
{
|
||||
if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf( 2.4 ) }
|
||||
}
|
||||
|
||||
/// Convert one linear-light channel to sRGB gamma-encoded.
|
||||
#[ inline ]
|
||||
pub fn linear_to_srgb( x: f32 ) -> f32
|
||||
{
|
||||
if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf( 1.0 / 2.4 ) - 0.055 }
|
||||
}
|
||||
|
||||
// ─── Sampling ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sample the stops at position `t` using the requested interpolation
|
||||
/// space. Stops need not be sorted. `t` may fall outside `[0, 1]`.
|
||||
pub fn sample_stops( stops: &[ColorStop], t: f32, space: GradientSpace ) -> Color
|
||||
{
|
||||
if stops.is_empty() { return Color::TRANSPARENT; }
|
||||
if stops.len() == 1 { return stops[0].color; }
|
||||
|
||||
// Sort by position. We do this each call because gradients hold at
|
||||
// most a handful of stops and the LUT builder only calls us N times
|
||||
// per gradient build (not per pixel).
|
||||
let mut sorted: Vec<&ColorStop> = stops.iter().collect();
|
||||
sorted.sort_by( |a, b|
|
||||
a.position.partial_cmp( &b.position ).unwrap_or( std::cmp::Ordering::Equal )
|
||||
);
|
||||
|
||||
// Pick the bracketing pair. Below the first stop we extrapolate from
|
||||
// `(first, second)`; above the last, from `(last-1, last)`.
|
||||
let n = sorted.len();
|
||||
let ( a, b ) = if t <= sorted[0].position
|
||||
{
|
||||
( sorted[0], sorted[1] )
|
||||
}
|
||||
else if t >= sorted[n - 1].position
|
||||
{
|
||||
( sorted[n - 2], sorted[n - 1] )
|
||||
}
|
||||
else
|
||||
{
|
||||
let mut pair = ( sorted[0], sorted[1] );
|
||||
for win in sorted.windows( 2 )
|
||||
{
|
||||
if t >= win[0].position && t <= win[1].position
|
||||
{
|
||||
pair = ( win[0], win[1] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
pair
|
||||
};
|
||||
|
||||
let dt = b.position - a.position;
|
||||
let u = if dt.abs() < 1e-6 { 0.0 } else { ( t - a.position ) / dt };
|
||||
|
||||
mix_colors( a.color, b.color, u, space )
|
||||
}
|
||||
|
||||
/// Linear mix of two colours at parameter `u` (not clamped — the caller
|
||||
/// has already chosen the right bracketing pair).
|
||||
fn mix_colors( a: Color, b: Color, u: f32, space: GradientSpace ) -> Color
|
||||
{
|
||||
let alpha = a.a + ( b.a - a.a ) * u;
|
||||
match space
|
||||
{
|
||||
GradientSpace::Srgb => Color
|
||||
{
|
||||
r: a.r + ( b.r - a.r ) * u,
|
||||
g: a.g + ( b.g - a.g ) * u,
|
||||
b: a.b + ( b.b - a.b ) * u,
|
||||
a: alpha,
|
||||
},
|
||||
GradientSpace::LinearRgb => mix_linear( a, b, u, alpha ),
|
||||
// TODO: proper Oklab mix. For now fall back to linear-light.
|
||||
GradientSpace::Oklab => mix_linear( a, b, u, alpha ),
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_linear( a: Color, b: Color, u: f32, alpha: f32 ) -> Color
|
||||
{
|
||||
let ar = srgb_to_linear( a.r );
|
||||
let ag = srgb_to_linear( a.g );
|
||||
let ab = srgb_to_linear( a.b );
|
||||
let br = srgb_to_linear( b.r );
|
||||
let bg = srgb_to_linear( b.g );
|
||||
let bb = srgb_to_linear( b.b );
|
||||
|
||||
let r = linear_to_srgb( ar + ( br - ar ) * u );
|
||||
let g = linear_to_srgb( ag + ( bg - ag ) * u );
|
||||
let b = linear_to_srgb( ab + ( bb - ab ) * u );
|
||||
Color { r, g, b, a: alpha }
|
||||
}
|
||||
|
||||
// ─── LUT baking ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build an RGBA8 LUT of `LUT_SAMPLES` equally spaced samples spanning
|
||||
/// [`LUT_DOMAIN`]. The returned vector has `LUT_SAMPLES * 4` bytes in
|
||||
/// straight-alpha, row-major. The GPU shader expects this layout and
|
||||
/// premultiplies at sample time.
|
||||
pub fn build_lut_bytes( stops: &[ColorStop], space: GradientSpace ) -> Vec<u8>
|
||||
{
|
||||
let ( t0, t1 ) = LUT_DOMAIN;
|
||||
let n = LUT_SAMPLES;
|
||||
let mut out = Vec::with_capacity( n * 4 );
|
||||
for i in 0..n
|
||||
{
|
||||
let t = t0 + ( t1 - t0 ) * ( i as f32 / ( n - 1 ) as f32 );
|
||||
let c = sample_stops( stops, t, space );
|
||||
out.push( ( c.r.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
|
||||
out.push( ( c.g.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
|
||||
out.push( ( c.b.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
|
||||
out.push( ( c.a.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
fn approx( a: f32, b: f32 ) -> bool { ( a - b ).abs() < 2e-2 }
|
||||
|
||||
#[ test ]
|
||||
fn srgb_linear_roundtrip_is_stable()
|
||||
{
|
||||
for &v in &[ 0.0, 0.04, 0.1, 0.25, 0.5, 0.75, 1.0 ]
|
||||
{
|
||||
let back = linear_to_srgb( srgb_to_linear( v ) );
|
||||
assert!( ( v - back ).abs() < 1e-5, "roundtrip {} -> {}", v, back );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn sample_at_exact_stop_returns_that_stop()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::WHITE },
|
||||
ColorStop { position: 1.0, color: Color::BLACK },
|
||||
];
|
||||
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
|
||||
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
|
||||
assert_eq!( a, Color::WHITE );
|
||||
assert_eq!( b, Color::BLACK );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn sample_midpoint_srgb_is_halfway()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
|
||||
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
|
||||
];
|
||||
let m = sample_stops( &stops, 0.5, GradientSpace::Srgb );
|
||||
assert!( approx( m.r, 0.5 ) && approx( m.g, 0.5 ) && approx( m.b, 0.5 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn sample_midpoint_linear_rgb_is_brighter_than_srgb()
|
||||
{
|
||||
// Classic demonstration that linear-light midpoint is brighter
|
||||
// than the naive sRGB midpoint when interpolating 0 → 1.
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
|
||||
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
|
||||
];
|
||||
let m_srgb = sample_stops( &stops, 0.5, GradientSpace::Srgb );
|
||||
let m_linear = sample_stops( &stops, 0.5, GradientSpace::LinearRgb );
|
||||
assert!( m_linear.r > m_srgb.r, "linear {:?} should be brighter than srgb {:?}", m_linear, m_srgb );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn extrapolation_below_first_stop_continues_slope()
|
||||
{
|
||||
// stops at 0.0 (white) and 1.0 (black). Extrapolating to -1.0
|
||||
// along the same slope lands above 1.0 — we do NOT clamp; the
|
||||
// LUT baker will clip when converting to u8.
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::WHITE },
|
||||
ColorStop { position: 1.0, color: Color::BLACK },
|
||||
];
|
||||
let c = sample_stops( &stops, -1.0, GradientSpace::Srgb );
|
||||
// Slope is (-1, -1, -1) per unit of t; at t=-1 we get rgb=(2,2,2).
|
||||
assert!( c.r > 1.0, "extrapolation should exceed 1.0 before clamp: {}", c.r );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn extrapolation_above_last_stop_continues_slope()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::rgb( 0.2, 0.2, 0.2 ) },
|
||||
ColorStop { position: 1.0, color: Color::rgb( 0.8, 0.8, 0.8 ) },
|
||||
];
|
||||
let c = sample_stops( &stops, 2.0, GradientSpace::Srgb );
|
||||
// Slope (0.6, 0.6, 0.6) per unit. At t=2.0, rgb=(1.4, …) — exceeds 1.0.
|
||||
assert!( c.r > 1.0, "extrapolation should exceed 1.0: {}", c.r );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn alpha_mixes_linearly_across_spaces()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
|
||||
ColorStop { position: 1.0, color: Color::rgba( 1.0, 0.0, 0.0, 0.0 ) },
|
||||
];
|
||||
for space in [ GradientSpace::Srgb, GradientSpace::LinearRgb, GradientSpace::Oklab ]
|
||||
{
|
||||
let m = sample_stops( &stops, 0.5, space );
|
||||
assert!( approx( m.a, 0.5 ), "alpha should mix linearly in {:?}: got {}", space, m.a );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn unsorted_stops_are_handled()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 1.0, color: Color::BLACK },
|
||||
ColorStop { position: 0.0, color: Color::WHITE },
|
||||
];
|
||||
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
|
||||
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
|
||||
assert_eq!( a, Color::WHITE );
|
||||
assert_eq!( b, Color::BLACK );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn build_lut_has_expected_length()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::WHITE },
|
||||
ColorStop { position: 1.0, color: Color::BLACK },
|
||||
];
|
||||
let bytes = build_lut_bytes( &stops, GradientSpace::LinearRgb );
|
||||
assert_eq!( bytes.len(), LUT_SAMPLES * 4 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn build_lut_clips_extrapolation_to_u8_range()
|
||||
{
|
||||
let stops = vec!
|
||||
[
|
||||
ColorStop { position: 0.0, color: Color::WHITE },
|
||||
ColorStop { position: 1.0, color: Color::BLACK },
|
||||
];
|
||||
let bytes = build_lut_bytes( &stops, GradientSpace::Srgb );
|
||||
// All bytes must be in [0, 255] — no panics from out-of-range casts.
|
||||
for b in &bytes { let _ = *b; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user