Skip to main content

trueno_viz/
framebuffer.rs

1//! Core framebuffer for pixel rendering.
2//!
3//! Provides a SIMD-aligned RGBA pixel buffer optimized for hardware-accelerated operations.
4//! Uses trueno for SIMD-accelerated vector operations where applicable.
5
6use crate::color::Rgba;
7use crate::error::{Error, Result};
8use trueno::{Backend, Vector};
9
10/// Alignment for SIMD operations (64 bytes for AVX-512).
11const SIMD_ALIGNMENT: usize = 64;
12
13/// SIMD-aligned framebuffer for efficient pixel operations.
14///
15/// The pixel buffer is aligned to 64 bytes for optimal SIMD performance
16/// on AVX-512 and other wide SIMD architectures.
17///
18/// # SIMD Acceleration
19///
20/// Operations like `clear()`, `fill_rect()`, and `blend()` are SIMD-accelerated
21/// using trueno's automatic backend selection (SSE2/AVX2/AVX512/NEON).
22#[derive(Debug, Clone)]
23pub struct Framebuffer {
24    /// Width in pixels.
25    width: u32,
26    /// Height in pixels.
27    height: u32,
28    /// RGBA pixels in row-major order.
29    /// Each pixel is 4 bytes: [R, G, B, A].
30    /// Aligned to SIMD_ALIGNMENT bytes.
31    pixels: Vec<u8>,
32    /// Stride in bytes (may include padding for alignment).
33    stride: usize,
34}
35
36impl Framebuffer {
37    /// Create a new framebuffer with the given dimensions.
38    ///
39    /// The buffer is aligned to 64 bytes for optimal SIMD performance.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if width or height is zero.
44    ///
45    /// # Example
46    ///
47    /// ```
48    /// use trueno_viz::framebuffer::Framebuffer;
49    ///
50    /// let fb = Framebuffer::new(800, 600).unwrap();
51    /// assert_eq!(fb.width(), 800);
52    /// assert_eq!(fb.height(), 600);
53    /// ```
54    pub fn new(width: u32, height: u32) -> Result<Self> {
55        if width == 0 || height == 0 {
56            return Err(Error::InvalidDimensions { width, height });
57        }
58
59        // Calculate stride with alignment padding
60        let row_bytes = (width as usize) * 4;
61        let stride = (row_bytes + SIMD_ALIGNMENT - 1) & !(SIMD_ALIGNMENT - 1);
62
63        let size = stride * (height as usize);
64
65        // Allocate with extra space for alignment
66        let mut pixels = Vec::with_capacity(size + SIMD_ALIGNMENT);
67        pixels.resize(size, 0);
68
69        Ok(Self { width, height, pixels, stride })
70    }
71
72    /// Get the width in pixels.
73    #[must_use]
74    pub const fn width(&self) -> u32 {
75        self.width
76    }
77
78    /// Get the height in pixels.
79    #[must_use]
80    pub const fn height(&self) -> u32 {
81        self.height
82    }
83
84    /// Get the stride (row width in bytes, including any padding).
85    #[must_use]
86    pub const fn stride(&self) -> usize {
87        self.stride
88    }
89
90    /// Get the total number of pixels.
91    #[must_use]
92    pub const fn pixel_count(&self) -> usize {
93        (self.width as usize) * (self.height as usize)
94    }
95
96    /// Get the raw pixel data as a slice.
97    #[must_use]
98    pub fn pixels(&self) -> &[u8] {
99        &self.pixels
100    }
101
102    /// Get the raw pixel data as a mutable slice.
103    pub fn pixels_mut(&mut self) -> &mut [u8] {
104        &mut self.pixels
105    }
106
107    /// Get a row of pixels as a slice.
108    #[must_use]
109    pub fn row(&self, y: u32) -> Option<&[u8]> {
110        if y >= self.height {
111            return None;
112        }
113        let start = (y as usize) * self.stride;
114        let end = start + (self.width as usize) * 4;
115        Some(&self.pixels[start..end])
116    }
117
118    /// Get a row of pixels as a mutable slice.
119    pub fn row_mut(&mut self, y: u32) -> Option<&mut [u8]> {
120        if y >= self.height {
121            return None;
122        }
123        let start = (y as usize) * self.stride;
124        let end = start + (self.width as usize) * 4;
125        Some(&mut self.pixels[start..end])
126    }
127
128    /// Clear the framebuffer to a solid color.
129    ///
130    /// This operation is optimized for SIMD by processing 16 pixels at a time
131    /// (64 bytes = 16 RGBA pixels on AVX-512).
132    pub fn clear(&mut self, color: Rgba) {
133        let [r, g, b, a] = color.to_array();
134
135        // Create a 64-byte pattern (16 pixels) for SIMD-friendly memset
136        let pattern: [u8; 64] = {
137            let mut p = [0u8; 64];
138            for i in 0..16 {
139                p[i * 4] = r;
140                p[i * 4 + 1] = g;
141                p[i * 4 + 2] = b;
142                p[i * 4 + 3] = a;
143            }
144            p
145        };
146
147        // Fill each row (compiler will auto-vectorize this pattern copy)
148        for y in 0..self.height {
149            let row_start = (y as usize) * self.stride;
150            let row_end = row_start + (self.width as usize) * 4;
151            let row = &mut self.pixels[row_start..row_end];
152
153            // Copy pattern in 64-byte chunks
154            let mut offset = 0;
155            while offset + 64 <= row.len() {
156                row[offset..offset + 64].copy_from_slice(&pattern);
157                offset += 64;
158            }
159
160            // Handle remaining pixels
161            for chunk in row[offset..].chunks_exact_mut(4) {
162                chunk[0] = r;
163                chunk[1] = g;
164                chunk[2] = b;
165                chunk[3] = a;
166            }
167        }
168    }
169
170    /// Fill a rectangular region with a solid color.
171    ///
172    /// Coordinates are clamped to framebuffer bounds.
173    pub fn fill_rect(&mut self, x: u32, y: u32, w: u32, h: u32, color: Rgba) {
174        let x1 = x.min(self.width);
175        let y1 = y.min(self.height);
176        let x2 = (x + w).min(self.width);
177        let y2 = (y + h).min(self.height);
178
179        if x1 >= x2 || y1 >= y2 {
180            return;
181        }
182
183        let [r, g, b, a] = color.to_array();
184        let rect_width = (x2 - x1) as usize;
185
186        for row_y in y1..y2 {
187            let row_start = (row_y as usize) * self.stride + (x1 as usize) * 4;
188            let row = &mut self.pixels[row_start..row_start + rect_width * 4];
189
190            for chunk in row.chunks_exact_mut(4) {
191                chunk[0] = r;
192                chunk[1] = g;
193                chunk[2] = b;
194                chunk[3] = a;
195            }
196        }
197    }
198
199    /// Get the color at a specific pixel coordinate.
200    ///
201    /// Returns `None` if the coordinates are out of bounds.
202    #[must_use]
203    pub fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
204        if x >= self.width || y >= self.height {
205            return None;
206        }
207
208        let idx = self.pixel_index(x, y);
209        Some(Rgba::from_array([
210            self.pixels[idx],
211            self.pixels[idx + 1],
212            self.pixels[idx + 2],
213            self.pixels[idx + 3],
214        ]))
215    }
216
217    /// Set the color at a specific pixel coordinate.
218    ///
219    /// Does nothing if the coordinates are out of bounds.
220    pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) {
221        if x >= self.width || y >= self.height {
222            return;
223        }
224
225        let idx = self.pixel_index(x, y);
226        let [r, g, b, a] = color.to_array();
227        self.pixels[idx] = r;
228        self.pixels[idx + 1] = g;
229        self.pixels[idx + 2] = b;
230        self.pixels[idx + 3] = a;
231    }
232
233    /// Blend a color at a specific pixel coordinate using alpha blending.
234    ///
235    /// Uses the standard "over" compositing operation:
236    /// `out = src * src_alpha + dst * dst_alpha * (1 - src_alpha)`
237    pub fn blend_pixel(&mut self, x: u32, y: u32, color: Rgba) {
238        if x >= self.width || y >= self.height {
239            return;
240        }
241
242        let idx = self.pixel_index(x, y);
243        let src_a = f32::from(color.a) / 255.0;
244        let dst_a = f32::from(self.pixels[idx + 3]) / 255.0;
245        let out_a = src_a + dst_a * (1.0 - src_a);
246
247        if out_a > 0.0 {
248            let blend = |src: u8, dst: u8| -> u8 {
249                let src_f = f32::from(src) / 255.0;
250                let dst_f = f32::from(dst) / 255.0;
251                let out = (src_f * src_a + dst_f * dst_a * (1.0 - src_a)) / out_a;
252                (out * 255.0) as u8
253            };
254
255            self.pixels[idx] = blend(color.r, self.pixels[idx]);
256            self.pixels[idx + 1] = blend(color.g, self.pixels[idx + 1]);
257            self.pixels[idx + 2] = blend(color.b, self.pixels[idx + 2]);
258            self.pixels[idx + 3] = (out_a * 255.0) as u8;
259        }
260    }
261
262    /// Blend an entire framebuffer over this one using SIMD-accelerated operations.
263    ///
264    /// Uses trueno's Vector operations for alpha blending.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if the framebuffers have different dimensions.
269    pub fn blend_over(&mut self, other: &Framebuffer, alpha: f32) -> Result<()> {
270        if self.width != other.width || self.height != other.height {
271            return Err(Error::InvalidDimensions { width: other.width, height: other.height });
272        }
273
274        let alpha = alpha.clamp(0.0, 1.0);
275        let inv_alpha = 1.0 - alpha;
276
277        // Process row by row to maintain cache locality
278        for y in 0..self.height {
279            let row_start = (y as usize) * self.stride;
280            let row_pixels = (self.width as usize) * 4;
281
282            // Convert u8 rows to f32 vectors for SIMD processing
283            let dst_slice = &self.pixels[row_start..row_start + row_pixels];
284            let src_slice = &other.pixels[row_start..row_start + row_pixels];
285
286            // Convert to f32 for SIMD operations
287            let dst_f32: Vec<f32> = dst_slice.iter().map(|&b| f32::from(b)).collect();
288            let src_f32: Vec<f32> = src_slice.iter().map(|&b| f32::from(b)).collect();
289
290            // Use trueno vectors for SIMD blending
291            let dst_vec = Vector::from_vec(dst_f32);
292            let src_vec = Vector::from_vec(src_f32);
293
294            // out = src * alpha + dst * (1 - alpha)
295            if let (Ok(src_scaled), Ok(dst_scaled)) = (
296                src_vec.mul(&Vector::from_vec(vec![alpha; row_pixels])),
297                dst_vec.mul(&Vector::from_vec(vec![inv_alpha; row_pixels])),
298            ) {
299                if let Ok(result) = src_scaled.add(&dst_scaled) {
300                    // Convert back to u8
301                    let row = &mut self.pixels[row_start..row_start + row_pixels];
302                    for (i, &v) in result.as_slice().iter().enumerate() {
303                        row[i] = v.clamp(0.0, 255.0) as u8;
304                    }
305                }
306            }
307        }
308
309        Ok(())
310    }
311
312    /// Apply a brightness adjustment using SIMD-accelerated operations.
313    ///
314    /// `factor` of 1.0 is no change, < 1.0 darkens, > 1.0 brightens.
315    pub fn adjust_brightness(&mut self, factor: f32) {
316        let factor = factor.max(0.0);
317
318        for y in 0..self.height {
319            let row_start = (y as usize) * self.stride;
320            let row_pixels = (self.width as usize) * 4;
321            let row = &mut self.pixels[row_start..row_start + row_pixels];
322
323            // Process RGB channels, preserve alpha
324            for chunk in row.chunks_exact_mut(4) {
325                chunk[0] = (f32::from(chunk[0]) * factor).clamp(0.0, 255.0) as u8;
326                chunk[1] = (f32::from(chunk[1]) * factor).clamp(0.0, 255.0) as u8;
327                chunk[2] = (f32::from(chunk[2]) * factor).clamp(0.0, 255.0) as u8;
328                // Alpha unchanged
329            }
330        }
331    }
332
333    /// Get statistics about the framebuffer using SIMD-accelerated reduction.
334    ///
335    /// Returns (min_luminance, max_luminance, avg_luminance).
336    #[must_use]
337    pub fn luminance_stats(&self) -> (f32, f32, f32) {
338        let mut luminances = Vec::with_capacity(self.pixel_count());
339
340        for y in 0..self.height {
341            if let Some(row) = self.row(y) {
342                for chunk in row.chunks_exact(4) {
343                    // ITU-R BT.709 luminance formula
344                    let lum = 0.2126 * f32::from(chunk[0])
345                        + 0.7152 * f32::from(chunk[1])
346                        + 0.0722 * f32::from(chunk[2]);
347                    luminances.push(lum);
348                }
349            }
350        }
351
352        // Use trueno for SIMD-accelerated min/max/mean
353        let vec = Vector::from_vec(luminances);
354
355        let min = vec.min().unwrap_or(0.0);
356        let max = vec.max().unwrap_or(255.0);
357        let mean = vec.mean().unwrap_or(127.5);
358
359        (min, max, mean)
360    }
361
362    /// Calculate the byte index for a pixel coordinate.
363    #[inline]
364    fn pixel_index(&self, x: u32, y: u32) -> usize {
365        (y as usize) * self.stride + (x as usize) * 4
366    }
367
368    /// Check if the pixel buffer is properly aligned for SIMD.
369    #[must_use]
370    pub fn is_aligned(&self) -> bool {
371        self.pixels.as_ptr() as usize % SIMD_ALIGNMENT == 0
372    }
373
374    /// Get pixel data as a compact buffer without stride padding.
375    ///
376    /// This is useful for encoding to formats like PNG that expect
377    /// tightly-packed pixel data.
378    #[must_use]
379    pub fn to_compact_pixels(&self) -> Vec<u8> {
380        let row_bytes = (self.width as usize) * 4;
381
382        // If stride equals row bytes, return a clone
383        if self.stride == row_bytes {
384            return self.pixels[..row_bytes * (self.height as usize)].to_vec();
385        }
386
387        // Otherwise, copy row by row
388        let mut compact = Vec::with_capacity(row_bytes * (self.height as usize));
389        for y in 0..self.height {
390            let start = (y as usize) * self.stride;
391            compact.extend_from_slice(&self.pixels[start..start + row_bytes]);
392        }
393        compact
394    }
395
396    /// Get the selected SIMD backend.
397    #[must_use]
398    pub fn backend() -> Backend {
399        Backend::select_best()
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_new_framebuffer() {
409        let fb = Framebuffer::new(100, 50).expect("framebuffer creation should succeed");
410        assert_eq!(fb.width(), 100);
411        assert_eq!(fb.height(), 50);
412        assert_eq!(fb.pixel_count(), 5000);
413        // Stride should be >= width * 4
414        assert!(fb.stride() >= 400);
415    }
416
417    #[test]
418    fn test_invalid_dimensions() {
419        assert!(Framebuffer::new(0, 100).is_err());
420        assert!(Framebuffer::new(100, 0).is_err());
421        assert!(Framebuffer::new(0, 0).is_err());
422    }
423
424    #[test]
425    fn test_clear() {
426        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
427        fb.clear(Rgba::RED);
428
429        for y in 0..10 {
430            for x in 0..10 {
431                assert_eq!(fb.get_pixel(x, y), Some(Rgba::RED));
432            }
433        }
434    }
435
436    #[test]
437    fn test_clear_large() {
438        // Test with larger buffer to exercise SIMD paths
439        let mut fb = Framebuffer::new(1920, 1080).expect("framebuffer creation should succeed");
440        fb.clear(Rgba::BLUE);
441
442        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLUE));
443        assert_eq!(fb.get_pixel(959, 539), Some(Rgba::BLUE));
444        assert_eq!(fb.get_pixel(1919, 1079), Some(Rgba::BLUE));
445    }
446
447    #[test]
448    fn test_fill_rect() {
449        let mut fb = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
450        fb.clear(Rgba::WHITE);
451        fb.fill_rect(10, 10, 20, 20, Rgba::RED);
452
453        // Inside rect
454        assert_eq!(fb.get_pixel(15, 15), Some(Rgba::RED));
455        // Outside rect
456        assert_eq!(fb.get_pixel(5, 5), Some(Rgba::WHITE));
457    }
458
459    #[test]
460    fn test_set_get_pixel() {
461        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
462
463        fb.set_pixel(5, 5, Rgba::BLUE);
464        assert_eq!(fb.get_pixel(5, 5), Some(Rgba::BLUE));
465
466        // Out of bounds
467        assert_eq!(fb.get_pixel(100, 100), None);
468    }
469
470    #[test]
471    fn test_blend_pixel() {
472        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
473        fb.clear(Rgba::WHITE);
474
475        // Blend semi-transparent red
476        let semi_red = Rgba::new(255, 0, 0, 128);
477        fb.blend_pixel(5, 5, semi_red);
478
479        let result = fb.get_pixel(5, 5).expect("operation should succeed");
480        // Should be pinkish (blend of red and white)
481        assert!(result.r > 200);
482        assert!(result.g > 100);
483        assert!(result.b > 100);
484    }
485
486    #[test]
487    fn test_blend_over() {
488        let mut fb1 = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
489        let mut fb2 = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
490
491        fb1.clear(Rgba::BLACK);
492        fb2.clear(Rgba::WHITE);
493
494        fb1.blend_over(&fb2, 0.5).expect("operation should succeed");
495
496        let result = fb1.get_pixel(50, 50).expect("operation should succeed");
497        // Should be gray (50% blend)
498        assert!(result.r > 100 && result.r < 150);
499        assert!(result.g > 100 && result.g < 150);
500        assert!(result.b > 100 && result.b < 150);
501    }
502
503    #[test]
504    fn test_adjust_brightness() {
505        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
506        fb.clear(Rgba::rgb(100, 100, 100));
507
508        fb.adjust_brightness(2.0);
509
510        let result = fb.get_pixel(5, 5).expect("operation should succeed");
511        assert_eq!(result.r, 200);
512        assert_eq!(result.g, 200);
513        assert_eq!(result.b, 200);
514    }
515
516    #[test]
517    fn test_luminance_stats() {
518        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
519        fb.clear(Rgba::rgb(128, 128, 128));
520
521        let (min, max, mean) = fb.luminance_stats();
522
523        // All same color, so min ≈ max ≈ mean
524        assert!((min - max).abs() < 1.0);
525        assert!((mean - min).abs() < 1.0);
526    }
527
528    #[test]
529    fn test_row_access() {
530        let mut fb = Framebuffer::new(10, 5).expect("framebuffer creation should succeed");
531        fb.clear(Rgba::BLACK);
532
533        // Modify a row
534        if let Some(row) = fb.row_mut(2) {
535            for chunk in row.chunks_exact_mut(4) {
536                chunk[0] = 255; // Set red
537            }
538        }
539
540        // Verify
541        assert_eq!(fb.get_pixel(5, 2).expect("value should be present").r, 255);
542        assert_eq!(fb.get_pixel(5, 1).expect("value should be present").r, 0);
543    }
544
545    #[test]
546    fn test_backend_selection() {
547        let backend = Framebuffer::backend();
548        // Should return a valid backend
549        println!("Selected backend: {backend:?}");
550    }
551
552    #[test]
553    fn test_pixels_access() {
554        let fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
555        let pixels = fb.pixels();
556        // Buffer size is stride * height (includes alignment padding)
557        assert_eq!(pixels.len(), fb.stride() * 10);
558        // Stride is at least width * 4
559        assert!(pixels.len() >= 10 * 10 * 4);
560    }
561
562    #[test]
563    fn test_pixels_mut_access() {
564        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
565        // Buffer size is stride * height
566        let expected_size = fb.stride() * 10;
567        let pixels = fb.pixels_mut();
568        assert_eq!(pixels.len(), expected_size);
569        // Modify a pixel directly
570        pixels[0] = 255;
571        pixels[1] = 0;
572        pixels[2] = 0;
573        pixels[3] = 255;
574        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::RED));
575    }
576
577    #[test]
578    fn test_row_out_of_bounds() {
579        let fb = Framebuffer::new(10, 5).expect("framebuffer creation should succeed");
580        assert!(fb.row(5).is_none());
581        assert!(fb.row(100).is_none());
582    }
583
584    #[test]
585    fn test_row_mut_out_of_bounds() {
586        let mut fb = Framebuffer::new(10, 5).expect("framebuffer creation should succeed");
587        assert!(fb.row_mut(5).is_none());
588        assert!(fb.row_mut(100).is_none());
589    }
590
591    #[test]
592    fn test_fill_rect_empty() {
593        let mut fb = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
594        fb.clear(Rgba::WHITE);
595        // Zero-width rect
596        fb.fill_rect(10, 10, 0, 20, Rgba::RED);
597        assert_eq!(fb.get_pixel(10, 10), Some(Rgba::WHITE));
598
599        // Zero-height rect
600        fb.fill_rect(10, 10, 20, 0, Rgba::RED);
601        assert_eq!(fb.get_pixel(10, 10), Some(Rgba::WHITE));
602    }
603
604    #[test]
605    fn test_fill_rect_out_of_bounds() {
606        let mut fb = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
607        fb.clear(Rgba::WHITE);
608        // Rect starting outside
609        fb.fill_rect(200, 200, 20, 20, Rgba::RED);
610        assert_eq!(fb.get_pixel(50, 50), Some(Rgba::WHITE));
611    }
612
613    #[test]
614    fn test_blend_pixel_out_of_bounds() {
615        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
616        fb.clear(Rgba::WHITE);
617        // Out of bounds - should be no-op
618        fb.blend_pixel(100, 100, Rgba::RED);
619        fb.blend_pixel(10, 5, Rgba::RED);
620        fb.blend_pixel(5, 10, Rgba::RED);
621        // Original pixels unchanged
622        assert_eq!(fb.get_pixel(5, 5), Some(Rgba::WHITE));
623    }
624
625    #[test]
626    fn test_blend_over_dimension_mismatch() {
627        let mut fb1 = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
628        let fb2 = Framebuffer::new(50, 50).expect("framebuffer creation should succeed");
629
630        let result = fb1.blend_over(&fb2, 0.5);
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_is_aligned() {
636        let fb = Framebuffer::new(100, 100).expect("framebuffer creation should succeed");
637        // Just verify it returns a bool (alignment depends on allocator)
638        let _aligned = fb.is_aligned();
639    }
640
641    #[test]
642    fn test_to_compact_pixels() {
643        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
644        fb.clear(Rgba::RED);
645        let compact = fb.to_compact_pixels();
646        // Compact size should be width * height * 4 (no stride padding)
647        assert_eq!(compact.len(), 10 * 10 * 4);
648        // First pixel should be red
649        assert_eq!(&compact[0..4], &[255, 0, 0, 255]);
650    }
651
652    #[test]
653    fn test_to_compact_pixels_with_stride() {
654        // Width that requires stride padding (not a multiple of 16)
655        let mut fb = Framebuffer::new(10, 5).expect("framebuffer creation should succeed");
656        fb.clear(Rgba::GREEN);
657
658        let compact = fb.to_compact_pixels();
659        assert_eq!(compact.len(), 10 * 5 * 4);
660
661        // Verify all pixels are green (stride padding should be excluded)
662        for chunk in compact.chunks_exact(4) {
663            assert_eq!(chunk, &[0, 255, 0, 255]);
664        }
665    }
666
667    #[test]
668    fn test_set_pixel_out_of_bounds() {
669        let mut fb = Framebuffer::new(10, 10).expect("framebuffer creation should succeed");
670        // Out of bounds - should be no-op
671        fb.set_pixel(100, 100, Rgba::RED);
672        fb.set_pixel(10, 5, Rgba::RED);
673        fb.set_pixel(5, 10, Rgba::RED);
674    }
675}