Skip to main content

oxframe_render/
cpu.rs

1use std::collections::BTreeMap;
2
3use kurbo::{Affine, BezPath, PathEl, Shape};
4use oxframe_2d::{
5    DrawItem, Layer, LineCap, LineJoin, Paint as MPaint, Primitive, RasterImage, SceneGraph,
6    StrokeStyle,
7};
8use oxframe_core::{BlendMode, Color, Gradient, GradientKind};
9use tiny_skia::{
10    FillRule, FilterQuality, GradientStop, LinearGradient, Mask, Paint, PathBuilder, Pixmap,
11    PixmapPaint, RadialGradient, SpreadMode, Stroke, StrokeDash, Transform,
12};
13
14use crate::blur::gaussian_blur;
15use crate::{Image, RenderOptions, Renderer, scene_to_pixels};
16
17/// tiny-skia backend. Deterministic: same scene, same bytes.
18#[derive(Default)]
19pub struct CpuRenderer {
20    pixmap: Option<Pixmap>,
21    images: BTreeMap<u64, Pixmap>,
22    /// Reused layer buffers.
23    pool: Vec<Pixmap>,
24}
25
26impl CpuRenderer {
27    /// A CPU renderer; buffers are reused between frames.
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Renders draw items (with `view` mapping scene → pixels) into a
33    /// transparent pixmap. Used by the GPU renderer for filtered layers.
34    pub fn render_items(&mut self, items: &[DrawItem], view: Affine, w: u32, h: u32) -> Pixmap {
35        let mut target = Pixmap::new(w.max(1), h.max(1)).expect("valid pixmap size");
36        self.draw_items(&mut target, items, view);
37        target
38    }
39
40    fn take_layer(&mut self, w: u32, h: u32) -> Pixmap {
41        let (w, h) = (w.max(1), h.max(1));
42        match self
43            .pool
44            .iter()
45            .position(|p| p.width() == w && p.height() == h)
46        {
47            Some(i) => {
48                let mut p = self.pool.swap_remove(i);
49                p.fill(tiny_skia::Color::TRANSPARENT);
50                p
51            }
52            None => Pixmap::new(w, h).expect("valid pixmap size"),
53        }
54    }
55
56    fn draw_items(&mut self, base: &mut Pixmap, items: &[DrawItem], view: Affine) {
57        let full = Region {
58            x: 0,
59            y: 0,
60            w: base.width(),
61            h: base.height(),
62        };
63        // Open layers: buffer, the region of the frame it covers, layer, transform.
64        let mut stack: Vec<(Pixmap, Region, Layer, Affine)> = Vec::new();
65        for (i, item) in items.iter().enumerate() {
66            let tf = view * item.transform;
67            match &item.prim {
68                Primitive::PushLayer(layer) => {
69                    // Only the area the layer's content (plus blur and
70                    // shadow reach) can touch is allocated and filtered.
71                    let parent = stack.last().map_or(full, |(_, r, _, _)| *r);
72                    let region = layer_region(items, i, view, parent).unwrap_or(Region {
73                        x: parent.x,
74                        y: parent.y,
75                        w: 1,
76                        h: 1,
77                    });
78                    let buf = self.take_layer(region.w, region.h);
79                    stack.push((buf, region, layer.clone(), tf));
80                }
81                Primitive::PopLayer => {
82                    let Some((buf, region, layer, ltf)) = stack.pop() else {
83                        continue;
84                    };
85                    let (dst, dst_region) = match stack.last_mut() {
86                        Some((p, r, _, _)) => (p, *r),
87                        None => (&mut *base, full),
88                    };
89                    composite(dst, dst_region, buf, region, &layer, ltf, &mut self.pool);
90                }
91                prim => {
92                    let (dst, region) = match stack.last_mut() {
93                        Some((p, r, _, _)) => (p, *r),
94                        None => (&mut *base, full),
95                    };
96                    draw_prim(dst, prim, region.shift() * tf, &mut self.images);
97                }
98            }
99        }
100        // Unbalanced pushes (should not happen): flush them.
101        while let Some((buf, region, layer, ltf)) = stack.pop() {
102            let (dst, dst_region) = match stack.last_mut() {
103                Some((p, r, _, _)) => (p, *r),
104                None => (&mut *base, full),
105            };
106            composite(dst, dst_region, buf, region, &layer, ltf, &mut self.pool);
107        }
108    }
109}
110
111/// A pixel rectangle of the output frame.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113struct Region {
114    x: i32,
115    y: i32,
116    w: u32,
117    h: u32,
118}
119
120impl Region {
121    /// Maps frame pixels to this region's buffer.
122    fn shift(self) -> Affine {
123        Affine::translate((-f64::from(self.x), -f64::from(self.y)))
124    }
125}
126
127/// Pixel bounds of everything between the `PushLayer` at `start` and its
128/// `PopLayer`, grown by the reach of blurs and shadows, clipped to `within`.
129fn layer_region(items: &[DrawItem], start: usize, view: Affine, within: Region) -> Option<Region> {
130    let mut bb: Option<kurbo::Rect> = None;
131    let mut grow = 0.0f64;
132    let mut depth = 0usize;
133    for item in &items[start..] {
134        let tf = view * item.transform;
135        let r = match &item.prim {
136            Primitive::PushLayer(l) => {
137                depth += 1;
138                let k = scale_of(tf);
139                grow += 3.0 * l.blur * k;
140                if let Some(sh) = &l.shadow {
141                    let off = linear(tf) * sh.offset.to_point();
142                    grow += 3.0 * sh.blur * k + off.x.abs().max(off.y.abs());
143                }
144                None
145            }
146            Primitive::PopLayer => {
147                depth -= 1;
148                if depth == 0 {
149                    break;
150                }
151                None
152            }
153            Primitive::Fill { path, .. } => Some(tf.transform_rect_bbox(path.bounding_box())),
154            Primitive::Stroke { path, style, .. } => {
155                let pad = style.width * scale_of(tf);
156                Some(
157                    tf.transform_rect_bbox(path.bounding_box())
158                        .inflate(pad, pad),
159                )
160            }
161            Primitive::Image { rect, .. } => Some(tf.transform_rect_bbox(*rect)),
162        };
163        if let Some(r) = r {
164            bb = Some(bb.map_or(r, |b| b.union(r)));
165        }
166    }
167    let r = bb?.inflate(grow + 2.0, grow + 2.0);
168    let x0 = r.x0.floor().max(f64::from(within.x)) as i32;
169    let y0 = r.y0.floor().max(f64::from(within.y)) as i32;
170    let x1 = r.x1.ceil().min(f64::from(within.x) + f64::from(within.w)) as i32;
171    let y1 = r.y1.ceil().min(f64::from(within.y) + f64::from(within.h)) as i32;
172    (x1 > x0 && y1 > y0).then(|| Region {
173        x: x0,
174        y: y0,
175        w: (x1 - x0) as u32,
176        h: (y1 - y0) as u32,
177    })
178}
179
180fn scale_of(tf: Affine) -> f64 {
181    tf.determinant().abs().sqrt()
182}
183
184fn composite(
185    dst: &mut Pixmap,
186    dst_region: Region,
187    mut buf: Pixmap,
188    region: Region,
189    layer: &Layer,
190    tf: Affine,
191    pool: &mut Vec<Pixmap>,
192) {
193    let (w, h) = (buf.width() as usize, buf.height() as usize);
194    let k = scale_of(tf);
195    if let Some(clip) = &layer.clip
196        && let Some(path) = to_skia_path(clip)
197        && let Some(mut mask) = Mask::new(buf.width(), buf.height())
198    {
199        mask.fill_path(
200            &path,
201            FillRule::Winding,
202            true,
203            to_skia_transform(region.shift() * tf),
204        );
205        buf.apply_mask(&mask);
206    }
207    // Where this buffer goes in the destination buffer.
208    let (dx, dy) = (region.x - dst_region.x, region.y - dst_region.y);
209    if layer.blur > 0.0 {
210        gaussian_blur(buf.data_mut(), w, h, layer.blur * k);
211    }
212    let paint = PixmapPaint {
213        opacity: layer.opacity.clamp(0.0, 1.0) as f32,
214        blend_mode: blend_mode(layer.blend),
215        quality: FilterQuality::Nearest,
216    };
217    if let Some(shadow) = &layer.shadow {
218        // Shadow = the layer's coverage tinted, blurred and offset.
219        let mut sh = buf.clone();
220        let [r, g, b, a] = shadow.color.to_rgba8().map(u32::from);
221        for px in sh.data_mut().chunks_exact_mut(4) {
222            let cov = u32::from(px[3]) * a / 255;
223            px[0] = (r * cov / 255) as u8;
224            px[1] = (g * cov / 255) as u8;
225            px[2] = (b * cov / 255) as u8;
226            px[3] = cov as u8;
227        }
228        gaussian_blur(sh.data_mut(), w, h, shadow.blur * k);
229        let off = linear(tf) * shadow.offset.to_point();
230        dst.draw_pixmap(
231            dx,
232            dy,
233            sh.as_ref(),
234            &paint,
235            Transform::from_translate(off.x as f32, off.y as f32),
236            None,
237        );
238        pool.push(sh);
239    }
240    dst.draw_pixmap(dx, dy, buf.as_ref(), &paint, Transform::identity(), None);
241    pool.push(buf);
242}
243
244/// The linear part of an affine map (for offsets).
245fn linear(tf: Affine) -> Affine {
246    let [a, b, c, d, _, _] = tf.as_coeffs();
247    Affine::new([a, b, c, d, 0.0, 0.0])
248}
249
250pub(crate) fn blend_mode(b: BlendMode) -> tiny_skia::BlendMode {
251    use tiny_skia::BlendMode as T;
252    match b {
253        BlendMode::Normal => T::SourceOver,
254        BlendMode::Multiply => T::Multiply,
255        BlendMode::Screen => T::Screen,
256        BlendMode::Overlay => T::Overlay,
257        BlendMode::Darken => T::Darken,
258        BlendMode::Lighten => T::Lighten,
259        BlendMode::ColorDodge => T::ColorDodge,
260        BlendMode::ColorBurn => T::ColorBurn,
261        BlendMode::HardLight => T::HardLight,
262        BlendMode::SoftLight => T::SoftLight,
263        BlendMode::Difference => T::Difference,
264        BlendMode::Exclusion => T::Exclusion,
265        BlendMode::Hue => T::Hue,
266        BlendMode::Saturation => T::Saturation,
267        BlendMode::Color => T::Color,
268        BlendMode::Luminosity => T::Luminosity,
269    }
270}
271
272fn draw_prim(dst: &mut Pixmap, prim: &Primitive, tf: Affine, images: &mut BTreeMap<u64, Pixmap>) {
273    match prim {
274        Primitive::Fill { path, paint } => {
275            if let (Some(path), Some(p)) = (to_skia_path(path), skia_paint(paint)) {
276                dst.fill_path(&path, &p, FillRule::Winding, to_skia_transform(tf), None);
277            }
278        }
279        Primitive::Stroke { path, paint, style } => {
280            if let (Some(path), Some(p)) = (to_skia_path(path), skia_paint(paint)) {
281                dst.stroke_path(&path, &p, &skia_stroke(style), to_skia_transform(tf), None);
282            }
283        }
284        Primitive::Image {
285            image,
286            rect,
287            opacity,
288        } => {
289            let src = match images.entry(image.id) {
290                std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
291                std::collections::btree_map::Entry::Vacant(e) => {
292                    let Some(pm) = to_pixmap(image) else { return };
293                    e.insert(pm)
294                }
295            };
296            let place = tf
297                * Affine::translate((rect.x0, rect.y0))
298                * Affine::scale_non_uniform(
299                    rect.width() / f64::from(image.width),
300                    rect.height() / f64::from(image.height),
301                );
302            let paint = PixmapPaint {
303                opacity: *opacity as f32,
304                quality: FilterQuality::Bicubic,
305                ..Default::default()
306            };
307            dst.draw_pixmap(0, 0, src.as_ref(), &paint, to_skia_transform(place), None);
308        }
309        Primitive::PushLayer(_) | Primitive::PopLayer => {}
310    }
311}
312
313pub(crate) fn to_skia_path(path: &BezPath) -> Option<tiny_skia::Path> {
314    let mut pb = PathBuilder::new();
315    for el in path.elements() {
316        match *el {
317            PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
318            PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
319            PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
320            PathEl::CurveTo(a, b, p) => pb.cubic_to(
321                a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32,
322            ),
323            PathEl::ClosePath => pb.close(),
324        }
325    }
326    pb.finish()
327}
328
329fn to_skia_transform(a: Affine) -> Transform {
330    let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32);
331    Transform::from_row(a, b, c, d, e, f)
332}
333
334fn skia_color(c: Color) -> Option<tiny_skia::Color> {
335    tiny_skia::Color::from_rgba(
336        c.r.clamp(0.0, 1.0),
337        c.g.clamp(0.0, 1.0),
338        c.b.clamp(0.0, 1.0),
339        c.a.clamp(0.0, 1.0),
340    )
341}
342
343fn shader(g: &Gradient) -> Option<tiny_skia::Shader<'static>> {
344    let stops: Vec<GradientStop> = g
345        .stops
346        .iter()
347        .filter_map(|(o, c)| Some(GradientStop::new(*o as f32, skia_color(*c)?)))
348        .collect();
349    let pt = |p: kurbo::Point| tiny_skia::Point::from_xy(p.x as f32, p.y as f32);
350    match g.kind {
351        GradientKind::Linear { from, to } => LinearGradient::new(
352            pt(from),
353            pt(to),
354            stops,
355            SpreadMode::Pad,
356            Transform::identity(),
357        ),
358        GradientKind::Radial { center, radius } => RadialGradient::new(
359            pt(center),
360            0.0,
361            pt(center),
362            radius.max(1e-3) as f32,
363            stops,
364            SpreadMode::Pad,
365            Transform::identity(),
366        ),
367    }
368}
369
370fn skia_paint(p: &MPaint) -> Option<Paint<'static>> {
371    let mut paint = Paint::default();
372    match p {
373        MPaint::Solid(c) => paint.set_color(skia_color(*c)?),
374        MPaint::Gradient(g) => paint.shader = shader(g)?,
375    }
376    paint.anti_alias = true;
377    Some(paint)
378}
379
380fn skia_stroke(s: &StrokeStyle) -> Stroke {
381    Stroke {
382        width: s.width as f32,
383        line_cap: match s.cap {
384            LineCap::Butt => tiny_skia::LineCap::Butt,
385            LineCap::Round => tiny_skia::LineCap::Round,
386            LineCap::Square => tiny_skia::LineCap::Square,
387        },
388        line_join: match s.join {
389            LineJoin::Miter => tiny_skia::LineJoin::Miter,
390            LineJoin::Round => tiny_skia::LineJoin::Round,
391            LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
392        },
393        dash: (s.dash.len() >= 2 && s.dash.iter().any(|d| *d > 0.0))
394            .then(|| {
395                // tiny-skia needs an even count.
396                let mut d: Vec<f32> = s.dash.iter().map(|v| *v as f32).collect();
397                if d.len() % 2 == 1 {
398                    d.extend_from_within(..);
399                }
400                StrokeDash::new(d, s.dash_offset as f32)
401            })
402            .flatten(),
403        ..Default::default()
404    }
405}
406
407fn to_pixmap(img: &RasterImage) -> Option<Pixmap> {
408    let mut pm = Pixmap::new(img.width, img.height)?;
409    for (dst, src) in pm.pixels_mut().iter_mut().zip(img.rgba.chunks_exact(4)) {
410        *dst = tiny_skia::ColorU8::from_rgba(src[0], src[1], src[2], src[3]).premultiply();
411    }
412    Some(pm)
413}
414
415/// Converts a premultiplied pixmap to straight-alpha RGBA8.
416pub(crate) fn demultiply(pixmap: &Pixmap) -> Vec<u8> {
417    let mut data = Vec::with_capacity(pixmap.data().len());
418    for px in pixmap.pixels() {
419        let c = px.demultiply();
420        data.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
421    }
422    data
423}
424
425impl Renderer for CpuRenderer {
426    fn render(&mut self, graph: &SceneGraph, options: &RenderOptions) -> Image {
427        let (w, h) = (options.width.max(1), options.height.max(1));
428        let mut base = match self.pixmap.take() {
429            Some(p) if p.width() == w && p.height() == h => p,
430            _ => Pixmap::new(w, h).expect("valid pixmap size"),
431        };
432        let bg = options.background;
433        base.fill(skia_color(bg).unwrap_or(tiny_skia::Color::BLACK));
434        let view = scene_to_pixels(graph.size(), (w, h));
435        let items = graph.draw_list();
436        self.draw_items(&mut base, &items, view);
437        let data = demultiply(&base);
438        self.pixmap = Some(base);
439        Image {
440            width: w,
441            height: h,
442            data,
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use oxframe_2d::{NodeBuilder, Rect, View};
450
451    use super::*;
452
453    #[test]
454    fn draws_centered_rect() {
455        let graph = SceneGraph::new(100, 100);
456        graph.enter(|| {
457            View::current().add(
458                Rect::new()
459                    .size(50.0)
460                    .fill(Color::from_hex("#ff0000").unwrap()),
461            );
462        });
463        let img = CpuRenderer::new().render(
464            &graph,
465            &RenderOptions {
466                width: 100,
467                height: 100,
468                background: Color::BLACK,
469            },
470        );
471        let px = |x: usize, y: usize| &img.data[(y * 100 + x) * 4..][..4];
472        assert_eq!(px(50, 50), [255, 0, 0, 255]);
473        assert_eq!(px(10, 10), [0, 0, 0, 255]);
474        assert_eq!(px(74, 50), [255, 0, 0, 255]);
475        assert_eq!(px(76, 50), [0, 0, 0, 255]);
476    }
477}