Files
vectorcraft/crates/render/src/dither.rs
T
Taha-Mahmoodi 2bad646821 Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
2026-10-23 01:53:29 +07:00

149 lines
7.1 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.
//! Dithered gradient fills in the renderer.
//!
//! A dithered linear or radial gradient is drawn as pixels: the gradient sampled on a grid over
//! the painted box with a cell per device pixel or finer (in quarter-octave steps, so a pan reuses
//! 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
//! 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. 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::hash::{Hash, Hasher};
use std::ops::Range;
use std::sync::Arc;
use vectorcraft_color::GradientPaint;
use vectorcraft_color::freeform::painted_box;
use vectorcraft_geom::{Affine, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::freeform::hash_color;
use crate::ink::Ink;
use crate::lru::{self, FrameCache};
/// 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;
/// 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! {
/// 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));
}
/// 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
/// quarter-octave step, 1 to [`MAX_CELLS`].
fn cells(px: f64) -> u16 {
let steps = (4.0 * px.max(1.0).log2()).ceil();
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, cells `win` of it,
/// painted with `ink` (and the active colour settings, which turn colours into display RGB or
/// 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 gr = &g.gradient;
(gr.kind as u8, gr.dither, gr.method as u8).hash(&mut h);
for s in &gr.stops {
(s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h);
hash_color(&s.color, &mut h);
}
let geom = g.resolve(b);
(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);
(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);
h.finish()
}
/// The cells of a `cols` × `rows` grid over box `b` that a `w` × `h` render target, which
/// 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
.pixels_within(b, b, cols, rows, win, true, &|c| ink.rgb(c))
.map(|[r, gr, bl, a]| {
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 }
})
.collect();
Pixmap::from_parts(data, w, h)
}
/// 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 or one off the
/// render target.
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 };
// Device pixels per unit along the box's sides.
let [m0, m1, m2, m3, _, _] = ctx.transform().as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let (wpx, hpx) = (b.width() * sx, b.height() * sy);
if !(wpx.is_finite() && hpx.is_finite()) {
return false;
}
let (cols, rows) = (cells(wpx), cells(hpx));
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
.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.get(&k, t).cloned()
})
.unwrap_or_else(|| {
let pm = Arc::new(rasterize(g, b, cols, rows, win, ink));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.insert(k, pm.clone(), usize::from(pm.width()) * usize::from(pm.height()) * 4, t);
});
pm
});
// A cell per pixel or finer: each pixel takes one cell, its noise intact. Coarser (a box past
// the grid's largest size on screen): mixed bilinearly, so no cell shows as a block.
let fine = f64::from(cols) >= wpx && f64::from(rows) >= hpx;
let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium };
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 });
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
}