mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
Gradients: a dithered fill samples only the part of its grid the view shows
Review follow-up (CodeRabbit): a large dithered gradient mostly off-screen sampled its whole grid (up to 4096 x 4096 cells, 64 MiB) to draw a small visible part, and did it again on each zoom step. The renderer now samples only the cells the render target can show, a cell more each way for the bilinear mix, snapped out to 256-cell tiles so a pan within them reuses the grid. Cells keep their place in the whole grid, so the gradient and its noise are the same pixels as before (GradientPaint::pixels_within).
This commit is contained in:
1 parent
45f6e99eb1
commit
4062e74ff3
3 files changed
+94
-14
No files matched your search
@@ -553,11 +553,25 @@ impl GradientPaint {
|
|||||||
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered, each colour
|
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered, each colour
|
||||||
/// channel of pixel (column, row) gets [`dither_noise`] before it is rounded to a level.
|
/// channel of pixel (column, row) gets [`dither_noise`] before it is rounded to a level.
|
||||||
pub fn pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, rgb: &dyn Fn(&Color) -> [f32; 3]) -> impl Iterator<Item = [u8; 4]> + use<> {
|
pub fn pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, rgb: &dyn Fn(&Color) -> [f32; 3]) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||||
|
self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], rgb)
|
||||||
|
}
|
||||||
|
/// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only,
|
||||||
|
/// row by row: the same pixels, noise and all, the whole grid has there.
|
||||||
|
pub fn pixels_within(
|
||||||
|
&self,
|
||||||
|
bounds: Rect,
|
||||||
|
area: Rect,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
cells: [std::ops::Range<u16>; 2],
|
||||||
|
rgb: &dyn Fn(&Color) -> [f32; 3],
|
||||||
|
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||||
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), self.gradient.dither);
|
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), self.gradient.dither);
|
||||||
let ramp = Ramp::new(&self.gradient, rgb);
|
let ramp = Ramp::new(&self.gradient, rgb);
|
||||||
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
|
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
|
||||||
let (x0, y0) = (area.x0, area.y0);
|
let (x0, y0) = (area.x0, area.y0);
|
||||||
(0..u32::from(rows)).flat_map(move |j| (0..u32::from(cols)).map(move |i| (i, j))).map(move |(i, j)| {
|
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
|
||||||
|
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
|
||||||
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
|
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
|
||||||
let c = ramp.at(geom.param_at(kind, p) as f32);
|
let c = ramp.at(geom.param_at(kind, p) as f32);
|
||||||
let level = |k: usize| {
|
let level = |k: usize| {
|
||||||
|
|||||||
+56
-13
@@ -5,10 +5,13 @@
|
|||||||
//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level
|
//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level
|
||||||
//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image
|
//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image
|
||||||
//! paint that the fill clips. Past [`MAX_CELLS`] a side (a box larger than that on screen), cells
|
//! paint that the fill clips. Past [`MAX_CELLS`] a side (a box larger than that on screen), cells
|
||||||
//! grow beyond a pixel and are mixed bilinearly, so the gradient stays smooth.
|
//! grow beyond a pixel and are mixed bilinearly, so the gradient stays smooth. Only the cells the
|
||||||
|
//! render target can show are sampled ([`window`]), so a large box mostly off-screen costs what
|
||||||
|
//! its visible part does.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use vectorcraft_color::GradientPaint;
|
use vectorcraft_color::GradientPaint;
|
||||||
@@ -24,11 +27,23 @@ use crate::lru::{self, FrameCache};
|
|||||||
/// Most cells along a side of a dithered gradient's grid (64 MiB for a square grid).
|
/// Most cells along a side of a dithered gradient's grid (64 MiB for a square grid).
|
||||||
pub(crate) const MAX_CELLS: f64 = 4096.0;
|
pub(crate) const MAX_CELLS: f64 = 4096.0;
|
||||||
|
|
||||||
|
/// Cells along a side of the part of a grid that is sampled snap out to multiples of this
|
||||||
|
/// ([`window`]), so a pan within them keeps the sampled part.
|
||||||
|
const TILE: f64 = 256.0;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// Grids by [`key`], within [`lru::DITHER_BUDGET`], least recently used first out.
|
/// Grids by [`key`], within [`lru::DITHER_BUDGET`], least recently used first out.
|
||||||
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::DITHER_BUDGET, usize::MAX));
|
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::DITHER_BUDGET, usize::MAX));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes of the grids this thread keeps, after `f` runs on an empty cache.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn grid_bytes_after(f: impl FnOnce()) -> usize {
|
||||||
|
GRIDS.with(|c| c.borrow_mut().clear());
|
||||||
|
f();
|
||||||
|
GRIDS.with(|c| c.borrow().bytes())
|
||||||
|
}
|
||||||
|
|
||||||
/// Cells along a side `px` device pixels long: at least one per pixel, rounded up to a
|
/// Cells along a side `px` device pixels long: at least one per pixel, rounded up to a
|
||||||
/// quarter-octave step, 1 to [`MAX_CELLS`].
|
/// quarter-octave step, 1 to [`MAX_CELLS`].
|
||||||
fn cells(px: f64) -> u16 {
|
fn cells(px: f64) -> u16 {
|
||||||
@@ -36,9 +51,10 @@ fn cells(px: f64) -> u16 {
|
|||||||
2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16
|
2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cache key of gradient `g` placed on box `b` as a `cols` × `rows` grid painted with `ink` (and
|
/// Cache key of gradient `g` placed on box `b` as a `cols` × `rows` grid, cells `win` of it,
|
||||||
/// the active colour settings, which turn colours into display RGB or inks).
|
/// painted with `ink` (and the active colour settings, which turn colours into display RGB or
|
||||||
fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
|
/// inks).
|
||||||
|
fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range<u16>; 2], ink: Ink) -> u64 {
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
let gr = &g.gradient;
|
let gr = &g.gradient;
|
||||||
(gr.kind as u8, gr.dither).hash(&mut h);
|
(gr.kind as u8, gr.dither).hash(&mut h);
|
||||||
@@ -50,24 +66,47 @@ fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
|
|||||||
(geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h);
|
(geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h);
|
||||||
geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h);
|
geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h);
|
||||||
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
|
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
|
||||||
|
win.hash(&mut h);
|
||||||
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h);
|
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h);
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `g` placed on `b` as a premultiplied `cols` × `rows` pixmap, colours as `ink` paints them.
|
/// The cells of a `cols` × `rows` grid over box `b` that a `w` × `h` render target, which
|
||||||
pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
|
/// transform `xf` maps the box onto, can show: (columns, rows), a cell more each way for the
|
||||||
|
/// bilinear mix, out to whole [`TILE`]s. The whole grid where `xf` has no inverse; None where
|
||||||
|
/// no cell shows.
|
||||||
|
fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[Range<u16>; 2]> {
|
||||||
|
let seen = xf.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||||
|
let span = |lo: f64, hi: f64, start: f64, len: f64, n: u16| {
|
||||||
|
let cell = len / f64::from(n);
|
||||||
|
let (first, last) = (((lo - start) / cell).floor() - 1.0, ((hi - start) / cell).ceil() + 1.0);
|
||||||
|
if !(first.is_finite() && last.is_finite()) {
|
||||||
|
return Some(0..n);
|
||||||
|
}
|
||||||
|
let a = ((first / TILE).floor() * TILE).clamp(0.0, f64::from(n));
|
||||||
|
let z = ((last / TILE).ceil() * TILE).clamp(0.0, f64::from(n));
|
||||||
|
(a < z).then_some(a as u16..z as u16)
|
||||||
|
};
|
||||||
|
Some([span(seen.x0, seen.x1, b.x0, b.width(), cols)?, span(seen.y0, seen.y1, b.y0, b.height(), rows)?])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cells `win` (columns, rows) of `g` placed on `b` as a `cols` × `rows` grid, as a
|
||||||
|
/// premultiplied pixmap, colours as `ink` paints them.
|
||||||
|
pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range<u16>; 2], ink: Ink) -> Pixmap {
|
||||||
|
let (w, h) = (win[0].len() as u16, win[1].len() as u16);
|
||||||
let data = g
|
let data = g
|
||||||
.pixels(b, b, cols, rows, &|c| ink.rgb(c))
|
.pixels_within(b, b, cols, rows, win, &|c| ink.rgb(c))
|
||||||
.map(|[r, gr, bl, a]| {
|
.map(|[r, gr, bl, a]| {
|
||||||
let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8;
|
let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8;
|
||||||
vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a }
|
vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a }
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Pixmap::from_parts(data, cols, rows)
|
Pixmap::from_parts(data, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set dithered gradient `g` on box `bounds` (in the paint's space, which the context's current
|
/// Set dithered gradient `g` on box `bounds` (in the paint's space, which the context's current
|
||||||
/// transform maps to pixels) as the context paint. Returns false for an empty box.
|
/// transform maps to pixels) as the context paint. Returns false for an empty box or one off the
|
||||||
|
/// render target.
|
||||||
pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
|
pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
|
||||||
let Some(b) = painted_box(bounds) else { return false };
|
let Some(b) = painted_box(bounds) else { return false };
|
||||||
// Device pixels per unit along the box's sides.
|
// Device pixels per unit along the box's sides.
|
||||||
@@ -78,7 +117,9 @@ pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let (cols, rows) = (cells(wpx), cells(hpx));
|
let (cols, rows) = (cells(wpx), cells(hpx));
|
||||||
let k = key(g, b, cols, rows, ink);
|
let Some(win) = window(*ctx.transform(), ctx.width(), ctx.height(), b, cols, rows) else { return false };
|
||||||
|
let (first_col, first_row) = (win[0].start, win[1].start);
|
||||||
|
let k = key(g, b, cols, rows, &win, ink);
|
||||||
let pm = GRIDS
|
let pm = GRIDS
|
||||||
.with(|c| {
|
.with(|c| {
|
||||||
let mut c = c.borrow_mut();
|
let mut c = c.borrow_mut();
|
||||||
@@ -86,11 +127,11 @@ pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
|
|||||||
c.get(&k, t).cloned()
|
c.get(&k, t).cloned()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
let pm = Arc::new(rasterize(g, b, cols, rows, ink));
|
let pm = Arc::new(rasterize(g, b, cols, rows, win, ink));
|
||||||
GRIDS.with(|c| {
|
GRIDS.with(|c| {
|
||||||
let mut c = c.borrow_mut();
|
let mut c = c.borrow_mut();
|
||||||
let t = c.tick();
|
let t = c.tick();
|
||||||
c.insert(k, pm.clone(), cols as usize * rows as usize * 4, t);
|
c.insert(k, pm.clone(), usize::from(pm.width()) * usize::from(pm.height()) * 4, t);
|
||||||
});
|
});
|
||||||
pm
|
pm
|
||||||
});
|
});
|
||||||
@@ -100,6 +141,8 @@ pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
|
|||||||
let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium };
|
let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium };
|
||||||
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality, alpha: 1.0 };
|
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality, alpha: 1.0 };
|
||||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
|
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
|
||||||
ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64));
|
let (cw, ch) = (b.width() / f64::from(cols), b.height() / f64::from(rows));
|
||||||
|
let origin = (b.x0 + f64::from(first_col) * cw, b.y0 + f64::from(first_row) * ch);
|
||||||
|
ctx.set_paint_transform(Affine::translate(origin) * Affine::scale_non_uniform(cw, ch));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -98,3 +98,26 @@ fn a_zoomed_or_rotated_dithered_gradient_keeps_its_colours() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_dithered_box_mostly_off_screen_samples_only_what_shows() {
|
||||||
|
// A 2000-unit square, the shallow ramp across it: a 2048-cell grid a side, 16 MiB.
|
||||||
|
const S: u32 = 2000;
|
||||||
|
let mut d = Document::new(f64::from(S), f64::from(S));
|
||||||
|
let id = d.alloc_id();
|
||||||
|
let n = Node::path(
|
||||||
|
id,
|
||||||
|
shapes::rectangle(Rect::new(0.0, 0.0, f64::from(S), f64::from(S))),
|
||||||
|
Appearance::basic(shallow(GradientKind::Linear, true), Paint::None, 0.0),
|
||||||
|
);
|
||||||
|
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||||
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||||
|
let whole = Renderer::new().render(&d, S, S, Affine::IDENTITY, &opts);
|
||||||
|
// A 64-pixel view of its middle draws the pixels the whole render has there...
|
||||||
|
let mut part = None;
|
||||||
|
let bytes = dither::grid_bytes_after(|| part = Some(Renderer::new().render(&d, 64, 64, Affine::translate((-1000.0, -1000.0)), &opts)));
|
||||||
|
let part = part.unwrap();
|
||||||
|
assert!((0..64).all(|y| (0..64).all(|x| part.pixel(x, y) == whole.pixel(1000 + x, 1000 + y))), "the view draws what the whole render does");
|
||||||
|
// ...from the cells around it only, not the whole grid.
|
||||||
|
assert!(bytes > 0 && bytes <= 512 * 512 * 4, "{bytes} bytes of grid for a 64-pixel view");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user