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

@@ -1,12 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `glScissor`-based clipping + whole-canvas fill / clear for
//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives
//! multiple rects the bounding-box union is used as the scissor —
//! coarse, but the partial-redraw path normally clusters 13 rects
//! so the union is barely larger than the sum. Disjoint regions
//! would want a stencil-buffer path; not implemented today.
//! Clipping + whole-canvas fill / clear for [`GlesCanvas`]. Rect clips
//! ([`GlesCanvas::set_clip_rects`]) use `glScissor`: when given multiple rects
//! the bounding-box union is the scissor — coarse, but the partial-redraw path
//! normally clusters 13 rects so the union is barely larger than the sum
//! (disjoint regions are not clipped tightly). Arbitrary path clips
//! ([`GlesCanvas::set_clip_path`]) are anti-aliased: the clipped draws are
//! captured into an offscreen layer FBO, then composited back onto the canvas
//! multiplied by a CPU-rasterised (tiny-skia) anti-aliased coverage mask, so the
//! clipped edge is smooth rather than the hard edge a 1-bit stencil would give.
use glow::HasContext;
@@ -18,6 +21,8 @@ impl GlesCanvas
{
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
// A rect clip replaces any active path clip: flush its layer first.
self.flush_clip_layer();
// Scissor is global GL state; rebind our FBO first so the scissor
// applies to this canvas and not whatever target was active before.
self.activate_target();
@@ -58,6 +63,114 @@ impl GlesCanvas
// to match below.
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
self.clip_scissor = None;
self.flush_clip_layer();
}
/// Allocate the offscreen clip layer (a full-canvas colour FBO) on first
/// use. Freed and re-allocated at the new size by `resize`.
fn ensure_clip_layer( &mut self )
{
if self.clip_layer.is_some() { return; }
// SAFETY: GL context current (canvas invariant). Allocates an FBO + colour
// texture sized to the canvas and attaches it; the handles are stored so
// `Drop`/`resize` manage their lifetime. Restores the canvas FBO binding.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "clip-layer FBO" );
let tex = super::helpers::alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
self.gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0 );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.clip_layer = Some( ( fbo, tex ) );
}
}
/// Clip subsequent draws to an arbitrary vector path, with an anti-aliased
/// edge. Begins capturing draws into the offscreen clip layer; the path's
/// anti-aliased coverage is rasterised (tiny-skia) into `clip_mask_tex` and
/// applied when the layer is composited back by `flush_clip_layer` (on the
/// next `clear_clip` / `set_clip_rects`). GLES counterpart of the software
/// backend's `set_clip_path`.
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
// A new path clip supersedes any layer still open.
self.flush_clip_layer();
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { self.clear_clip(); return; };
let b = path.bounds();
let x0 = b.left().floor().max( 0.0 );
let y0 = b.top().floor().max( 0.0 );
let x1 = b.right().ceil().min( self.width as f32 );
let y1 = b.bottom().ceil().min( self.height as f32 );
let pw = ( x1 - x0 ).max( 0.0 ) as u32;
let ph = ( y1 - y0 ).max( 0.0 ) as u32;
if pw == 0 || ph == 0 || pw > 8192 || ph > 8192 { self.clear_clip(); return; }
// Rasterise the path coverage (anti-aliased, opaque inside) into a
// bbox-sized pixmap; the composite shader multiplies the layer by it.
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return; };
let mut paint = tiny_skia::Paint::default();
paint.set_color( tiny_skia::Color::WHITE );
paint.anti_alias = true;
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None );
let rgba = pixmap.data().to_vec();
let mask = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
self.clip_mask_tex = Some( mask );
self.clip_bbox = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
self.ensure_clip_layer();
// Redirect to the layer and clear it transparent so untouched pixels stay
// empty (they contribute nothing to the composite).
self.clip_layer_active = true;
self.clip_scissor = None;
self.activate_target();
// SAFETY: GL context current; `activate_target` bound the layer FBO.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Composite the clip layer back onto the canvas FBO, multiplied by the
/// anti-aliased coverage mask, then end the path clip. No-op when no path
/// clip is open.
fn flush_clip_layer( &mut self )
{
if !self.clip_layer_active { return; }
self.clip_layer_active = false;
let Some( ( _, layer_tex ) ) = self.clip_layer else { return; };
let Some( mask_tex ) = self.clip_mask_tex.take() else { return; };
let bbox = self.clip_bbox;
let mvp = super::helpers::ortho_rect( self.width, self.height, bbox );
// SAFETY: GL context current. Bind the canvas FBO, disable scissor, and
// draw a quad over the bbox sampling the layer (unit 0) and coverage mask
// (unit 1); the premultiplied result is blended SRC_OVER. All handles are
// canvas-owned; the transient mask texture is deleted after the draw.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.use_program( Some( self.clip_composite_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_clip_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_clip_canvas ), self.width as f32, self.height as f32 );
self.gl.uniform_4_f32( Some( &self.u_clip_bbox ), bbox.x, bbox.y, bbox.width, bbox.height );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( layer_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_layer ), 0 );
self.gl.active_texture( glow::TEXTURE1 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( mask_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_mask ), 1 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.delete_texture( mask_tex );
}
}
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no

