feat(canvas): render mesh-gradient + sksl shader fills natively

The mesh/shader migration only papered over the compile errors: the
canvas dispatched Mesh bodies to a flat first-vertex fill and never
dispatched SceneNode.shader at all, even though the jian Painter trait
already carries fill_round_rect_mesh_gradient / fill_round_rect_shader
with real Skia implementations available. Port jian-skia's Gouraud
vertices + cached-RuntimeEffect paint onto NativeBackend (sibling
mesh_shader.rs, ShaderCache shared with jian-skia so sources compile
once), delegate through NativeFrameBackend, and route the canvas fill
dispatch through the trait methods — shader wins over gradient over
solid. CanvasKit/web inherits the trait's first-vertex / fallback-solid
defaults (documented parity gap, same as jian's own non-native
painters). Pixel-level raster tests prove interpolation actually
happens and a compiled shader beats its fallback.
This commit is contained in:
Kayshen-X 2026-07-03 00:08:26 +08:00
parent c0e4f654d2
commit 2d0bc74cbe
5 changed files with 460 additions and 14 deletions

View file

@ -115,11 +115,25 @@ pub fn paint_fill_then_stroke(
) {
let r = node.corner_radius * zoom;
let use_round = r > 0.5;
// Gradients win over solid `fill` when present. The scene
// builder leaves the first stop's colour in `fill` as a
// fallback for paint paths that don't grok gradients yet, so
// the gradient body is the more faithful representation here.
if let Some(gradient) = node.gradient.as_ref() {
// Shader / gradient bodies win over solid `fill` when present. The
// scene builder leaves a representative colour in `fill` as a
// fallback for paint paths that don't grok them yet, so the richer
// body is the more faithful representation here.
if let Some(shader) = node.shader.as_ref() {
let uniforms: Vec<(&str, &[f32])> = shader
.uniforms
.iter()
.map(|u| (u.name.as_str(), u.values.as_slice()))
.collect();
cx.backend.fill_round_rect_shader(
world_rect,
if use_round { r } else { 0.0 },
&shader.sksl,
&uniforms,
shader.opacity,
shader.fallback,
);
} else if let Some(gradient) = node.gradient.as_ref() {
paint_gradient_rect(cx, gradient, world_rect, if use_round { r } else { 0.0 });
} else if let Some(fill) = fill {
if use_round {
@ -243,16 +257,23 @@ pub(crate) fn paint_gradient_rect(
);
}
SceneGradient::Mesh {
colors, opacity, ..
rows,
cols,
colors,
opacity,
} => {
// No mesh-gradient method on the RenderBackend yet —
// degrade to the first vertex colour (same fallback the
// scene builder bakes into `SceneNode.fill`).
if let Some(first) = colors.first() {
let mut c = *first;
c.a = (c.a * opacity).clamp(0.0, 1.0);
cx.backend.fill_round_rect(rect, corner_radius, c);
}
// Real Gouraud interpolation on backends that override the
// Painter method (the native Skia host); the trait default
// degrades to the first vertex colour elsewhere (CanvasKit
// web — documented parity gap, same as jian's own painters).
cx.backend.fill_round_rect_mesh_gradient(
rect,
corner_radius,
*rows,
*cols,
colors,
*opacity,
);
}
}
}

View file

@ -24,6 +24,8 @@ struct RecordingBackend {
strokes: usize,
text: usize,
dots: usize,
mesh_fills: usize,
shader_fills: usize,
}
impl crate::RenderBackend for RecordingBackend {
@ -74,6 +76,30 @@ impl crate::RenderBackend for RecordingBackend {
self.strokes += 1;
self.ops.push(Op::Stroke);
}
fn fill_round_rect_mesh_gradient(
&mut self,
_: Rect,
_: f32,
_: u32,
_: u32,
_: &[Color],
_: f32,
) {
self.mesh_fills += 1;
self.ops.push(Op::Fill);
}
fn fill_round_rect_shader(
&mut self,
_: Rect,
_: f32,
_: &str,
_: &[(&str, &[f32])],
_: f32,
_: Color,
) {
self.shader_fills += 1;
self.ops.push(Op::Fill);
}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
@ -798,3 +824,80 @@ fn arc_handle_positions_none_for_non_ellipse() {
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
assert!(super::arc_handle_positions(&node).is_none());
}
#[test]
fn shader_fill_dispatches_through_the_shader_backend_method() {
use crate::layout_scene::{SceneShader, SceneShaderUniform};
let mut node = SceneNode::leaf("s1", NodeKind::Rect);
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
node.fill = Some(Color::WHITE); // fallback colour the builder bakes in
node.fill_type = SceneFillType::Shader;
node.shader = Some(SceneShader {
sksl: "half4 main(float2 p){ return half4(1.0,0.0,0.0,1.0); }".to_string(),
uniforms: vec![SceneShaderUniform {
name: "u_mix".to_string(),
values: vec![0.5],
}],
opacity: 1.0,
fallback: Color::WHITE,
});
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
crate::widgets::canvas_viewport_overlay::paint_fill_then_stroke(
&mut cx,
&node,
node.bounds,
1.0,
node.fill,
);
}
assert_eq!(
backend.shader_fills, 1,
"shader body must route through fill_round_rect_shader, not the solid fill"
);
assert_eq!(
backend.rects, 0,
"no plain-fill fallback when a shader is present"
);
}
#[test]
fn mesh_gradient_dispatches_through_the_mesh_backend_method() {
use crate::layout_scene::SceneGradient;
let mut node = SceneNode::leaf("m1", NodeKind::Rect);
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
node.fill = Some(Color::WHITE);
node.fill_type = SceneFillType::MeshGradient;
node.gradient = Some(SceneGradient::Mesh {
rows: 2,
cols: 2,
colors: vec![Color::WHITE; 4],
opacity: 1.0,
});
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
crate::widgets::canvas_viewport_overlay::paint_fill_then_stroke(
&mut cx,
&node,
node.bounds,
1.0,
node.fill,
);
}
assert_eq!(
backend.mesh_fills, 1,
"mesh body must route through fill_round_rect_mesh_gradient"
);
assert_eq!(
backend.rects, 0,
"no flat first-vertex fill at the dispatch layer"
);
}

