Add Canvas::set_clip_path — anti-aliased arbitrary-path clipping on both backends
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Add `Canvas::set_clip_path(&[PathCmd])`, clipping subsequent draws to an arbitrary vector path with an anti-aliased edge, on both the software and GLES backends. It complements the existing rect clip (`set_clip_rects`) and is what an embedder needs to render a shaped clip — a circular avatar, a rounded card, a `VectorDrawable` mask — rather than a bounding box. Kept general rather than tied to any one consumer.
Software backend: rasterise the path into an anti-aliased tiny-skia coverage `Mask` (Winding fill) and install it as the active clip mask. Every software primitive already threads `clip_mask` through tiny-skia (fills, strokes, lines, paths, images, text, blit), so the path clip applies uniformly with smooth edges. `clip_bounds` reports the path's bounding box while it is active.
GLES backend: a 1-bit stencil would clip exactly but leave a hard, aliased edge, so instead the clipped draws are captured into an offscreen layer and composited back through an anti-aliased coverage mask. `set_clip_path` rasterises the path coverage (tiny-skia, anti-aliased), uploads it as a mask texture, allocates a full-canvas layer FBO on first use, and redirects subsequent draws to it via `activate_target`. Ending the clip (`clear_clip` / `set_clip_rects` / a new `set_clip_path`) composites the layer back onto the canvas FBO with a new two-sampler program (`CLIP_COMPOSITE_FRAG_SRC`) that multiplies the layer colour by the mask coverage and blends it premultiplied-over. The layer attaches to the canvas's own shadow FBO, so it needs no stencil bits in the EGL config; it is freed and reallocated on resize and freed on drop, and shared programs/uniforms are copied to sub-canvases like the rest.
Usage: a path clip is bracketed — `set_clip_path` then, after the clipped draws, `clear_clip` or `set_clip_rects` to flush it (on GLES this is when the layer is composited). Snapshot the prior clip with `clip_bounds` beforehand and restore it with `set_clip_rects` to compose with an outer clip without leaking state.
Add an `examples/clip_path.rs` demo (rounded rect, circle, triangle — same smooth result on both backends) and software-backend unit tests covering the bounding box, the empty-path clear, and a pixel-level check that a triangular clip masks a fill to the path silhouette rather than its bounding box. The GLES layer-composite path needs a live GL context and is exercised by the example.
Also fix three rustdoc intra-doc-link warnings surfaced along the way: a private-item link in `app.rs` (`scroll`) and the new GLES doc (`SoftwareCanvas::set_clip_path`) demoted to code spans, and a redundant explicit link target in `chassis.rs`.
This commit is contained in:
2026-06-18 23:59:38 +02:00
parent b00cf460bb
commit f8c45f0e30
10 changed files with 482 additions and 19 deletions

View File

@@ -28,6 +28,7 @@ use super::shaders::
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC,
CLIP_COMPOSITE_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
SUB_BLIT_FRAG_SRC,
TEX_FRAG_SRC, VERT_SRC,
@@ -211,6 +212,16 @@ impl GlesCanvas
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
)};
let clip_composite_program = compile_program( &gl, VERT_SRC, CLIP_COMPOSITE_FRAG_SRC );
let ( u_clip_mvp, u_clip_layer, u_clip_mask, u_clip_canvas, u_clip_bbox ) = unsafe
{(
gl.get_uniform_location( clip_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_layer" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_mask" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_canvas" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_bbox" ).unwrap(),
)};
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
@@ -464,6 +475,16 @@ impl GlesCanvas
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program,
u_clip_mvp,
u_clip_layer,
u_clip_mask,
u_clip_canvas,
u_clip_bbox,
fbo,
fbo_tex,
aux_a: None,
@@ -665,6 +686,16 @@ impl GlesCanvas
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program: self.clip_composite_program,
u_clip_mvp: self.u_clip_mvp,
u_clip_layer: self.u_clip_layer,
u_clip_mask: self.u_clip_mask,
u_clip_canvas: self.u_clip_canvas,
u_clip_bbox: self.u_clip_bbox,
fbo,
fbo_tex,
aux_a: None,
@@ -768,10 +799,22 @@ impl GlesCanvas
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
);
// The clip layer was sized for the old dimensions — free it so the next
// path clip re-allocates at the new size; any in-flight clip is dropped.
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( tex ) = self.clip_mask_tex.take()
{
self.gl.delete_texture( tex );
}
self.gl.viewport( 0, 0, width as i32, height as i32 );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
self.clip_layer_active = false;
// Auxiliary textures were sized for the old dimensions — drop them so
// the next effect that needs them re-allocates at the new size.
self.invalidate_aux();