View File

@@ -83,22 +83,32 @@ impl GlesCanvas
/// disable scissor when the canvas has no clip).
pub( super ) fn activate_target( &self )
{
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`)
// While a path clip is active, draws are redirected to the offscreen clip
// layer (unclipped); the anti-aliased mask is applied when the layer is
// composited back on clip end. Otherwise draws go to `fbo` with the
// active scissor.
// SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent.
let target = match ( self.clip_layer_active, self.clip_layer )
{
( true, Some( ( fbo, _ ) ) ) => fbo,
_ => self.fbo,
};
let to_layer = self.clip_layer_active && self.clip_layer.is_some();
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) =>
Some( r ) if !to_layer =>
{
let ( x, y, w, h ) = self.scissor_pixels( r );
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
None =>
_ =>
{
self.gl.disable( glow::SCISSOR_TEST );
}

View File

@@ -9,12 +9,14 @@
//! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current.
//!
//! Clipping is implemented with `glScissor`. When
//! Rect clipping is implemented with `glScissor`. When
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the
//! bounding-box union is used as the scissor — coarse, but the
//! partial-redraw path normally clusters the dirty rects of 13
//! widgets so the union is barely larger than the sum. Disjoint
//! regions would want a stencil-buffer path; not implemented today.
//! widgets so the union is barely larger than the sum. Arbitrary
//! path clipping ([`GlesCanvas::set_clip_path`]) captures the clipped
//! draws into an offscreen layer and composites it back through an
//! anti-aliased coverage mask, for a smooth clipped edge.
//!
//! # Submodule layout
//!
@@ -354,6 +356,23 @@ pub struct GlesCanvas
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>,
/// Anti-aliased path clip (`set_clip_path`). While `clip_layer_active`,
/// `activate_target` redirects draws to `clip_layer` (a full-canvas
/// offscreen FBO); ending the clip composites that layer back onto `fbo`
/// multiplied by `clip_mask_tex` (an anti-aliased coverage texture covering
/// `clip_bbox`). The layer FBO is allocated lazily on the first path clip.
clip_layer: Option<( glow::Framebuffer, glow::Texture )>,
clip_layer_active: bool,
clip_mask_tex: Option<glow::Texture>,
clip_bbox: Rect,
/// Composite program (layer × coverage mask). Shared with sub-canvases.
clip_composite_program: glow::Program,
u_clip_mvp: glow::UniformLocation,
u_clip_layer: glow::UniformLocation,
u_clip_mask: glow::UniformLocation,
u_clip_canvas: glow::UniformLocation,
u_clip_bbox: glow::UniformLocation,
/// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer.
@@ -398,6 +417,15 @@ impl Drop for GlesCanvas
{
self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex );
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 );
}
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );

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();

View File

@@ -147,6 +147,30 @@ void main()
}
"##;
// Clip-layer composite shader for `set_clip_path`. Path-clipped content is
// drawn to an offscreen layer; this composites that layer onto the canvas
// multiplied by an anti-aliased coverage mask, giving a smooth clipped edge.
// `u_layer` is the full-canvas layer texture (premultiplied), `u_mask` the
// path coverage rasterised over `u_bbox` (x, y, w, h in canvas pixels). The
// quad is drawn over the bbox; `v_uv` runs 0..1 across it. Both FBO-backed
// textures are sampled y-flipped (GL's lower-left origin).
pub( super ) const CLIP_COMPOSITE_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_layer;
uniform sampler2D u_mask;
uniform vec2 u_canvas;
uniform vec4 u_bbox;
void main()
{
vec2 sp = u_bbox.xy + v_uv * u_bbox.zw;
vec2 luv = vec2( sp.x / u_canvas.x, 1.0 - sp.y / u_canvas.y );
vec4 col = texture2D( u_layer, luv );
float cov = texture2D( u_mask, vec2( v_uv.x, 1.0 - v_uv.y ) ).a;
gl_FragColor = col * cov;
}
"##;
// Fragment shader for single-channel glyph textures with color tint.
//
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`