Files
vectorcraft/crates/render/src/tests/viewraster.rs
T

366 lines
19 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.
//! Effect rasters cropped to the view, or made at a lower resolution, are kept while the view
//! stays as it is: dragging other art over an object whose effects reach far past the view (a wide
//! blur, or any blur zoomed in) draws its raster from the last frame. A cropped raster made again
//! after a pan is kept while later pans stay within the margin it is made with.
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::{RenderOptions, Renderer};
/// An A4 page filled by a blue rectangle under `effects`, and a small orange square over it
/// (returned with the document): the square is the art a drag moves.
fn backdrop(effects: &[(&str, serde_json::Value)]) -> (Document, NodeId) {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)),
Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0),
);
back.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
d.insert(Some(layer), 0, back).unwrap();
let id = d.alloc_id();
let square = Node::path(
id,
shapes::rectangle(Rect::new(280.0, 400.0, 300.0, 420.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0),
);
d.insert(Some(layer), 1, square).unwrap();
(d, id)
}
/// A `w` × `h` view at `zoom`, centered on the page.
fn view(zoom: f64, (w, h): (u32, u32)) -> Affine {
Affine::translate((f64::from(w) / 2.0, f64::from(h) / 2.0)) * Affine::scale(zoom) * Affine::translate((-297.5, -421.0))
}
/// `base` with the square moved by `step` steps of a drag.
fn moved(base: &Document, id: NodeId, step: u32, zoom: f64) -> Document {
let mut d = base.clone();
let by = (f64::from(step) * 3.0 / zoom, f64::from(step) * 2.0 / zoom);
d.node_mut(id).unwrap().transform(Affine::translate(by), false);
d
}
/// A case's name, its effects (id and params) and its zoom.
type Case = (&'static str, Vec<(&'static str, serde_json::Value)>, f64);
/// Every effect whose raster can be cropped to the view or made coarser, with a zoom at which it
/// is: wide blurs at the fit zoom, narrow ones zoomed in, a Photoshop-style filter, a shadow and a
/// glow.
fn cases() -> Vec<Case> {
vec![
("Gaussian Blur 500 pt", vec![("blur.gaussian", json!({"radius": 500}))], 1.6),
("Gaussian Blur 100 pt", vec![("blur.gaussian", json!({"radius": 100}))], 1.6),
("Gaussian Blur 20 pt", vec![("blur.gaussian", json!({"radius": 20}))], 4.0),
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))], 16.0),
("Feather 20 pt", vec![("stylize.feather", json!({"radius": 20}))], 4.0),
("Craquelure", vec![("texture.craquelure", json!({}))], 4.0),
("Drop Shadow blur 100 pt", vec![("stylize.dropShadow", json!({"blur": 100, "x": 20, "y": 20}))], 1.6),
("Drop Shadow blur 10 pt", vec![("stylize.dropShadow", json!({"blur": 10, "x": 5, "y": 5}))], 8.0),
("Outer Glow blur 100 pt", vec![("stylize.outerGlow", json!({"blur": 100}))], 1.6),
]
}
/// A drag of the square over the backdrop draws the backdrop's effects from the rasters of the
/// frame before the drag, at full resolution: no frame of the drag previews them at a drag's lower
/// resolution, and each draws as a fresh renderer draws its still frame.
#[test]
fn a_drag_over_art_with_effects_past_the_view_draws_their_rasters_kept() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, xf, &opts);
for step in 1..4 {
let d = moved(&base, id, step, zoom);
let drag = r.render(&d, size.0, size.1, xf, &live);
assert_eq!(r.stats.previewed, 0, "{name}: drag frame {step} drew the backdrop's effects from the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, xf, &opts);
assert!(drag.pixels == still.pixels, "{name}: drag frame {step} draws as a still frame");
}
}
}
/// A pan by whole pixels or a zoom makes the rasters cropped to the view again: the frame after it draws as a
/// fresh renderer draws it.
#[test]
fn rasters_cropped_to_the_view_follow_a_pan_and_a_zoom() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (d, _) = backdrop(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, view(zoom, size), &opts);
for xf in [Affine::translate((37.0, -21.0)) * view(zoom, size), view(zoom * 1.25, size)] {
let image = r.render(&d, size.0, size.1, xf, &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(image.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "{name}");
}
}
}
/// A 6000 × 4000 pt image (300 × 200 pixels, 20 pt a pixel) under `effects`.
fn large_image(effects: &[(&str, serde_json::Value)]) -> Document {
let img = image::RgbaImage::from_fn(300, 200, |x, y| image::Rgba([(x * 7) as u8, (y * 13) as u8, ((x + y) * 3) as u8, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(6000.0, 4000.0);
d.images.insert("viewraster-pan".into(), ImageBlob::new("image/png", png));
let im = ImageObject {
key: "viewraster-pan".into(),
width: 300,
height: 200,
xf: Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
d
}
/// At 100 %, the Drop Shadow, Gaussian Blur and Feather rasters of a large image are cropped to the view. The pan
/// that makes one again at the same zoom makes it with a margin around the view, and later pans within the margin
/// (by up to 259 pixels from the frame that made them, and back) draw it without making any offscreen raster: each
/// such frame makes as many offscreen pixels as the same view drawn again, and draws as a fresh renderer draws it,
/// within a level a channel: an image sampled into a raster that starts at another pixel rounds by up to a level, as it
/// does in the frames of two fresh renderers whose views differ in size.
#[test]
fn pans_within_a_cropped_rasters_margin_draw_it_kept() {
let size = (640, 360);
let cases: Vec<Case> = vec![
("Drop Shadow", vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))], 1.0),
("Gaussian Blur", vec![("blur.gaussian", json!({"radius": 20}))], 1.0),
("Feather", vec![("stylize.feather", json!({"radius": 20}))], 1.0),
(
"Drop Shadow over a Drop Shadow",
vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": 9}))],
1.0,
),
];
let counted = |r: &mut Renderer, d: &Document, xf: Affine, opts: &RenderOptions| {
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
let image = r.render(d, size.0, size.1, xf, opts);
(image, crate::OFFSCREEN_PIXELS.with(|n| n.get()))
};
for (name, effects, zoom) in cases {
let d = large_image(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::scale(zoom) * Affine::translate((-3000.0, -2000.0));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
// The pan that makes the rasters again, and the same view drawn again.
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
let (_, kept) = counted(&mut r, &d, at(37.0, 0.0), &opts);
for (dx, dy) in [(74.0, 0.0), (74.0, -37.0), (296.0, -37.0), (0.0, 0.0), (-222.0, 37.0)] {
let (image, made) = counted(&mut r, &d, at(dx, dy), &opts);
assert_eq!(made, kept, "{name}: the pan to ({dx}, {dy}) drew the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, at(dx, dy), &opts);
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(worst <= 1, "{name}: ({dx}, {dy}): a channel differs by {worst}");
}
}
}
/// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left
/// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster
/// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every
/// frame.
#[test]
fn fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept() {
let size = (640, 360);
let d = large_image(&[("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))]);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::translate((-3000.0, -2000.0));
let counted = |r: &mut Renderer, xf: Affine| {
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
r.render(&d, size.0, size.1, xf, &opts);
crate::OFFSCREEN_PIXELS.with(|n| n.get())
};
let scrolls = [(0.37, 1.13), (-2.71, 0.0), (0.0, 0.45), (1.9, -0.83), (0.21, 0.21), (13.6, -4.4), (-0.5, 0.5), (7.77, 2.22)];
for whole in [true, false] {
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
// The scroll that makes the raster again with its margin, and the same view drawn again.
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
let kept = counted(&mut r, at(37.0, 0.0));
let (mut given, mut moved): ((f64, f64), (f64, f64)) = ((37.0, 0.0), (37.0, 0.0));
let mut made_again = 0;
for (k, (x, y)) in scrolls.into_iter().enumerate() {
given = (given.0 + x, given.1 + y);
moved = if whole { (given.0.round(), given.1.round()) } else { given };
let made = counted(&mut r, at(moved.0, moved.1));
if whole {
assert_eq!(made, kept, "scroll {k} to {moved:?} drew the raster kept");
} else if made > kept {
made_again += 1;
}
}
assert!((moved.0 - given.0).abs() <= 0.5 && (moved.1 - given.1).abs() <= 0.5, "moved {moved:?} for {given:?}");
if !whole {
assert_eq!(made_again, scrolls.len(), "scrolls by amounts that are not whole pixels make the raster again");
}
}
}
/// A group's shadow raster holds the shadows of the members outside the view: a pan that brings a member into view
/// draws its shadow from the raster kept as a fresh renderer draws it, for a raster of the group's whole reach (two
/// 100 pt squares) and for one cropped to the view and the margin a pan adds (two 3000 × 2000 pt rectangles).
#[test]
fn a_groups_shadow_raster_holds_the_members_outside_the_view() {
let size = (640, 360);
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
// The two members, the point the view is centered on, and the pans after the first frame.
let cases = [
("whole", [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)], (220.0, 50.0), vec![-300.0]),
("cropped", [Rect::new(0.0, 0.0, 3000.0, 2000.0), Rect::new(3400.0, 0.0, 6400.0, 2000.0)], (3000.0, 1000.0), vec![-37.0, -237.0]),
];
let mut wrong = vec![];
for (name, members, (cx, cy), pans) in cases {
let mut d = Document::new(6400.0, 2000.0);
let members = members.iter().map(|r| std::sync::Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), blue()))).collect();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, group).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64| Affine::translate((320.0 + dx, 180.0)) * Affine::translate((-cx, -cy));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0), &opts);
for dx in pans {
let image = r.render(&d, size.0, size.1, at(dx), &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
if image.pixels != fresh.render(&d, size.0, size.1, at(dx), &opts).pixels {
wrong.push(format!("{name}: panned by {dx}"));
}
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
/// While interacting, a group with a member outside the view that only moved draws its shadow from the raster of the
/// frame before, where a fresh renderer draws it: the moved silhouette holds that member too.
#[test]
fn a_moved_group_with_a_member_outside_the_view_draws_its_shadow_kept() {
let size = (640, 360);
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
let mut base = Document::new(700.0, 100.0);
let members = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)]
.iter()
.map(|r| std::sync::Arc::new(Node::path(base.alloc_id(), shapes::rectangle(*r), blue())))
.collect();
let mut group = Node::group(base.alloc_id(), members);
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
let (id, layer) = (group.id, base.layers[0].id);
base.insert(Some(layer), 0, group).unwrap();
let view = Affine::translate((100.0, 130.0));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, view, &opts);
let mut moved = base.clone();
moved.node_mut(id).unwrap().transform(Affine::translate((3.0, 2.0)), false);
let frame = r.render(&moved, size.0, size.1, view, &live);
assert!(r.stats.moved > 0, "drawn from the raster of the frame before");
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(frame.pixels == fresh.render(&moved, size.0, size.1, view, &opts).pixels);
}
/// A shadow made over an earlier cropped shadow covers only the rect the earlier one was made for. In a 2000 × 1200
/// view of the image's right edge, the pixel budget gives the earlier shadow, which also covers what the later one
/// reads 60 pixels below, a margin of 548 pixels and the later one 643: a pan of 600 pixels past the first makes both
/// again, and the frame draws as a fresh renderer draws it.
#[test]
fn a_shadow_over_a_cropped_shadow_keeps_to_the_earlier_ones_margin() {
let size = (2000, 1200);
let effects = [("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": -60}))];
let d = large_image(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dy: f64| Affine::translate((1000.0, 600.0 + dy)) * Affine::translate((-6000.0, -2000.0));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0), &opts);
r.render(&d, size.0, size.1, at(37.0), &opts);
for dy in [37.0 - 600.0, 37.0 + 600.0] {
let image = r.render(&d, size.0, size.1, at(dy), &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, at(dy), &opts);
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert_eq!(worst, 0, "panned to {dy}");
}
}
/// Time a drag of the square over each backdrop in a 2560 × 1440 view: the first frame renders as
/// before a drag, the next eight as a drag's frames (`interacting` and `dragging`, as the canvas
/// renders them), then the frame after the drag. The hashes of that frame and of a fresh renderer's
/// frame compare builds.
/// Run with `cargo test --release -p vectorcraft-render backdrop_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn backdrop_drag_perf() {
let hash = |px: &[u8]| {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
px.hash(&mut h);
h.finish()
};
let size = (2560, 1440);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = crate::default_threads();
let start = std::time::Instant::now();
std::hint::black_box(r.render(&base, size.0, size.1, xf, &opts));
let first = start.elapsed().as_secs_f64() * 1000.0;
let (mut ms, mut previewed, mut doc) = (vec![], 0, base.clone());
for step in 1..9 {
doc = moved(&base, id, step, zoom);
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, size.0, size.1, xf, &live));
ms.push(start.elapsed().as_secs_f64() * 1000.0);
previewed += r.stats.previewed;
}
let start = std::time::Instant::now();
let still = r.render(&doc, size.0, size.1, xf, &opts);
let after = start.elapsed().as_secs_f64() * 1000.0;
let fresh = Renderer::new().render(&doc, size.0, size.1, xf, &opts);
ms.sort_by(f64::total_cmp);
println!(
"{name} at {:.0}%: first frame {first:.1} ms; drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
zoom * 100.0,
(ms[3] + ms[4]) * 0.5,
ms[0],
ms[7],
hash(&still.pixels),
hash(&fresh.pixels)
);
}
}