diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs index 24aef582d..2ad0e81c2 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_overlay.rs @@ -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, + ); } } } diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs index 5792b7447..3379de15f 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs @@ -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" + ); +} diff --git a/crates/op-host-native/src/backend/frame_backend.rs b/crates/op-host-native/src/backend/frame_backend.rs index 182ca9f1f..ed6d035d7 100644 --- a/crates/op-host-native/src/backend/frame_backend.rs +++ b/crates/op-host-native/src/backend/frame_backend.rs @@ -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, diff --git a/crates/op-host-native/src/backend/skia.rs b/crates/op-host-native/src/backend/skia.rs index 3e92ad5e0..8ea01cc0c 100644 --- a/crates/op-host-native/src/backend/skia.rs +++ b/crates/op-host-native/src/backend/skia.rs @@ -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, svg_raster_cache_order: std::collections::VecDeque, dot_point_buffer: Vec, + /// 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, diff --git a/crates/op-host-native/src/backend/skia/mesh_shader.rs b/crates/op-host-native/src/backend/skia/mesh_shader.rs new file mode 100644 index 000000000..18bd72b1b --- /dev/null +++ b/crates/op-host-native/src/backend/skia/mesh_shader.rs @@ -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 = Vec::with_capacity(vcount); + let mut sk_colors: Vec = 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 = 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 { + 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"); + } +}