Skip to main content

retroglyph_core/color/
tint.rs

1//! Sprite colour modulation: how a sprite's own pixels are recoloured at draw time.
2
3use gem::rgb::Rgb888;
4
5/// How a sprite's own pixels are recoloured at draw time.
6///
7/// A sprite is composited from the artwork's pixels, and a cell's
8/// [`Style::fg`](crate::color::Style::fg) does not touch it (see
9/// [`Surface::put_span`](crate::surface::Surface::put_span)). A tint is the separate channel that does,
10/// so one piece of artwork can serve a biome variant, a damage flash, or a shadowed copy of
11/// itself without a second sprite in the sheet.
12///
13/// Pixel backends only. Cell backends have no sprite to recolour and ignore a tint entirely;
14/// they draw the cell's glyph in its own [`Style`](crate::color::Style), as always.
15///
16/// # Why not reuse `fg`
17///
18/// Tinting a sprite by the cell's foreground colour is what most tileset libraries do, and it
19/// works for them because their foreground colour has exactly one job and defaults to white, the
20/// identity of a multiply.
21///
22/// Neither holds here. [`Color::Default`](crate::color::Color::Default) means "whatever foreground the
23/// terminal is configured for", not white, so it has no sensible reading as a modulation value.
24/// More importantly, a cell drawn as a sprite by a pixel backend is drawn as an `fg`-coloured
25/// *glyph* by a cell backend, and the colour that reads correctly as a solid character is not
26/// the colour that reads correctly multiplied onto artwork that already has colour of its own.
27/// One field cannot serve both.
28///
29/// # Choosing an operation
30///
31/// [`Multiply`](Self::Multiply) is the workhorse and can only darken: every channel scales
32/// toward zero. It preserves the artwork's own shading, which is what makes it right for
33/// variants of one material (grass to savanna, stone to mossy stone) and for lighting.
34///
35/// [`Mix`](Self::Mix) blends toward a colour and can therefore brighten, which multiply cannot
36/// express at all. It is also the only one of the two a caller could not approximate for
37/// themselves, since doing so needs the sprite's pixels. `Mix` at full strength replaces the
38/// artwork's colour outright while keeping its alpha, which is how a white-on-transparent mask
39/// sheet gets recoloured.
40///
41/// Alpha is never touched by either: a tint changes what the sprite's opaque pixels look like,
42/// never which of them are opaque. Compositing and the cell background showing through
43/// transparent pixels behave identically tinted or not.
44///
45/// # Scope: what `Tint` is not for
46///
47/// `Tint` is per-cell and per-draw, not per-sheet or per-frame. "Is this sheet art
48/// or a mask" is a different, fixed-at-load-time question, answered once by
49/// `retroglyph_window::tileset::SheetColor` rather than by this type. The two compose instead of
50/// collapsing into one flag (see `retroglyph_window::sprite_cache::SpriteTint`, which resolves
51/// both in one place), because "is this sheet art or a mask" (fixed when the asset is authored)
52/// and "what colour to flash this cell right now" (fixed per frame) are different questions that
53/// would conflict if merged into a single `modulate(bool)`-style flag: a sheet declared
54/// art-not-mask still needs to be flashable.
55///
56/// Frame- or layer-level colour transforms (day/night cycles, fog of war, a "remembered" map
57/// render) are not a use case for `Tint` either. Those apply to everything already drawn,
58/// every frame, so routing them through per-cell `Tint` would mean writing the same value into a
59/// side-table entry for every cell of every layer, every frame: the wrong lever for a
60/// screen-wide effect. That is tracked as its own, not-yet-designed concern in retroglyph#562;
61/// it is out of scope here.
62///
63/// `Tint` is `#[non_exhaustive]` so more operations (add, screen, replace) can be added later
64/// without breaking either backend: the GL encoder already falls through to "no recolour" on an
65/// operation it does not recognize.
66///
67/// # Examples
68///
69/// ```
70/// use retroglyph_core::color::Tint;
71///
72/// // Grass artwork, dimmed toward its own shadow.
73/// let shadowed = Tint::multiply(128, 128, 128);
74/// assert_eq!(shadowed.apply((200, 180, 60)), (100, 90, 30));
75///
76/// // The same pixels, flashed most of the way to white.
77/// let hit = Tint::mix(255, 255, 255, 192);
78/// assert_eq!(hit.apply((200, 180, 60)), (241, 236, 207));
79///
80/// // The default costs nothing and changes nothing.
81/// assert_eq!(Tint::None.apply((200, 180, 60)), (200, 180, 60));
82/// ```
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
84#[non_exhaustive]
85pub enum Tint {
86    /// Composite the sprite's pixels verbatim.
87    #[default]
88    None,
89    /// Scale each channel by `rgb / 255`, darkening toward black.
90    ///
91    /// `(255, 255, 255)` is the identity and behaves as [`None`](Self::None), just less cheaply.
92    Multiply {
93        /// Red scale factor.
94        r: u8,
95        /// Green scale factor.
96        g: u8,
97        /// Blue scale factor.
98        b: u8,
99    },
100    /// Blend each channel `amount / 255` of the way toward `rgb`.
101    ///
102    /// `amount` of 0 is the identity; 255 replaces the sprite's colour outright, keeping its
103    /// alpha.
104    Mix {
105        /// Red channel of the colour blended toward.
106        r: u8,
107        /// Green channel of the colour blended toward.
108        g: u8,
109        /// Blue channel of the colour blended toward.
110        b: u8,
111        /// How far to blend, from 0 (unchanged) to 255 (fully replaced).
112        amount: u8,
113    },
114}
115
116impl Tint {
117    /// A [`Multiply`](Self::Multiply) tint scaling each channel by `rgb / 255`.
118    #[must_use]
119    pub const fn multiply(r: u8, g: u8, b: u8) -> Self {
120        Self::Multiply { r, g, b }
121    }
122
123    /// A [`Mix`](Self::Mix) tint blending `amount / 255` of the way toward `rgb`.
124    #[must_use]
125    pub const fn mix(r: u8, g: u8, b: u8, amount: u8) -> Self {
126        Self::Mix { r, g, b, amount }
127    }
128
129    /// Whether this tint leaves every pixel exactly as authored.
130    ///
131    /// True for [`None`](Self::None) and for the identity of either operation, so a renderer can
132    /// take its untinted fast path for a tint that would do nothing.
133    #[must_use]
134    pub const fn is_identity(self) -> bool {
135        match self {
136            Self::None => true,
137            Self::Multiply { r, g, b } => r == 255 && g == 255 && b == 255,
138            Self::Mix { amount, .. } => amount == 0,
139        }
140    }
141
142    /// Applies this tint to one straight-alpha RGB triple, returning the recoloured channels.
143    ///
144    /// The reference implementation of the operation. Both pixel backends produce their output
145    /// from this, the software renderer by calling it per pixel and the GL renderer by matching
146    /// its arithmetic in the sprite fragment shader, so that a sprite tinted on one backend
147    /// matches the same sprite tinted on the other.
148    ///
149    /// Alpha is not an input and not an output: a tint never changes which pixels are opaque.
150    #[must_use]
151    pub const fn apply(self, rgb: (u8, u8, u8)) -> (u8, u8, u8) {
152        use gem::channel::{mix_u8, multiply_u8};
153        let (sr, sg, sb) = rgb;
154        match self {
155            Self::None => (sr, sg, sb),
156            Self::Multiply { r, g, b } => {
157                (multiply_u8(sr, r), multiply_u8(sg, g), multiply_u8(sb, b))
158            }
159            Self::Mix { r, g, b, amount } => (
160                mix_u8(sr, r, amount),
161                mix_u8(sg, g, amount),
162                mix_u8(sb, b, amount),
163            ),
164        }
165    }
166
167    /// Applies this tint to an [`Rgb888`], the same operation as [`apply`](Self::apply) but
168    /// without the channel-order round trip through a bare `(u8, u8, u8)` tuple.
169    ///
170    /// `const` because [`Rgb888::to_rgb`][gem::rgb::Rgb::to_rgb] is a const inherent method
171    /// (gem 0.2.0): the equivalent by way of the [`HasRed`](gem::rgb::HasRed)-family traits
172    /// cannot be, since trait methods aren't const-callable on stable.
173    ///
174    /// ```rust
175    /// use retroglyph_core::color::Tint;
176    /// use gem::rgb::Rgb888;
177    ///
178    /// const PX: Rgb888 = Tint::Multiply { r: 128, g: 128, b: 128 }
179    ///     .apply_rgb888(Rgb888::from_rgb(200, 180, 60));
180    /// assert_eq!(PX, Rgb888::from_rgb(100, 90, 30));
181    /// ```
182    #[must_use]
183    pub const fn apply_rgb888(self, px: Rgb888) -> Rgb888 {
184        let (r, g, b) = self.apply(px.to_rgb());
185        Rgb888::from_rgb(r, g, b)
186    }
187
188    /// A [`Multiply`](Self::Multiply) tint by `color`'s resolved RGB, falling back to `default`
189    /// for [`Color::Default`](crate::color::Color::Default) (which has no intrinsic reading as a
190    /// modulation value). Built for `retroglyph-window`'s sheet-level recolouring: a
191    /// `SheetColor::Mask` sheet is tinted by the cell's own foreground colour this way before
192    /// the cell's own [`Tint`](crate::color::Tint) is applied on top.
193    #[must_use]
194    pub const fn multiply_color(c: crate::color::Color, default: (u8, u8, u8)) -> Self {
195        let (r, g, b) = c.resolve_rgb(default);
196        Self::multiply(r, g, b)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::Tint;
203
204    #[test]
205    fn none_is_the_identity() {
206        assert_eq!(Tint::None.apply((13, 200, 255)), (13, 200, 255));
207        assert!(Tint::None.is_identity());
208        assert_eq!(Tint::default(), Tint::None);
209    }
210
211    #[test]
212    fn multiply_by_white_is_exact() {
213        let white = Tint::multiply(255, 255, 255);
214        for c in [0u8, 1, 63, 127, 128, 200, 254, 255] {
215            assert_eq!(white.apply((c, c, c)), (c, c, c), "channel {c}");
216        }
217        assert!(white.is_identity());
218    }
219
220    #[test]
221    fn multiply_by_black_is_black() {
222        assert_eq!(Tint::multiply(0, 0, 0).apply((200, 180, 60)), (0, 0, 0));
223    }
224
225    #[test]
226    fn multiply_scales_per_channel() {
227        // Only the green channel is scaled; the others pass through untouched.
228        let green_only = Tint::multiply(255, 128, 255);
229        assert_eq!(green_only.apply((200, 200, 200)), (200, 100, 200));
230    }
231
232    #[test]
233    fn multiply_can_only_darken() {
234        let t = Tint::multiply(200, 200, 200);
235        for c in 0..=255u8 {
236            let (r, _, _) = t.apply((c, c, c));
237            assert!(r <= c, "multiply brightened {c} to {r}");
238        }
239    }
240
241    #[test]
242    fn mix_endpoints_are_exact() {
243        let src = (200, 180, 60);
244        assert_eq!(Tint::mix(255, 255, 255, 0).apply(src), src);
245        assert_eq!(Tint::mix(255, 255, 255, 255).apply(src), (255, 255, 255));
246        assert_eq!(Tint::mix(0, 0, 0, 255).apply(src), (0, 0, 0));
247    }
248
249    #[test]
250    fn mix_at_zero_amount_is_identity_for_every_colour() {
251        assert!(Tint::mix(1, 2, 3, 0).is_identity());
252        assert_eq!(Tint::mix(1, 2, 3, 0).apply((9, 9, 9)), (9, 9, 9));
253    }
254
255    #[test]
256    fn mix_halfway_is_the_midpoint() {
257        // 128/255 is a hair over half, so a 0 -> 254 blend lands on 127.
258        assert_eq!(
259            Tint::mix(254, 254, 254, 128).apply((0, 0, 0)),
260            (127, 127, 127)
261        );
262    }
263
264    #[test]
265    fn mix_can_brighten_which_multiply_cannot() {
266        let src = (10, 10, 10);
267        let (r, _, _) = Tint::mix(255, 255, 255, 128).apply(src);
268        assert!(r > 10, "mix toward white should brighten, got {r}");
269    }
270
271    #[test]
272    fn mix_rounds_symmetrically_in_both_directions() {
273        // Blending 100 toward 200 and 200 toward 100 by the same amount should move each the
274        // same distance, otherwise a pulsing flash would drift.
275        let up = Tint::mix(200, 200, 200, 64).apply((100, 100, 100)).0;
276        let down = Tint::mix(100, 100, 100, 64).apply((200, 200, 200)).0;
277        assert_eq!(up - 100, 200 - down);
278    }
279
280    #[test]
281    fn identity_variants_never_change_a_pixel() {
282        let src = (37, 211, 4);
283        for t in [
284            Tint::None,
285            Tint::multiply(255, 255, 255),
286            Tint::mix(0, 0, 0, 0),
287            Tint::mix(255, 255, 255, 0),
288        ] {
289            assert!(t.is_identity(), "{t:?} should report as identity");
290            assert_eq!(t.apply(src), src, "{t:?} changed a pixel");
291        }
292    }
293
294    #[test]
295    fn apply_is_usable_in_const_context() {
296        const SHADOWED: (u8, u8, u8) = Tint::multiply(128, 128, 128).apply((200, 180, 60));
297        assert_eq!(SHADOWED, (100, 90, 30));
298    }
299
300    // The values in this type's doc example are worked arithmetic, not round numbers, so they
301    // are easy to write by hand and get wrong. Pin them here: a doctest catches a stale example
302    // only when someone runs the doctests, and this keeps the two in one place.
303    #[test]
304    fn doc_example_values_are_what_apply_actually_returns() {
305        let src = (200, 180, 60);
306        assert_eq!(Tint::multiply(128, 128, 128).apply(src), (100, 90, 30));
307        assert_eq!(Tint::mix(255, 255, 255, 192).apply(src), (241, 236, 207));
308        assert_eq!(Tint::None.apply(src), src);
309    }
310}