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}