Render: pattern tiles at different scales are kept between frames

The renderer kept one tile per pattern name and ink plane, and used it
only at the raster scale it was made at: the device scale rounded up to
a power of two and capped. Two objects that paint one pattern at scales
for which the raster scale differs, such as a pattern scaled to 25%
with Transform Pattern Tiles next to the same pattern at 100%, replaced
each other's tile, and every frame rasterized both tiles again.

Tiles are now kept per pattern name and raster scale, within the tile
cache's budget of 128 MiB per thread and ink plane. When the budget
fills, the cache drops the least recently used tiles first. A tile is
still used only while the pattern's art, tile, tile type and overlap
are the ones it was made from. At 760%, with a pattern of 576 stars in
a 288 pt tile on a rectangle that fills the view, and the same pattern
at 25% on a square, release builds of vectorcraft-cli bench at
2880x1800 took a median of 26.0 ms a frame on main and 8.8 ms with this
change, with 8 threads.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-13 12:58:05 +07:00
1 parent 36dc6e0c11
commit 35d6aa0a16
2 files changed
+124 -10

No files matched your search

+122 -10
View File
@@ -1,12 +1,12 @@
//! Pattern fills and the pattern-editing preview.
//!
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
//! "revision".
//! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a
//! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`,
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision".
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
@@ -28,10 +28,13 @@ use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels).
const MAX_TILE_PX: f64 = 2048.0;
/// What a tile is kept under: the pattern's name and the bits of its raster scale
/// ([`raster_scale`]).
type TileKey = (String, u64);
struct TileEntry {
art: Vec<Arc<Node>>,
params: (Rect, TileType, Overlap),
scale: f64,
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
@@ -39,7 +42,7 @@ struct TileEntry {
struct PatternCache {
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [FrameCache<String, TileEntry>; 4],
tiles: [FrameCache<TileKey, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
}
@@ -155,11 +158,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let (pw, ph) = def.period();
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let key = (name.to_string(), scale.to_bits());
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles.get(name, t).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
@@ -176,7 +180,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
});
(pm, sx, sy)
}
@@ -379,6 +383,114 @@ mod tests {
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
}
/// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with
/// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it.
fn doc_with_two_scales(s: f64) -> Document {
let mut d = doc_with_pattern(TileType::Grid);
let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0));
let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s));
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
d
}
/// `d` drawn by `r` at `scale`.
fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered {
let px = (200.0 * scale) as u32;
r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The tiles rasterized on this thread so far: the tile renderer counts a frame for each.
fn tiles_rasterized() -> u64 {
CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp))
}
#[test]
fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() {
// At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is
// rasterized at 0.25, or at 2 when its tiles are scaled to 101%.
for s in [0.25, 1.01] {
let d = doc_with_two_scales(s);
let mut r = Renderer::new();
let start = tiles_rasterized();
let first = frame(&mut r, &d, 1.0).pixels;
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object");
for _ in 0..3 {
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
}
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles");
// After a frame at 200%, the next frame at 100% draws from the tiles made at 100%.
frame(&mut r, &d, 2.0);
let zoomed = tiles_rasterized();
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%");
}
}
#[test]
fn kept_tiles_draw_what_a_new_renderer_draws() {
// A new renderer on a thread of its own starts with no tiles.
let fresh = |d: &Document, zoom: f64| {
let d = d.clone();
std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap()
};
let mut d = doc_with_two_scales(0.25);
let mut r = Renderer::new();
// Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is
// clear in both, and (103, 103), outside the square, is red in the rectangle's.
let first = frame(&mut r, &d, 1.0);
assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103)));
for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}");
}
// Change the tile type as Pattern Options does.
d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 };
for zoom in [2.0, 1.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}");
}
// Replace the art as Done Editing Pattern does.
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))];
for zoom in [1.0, 2.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}");
}
}
#[test]
fn kept_tiles_stay_within_the_budget() {
// The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5
// pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both
// tiles at 200%; the tiles drawn from 100% to 400% need more.
let d = doc_with_two_scales(0.25);
let tile = |side: usize| size_of::<TileEntry>() + side * side * 4;
let budget = tile(80) + tile(40) + tile(10);
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX));
let kept = || {
CACHE.with(|c| {
let c = c.borrow();
let tiles = &c.tiles[Ink::Display as usize];
(tiles.len(), tiles.bytes())
})
};
let mut r = Renderer::new();
for zoom in [1.0, 2.0, 4.0] {
frame(&mut r, &d, zoom);
let drawn = tiles_rasterized();
frame(&mut r, &d, zoom);
assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made");
let (len, bytes) = kept();
assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes");
}
// Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least
// recently used tiles first.
assert!(kept().0 < 5, "{:?}", kept());
let before = tiles_rasterized();
frame(&mut r, &d, 1.0);
assert!(tiles_rasterized() > before, "a tile of 100% was dropped");
assert!(kept().1 <= budget, "{:?}", kept());
}
#[test]
fn missing_pattern_is_grey() {
let mut d = doc_with_pattern(TileType::Grid);
+2
View File
@@ -517,6 +517,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
- **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames.
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.