Skip to main content

oxigdal_gpu/
compositing.rs

1//! Tile compositing pipeline for the GPU rendering layer.
2//!
3//! Provides CPU-side Porter-Duff compositing, multi-layer stacking, 4x5 color
4//! matrix transforms, and a high-level [`TileRenderPipeline`] that wires
5//! everything together with shader hot-reload support.
6
7use crate::shader_reload::HotReloadRegistry;
8
9// ─── Rgba ─────────────────────────────────────────────────────────────────────
10
11/// A 32-bit RGBA colour in linear-light floating-point space.
12///
13/// All channels are nominally in `[0.0, 1.0]`.  Arithmetic operations may
14/// produce out-of-range values; call [`Rgba::clamp`] to normalise.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Rgba {
17    pub r: f32,
18    pub g: f32,
19    pub b: f32,
20    pub a: f32,
21}
22
23impl Rgba {
24    /// Construct from linear-light float channels.
25    #[inline]
26    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
27        Self { r, g, b, a }
28    }
29
30    /// Construct from 8-bit unsigned integer channels (sRGB transfer assumed
31    /// by the caller; no gamma conversion is applied here).
32    #[inline]
33    pub fn from_u8(r: u8, g: u8, b: u8, a: u8) -> Self {
34        Self {
35            r: r as f32 / 255.0,
36            g: g as f32 / 255.0,
37            b: b as f32 / 255.0,
38            a: a as f32 / 255.0,
39        }
40    }
41
42    /// Convert back to 8-bit channels, clamping to `[0, 255]`.
43    #[inline]
44    pub fn to_u8(&self) -> (u8, u8, u8, u8) {
45        let clamp_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
46        (
47            clamp_u8(self.r),
48            clamp_u8(self.g),
49            clamp_u8(self.b),
50            clamp_u8(self.a),
51        )
52    }
53
54    /// Clamp all channels to `[0.0, 1.0]`.
55    #[inline]
56    pub fn clamp(&self) -> Self {
57        Self {
58            r: self.r.clamp(0.0, 1.0),
59            g: self.g.clamp(0.0, 1.0),
60            b: self.b.clamp(0.0, 1.0),
61            a: self.a.clamp(0.0, 1.0),
62        }
63    }
64
65    /// Convert to premultiplied-alpha representation (RGB *= alpha).
66    #[inline]
67    pub fn premultiply(&self) -> Self {
68        Self {
69            r: self.r * self.a,
70            g: self.g * self.a,
71            b: self.b * self.a,
72            a: self.a,
73        }
74    }
75
76    /// Convert from premultiplied-alpha back to straight alpha.
77    ///
78    /// If `alpha == 0` the RGB channels are left as-is to avoid NaN.
79    #[inline]
80    pub fn unpremultiply(&self) -> Self {
81        if self.a < 1e-8 {
82            Self {
83                r: 0.0,
84                g: 0.0,
85                b: 0.0,
86                a: 0.0,
87            }
88        } else {
89            Self {
90                r: self.r / self.a,
91                g: self.g / self.a,
92                b: self.b / self.a,
93                a: self.a,
94            }
95        }
96    }
97
98    /// Fully transparent black pixel.
99    #[inline]
100    pub fn transparent() -> Self {
101        Self::new(0.0, 0.0, 0.0, 0.0)
102    }
103
104    /// Opaque white pixel.
105    #[inline]
106    pub fn white() -> Self {
107        Self::new(1.0, 1.0, 1.0, 1.0)
108    }
109
110    /// Opaque black pixel.
111    #[inline]
112    pub fn black() -> Self {
113        Self::new(0.0, 0.0, 0.0, 1.0)
114    }
115}
116
117// ─── BlendMode ────────────────────────────────────────────────────────────────
118
119/// Porter-Duff and Photoshop blend modes.
120///
121/// All `blend` operations work in **straight** (non-premultiplied)
122/// linear-light colour space.  The internal implementation premultiplies
123/// inputs as needed.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum BlendMode {
126    // ── Photoshop-style separable modes ──────────────────────────────────────
127    Normal,
128    Multiply,
129    Screen,
130    Overlay,
131    HardLight,
132    SoftLight,
133    Darken,
134    Lighten,
135    ColorDodge,
136    ColorBurn,
137    Difference,
138    Exclusion,
139    // ── Porter-Duff compositing operators ────────────────────────────────────
140    SrcOver,
141    SrcIn,
142    SrcOut,
143    SrcAtop,
144    DstOver,
145    DstIn,
146    DstOut,
147    DstAtop,
148    Xor,
149    Clear,
150}
151
152impl BlendMode {
153    /// Blend `src` over `dst` using this mode.
154    ///
155    /// Inputs and output are in **straight** (non-premultiplied) linear-light
156    /// colour space.  Alpha compositing follows the Porter-Duff `SrcOver`
157    /// model for all non-Porter-Duff modes.
158    pub fn blend(&self, src: Rgba, dst: Rgba) -> Rgba {
159        match self {
160            BlendMode::Normal | BlendMode::SrcOver => src_over(src, dst),
161            BlendMode::Multiply => separable_blend(src, dst, |s, d| s * d),
162            BlendMode::Screen => separable_blend(src, dst, |s, d| 1.0 - (1.0 - s) * (1.0 - d)),
163            BlendMode::Overlay => separable_blend(src, dst, |s, d| hard_light_channel(d, s)),
164            BlendMode::HardLight => separable_blend(src, dst, |s, d| hard_light_channel(s, d)),
165            BlendMode::SoftLight => separable_blend(src, dst, soft_light_channel),
166            BlendMode::Darken => separable_blend(src, dst, |s, d| s.min(d)),
167            BlendMode::Lighten => separable_blend(src, dst, |s, d| s.max(d)),
168            BlendMode::ColorDodge => separable_blend(src, dst, color_dodge_channel),
169            BlendMode::ColorBurn => separable_blend(src, dst, color_burn_channel),
170            BlendMode::Difference => separable_blend(src, dst, |s, d| (s - d).abs()),
171            BlendMode::Exclusion => separable_blend(src, dst, |s, d| s + d - 2.0 * s * d),
172            BlendMode::SrcIn => porter_duff(src, dst, src.a * dst.a, 0.0),
173            BlendMode::SrcOut => porter_duff(src, dst, src.a * (1.0 - dst.a), 0.0),
174            BlendMode::SrcAtop => porter_duff(src, dst, src.a * dst.a, dst.a * (1.0 - src.a)),
175            BlendMode::DstOver => src_over(dst, src),
176            BlendMode::DstIn => porter_duff(src, dst, 0.0, dst.a * src.a),
177            BlendMode::DstOut => porter_duff(src, dst, 0.0, dst.a * (1.0 - src.a)),
178            BlendMode::DstAtop => porter_duff(src, dst, src.a * (1.0 - dst.a), dst.a * src.a),
179            BlendMode::Xor => porter_duff(src, dst, src.a * (1.0 - dst.a), dst.a * (1.0 - src.a)),
180            BlendMode::Clear => Rgba::transparent(),
181        }
182    }
183}
184
185// ── Internal compositing helpers ─────────────────────────────────────────────
186
187/// Standard Porter-Duff `SrcOver` — straight-alpha implementation.
188fn src_over(src: Rgba, dst: Rgba) -> Rgba {
189    let a_out = src.a + dst.a * (1.0 - src.a);
190    if a_out < 1e-8 {
191        return Rgba::transparent();
192    }
193    Rgba {
194        r: (src.r * src.a + dst.r * dst.a * (1.0 - src.a)) / a_out,
195        g: (src.g * src.a + dst.g * dst.a * (1.0 - src.a)) / a_out,
196        b: (src.b * src.a + dst.b * dst.a * (1.0 - src.a)) / a_out,
197        a: a_out,
198    }
199}
200
201/// Apply a separable per-channel blend function with `SrcOver` alpha.
202fn separable_blend<F>(src: Rgba, dst: Rgba, f: F) -> Rgba
203where
204    F: Fn(f32, f32) -> f32,
205{
206    // Blend RGB channels using the supplied function, then apply SrcOver alpha.
207    let blended = Rgba {
208        r: f(src.r, dst.r),
209        g: f(src.g, dst.g),
210        b: f(src.b, dst.b),
211        a: src.a,
212    };
213    src_over(blended, dst)
214}
215
216/// Porter-Duff generic compositor with explicit src/dst alpha factors.
217fn porter_duff(src: Rgba, dst: Rgba, src_factor: f32, dst_factor: f32) -> Rgba {
218    let a_out = src_factor + dst_factor;
219    if a_out < 1e-8 {
220        return Rgba::transparent();
221    }
222    Rgba {
223        r: (src.r * src_factor + dst.r * dst_factor) / a_out,
224        g: (src.g * src_factor + dst.g * dst_factor) / a_out,
225        b: (src.b * src_factor + dst.b * dst_factor) / a_out,
226        a: a_out,
227    }
228}
229
230/// Hard-light blend for a single channel (used by Overlay and HardLight).
231#[inline]
232fn hard_light_channel(src: f32, dst: f32) -> f32 {
233    if src <= 0.5 {
234        2.0 * src * dst
235    } else {
236        1.0 - 2.0 * (1.0 - src) * (1.0 - dst)
237    }
238}
239
240/// Soft-light blend for a single channel (W3C/CSS compositing definition).
241#[inline]
242fn soft_light_channel(src: f32, dst: f32) -> f32 {
243    if src <= 0.5 {
244        dst - (1.0 - 2.0 * src) * dst * (1.0 - dst)
245    } else {
246        let d = if dst <= 0.25 {
247            ((16.0 * dst - 12.0) * dst + 4.0) * dst
248        } else {
249            dst.sqrt()
250        };
251        dst + (2.0 * src - 1.0) * (d - dst)
252    }
253}
254
255/// Color-dodge for a single channel.
256#[inline]
257fn color_dodge_channel(src: f32, dst: f32) -> f32 {
258    if (1.0 - src) < 1e-8 {
259        1.0
260    } else {
261        (dst / (1.0 - src)).min(1.0)
262    }
263}
264
265/// Color-burn for a single channel.
266#[inline]
267fn color_burn_channel(src: f32, dst: f32) -> f32 {
268    if src < 1e-8 {
269        0.0
270    } else {
271        1.0 - ((1.0 - dst) / src).min(1.0)
272    }
273}
274
275// ─── Layer ────────────────────────────────────────────────────────────────────
276
277/// A compositable image layer with blend settings.
278pub struct Layer {
279    pub label: String,
280    /// Pixel data in row-major order (row 0 at index 0).
281    pub pixels: Vec<Rgba>,
282    pub width: u32,
283    pub height: u32,
284    /// Layer-wide opacity multiplier applied to each pixel's alpha.
285    pub opacity: f32,
286    pub blend_mode: BlendMode,
287    pub visible: bool,
288    /// Compositing order; layers are sorted ascending before compositing.
289    pub z_order: i32,
290}
291
292impl Layer {
293    /// Create a new, fully transparent layer.
294    pub fn new(label: impl Into<String>, width: u32, height: u32) -> Self {
295        let count = (width * height) as usize;
296        Self {
297            label: label.into(),
298            pixels: vec![Rgba::transparent(); count],
299            width,
300            height,
301            opacity: 1.0,
302            blend_mode: BlendMode::Normal,
303            visible: true,
304            z_order: 0,
305        }
306    }
307
308    /// Fill every pixel with `color` (builder pattern).
309    pub fn fill(mut self, color: Rgba) -> Self {
310        self.pixels.fill(color);
311        self
312    }
313
314    /// Return the pixel at `(x, y)`, or `None` if out of bounds.
315    pub fn pixel_at(&self, x: u32, y: u32) -> Option<Rgba> {
316        if x >= self.width || y >= self.height {
317            return None;
318        }
319        self.pixels.get((y * self.width + x) as usize).copied()
320    }
321
322    /// Set the pixel at `(x, y)`.  Returns `true` on success.
323    pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
324        if x >= self.width || y >= self.height {
325            return false;
326        }
327        let idx = (y * self.width + x) as usize;
328        if let Some(p) = self.pixels.get_mut(idx) {
329            *p = color;
330            true
331        } else {
332            false
333        }
334    }
335}
336
337// ─── CompositeStats ───────────────────────────────────────────────────────────
338
339/// Per-channel statistics over a composited image.
340#[derive(Debug, Clone)]
341pub struct CompositeStats {
342    pub min_r: f32,
343    pub max_r: f32,
344    pub mean_r: f32,
345    pub min_g: f32,
346    pub max_g: f32,
347    pub mean_g: f32,
348    pub min_b: f32,
349    pub max_b: f32,
350    pub mean_b: f32,
351    pub min_a: f32,
352    pub max_a: f32,
353    pub mean_a: f32,
354    /// Number of pixels where alpha < `1e-4` (effectively transparent).
355    pub transparent_pixel_count: u64,
356}
357
358// ─── TileCompositor ───────────────────────────────────────────────────────────
359
360/// Composites a stack of [`Layer`]s into a single image tile.
361pub struct TileCompositor {
362    pub width: u32,
363    pub height: u32,
364    /// Background colour drawn beneath all layers.
365    pub background: Rgba,
366}
367
368impl TileCompositor {
369    /// Create a new compositor for tiles of the given dimensions.
370    pub fn new(width: u32, height: u32, background: Rgba) -> Self {
371        Self {
372            width,
373            height,
374            background,
375        }
376    }
377
378    /// Composite `layers` in ascending `z_order` order.
379    ///
380    /// Invisible layers are skipped.  Each layer's per-pixel alpha is
381    /// multiplied by `layer.opacity` before blending.
382    pub fn composite(&self, layers: &mut [Layer]) -> Vec<Rgba> {
383        let pixel_count = (self.width * self.height) as usize;
384
385        // Initialise with the background colour.
386        let mut canvas: Vec<Rgba> = vec![self.background; pixel_count];
387
388        // Sort layers by z_order (stable sort preserves insertion order for ties).
389        layers.sort_by_key(|l| l.z_order);
390
391        for layer in layers.iter() {
392            if !layer.visible {
393                continue;
394            }
395            if layer.width != self.width || layer.height != self.height {
396                // Skip layers with mismatched dimensions.
397                continue;
398            }
399
400            let opacity = layer.opacity.clamp(0.0, 1.0);
401
402            for (i, canvas_pixel) in canvas.iter_mut().enumerate() {
403                let src = match layer.pixels.get(i) {
404                    Some(&p) => p,
405                    None => continue,
406                };
407                // Apply layer opacity to the source alpha.
408                let src = Rgba {
409                    a: src.a * opacity,
410                    ..src
411                };
412                *canvas_pixel = layer.blend_mode.blend(src, *canvas_pixel);
413            }
414        }
415
416        canvas
417    }
418
419    /// Convert a pixel slice to interleaved RGBA bytes (4 bytes per pixel).
420    pub fn to_rgba_bytes(pixels: &[Rgba]) -> Vec<u8> {
421        let mut out = Vec::with_capacity(pixels.len() * 4);
422        for p in pixels {
423            let (r, g, b, a) = p.to_u8();
424            out.push(r);
425            out.push(g);
426            out.push(b);
427            out.push(a);
428        }
429        out
430    }
431
432    /// Convert a pixel slice to interleaved RGB bytes (3 bytes per pixel),
433    /// discarding the alpha channel.
434    pub fn to_rgb_bytes(pixels: &[Rgba]) -> Vec<u8> {
435        let mut out = Vec::with_capacity(pixels.len() * 3);
436        for p in pixels {
437            let (r, g, b, _) = p.to_u8();
438            out.push(r);
439            out.push(g);
440            out.push(b);
441        }
442        out
443    }
444
445    /// Compute per-channel statistics on a composited pixel slice.
446    pub fn stats(pixels: &[Rgba]) -> CompositeStats {
447        if pixels.is_empty() {
448            return CompositeStats {
449                min_r: 0.0,
450                max_r: 0.0,
451                mean_r: 0.0,
452                min_g: 0.0,
453                max_g: 0.0,
454                mean_g: 0.0,
455                min_b: 0.0,
456                max_b: 0.0,
457                mean_b: 0.0,
458                min_a: 0.0,
459                max_a: 0.0,
460                mean_a: 0.0,
461                transparent_pixel_count: 0,
462            };
463        }
464
465        let n = pixels.len();
466        let mut min_r = f32::MAX;
467        let mut max_r = f32::MIN;
468        let mut sum_r = 0.0_f64;
469        let mut min_g = f32::MAX;
470        let mut max_g = f32::MIN;
471        let mut sum_g = 0.0_f64;
472        let mut min_b = f32::MAX;
473        let mut max_b = f32::MIN;
474        let mut sum_b = 0.0_f64;
475        let mut min_a = f32::MAX;
476        let mut max_a = f32::MIN;
477        let mut sum_a = 0.0_f64;
478        let mut transparent_count: u64 = 0;
479
480        for p in pixels {
481            min_r = min_r.min(p.r);
482            max_r = max_r.max(p.r);
483            sum_r += p.r as f64;
484            min_g = min_g.min(p.g);
485            max_g = max_g.max(p.g);
486            sum_g += p.g as f64;
487            min_b = min_b.min(p.b);
488            max_b = max_b.max(p.b);
489            sum_b += p.b as f64;
490            min_a = min_a.min(p.a);
491            max_a = max_a.max(p.a);
492            sum_a += p.a as f64;
493            if p.a < 1e-4 {
494                transparent_count += 1;
495            }
496        }
497
498        let n_f64 = n as f64;
499        CompositeStats {
500            min_r,
501            max_r,
502            mean_r: (sum_r / n_f64) as f32,
503            min_g,
504            max_g,
505            mean_g: (sum_g / n_f64) as f32,
506            min_b,
507            max_b,
508            mean_b: (sum_b / n_f64) as f32,
509            min_a,
510            max_a,
511            mean_a: (sum_a / n_f64) as f32,
512            transparent_pixel_count: transparent_count,
513        }
514    }
515}
516
517// ─── ColorMatrix ─────────────────────────────────────────────────────────────
518
519/// A 4×5 colour transformation matrix.
520///
521/// Each output channel is:
522/// ```text
523/// out_R = m[0][0]*R + m[0][1]*G + m[0][2]*B + m[0][3]*A + m[0][4]
524/// out_G = m[1][0]*R + m[1][1]*G + m[1][2]*B + m[1][3]*A + m[1][4]
525/// out_B = m[2][0]*R + m[2][1]*G + m[2][2]*B + m[2][3]*A + m[2][4]
526/// out_A = m[3][0]*R + m[3][1]*G + m[3][2]*B + m[3][3]*A + m[3][4]
527/// ```
528#[derive(Debug, Clone, Copy)]
529pub struct ColorMatrix {
530    /// Row-major 4×5 matrix: rows are [R, G, B, A] output channels.
531    pub matrix: [[f32; 5]; 4],
532}
533
534impl ColorMatrix {
535    /// Identity matrix — no change to colours.
536    pub fn identity() -> Self {
537        Self {
538            matrix: [
539                [1.0, 0.0, 0.0, 0.0, 0.0],
540                [0.0, 1.0, 0.0, 0.0, 0.0],
541                [0.0, 0.0, 1.0, 0.0, 0.0],
542                [0.0, 0.0, 0.0, 1.0, 0.0],
543            ],
544        }
545    }
546
547    /// Scale all RGB channels by `factor`.  Alpha is unchanged.
548    pub fn brightness(factor: f32) -> Self {
549        Self {
550            matrix: [
551                [factor, 0.0, 0.0, 0.0, 0.0],
552                [0.0, factor, 0.0, 0.0, 0.0],
553                [0.0, 0.0, factor, 0.0, 0.0],
554                [0.0, 0.0, 0.0, 1.0, 0.0],
555            ],
556        }
557    }
558
559    /// Contrast adjustment around the midpoint `0.5`.
560    ///
561    /// A `factor` of `1.0` is a no-op; values above `1.0` increase contrast.
562    pub fn contrast(factor: f32) -> Self {
563        let offset = 0.5 * (1.0 - factor);
564        Self {
565            matrix: [
566                [factor, 0.0, 0.0, 0.0, offset],
567                [0.0, factor, 0.0, 0.0, offset],
568                [0.0, 0.0, factor, 0.0, offset],
569                [0.0, 0.0, 0.0, 1.0, 0.0],
570            ],
571        }
572    }
573
574    /// Saturation adjustment.
575    ///
576    /// Uses ITU-R BT.601 luma weights.  `factor = 0` → greyscale, `factor = 1`
577    /// → no change, `factor > 1` → more saturated.
578    pub fn saturation(factor: f32) -> Self {
579        // BT.601 luma weights
580        let lr = 0.2126_f32;
581        let lg = 0.7152_f32;
582        let lb = 0.0722_f32;
583
584        let sr = (1.0 - factor) * lr;
585        let sg = (1.0 - factor) * lg;
586        let sb = (1.0 - factor) * lb;
587
588        Self {
589            matrix: [
590                [sr + factor, sg, sb, 0.0, 0.0],
591                [sr, sg + factor, sb, 0.0, 0.0],
592                [sr, sg, sb + factor, 0.0, 0.0],
593                [0.0, 0.0, 0.0, 1.0, 0.0],
594            ],
595        }
596    }
597
598    /// Hue rotation by `degrees`.
599    ///
600    /// Implemented as a rotation in the colour-opponent plane after projecting
601    /// out the luminance axis (Hacker-level approximation suitable for
602    /// real-time use).
603    pub fn hue_rotate(degrees: f32) -> Self {
604        let rad = degrees.to_radians();
605        let cos = rad.cos();
606        let sin = rad.sin();
607
608        // BT.601 luma weights.
609        let lr = 0.2126_f32;
610        let lg = 0.7152_f32;
611        let lb = 0.0722_f32;
612
613        Self {
614            matrix: [
615                [
616                    lr + cos * (1.0 - lr) - sin * lr,
617                    lg + cos * (-lg) - sin * lg,
618                    lb + cos * (-lb) - sin * (1.0 - lb),
619                    0.0,
620                    0.0,
621                ],
622                [
623                    lr + cos * (-lr) + sin * 0.143,
624                    lg + cos * (1.0 - lg) + sin * 0.140,
625                    lb + cos * (-lb) - sin * 0.283,
626                    0.0,
627                    0.0,
628                ],
629                [
630                    lr + cos * (-lr) - sin * (1.0 - lr),
631                    lg + cos * (-lg) + sin * lg,
632                    lb + cos * (1.0 - lb) + sin * lb,
633                    0.0,
634                    0.0,
635                ],
636                [0.0, 0.0, 0.0, 1.0, 0.0],
637            ],
638        }
639    }
640
641    /// Invert all RGB channels (`1.0 - channel`).  Alpha is unchanged.
642    pub fn invert() -> Self {
643        Self {
644            matrix: [
645                [-1.0, 0.0, 0.0, 0.0, 1.0],
646                [0.0, -1.0, 0.0, 0.0, 1.0],
647                [0.0, 0.0, -1.0, 0.0, 1.0],
648                [0.0, 0.0, 0.0, 1.0, 0.0],
649            ],
650        }
651    }
652
653    /// Greyscale conversion using ITU-R BT.601 luma weights.
654    pub fn grayscale() -> Self {
655        let lr = 0.2126_f32;
656        let lg = 0.7152_f32;
657        let lb = 0.0722_f32;
658        Self {
659            matrix: [
660                [lr, lg, lb, 0.0, 0.0],
661                [lr, lg, lb, 0.0, 0.0],
662                [lr, lg, lb, 0.0, 0.0],
663                [0.0, 0.0, 0.0, 1.0, 0.0],
664            ],
665        }
666    }
667
668    /// Sepia-tone matrix (classic film emulation).
669    pub fn sepia() -> Self {
670        Self {
671            matrix: [
672                [0.393, 0.769, 0.189, 0.0, 0.0],
673                [0.349, 0.686, 0.168, 0.0, 0.0],
674                [0.272, 0.534, 0.131, 0.0, 0.0],
675                [0.0, 0.0, 0.0, 1.0, 0.0],
676            ],
677        }
678    }
679
680    /// Apply the matrix to a single pixel, clamping the result to `[0, 1]`.
681    pub fn apply(&self, pixel: Rgba) -> Rgba {
682        let m = &self.matrix;
683        let r =
684            m[0][0] * pixel.r + m[0][1] * pixel.g + m[0][2] * pixel.b + m[0][3] * pixel.a + m[0][4];
685        let g =
686            m[1][0] * pixel.r + m[1][1] * pixel.g + m[1][2] * pixel.b + m[1][3] * pixel.a + m[1][4];
687        let b =
688            m[2][0] * pixel.r + m[2][1] * pixel.g + m[2][2] * pixel.b + m[2][3] * pixel.a + m[2][4];
689        let a =
690            m[3][0] * pixel.r + m[3][1] * pixel.g + m[3][2] * pixel.b + m[3][3] * pixel.a + m[3][4];
691
692        Rgba::new(r, g, b, a).clamp()
693    }
694
695    /// Compose two matrices: `self ∘ other` (apply `other` first, then `self`).
696    ///
697    /// The 4×5 matrices are augmented to 5×5 (with an implicit row \[0,0,0,0,1\])
698    /// for standard homogeneous composition, then the result is trimmed back
699    /// to 4×5.
700    pub fn compose(&self, other: &ColorMatrix) -> ColorMatrix {
701        let a = &self.matrix;
702        let b = &other.matrix;
703
704        let mut out = [[0.0_f32; 5]; 4];
705
706        for i in 0..4 {
707            for j in 0..5 {
708                // The 5th implicit row of `b` is [0,0,0,0,1].
709                let mut sum = 0.0_f32;
710                for k in 0..4 {
711                    sum += a[i][k] * b[k][j];
712                }
713                // j == 4 is the offset column; the implicit row contributes
714                // a[i][4] * 1.0 for j == 4, zero otherwise.
715                if j == 4 {
716                    sum += a[i][4];
717                }
718                out[i][j] = sum;
719            }
720        }
721
722        ColorMatrix { matrix: out }
723    }
724}
725
726// ─── TileRenderPipeline ───────────────────────────────────────────────────────
727
728/// High-level CPU rendering pipeline: shader hot-reload + layer compositing
729/// + optional colour-matrix post-processing.
730pub struct TileRenderPipeline {
731    pub compositor: TileCompositor,
732    /// Default colour matrix applied by [`Self::render`] (identity by default).
733    pub color_matrix: ColorMatrix,
734    pub shader_registry: HotReloadRegistry,
735}
736
737impl TileRenderPipeline {
738    /// Create a new pipeline for a tile of the given size.
739    pub fn new(width: u32, height: u32) -> Self {
740        Self {
741            compositor: TileCompositor::new(width, height, Rgba::transparent()),
742            color_matrix: ColorMatrix::identity(),
743            shader_registry: HotReloadRegistry::new(),
744        }
745    }
746
747    /// Register a WGSL shader source with the hot-reload registry.
748    pub fn add_shader(&mut self, label: impl Into<String>, wgsl: impl Into<String>) {
749        let lbl: String = label.into();
750        self.shader_registry.watcher.add_inline(lbl, wgsl);
751    }
752
753    /// Update a registered shader source, bumping its version.
754    ///
755    /// Returns `true` if the label existed; `false` otherwise.
756    pub fn update_shader(&mut self, label: &str, new_wgsl: impl Into<String>) -> bool {
757        self.shader_registry.watcher.update_source(label, new_wgsl)
758    }
759
760    /// Composite `layers`, apply the pipeline's default colour matrix, and
761    /// return the result as interleaved RGBA bytes.
762    pub fn render(&self, layers: &mut [Layer]) -> Vec<u8> {
763        self.render_with_matrix(layers, &self.color_matrix)
764    }
765
766    /// Composite `layers`, apply `matrix`, and return RGBA bytes.
767    pub fn render_with_matrix(&self, layers: &mut [Layer], matrix: &ColorMatrix) -> Vec<u8> {
768        let pixels = self.compositor.composite(layers);
769        let transformed: Vec<Rgba> = pixels.iter().map(|p| matrix.apply(*p)).collect();
770        TileCompositor::to_rgba_bytes(&transformed)
771    }
772}
773
774// ─── Tests ────────────────────────────────────────────────────────────────────
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    const EPSILON: f32 = 1e-4;
781
782    fn approx_eq(a: f32, b: f32) -> bool {
783        (a - b).abs() < EPSILON
784    }
785
786    fn rgba_approx(a: Rgba, b: Rgba) -> bool {
787        approx_eq(a.r, b.r) && approx_eq(a.g, b.g) && approx_eq(a.b, b.b) && approx_eq(a.a, b.a)
788    }
789
790    // ── Rgba ──────────────────────────────────────────────────────────────────
791
792    #[test]
793    fn test_rgba_from_u8_round_trip() {
794        let (r, g, b, a) = (128_u8, 64_u8, 255_u8, 200_u8);
795        let px = Rgba::from_u8(r, g, b, a);
796        let (ro, go, bo, ao) = px.to_u8();
797        assert_eq!(ro, r);
798        assert_eq!(go, g);
799        assert_eq!(bo, b);
800        assert_eq!(ao, a);
801    }
802
803    #[test]
804    fn test_rgba_from_u8_black() {
805        let px = Rgba::from_u8(0, 0, 0, 255);
806        assert!(approx_eq(px.r, 0.0));
807        assert!(approx_eq(px.a, 1.0));
808    }
809
810    #[test]
811    fn test_rgba_from_u8_white() {
812        let px = Rgba::from_u8(255, 255, 255, 255);
813        assert!(approx_eq(px.r, 1.0));
814        assert!(approx_eq(px.g, 1.0));
815    }
816
817    #[test]
818    fn test_rgba_to_u8_clamps_over() {
819        let px = Rgba::new(1.5, -0.5, 0.5, 1.0);
820        let (r, _g, b, _a) = px.to_u8();
821        assert_eq!(r, 255);
822        assert_eq!(b, 128);
823    }
824
825    #[test]
826    fn test_rgba_clamp() {
827        let px = Rgba::new(1.5, -0.1, 0.5, 2.0).clamp();
828        assert!(approx_eq(px.r, 1.0));
829        assert!(approx_eq(px.g, 0.0));
830        assert!(approx_eq(px.b, 0.5));
831        assert!(approx_eq(px.a, 1.0));
832    }
833
834    #[test]
835    fn test_rgba_premultiply() {
836        let px = Rgba::new(1.0, 0.5, 0.25, 0.5);
837        let pre = px.premultiply();
838        assert!(approx_eq(pre.r, 0.5));
839        assert!(approx_eq(pre.g, 0.25));
840        assert!(approx_eq(pre.b, 0.125));
841        assert!(approx_eq(pre.a, 0.5));
842    }
843
844    #[test]
845    fn test_rgba_premultiply_full_alpha() {
846        let px = Rgba::new(0.3, 0.6, 0.9, 1.0);
847        let pre = px.premultiply();
848        assert!(approx_eq(pre.r, 0.3));
849        assert!(approx_eq(pre.g, 0.6));
850        assert!(approx_eq(pre.b, 0.9));
851    }
852
853    #[test]
854    fn test_rgba_unpremultiply() {
855        let px = Rgba::new(0.5, 0.25, 0.125, 0.5);
856        let straight = px.unpremultiply();
857        assert!(approx_eq(straight.r, 1.0));
858        assert!(approx_eq(straight.g, 0.5));
859        assert!(approx_eq(straight.b, 0.25));
860    }
861
862    #[test]
863    fn test_rgba_unpremultiply_zero_alpha() {
864        let px = Rgba::new(0.5, 0.5, 0.5, 0.0);
865        let straight = px.unpremultiply();
866        assert!(approx_eq(straight.r, 0.0));
867        assert!(approx_eq(straight.a, 0.0));
868    }
869
870    #[test]
871    fn test_rgba_premultiply_unpremultiply_round_trip() {
872        let px = Rgba::new(0.8, 0.4, 0.2, 0.6);
873        let recovered = px.premultiply().unpremultiply();
874        assert!(rgba_approx(px, recovered));
875    }
876
877    // ── BlendMode ─────────────────────────────────────────────────────────────
878
879    #[test]
880    fn test_blend_normal_src_over() {
881        let src = Rgba::new(1.0, 0.0, 0.0, 1.0);
882        let dst = Rgba::new(0.0, 1.0, 0.0, 1.0);
883        let result = BlendMode::Normal.blend(src, dst);
884        // Fully opaque src → result equals src.
885        assert!(rgba_approx(result, src));
886    }
887
888    #[test]
889    fn test_blend_src_over_transparent_src() {
890        let src = Rgba::new(1.0, 0.0, 0.0, 0.0);
891        let dst = Rgba::new(0.0, 1.0, 0.0, 1.0);
892        let result = BlendMode::SrcOver.blend(src, dst);
893        assert!(rgba_approx(result, dst));
894    }
895
896    #[test]
897    fn test_blend_src_over_half_alpha() {
898        let src = Rgba::new(1.0, 0.0, 0.0, 0.5);
899        let dst = Rgba::new(0.0, 0.0, 1.0, 1.0);
900        let result = BlendMode::SrcOver.blend(src, dst);
901        // a_out = 0.5 + 1.0 * 0.5 = 1.0
902        assert!(approx_eq(result.a, 1.0));
903        // r_out = (1.0 * 0.5 + 0.0 * 1.0 * 0.5) / 1.0 = 0.5
904        assert!(approx_eq(result.r, 0.5));
905        // b_out = (0.0 * 0.5 + 1.0 * 1.0 * 0.5) / 1.0 = 0.5
906        assert!(approx_eq(result.b, 0.5));
907    }
908
909    #[test]
910    fn test_blend_multiply_black_src() {
911        let src = Rgba::new(0.0, 0.0, 0.0, 1.0);
912        let dst = Rgba::new(0.8, 0.5, 0.3, 1.0);
913        let result = BlendMode::Multiply.blend(src, dst);
914        assert!(approx_eq(result.r, 0.0));
915        assert!(approx_eq(result.g, 0.0));
916        assert!(approx_eq(result.b, 0.0));
917    }
918
919    #[test]
920    fn test_blend_multiply_white_src() {
921        let src = Rgba::new(1.0, 1.0, 1.0, 1.0);
922        let dst = Rgba::new(0.5, 0.5, 0.5, 1.0);
923        let result = BlendMode::Multiply.blend(src, dst);
924        // Multiply with 1.0 is identity-ish via src_over; final blend ≈ dst.
925        assert!(approx_eq(result.r, 0.5));
926    }
927
928    #[test]
929    fn test_blend_screen_white_src() {
930        let src = Rgba::new(1.0, 1.0, 1.0, 1.0);
931        let dst = Rgba::new(0.5, 0.5, 0.5, 1.0);
932        // Screen with fully opaque white → result is white (1,1,1).
933        let result = BlendMode::Screen.blend(src, dst);
934        assert!(approx_eq(result.r, 1.0));
935    }
936
937    #[test]
938    fn test_blend_darken_picks_darker() {
939        let src = Rgba::new(0.3, 0.7, 0.2, 1.0);
940        let dst = Rgba::new(0.5, 0.4, 0.8, 1.0);
941        let result = BlendMode::Darken.blend(src, dst);
942        // Each channel should be ≤ both inputs.
943        assert!(result.r <= src.r + EPSILON && result.r <= dst.r + EPSILON);
944        assert!(result.g <= src.g + EPSILON && result.g <= dst.g + EPSILON);
945        assert!(result.b <= src.b + EPSILON && result.b <= dst.b + EPSILON);
946    }
947
948    #[test]
949    fn test_blend_lighten_picks_lighter() {
950        let src = Rgba::new(0.3, 0.7, 0.2, 1.0);
951        let dst = Rgba::new(0.5, 0.4, 0.8, 1.0);
952        let result = BlendMode::Lighten.blend(src, dst);
953        assert!(result.r >= src.r - EPSILON && result.r >= dst.r - EPSILON);
954        assert!(result.g >= src.g - EPSILON && result.g >= dst.g - EPSILON);
955        assert!(result.b >= src.b - EPSILON && result.b >= dst.b - EPSILON);
956    }
957
958    #[test]
959    fn test_blend_difference_same_color() {
960        let color = Rgba::new(0.5, 0.3, 0.8, 1.0);
961        let result = BlendMode::Difference.blend(color, color);
962        // Difference of identical colours → black (RGB all 0).
963        assert!(approx_eq(result.r, 0.0));
964        assert!(approx_eq(result.g, 0.0));
965        assert!(approx_eq(result.b, 0.0));
966    }
967
968    #[test]
969    fn test_blend_clear() {
970        let src = Rgba::new(1.0, 0.5, 0.0, 1.0);
971        let dst = Rgba::new(0.0, 1.0, 0.0, 1.0);
972        let result = BlendMode::Clear.blend(src, dst);
973        assert!(approx_eq(result.a, 0.0));
974    }
975
976    #[test]
977    fn test_blend_src_in() {
978        // SrcIn: only where both src and dst exist.
979        let src = Rgba::new(1.0, 0.0, 0.0, 0.5);
980        let dst = Rgba::new(0.0, 1.0, 0.0, 0.6);
981        let result = BlendMode::SrcIn.blend(src, dst);
982        // a_out = src.a * dst.a = 0.3
983        assert!(approx_eq(result.a, 0.3));
984    }
985
986    #[test]
987    fn test_blend_src_out() {
988        let src = Rgba::new(1.0, 0.0, 0.0, 1.0);
989        let dst = Rgba::new(0.0, 1.0, 0.0, 1.0);
990        let result = BlendMode::SrcOut.blend(src, dst);
991        // SrcOut: src.a * (1 - dst.a) → 0 if dst is fully opaque.
992        assert!(approx_eq(result.a, 0.0));
993    }
994
995    #[test]
996    fn test_blend_dst_over() {
997        // DstOver: dst paints over src (swap).
998        let src = Rgba::new(1.0, 0.0, 0.0, 0.5);
999        let dst = Rgba::new(0.0, 0.0, 1.0, 1.0);
1000        let result = BlendMode::DstOver.blend(src, dst);
1001        // dst is opaque → result is dst.
1002        assert!(rgba_approx(result, dst));
1003    }
1004
1005    #[test]
1006    fn test_blend_xor() {
1007        // Xor with fully opaque src and dst → transparent.
1008        let src = Rgba::new(1.0, 0.0, 0.0, 1.0);
1009        let dst = Rgba::new(0.0, 1.0, 0.0, 1.0);
1010        let result = BlendMode::Xor.blend(src, dst);
1011        // Both alphas = 1 → 1*(1-1) + 1*(1-1) = 0.
1012        assert!(approx_eq(result.a, 0.0));
1013    }
1014
1015    #[test]
1016    fn test_blend_exclusion() {
1017        let src = Rgba::new(0.5, 0.5, 0.5, 1.0);
1018        let dst = Rgba::new(0.5, 0.5, 0.5, 1.0);
1019        // Exclusion with same colour: s + d - 2sd = 0.5 + 0.5 - 2*0.25 = 0.5.
1020        let result = BlendMode::Exclusion.blend(src, dst);
1021        assert!(approx_eq(result.r, 0.5));
1022    }
1023
1024    // ── Layer ─────────────────────────────────────────────────────────────────
1025
1026    #[test]
1027    fn test_layer_new_transparent() {
1028        let layer = Layer::new("base", 4, 4);
1029        for px in &layer.pixels {
1030            assert!(approx_eq(px.a, 0.0));
1031        }
1032    }
1033
1034    #[test]
1035    fn test_layer_fill() {
1036        let color = Rgba::new(0.5, 0.0, 1.0, 1.0);
1037        let layer = Layer::new("l", 2, 2).fill(color);
1038        for px in &layer.pixels {
1039            assert!(rgba_approx(*px, color));
1040        }
1041    }
1042
1043    #[test]
1044    fn test_layer_pixel_at_in_bounds() {
1045        let layer = Layer::new("l", 4, 4).fill(Rgba::white());
1046        let px = layer.pixel_at(2, 3);
1047        assert!(px.is_some());
1048        assert!(rgba_approx(px.expect("should be Some"), Rgba::white()));
1049    }
1050
1051    #[test]
1052    fn test_layer_pixel_at_out_of_bounds() {
1053        let layer = Layer::new("l", 4, 4);
1054        assert!(layer.pixel_at(4, 0).is_none());
1055        assert!(layer.pixel_at(0, 4).is_none());
1056    }
1057
1058    #[test]
1059    fn test_layer_set_pixel() {
1060        let mut layer = Layer::new("l", 4, 4);
1061        let ok = layer.set_pixel(1, 2, Rgba::white());
1062        assert!(ok);
1063        assert!(rgba_approx(
1064            layer.pixel_at(1, 2).expect("should be Some"),
1065            Rgba::white()
1066        ));
1067    }
1068
1069    #[test]
1070    fn test_layer_set_pixel_out_of_bounds() {
1071        let mut layer = Layer::new("l", 4, 4);
1072        assert!(!layer.set_pixel(10, 10, Rgba::white()));
1073    }
1074
1075    // ── TileCompositor ────────────────────────────────────────────────────────
1076
1077    #[test]
1078    fn test_compositor_empty_layers() {
1079        let comp = TileCompositor::new(2, 2, Rgba::black());
1080        let mut layers: Vec<Layer> = vec![];
1081        let out = comp.composite(&mut layers);
1082        assert_eq!(out.len(), 4);
1083        for px in out {
1084            assert!(rgba_approx(px, Rgba::black()));
1085        }
1086    }
1087
1088    #[test]
1089    fn test_compositor_single_opaque_layer() {
1090        let comp = TileCompositor::new(2, 2, Rgba::transparent());
1091        let layer = Layer::new("l", 2, 2).fill(Rgba::white());
1092        let mut layers = vec![layer];
1093        let out = comp.composite(&mut layers);
1094        for px in out {
1095            assert!(rgba_approx(px, Rgba::white()));
1096        }
1097    }
1098
1099    #[test]
1100    fn test_compositor_z_order_respected() {
1101        let comp = TileCompositor::new(1, 1, Rgba::transparent());
1102        let bottom = Layer::new("bottom", 1, 1).fill(Rgba::new(0.0, 0.0, 1.0, 1.0));
1103        let top = {
1104            let mut l = Layer::new("top", 1, 1);
1105            l.z_order = 1;
1106            l.fill(Rgba::new(1.0, 0.0, 0.0, 1.0))
1107        };
1108        let mut layers = vec![top, bottom]; // note reversed insertion order
1109        let out = comp.composite(&mut layers);
1110        // Top (red, z=1) should completely cover bottom (blue, z=0).
1111        assert!(approx_eq(out[0].r, 1.0));
1112        assert!(approx_eq(out[0].b, 0.0));
1113    }
1114
1115    #[test]
1116    fn test_compositor_invisible_layer_skipped() {
1117        let comp = TileCompositor::new(1, 1, Rgba::black());
1118        let mut invisible = Layer::new("inv", 1, 1).fill(Rgba::white());
1119        invisible.visible = false;
1120        let mut layers = vec![invisible];
1121        let out = comp.composite(&mut layers);
1122        // Background should remain black.
1123        assert!(rgba_approx(out[0], Rgba::black()));
1124    }
1125
1126    #[test]
1127    fn test_compositor_opacity_scales_alpha() {
1128        let comp = TileCompositor::new(1, 1, Rgba::transparent());
1129        let mut layer = Layer::new("l", 1, 1).fill(Rgba::new(1.0, 0.0, 0.0, 1.0));
1130        layer.opacity = 0.5;
1131        let mut layers = vec![layer];
1132        let out = comp.composite(&mut layers);
1133        // Red channel will be 0.5 * 1.0 (src.a = 0.5 → src_over with transparent).
1134        assert!(approx_eq(out[0].a, 0.5));
1135    }
1136
1137    #[test]
1138    fn test_compositor_to_rgba_bytes_length() {
1139        let pixels = vec![Rgba::white(); 10];
1140        let bytes = TileCompositor::to_rgba_bytes(&pixels);
1141        assert_eq!(bytes.len(), 40);
1142    }
1143
1144    #[test]
1145    fn test_compositor_to_rgb_bytes_length() {
1146        let pixels = vec![Rgba::white(); 10];
1147        let bytes = TileCompositor::to_rgb_bytes(&pixels);
1148        assert_eq!(bytes.len(), 30);
1149    }
1150
1151    #[test]
1152    fn test_compositor_to_rgba_bytes_values() {
1153        let pixels = vec![Rgba::from_u8(100, 150, 200, 255)];
1154        let bytes = TileCompositor::to_rgba_bytes(&pixels);
1155        assert_eq!(bytes[0], 100);
1156        assert_eq!(bytes[1], 150);
1157        assert_eq!(bytes[2], 200);
1158        assert_eq!(bytes[3], 255);
1159    }
1160
1161    #[test]
1162    fn test_compositor_stats_mean_r() {
1163        let pixels = vec![Rgba::new(0.0, 0.0, 0.0, 1.0), Rgba::new(1.0, 0.0, 0.0, 1.0)];
1164        let s = TileCompositor::stats(&pixels);
1165        assert!(approx_eq(s.mean_r, 0.5));
1166    }
1167
1168    #[test]
1169    fn test_compositor_stats_transparent_count() {
1170        let pixels = vec![
1171            Rgba::new(0.0, 0.0, 0.0, 0.0),
1172            Rgba::new(0.0, 0.0, 0.0, 0.0),
1173            Rgba::new(1.0, 1.0, 1.0, 1.0),
1174        ];
1175        let s = TileCompositor::stats(&pixels);
1176        assert_eq!(s.transparent_pixel_count, 2);
1177    }
1178
1179    #[test]
1180    fn test_compositor_stats_empty() {
1181        let s = TileCompositor::stats(&[]);
1182        assert!(approx_eq(s.min_r, 0.0));
1183        assert!(approx_eq(s.mean_r, 0.0));
1184    }
1185
1186    #[test]
1187    fn test_compositor_stats_min_max() {
1188        let pixels = vec![Rgba::new(0.1, 0.2, 0.3, 0.4), Rgba::new(0.9, 0.8, 0.7, 0.6)];
1189        let s = TileCompositor::stats(&pixels);
1190        assert!(approx_eq(s.min_r, 0.1));
1191        assert!(approx_eq(s.max_r, 0.9));
1192        assert!(approx_eq(s.min_g, 0.2));
1193        assert!(approx_eq(s.max_g, 0.8));
1194    }
1195
1196    // ── ColorMatrix ───────────────────────────────────────────────────────────
1197
1198    #[test]
1199    fn test_color_matrix_identity_noop() {
1200        let m = ColorMatrix::identity();
1201        let px = Rgba::new(0.4, 0.6, 0.2, 0.8);
1202        let out = m.apply(px);
1203        assert!(rgba_approx(out, px));
1204    }
1205
1206    #[test]
1207    fn test_color_matrix_brightness_doubles() {
1208        let m = ColorMatrix::brightness(2.0);
1209        let px = Rgba::new(0.2, 0.3, 0.4, 1.0);
1210        let out = m.apply(px);
1211        // 0.4, 0.6, 0.8 (clamped at 1.0 if needed)
1212        assert!(approx_eq(out.r, 0.4));
1213        assert!(approx_eq(out.g, 0.6));
1214        assert!(approx_eq(out.b, 0.8));
1215    }
1216
1217    #[test]
1218    fn test_color_matrix_brightness_zero() {
1219        let m = ColorMatrix::brightness(0.0);
1220        let px = Rgba::new(1.0, 1.0, 1.0, 1.0);
1221        let out = m.apply(px);
1222        assert!(approx_eq(out.r, 0.0));
1223        assert!(approx_eq(out.g, 0.0));
1224        assert!(approx_eq(out.b, 0.0));
1225        assert!(approx_eq(out.a, 1.0));
1226    }
1227
1228    #[test]
1229    fn test_color_matrix_invert() {
1230        let m = ColorMatrix::invert();
1231        let px = Rgba::new(0.2, 0.5, 0.8, 1.0);
1232        let out = m.apply(px);
1233        assert!(approx_eq(out.r, 0.8));
1234        assert!(approx_eq(out.g, 0.5));
1235        assert!(approx_eq(out.b, 0.2));
1236        assert!(approx_eq(out.a, 1.0));
1237    }
1238
1239    #[test]
1240    fn test_color_matrix_invert_twice_is_identity() {
1241        let m = ColorMatrix::invert().compose(&ColorMatrix::invert());
1242        let px = Rgba::new(0.3, 0.6, 0.9, 0.7);
1243        let out = m.apply(px);
1244        assert!(rgba_approx(out, px));
1245    }
1246
1247    #[test]
1248    fn test_color_matrix_grayscale_equal_channels() {
1249        let m = ColorMatrix::grayscale();
1250        let px = Rgba::new(0.6, 0.4, 0.2, 1.0);
1251        let out = m.apply(px);
1252        // All output RGB channels equal (grayscale).
1253        assert!(approx_eq(out.r, out.g));
1254        assert!(approx_eq(out.g, out.b));
1255    }
1256
1257    #[test]
1258    fn test_color_matrix_grayscale_white_stays_white() {
1259        let m = ColorMatrix::grayscale();
1260        let out = m.apply(Rgba::white());
1261        assert!(approx_eq(out.r, 1.0));
1262        assert!(approx_eq(out.g, 1.0));
1263        assert!(approx_eq(out.b, 1.0));
1264    }
1265
1266    #[test]
1267    fn test_color_matrix_compose_identity() {
1268        let any_m = ColorMatrix::brightness(1.5);
1269        let composed = any_m.compose(&ColorMatrix::identity());
1270        let px = Rgba::new(0.3, 0.4, 0.5, 1.0);
1271        let a = any_m.apply(px);
1272        let b = composed.apply(px);
1273        assert!(rgba_approx(a, b));
1274    }
1275
1276    #[test]
1277    fn test_color_matrix_compose_identity_left() {
1278        let any_m = ColorMatrix::contrast(1.5);
1279        let composed = ColorMatrix::identity().compose(&any_m);
1280        let px = Rgba::new(0.3, 0.4, 0.5, 1.0);
1281        let a = any_m.apply(px);
1282        let b = composed.apply(px);
1283        assert!(rgba_approx(a, b));
1284    }
1285
1286    #[test]
1287    fn test_color_matrix_saturation_zero_is_grayscale() {
1288        let gray = ColorMatrix::grayscale();
1289        let sat0 = ColorMatrix::saturation(0.0);
1290        let px = Rgba::new(0.5, 0.3, 0.7, 1.0);
1291        let a = gray.apply(px);
1292        let b = sat0.apply(px);
1293        // Both should produce the same luma value in all channels.
1294        assert!(approx_eq(a.r, b.r));
1295    }
1296
1297    #[test]
1298    fn test_color_matrix_contrast_identity() {
1299        let m = ColorMatrix::contrast(1.0);
1300        let px = Rgba::new(0.4, 0.6, 0.8, 1.0);
1301        assert!(rgba_approx(m.apply(px), px));
1302    }
1303
1304    #[test]
1305    fn test_color_matrix_sepia_non_zero() {
1306        let m = ColorMatrix::sepia();
1307        let px = Rgba::new(0.5, 0.5, 0.5, 1.0);
1308        let out = m.apply(px);
1309        assert!(out.r > 0.0);
1310        assert!(out.g > 0.0);
1311        assert!(out.b > 0.0);
1312    }
1313
1314    // ── TileRenderPipeline ─────────────────────────────────────────────────
1315
1316    #[test]
1317    fn test_pipeline_render_byte_length() {
1318        let pipeline = TileRenderPipeline::new(4, 4);
1319        let mut layers = vec![Layer::new("l", 4, 4).fill(Rgba::white())];
1320        let bytes = pipeline.render(&mut layers);
1321        assert_eq!(bytes.len(), 4 * 4 * 4); // 64
1322    }
1323
1324    #[test]
1325    fn test_pipeline_render_with_matrix() {
1326        let pipeline = TileRenderPipeline::new(2, 2);
1327        let mut layers = vec![Layer::new("l", 2, 2).fill(Rgba::new(0.5, 0.5, 0.5, 1.0))];
1328        let matrix = ColorMatrix::brightness(2.0);
1329        let bytes = pipeline.render_with_matrix(&mut layers, &matrix);
1330        // Bytes should be 16 (2x2 * 4 channels).
1331        assert_eq!(bytes.len(), 16);
1332        // First pixel R should be clamped to 255.
1333        assert_eq!(bytes[0], 255);
1334    }
1335
1336    #[test]
1337    fn test_pipeline_add_shader() {
1338        let mut pipeline = TileRenderPipeline::new(4, 4);
1339        pipeline.add_shader("my_shader", "@compute fn main() {}");
1340        assert!(
1341            pipeline
1342                .shader_registry
1343                .watcher
1344                .get_source("my_shader")
1345                .is_some()
1346        );
1347    }
1348
1349    #[test]
1350    fn test_pipeline_update_shader() {
1351        let mut pipeline = TileRenderPipeline::new(4, 4);
1352        pipeline.add_shader("s", "@compute fn main() {}");
1353        let ok = pipeline.update_shader("s", "@compute fn main_v2() {}");
1354        assert!(ok);
1355        assert_eq!(
1356            pipeline.shader_registry.watcher.source_version("s"),
1357            Some(2)
1358        );
1359    }
1360
1361    #[test]
1362    fn test_pipeline_update_unknown_shader() {
1363        let mut pipeline = TileRenderPipeline::new(4, 4);
1364        assert!(!pipeline.update_shader("ghost", "@compute fn x() {}"));
1365    }
1366
1367    #[test]
1368    fn test_pipeline_render_empty_layers() {
1369        let pipeline = TileRenderPipeline::new(3, 3);
1370        let mut layers: Vec<Layer> = vec![];
1371        let bytes = pipeline.render(&mut layers);
1372        assert_eq!(bytes.len(), 3 * 3 * 4); // 36
1373    }
1374}