Files
wordcraft/crates/render/src/context_pool.rs
T
rabisandClaude Opus 5.5 fba2331bbf #3027: reset test checks the fill rule and paint transform for real
The test scene set even-odd itself before its only path that depends on
it and used solid paints only, so a fill rule or paint transform left
over from the last render could not change its pixels. It now fills
nested squares and a gradient on the defaults first, and checks that a
stale rule or paint transform does change the scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:00:20 +02:00

274 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! One kept vello_cpu render context, so rendering reuses its worker threads.
//!
//! A multi-threaded `RenderContext` starts its own rayon thread pool when it is built and
//! stops it when dropped. Building one per render started and tore down a pool for every
//! repaint: about 50 threads a second while typing, each spin-waiting before it exits (#3027).
//! Renders now [`take`](ContextPool::take) the kept context, reset to their size, and
//! [`give_back`](ContextPool::give_back) it when done.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
use vello_cpu::{RenderContext, RenderSettings};
/// A render context kept between renders.
pub(crate) struct ContextPool {
slot: Mutex<Option<RenderContext>>,
/// How many contexts (so thread pools) were built: one, unless renders overlap.
built: AtomicUsize,
}
/// The context page, area and icon renders share.
pub(crate) static CONTEXTS: ContextPool = ContextPool::new();
impl ContextPool {
pub(crate) const fn new() -> Self {
ContextPool { slot: Mutex::new(None), built: AtomicUsize::new(0) }
}
/// A context reset to `w`×`h`: the kept one, or a new one with [`crate::default_threads`]
/// workers while another render has it (another thread, or a render inside a render).
pub(crate) fn take(&self, w: u16, h: u16) -> RenderContext {
let kept = self.slot.lock().unwrap_or_else(PoisonError::into_inner).take();
match kept {
Some(mut ctx) => {
ctx.reset_and_resize(w, h);
ctx
}
None => {
self.built.fetch_add(1, Ordering::Relaxed);
RenderContext::new_with(w, h, RenderSettings { num_threads: crate::default_threads(), ..Default::default() })
}
}
}
/// Keep `ctx` for the next [`take`](Self::take), unless one is kept already (then `ctx` and
/// its threads go). A render that panics never gets here, so a half-drawn context is dropped
/// rather than reused.
pub(crate) fn give_back(&self, ctx: RenderContext) {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
if slot.is_none() {
*slot = Some(ctx);
}
}
/// How many contexts this pool has built.
#[cfg(test)]
fn built(&self) -> usize {
self.built.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{MAX_SIDE, RenderOptions, Rendered, render_page, render_region};
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
use vello_cpu::{Resources, peniko};
use wordcraft_doc::props::{Highlight, Rgb};
use wordcraft_doc::{Document, Pos};
use wordcraft_layout::{LayoutCache, LayoutOptions, Page, layout};
/// Draw a scene that uses what the renderer uses (fills, strokes, transforms, a gradient,
/// both fill rules, opacity and clip layers) across a `w`×`h` context and rasterise it. It
/// leans on the defaults first: nested squares wound the same way fill whole under the
/// default non-zero rule (a left-over even-odd rule cuts a hole), and a gradient moves with
/// a left-over paint transform.
fn scene(ctx: &mut RenderContext, w: u16, h: u16) -> Rendered {
let (fw, fh) = (w as f64, h as f64);
ctx.set_paint(crate::color(Rgb(0xF4, 0xEE, 0xD8), 1.0));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, fw, fh));
let mut nested = BezPath::new();
for s in [fw.min(fh) / 2.0, fw.min(fh) / 4.0] {
nested.move_to((0.0, 0.0));
nested.line_to((s, 0.0));
nested.line_to((s, s));
nested.line_to((0.0, s));
nested.close_path();
}
ctx.set_paint(crate::color(Rgb(0x10, 0x10, 0x10), 1.0));
ctx.fill_path(&nested);
let stops =
[peniko::ColorStop::from((0.0, crate::color(Rgb(0, 0, 0), 1.0))), peniko::ColorStop::from((1.0, crate::color(Rgb(255, 255, 255), 1.0)))];
ctx.set_paint(peniko::Gradient::new_linear((0.0, 0.0), (fw, 0.0)).with_stops(stops.as_slice()));
ctx.fill_rect(&kurbo::Rect::new(0.0, fh * 3.0 / 4.0, fw, fh));
ctx.set_transform(Affine::translate((fw / 2.0, fh / 2.0)) * Affine::rotate(0.3));
ctx.set_paint(crate::color(Rgb(0x2B, 0x57, 0x9A), 1.0));
ctx.set_stroke(kurbo::Stroke::new((fw.min(fh) / 20.0).max(1.0)));
ctx.stroke_rect(&kurbo::Rect::new(-fw / 3.0, -fh / 3.0, fw / 3.0, fh / 3.0));
let mut ring = BezPath::new();
for r in [fw.min(fh) / 3.0, fw.min(fh) / 6.0] {
ring.move_to((r, 0.0));
ring.line_to((0.0, r));
ring.line_to((-r, 0.0));
ring.line_to((0.0, -r));
ring.close_path();
}
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.set_paint(crate::color(Rgb(0xC0, 0x30, 0x30), 1.0));
ctx.push_opacity_layer(0.6);
ctx.fill_path(&ring);
ctx.pop_layer();
ctx.set_transform(Affine::IDENTITY);
ctx.push_clip_layer(&kurbo::Rect::new(0.0, 0.0, fw / 2.0, fh).to_path(0.1));
ctx.set_paint(crate::color(Rgb(0x20, 0x90, 0x40), 0.5));
ctx.fill_rect(&kurbo::Rect::new(0.0, fh / 4.0, fw, fh * 3.0 / 4.0));
ctx.pop_layer();
ctx.flush();
let mut pixels = vec![0u8; w as usize * h as usize * 4];
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
ctx.render(pm, &mut Resources::new());
}
Rendered { width: w as u32, height: h as u32, pixels }
}
/// [`scene`] through `pool`.
fn pooled(pool: &ContextPool, w: u16, h: u16) -> Rendered {
let mut ctx = pool.take(w, h);
let img = scene(&mut ctx, w, h);
pool.give_back(ctx);
img
}
/// [`scene`] on a context built for it alone, as every render was before #3027.
fn fresh(w: u16, h: u16) -> Rendered {
scene(&mut RenderContext::new_with(w, h, RenderSettings { num_threads: crate::default_threads(), ..Default::default() }), w, h)
}
fn same(a: &Rendered, b: &Rendered, what: &str) {
assert_eq!((a.width, a.height), (b.width, b.height), "{what}: size");
assert!(a.pixels == b.pixels, "{what}: different pixels");
}
/// #3027: one context (one thread pool) serves renders of every size, growing and
/// shrinking, down to 1×1 and out to the largest side, and each draws exactly what a
/// context of its own would.
#[test]
fn one_context_serves_renders_of_every_size() {
let pool = ContextPool::new();
let max = MAX_SIDE as u16;
let sizes = [(600, 800), (1, 1), (600, 800), (2, 3000), (3000, 2), (max, 1), (1, max), (255, 257), (600, 800)];
for (w, h) in sizes {
same(&pooled(&pool, w, h), &fresh(w, h), &format!("{w}x{h}"));
}
for _ in 0..50 {
pooled(&pool, 300, 200);
}
assert_eq!(pool.built(), 1, "every render after the first reuses the kept context");
let ctx = pool.take(1, 1);
assert_eq!(ctx.is_multi_threaded(), crate::default_threads() > 0, "the kept context still renders on worker threads");
}
/// Whatever the last render left in the context (transform, paint, stroke, fill rule, paint
/// transform, a layer still pushed, a scene drawn but never rasterised) is gone before the
/// next render draws.
#[test]
fn nothing_left_in_a_context_reaches_the_next_render() {
let pool = ContextPool::new();
let mut ctx = pool.take(400, 300);
ctx.set_transform(Affine::rotate(0.7) * Affine::scale(3.0));
ctx.set_paint(crate::color(Rgb(200, 0, 0), 1.0));
ctx.set_stroke(kurbo::Stroke::new(25.0));
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.set_paint_transform(Affine::translate((40.0, 40.0)));
ctx.push_opacity_layer(0.3);
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, 120.0, 80.0));
ctx.stroke_rect(&kurbo::Rect::new(10.0, 10.0, 90.0, 60.0));
pool.give_back(ctx);
same(&pooled(&pool, 400, 300), &fresh(400, 300), "after an abandoned scene");
// The scene does show a left-over fill rule or paint transform when nothing resets them.
let settings = RenderSettings { num_threads: crate::default_threads(), ..Default::default() };
let mut stale_rule = RenderContext::new_with(400, 300, settings);
stale_rule.set_fill_rule(peniko::Fill::EvenOdd);
assert!(scene(&mut stale_rule, 400, 300).pixels != fresh(400, 300).pixels, "a stale even-odd rule shows");
let mut stale_paint = RenderContext::new_with(400, 300, settings);
stale_paint.set_paint_transform(Affine::translate((40.0, 40.0)));
assert!(scene(&mut stale_paint, 400, 300).pixels != fresh(400, 300).pixels, "a stale paint transform shows");
same(&pooled(&pool, 120, 90), &fresh(120, 90), "smaller, after a full render");
assert_eq!(pool.built(), 1);
}
/// A render that panics never gives its context back: the next render builds a new one.
/// A context that is already kept wins over one given back later, which is dropped.
#[test]
fn a_context_not_given_back_is_replaced_and_a_spare_is_dropped() {
let pool = ContextPool::new();
let lost = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut ctx = pool.take(200, 100);
ctx.push_opacity_layer(0.5);
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, 50.0, 50.0));
panic!("a draw failed half way");
}));
assert!(lost.is_err());
same(&pooled(&pool, 200, 100), &fresh(200, 100), "after the panic");
assert_eq!(pool.built(), 2, "the half-drawn context was dropped, not reused");
let (a, b) = (pool.take(10, 10), pool.take(10, 10));
assert_eq!(pool.built(), 3, "a render inside a render gets its own context");
pool.give_back(a);
pool.give_back(b);
pooled(&pool, 10, 10);
pooled(&pool, 10, 10);
assert_eq!(pool.built(), 3, "one of the two was kept and the other dropped");
}
/// Two threads rendering at the same time each get a context and both draw correctly;
/// afterwards one context is kept and reused.
#[test]
fn renders_on_two_threads_at_once_both_draw() {
let pool = ContextPool::new();
let both_hold_one = std::sync::Barrier::new(2);
let (a, b) = std::thread::scope(|s| {
let run = || {
let mut ctx = pool.take(240, 160);
both_hold_one.wait();
let img = scene(&mut ctx, 240, 160);
pool.give_back(ctx);
img
};
let (a, b) = (s.spawn(run), s.spawn(run));
(a.join().expect("thread a"), b.join().expect("thread b"))
});
let want = fresh(240, 160);
same(&a, &want, "thread a");
same(&b, &want, "thread b");
assert_eq!(pool.built(), 2, "neither thread waited for the other's context");
pooled(&pool, 240, 160);
assert_eq!(pool.built(), 2, "one of the two was kept");
}
/// A page with text, a highlight and a coloured background.
fn page() -> (Document, Page) {
let mut d = Document::from_text("Reused contexts draw the same page as new ones.");
d.settings.page_color = Some(Rgb(0xF4, 0xEE, 0xD8));
d.format_range(&Pos::body(0, 0), &Pos::body(0, 6), &|c| c.highlight = Some(Highlight::Yellow)).expect("highlight");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let page = l.pages.first().cloned().expect("a page");
(d, page)
}
/// Through the shared pool: a page region drawn again after renders of other sizes and an
/// icon comes out the same, and hostile sizes and scales still render, clamped.
#[test]
fn page_and_icon_renders_share_the_pool_without_traces() {
let (d, page) = page();
let opts = RenderOptions::default();
let region = |w: u32, h: u32| render_region(&d, &page, w, h, Affine::scale(1.5), &opts);
let first = region(500, 300);
let icon = crate::icon_lib::ICONS.first().expect("an icon");
let first_icon = crate::icon_lib::render(icon, 48, Rgb(0, 0x80, 0));
for (w, h) in [(0, 0), (u32::MAX, 1), (1, u32::MAX), (0, 50), (900, 1200)] {
let img = region(w, h);
let (cw, ch) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
assert_eq!((img.width, img.height), (cw, ch), "{w}x{h}");
assert_eq!(img.pixels.len(), cw as usize * ch as usize * 4, "{w}x{h}");
}
same(&crate::icon_lib::render(icon, 48, Rgb(0, 0x80, 0)), &first_icon, "the icon again");
same(&region(500, 300), &first, "the region again");
let img = render_page(&d, &page, f32::NAN, &opts);
assert!(img.width > 0 && img.height > 0, "a junk scale falls back to 1");
// Other tests render at the same time, so how many the shared pool built can't be pinned
// down here; that it built any shows page renders go through it.
assert!(CONTEXTS.built() >= 1, "page and icon renders take the shared context");
}
}