Files
vectorcraft/crates/render/src/tests_viewraster.rs
T
Ari MarkowitzandClaude Opus 5.5 be7fd07562 #2558: a drag over a wide blur draws it from the still frame's raster
An effect raster too large to keep whole (a wide blur, a shadow or glow,
or any blur zoomed in) is cropped to what can show in the view, or made
at a lower resolution and resampled to the view's pixels, and kept while
the view stays the same. Dragging other art over the object then draws
its effects from the still frame instead of previewing them at the
drag's lower resolution on every frame. An interaction's frames stay on
the render worker once it drew the frame shown, since only its renderer
holds those rasters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:19:57 +07:00

162 lines
7.7 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.
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
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}");
}
}
}
/// 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)
);
}
}