View file

@ -237,6 +237,46 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
);
}
fn fill_round_rect_mesh_gradient(
&mut self,
rect: Rect,
radius: f32,
rows: u32,
cols: u32,
colors: &[Color],
opacity: f32,
) {
self.inner.fill_round_rect_mesh_gradient(
self.canvas,
rect,
radius,
rows,
cols,
colors,
opacity,
);
}
fn fill_round_rect_shader(
&mut self,
rect: Rect,
radius: f32,
sksl: &str,
uniforms: &[(&str, &[f32])],
opacity: f32,
fallback: Color,
) {
self.inner.fill_round_rect_shader(
self.canvas,
rect,
radius,
sksl,
uniforms,
opacity,
fallback,
);
}
#[allow(clippy::too_many_arguments)]
fn fill_round_rect_radial_gradient(
&mut self,

View file

@ -70,6 +70,7 @@ pub(super) fn jian_color_to_color4f(c: Color) -> skia_safe::Color4f {
mod font_script;
mod gradient;
mod image;
mod mesh_shader;
mod path;
mod text;
#[cfg(test)]
@ -132,6 +133,11 @@ pub struct NativeBackend {
svg_raster_cache: std::collections::HashMap<path::SvgRasterKey, path::SvgRasterCacheEntry>,
svg_raster_cache_order: std::collections::VecDeque<path::SvgRasterKey>,
dot_point_buffer: Vec<skia_safe::Point>,
/// Compiled-SkSL `RuntimeEffect` cache for shader fills — compiles
/// each distinct source once (failures cached too), same rationale
/// as the typeface caches above. Shared type with jian-skia so both
/// render paths key sources identically.
shader_cache: jian_skia::shader_cache::ShaderCache,
}
/// Maximum number of decoded chat images held at once. Decoded RGBA
@ -198,6 +204,7 @@ impl NativeBackend {
svg_raster_cache: std::collections::HashMap::new(),
svg_raster_cache_order: std::collections::VecDeque::new(),
dot_point_buffer: Vec::new(),
shader_cache: jian_skia::shader_cache::ShaderCache::new(),
};
// Pre-warm the per-codepoint typeface cache with every CJK
// glyph that appears in the chrome (top bar, layer panel,

View file

@ -0,0 +1,275 @@
//! Mesh-gradient + SkSL-shader fill methods on [`NativeBackend`].
//!
//! Ports jian-skia's `draw_mesh_gradient_rect` / `draw_shader_rect`
//! (vendor/jian/crates/jian-skia/src/backend.rs) onto the editor's
//! direct-canvas painter so canvas nodes with `PenFill::MeshGradient`
//! / `PenFill::Shader` render for real on the native host instead of
//! falling back to the jian `Painter` trait's first-vertex / fallback
//! solid defaults (which remain the web/CanvasKit behaviour — the
//! documented parity gap). Carved into a sibling file per the 800-line
//! cap, same as `gradient.rs`.
use super::{jian_color_to_color4f, to_sk_rect, NativeBackend};
use op_editor_ui::{Color, Rect};
impl NativeBackend {
/// Gouraud-fill a (round-)rect from a row-major `rows`×`cols`
/// vertex-colour lattice. Grid cells triangulate into two CCW
/// triangles each; `opacity` rides in the paint alpha so the
/// vertex colours stay at their authored values.
#[allow(clippy::too_many_arguments)]
pub fn fill_round_rect_mesh_gradient(
&self,
canvas: &skia_safe::Canvas,
rect: Rect,
radius: f32,
rows: u32,
cols: u32,
colors: &[Color],
opacity: f32,
) {
use skia_safe::{vertices::VertexMode, BlendMode, Color4f, Paint, Vertices};
let rows = rows.max(2);
let cols = cols.max(2);
let vcount = (rows * cols) as usize;
if colors.len() != vcount {
// Malformed lattice — paint the first-vertex fallback so the
// node stays visible (mirrors the trait default).
if let Some(first) = colors.first() {
let mut c = *first;
c.a = (c.a * opacity).clamp(0.0, 1.0);
self.fill_round_rect(canvas, rect, radius, c);
}
return;
}
let mut positions: Vec<skia_safe::Point> = Vec::with_capacity(vcount);
let mut sk_colors: Vec<skia_safe::Color> = Vec::with_capacity(vcount);
let denom_c = (cols - 1) as f32;
let denom_r = (rows - 1) as f32;
for r in 0..rows {
for c in 0..cols {
let fx = rect.origin.x + (c as f32 / denom_c) * rect.size.x;
let fy = rect.origin.y + (r as f32 / denom_r) * rect.size.y;
positions.push(skia_safe::Point::new(fx, fy));
let jc = colors[(r * cols + c) as usize];
sk_colors.push(jian_color_to_color4f(jc).to_color());
}
}
// Index of vertex (r, c) is `r * cols + c`.
let mut indices: Vec<u16> = Vec::with_capacity(((rows - 1) * (cols - 1) * 6) as usize);
for r in 0..(rows - 1) {
for c in 0..(cols - 1) {
let tl = (r * cols + c) as u16;
let tr = (r * cols + c + 1) as u16;
let bl = ((r + 1) * cols + c) as u16;
let br = ((r + 1) * cols + c + 1) as u16;
indices.extend_from_slice(&[tl, tr, bl, tr, br, bl]);
}
}
let vertices = Vertices::new_copy(
VertexMode::Triangles,
&positions,
&positions,
&sk_colors,
Some(&indices),
);
// Clip to the round-rect so the Gouraud fill respects corner
// radius, then draw. A default Paint is opaque black — seed it
// white and Modulate (white × vertex == vertex) so the vertex
// colours pass through unchanged; `opacity` rides in the alpha.
let restore_count = canvas.save();
if radius > 0.0 {
let rrect = skia_safe::RRect::new_rect_xy(to_sk_rect(rect), radius, radius);
canvas.clip_rrect(rrect, None, Some(true));
} else {
canvas.clip_rect(to_sk_rect(rect), None, Some(true));
}
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color4f(Color4f::new(1.0, 1.0, 1.0, opacity.clamp(0.0, 1.0)), None);
canvas.draw_vertices(&vertices, BlendMode::Modulate, &paint);
canvas.restore_to_count(restore_count);
}
/// Fill a (round-)rect with a compiled SkSL program. The compile is
/// cached per distinct source (failures too) in `shader_cache`;
/// uniform bind errors are ignored per-uniform so one bad binding
/// doesn't sink the fill. On compile failure the visible `fallback`
/// solid paints instead.
#[allow(clippy::too_many_arguments)]
pub fn fill_round_rect_shader(
&mut self,
canvas: &skia_safe::Canvas,
rect: Rect,
radius: f32,
sksl: &str,
uniforms: &[(&str, &[f32])],
opacity: f32,
fallback: Color,
) {
use skia_safe::runtime_effect::RuntimeShaderBuilder;
use skia_safe::{Paint, PaintStyle};
let shader = self.shader_cache.get_or_compile(sksl).and_then(|effect| {
// `RuntimeEffect` is an RCHandle — the clone handed to the
// builder is a refcount bump, not a recompile.
let mut builder = RuntimeShaderBuilder::new(effect);
for (name, values) in uniforms {
let _ = builder.set_uniform_float(name, values);
}
builder.make_shader(&skia_safe::Matrix::default())
});
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Fill);
match shader {
Some(s) => {
paint.set_shader(s);
paint.set_alpha_f(opacity.clamp(0.0, 1.0));
}
None => {
let mut c = jian_color_to_color4f(fallback);
c.a = (c.a * opacity).clamp(0.0, 1.0);
paint.set_color4f(c, None);
}
}
if radius > 0.0 {
let rrect = skia_safe::RRect::new_rect_xy(to_sk_rect(rect), radius, radius);
canvas.draw_rrect(rrect, &paint);
} else {
canvas.draw_rect(to_sk_rect(rect), &paint);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_editor_ui::Point2D;
fn raster_canvas_run(
w: i32,
h: i32,
f: impl FnOnce(&mut NativeBackend, &skia_safe::Canvas),
) -> Vec<u8> {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
let mut backend = NativeBackend::with_dpi(1.0);
f(&mut backend, surface.canvas());
let image = surface.image_snapshot();
let pixmap = image.peek_pixels().expect("cpu raster pixels");
pixmap.bytes().expect("pixel bytes").to_vec()
}
fn rect_full(w: f32, h: f32) -> Rect {
Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(w, h),
}
}
#[test]
fn mesh_gradient_interpolates_between_vertex_colors() {
// 2×2 lattice: red / red on top, blue / blue on bottom — the
// vertical midline must be neither pure red nor pure blue.
let red = Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
let blue = Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
};
let px = raster_canvas_run(64, 64, |b, canvas| {
b.fill_round_rect_mesh_gradient(
canvas,
rect_full(64.0, 64.0),
0.0,
2,
2,
&[red, red, blue, blue],
1.0,
);
});
// BGRA/RGBA layout differs per platform; compare channel maxes
// instead of fixed offsets. Top row ≈ red-ish, bottom ≈ blue-ish,
// middle has BOTH components clearly present (interpolated).
let at = |x: usize, y: usize| -> (u8, u8, u8) {
let i = (y * 64 + x) * 4;
(px[i], px[i + 1], px[i + 2])
};
let (m0, m1, m2) = at(32, 32);
let mid_channels = [m0, m1, m2];
let strong = mid_channels.iter().filter(|v| **v > 60).count();
assert!(
strong >= 2,
"midline should be an interpolated mix, got {mid_channels:?}"
);
let top = at(32, 2);
let bottom = at(32, 61);
assert_ne!(top, bottom, "gradient must vary across the lattice");
}
#[test]
fn shader_fill_compiles_and_beats_the_fallback() {
let green_fallback = Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
};
// Constant-red program: any pixel being red proves the shader
// ran; green would mean the fallback painted instead.
let px = raster_canvas_run(16, 16, |b, canvas| {
b.fill_round_rect_shader(
canvas,
rect_full(16.0, 16.0),
0.0,
"half4 main(float2 p){ return half4(1.0, 0.0, 0.0, 1.0); }",
&[],
1.0,
green_fallback,
);
});
let i = (8 * 16 + 8) * 4;
let channels = [px[i], px[i + 1], px[i + 2]];
assert!(
channels.contains(&255) && channels.contains(&0),
"expected pure shader red, got {channels:?}"
);
// Green channel must NOT be the strong one (that's the fallback).
assert!(px[i + 1] < 60, "fallback green leaked: {channels:?}");
}
#[test]
fn shader_compile_failure_paints_fallback() {
let fallback = Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
};
let px = raster_canvas_run(8, 8, |b, canvas| {
b.fill_round_rect_shader(
canvas,
rect_full(8.0, 8.0),
0.0,
"this is not sksl",
&[],
1.0,
fallback,
);
});
let i = (4 * 8 + 4) * 4;
assert_eq!(px[i + 1], 255, "fallback green expected");
}
}