diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 2ba7db74..62c1c69b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -187,10 +187,6 @@ fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { }) } -fn blur_filter(sigma: f64) -> Filter { - Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None }) -} - fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { let [r, g, b] = ink.rgb(c); peniko::Color::new([r, g, b, 1.0]) @@ -495,44 +491,12 @@ impl Renderer { c.pop_layer(); }); } - // The content itself (blurred / feathered / filtered). - let blur: f64 = rfx - .iter() - .map(|fx| match fx { - RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => radius.max(0.0), - _ => 0.0, - }) - .sum(); - if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) { + // The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur + // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel + // and looked blocky as the view moved by parts of a pixel (#755). + let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0)); + if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) { self.pixel_content(ctx, f, content, rfx, paint); - } else if blur > 0.0 { - self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { - let mut layers = 0; - for fx in rfx { - match fx { - RasterFx::Feather { radius } if *radius > 0.0 => { - c.set_transform(fr.view); - if let Some((g, rule)) = content.outline { - c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(g); - layers += 1; - } - c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 1; - } - RasterFx::GaussianBlur { radius } if *radius > 0.0 => { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 1; - } - _ => {} - } - } - paint(r, c, fr); - for _ in 0..layers { - c.pop_layer(); - } - }); } else { paint(self, ctx, f); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 156a3fe2..386732e3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1760,6 +1760,8 @@ mod tests_fontchange; #[cfg(test)] mod tests_freeform; #[cfg(test)] +mod tests_fxzoom; +#[cfg(test)] mod tests_isolation; #[cfg(test)] mod tests_knockout; diff --git a/crates/render/src/tests_fxzoom.rs b/crates/render/src/tests_fxzoom.rs new file mode 100644 index 00000000..a3472667 --- /dev/null +++ b/crates/render/src/tests_fxzoom.rs @@ -0,0 +1,142 @@ +//! Raster effects stay where they are, and look the same, at every zoom and pan (#755). + +use serde_json::json; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, Effect, Node, NodeKind}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +/// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle +/// that overlays at half opacity; without the type when `radius` is `None`. +fn scene(radius: Option, feather: bool) -> Document { + let mut d = Document::new(200.0, 100.0); + let l = d.layers[0].id; + let mut back = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::None, 0.0), + ); + back.blend = BlendMode::Overlay; + back.opacity = 0.5; + d.insert(Some(l), 0, back).unwrap(); + if let Some(radius) = radius { + let style = CharStyle { size: 36.0, ..CharStyle::default() }; + let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(30.0, 60.0), "Hello", style)))); + let id = if feather { "stylize.feather" } else { "blur.gaussian" }; + t.appearance.effects.push(Effect { id: id.into(), params: json!({"radius": radius}), visible: true }); + d.insert(Some(l), 1, t).unwrap(); + } + d +} + +/// `d` with its layer's art in one group: the overlay and the type grouped, as in #755's report. +fn grouped(mut d: Document) -> Document { + let l = d.layers[0].id; + let id = d.alloc_id(); + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Group { children: members, clip: false })).unwrap(); + d +} + +/// The canvas's options: art on nothing, no artboards. +fn screen() -> RenderOptions { + RenderOptions { background: None, artboards: false, ..Default::default() } +} + +fn render(d: &Document, threads: u16, view: Affine, (w, h): (u32, u32)) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + r.render(d, w, h, view, &screen()) +} + +/// Where the type's ink is on the document (its centroid), rendered `threads`-threaded through +/// `view` on a `w` × `h` canvas. +fn ink_centre(radius: f64, threads: u16, view: Affine, size: (u32, u32)) -> Point { + let with = render(&scene(Some(radius), false), threads, view, size); + let without = render(&scene(None, false), threads, view, size); + let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0); + for (i, (p, q)) in with.pixels.chunks(4).zip(without.pixels.chunks(4)).enumerate() { + let d: f64 = (0..4).map(|c| f64::from(p[c].abs_diff(q[c]))).sum(); + let (x, y) = ((i % size.0 as usize) as f64 + 0.5, (i / size.0 as usize) as f64 + 0.5); + sx += d * x; + sy += d * y; + sw += d; + } + assert!(sw > 0.0, "the type shows"); + view.inverse() * Point::new(sx / sw, sy / sw) +} + +#[test] +fn blurred_type_stays_in_place_at_every_zoom_and_pan() { + for (radius, threads) in [(3.0, 0), (3.0, 4), (12.0, 0), (12.0, 4)] { + let base = ink_centre(radius, threads, Affine::IDENTITY, (200, 100)); + // The whole document in view, at other zooms and moved by parts of a pixel. + for (zoom, pan) in + [(1.0, (13.3, 7.6)), (2.0, (41.7, 20.2)), (3.3, (15.25, 9.5)), (0.5, (10.4, 5.1)), (6.0, (30.6, 25.3)), (1.37, (0.5, 0.25))] + { + let view = Affine::translate(pan) * Affine::scale(zoom); + let size = ((200.0 * zoom + 2.0 * pan.0).ceil() as u32, (100.0 * zoom + 2.0 * pan.1).ceil() as u32); + let at = ink_centre(radius, threads, view, size); + assert!( + (at.x - base.x).abs() < 1.0 && (at.y - base.y).abs() < 1.0, + "radius {radius}, threads {threads}, zoom {zoom}, pan {pan:?}: {at:?} vs {base:?}" + ); + } + } +} + +/// Scrolling moves the blurred type with the page and nothing else: two views a whole number of +/// pixels apart draw the same pixels where they overlap, whether the type fits on the canvas or +/// runs off it. +#[test] +fn scrolling_moves_blurred_type_without_changing_it() { + for (feather, group) in [(false, false), (true, false), (false, true)] { + let d = scene(Some(12.0), feather); + let d = if group { grouped(d) } else { d }; + for threads in [0, 4] { + for (zoom, (w, h)) in [(1.0, (240, 140)), (4.0, (500, 300)), (8.0, (400, 300))] { + let at = |dx: f64, dy: f64| render(&d, threads, Affine::translate((-dx, -dy)) * Affine::scale(zoom), (w, h)); + let a = at(20.0, 10.0); + for (sx, sy) in [(1u32, 0u32), (3, 2), (37, 23), (101, 57)] { + let b = at(20.0 + f64::from(sx), 10.0 + f64::from(sy)); + let mut worst = 0; + for y in sy..h { + for x in sx..w { + let i = ((y * w + x) * 4) as usize; + let j = (((y - sy) * w + x - sx) * 4) as usize; + for c in 0..4 { + worst = worst.max(a.pixels[i + c].abs_diff(b.pixels[j + c])); + } + } + } + assert!( + worst <= 3, + "feather {feather}, grouped {group}, threads {threads}, zoom {zoom}, scrolled by ({sx}, {sy}): a pixel changed by {worst}" + ); + } + } + } + } +} + +/// Scrolling by parts of a pixel (a trackpad, a zoom about the pointer) moves heavily blurred type +/// by just that: its ink stays put on the page within a tenth of a pixel, at any zoom. +#[test] +fn blurred_type_doesnt_wobble_as_the_view_moves_by_parts_of_a_pixel() { + for threads in [0, 4] { + for zoom in [1.0, 4.0] { + let at = |p: f64| { + ink_centre(30.0, threads, Affine::translate((400.0 - 75.0 * zoom + p, 330.0 - 48.0 * zoom + p)) * Affine::scale(zoom), (800, 660)) + }; + let base = at(0.0); + for k in 1..10 { + let c = at(f64::from(k) * 0.1); + // How far it moved on the screen, besides the view's own move. + let (dx, dy) = ((c.x - base.x) * zoom, (c.y - base.y) * zoom); + assert!(dx.abs() < 0.1 && dy.abs() < 0.1, "threads {threads}, zoom {zoom}, moved by {}: off by ({dx}, {dy}) px", f64::from(k) * 0.1); + } + } + } +}