From 00fe2be46948e721f5ff2544f0190316f2138004 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:53:20 +0700 Subject: [PATCH] Gradient mesh: each tessellated quad shades between its corner colours, no banding --- crates/doc/src/live.rs | 20 ++++++++-- crates/render/src/live.rs | 82 ++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 7fac073dc..fe1f545e3 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -291,12 +291,15 @@ pub struct GradientMesh { pub points: Vec, } -/// One tessellated piece of a mesh (a small quad with a solid colour). +/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and +/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads +/// meet without a step. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MeshQuad { pub pts: [Point; 4], pub color: Color, pub opacity: f32, + pub corners: [(Color, f32); 4], } /// Create Gradient Mesh → Appearance. @@ -832,7 +835,7 @@ impl GradientMesh { (Color::rgb(rgb[0], rgb[1], rgb[2]), op) } - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre. + /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. pub fn quads(&self, n: usize) -> Vec { if !self.is_valid() { return vec![]; @@ -840,18 +843,27 @@ impl GradientMesh { let n = n.clamp(1, 64); let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; + let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; for r in 0..self.rows as usize { for c in 0..self.cols as usize { for j in 0..=n { for i in 0..=n { - grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64); + let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + grid[j * (n + 1) + i] = self.eval(r, c, u, v); + colors[j * (n + 1) + i] = self.color_at(r, c, u, v); } } for j in 0..n { for i in 0..n { let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); let g = |a: usize, b: usize| grid[b * (n + 1) + a]; - out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity }); + let k = |a: usize, b: usize| colors[b * (n + 1) + a]; + out.push(MeshQuad { + pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], + color, + opacity, + corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], + }); } } } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index ad25aeb8a..014c8efa4 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -179,7 +179,8 @@ const MESH_RASTER_MAX_PIXELS: usize = 4 << 20; /// Draw a tessellated gradient mesh as one image: its quads are filled into a pixel buffer of the /// mesh's on-screen box by scanline (each pixel takes the quad its centre is in, so neighbouring -/// quads meet without seams or overlap), and the buffer is drawn through the mesh's outline, which +/// quads meet without seams or overlap, and is shaded between the quad's corner colours, so the +/// colour changes smoothly instead of in steps), and the buffer is drawn through the mesh's outline, which /// antialiases the outer edge. The outline is grown by half a device pixel ([`grown_outline`]), as /// the quads drawn one by one are: a page of meshes laid edge to edge would otherwise show the /// background through each shared, antialiased edge. Drawing every quad as a path of its own costs a @@ -208,9 +209,12 @@ fn draw_mesh_raster(ctx: &mut RenderContext, f: &Frame, m: &GradientMesh, quads: let mut filled = vec![false; w * h]; let to_buffer = Affine::translate((-x0, -y0)) * f.view; for q in quads { - // A fully transparent quad still claims its pixels, so the edge fill below leaves them clear. - let c = if q.opacity > 0.0 { f.ink.color(&q.color, q.opacity * alpha).premultiply().to_rgba8() } else { clear }; - fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), c); + // A fully transparent corner still shades toward clear, so the edge fill below leaves it clear. + let corners = q.corners.map(|(color, opacity)| { + let c = if opacity > 0.0 { f.ink.color(&color, opacity * alpha).premultiply().to_rgba8() } else { clear }; + [c.r, c.g, c.b, c.a].map(f32::from) + }); + fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), corners); } // Twice: the grown edge samples pixels up to about a pixel and a half out from the quads. let filled = spread_to_edges(&mut px, filled, w, h); @@ -280,7 +284,7 @@ fn grown_outline(path: &BezPath, grow: f64) -> BezPath { /// Fill the pixels of the `w`×`h` buffer whose centres are inside quad `p` (buffer pixels, even-odd /// so a twisted quad is filled as drawn) with `c`, and mark them `filled`. -fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: vello_cpu::color::PremulRgba8) { +fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: [[f32; 4]; 4]) { if !p.iter().all(|p| p.x.is_finite() && p.y.is_finite()) { return; } @@ -304,16 +308,52 @@ fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: u let from = (a - 0.5).ceil().clamp(0.0, w as f64) as usize; let to = (b - 0.5).ceil().clamp(0.0, w as f64) as usize; let row = y * w; - for (d, k) in - px.get_mut(row + from..row + to).unwrap_or_default().iter_mut().zip(filled.get_mut(row + from..row + to).unwrap_or_default()) + for ((x, d), k) in (from..) + .zip(px.get_mut(row + from..row + to).unwrap_or_default().iter_mut()) + .zip(filled.get_mut(row + from..row + to).unwrap_or_default()) { - *d = c; + *d = shade(p, &c, Point::new(x as f64 + 0.5, yc)); *k = true; } } } } +/// The colour at `q` in quad `p` with corner colours `c` (premultiplied, 0–255): the corners' +/// colours mixed by `q`'s barycentric weights in the quad's triangle 0-1-2 or 0-2-3, whichever +/// holds it. A tessellated quad is a few pixels across, so the split along its diagonal doesn't show. +fn shade(p: [Point; 4], c: &[[f32; 4]; 4], q: Point) -> vello_cpu::color::PremulRgba8 { + let weights = |a: Point, b: Point, d: Point| -> Option<[f64; 3]> { + let det = (b.x - a.x) * (d.y - a.y) - (d.x - a.x) * (b.y - a.y); + if det.abs() < 1e-12 { + return None; + } + let wb = ((q.x - a.x) * (d.y - a.y) - (d.x - a.x) * (q.y - a.y)) / det; + let wd = ((b.x - a.x) * (q.y - a.y) - (q.x - a.x) * (b.y - a.y)) / det; + Some([1.0 - wb - wd, wb, wd]) + }; + let inside = |w: &[f64; 3]| w.iter().all(|w| *w >= -1e-6); + let first = weights(p[0], p[1], p[2]).map(|w| (w, [0, 1, 2])); + let second = weights(p[0], p[2], p[3]).map(|w| (w, [0, 2, 3])); + // The triangle holding `q`, else (at the quad's edge, or a folded quad) the nearer one. + let pick = match (first, second) { + (Some(a), _) if inside(&a.0) => Some(a), + (_, Some(b)) if inside(&b.0) => Some(b), + (Some(a), Some(b)) => Some(if a.0.iter().fold(0.0f64, |m, w| m.min(*w)) >= b.0.iter().fold(0.0f64, |m, w| m.min(*w)) { a } else { b }), + (a, b) => a.or(b), + }; + let mix = match pick { + Some((w, idx)) => { + let w = w.map(|w| w.clamp(0.0, 1.0)); + let sum = w.iter().sum::().max(1e-12); + std::array::from_fn(|ch| (0..3).map(|k| c[idx[k]][ch] * (w[k] / sum) as f32).sum::()) + } + None => std::array::from_fn(|ch| c.iter().map(|corner| corner[ch]).sum::() / 4.0), + }; + let [r, g, b, a] = mix.map(|v| v.round().clamp(0.0, 255.0) as u8); + vello_cpu::color::PremulRgba8 { r: r.min(a), g: g.min(a), b: b.min(a), a } +} + /// Give each pixel no quad covered the colour of a covered neighbour: the outline's antialiased /// edge samples pixels whose centres fall just outside the quads, which would otherwise fade it. /// → the pixels covered now (the given ones and those just given a colour). @@ -555,12 +595,36 @@ mod tests { assert!(holes < 60, "{holes} pixels the smooth render covers are empty with hard edges"); } + /// A quad is shaded between its corner colours: across a quad running from black on the left to + /// white on the right, each column is lighter than the one before (no flat step), the left and + /// right edges are near their corners' colours, and a column's pixels are alike. + #[test] + fn a_quad_shades_smoothly_between_its_corner_colours() { + let (w, h) = (40, 10); + let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; + let mut px = vec![clear; w * h]; + let mut filled = vec![false; w * h]; + let (black, white) = ([0.0, 0.0, 0.0, 255.0], [255.0, 255.0, 255.0, 255.0]); + let pts = [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 10.0), Point::new(0.0, 10.0)]; + fill_quad(&mut px, &mut filled, w, h, pts, [black, white, white, black]); + let at = |x: usize, y: usize| px[y * w + x].r; + for y in 0..h { + for x in 1..w { + assert!(at(x, y) > at(x - 1, y), "column {x} row {y}: {} after {}", at(x, y), at(x - 1, y)); + } + assert!(at(0, y) < 8 && at(w - 1, y) > 247, "row {y}: {} to {}", at(0, y), at(w - 1, y)); + } + for x in 0..w { + assert!(at(x, 0).abs_diff(at(x, h - 1)) <= 1, "column {x}: {} vs {}", at(x, 0), at(x, h - 1)); + } + } + /// Quads that share edges fill every pixel of their union once: no gap, no overlap. #[test] fn quads_meeting_edge_to_edge_fill_their_union_once() { let (w, h) = (20, 20); let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; - let red = vello_cpu::color::PremulRgba8 { r: 255, g: 0, b: 0, a: 255 }; + let red = [[255.0, 0.0, 0.0, 255.0]; 4]; let mut px = vec![clear; w * h]; let mut count = vec![0u8; w * h]; // A 2×2 grid over (2.3, 2.7)–(17.6, 16.2) with an inner corner off the pixel grid.