ltk/gles_render/clip.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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 1–3 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;
use crate::types::{ Color, Rect };
use super::GlesCanvas;
impl GlesCanvas
{
/// Clip subsequent draws to `rects` via `glScissor`. The scissor is the
/// bounding-box union of all rects clamped to the canvas — a coarse clip,
/// unlike the software backend's exact per-rect mask, so pixels between
/// disjoint rects are not culled. An empty slice clears the clip; an empty
/// union installs a zero-area scissor so subsequent draws become no-ops.
/// Replaces any active path clip, flushing its layer first.
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();
if rects.is_empty()
{
self.clear_clip();
return;
}
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = -f32::INFINITY;
let mut y1 = -f32::INFINITY;
for r in rects
{
x0 = x0.min( r.x );
y0 = y0.min( r.y );
x1 = x1.max( r.x + r.width );
y1 = y1.max( r.y + r.height );
}
x0 = x0.max( 0.0 );
y0 = y0.max( 0.0 );
x1 = x1.min( self.width as f32 );
y1 = y1.min( self.height as f32 );
if x1 <= x0 || y1 <= y0
{
// Empty union — install a zero-area scissor so subsequent draws
// are no-ops without disabling the test.
self.set_scissor( Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } );
return;
}
self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } );
}
/// Drop the active clip — disable the scissor test and flush any open path
/// clip layer (compositing it back). Subsequent draws cover the whole canvas.
pub fn clear_clip( &mut self )
{
// SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is
// pure global-state mutation; the cached `clip_scissor` is updated
// 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
/// scissor is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
self.clip_scissor.map_or_else( Vec::new, |r| vec![ r ] )
}
/// Apply `rect` as the current scissor (top-left coords, GL bottom-left).
fn set_scissor( &mut self, rect: Rect )
{
let ( x, y, w, h ) = self.scissor_pixels( rect );
// SAFETY: `scissor_pixels` clamps to non-negative integers; GL accepts
// arbitrary scissor rects (regions outside the framebuffer simply
// cull all fragments). State change is mirrored in `clip_scissor`.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
self.clip_scissor = Some( rect );
}
/// Convert a top-left rect to the bottom-left integer pixel scissor that
/// GL expects.
pub( super ) fn scissor_pixels( &self, rect: Rect ) -> ( i32, i32, i32, i32 )
{
let x = rect.x.floor() as i32;
let w = rect.width.ceil() as i32;
let h = rect.height.ceil() as i32;
// GL origin is bottom-left, our coords are top-left.
let y_top = rect.y.floor() as i32;
let y_bottom = self.height as i32 - y_top - h;
( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) )
}
/// Clear to a solid color. Honours the active scissor — if a clip is set,
/// only the clipped region is filled.
pub fn fill( &mut self, color: Color )
{
self.activate_target();
// SAFETY: `clear` writes the configured `clear_color` into every
// fragment that survives the scissor test (which `activate_target`
// has already configured to match `clip_scissor`).
unsafe
{
self.gl.clear_color( color.r, color.g, color.b, color.a );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Clear to fully transparent. Honours the active scissor.
pub fn clear( &mut self )
{
self.activate_target();
// SAFETY: same as `fill`, with a transparent clear colour.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Zero the pixels inside each rect (alpha+RGB → 0).
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
self.activate_target();
let saved = self.clip_scissor;
// SAFETY: enable scissor + set transparent clear colour once for
// the whole loop; per-rect we rewrite `scissor` and clear. After
// the loop we restore `saved` via `set_scissor` / `clear_clip`
// so the cached `clip_scissor` matches the actual GL state again.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
}
for r in rects
{
let ( x, y, w, h ) = self.scissor_pixels( *r );
if w <= 0 || h <= 0 { continue; }
// SAFETY: per-rect scissor + clear; same invariants as above.
unsafe
{
self.gl.scissor( x, y, w, h );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
// Restore the previous scissor (or disable if none was active).
match saved
{
Some( r ) => self.set_scissor( r ),
None => self.clear_clip(),
}
}
}