Skip to main content

oxiui_render_soft/
dither.rs

1//! Bayer-matrix ordered dithering for reduced-bit output paths.
2//!
3//! Bayer dithering adds a spatially varying threshold to each pixel before
4//! quantisation, spreading quantisation error in a visually pleasing pattern
5//! rather than banding. The 8×8 matrix covers a large enough area to avoid
6//! coarse moiré artefacts.
7
8use crate::clip::ClipRect;
9use crate::framebuffer::{pack_rgba, unpack, Framebuffer};
10
11/// An 8×8 Bayer threshold matrix (indices 0–63), normalised to `[0, 1)`.
12///
13/// The canonical Bayer matrix values range from 0 to 63. We store the
14/// unnormalised integer values and normalise on use so callers can inspect
15/// the raw matrix if needed.
16#[derive(Clone, Debug)]
17pub struct BayerMatrix {
18    /// 8×8 raw threshold values in `[0, 63]`.
19    pub matrix: [[u8; 8]; 8],
20}
21
22impl BayerMatrix {
23    /// Construct the standard 8×8 Bayer threshold matrix.
24    ///
25    /// Values follow the recursive construction:
26    /// `B(2n) = [[4*B(n), 4*B(n)+2], [4*B(n)+3, 4*B(n)+1]]`
27    /// starting from `B(1) = [[0]]`.
28    pub fn standard_8x8() -> Self {
29        // Standard Bayer 8×8 matrix (0-indexed, range 0–63).
30        Self {
31            matrix: [
32                [0, 32, 8, 40, 2, 34, 10, 42],
33                [48, 16, 56, 24, 50, 18, 58, 26],
34                [12, 44, 4, 36, 14, 46, 6, 38],
35                [60, 28, 52, 20, 62, 30, 54, 22],
36                [3, 35, 11, 43, 1, 33, 9, 41],
37                [51, 19, 59, 27, 49, 17, 57, 25],
38                [15, 47, 7, 39, 13, 45, 5, 37],
39                [63, 31, 55, 23, 61, 29, 53, 21],
40            ],
41        }
42    }
43
44    /// Return the normalised threshold for position `(x, y)`, in `[0, 1)`.
45    ///
46    /// The position wraps modulo 8 so the matrix tiles over arbitrary regions.
47    pub fn threshold(&self, x: u32, y: u32) -> f32 {
48        let tx = (x % 8) as usize;
49        let ty = (y % 8) as usize;
50        self.matrix[ty][tx] as f32 / 64.0
51    }
52}
53
54impl Default for BayerMatrix {
55    fn default() -> Self {
56        Self::standard_8x8()
57    }
58}
59
60/// Apply Bayer ordered dithering to the RGBA pixels within `rect` in `fb`.
61///
62/// Each colour channel is shifted right by `bits_to_drop` bits, dithered
63/// using the 8×8 Bayer matrix, and then clamped back to `[0, 255]`. This
64/// simulates a display with `8 - bits_to_drop` bits per channel.
65///
66/// A `bits_to_drop` of 0 is a no-op. Values > 7 are clamped to 7.
67pub fn ordered_dither_rgba(fb: &mut Framebuffer, rect: ClipRect, bits_to_drop: u32) {
68    if bits_to_drop == 0 {
69        return;
70    }
71    let bits = bits_to_drop.min(7);
72    let step = (1u32 << bits) as f32; // quantisation step size
73    let matrix = BayerMatrix::standard_8x8();
74
75    let fb_w = fb.width();
76    let fb_h = fb.height();
77    let x0 = rect.x0.max(0) as u32;
78    let y0 = rect.y0.max(0) as u32;
79    let x1 = (rect.x1 as u32).min(fb_w);
80    let y1 = (rect.y1 as u32).min(fb_h);
81
82    for y in y0..y1 {
83        for x in x0..x1 {
84            let Some(px) = fb.get(x, y) else { continue };
85            let (r, g, b, a) = unpack(px);
86            let thresh = matrix.threshold(x, y); // in [0, 1)
87                                                 // Add dither offset, then quantise.
88            let dither_channel = |c: u8| -> u8 {
89                let v = c as f32 + thresh * step;
90                // Quantise: round down to the nearest multiple of `step`.
91                let q = (v / step).floor() * step;
92                q.clamp(0.0, 255.0) as u8
93            };
94            let nr = dither_channel(r);
95            let ng = dither_channel(g);
96            let nb = dither_channel(b);
97            fb.set(x, y, pack_rgba(nr, ng, nb, a));
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Tests
104// ---------------------------------------------------------------------------
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::framebuffer::Framebuffer;
110
111    fn grey_fb(w: u32, h: u32, level: u8) -> Framebuffer {
112        use oxiui_core::Color;
113        Framebuffer::with_fill(w, h, Color(level, level, level, 255))
114    }
115
116    #[test]
117    fn bayer_matrix_range() {
118        let m = BayerMatrix::standard_8x8();
119        let mut all_values = std::collections::HashSet::new();
120        for row in &m.matrix {
121            for &v in row {
122                all_values.insert(v);
123            }
124        }
125        // All 64 values 0–63 must be present (bijection property).
126        assert_eq!(
127            all_values.len(),
128            64,
129            "8x8 Bayer matrix must have 64 unique values"
130        );
131        assert!(!all_values.contains(&64), "max value must be 63");
132    }
133
134    #[test]
135    fn bayer_threshold_range() {
136        let m = BayerMatrix::standard_8x8();
137        for y in 0..8 {
138            for x in 0..8 {
139                let t = m.threshold(x, y);
140                assert!(
141                    (0.0..1.0).contains(&t),
142                    "threshold {t} out of range at ({x},{y})"
143                );
144            }
145        }
146    }
147
148    #[test]
149    fn bayer_determinism() {
150        // Same input → same dithered output on repeated calls.
151        use crate::clip::ClipRect;
152        let mut fb1 = grey_fb(8, 8, 100);
153        let mut fb2 = grey_fb(8, 8, 100);
154        let rect = ClipRect::full(8, 8);
155        ordered_dither_rgba(&mut fb1, rect, 2);
156        ordered_dither_rgba(&mut fb2, rect, 2);
157        for y in 0..8 {
158            for x in 0..8 {
159                assert_eq!(fb1.get(x, y), fb2.get(x, y), "mismatch at ({x},{y})");
160            }
161        }
162    }
163
164    #[test]
165    fn zero_bits_noop() {
166        use crate::clip::ClipRect;
167        use oxiui_core::Color;
168        let mut fb = Framebuffer::with_fill(4, 4, Color(123, 45, 67, 200));
169        let original = fb.get(0, 0);
170        ordered_dither_rgba(&mut fb, ClipRect::full(4, 4), 0);
171        assert_eq!(fb.get(0, 0), original, "0 bits_to_drop must be a no-op");
172    }
173
174    #[test]
175    fn dither_changes_some_pixels() {
176        // With bits_to_drop=4, step=16. Level=10 → [10, 10+15]. Some cross boundary at 16.
177        // The Bayer matrix has entries from 0 to 63/64, so 10 + (x/64)*16 ranges from 10 to 24.9.
178        // floor(10/16)*16 = 0, floor(24/16)*16 = 16. So some pixels will change.
179        use crate::clip::ClipRect;
180        let original = grey_fb(8, 8, 10);
181        let mut dithered = grey_fb(8, 8, 10);
182        ordered_dither_rgba(&mut dithered, ClipRect::full(8, 8), 4);
183        let mut changed = 0u32;
184        for y in 0..8 {
185            for x in 0..8 {
186                if original.get(x, y) != dithered.get(x, y) {
187                    changed += 1;
188                }
189            }
190        }
191        assert!(
192            changed > 0,
193            "dithering should change at least some pixels (level=10, bits=4)"
194        );
195    }
196}