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`.
353 lines
14 KiB
Rust
353 lines
14 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit,
|
|
//! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for
|
|
//! snapshot-based effects (Overlay blend inset shadow), and the
|
|
//! externally-exposed borrowed-texture view.
|
|
//!
|
|
//! See `primitives.rs` module doc for the canvas-wide `unsafe` contract
|
|
//! shared by every block in this file. Per-block notes below only call
|
|
//! out what is specific to the operation.
|
|
|
|
use glow::HasContext;
|
|
|
|
use crate::types::Rect;
|
|
|
|
use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect };
|
|
use super::raii::{ FboBinding, ProgramBinding };
|
|
use super::{ BorrowedGlesTexture, GlesCanvas };
|
|
|
|
impl GlesCanvas
|
|
{
|
|
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
|
|
{
|
|
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
|
|
}
|
|
|
|
/// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering
|
|
/// the last `fade_bottom_px` source rows so the bottom edge dissolves into
|
|
/// transparency instead of cutting off cleanly. Used by viewports whose
|
|
/// bottom edge is the leading edge of a slide-down animation, where a hard
|
|
/// cut against the underlying layer reads as a knife. With `fade_bottom_px
|
|
/// == 0.0` this matches [`Self::blit`] exactly.
|
|
pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
|
|
{
|
|
self.activate_target();
|
|
let dest = Rect
|
|
{
|
|
x: dest_x as f32,
|
|
y: dest_y as f32,
|
|
width: src.width as f32,
|
|
height: src.height as f32,
|
|
};
|
|
let mvp = ortho_rect( self.width, self.height, dest );
|
|
let alpha = self.global_alpha;
|
|
let height_px = src.height as f32;
|
|
let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px );
|
|
// SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument)
|
|
// and outlives the call. `src` and `self` share the same `Arc<glow::Context>`
|
|
// — verified by construction (sub-canvases are built via `sub_canvas`,
|
|
// which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture
|
|
// from `self`'s FBO is well-defined.
|
|
unsafe
|
|
{
|
|
// Both the main canvas and the sub-canvas FBO hold premultiplied
|
|
// colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` —
|
|
// the premul over-composite formula this blit needs. No temporary
|
|
// blend switch necessary.
|
|
self.gl.use_program( Some( self.sub_blit_program ) );
|
|
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp );
|
|
self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha );
|
|
self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), fade_clamp );
|
|
self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px );
|
|
self.gl.active_texture( glow::TEXTURE0 );
|
|
self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_tex ) );
|
|
self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 );
|
|
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 );
|
|
}
|
|
}
|
|
|
|
/// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and
|
|
/// idempotent, called at the top of every draw / clear / clip method so
|
|
/// that switching between canvases (e.g. main → sub-canvas → main) leaves
|
|
/// each one's state correct without explicit "make active" calls.
|
|
///
|
|
/// Why this exists: GL state (FBO binding, scissor box, viewport) is
|
|
/// global — there is no implicit per-canvas state. When rendering
|
|
/// switches between targets, every method on the active canvas must
|
|
/// reassert its own FBO + viewport, plus re-enable its own scissor (or
|
|
/// disable scissor when the canvas has no clip).
|
|
pub( super ) fn activate_target( &self )
|
|
{
|
|
// 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( target ) );
|
|
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
|
match self.clip_scissor
|
|
{
|
|
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 );
|
|
}
|
|
_ =>
|
|
{
|
|
self.gl.disable( glow::SCISSOR_TEST );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return a borrowed descriptor for the FBO color texture
|
|
/// containing the latest rendered pixels.
|
|
///
|
|
/// `y_inverted` is `true`: the FBO uses GL's native lower-left
|
|
/// origin, so row 0 in texture memory is the bottom of the
|
|
/// rendered image. Consumers that follow the same convention flip
|
|
/// during sampling when this flag is set, producing a correctly-
|
|
/// oriented result. The CPU-side counterpart
|
|
/// [`Self::read_rgba_pixels`] does the same flip inline so the
|
|
/// byte buffer is top-down.
|
|
pub fn borrowed_texture( &self ) -> BorrowedGlesTexture
|
|
{
|
|
BorrowedGlesTexture
|
|
{
|
|
texture_id: native_texture_id( self.fbo_tex ),
|
|
framebuffer_id: native_framebuffer_id( self.fbo ),
|
|
texture: self.fbo_tex,
|
|
framebuffer: self.fbo,
|
|
width: self.width,
|
|
height: self.height,
|
|
premultiplied: true,
|
|
y_inverted: true,
|
|
}
|
|
}
|
|
|
|
/// Read the FBO color attachment into `out` as tightly packed RGBA8,
|
|
/// top-left row first.
|
|
///
|
|
/// This is a compatibility escape hatch. It forces a GPU→CPU sync and
|
|
/// should not be used in steady-state hot paths.
|
|
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
|
|
{
|
|
let needed = self.width as usize * self.height as usize * 4;
|
|
if out.len() < needed
|
|
{
|
|
return Err( format!(
|
|
"read_rgba_pixels needs {needed} bytes, got {}",
|
|
out.len(),
|
|
) );
|
|
}
|
|
|
|
let mut raw = vec![ 0_u8; needed ];
|
|
// SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT
|
|
// is set to 1, so `read_pixels` writes exactly `needed` bytes into a
|
|
// buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only
|
|
// guaranteed-readable format on every GLES2/3 driver.
|
|
unsafe
|
|
{
|
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
|
|
self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 );
|
|
self.gl.read_pixels(
|
|
0,
|
|
0,
|
|
self.width as i32,
|
|
self.height as i32,
|
|
glow::RGBA,
|
|
glow::UNSIGNED_BYTE,
|
|
glow::PixelPackData::Slice( Some( &mut raw ) ),
|
|
);
|
|
}
|
|
let stride = self.width as usize * 4;
|
|
for y in 0..self.height as usize
|
|
{
|
|
let src = ( self.height as usize - 1 - y ) * stride;
|
|
let dst = y * stride;
|
|
out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] );
|
|
}
|
|
Ok( () )
|
|
}
|
|
|
|
/// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of
|
|
/// `fbo` for framebuffer-fetch-style effects (Overlay blend,
|
|
/// backdrop-blur source). The pair is sized to match the canvas so
|
|
/// `gl_FragCoord.xy / canvas_size` samples the right texel.
|
|
///
|
|
/// Returns the texture handle of `aux_a`. The FBO is only needed for
|
|
/// the backdrop blur passes that write into `aux_b`; Overlay only
|
|
/// reads from `aux_a`, so this helper keeps the blur-only `aux_b`
|
|
/// allocation deferred until it is actually needed.
|
|
fn ensure_aux_a( &mut self ) -> glow::Texture
|
|
{
|
|
if self.aux_a.is_none()
|
|
{
|
|
// SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current
|
|
// GL context) holds. Same FBO build / completeness assertion as
|
|
// `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live
|
|
// binding — the next draw goes through `activate_target` which
|
|
// rebinds `self.fbo`.
|
|
unsafe
|
|
{
|
|
let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" );
|
|
let tex = 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,
|
|
);
|
|
let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER );
|
|
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" );
|
|
self.aux_a = Some( ( fbo, tex ) );
|
|
}
|
|
}
|
|
self.aux_a.expect( "just allocated" ).1
|
|
}
|
|
|
|
/// Snapshot variant that additionally clamps `region` to the active
|
|
/// scissor. Safe only for shaders that sample the snapshot at
|
|
/// exactly one point per fragment.
|
|
pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect )
|
|
{
|
|
self.snapshot_fbo_region_impl( region, true )
|
|
}
|
|
|
|
fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool )
|
|
{
|
|
let aux_tex = self.ensure_aux_a();
|
|
// Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would
|
|
// generate `INVALID_VALUE` (or undefined behaviour on some
|
|
// drivers) if the source rect extends outside the framebuffer.
|
|
let cw = self.width as f32;
|
|
let ch = self.height as f32;
|
|
let mut x0 = region.x.max( 0.0 );
|
|
let mut y0_top = region.y.max( 0.0 );
|
|
let mut x1 = ( region.x + region.width ).min( cw );
|
|
let mut y1_top = ( region.y + region.height ).min( ch );
|
|
if intersect_scissor
|
|
{
|
|
if let Some( clip ) = self.clip_scissor
|
|
{
|
|
x0 = x0.max( clip.x );
|
|
y0_top = y0_top.max( clip.y );
|
|
x1 = x1.min( clip.x + clip.width );
|
|
y1_top = y1_top.min( clip.y + clip.height );
|
|
}
|
|
}
|
|
let w = ( x1 - x0 ).floor() as i32;
|
|
let h = ( y1_top - y0_top ).floor() as i32;
|
|
if w <= 0 || h <= 0 { return; }
|
|
// GL framebuffer origin is bottom-left; our rect is top-left.
|
|
let src_x = x0.floor() as i32;
|
|
let src_y = self.height as i32 - y0_top.floor() as i32 - h;
|
|
// SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)`
|
|
// lies fully inside `self.fbo`'s colour attachment, so
|
|
// `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was
|
|
// allocated through `ensure_aux_a` to canvas dimensions, so the
|
|
// destination region is also in-bounds.
|
|
unsafe
|
|
{
|
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
|
|
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
|
|
self.gl.copy_tex_sub_image_2d
|
|
(
|
|
glow::TEXTURE_2D, 0,
|
|
src_x, src_y,
|
|
src_x, src_y,
|
|
w, h,
|
|
);
|
|
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
|
}
|
|
}
|
|
|
|
/// Drop both auxiliary FBO+texture pairs if allocated. Called from
|
|
/// [`Self::resize`] so the next effect re-allocates at the new size.
|
|
pub( super ) fn invalidate_aux( &mut self )
|
|
{
|
|
// SAFETY: each (fbo, tex) pair was created through `self.gl` in
|
|
// `ensure_aux_a` / `ensure_aux_b`, so deleting through the same
|
|
// context is well-defined. `take()` ensures we never double-delete.
|
|
unsafe
|
|
{
|
|
if let Some( ( fbo, tex ) ) = self.aux_a.take()
|
|
{
|
|
self.gl.delete_framebuffer( fbo );
|
|
self.gl.delete_texture( tex );
|
|
}
|
|
if let Some( ( fbo, tex ) ) = self.aux_b.take()
|
|
{
|
|
self.gl.delete_framebuffer( fbo );
|
|
self.gl.delete_texture( tex );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Blit the FBO color attachment onto the default framebuffer (the EGL
|
|
/// window). Caller is responsible for the `eglSwapBuffers` that
|
|
/// publishes the result. After present, the FBO is rebound so the next
|
|
/// frame's draws keep accumulating into the shadow canvas.
|
|
///
|
|
/// The blit always covers the full surface — partial-redraw still saves
|
|
/// work upstream (only changed widget pixels are repainted into the
|
|
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
|
|
/// op.
|
|
pub fn present( &mut self )
|
|
{
|
|
// Scoped guards: bind the default framebuffer (id 0) and the
|
|
// blit program for the duration of this fn. On Drop they restore
|
|
// `self.fbo` and "no program", so any future early-return / panic
|
|
// in the blit body cannot leave the canvas pointing at the wrong
|
|
// FBO or program. The viewport, blend and scissor are restored
|
|
// inline below — they are non-resource state that does not need
|
|
// the guard treatment because `activate_target` rewrites them on
|
|
// every subsequent draw.
|
|
//
|
|
// SAFETY: GL context is current (canvas invariant). `self.fbo` is
|
|
// the canvas-owned FBO from `setup.rs::new`. `self.blit_program`
|
|
// was linked in `setup.rs::new`. Restoring "no program active"
|
|
// (`None`) is always sound.
|
|
let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) };
|
|
let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) };
|
|
// SAFETY: see above. The block sets up the blit pipeline state,
|
|
// draws a fullscreen quad sampling `fbo_tex`, then restores blend
|
|
// and viewport so the next frame's draws inherit the canvas-wide
|
|
// defaults. FBO + program are restored by the guards on scope exit.
|
|
unsafe
|
|
{
|
|
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
|
self.gl.disable( glow::SCISSOR_TEST );
|
|
self.gl.disable( glow::BLEND );
|
|
self.gl.active_texture( glow::TEXTURE0 );
|
|
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) );
|
|
self.gl.uniform_1_i32( Some( &self.u_blit_sampler ), 0 );
|
|
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.enable( glow::BLEND );
|
|
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
|
}
|
|
// Scissor was disabled above; reflect that in our cached state.
|
|
self.clip_scissor = None;
|
|
// Guards drop here: FBO → self.fbo, program → None.
|
|
}
|
|
}
|