Skip to main content

stipple_render/
software.rs

1//! The software rendering backend: rasterizes a [`Scene`] on the CPU via
2//! `oxideav-raster` into a [`Pixmap`] ready for any [`Surface`] to present.
3
4use crate::{Color, Pixmap, Scene};
5use oxideav_raster::Renderer;
6use stipple_geometry::{PhysicalSize, Rect, ScaleFactor};
7
8/// Rasterizes scenes on the CPU. Cheap to construct and reusable across
9/// frames; the underlying `oxideav` renderer also caches per-subtree work.
10#[derive(Debug, Default)]
11pub struct SoftwareRenderer {
12    background: Color,
13}
14
15impl SoftwareRenderer {
16    pub fn new() -> Self {
17        Self::default()
18    }
19
20    /// Set the canvas clear color (default: transparent).
21    pub fn with_background(mut self, color: Color) -> Self {
22        self.background = color;
23        self
24    }
25
26    /// Rasterize `scene` at the given `scale`, returning a physical-pixel
27    /// [`Pixmap`].
28    ///
29    /// The scene's logical size combined with `scale` determines the output
30    /// resolution; the rasterizer maps logical → physical via the frame's
31    /// view box, so widgets are authored once in logical pixels and stay
32    /// crisp at any DPI.
33    pub fn render(&self, scene: Scene, scale: ScaleFactor) -> Pixmap {
34        let physical = scale.to_physical(scene.logical_size());
35        self.render_at(scene, physical)
36    }
37
38    /// Rasterize `scene` into a buffer of exactly `physical` pixels.
39    pub fn render_at(&self, scene: Scene, physical: PhysicalSize) -> Pixmap {
40        let frame = scene.into_vector_frame();
41        self.rasterize_frame(frame, physical)
42    }
43
44    /// Rasterize only `view` — a logical sub-rect of `scene` — into a buffer of
45    /// exactly `physical` pixels, which the caller picks to match the integer
46    /// device-pixel rect it will blit/present (so there is no resampling seam).
47    ///
48    /// Pixels outside `view` are clipped by the canvas bounds; the background
49    /// fill makes the region opaque, so the result can be composited with a
50    /// straight copy (see [`Pixmap::blit`]). This is the area-repaint path: a
51    /// hover change re-rasterizes two small button rects instead of the window.
52    pub fn render_region(&self, scene: Scene, view: Rect, physical: PhysicalSize) -> Pixmap {
53        let frame = scene.into_vector_frame_region(view);
54        self.rasterize_frame(frame, physical)
55    }
56
57    /// Run a lowered frame through the rasterizer and copy the single packed
58    /// RGBA plane into a tightly-packed [`Pixmap`] of `physical` pixels.
59    fn rasterize_frame(&self, frame: oxideav_core::VectorFrame, physical: PhysicalSize) -> Pixmap {
60        let (w, h) = (physical.width.max(1), physical.height.max(1));
61        let mut renderer = Renderer::new(w, h);
62        renderer.background = self.background.to_oxideav();
63        let video = renderer.render(&frame);
64
65        // `render` always produces a single packed-RGBA plane with
66        // `stride == w * 4`; copy it into a tightly-packed Pixmap.
67        let plane = &video.planes[0];
68        let dst_stride = w as usize * 4;
69        let total = dst_stride * h as usize;
70        let mut data = vec![0u8; total];
71        if plane.stride == dst_stride {
72            data.copy_from_slice(&plane.data[..total]);
73        } else {
74            for y in 0..h as usize {
75                let src = &plane.data[y * plane.stride..y * plane.stride + dst_stride];
76                data[y * dst_stride..(y + 1) * dst_stride].copy_from_slice(src);
77            }
78        }
79        Pixmap::from_rgba8(PhysicalSize::new(w, h), data)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use stipple_geometry::{Rect, Size};
87
88    #[test]
89    fn fills_pixel_at_expected_location() {
90        // 100x100 logical, 1x scale. Fill the whole canvas red, then check a
91        // center pixel made it through the oxideav rasterizer.
92        let mut scene = Scene::new(Size::new(100.0, 100.0));
93        scene.fill_rect(
94            Rect::from_xywh(0.0, 0.0, 100.0, 100.0),
95            Color::rgb(255, 0, 0),
96        );
97        let pm = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
98
99        assert_eq!(pm.size(), PhysicalSize::new(100, 100));
100        let [r, g, b, a] = pm.pixel(50, 50).unwrap();
101        assert_eq!((r, g, b, a), (255, 0, 0, 255));
102    }
103
104    #[test]
105    fn hidpi_scale_doubles_resolution() {
106        let scene = Scene::new(Size::new(100.0, 80.0));
107        let pm = SoftwareRenderer::new().render(scene, ScaleFactor::new(2.0));
108        assert_eq!(pm.size(), PhysicalSize::new(200, 160));
109    }
110
111    #[test]
112    fn render_region_matches_full_render_within_the_region() {
113        // A scene with two distinct colored squares far apart.
114        let make = || {
115            let mut s = Scene::new(Size::new(100.0, 100.0));
116            s.fill_rect(
117                Rect::from_xywh(10.0, 10.0, 20.0, 20.0),
118                Color::rgb(255, 0, 0),
119            );
120            s.fill_rect(
121                Rect::from_xywh(70.0, 70.0, 20.0, 20.0),
122                Color::rgb(0, 0, 255),
123            );
124            s
125        };
126        let r = SoftwareRenderer::new().with_background(Color::rgb(0, 0, 0));
127        let full = r.render(make(), ScaleFactor::IDENTITY);
128
129        // Re-render just the 1x-scale region around the red square.
130        let view = Rect::from_xywh(10.0, 10.0, 20.0, 20.0);
131        let region = r.render_region(make(), view, PhysicalSize::new(20, 20));
132        assert_eq!(region.size(), PhysicalSize::new(20, 20));
133
134        // Every region pixel equals the full render at the same absolute spot.
135        for y in 0..20u32 {
136            for x in 0..20u32 {
137                assert_eq!(
138                    region.pixel(x, y),
139                    full.pixel(10 + x, 10 + y),
140                    "mismatch at region ({x},{y})"
141                );
142            }
143        }
144        // And the region actually captured the red square, not background.
145        assert_eq!(region.pixel(10, 10), Some([255, 0, 0, 255]));
146    }
147}